민혜린 민혜린 08-13
feat: FoxSelect, FoxSelectOption, FoxSelectOptionItem 추가
@0b184352ce5717a6965f9a74adef288cd295393c
 
@fox/core/components/fox-select-option-item/fox-select-option-item.tsx (added)
+++ @fox/core/components/fox-select-option-item/fox-select-option-item.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxSelectOptionItemProps {
+  /** 옵션 목록 안에서 유일해야 한다 — 목록을 여는 요소의 `aria-activedescendant`가 가리킨다. */
+  id?: string;
+  label: ReactNode;
+  /** 라벨 뒤에 놓인다(시안에 앞 슬롯이 없다). 색은 라벨을 따라간다. */
+  icon?: ReactNode;
+  /** 선택된 항목. `aria-selected`가 붙고 맨 앞에 체크 아이콘이 그려진다. */
+  selected?: boolean;
+  /**
+   * 키보드로 이동 중인 항목. 마우스 hover와 같은 모양이지만 `:hover`로는 표현할 수 없어
+   * 클래스로 둔다. 호출부가 소유한다 — 항목이 스스로 켜지지 않는다.
+   */
+  active?: boolean;
+  /**
+   * 위험한 선택지(시안의 `state=danger`). 글자·아이콘이 danger 색으로 바뀌고, 선택된 뒤에도
+   * 그 색을 유지한다. 시안이 hover·pressed·selected와 한 `state`로 묶어 놨지만 나머지 셋은
+   * 브라우저가 스스로 아는 상태라, 밖에서 받아야 하는 건 이것뿐이다.
+   */
+  danger?: boolean;
+  onSelect?: () => void;
+  /** 포인터가 올라왔을 때. 호출부가 활성 항목을 옮기는 데 쓴다. */
+  onHover?: () => void;
+  /** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */
+  className?: string;
+  ref?: Ref<HTMLLIElement>;
+}
+
+/**
+ * @fox 옵션 목록(`FoxSelectOption`)의 항목 하나. 상태를 갖지 않는다.
+ *
+ * 목록을 여는 요소가 포커스를 계속 쥐고 있어야 하므로 이 항목은 포커스를 받지 않는다 —
+ * 활성 표시는 `aria-activedescendant`가, 클릭은 `onSelect`가 담당한다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxSelectOptionItem({
+  id,
+  label,
+  icon,
+  selected = false,
+  active = false,
+  danger = false,
+  onSelect,
+  onHover,
+  className,
+  ref,
+}: FoxSelectOptionItemProps) {
+  return (
+    <li
+      ref={ref}
+      id={id}
+      role="option"
+      aria-selected={selected}
+      className={cx(
+        "fox-select-option-item",
+        active && "fox-select-option-item--active",
+        danger && "fox-select-option-item--danger",
+        className
+      )}
+      onMouseEnter={onHover}
+      onClick={onSelect}
+    >
+      {selected ? (
+        <span className="fox-select-option-item__check" aria-hidden="true">
+          <CheckIcon />
+        </span>
+      ) : null}
+      <span className="fox-select-option-item__text">{label}</span>
+      {icon ? (
+        <span className="fox-select-option-item__icon" aria-hidden="true">
+          {icon}
+        </span>
+      ) : null}
+    </li>
+  );
+}
+
+// 시안 아이콘. 호출부가 넘기는 콘텐츠가 아니라 디자인이 정한 표시라 컴포넌트가 소유한다.
+// `fill`을 `currentColor`로 두면 선택된 항목의 글자색을 물려받는데, 그 값
+// (`font-theme-secondary`)이 시안의 `#1C589C`와 같아 색 규칙을 따로 둘 필요가 없다.
+function CheckIcon() {
+  return (
+    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
+      <path
+        d="M14.5306 5.03063L6.5306 13.0306C6.46092 13.1006 6.37813 13.156 6.28696 13.1939C6.1958 13.2317 6.09806 13.2512 5.99935 13.2512C5.90064 13.2512 5.8029 13.2317 5.71173 13.1939C5.62057 13.156 5.53778 13.1006 5.4681 13.0306L1.9681 9.53063C1.89833 9.46087 1.84299 9.37804 1.80524 9.28689C1.76748 9.19574 1.74805 9.09804 1.74805 8.99938C1.74805 8.90072 1.76748 8.80302 1.80524 8.71187C1.84299 8.62072 1.89833 8.53789 1.9681 8.46813C2.03786 8.39837 2.12069 8.34302 2.21184 8.30527C2.30299 8.26751 2.40069 8.24808 2.49935 8.24808C2.59801 8.24808 2.69571 8.26751 2.78686 8.30527C2.87801 8.34302 2.96083 8.39837 3.0306 8.46813L5.99997 11.4375L13.4693 3.96938C13.6102 3.82848 13.8013 3.74933 14.0006 3.74933C14.1999 3.74933 14.391 3.82848 14.5318 3.96938C14.6727 4.11028 14.7519 4.30137 14.7519 4.50063C14.7519 4.69989 14.6727 4.89098 14.5318 5.03188L14.5306 5.03063Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
 
@fox/core/components/fox-select-option-item/index.ts (added)
+++ @fox/core/components/fox-select-option-item/index.ts
@@ -0,0 +1,4 @@
+export {
+  FoxSelectOptionItem,
+  type FoxSelectOptionItemProps,
+} from "./fox-select-option-item";
 
@fox/core/components/fox-select-option/fox-select-option.tsx (added)
+++ @fox/core/components/fox-select-option/fox-select-option.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxSelectOptionProps {
+  /** 목록을 여는 요소의 `aria-controls`가 가리킬 값. */
+  id?: string;
+  /** 목록에 이름을 주는 요소의 id — 보통 셀렉트의 라벨이다. */
+  labelledBy?: string;
+  /** `FoxSelectOptionItem`들. */
+  children?: ReactNode;
+  /** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */
+  className?: string;
+  ref?: Ref<HTMLUListElement>;
+}
+
+/**
+ * @fox 셀렉트가 열렸을 때 뜨는 옵션 목록. 배치만 책임지고 상태를 갖지 않는다 —
+ * 열림 여부·활성 항목·선택 값은 전부 목록을 여는 쪽이 소유한다.
+ *
+ * 항목을 눌러도 포커스가 옮겨가지 않게 `mousedown` 기본 동작을 막는다. 콤보박스는 포커스가
+ * 여는 요소에 남아 있어야 `aria-activedescendant`가 성립하고, 막지 않으면 누르는 순간
+ * 포커스가 빠져 목록이 닫히면서 클릭이 성사되지 않는다.
+ *
+ * ⚠️ 스스로 위치를 잡지 못한다 — `position: absolute`라 위치 기준이 되는 조상이 필요하다.
+ * `FoxSelect` 안에서는 `fox-select__control`이 그 역할을 한다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxSelectOption({
+  id,
+  labelledBy,
+  children,
+  className,
+  ref,
+}: FoxSelectOptionProps) {
+  return (
+    <ul
+      ref={ref}
+      id={id}
+      role="listbox"
+      aria-labelledby={labelledBy}
+      className={cx("fox-select-option", className)}
+      onMouseDown={(event) => event.preventDefault()}
+    >
+      {children}
+    </ul>
+  );
+}
 
@fox/core/components/fox-select-option/index.ts (added)
+++ @fox/core/components/fox-select-option/index.ts
@@ -0,0 +1,1 @@
+export { FoxSelectOption, type FoxSelectOptionProps } from "./fox-select-option";
 
@fox/core/components/fox-select/fox-select.tsx (added)
+++ @fox/core/components/fox-select/fox-select.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import {
+  useEffect,
+  useId,
+  useRef,
+  useState,
+  type KeyboardEvent,
+  type ReactNode,
+  type Ref,
+} from "react";
+import { cx } from "../../utils";
+import { FoxSelectOption } from "../fox-select-option";
+import { FoxSelectOptionItem } from "../fox-select-option-item";
+
+export type FoxSelectSize = "sm" | "md" | "lg";
+
+/** 옵션 하나의 데이터. 그리는 일은 `FoxSelectOptionItem`이 한다. */
+export interface FoxSelectItem {
+  value: string;
+  label: ReactNode;
+  /** 옵션 라벨 뒤에 붙는 아이콘. 없으면 텍스트만 보여준다. */
+  icon?: ReactNode;
+  /** 위험한 선택지(시안의 `state=danger`). 글자·아이콘이 danger 색으로 바뀐다. */
+  danger?: boolean;
+}
+
+export interface FoxSelectProps {
+  size: FoxSelectSize;
+  options: FoxSelectItem[];
+  /** 주면 제어 컴포넌트가 된다 — 호출부가 소유하고, select가 스스로 바꾸지 않는다. */
+  value?: string;
+  /**
+   * 비제어로 쓸 때의 초기 값. `value`를 주면 무시된다.
+   *
+   * 버튼 계열과 달리 값 상태를 안에 둘 수 있게 열어 둔 이유는, 폼 한 칸을 놓기 위해
+   * 호출부마다 `useState`를 반복하는 비용이 크기 때문이다(사용자 승인).
+   */
+  defaultValue?: string;
+  /** 값이 바뀔 때 호출된다. 이름이 `onChange`가 아닌 이유는 DOM 이벤트가 아니기 때문이다. */
+  onValueChange?: (value: string) => void;
+  /** 아무 옵션도 선택되지 않았을 때 트리거에 보여줄 문구. */
+  placeholder?: ReactNode;
+  /** Figma 시안의 error 상태. */
+  error?: boolean;
+  disabled?: boolean;
+  /**
+   * Figma 시안의 view(조회 전용) 상태. 값은 보이되 바꿀 수 없다 — 내부적으로는
+   * `disabled`를 걸되, disabled(입력 불가 안내)와 시각적으로 구분해 보여준다.
+   */
+  readOnly?: boolean;
+  /** 필드 위 라벨. 넘기지 않으면 라벨을 렌더링하지 않는다. */
+  label?: ReactNode;
+  /** select 아래 힌트 메시지. 넘기지 않으면 힌트 영역을 렌더링하지 않는다. */
+  hint?: ReactNode;
+  /** 힌트 메시지 앞에 붙는 아이콘. `hint`가 없으면 의미 없다. */
+  hintIcon?: ReactNode;
+  id?: string;
+  /** 배치 조정용. 모양이 달라야 하면 여기 말고 모디파이어를 추가한다. */
+  className?: string;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 포인터가 올라오면 `true`, 벗어나면 `false`. */
+  onHoverChange?: (hovered: boolean) => void;
+  /** 트리거 버튼을 가리킨다 — 호출부가 포커스를 옮길 때 쓴다. */
+  ref?: Ref<HTMLButtonElement>;
+}
+
+const SIZE_CLASS: Record<FoxSelectSize, string> = {
+  sm: "fox-select--sm",
+  md: "fox-select--md",
+  lg: "fox-select--lg",
+};
+
+/**
+ * @fox 커스텀 드롭다운. 네이티브 `<select>`는 옵션 목록 자체를 브라우저가 그려서
+ * 스타일링할 수 없기 때문에, 버튼(트리거) + 리스트박스로 직접 구현한다.
+ *
+ * 버튼 계열과 달리 상태를 갖는다 — 열림 여부와, 비제어로 쓸 때의 값이다. `value`를 주면
+ * 값은 호출부가 소유하고 열림 여부만 안에 남는다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxSelect({
+  size,
+  options,
+  value,
+  defaultValue,
+  onValueChange,
+  placeholder = "선택해주세요",
+  error,
+  disabled = false,
+  readOnly = false,
+  label,
+  hint,
+  hintIcon,
+  id,
+  className,
+  hidden = false,
+  onHoverChange,
+  ref,
+}: FoxSelectProps) {
+  const isDisabled = disabled || readOnly;
+  const [open, setOpen] = useState(false);
+  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
+  const [activeIndex, setActiveIndex] = useState(-1);
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  const currentValue = value ?? uncontrolledValue;
+  const selectedIndex = options.findIndex((option) => option.value === currentValue);
+  const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined;
+
+  const generatedId = useId();
+  const triggerId = id ?? generatedId;
+  const listboxId = `${triggerId}-listbox`;
+  const labelId = `${triggerId}-label`;
+  // 값이 아니라 순번으로 만든다 — 옵션 값에 공백·특수문자가 들어와도 id가 깨지지 않는다.
+  const optionId = (index: number) => `${triggerId}-opt-${index}`;
+
+  useEffect(() => {
+    if (!open) return;
+    function handlePointerDown(event: PointerEvent) {
+      if (!rootRef.current?.contains(event.target as Node)) {
+        setOpen(false);
+      }
+    }
+    // 포인터만 보면 Tab으로 빠져나갈 때 리스트가 열린 채 남는다.
+    //
+    // 포커스가 아무 데도 가지 않은 경우(relatedTarget이 null)는 여기서 닫지 않는다 —
+    // 바깥의 포커스 못 받는 곳을 누른 것이고, 그건 위 pointerdown이 이미 처리한다.
+    // 이걸 닫기로 치면 옵션을 누르는 순간에도 닫혀 클릭이 성사되지 않는다.
+    function handleFocusOut(event: FocusEvent) {
+      const next = event.relatedTarget as Node | null;
+      if (next && !rootRef.current?.contains(next)) {
+        setOpen(false);
+      }
+    }
+    document.addEventListener("pointerdown", handlePointerDown);
+    const root = rootRef.current;
+    root?.addEventListener("focusout", handleFocusOut);
+    return () => {
+      document.removeEventListener("pointerdown", handlePointerDown);
+      root?.removeEventListener("focusout", handleFocusOut);
+    };
+  }, [open]);
+
+  function openList() {
+    setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
+    setOpen(true);
+  }
+
+  function commit(next: string) {
+    // 제어 컴포넌트일 때는 내부 값을 건드리지 않는다 — 건드리면 호출부가 값을 거부해도
+    // 내부에만 반영되어 둘이 조용히 갈라진다.
+    if (value === undefined) setUncontrolledValue(next);
+    onValueChange?.(next);
+    setOpen(false);
+  }
+
+  function moveActive(delta: number) {
+    setActiveIndex((current) => {
+      const count = options.length;
+      if (count === 0) return current;
+      return (current + delta + count) % count;
+    });
+  }
+
+  function handleTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
+    if (isDisabled) return;
+
+    if (!open) {
+      if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
+        event.preventDefault();
+        openList();
+      }
+      return;
+    }
+
+    switch (event.key) {
+      case "ArrowDown":
+        event.preventDefault();
+        moveActive(1);
+        break;
+      case "ArrowUp":
+        event.preventDefault();
+        moveActive(-1);
+        break;
+      case "Home":
+        event.preventDefault();
+        setActiveIndex(0);
+        break;
+      case "End":
+        event.preventDefault();
+        setActiveIndex(options.length - 1);
+        break;
+      case "Enter":
+      case " ":
+        event.preventDefault();
+        if (activeIndex >= 0) {
+          commit(options[activeIndex].value);
+        }
+        break;
+      case "Escape":
+        event.preventDefault();
+        setOpen(false);
+        break;
+      default:
+        break;
+    }
+  }
+
+  // 훅을 전부 부른 뒤에 빠져나간다 — 위쪽에서 반환하면 훅 호출 순서가 깨진다.
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={rootRef}
+      className={cx("fox-select", SIZE_CLASS[size], className)}
+      onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
+      onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
+    >
+      {label ? (
+        <label id={labelId} htmlFor={triggerId} className="fox-select__label">
+          {label}
+        </label>
+      ) : null}
+
+      <div className="fox-select__control">
+        <button
+          ref={ref}
+          type="button"
+          id={triggerId}
+          role="combobox"
+          disabled={isDisabled}
+          aria-haspopup="listbox"
+          aria-expanded={open}
+          // 리스트박스는 열렸을 때만 렌더되므로, 닫힌 동안 없는 id를 가리키지 않게 한다.
+          aria-controls={open ? listboxId : undefined}
+          // 화살표로 옮겨 다니는 옵션을 보조기술에 알린다. 이게 없으면 `--active` 강조가
+          // 시각 전용이라 스크린리더 사용자는 이동을 알 수 없다.
+          aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
+          aria-invalid={error || undefined}
+          aria-readonly={readOnly || undefined}
+          className={cx(
+            "fox-select__trigger",
+            selectedOption && "fox-select__trigger--completed"
+          )}
+          onClick={() => (open ? setOpen(false) : openList())}
+          onKeyDown={handleTriggerKeyDown}
+        >
+          <span className="fox-select__value">{selectedOption?.label ?? placeholder}</span>
+          <ChevronIcon className="fox-select__icon" />
+        </button>
+
+        {open ? (
+          <FoxSelectOption id={listboxId} labelledBy={label ? labelId : undefined}>
+            {options.map((option, index) => (
+              <FoxSelectOptionItem
+                key={option.value}
+                id={optionId(index)}
+                label={option.label}
+                icon={option.icon}
+                danger={option.danger}
+                selected={option.value === currentValue}
+                active={index === activeIndex}
+                onHover={() => setActiveIndex(index)}
+                onSelect={() => commit(option.value)}
+              />
+            ))}
+          </FoxSelectOption>
+        ) : null}
+      </div>
+
+      {hint ? (
+        <div className="fox-select__hint">
+          {hintIcon ? (
+            <span className="fox-select__hint-icon" aria-hidden="true">
+              {hintIcon}
+            </span>
+          ) : null}
+          <span className="fox-select__hint-message">{hint}</span>
+        </div>
+      ) : null}
+    </div>
+  );
+}
+
+// 시안 아이콘. viewBox가 24×24 정사각형이고 화살표가 그 안에 여백을 두고 들어 있다 —
+// 여백이 SVG에 내장돼 있어 슬롯이 커지고 작아질 때 여백도 같이 비례한다(CSS 패딩 불필요).
+// `fill`은 시안의 리터럴 대신 `currentColor`로 두어 색을 토큰이 정한다.
+function ChevronIcon({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
+      <path
+        d="M20.296 9.79595L12.796 17.2959C12.6914 17.4008 12.5672 17.484 12.4305 17.5408C12.2938 17.5976 12.1471 17.6268 11.9991 17.6268C11.851 17.6268 11.7044 17.5976 11.5677 17.5408C11.4309 17.484 11.3067 17.4008 11.2022 17.2959L3.70221 9.79595C3.49086 9.5846 3.37213 9.29796 3.37213 8.99907C3.37213 8.70019 3.49086 8.41354 3.70221 8.2022C3.91355 7.99085 4.2002 7.87212 4.49908 7.87212C4.79797 7.87212 5.08461 7.99085 5.29596 8.2022L12 14.9063L18.7041 8.20126C18.9154 7.98992 19.2021 7.87119 19.501 7.87119C19.7998 7.87119 20.0865 7.98992 20.2978 8.20126C20.5092 8.41261 20.6279 8.69925 20.6279 8.99814C20.6279 9.29702 20.5092 9.58367 20.2978 9.79501L20.296 9.79595Z"
+        fill="currentColor"
+      />
+    </svg>
+  );
+}
 
@fox/core/components/fox-select/index.ts (added)
+++ @fox/core/components/fox-select/index.ts
@@ -0,0 +1,6 @@
+export {
+  FoxSelect,
+  type FoxSelectProps,
+  type FoxSelectSize,
+  type FoxSelectItem,
+} from "./fox-select";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -4,3 +4,6 @@
 export * from "./fox-icon-button";
 export * from "./fox-button-group";
 export * from "./fox-button-panel";
+export * from "./fox-select";
+export * from "./fox-select-option";
+export * from "./fox-select-option-item";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -1,12 +1,15 @@
 "use client";
 
-import { useState, type ReactNode } from "react";
+import { useState, type CSSProperties, type ReactNode } from "react";
 import { FoxButton } from "../core/components/fox-button";
 import { FoxTextButton } from "../core/components/fox-text-button";
 import { FoxLinkButton } from "../core/components/fox-link-button";
 import { FoxIconButton } from "../core/components/fox-icon-button";
 import { FoxButtonGroup } from "../core/components/fox-button-group";
 import { FoxButtonPanel } from "../core/components/fox-button-panel";
+import { FoxSelect, type FoxSelectItem } from "../core/components/fox-select";
+import { FoxSelectOption } from "../core/components/fox-select-option";
+import { FoxSelectOptionItem } from "../core/components/fox-select-option-item";
 import styles from "./dev-test.module.scss";
 
 export interface ComponentExample {
@@ -18,10 +21,18 @@
   variants: { label: string; node: ReactNode }[];
 }
 
-// 자리 확인용 도형. `currentColor`로 그려야 계열별 색이 입혀진다.
-function DemoIcon() {
+// 자리 확인용 도형. `currentColor`로 그려야 계열별 색이 입혀진다. style은 FoxSelect
+// 힌트 아이콘처럼 크기를 줄여 써야 하는 자리에 쓴다.
+function DemoIcon({ style }: { style?: CSSProperties } = {}) {
   return (
-    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
+    <svg
+      viewBox="0 0 16 16"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="2"
+      aria-hidden="true"
+      style={style}
+    >
       <path d="M6 3l5 5-5 5" />
     </svg>
   );
@@ -87,6 +98,20 @@
     </>
   );
 }
+
+const SELECT_ITEMS: FoxSelectItem[] = [
+  { value: "a", label: "옵션 A" },
+  { value: "b", label: "옵션 B" },
+  { value: "c", label: "옵션 C" },
+];
+
+// 옵션 아이콘은 별도 목록으로 둔다 — 위 목록에 섞으면 크기·상태 예제가 전부 같이 바뀌어
+// 무엇을 보는 예제인지 흐려진다. 아이콘은 시안 미정이라 자리 확인용 도형이다.
+const SELECT_ITEMS_RICH: FoxSelectItem[] = [
+  { value: "a", label: "아이콘 있는 옵션", icon: <DemoIcon /> },
+  { value: "b", label: "아이콘 없는 옵션" },
+  { value: "c", label: "아주 긴 옵션 라벨 — 트리거에서는 말줄임으로 잘린다" },
+];
 
 /** 컴포넌트를 만들 때마다 항목 하나를 추가한다 — 다른 파일은 고치지 않는다. */
 export const COMPONENT_EXAMPLES: ComponentExample[] = [
@@ -513,4 +538,154 @@
       },
     ],
   },
+  {
+    id: "fox-select",
+    name: "FoxSelect",
+    description:
+      "Figma 시안(select, 컴포넌트 세트 111:272). 네이티브 <select>가 아니라 트리거 버튼 + 리스트박스라 옵션 목록까지 시안대로 그립니다. 상태는 전부 ARIA 속성이 스타일 훅을 겸합니다 — 열림·오류·조회 전용·옵션 선택에 별도 클래스가 없습니다.",
+    variants: [
+      {
+        label: "크기 (size)",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+          </div>
+        ),
+      },
+      {
+        label: "완료(값 선택됨)",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+          </div>
+        ),
+      },
+      {
+        label: "error",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} error hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+          </div>
+        ),
+      },
+      {
+        label: "disabled",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} disabled hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+          </div>
+        ),
+      },
+      {
+        label: "view (조회 전용)",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="lg" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="md" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+            <FoxSelect size="sm" label="레이블" options={SELECT_ITEMS} defaultValue="a" readOnly hint="힌트 메시지" hintIcon={<DemoIcon style={{ width: 12, height: 12 }} />} />
+          </div>
+        ),
+      },
+      {
+        label: "옵션 아이콘(맨 뒤) · 긴 라벨 — 열어서 확인한다",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="md" label="레이블" options={SELECT_ITEMS_RICH} />
+          </div>
+        ),
+      },
+      {
+        label: "라벨 없음 · 힌트 없음 — 해당 영역이 렌더되지 않는다",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="md" options={SELECT_ITEMS} />
+            <FoxSelect size="md" label="라벨만" options={SELECT_ITEMS} />
+          </div>
+        ),
+      },
+      {
+        label: "placeholder — 기본값은 “선택해주세요”",
+        node: (
+          <div className={styles.demoFields}>
+            <FoxSelect size="md" label="기본" options={SELECT_ITEMS} />
+            <FoxSelect size="md" label="직접 지정" options={SELECT_ITEMS} placeholder="등급을 고르세요" />
+          </div>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-select-option",
+    name: "FoxSelectOption",
+    description:
+      "Figma 시안(select, 컴포넌트 세트 732:5879) FoxSelect가 열렸을 때 뜨는 옵션 목록입니다. 셀렉트에서 떼어내 단독으로 올려두었습니다. 목록은 position: absolute라 위치 기준이 되는 조상이 필요합니다",
+    variants: [
+      {
+        label: "목록",
+        node: (
+          <div className={styles.demoListboxStage}>
+            <FoxSelectOption>
+              <FoxSelectOptionItem label="셀렉트 옵션" />
+              <FoxSelectOptionItem label="셀렉트 옵션" />
+              <FoxSelectOptionItem label="셀렉트 옵션" />
+              <FoxSelectOptionItem label="셀렉트 옵션" />
+            </FoxSelectOption>
+          </div>
+        ),
+      },
+      {
+        label: "스크롤 — 항목이 많으면 240px에서 스크롤된다 (⚠️ 높이 미확정)",
+        node: (
+          <div className={styles.demoListboxStage}>
+            <FoxSelectOption>
+              {Array.from({ length: 12 }, (_, i) => (
+                <FoxSelectOptionItem key={i} label={`항목 ${i + 1}`} selected={i === 2} />
+              ))}
+            </FoxSelectOption>
+          </div>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-select-option-item",
+    name: "FoxSelectOptionItem",
+    description:
+      "Figma 시안(select, 컴포넌트 세트 251:396) 옵션 목록의 항목 하나입니다. 선택 여부는 aria-selected가 스타일 훅을 겸하고, 키보드로 이동 중인 항목만 네이티브 대응이 없어 --active 클래스를 씁니다. 항목은 포커스를 받지 않습니다 — 콤보박스는 포커스가 트리거에 남아야 aria-activedescendant가 성립하기 때문입니다. 눌림(pressed)은 마우스로 누르고 있는 동안 보입니다.",
+    variants: [
+      {
+        label: "상태 — 기본 / 선택됨 / hover / pressed 클릭",
+        node: (
+          <div className={styles.demoListboxStage}>
+            <FoxSelectOption>
+              <FoxSelectOptionItem label="셀렉트 옵션" />
+              <FoxSelectOptionItem label="셀렉트 옵션-selected" selected />
+              <FoxSelectOptionItem label="셀렉트 옵션-hover" active />
+              <FoxSelectOptionItem label="셀렉트 옵션-pressed (클릭으로 확인)" />
+              <FoxSelectOptionItem label="셀렉트 옵션-danger" danger />
+            </FoxSelectOption>
+          </div>
+        ),
+      },
+      {
+        label: "아이콘 — 체크는 맨 앞(선택됐을 때만), 아이콘은 맨 뒤",
+        node: (
+          <div className={styles.demoListboxStage}>
+            <FoxSelectOption>
+              <FoxSelectOptionItem label="셀렉트 옵션" icon={<DemoIcon />} />
+              <FoxSelectOptionItem label="셀렉트 옵션-selected" selected icon={<DemoIcon />} />
+            </FoxSelectOption>
+          </div>
+        ),
+      },
+    ],
+  },
 ];
@fox/dev-test/dev-test.module.scss
--- @fox/dev-test/dev-test.module.scss
+++ @fox/dev-test/dev-test.module.scss
@@ -431,6 +431,32 @@
   inline-size: 100%;
 }
 
+// 폼 컨트롤 예제용. FoxSelect는 폭을 부모가 정하므로 무대가 폭을 줘야 크기 비교가 된다.
+// 칸 너비는 디자인 값이 아니라 데모용 지면이라 토큰을 두지 않는다.
+.demoFields {
+  display: flex;
+  flex-wrap: wrap;
+  gap: fox.gap(5);
+  align-items: start;
+  inline-size: 100%;
+
+  > * {
+    inline-size: 28rem;
+    max-inline-size: 100%;
+  }
+}
+
+// 옵션 목록 단독 예제용. 목록이 `position: absolute`라 위치 기준이 필요하고, 자기 높이를
+// 차지하지 않으므로 아래쪽 여백으로 펼쳐질 자리를 만들어 준다. 높이 0이면 목록이 무대
+// 맨 위에서 시작한다. 치수는 데모용 지면이라 토큰을 두지 않는다.
+.demoListboxStage {
+  position: relative;
+  inline-size: 28rem;
+  max-inline-size: 100%;
+  block-size: 0;
+  margin-block-end: 22rem;
+}
+
 // wrap 예제는 자식이 확실히 넘치도록 좁아야 한다.
 .demoNarrow {
   padding: fox.padding(3);
 
@fox/styles/_fox-select-option-item.scss (added)
+++ @fox/styles/_fox-select-option-item.scss
@@ -0,0 +1,107 @@
+// FoxSelectOptionItem — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma select (컴포넌트 세트 111:272)
+//
+// 옵션 목록(`fox-select-option`)의 항목 하나다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <li class="fox-select-option-item" id="sel-opt-0" role="option" aria-selected="true">
+//     <span class="fox-select-option-item__check">…svg…</span>  <!-- 선택됐을 때만, 맨 앞 -->
+//     <span class="fox-select-option-item__text">…</span>
+//     <span class="fox-select-option-item__icon">…svg…</span>   <!-- 선택, 맨 뒤 -->
+//   </li>
+//
+// 위험한 선택지에는 `fox-select-option-item--danger`를 더한다.
+//
+// 선택 여부는 `aria-selected="true"`가 스타일 훅을 겸한다 — 상태용 클래스를 만들지 않는다.
+// 체크 아이콘은 `currentColor`라 선택된 항목의 글자색을 물려받는다. 그 색이 시안의 아이콘
+// 색(`#1C589C`)과 같아 별도 색 규칙이 없다.
+//
+// 시안은 hover·pressed·selected·danger를 모두 `state` 하나로 묶어 놨지만, 앞의 셋은
+// 브라우저가 스스로 아는 상태다(`--active`·`:active`·`aria-selected`). 밖에서 지정해야 하는
+// 건 danger뿐이라 그것만 클래스로 받는다.
+//
+// 네이티브 대응이 없어 클래스로 두는 둘:
+//   - `--active`: 키보드로 이동 중인 항목. 마우스 hover와 같은 모양이어야 하는데 `:hover`로는
+//     표현할 수 없다. 리스트박스를 여는 요소의 `aria-activedescendant`가 이 항목을 가리킨다.
+//   - `--danger`: 위험한 선택지. 값의 성격이라 브라우저도 ARIA도 알 방법이 없다.
+//
+// `__text`가 남는 폭을 먹어서 체크는 왼쪽 끝, 아이콘은 오른쪽 끝에 붙는다.
+//
+// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목):
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다.
+//   - cursor / flex-shrink: 구조·상호작용 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-select-option-item {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(3);
+  padding: fox.padding(4) fox.form(padding-sm);
+  border-radius: fox.form(radius-sm);
+  background: fox.color(action-secondary);
+  color: fox.color(font-neutral-default);
+  font-family: fox.font-family(body);
+  font-weight: fox.font-weight(regular);
+  font-size: fox.font-size(body-sm);
+  // line-height 150% / letter-spacing -0.375px — Figma 텍스트 스타일 값이며 변수로
+  // export되지 않아 토큰이 없다(fox-button의 label/*-strong 사례와 같은 예외, 사용자 승인).
+  line-height: 1.5;
+  letter-spacing: -0.375px;
+  cursor: pointer;
+
+  // 키보드 화살표로 이동한 항목도 마우스 호버와 같은 모양이어야 하므로, 실제 `:hover`
+  // 대신 JS로 추적하는 active 모디파이어를 쓴다.
+  &--active {
+    background: fox.color(action-secondary-hover);
+  }
+
+  &:active {
+    background: fox.color(action-secondary-pressed);
+  }
+
+  &[aria-selected="true"] {
+    background: fox.color(action-secondary-selected);
+    color: fox.color(font-theme-secondary);
+  }
+
+  // danger(위험한 선택지). 배경·눌림은 다른 항목과 같고 글자·아이콘 색만 다르다.
+  //
+  // 선택돼도 색을 유지한다 — 위험한 걸 고른 직후가 그 사실을 가장 알려야 할 때다. 다만
+  // `aria-selected` 규칙과 특이도가 같아 그것만으로는 지지 않으므로, 선택자를 겹쳐 쓴
+  // 두 번째 줄이 선택된 danger 항목까지 잡는다.
+  &--danger,
+  &--danger[aria-selected="true"] {
+    color: fox.color(font-system-danger);
+
+    // 아이콘 계열은 토큰이 따로다 — 라이트 모드에서 글자(danger-60)보다 한 단계
+    // 밝다(danger-50). 힌트 메시지에서 쓰는 짝과 같다.
+    .fox-select-option-item__icon,
+    .fox-select-option-item__check {
+      color: fox.color(icon-system-danger);
+    }
+  }
+
+  &__text {
+    flex: 1;
+  }
+
+  // 시안이 체크 아이콘에 `icon-3`(16px)을 건다. 아이콘 viewBox도 16×16 정사각형이라
+  // 슬롯을 그대로 채운다.
+  &__icon,
+  &__check {
+    display: inline-flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    inline-size: fox.icon(3);
+    block-size: fox.icon(3);
+  }
+
+  // 슬롯이 크기를 정하고 안의 SVG는 그 상자를 채운다. 비율이 다른 아이콘이 들어와도
+  // `preserveAspectRatio` 기본값이 비율을 지키며 축소해 가운데 놓는다.
+  &__icon > *,
+  &__check > * {
+    inline-size: 100%;
+    block-size: 100%;
+  }
+}
 
@fox/styles/_fox-select-option.scss (added)
+++ @fox/styles/_fox-select-option.scss
@@ -0,0 +1,51 @@
+// FoxSelectOption — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma select (컴포넌트 세트 111:272)
+//
+// 셀렉트가 열렸을 때 뜨는 옵션 목록(리스트박스)이다. 항목 하나는 `fox-select-option-item`이
+// 담당한다 — 이 파일은 떠 있는 판때기(위치·테두리·그림자·스크롤)만 책임진다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <ul class="fox-select-option" id="sel-listbox" role="listbox">
+//     <li class="fox-select-option-item" …>…</li>
+//   </ul>
+//
+// ⚠️ 스스로 위치를 잡지 못한다 — `position: absolute`라서 **위치 기준이 되는 조상**
+// (`position: relative` 등)이 있어야 한다. `fox-select` 안에서는 `fox-select__control`이
+// 그 역할을 한다. 단독으로 쓸 때는 감싸는 쪽이 기준을 만들어야 한다.
+//
+// ⚠️ 그 대가로 `overflow: hidden`인 조상 안에서는 잘린다(테이블 셀·모달·스크롤 영역).
+// 그런 자리에 놓아야 하면 `popover` 속성으로 top layer에 올려야 한다 — DOM 위치는 그대로라
+// ARIA 연결과 이벤트 버블링은 유지되고, 대신 기준 요소와의 위치를 anchor positioning이나
+// 좌표 계산으로 다시 잡아야 한다. React 포털은 이 패키지에선 쓸 수 없다(프레임워크 무관).
+//
+// 크기(`size`)에 반응하지 않는다 — 어느 크기에서 열려도 목록은 같은 모양이어야 한다.
+//
+// ⚠️ TODO: 스크롤 높이(`max-block-size: 24rem` = 240px)는 아직 확정이 아니다. 옵션이 많을
+// 때의 상한이 필요해 임시로 잡은 값이고, 시안이 변수로 바인딩하지 않았으며 토큰 스케일에도
+// 240이 없다. 확정되면 값을 갈고 — 토큰이 생기면 `fox.*()`로 바꾸고 — 아래 승인 목록에서
+// 이 항목을 뺀다.
+//
+// 토큰이 아닌 값(사용자 승인):
+//   - `max-block-size: 24rem`(240px): 위 TODO 참고 — 확정 전까지만 유효하다.
+//   - overflow / list-style / z-index: 구조 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-select-option {
+  position: absolute;
+  inset-inline: 0;
+  inset-block-start: calc(100% + #{fox.gap(2)});
+  z-index: 10;
+  margin: 0;
+  padding: fox.padding(3);
+  // ⚠️ 확정 전 임시값 — 파일 상단 TODO 참고.
+  max-block-size: 24rem;
+  overflow-y: auto;
+  list-style: none;
+  border: fox.border(1) solid fox.color(border-neutral-default);
+  border-radius: fox.form(radius-md);
+  background: fox.color(surface-neutral-default);
+  // 시안이 그림자 색을 테마 토큰이 아니라 `alpha/black10` 원시값에 직접 물려 놓았다.
+  // 그래서 다크 모드에서도 검은 그림자다 — 반전시키려면 `alpha-default-inverse10`이다.
+  box-shadow: fox.shadow(location1) fox.shadow(location1) fox.shadow(blur1) 0
+    fox.primitive(alpha-black10);
+}
 
@fox/styles/_fox-select.scss (added)
+++ @fox/styles/_fox-select.scss
@@ -0,0 +1,220 @@
+// FoxSelect — 시안: 통합관리자페이지 디자인시스템(KE9UILWhB8qTBe14uXUn4F) Figma select (컴포넌트 세트 111:272)
+//
+// 네이티브 `<select>`가 아니라 버튼(트리거) + 리스트박스 커스텀 드롭다운이다. 옵션 목록
+// 자체를 스타일링하려면 브라우저가 그리는 네이티브 팝업으로는 불가능하다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-select fox-select--md">
+//     <label class="fox-select__label" for="sel">라벨</label>        <!-- 선택 -->
+//     <div class="fox-select__control">                             <!-- 리스트박스 위치 기준 -->
+//       <button class="fox-select__trigger fox-select__trigger--completed" id="sel"
+//               type="button" role="combobox" aria-haspopup="listbox"
+//               aria-expanded="true" aria-controls="sel-listbox"
+//               aria-activedescendant="sel-opt-0">
+//         <span class="fox-select__value">선택된 값 또는 플레이스홀더</span>
+//         <svg class="fox-select__icon">…svg…</svg>
+//       </button>
+//       <ul class="fox-select-option" id="sel-listbox" role="listbox">   <!-- 열렸을 때만 -->
+//         <li class="fox-select-option-item" id="sel-opt-0" role="option">…</li>
+//       </ul>
+//     </div>
+//     <div class="fox-select__hint">…</div>                          <!-- 선택 -->
+//   </div>
+//
+// 옵션 목록은 이 파일이 아니라 `fox-select-option`(판때기)과 `fox-select-option-item`
+// (항목)이 소유한다. 각자의 마크업 계약은 그 파일 상단에 있다.
+//
+// 상태는 네이티브·ARIA 속성이 스타일 훅을 겸한다 — 상태용 클래스를 만들지 않는다.
+//   - 트리거: 비활성 `disabled` · 조회 전용 `disabled aria-readonly="true"`
+//     · 오류 `aria-invalid="true"` · 열림 `aria-expanded="true"`
+//
+// 네이티브 대응이 없어 클래스로 두는 하나:
+//   - `fox-select__trigger--completed`: 값이 선택된 상태. 미선택(플레이스홀더) 글자색과 구분한다.
+//
+// `fox-select__control`이 옵션 목록의 위치 기준이다(`position: relative`). `fox-select`
+// 전체를 기준으로 삼으면 힌트 메시지가 있을 때 목록이 힌트 아래로 밀려 그걸 가리게 된다.
+//
+// 폭은 정하지 않는다 — 부모가 정한다. 높이만 `size`가 정한다.
+//
+// 라벨·옵션·힌트는 `size`와 무관하게 공통 스타일이다. 크기 모디파이어는 트리거와 트리거
+// 아이콘만 바꾼다 — 드롭다운 목록은 어느 크기에서 열려도 같은 서체로 읽혀야 한다.
+//
+// 토큰이 아닌 값(FoxButton과 동일한 근거로 승인된 항목):
+//   - line-height / letter-spacing: 시안 텍스트 스타일의 값이나 Figma가 변수로 export하지 않는다.
+//   - box-sizing / cursor / overflow: 구조·상호작용 규칙.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-select {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(4);
+
+  &__label {
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(label-md);
+    font-weight: fox.font-weight(medium);
+  }
+
+  &__control {
+    position: relative;
+  }
+
+  &__trigger {
+    // 포커스 시 테두리가 1px → 2px로 두꺼워져도 높이가 흔들리지 않게 한다
+    // (fox-button의 같은 이유 — box-sizing/_fox-button.scss 주석 참고).
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    gap: fox.gap(3);
+    inline-size: 100%;
+    border: fox.border(1) solid fox.color(input-border);
+    background: fox.color(input-surface);
+    // 미선택(플레이스홀더) 상태의 기본 글자색. `&--completed`가 덮어쓴다.
+    color: fox.color(font-neutral-subtler);
+    font-family: fox.font-family(body);
+    cursor: pointer;
+
+    // `:focus-visible`는 마우스 클릭으론 안 붙는다(브라우저 기본 동작). 열려 있는 동안은
+    // 클릭으로 열었어도 같은 포커스 모양이어야 하므로 `aria-expanded`도 같이 건다.
+    &:focus-visible,
+    &[aria-expanded="true"] {
+      border: fox.border(2) solid fox.color(input-border-active);
+      color: fox.color(font-neutral-strong);
+    }
+
+    // 포커스 표시는 위 테두리가 맡으므로 브라우저 기본 링을 걷어낸다 — 그대로 두면 시안에
+    // 없는 검은 링이 테두리 위에 겹쳐 그려진다.
+    //
+    // `none`이 아니라 투명 아웃라인인 이유: 강제 색상 모드(Windows 고대비)는 테두리 색을
+    // 시스템 색으로 덮어써 파란 테두리가 사라진다. 투명 아웃라인은 그 모드에서만 실제 링으로
+    // 그려져 포커스 표시가 남는다.
+    &:focus-visible {
+      outline: fox.border(2) solid transparent;
+      outline-offset: fox.border(1);
+    }
+
+    &:disabled {
+      cursor: not-allowed;
+    }
+  }
+
+  &__trigger--completed {
+    color: fox.color(font-neutral-strong);
+  }
+
+  &__trigger[aria-invalid="true"] {
+    border: fox.border(2) solid fox.color(input-border-error);
+  }
+
+  &__value {
+    flex: 1;
+    overflow: hidden;
+    text-align: start;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  // 슬롯은 `form-icon-*` 정사각형이고 아이콘 viewBox도 24×24 정사각형이라 그대로 채운다.
+  // 화살표 주변 여백은 SVG 안에 들어 있으므로 CSS 패딩을 따로 주지 않는다 — 그래야 크기가
+  // 바뀔 때 여백도 함께 비례하고, 토큰에 없는 패딩 값을 지어내지 않아도 된다.
+  &__icon {
+    flex-shrink: 0;
+    color: fox.color(icon-neutral-default);
+  }
+
+  &__hint {
+    display: flex;
+    align-items: center;
+    gap: fox.gap(3);
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-weight: fox.font-weight(regular);
+    font-size: fox.font-size(body-sm);
+    // line-height 150% / letter-spacing -0.325px — Figma 텍스트 스타일 값이며 변수로
+    // export되지 않아 토큰이 없다(fox-button의 label/*-strong 사례와 같은 예외, 사용자 승인).
+    line-height: 1.5;
+    letter-spacing: -0.325px;
+  }
+
+  // 힌트 색은 트리거의 `aria-invalid`를 따라간다 — 오류 표시를 트리거 한 곳에만 걸면
+  // React를 안 쓰는 소비자가 힌트에 따로 클래스를 달지 않아도 같은 결과를 얻는다.
+  &:has(.fox-select__trigger[aria-invalid="true"]) {
+    .fox-select__hint-message {
+      color: fox.color(font-system-danger);
+    }
+
+    .fox-select__hint-icon {
+      color: fox.color(icon-system-danger);
+    }
+  }
+
+  &__hint-icon {
+    display: inline-flex;
+    flex-shrink: 0;
+  }
+
+  // ── 크기 ──────────────────────────────────────────────────────────────────
+  &--lg {
+    .fox-select__trigger {
+      block-size: fox.form(height-lg);
+      padding-inline: fox.form(padding-lg);
+      border-radius: fox.form(radius-lg);
+      font-size: fox.font-size(body-lg);
+    }
+
+    .fox-select__icon {
+      inline-size: fox.form(icon-lg);
+      block-size: fox.form(icon-lg);
+    }
+  }
+
+  &--md {
+    .fox-select__trigger {
+      block-size: fox.form(height-md);
+      padding-inline: fox.form(padding-md);
+      border-radius: fox.form(radius-md);
+      font-size: fox.font-size(body-md);
+    }
+
+    .fox-select__icon {
+      inline-size: fox.form(icon-md);
+      block-size: fox.form(icon-md);
+    }
+  }
+
+  &--sm {
+    .fox-select__trigger {
+      block-size: fox.form(height-sm);
+      padding-inline: fox.form(padding-sm);
+      border-radius: fox.form(radius-sm);
+      font-size: fox.font-size(body-sm);
+    }
+
+    .fox-select__icon {
+      inline-size: fox.form(icon-sm);
+      block-size: fox.form(icon-sm);
+    }
+  }
+}
+
+// 비활성 외형. `aria-invalid`(오류)와 특이도가 같아 블록 뒤에 두어야 이긴다 —
+// 오류 상태에서 비활성이 되면 오류 테두리가 아니라 비활성 테두리가 보여야 한다.
+.fox-select__trigger:disabled {
+  border: fox.border(1) solid fox.color(input-border-disabled);
+  background: fox.color(input-surface-disabled);
+  color: fox.color(font-neutral-disabled-strong);
+}
+
+// 아이콘은 비활성·조회 전용 둘 다 같은 색이다. 두 상태 모두 트리거에 `disabled`가 붙으므로
+// 규칙 하나로 끝난다 — 아래 view 규칙이 글자색만 되돌리고 아이콘은 건드리지 않는다.
+.fox-select__trigger:disabled .fox-select__icon {
+  color: fox.color(icon-neutral-disabled);
+}
+
+// view(조회 전용)는 `disabled`를 빌려 쓰지만(버튼엔 readonly 개념이 없다) 글자색만 다르다.
+// 위 비활성 규칙을 이기려면 속성 하나를 더 얹어 특이도를 올려야 한다.
+.fox-select__trigger:disabled[aria-readonly="true"] {
+  color: fox.color(font-neutral-default);
+}
Add a comment
List