{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-select",
  "type": "registry:ui",
  "title": "Multi Select",
  "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/multi-select.tsx",
      "content": "\"use client\";\n\nimport { type HTMLAttributes, type KeyboardEvent, type ReactNode, type Ref, useId, useRef, useState } 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, ChevronDownIcon } from \"./icons\";\n\n/* -------------------------------------------------------------------------- */\n/*  Multi Select (components/multi-select.md, styles/atom/elements/multi-select.css) */\n/*    <MultiSelect                                                            */\n/*      options={[{ value: \"a\", label: \"항목 A\" }, ...]}                      */\n/*      value={value} onValueChange={setValue}                                */\n/*      placeholder=\"항목을 선택해주세요\" selectAll maxSelected={5}           */\n/*    />                                                                      */\n/*  WAI-ARIA combobox(select-only) + listbox(aria-multiselectable).           */\n/*  초점은 Trigger 에 머물고 활성 Option 은 aria-activedescendant 로 가리킵니다. */\n/*  고른 뒤에도 목록은 열린 채로 남고 Esc · 바깥 클릭 · Tab 으로만 닫힙니다.   */\n/* -------------------------------------------------------------------------- */\n\nexport type MultiSelectSize = \"small\" | \"medium\" | \"large\";\nexport type MultiSelectVariant = \"weak\" | \"outline\";\nexport type MultiSelectDisplay = \"chip\" | \"count\";\nexport type MultiSelectOption = {\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-multi-select\";\nconst EMPTY: string[] = [];\n\n/** 원본 데모의 Chip Remove 아이콘 (스프라이트에 없는 인라인 SVG) */\nfunction RemoveGlyph() {\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\n/** 체크박스 모양의 Option box (checkbox 시각을 반복 — multi-select.css 헤더 주석) */\nfunction OptionBox() {\n  return (\n    <span className={`${B}__option-box`}>\n      <CheckIcon className={`${B}__option-check`} />\n      <span className={`${B}__option-indeterminate`} />\n    </span>\n  );\n}\n\nexport type MultiSelectProps = Omit<HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** Trigger(`role=\"combobox\"`) 요소의 ref 입니다. */\n  ref?: Ref<HTMLDivElement>;\n  /** 선택된 항목들의 값입니다. */\n  value?: string[];\n  /** uncontrolled 초기 값입니다. */\n  defaultValue?: string[];\n  /** 선택지 목록입니다. */\n  options: MultiSelectOption[];\n  /** 선택이 없을 때 Trigger 에 보이는 문구입니다. */\n  placeholder?: string;\n  /** 선택된 값을 Trigger 에 요약하는 방식입니다. */\n  display?: MultiSelectDisplay;\n  /** `display=\"chip\"` 에서 칩으로 보여줄 최대 개수입니다. 나머지는 넘침 카운트가 됩니다. */\n  maxVisibleChips?: number;\n  /** 고를 수 있는 최대 개수입니다. 도달하면 나머지 Option 이 비활성이 됩니다. */\n  maxSelected?: number;\n  /** Listbox 위에 전체 선택 / 선택 해제 Toolbar 를 둡니다. */\n  selectAll?: boolean;\n  /** Trigger 높이와 폰트 크기를 결정합니다. Field 안이면 Field 의 size 를 따릅니다. */\n  size?: MultiSelectSize;\n  /** Trigger 의 배경과 테두리 처리입니다. */\n  variant?: MultiSelectVariant;\n  /** Trigger 테두리를 오류 색으로 바꿉니다. */\n  invalid?: boolean;\n  /** 열 수 없게 만듭니다. */\n  disabled?: boolean;\n  /** 선택지가 비었을 때 Listbox 에 보일 문구입니다. */\n  emptyText?: string;\n  /** 폼 제출용 hidden input 의 name 입니다 (값마다 하나씩). */\n  name?: string;\n  /** 선택이 바뀔 때마다 호출됩니다. 목록이 닫힐 때가 아닙니다. */\n  onValueChange?: (value: string[]) => void;\n};\n\ntype Item = { kind: \"all\" } | { kind: \"option\"; option: MultiSelectOption };\n\nexport function MultiSelect(props: MultiSelectProps) {\n  const field = useFieldControl();\n  const {\n    ref,\n    value: valueProp,\n    defaultValue = EMPTY,\n    options,\n    placeholder,\n    display = \"chip\",\n    maxVisibleChips = 3,\n    maxSelected,\n    selectAll = false,\n    size = field?.size ?? \"medium\",\n    variant = \"weak\",\n    invalid: invalidProp = false,\n    disabled: disabledProp = false,\n    emptyText = \"결과가 없어요\",\n    name,\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 triggerId = id ?? field?.controlId ?? `${auto}-trigger`;\n  const listboxId = `${auto}-listbox`;\n  const labelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : field?.labelId);\n\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [open, setOpen] = useState(false);\n  const [active, setActive] = useState(-1);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLDivElement>(null);\n\n  const show = open && !disabled;\n  const selected = new Set(value);\n  const limitReached = maxSelected !== undefined && value.length >= maxSelected;\n  const isDisabled = (o: MultiSelectOption) => !!o.disabled || (limitReached && !selected.has(o.value));\n\n  const selectable = options.filter((o) => !o.disabled);\n  const allChecked = selectable.length > 0 && selectable.every((o) => selected.has(o.value));\n  const someChecked = !allChecked && selectable.some((o) => selected.has(o.value));\n\n  const items: Item[] = [\n    ...(selectAll && options.length ? [{ kind: \"all\" } as const] : []),\n    ...options.map((option) => ({ kind: \"option\", option }) as const),\n  ];\n  const itemId = (i: number) => `${listboxId}-opt-${i}`;\n  const enabled = items.flatMap((it, i) => (it.kind === \"all\" || !isDisabled(it.option) ? [i] : []));\n\n  function close(focusTrigger = true) {\n    setOpen(false);\n    setActive(-1);\n    if (focusTrigger) triggerRef.current?.focus();\n  }\n  function openList(step: 1 | -1 = 1) {\n    if (disabled) return;\n    setOpen(true);\n    const firstSelected = items.findIndex((it) => it.kind === \"option\" && selected.has(it.option.value));\n    setActive(firstSelected !== -1 ? firstSelected : step === 1 ? (enabled[0] ?? -1) : (enabled.at(-1) ?? -1));\n  }\n  useDismiss(show, [rootRef], (reason) => close(reason === \"escape\"));\n  const listboxRef = useRef<HTMLDivElement>(null);\n  useAnchoredPosition(show, rootRef, listboxRef);\n\n  function commit(next: string[]) {\n    // 옵션 순서를 유지합니다.\n    const set = new Set(next);\n    setValue(options.filter((o) => set.has(o.value)).map((o) => o.value));\n  }\n\n  function toggle(option: MultiSelectOption) {\n    if (isDisabled(option)) return;\n    if (selected.has(option.value)) commit(value.filter((v) => v !== option.value));\n    else commit([...value, option.value]);\n  }\n\n  function toggleAll() {\n    if (allChecked) {\n      // 비활성 Option 에 걸려 있던 선택은 그대로 둡니다.\n      commit(value.filter((v) => options.find((o) => o.value === v)?.disabled));\n      return;\n    }\n    const next = [...value];\n    for (const o of selectable) {\n      if (maxSelected !== undefined && next.length >= maxSelected) break;\n      if (!next.includes(o.value)) next.push(o.value);\n    }\n    commit(next);\n  }\n\n  function clearAll() {\n    commit(value.filter((v) => options.find((o) => o.value === v)?.disabled));\n  }\n\n  function activate(i: number) {\n    const it = items[i];\n    if (!it) return;\n    if (it.kind === \"all\") toggleAll();\n    else toggle(it.option);\n  }\n\n  function move(to: \"next\" | \"prev\" | \"first\" | \"last\") {\n    if (!enabled.length) return;\n    const pos = enabled.indexOf(active);\n    let next: number;\n    if (to === \"first\") next = enabled[0]!;\n    else if (to === \"last\") next = enabled.at(-1)!;\n    else if (pos === -1) next = to === \"next\" ? enabled[0]! : enabled.at(-1)!;\n    else next = enabled[Math.min(Math.max(pos + (to === \"next\" ? 1 : -1), 0), enabled.length - 1)]!;\n    setActive(next);\n    if (typeof document !== \"undefined\") document.getElementById(itemId(next))?.scrollIntoView({ block: \"nearest\" });\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {\n    // Chip Remove 버튼에서 올라온 키는 버튼이 처리합니다.\n    if (e.target !== e.currentTarget) return;\n    if (!show) {\n      if ([\"ArrowDown\", \"ArrowUp\", \"Enter\", \" \"].includes(e.key)) {\n        e.preventDefault();\n        openList(e.key === \"ArrowUp\" ? -1 : 1);\n      }\n      return;\n    }\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        move(\"next\");\n        break;\n      case \"ArrowUp\":\n        e.preventDefault();\n        if (e.altKey) close();\n        else move(\"prev\");\n        break;\n      case \"Home\":\n        e.preventDefault();\n        move(\"first\");\n        break;\n      case \"End\":\n        e.preventDefault();\n        move(\"last\");\n        break;\n      case \" \":\n      case \"Enter\":\n        // Enter 로는 닫지 않습니다 — 계속 고를 수 있도록.\n        e.preventDefault();\n        if (active !== -1) activate(active);\n        break;\n      case \"Escape\":\n        e.preventDefault();\n        close();\n        break;\n      case \"Tab\":\n        close(false);\n        break;\n    }\n  }\n\n  const chosen = options.filter((o) => selected.has(o.value));\n  const empty = chosen.length === 0;\n  const chips = chosen.slice(0, Math.max(0, maxVisibleChips));\n  const overflow = chosen.length - chips.length;\n  const countText = `${chosen.length}개 선택됨`;\n\n  // 그룹 순서를 유지하며 묶습니다.\n  const groups: { name?: string; entries: { option: MultiSelectOption; index: number }[] }[] = [];\n  items.forEach((it, index) => {\n    if (it.kind !== \"option\") return;\n    const last = groups.at(-1);\n    if (last && last.name === it.option.group) last.entries.push({ option: it.option, index });\n    else groups.push({ name: it.option.group, entries: [{ option: it.option, index }] });\n  });\n\n  function renderOption({ option, index }: { option: MultiSelectOption; index: number }) {\n    const on = selected.has(option.value);\n    const off = isDisabled(option);\n    return (\n      <div\n        key={option.value}\n        id={itemId(index)}\n        role=\"option\"\n        className={`${B}__option`}\n        aria-selected={on}\n        aria-disabled={off || undefined}\n        data-active={index === active ? \"\" : undefined}\n        onPointerMove={() => !off && index !== active && setActive(index)}\n        onClick={() => {\n          setActive(index);\n          toggle(option);\n        }}\n      >\n        <OptionBox />\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`}>{option.label}</span>\n          {option.description !== undefined && (\n            <span className={`${B}__option-description`}>{option.description}</span>\n          )}\n        </span>\n      </div>\n    );\n  }\n\n  const allIndex = items.findIndex((it) => it.kind === \"all\");\n\n  return (\n    <div\n      {...rest}\n      ref={rootRef}\n      data-state={show ? \"open\" : \"closed\"}\n      data-empty={empty ? \"\" : undefined}\n      data-invalid={invalid ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      className={cn(\n        B,\n        mod(B, \"size\", size, \"medium\"),\n        mod(B, \"variant\", variant, \"weak\"),\n        mod(B, \"display\", display, \"chip\"),\n        className,\n      )}\n    >\n      {/* 원본 Markup 의 <button> 대신 div — Chip Remove(<button>)를 안에 두려면 button 을 중첩할 수 없습니다. */}\n      <div\n        ref={(node) => {\n          triggerRef.current = node;\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) ref.current = node;\n        }}\n        id={triggerId}\n        className={`${B}__trigger`}\n        role=\"combobox\"\n        tabIndex={disabled ? -1 : 0}\n        aria-haspopup=\"listbox\"\n        aria-expanded={show}\n        aria-controls={listboxId}\n        aria-activedescendant={show && active !== -1 ? itemId(active) : undefined}\n        aria-label={ariaLabel}\n        aria-labelledby={labelledBy}\n        aria-describedby={cn(field?.describedBy, ariaDescribedBy) || undefined}\n        aria-invalid={invalid || undefined}\n        aria-required={field?.required || undefined}\n        aria-disabled={disabled || undefined}\n        onClick={() => (show ? close() : openList())}\n        onKeyDown={onKeyDown}\n        onBlur={(e) => {\n          if (show && !rootRef.current?.contains(e.relatedTarget as Node | null) && e.relatedTarget) close(false);\n        }}\n      >\n        <span className={`${B}__value`}>\n          {empty ? (\n            <span className={`${B}__placeholder`}>{placeholder}</span>\n          ) : (\n            <>\n              {chips.map((o) => (\n                <span key={o.value} className={`${B}__chip`}>\n                  <span className={`${B}__chip-label`}>{o.label}</span>\n                  <button\n                    type=\"button\"\n                    className={`${B}__chip-remove`}\n                    aria-label={`${o.label} 삭제`}\n                    disabled={disabled}\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      commit(value.filter((v) => v !== o.value));\n                      triggerRef.current?.focus();\n                    }}\n                  >\n                    <RemoveGlyph />\n                  </button>\n                </span>\n              ))}\n              {overflow > 0 && <span className={`${B}__overflow`}>+{overflow}</span>}\n              <span className={`${B}__count`}>{countText}</span>\n            </>\n          )}\n        </span>\n        <ChevronDownIcon className={`${B}__caret`} />\n      </div>\n\n      <div\n        ref={listboxRef}\n        id={listboxId}\n        role=\"listbox\"\n        aria-multiselectable=\"true\"\n        aria-labelledby={labelledBy}\n        aria-label={labelledBy ? undefined : ariaLabel}\n        className={`${B}__listbox`}\n        // 목록을 눌러도 초점이 Trigger 에 머물도록\n        onMouseDown={(e) => e.preventDefault()}\n      >\n        {show && (\n          <>\n            {allIndex !== -1 && (\n              <div className={`${B}__toolbar`}>\n                <span\n                  id={itemId(allIndex)}\n                  role=\"option\"\n                  className={`${B}__option`}\n                  aria-selected={allChecked}\n                  aria-checked={allChecked ? true : someChecked ? \"mixed\" : false}\n                  data-active={active === allIndex ? \"\" : undefined}\n                  style={{ padding: 0, gap: 8 }}\n                  onClick={() => {\n                    setActive(allIndex);\n                    toggleAll();\n                  }}\n                >\n                  <OptionBox />\n                  <span className={`${B}__option-label`}>전체 선택</span>\n                </span>\n                <button type=\"button\" tabIndex={-1} className={`${B}__clear`} onClick={clearAll}>\n                  선택 해제\n                </button>\n              </div>\n            )}\n            {options.length === 0 ? (\n              <div className={`${B}__empty`}>{emptyText}</div>\n            ) : (\n              groups.map((g, gi) =>\n                g.name === undefined ? (\n                  g.entries.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.entries.map(renderOption)}\n                  </div>\n                ),\n              )\n            )}\n            {maxSelected !== undefined && (\n              <div className={`${B}__footer`}>\n                {countText} · 최대 {maxSelected}개\n              </div>\n            )}\n          </>\n        )}\n      </div>\n\n      {/* 목록이 닫히지 않아 변화를 알기 어려우므로 선택 수를 polite 로 알립니다. */}\n      <span className=\"atom-sr-only\" role=\"status\" aria-live=\"polite\">\n        {show ? countText : \"\"}\n      </span>\n      {name !== undefined && value.map((v) => <input key={v} type=\"hidden\" name={name} value={v} />)}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/multi-select.tsx"
    },
    {
      "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"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/multi-select.css",
      "content": "/* ============================================================================\n   Element — Multi Select\n   ----------------------------------------------------------------------------\n   A trigger that opens a listbox of checkable options. Unlike Select the\n   listbox stays open after a pick, so the trigger has to report what is\n   currently chosen — either as removable chips or as a count.\n\n   atom-multi-select\n     --size_{small|medium|large}             medium is the default\n     --variant_{weak|outline}                weak is the default\n     --display_{chip|count}                  chip is the default\n     [data-state=\"closed|open\"]  [data-empty]  [data-invalid]  [data-disabled]\n     __trigger  __value  __placeholder  __caret\n     __chip  __chip-label  __chip-remove  __overflow  __count\n     __listbox  __toolbar  __clear  __group-label\n     __option  __option-box  __option-check  __option-indeterminate\n     __option-icon  __option-text  __option-label  __option-description\n     __empty  __footer\n\n   The option box repeats the Checkbox visual rather than importing it: a card\n   preview loads one stylesheet, and pulling a second file in just for the box\n   would leave the element unusable on its own.\n   ========================================================================== */\n\n.atom-multi-select {\n  position: relative;\n  width: 100%;\n  min-width: 0;\n}\n\n/* --- trigger ------------------------------------------------------------- */\n.atom-multi-select__trigger {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--atom-x3);\n  width: 100%;\n  height: var(--atom-control-h);\n  padding-inline: var(--atom-x3) var(--atom-x4);\n  border-radius: var(--atom-radius-r2);\n  background: var(--atom-bg-neutral-weak);\n  font-size: 15px;\n  color: var(--atom-fg-neutral);\n  text-align: left;\n  transition: box-shadow var(--atom-duration-fast) ease,\n    filter var(--atom-duration-fast) ease;\n}\n\n/* Chips must never push the trigger taller — the row clips and the overflow\n   counter carries the rest. A wrapping trigger reflows the whole form every\n   time a box is ticked. */\n.atom-multi-select__value {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  flex: 1;\n  min-width: 0;\n  overflow: hidden;\n  flex-wrap: nowrap;\n}\n\n.atom-multi-select__placeholder {\n  color: var(--atom-fg-neutral-subtle);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-multi-select__caret {\n  width: 24px;\n  height: 24px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n  transition: transform var(--atom-duration-base) var(--atom-ease-out);\n}\n\n/* --- chips --------------------------------------------------------------- */\n.atom-multi-select__chip {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  min-width: 0;\n  max-width: 140px;\n  padding: 3px 4px 3px 9px;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-layer);\n  box-shadow: inset 0 0 0 1px var(--atom-border-subtle);\n  font-size: 13px;\n  line-height: 1.4;\n  color: var(--atom-fg-neutral);\n}\n\n.atom-multi-select__chip-label {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.atom-multi-select__chip-remove {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 18px;\n  height: 18px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-full);\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease,\n    color var(--atom-duration-fast) ease;\n}\n\n.atom-multi-select__chip-remove svg {\n  width: 12px;\n  height: 12px;\n}\n\n.atom-multi-select__chip-remove:hover {\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral);\n}\n\n.atom-multi-select__overflow {\n  flex-shrink: 0;\n  padding: 3px 8px;\n  border-radius: var(--atom-radius-full);\n  background: var(--atom-bg-neutral-weak);\n  font-size: 12px;\n  font-weight: 600;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-multi-select__count {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n/* --- listbox ------------------------------------------------------------- */\n.atom-multi-select__listbox {\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-multi-select__toolbar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: var(--atom-x3);\n  padding: 4px 10px 8px;\n  border-bottom: 1px solid var(--atom-border-subtle);\n  margin-bottom: 6px;\n}\n\n.atom-multi-select__clear {\n  flex-shrink: 0;\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n  cursor: pointer;\n}\n\n.atom-multi-select__clear:hover {\n  color: var(--atom-fg-neutral);\n}\n\n.atom-multi-select__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-multi-select__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/* --- option checkbox ----------------------------------------------------- */\n.atom-multi-select__option-box {\n  position: relative;\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  border-radius: var(--atom-radius-r1);\n  background: var(--atom-bg-layer);\n  box-shadow: inset 0 0 0 1.5px var(--atom-border-default);\n  transition: background-color var(--atom-duration-fast) ease,\n    box-shadow var(--atom-duration-fast) ease;\n}\n\n.atom-multi-select__option-check,\n.atom-multi-select__option-indeterminate {\n  position: absolute;\n  inset: 0;\n  margin: auto;\n  color: var(--atom-fg-on-solid);\n  opacity: 0;\n}\n\n.atom-multi-select__option-check {\n  width: 14px;\n  height: 14px;\n}\n\n.atom-multi-select__option-indeterminate {\n  width: 10px;\n  height: 2px;\n  border-radius: 1px;\n  background: var(--atom-fg-on-solid);\n}\n\n.atom-multi-select__option-icon {\n  width: 20px;\n  height: 20px;\n  flex-shrink: 0;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-multi-select__option-text {\n  display: flex;\n  flex-direction: column;\n  min-width: 0;\n  flex: 1;\n}\n\n.atom-multi-select__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.atom-multi-select__option-description {\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-multi-select__empty {\n  padding: var(--atom-x5) 10px;\n  text-align: center;\n  font-size: 13px;\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-multi-select__footer {\n  padding: 8px 10px 4px;\n  border-top: 1px solid var(--atom-border-subtle);\n  margin-top: 6px;\n  font-size: 12px;\n  color: var(--atom-fg-neutral-muted);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-multi-select--size_small .atom-multi-select__trigger {\n  height: var(--atom-control-h-small);\n  font-size: 14px;\n}\n\n.atom-multi-select--size_small .atom-multi-select__caret {\n  width: 24px;\n  height: 24px;\n}\n\n.atom-multi-select--size_small .atom-multi-select__chip {\n  padding: 1px 3px 1px 8px;\n  font-size: 12px;\n}\n\n.atom-multi-select--size_large .atom-multi-select__trigger {\n  height: var(--atom-control-h-large);\n  border-radius: var(--atom-radius-r3);\n  font-size: 16px;\n}\n\n.atom-multi-select--size_large .atom-multi-select__chip {\n  font-size: 14px;\n}\n\n/* --- variants ------------------------------------------------------------ */\n.atom-multi-select--variant_outline .atom-multi-select__trigger {\n  background: var(--atom-bg-layer);\n  box-shadow: var(--atom-control-border);\n}\n\n.atom-multi-select--variant_outline .atom-multi-select__chip {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n}\n\n/* --- display ------------------------------------------------------------- */\n/* One markup, two readings: the trigger carries both the chip row and the\n   count, and the modifier decides which one is shown. Swapping display must\n   not mean re-authoring the trigger. */\n/* chip is the default, so the count hides on the base class rather than on\n   --display_chip. Omitting the modifier has to give the documented default,\n   not both readings at once. */\n.atom-multi-select__count {\n  display: none;\n}\n\n.atom-multi-select--display_count .atom-multi-select__count {\n  display: block;\n}\n\n.atom-multi-select--display_count .atom-multi-select__chip,\n.atom-multi-select--display_count .atom-multi-select__overflow {\n  display: none;\n}\n\n.atom-multi-select[data-empty] .atom-multi-select__count,\n.atom-multi-select[data-empty] .atom-multi-select__chip,\n.atom-multi-select[data-empty] .atom-multi-select__overflow {\n  display: none;\n}\n\n.atom-multi-select:not([data-empty]) .atom-multi-select__placeholder {\n  display: none;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-multi-select__trigger:hover {\n  filter: brightness(0.96);\n}\n\n.atom-multi-select[data-state=\"open\"] .atom-multi-select__caret {\n  transform: rotate(180deg);\n}\n\n.atom-multi-select[data-state=\"closed\"] .atom-multi-select__listbox {\n  display: none;\n}\n\n.atom-multi-select__option[aria-selected=\"true\"] .atom-multi-select__option-box,\n.atom-multi-select__option[aria-checked=\"mixed\"] .atom-multi-select__option-box {\n  background: var(--atom-bg-neutral-solid);\n  box-shadow: inset 0 0 0 1.5px var(--atom-bg-neutral-solid);\n}\n\n.atom-multi-select__option[aria-selected=\"true\"]\n  .atom-multi-select__option-check {\n  opacity: 1;\n}\n\n.atom-multi-select__option[aria-checked=\"mixed\"]\n  .atom-multi-select__option-indeterminate {\n  opacity: 1;\n}\n\n.atom-multi-select__option:hover:not([aria-disabled=\"true\"]) {\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-multi-select__option[aria-disabled=\"true\"] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n/* One ring for every control — see the Control surface block in tokens.css.\n   The trigger is the focusable element here (Input hands focus to an inner\n   <input>), so base.css's outset ring is replaced rather than layered: an\n   outset ring with a 2px offset crosses the 4px gap to its neighbour when a\n   field puts this next to another control. Placed above the invalid rule so\n   invalid keeps outranking focus. */\n.atom-multi-select__trigger:focus-visible {\n  outline: none;\n  box-shadow: var(--atom-control-border-focus);\n}\n\n.atom-multi-select[data-invalid] .atom-multi-select__trigger {\n  box-shadow: var(--atom-control-border-invalid);\n}\n\n.atom-multi-select[data-disabled] {\n  opacity: 0.4;\n  pointer-events: none;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/multi-select.css"
    }
  ],
  "categories": [
    "selection"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "component",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "MultiSelect"
    ],
    "docs": "poc/design-system/components/multi-select.md"
  }
}
