Input

Single-line text input field.

Installation

$npx @309-thingspire/ui@latest add input

Usage

import { Input } from "@/components/input/input"
<Input />

Examples

Live preview rendered from Input.preview.tsx. Switch to the Code tab to view the underlying component source.

Loading preview…
import React, { useState } from 'react';

import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import { IconArrowDownSLine, IconEarthLine, IconInformationLine } from '../icons';
import { FlagBaseSvg, FlagGroupSvg, FlagOverlaySvg } from './Input.assets';

import type { InputProps, InputSize, InputTarget, InputVisualState } from './Input.types';

const palette = colors.primitive.palette;
const textBase = colors.semantic.theme.text.base;
const textStatus = colors.semantic.theme.text.status;

type TypographyToken = {
  fontFamily: string;
  fontSize: number;
  fontWeight: number;
  lineHeight: number;
  letterSpacing: number;
};

type SizeStyle = {
  fieldPaddingX: number;
  fieldPaddingY: number;
  fieldRadius: number;
  inputContentGap: number;
  externalGap: number;
  buttonPaddingX: number;
  buttonPaddingY: number;
  buttonGap: number;
};

const SIZE_STYLES: Record<InputSize, SizeStyle> = {
  md: {
    fieldPaddingX: spacing.scale['12'],
    fieldPaddingY: spacing.scale['10'],
    fieldRadius: radius.scale.xl,
    inputContentGap: spacing.scale['4'],
    externalGap: spacing.scale['4'],
    buttonPaddingX: spacing.scale['10'],
    buttonPaddingY: spacing.scale['10'],
    buttonGap: spacing.scale['2'],
  },
  xs: {
    fieldPaddingX: spacing.scale['8'],
    fieldPaddingY: spacing.scale['6'],
    fieldRadius: radius.scale.lg,
    inputContentGap: spacing.scale['2'],
    externalGap: spacing.scale['2'],
    buttonPaddingX: spacing.scale['8'],
    buttonPaddingY: spacing.scale['6'],
    buttonGap: spacing.scale['0'],
  },
};

function toTypographyStyle(token: TypographyToken) {
  return {
    fontFamily: token.fontFamily,
    fontSize: token.fontSize,
    fontWeight: token.fontWeight,
    lineHeight: `${token.lineHeight}px`,
    letterSpacing: `${token.letterSpacing}px`,
  };
}

function resolveVisualState(
  forcedState: InputVisualState | undefined,
  disabled: boolean,
  hovered: boolean,
  focused: boolean,
  value: string,
): InputVisualState {
  if (disabled || forcedState === 'disabled') {
    return 'disabled';
  }

  if (forcedState) {
    return forcedState;
  }

  if (focused) {
    return 'focus';
  }

  if (hovered) {
    return 'hover';
  }

  if (value.trim().length > 0) {
    return 'filled';
  }

  return 'default';
}

function getFieldBorderColor(target: InputTarget, state: InputVisualState): string {
  if (target === 'destructive') {
    if (state === 'focus') {
      return palette.red['6'];
    }

    if (state === 'hover') {
      return palette.red['5'];
    }

    if (state === 'disabled') {
      return palette.gray['2'];
    }

    return palette.red['4'];
  }

  if (state === 'focus') {
    return palette.purple['6'];
  }

  if (state === 'hover') {
    return palette.gray['4'];
  }

  if (state === 'disabled') {
    return palette.gray['2'];
  }

  return palette.gray['3'];
}

function getFieldFocusShadow(target: InputTarget, state: InputVisualState): string {
  if (state !== 'focus') {
    return 'none';
  }

  return target === 'destructive' ? shadows.focusRing.lightDestructive.css : shadows.focusRing.light.css;
}

function FlagIcon({ disabled }: { disabled: boolean }) {
  return (
    <span
      aria-hidden="true"
      style={{
        position: 'relative',
        width: spacing.scale['20'],
        height: spacing.scale['20'],
        overflow: 'hidden',
        flexShrink: 0,
        opacity: disabled ? 0.5 : 1,
        mixBlendMode: disabled ? 'luminosity' : 'normal',
      }}
    >
      <span style={{ position: 'absolute', inset: 0, display: 'block' }}>
        <FlagBaseSvg />
      </span>
      <span style={{ position: 'absolute', inset: spacing.scale['0'] }}>
        <FlagGroupSvg />
      </span>
      <span style={{ position: 'absolute', inset: 0, display: 'block' }}>
        <FlagOverlaySvg />
      </span>
    </span>
  );
}

function IconSlot({
  size,
  disabled,
  children,
}: {
  size: number;
  disabled: boolean;
  children: React.ReactNode;
}) {
  return (
    <span
      aria-hidden="true"
      style={{
        width: size,
        height: size,
        flexShrink: 0,
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        opacity: disabled ? 0.5 : 1,
        fontSize: size,
        lineHeight: 1,
      }}
    >
      {children}
    </span>
  );
}

export function Input({
  id,
  className,
  style,
  type = 'default',
  size = 'md',
  target = 'default',
  state,
  disabled = false,
  label = 'Label',
  optionalLabel = '(optional)',
  helperText = 'Helper text',
  placeholder = 'Placeholder',
  value,
  defaultValue = '',
  externalLabel = 'Company',
  buttonLabel = 'Button',
  leadDropdownLabel = 'UK',
  tailDropdownLabel = 'EUR',
  badgeLabel = '⌘K',
  showLabel = true,
  showHelper = true,
  showFlag = true,
  showLeadDropdown = true,
  showLeadIcon = true,
  showBadge = true,
  showTailIcon = true,
  showTailDropdown = true,
  leadIcon,
  tailIcon,
  inputAriaLabel = 'Input field',
  onValueChange,
  onButtonClick,
  onMouseEnter,
  onMouseLeave,
  ...rest
}: InputProps) {
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);

  const sizeStyle = SIZE_STYLES[size];

  const resolvedValue = value ?? uncontrolledValue;

  const resolvedState = resolveVisualState(state, disabled, hovered, focused, resolvedValue);
  const componentDisabled = resolvedState === 'disabled';

  const hasFilledValue = resolvedState === 'filled' || resolvedValue.trim().length > 0;

  const fieldBorderColor = getFieldBorderColor(target, resolvedState);
  const fieldFocusShadow = getFieldFocusShadow(target, resolvedState);
  const helperColor =
    componentDisabled
      ? textBase.staticDarkQuaternary
      : target === 'destructive'
      ? textStatus.destructive
      : textBase.staticDarkTertiary;

  const bodyTextColor = componentDisabled ? textBase.staticDarkQuaternary : textBase.staticDark;
  const tertiaryTextColor = componentDisabled ? textBase.staticDarkQuaternary : textBase.staticDarkTertiary;
  const secondaryTextColor = componentDisabled ? textBase.staticDarkQuaternary : textBase.staticDarkSecondary;

  // Per Figma carbonscope: form fields render flat, no elevation shadow.
  const containerShadow = 'none';
  const sideBorderColor = componentDisabled ? palette.gray['2'] : palette.gray['3'];

  const handleMouseEnter: React.MouseEventHandler<HTMLDivElement> = (event) => {
    setHovered(true);
    onMouseEnter?.(event);
  };

  const handleMouseLeave: React.MouseEventHandler<HTMLDivElement> = (event) => {
    setHovered(false);
    onMouseLeave?.(event);
  };

  const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
    if (value === undefined) {
      setUncontrolledValue(event.target.value);
    }

    onValueChange?.(event.target.value);
  };

  const fieldTypography = toTypographyStyle(typography.scale.captionL.regular);
  const mediumTypography = toTypographyStyle(typography.scale.captionL.medium);

  const leftFieldRadius = {
    borderTopLeftRadius: sizeStyle.fieldRadius,
    borderBottomLeftRadius: sizeStyle.fieldRadius,
    borderTopRightRadius: radius.scale['0'],
    borderBottomRightRadius: radius.scale['0'],
  };

  const rightFieldRadius = {
    borderTopLeftRadius: radius.scale['0'],
    borderBottomLeftRadius: radius.scale['0'],
    borderTopRightRadius: sizeStyle.fieldRadius,
    borderBottomRightRadius: sizeStyle.fieldRadius,
  };

  const fullFieldRadius = {
    borderRadius: sizeStyle.fieldRadius,
  };

  return (
    <div
      id={id}
      className={className}
      style={{
        display: 'inline-flex',
        flexDirection: 'column',
        alignItems: 'flex-start',
        gap: spacing.scale['8'],
        minWidth: spacing.scale['144'],
        width: spacing.scale['400'],
        ...style,
      }}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      {...rest}
    >
      {showLabel ? (
        <div
          style={{
            width: '100%',
            display: 'flex',
            alignItems: 'flex-start',
            gap: spacing.scale['0'],
            padding: spacing.scale['0'],
          }}
        >
          <div
            style={{
              display: 'flex',
              alignItems: 'flex-start',
              gap: spacing.scale['4'],
              paddingInline: spacing.scale['0'],
              paddingBlock: spacing.scale['2'],
              whiteSpace: 'nowrap',
            }}
          >
            <span
              style={{
                color: textBase.staticDark,
                ...mediumTypography,
              }}
            >
              {label}
            </span>
            <span
              style={{
                color: textBase.staticDarkTertiary,
                ...mediumTypography,
              }}
            >
              {optionalLabel}
            </span>
          </div>
        </div>
      ) : null}

      <div
        style={{
          width: '100%',
          display: 'flex',
          alignItems: 'stretch',
          gap: spacing.scale['0'],
          boxShadow: containerShadow,
        }}
      >
        {type === 'external' ? (
          <div
            style={{
              display: 'inline-flex',
              alignItems: 'center',
              gap: sizeStyle.externalGap,
              paddingInline: sizeStyle.fieldPaddingX,
              paddingBlock: sizeStyle.fieldPaddingY,
              borderStyle: 'solid',
              borderTopWidth: border.width['1'],
              borderBottomWidth: border.width['1'],
              borderLeftWidth: border.width['1'],
              borderRightWidth: border.width['0'],
              borderColor: sideBorderColor,
              backgroundColor: palette.gray['1'],
              ...leftFieldRadius,
            }}
          >
            {leadIcon ? (
              <>{leadIcon ?? <IconSlot size={spacing.scale['20']} disabled={componentDisabled}><IconEarthLine /></IconSlot>}</>
            ) : null}
            <div
              style={{
                display: 'inline-flex',
                alignItems: 'center',
                gap: spacing.scale['0'],
                paddingInline: spacing.scale['4'],
                paddingBlock: spacing.scale['0'],
              }}
            >
              <span
                style={{
                  color: bodyTextColor,
                  ...fieldTypography,
                  whiteSpace: 'nowrap',
                }}
              >
                {externalLabel}
              </span>
            </div>
          </div>
        ) : null}

        <div
          style={{
            display: 'flex',
            flex: '1 0 0',
            alignItems: 'center',
            minWidth: spacing.scale['0'],
            paddingInline: sizeStyle.fieldPaddingX,
            paddingBlock: sizeStyle.fieldPaddingY,
            borderStyle: 'solid',
            borderWidth: border.width['1'],
            borderColor: fieldBorderColor,
            backgroundColor: palette.base.white,
            boxShadow: fieldFocusShadow,
            overflow: 'hidden',
            ...(type === 'default' ? fullFieldRadius : type === 'external' ? rightFieldRadius : leftFieldRadius),
          }}
        >
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              gap: spacing.scale['4'],
              flex: '1 0 0',
              minWidth: spacing.scale['0'],
            }}
          >
            {type !== 'external' && showFlag ? <FlagIcon disabled={componentDisabled} /> : null}

            {type !== 'external' && showLeadDropdown ? (
              <div
                style={{
                  display: 'inline-flex',
                  alignItems: 'center',
                  gap: spacing.scale['2'],
                  paddingLeft: spacing.scale['4'],
                  paddingRight: spacing.scale['0'],
                  paddingBlock: spacing.scale['0'],
                }}
              >
                <span
                  style={{
                    color: bodyTextColor,
                    ...mediumTypography,
                    whiteSpace: 'nowrap',
                  }}
                >
                  {leadDropdownLabel}
                </span>
                <IconSlot size={spacing.scale['16']} disabled={componentDisabled}><IconArrowDownSLine /></IconSlot>
              </div>
            ) : null}

            <div
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: sizeStyle.inputContentGap,
                flex: '1 0 0',
                minWidth: spacing.scale['0'],
              }}
            >
              {type !== 'external' && showLeadIcon ? (
                <>{leadIcon ?? <IconSlot size={spacing.scale['20']} disabled={componentDisabled}><IconEarthLine /></IconSlot>}</>
              ) : null}

              <div
                style={{
                  display: 'flex',
                  alignItems: 'center',
                  flex: '1 0 0',
                  minWidth: spacing.scale['0'],
                  paddingInline: spacing.scale['4'],
                  paddingBlock: spacing.scale['0'],
                }}
              >
                <input
                  aria-label={inputAriaLabel}
                  value={hasFilledValue ? (resolvedValue || 'Filled text') : resolvedValue}
                  placeholder={placeholder}
                  disabled={componentDisabled}
                  onChange={handleInputChange}
                  onFocus={() => setFocused(true)}
                  onBlur={() => setFocused(false)}
                  style={{
                    flex: '1 0 0',
                    minWidth: spacing.scale['0'],
                    border: 'none',
                    outline: 'none',
                    backgroundColor: 'transparent',
                    color: hasFilledValue ? bodyTextColor : tertiaryTextColor,
                    padding: spacing.scale['0'],
                    margin: spacing.scale['0'],
                    ...fieldTypography,
                  }}
                />
              </div>
            </div>

            {showBadge ? (
              <div
                style={{
                  display: 'inline-flex',
                  alignItems: 'flex-start',
                  paddingInline: spacing.scale['4'],
                  paddingBlock: spacing.scale['0'],
                }}
              >
                <div
                  style={{
                    display: 'inline-flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                    backgroundColor: palette.gray['2'],
                    borderRadius: radius.scale.sm,
                    paddingInline: spacing.scale['2'],
                    paddingBlock: spacing.scale['0'],
                  }}
                >
                  <div
                    style={{
                      display: 'inline-flex',
                      alignItems: 'center',
                      justifyContent: 'center',
                      paddingInline: spacing.scale['4'],
                      paddingBlock: spacing.scale['0'],
                    }}
                  >
                    <span
                      style={{
                        color: secondaryTextColor,
                        ...mediumTypography,
                        whiteSpace: 'nowrap',
                      }}
                    >
                      {badgeLabel}
                    </span>
                  </div>
                </div>
              </div>
            ) : null}

            {showTailIcon ? <>{tailIcon ?? <IconSlot size={spacing.scale['20']} disabled={componentDisabled}><IconInformationLine /></IconSlot>}</> : null}

            {showTailDropdown ? (
              <div
                style={{
                  display: 'inline-flex',
                  alignItems: 'center',
                  gap: spacing.scale['2'],
                  paddingLeft: spacing.scale['4'],
                  paddingRight: spacing.scale['0'],
                  paddingBlock: spacing.scale['0'],
                }}
              >
                <span
                  style={{
                    color: bodyTextColor,
                    ...mediumTypography,
                    whiteSpace: 'nowrap',
                  }}
                >
                  {tailDropdownLabel}
                </span>
                <IconSlot size={spacing.scale['16']} disabled={componentDisabled}><IconArrowDownSLine /></IconSlot>
              </div>
            ) : null}
          </div>
        </div>

        {type === 'button' ? (
          <button
            type="button"
            disabled={componentDisabled}
            onClick={onButtonClick}
            style={{
              display: 'inline-flex',
              alignItems: 'center',
              justifyContent: 'center',
              gap: sizeStyle.buttonGap,
              paddingInline: sizeStyle.buttonPaddingX,
              paddingBlock: sizeStyle.buttonPaddingY,
              borderStyle: 'solid',
              borderTopWidth: border.width['1'],
              borderBottomWidth: border.width['1'],
              borderRightWidth: border.width['1'],
              borderLeftWidth: border.width['0'],
              borderColor: sideBorderColor,
              backgroundColor: palette.base.transparent,
              cursor: componentDisabled ? 'not-allowed' : 'pointer',
              ...rightFieldRadius,
            }}
          >
            <span
              style={{
                color: bodyTextColor,
                ...mediumTypography,
                whiteSpace: 'nowrap',
              }}
            >
              {buttonLabel}
            </span>
          </button>
        ) : null}
      </div>

      {showHelper ? (
        <div
          style={{
            width: '100%',
            display: 'flex',
            alignItems: 'center',
            gap: spacing.scale['4'],
            paddingInline: spacing.scale['0'],
            paddingBlock: spacing.scale['2'],
          }}
        >
          <IconSlot size={spacing.scale['16']} disabled={componentDisabled}><IconInformationLine /></IconSlot>
          <span
            style={{
              color: helperColor,
              ...fieldTypography,
              whiteSpace: 'nowrap',
            }}
          >
            {helperText}
          </span>
        </div>
      ) : null}
    </div>
  );
}

API Reference

Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)