{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "autocomplete",
  "type": "registry:ui",
  "title": "Autocomplete",
  "description": "초점이 들어오면 목록을 펼치고, 입력한 글자를 포함하는 항목만 남겨 보여주는 검색형 선택 엘리먼트입니다.",
  "registryDependencies": [
    "@atom/tokens",
    "@atom/icons",
    "@atom/lib-bem",
    "@atom/lib-cn",
    "@atom/lib-field-context",
    "@atom/lib-slot",
    "@atom/lib-use-anchored-position",
    "@atom/lib-use-controllable-state",
    "@atom/lib-use-dismiss"
  ],
  "files": [
    {
      "path": "poc/design-system/implement/src/components/autocomplete.tsx",
      "content": "\"use client\";\n\nimport {\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { mod } from \"../lib/bem\";\nimport { cn } from \"../lib/cn\";\nimport { useFieldControl } from \"../lib/field-context\";\nimport { Slot } from \"../lib/slot\";\nimport { useAnchoredPosition } from \"../lib/use-anchored-position\";\nimport { useControllableState } from \"../lib/use-controllable-state\";\nimport { useDismiss } from \"../lib/use-dismiss\";\nimport { CheckIcon, SearchIcon } from \"./icons\";\n\n/* -------------------------------------------------------------------------- */\n/*  Autocomplete (components/autocomplete.md, styles/atom/elements/autocomplete.css) */\n/*    <Autocomplete                                                           */\n/*      options={[{ value: \"settings\", label: \"설정 관리\" }, ...]}            */\n/*      placeholder=\"항목을 검색해주세요\"                                     */\n/*      onValueChange={setValue}                                              */\n/*    />                                                                      */\n/*  WAI-ARIA combobox (editable, aria-autocomplete=\"list\"). 초점은 Input 에    */\n/*  머물고 활성 Option 은 aria-activedescendant + data-active 로 가리킵니다.  */\n/* -------------------------------------------------------------------------- */\n\nexport type AutocompleteSize = \"small\" | \"medium\" | \"large\";\nexport type AutocompleteVariant = \"outline\" | \"weak\";\nexport type AutocompleteOption = {\n  value: string;\n  label: string;\n  description?: ReactNode;\n  icon?: ReactNode;\n  disabled?: boolean;\n  /** 묶음 이름 — 같은 group 의 Option 은 `__group-label` 아래 모입니다. */\n  group?: string;\n};\n\nconst B = \"atom-autocomplete\";\n\nconst defaultFilter = (option: AutocompleteOption, inputValue: string) =>\n  option.label.toLowerCase().includes(inputValue.trim().toLowerCase());\n\n/** 라벨에서 입력과 일치한 구간(대소문자 무시, 여러 번)을 __match 로 감쌉니다. */\nfunction highlight(label: string, query: string): ReactNode {\n  const q = query.trim().toLowerCase();\n  if (!q) return label;\n  const lower = label.toLowerCase();\n  const out: ReactNode[] = [];\n  let from = 0;\n  let at = lower.indexOf(q);\n  while (at !== -1) {\n    if (at > from) out.push(label.slice(from, at));\n    out.push(\n      <span key={at} className={`${B}__match`}>\n        {label.slice(at, at + q.length)}\n      </span>,\n    );\n    from = at + q.length;\n    at = lower.indexOf(q, from);\n  }\n  if (from < label.length) out.push(label.slice(from));\n  return out;\n}\n\n/** 원본 데모의 Clear 아이콘 (스프라이트에 없는 인라인 SVG) */\nfunction ClearGlyph() {\n  return (\n    <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\">\n      <path d=\"M6 6l12 12M18 6L6 18\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nexport type AutocompleteProps = Omit<HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** 내부 `<input role=\"combobox\">` 의 ref 입니다. */\n  ref?: Ref<HTMLInputElement>;\n  /** 선택이 확정된 항목의 값입니다. */\n  value?: string;\n  /** uncontrolled 초기 값입니다. */\n  defaultValue?: string;\n  /** Input 에 지금 적혀 있는 글자입니다. 필터의 기준이며 `value` 와 다를 수 있습니다. */\n  inputValue?: string;\n  /** uncontrolled 초기 입력입니다. 생략하면 `value` 에 해당하는 라벨입니다. */\n  defaultInputValue?: string;\n  /** 필터 전 전체 선택지입니다. */\n  options: AutocompleteOption[];\n  /** 필터 규칙입니다. 기본값은 라벨에 입력이 포함되는지 봅니다(대소문자 무시 contains). */\n  filter?: (option: AutocompleteOption, inputValue: string) => boolean;\n  /** 초점이 들어올 때 입력 없이도 전체 목록을 펼칩니다. */\n  openOnFocus?: boolean;\n  /** Option 라벨에서 일치한 구간을 `__match` 로 감쌉니다. */\n  highlightMatch?: boolean;\n  /** 값이 없을 때 Input 에 보이는 문구입니다. */\n  placeholder?: string;\n  /** 값이 있을 때 Clear 버튼을 보여줍니다. */\n  clearable?: boolean;\n  /** 원격 조회 중임을 알립니다. Clear 자리에 Spinner 가 들어갑니다. */\n  loading?: boolean;\n  /** Field 높이와 폰트 크기를 결정합니다. Field 안이면 Field 의 size 를 따릅니다. */\n  size?: AutocompleteSize;\n  /** Field 의 배경과 테두리 처리입니다. */\n  variant?: AutocompleteVariant;\n  /** Field 테두리를 오류 색으로 바꿉니다. */\n  invalid?: boolean;\n  /** 입력도 열기도 할 수 없게 만듭니다. */\n  disabled?: boolean;\n  /** 필터 결과가 0개일 때 Listbox 에 보일 문구입니다. */\n  emptyText?: string;\n  /** 결과 없음 아래에 붙는 다음 행동 안내 (`__empty-hint`). */\n  emptyHint?: ReactNode;\n  /** Field 앞쪽 아이콘 (`__icon`). 기본은 검색 아이콘, `null` 이면 숨깁니다. */\n  icon?: ReactNode;\n  /** 폼 제출용 hidden input 의 name 입니다. */\n  name?: string;\n  /** 입력이 바뀔 때마다 호출됩니다. 원격 조회는 여기에 debounce 를 겁니다. */\n  onInputValueChange?: (inputValue: string) => void;\n  /** 항목을 골라 선택이 확정될 때 호출됩니다. */\n  onValueChange?: (value: string) => void;\n};\n\nexport function Autocomplete(props: AutocompleteProps) {\n  const field = useFieldControl();\n  const {\n    ref,\n    value: valueProp,\n    defaultValue = \"\",\n    inputValue: inputValueProp,\n    defaultInputValue,\n    options,\n    filter = defaultFilter,\n    openOnFocus = true,\n    highlightMatch = true,\n    placeholder,\n    clearable = true,\n    loading = false,\n    size = field?.size ?? \"medium\",\n    variant = \"outline\",\n    invalid: invalidProp = false,\n    disabled: disabledProp = false,\n    emptyText = \"검색 결과가 없어요\",\n    emptyHint,\n    icon = <SearchIcon />,\n    name,\n    onInputValueChange,\n    onValueChange,\n    className,\n    id,\n    \"aria-label\": ariaLabel,\n    \"aria-labelledby\": ariaLabelledBy,\n    \"aria-describedby\": ariaDescribedBy,\n    ...rest\n  } = props;\n\n  const invalid = invalidProp || !!field?.invalid;\n  const disabled = disabledProp || !!field?.disabled;\n  const auto = useId();\n  const inputId = id ?? field?.controlId ?? `${auto}-input`;\n  const listboxId = `${auto}-listbox`;\n\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? \"\";\n  const [inputValue, setInputValue] = useControllableState({\n    value: inputValueProp,\n    defaultValue: defaultInputValue ?? labelOf(defaultValue),\n    onChange: onInputValueChange,\n  });\n\n  const [open, setOpen] = useState(false);\n  const [active, setActive] = useState(-1);\n  // 선택 직후처럼 입력이 라벨과 같을 때는 전체 목록을 보여줍니다 (입력으로 좁힌 경우에만 필터).\n  const [filtering, setFiltering] = useState(false);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  const query = filtering ? inputValue : \"\";\n  const visible = useMemo(() => (query ? options.filter((o) => filter(o, query)) : options), [options, filter, query]);\n  const enabledIndexes = visible.flatMap((o, i) => (o.disabled ? [] : [i]));\n\n  const show = open && !disabled;\n\n  function openList(activeIndex = -1) {\n    if (disabled) return;\n    setOpen(true);\n    setActive(activeIndex);\n  }\n  function close() {\n    setOpen(false);\n    setActive(-1);\n  }\n  useDismiss(show, [rootRef], close);\n  const listboxRef = useRef<HTMLDivElement>(null);\n  useAnchoredPosition(show, rootRef, listboxRef);\n\n  function select(option: AutocompleteOption) {\n    if (option.disabled) return;\n    setValue(option.value);\n    setInputValue(option.label);\n    setFiltering(false);\n    close();\n  }\n\n  function move(step: 1 | -1) {\n    if (!enabledIndexes.length) return;\n    const pos = enabledIndexes.indexOf(active);\n    const next =\n      pos === -1\n        ? step === 1\n          ? enabledIndexes[0]!\n          : enabledIndexes[enabledIndexes.length - 1]!\n        : enabledIndexes[(pos + step + enabledIndexes.length) % enabledIndexes.length]!;\n    setActive(next);\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n    switch (e.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        e.preventDefault();\n        const step = e.key === \"ArrowDown\" ? 1 : -1;\n        if (!show) {\n          const selected = visible.findIndex((o) => o.value === value && !o.disabled);\n          openList(selected !== -1 ? selected : step === 1 ? (enabledIndexes[0] ?? -1) : (enabledIndexes.at(-1) ?? -1));\n        } else move(step);\n        break;\n      }\n      case \"Enter\":\n        if (show && active !== -1 && visible[active]) {\n          e.preventDefault();\n          select(visible[active]);\n        }\n        break;\n      case \"Escape\":\n        // 목록만 닫고 입력은 남깁니다 (useDismiss 도 같은 일을 하지만 열린 상태에서만 등록됩니다).\n        if (show) {\n          e.preventDefault();\n          close();\n        }\n        break;\n      case \"Tab\":\n        close();\n        break;\n    }\n  }\n\n  const activeOption = show && active !== -1 ? visible[active] : undefined;\n  const optionId = (i: number) => `${listboxId}-opt-${i}`;\n\n  // 그룹 순서를 유지하며 묶습니다 (group 이 없는 항목은 이름 없는 묶음).\n  const groups: { name?: string; items: { option: AutocompleteOption; index: number }[] }[] = [];\n  visible.forEach((option, index) => {\n    const last = groups.at(-1);\n    if (last && last.name === option.group) last.items.push({ option, index });\n    else groups.push({ name: option.group, items: [{ option, index }] });\n  });\n\n  const status = !show ? \"\" : loading ? \"불러오는 중\" : visible.length ? `${visible.length}개 결과` : \"결과 없음\";\n\n  function renderOption({ option, index }: { option: AutocompleteOption; index: number }) {\n    const selected = option.value === value && value !== \"\";\n    return (\n      <div\n        key={option.value}\n        id={optionId(index)}\n        role=\"option\"\n        className={`${B}__option`}\n        aria-selected={selected}\n        aria-disabled={option.disabled || undefined}\n        data-active={index === active ? \"\" : undefined}\n        onPointerMove={() => !option.disabled && index !== active && setActive(index)}\n        onClick={() => select(option)}\n      >\n        {option.icon !== undefined && (\n          <Slot aria-hidden=\"true\" className={`${B}__option-icon`}>\n            {option.icon}\n          </Slot>\n        )}\n        <span className={`${B}__option-text`}>\n          <span className={`${B}__option-label`}>\n            {highlightMatch ? highlight(option.label, query) : option.label}\n          </span>\n          {option.description !== undefined && (\n            <span className={`${B}__option-description`}>{option.description}</span>\n          )}\n        </span>\n        <CheckIcon className={`${B}__option-check`} />\n      </div>\n    );\n  }\n\n  return (\n    <div\n      {...rest}\n      ref={rootRef}\n      data-state={show ? \"open\" : \"closed\"}\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-loading={loading ? \"\" : undefined}\n      className={cn(B, mod(B, \"size\", size, \"medium\"), mod(B, \"variant\", variant, \"outline\"), className)}\n    >\n      <div className={`${B}__field`}>\n        {icon !== null && icon !== false && (\n          <Slot aria-hidden=\"true\" className={`${B}__icon`}>\n            {icon}\n          </Slot>\n        )}\n        <input\n          ref={(node) => {\n            inputRef.current = node;\n            if (typeof ref === \"function\") ref(node);\n            else if (ref) ref.current = node;\n          }}\n          id={inputId}\n          className={`${B}__input`}\n          type=\"text\"\n          role=\"combobox\"\n          autoComplete=\"off\"\n          aria-expanded={show}\n          aria-controls={listboxId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={activeOption ? optionId(active) : undefined}\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          aria-describedby={cn(field?.describedBy, ariaDescribedBy) || undefined}\n          aria-invalid={invalid || undefined}\n          aria-required={field?.required || undefined}\n          required={field?.required || undefined}\n          disabled={disabled}\n          placeholder={placeholder}\n          value={inputValue}\n          onChange={(e) => {\n            setInputValue(e.target.value);\n            setFiltering(true);\n            setOpen(true);\n            // 입력할 때마다 첫 결과를 활성으로 둡니다.\n            const next = e.target.value.trim()\n              ? options.filter((o) => filter(o, e.target.value)).findIndex((o) => !o.disabled)\n              : -1;\n            setActive(next);\n          }}\n          onFocus={() => {\n            if (openOnFocus) openList();\n          }}\n          onClick={() => {\n            if (!show) openList();\n          }}\n          onBlur={(e) => {\n            if (!rootRef.current?.contains(e.relatedTarget as Node | null)) close();\n          }}\n          onKeyDown={onKeyDown}\n        />\n        {clearable && inputValue !== \"\" && (\n          <button\n            type=\"button\"\n            className={`${B}__clear`}\n            aria-label=\"입력 지우기\"\n            tabIndex={-1}\n            onMouseDown={(e) => e.preventDefault()}\n            onClick={() => {\n              setInputValue(\"\");\n              setFiltering(false);\n              if (value !== \"\") setValue(\"\");\n              inputRef.current?.focus();\n              openList();\n            }}\n          >\n            <ClearGlyph />\n          </button>\n        )}\n        <span className={`${B}__spinner`} aria-hidden=\"true\" />\n      </div>\n\n      <div\n        ref={listboxRef}\n        id={listboxId}\n        role=\"listbox\"\n        className={`${B}__listbox`}\n        aria-labelledby={ariaLabelledBy ?? field?.labelId}\n        aria-label={ariaLabelledBy || field ? undefined : ariaLabel}\n        aria-busy={loading || undefined}\n        // 목록을 눌러도 초점이 Input 에 머물도록\n        onMouseDown={(e) => e.preventDefault()}\n      >\n        {show &&\n          (loading ? (\n            <p className={`${B}__loading`}>불러오는 중</p>\n          ) : visible.length === 0 ? (\n            <p className={`${B}__empty`}>\n              <span className={`${B}__empty-title`}>{emptyText}</span>\n              {emptyHint !== undefined && <span className={`${B}__empty-hint`}>{emptyHint}</span>}\n            </p>\n          ) : (\n            groups.map((g, gi) =>\n              g.name === undefined ? (\n                g.items.map(renderOption)\n              ) : (\n                <div key={`g-${gi}`} role=\"group\" aria-labelledby={`${listboxId}-g-${gi}`}>\n                  <div id={`${listboxId}-g-${gi}`} className={`${B}__group-label`} role=\"presentation\">\n                    {g.name}\n                  </div>\n                  {g.items.map(renderOption)}\n                </div>\n              ),\n            )\n          ))}\n      </div>\n\n      <span className={`${B}__status`} role=\"status\" aria-live=\"polite\">\n        {status}\n      </span>\n      {name !== undefined && <input type=\"hidden\" name={name} value={value} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/autocomplete.tsx"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/autocomplete.css",
      "content": "/* ============================================================================\n   Element — Autocomplete\n   ----------------------------------------------------------------------------\n   A text field that opens its listbox on focus and narrows it as you type.\n   Matching is `contains`, not `prefix`, so the highlighted run can sit anywhere\n   inside a label.\n\n   The field half repeats Text Input and the listbox half repeats Select rather\n   than importing either. A card preview loads one stylesheet, and pulling two\n   more files in would leave the element unusable on its own.\n\n   atom-autocomplete\n     --size_{small|medium|large}             medium is the default\n     --variant_{outline|weak}                outline is the default\n     [data-state=\"closed|open\"]  [data-invalid]  [data-disabled]  [data-loading]\n     __field  __icon  __input  __text  __placeholder  __caret  __clear  __spinner\n     __listbox  __group-label  __status\n     __option  __option-icon  __option-text  __option-label  __match\n     __option-description  __option-check\n     __empty  __empty-title  __empty-hint  __loading\n   ========================================================================== */\n\n.atom-autocomplete {\n  position: relative;\n  width: 100%;\n  min-width: 0;\n}\n\n/* --- field --------------------------------------------------------------- */\n.atom-autocomplete__field {\n  display: flex;\n  align-items: center;\n  gap: var(--atom-x2);\n  width: 100%;\n  min-width: 0;\n  min-height: var(--atom-control-h);\n  padding: var(--atom-x3) var(--atom-x4);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-control-border);\n  font-size: 15px;\n  line-height: 1.45;\n  color: var(--atom-fg-neutral);\n  transition: box-shadow var(--atom-duration-fast) ease;\n}\n\n.atom-autocomplete__icon {\n  width: 24px;\n  height: 24px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n}\n\n/* The real <input role=\"combobox\">. Borderless — the field draws the box. */\n.atom-autocomplete__input {\n  flex: 1;\n  min-width: 0;\n  border: 0;\n  padding: 0;\n  background: none;\n  outline: none;\n  font: inherit;\n  color: inherit;\n}\n\n.atom-autocomplete__input::placeholder {\n  color: var(--atom-fg-neutral-subtle);\n}\n\n/* Static stand-ins. A card preview is inert, so a typed query and a caret have\n   to be drawable without a real focus. */\n/* Sized to its content, not to the field. `__caret` is the text cursor and sits\n   directly after this span, so stretching it to fill the row would park the\n   cursor at the far edge — reading as a right-aligned field rather than as a\n   caret following what was typed. `min-width: 0` keeps the ellipsis working\n   when a long value does have to shrink. */\n.atom-autocomplete__text,\n.atom-autocomplete__placeholder {\n  flex: 0 1 auto;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-autocomplete__placeholder {\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-autocomplete__caret {\n  display: inline-block;\n  width: 1.5px;\n  height: 19px;\n  flex-shrink: 0;\n  background: var(--atom-fg-neutral);\n  animation: atom-autocomplete-blink 1.1s steps(1) infinite;\n}\n\n/* The field's gap is meant for the slots either side of the value, but a text\n   cursor belongs against the glyph it follows — 8px away reads as a separate\n   control. Cancel the gap on whichever side the caret touches the value; the\n   two orders differ because an empty field puts the cursor before its\n   placeholder and a filled one puts it after the text. */\n.atom-autocomplete__text + .atom-autocomplete__caret {\n  margin-inline-start: calc(1px - var(--atom-x2));\n}\n\n.atom-autocomplete__caret + .atom-autocomplete__placeholder {\n  margin-inline-start: calc(1px - var(--atom-x2));\n}\n\n@keyframes atom-autocomplete-blink {\n  50% {\n    opacity: 0;\n  }\n}\n\n/* The trailing controls take the leftover space themselves now that the value\n   no longer stretches, so they stay pinned to the end of the field. */\n.atom-autocomplete__clear {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  margin-inline-start: auto;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n}\n\n.atom-autocomplete__clear svg {\n  width: 12px;\n  height: 12px;\n}\n\n.atom-autocomplete__spinner {\n  display: none;\n  width: 16px;\n  height: 16px;\n  flex-shrink: 0;\n  margin-inline-start: auto;\n  border-radius: var(--atom-radius-full);\n  border: 2px solid var(--atom-border-default);\n  border-top-color: var(--atom-fg-neutral-muted);\n  animation: atom-autocomplete-spin 700ms linear infinite;\n}\n\n@keyframes atom-autocomplete-spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n\n/* --- listbox ------------------------------------------------------------- */\n/* The list overlaps whatever follows the field, so it has to win the stacking\n   order against the next form row. */\n.atom-autocomplete__listbox {\n  position: relative;\n  z-index: 1;\n  margin-top: 6px;\n  padding: 6px;\n  border-radius: var(--atom-radius-r3);\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-shadow-s2), inset 0 0 0 1px var(--atom-border-subtle);\n}\n\n.atom-autocomplete__group-label {\n  padding: 6px 10px 4px;\n  font-size: 11px;\n  font-weight: 600;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-autocomplete__option {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 9px 10px;\n  border-radius: var(--atom-radius-r2);\n  font-size: 14px;\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease;\n}\n\n.atom-autocomplete__option-icon {\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-autocomplete__option-text {\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n  flex: 1;\n}\n\n.atom-autocomplete__option-label {\n  font-weight: 500;\n  color: var(--atom-fg-neutral);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* Weight plus colour, no highlighter fill: a matched run can break across a\n   wrap and a background would leave a torn box behind. */\n.atom-autocomplete__match {\n  font-weight: 700;\n  color: var(--atom-fg-brand);\n}\n\n.atom-autocomplete__option-description {\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-autocomplete__option-check {\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral);\n  opacity: 0;\n}\n\n.atom-autocomplete__empty {\n  padding: var(--atom-x5) 10px;\n  text-align: center;\n}\n\n.atom-autocomplete__empty-title {\n  display: block;\n  font-size: 13px;\n  color: var(--atom-fg-neutral);\n}\n\n.atom-autocomplete__empty-hint {\n  display: block;\n  margin-top: 4px;\n  font-size: 12px;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-autocomplete__loading {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: var(--atom-x2);\n  padding: var(--atom-x5) 10px;\n  font-size: 13px;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n/* The result count is announced, not drawn — the list already shows it. */\n.atom-autocomplete__status {\n  position: absolute;\n  width: 1px;\n  height: 1px;\n  padding: 0;\n  margin: -1px;\n  overflow: hidden;\n  clip: rect(0, 0, 0, 0);\n  white-space: nowrap;\n  border-width: 0;\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-autocomplete--size_small .atom-autocomplete__field {\n  min-height: var(--atom-control-h-small);\n  padding: var(--atom-x2) var(--atom-x3);\n  font-size: 14px;\n}\n\n.atom-autocomplete--size_small .atom-autocomplete__caret {\n  height: 17px;\n}\n\n.atom-autocomplete--size_large .atom-autocomplete__field {\n  min-height: var(--atom-control-h-large);\n  padding-inline: var(--atom-x5);\n  border-radius: var(--atom-radius-r3);\n  font-size: 16px;\n}\n\n/* --- variants ------------------------------------------------------------ */\n.atom-autocomplete--variant_weak .atom-autocomplete__field {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-autocomplete__field:hover {\n  box-shadow: var(--atom-control-border-hover);\n}\n\n/* Open implies focused — the list only opens from the field. Keeping the ring\n   on lets a static demo draw the open state without a real focus. */\n.atom-autocomplete__field:focus-within,\n.atom-autocomplete[data-state=\"open\"] .atom-autocomplete__field {\n  box-shadow: var(--atom-control-border-focus);\n}\n\n.atom-autocomplete[data-state=\"closed\"] .atom-autocomplete__listbox {\n  display: none;\n}\n\n/* Hover is \"the pointer is here\"; active is \"the keyboard cursor is here\".\n   They have to differ, or ↑ ↓ looks like nothing moved. */\n.atom-autocomplete__option:hover:not([aria-disabled=\"true\"]) {\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-autocomplete__option[data-active] {\n  background: var(--atom-bg-side-active);\n}\n\n.atom-autocomplete__option[aria-selected=\"true\"]\n  .atom-autocomplete__option-label {\n  font-weight: 600;\n}\n\n.atom-autocomplete__option[aria-selected=\"true\"]\n  .atom-autocomplete__option-check {\n  opacity: 1;\n}\n\n.atom-autocomplete__option[aria-disabled=\"true\"] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n.atom-autocomplete[data-loading] .atom-autocomplete__spinner {\n  display: block;\n}\n\n.atom-autocomplete[data-loading] .atom-autocomplete__clear {\n  display: none;\n}\n\n.atom-autocomplete[data-invalid] .atom-autocomplete__field,\n.atom-autocomplete[data-invalid] .atom-autocomplete__field:focus-within {\n  box-shadow: var(--atom-control-border-invalid);\n}\n\n.atom-autocomplete[data-disabled] {\n  opacity: 0.45;\n  pointer-events: none;\n}\n\n.atom-autocomplete[data-disabled] .atom-autocomplete__field {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/autocomplete.css"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/field.css",
      "content": "/* ============================================================================\n   Element — Field\n   ----------------------------------------------------------------------------\n   The label / description / error / counter wrapper every input-ish component\n   is dropped into. Field owns the surrounding text, never the control itself.\n\n   atom-field\n     --layout_horizontal                     label to the left of the control\n     --size_{small|medium}                   medium is the default\n     [data-invalid]  [data-disabled]\n     __header  __label  __required  __optional\n     __control                               slot for the actual control\n     __separator                             the text between a range's inputs\n     __footer  __description  __error  __counter\n\n   __control is a row, not a single box. One input, an input plus a Button, or\n   two inputs joined by __separator for a range — all of them are the same slot,\n   so the 4px rhythm and the single row height are defined here once instead of\n   in every page that needs a combination.\n   ========================================================================== */\n\n.atom-field {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  width: 100%;\n  min-width: 0;\n}\n\n.atom-field__header {\n  display: flex;\n  align-items: center;\n  gap: var(--atom-x2);\n}\n\n.atom-field__label {\n  font-size: 13px;\n  font-weight: 600;\n  line-height: 1.4;\n  color: var(--atom-fg-neutral);\n}\n\n.atom-field__required {\n  margin-left: 2px;\n  color: var(--atom-primary-600);\n}\n\n.atom-field__optional {\n  font-size: 12px;\n  font-weight: 400;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n/* --- control slot -------------------------------------------------------- */\n/* Members sit 4px apart and share one height. Every control already agrees on\n   that height — `--atom-control-h`, see the Control surface block in\n   tokens.css — so the only member that needs handling is Button, which keeps\n   its own 47px: `stretch` cannot pull a fixed height, so the height is\n   released and the row decides. */\n.atom-field__control {\n  display: flex;\n  align-items: stretch;\n  gap: var(--atom-x1);\n  min-width: 0;\n}\n\n/* Every member starts at `width: 100%`, so no explicit `flex` is needed — they\n   all ask for the full row and shrink together. What must not shrink says so\n   below; what is left over goes to the typing surfaces, and two inputs in a\n   range land on the same width without being told. */\n.atom-field__control > * {\n  min-width: 0;\n}\n\n/* `:not(:only-child)` matters: a Button alone in the slot has nothing taller to\n   stretch against, so releasing its height would collapse it to its own text.\n   The height only gives way once there is a row to match. Every other control\n   is already `--atom-control-h` and needs nothing here. */\n.atom-field__control > .atom-button:not(:only-child) {\n  height: auto;\n  flex-shrink: 0;\n}\n\n\n/* Focus follows the control ring too. base.css draws its ring *outside* the\n   element at a 2px offset, which lands on the neighbour across a 4px gap, so a\n   Button in a combination borrows the inset ring every other control uses. */\n.atom-field__control > .atom-button:not(:only-child):focus-visible {\n  outline: none;\n  box-shadow: var(--atom-control-border-focus);\n}\n\n/* An icon button in the slot is square. Its own `--layout_icon-only` widths\n   (32 / 40 / 47 / 54px) follow Button's heights, and in the slot the row sets\n   the height instead — so the width is re-pointed at the control heights the\n   row can take, picked by the size modifier the author already put on the\n   button to match the control beside it.\n\n   `aspect-ratio` cannot do this: flex resolves an item's width before the row\n   stretches its height, so with `width: auto` there is no definite height for\n   the ratio to work from and the button collapses to its icon (0 wide, once\n   the icon is a percentage of it).\n\n   The icon is a share of the box rather than a fixed size, so it tracks all\n   three heights — 40 → 16px, 48 → 19.2px, 56 → 22.4px — which is the 38–42%\n   band Button's own sizes already sit in. */\n.atom-field__control > .atom-button--layout_icon-only:not(:only-child) {\n  width: var(--atom-control-h);\n  padding-inline: 0;\n}\n\n.atom-field__control\n  > .atom-button--layout_icon-only.atom-button--size_xsmall:not(:only-child),\n.atom-field__control\n  > .atom-button--layout_icon-only.atom-button--size_small:not(:only-child) {\n  width: var(--atom-control-h-small);\n}\n\n.atom-field__control\n  > .atom-button--layout_icon-only.atom-button--size_large:not(:only-child) {\n  width: var(--atom-control-h-large);\n}\n\n.atom-field__control\n  > .atom-button--layout_icon-only:not(:only-child)\n  .atom-button__icon {\n  width: 40%;\n  /* Not `height: 40%` — the row's height is auto, so a percentage height falls\n     back to the svg's 150px intrinsic height and the row grows to fit it. */\n  height: auto;\n  aspect-ratio: 1;\n}\n\n/* Checkbox, Radio and Switch are a line of text with a marker, not a box: they\n   have no control height, so a slot holding only those is as tall as its text\n   and sits above the 48px inputs in the fields beside it. Give that slot the\n   control height and centre the group in it — the marker then lines up with\n   the text inside those inputs. `:has` because the slot, not the group, is the\n   box being sized. */\n.atom-field__control:has(> .atom-checkbox-group:only-child),\n.atom-field__control:has(> .atom-radio-group:only-child),\n.atom-field__control:has(> .atom-checkbox:only-child),\n.atom-field__control:has(> .atom-radio:only-child),\n.atom-field__control:has(> .atom-switch:only-child) {\n  align-items: center;\n  min-height: var(--atom-control-h);\n}\n\n.atom-field--size_small\n  .atom-field__control:has(> .atom-checkbox-group:only-child),\n.atom-field--size_small\n  .atom-field__control:has(> .atom-radio-group:only-child),\n.atom-field--size_small .atom-field__control:has(> .atom-checkbox:only-child),\n.atom-field--size_small .atom-field__control:has(> .atom-radio:only-child),\n.atom-field--size_small .atom-field__control:has(> .atom-switch:only-child) {\n  min-height: var(--atom-control-h-small);\n}\n\n/* The ‘~’ or ‘부터’ between a range's two inputs. It is a label, not a control,\n   so it keeps its own size and centres against the row instead of stretching. */\n.atom-field__separator {\n  flex-shrink: 0;\n  align-self: center;\n  padding-inline: 2px;\n  font-size: 15px;\n  color: var(--atom-fg-neutral-muted);\n  white-space: nowrap;\n}\n\n.atom-field__footer {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: var(--atom-x4);\n  font-size: 12px;\n  line-height: 1.5;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-field__description,\n.atom-field__error {\n  min-width: 0;\n  word-break: keep-all;\n}\n\n.atom-field__error {\n  display: flex;\n  align-items: flex-start;\n  gap: 4px;\n  color: var(--atom-fg-critical);\n}\n\n.atom-field__error-icon {\n  width: 13px;\n  height: 13px;\n  flex-shrink: 0;\n  margin-top: 2px;\n}\n\n.atom-field__counter {\n  flex-shrink: 0;\n  margin-left: auto;\n  font-variant-numeric: tabular-nums;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-field--size_small .atom-field__label {\n  font-size: 12px;\n}\n\n.atom-field--size_small .atom-field__footer {\n  font-size: 11px;\n}\n\n/* --- horizontal layout --------------------------------------------------- */\n.atom-field--layout_horizontal {\n  flex-direction: row;\n  align-items: flex-start;\n  gap: var(--atom-x5);\n}\n\n.atom-field--layout_horizontal .atom-field__header {\n  flex-shrink: 0;\n  width: 120px;\n  padding-top: 12px;\n}\n\n.atom-field--layout_horizontal .atom-field__body {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n  flex: 1;\n  min-width: 0;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-field[data-invalid] .atom-field__description {\n  color: var(--atom-fg-critical);\n}\n\n.atom-field[data-disabled] {\n  opacity: 0.45;\n}\n\n/* Legacy alias — the old card preview marked invalid with a modifier class. */\n.atom-field--invalid .atom-field__description {\n  color: var(--atom-fg-critical);\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/field.css"
    }
  ],
  "categories": [
    "selection"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "component",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "Autocomplete"
    ],
    "docs": "poc/design-system/components/autocomplete.md"
  }
}
