{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-input",
  "type": "registry:ui",
  "title": "Time Input",
  "description": "시와 분을 세그먼트 단위로 타이핑해 넣거나, 시계 버튼으로 값 목록 패널을 열어 고르는 시간 입력 필드입니다.",
  "registryDependencies": [
    "@atom/tokens",
    "@atom/lib-bem",
    "@atom/lib-cn",
    "@atom/lib-field-context",
    "@atom/lib-time",
    "@atom/lib-use-anchored-position",
    "@atom/lib-use-controllable-state",
    "@atom/lib-use-dismiss"
  ],
  "files": [
    {
      "path": "poc/design-system/implement/src/components/time-input.tsx",
      "content": "\"use client\";\n\nimport {\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type Ref,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { mod } from \"../lib/bem\";\nimport { cn } from \"../lib/cn\";\nimport { useFieldControl } from \"../lib/field-context\";\nimport { formatTime, isTimeInRange, parseTime } from \"../lib/time\";\nimport { useAnchoredPosition } from \"../lib/use-anchored-position\";\nimport { useControllableState } from \"../lib/use-controllable-state\";\nimport { useDismiss } from \"../lib/use-dismiss\";\n\n/* -------------------------------------------------------------------------- */\n/*  Time Input (components/time-input.md, styles/atom/elements/time-input.css) */\n/*    <TimeInput label=\"시작 시각\" defaultValue=\"09:41\" />                     */\n/*    <TimeInput label=\"예약 시각\" withTrigger minuteStep={30}                 */\n/*               disabledTime={() => ({ disabledHours: () => [0, 1, 2] })} />  */\n/* -------------------------------------------------------------------------- */\n\nexport type TimeInputSize = \"small\" | \"medium\" | \"large\";\nexport type TimeInputVariant = \"outline\" | \"weak\";\nexport type TimeInputFormat = \"12h\" | \"24h\";\nexport type TimeInputSegment = \"hour\" | \"minute\" | \"second\" | \"meridiem\";\n/** 패널에서 고를 수 없는 값 (시는 24시간제 0–23) */\nexport type TimeInputDisabledTime = {\n  disabledHours?: () => number[];\n  disabledMinutes?: (hour: number) => number[];\n  disabledSeconds?: (hour: number, minute: number) => number[];\n};\n\nconst B = \"atom-time-input\";\n\ntype Draft = { hour: number | null; minute: number | null; second: number | null; meridiem: number | null };\nconst EMPTY: Draft = { hour: null, minute: null, second: null, meridiem: null };\n\nconst SEG_LABEL: Record<TimeInputSegment, string> = { hour: \"시\", minute: \"분\", second: \"초\", meridiem: \"오전 오후\" };\nconst MERIDIEM_TEXT = [\"AM\", \"PM\"] as const;\nconst MERIDIEM_KO = [\"오전\", \"오후\"] as const;\n\nconst pad2 = (n: number) => String(n).padStart(2, \"0\");\n\n/** 로케일의 시간제. ko 는 원본 문서대로 24시간제 */\nfunction localeFormat(): TimeInputFormat {\n  if (typeof navigator === \"undefined\") return \"24h\";\n  const lang = navigator.language || \"ko\";\n  if (lang.startsWith(\"ko\")) return \"24h\";\n  try {\n    const cycle = new Intl.DateTimeFormat(lang, { hour: \"numeric\" }).resolvedOptions().hourCycle;\n    return cycle === \"h11\" || cycle === \"h12\" ? \"12h\" : \"24h\";\n  } catch {\n    return \"24h\";\n  }\n}\n\nfunction to24(hour: number, meridiem: number) {\n  return (hour % 12) + (meridiem === 1 ? 12 : 0);\n}\n\nfunction draftOf(value: string | null | undefined, is12: boolean): Draft {\n  const { hour, minute, second } = parseTime(value);\n  if (hour === null || minute === null) return EMPTY;\n  if (!is12) return { hour, minute, second, meridiem: null };\n  return { hour: hour % 12 || 12, minute, second, meridiem: hour >= 12 ? 1 : 0 };\n}\n\nfunction hour24Of(d: Draft, is12: boolean): number | null {\n  if (d.hour === null) return null;\n  if (!is12) return d.hour;\n  return d.meridiem === null ? null : to24(d.hour, d.meridiem);\n}\n\nfunction composeTime(d: Draft, is12: boolean, withSeconds: boolean): string | null {\n  return formatTime({ hour: hour24Of(d, is12), minute: d.minute, second: withSeconds ? d.second : null }, withSeconds);\n}\n\n/** step 의 배수만 남긴 값 목록 */\nfunction stepValues(seg: TimeInputSegment, is12: boolean, step: number): number[] {\n  const s = Math.max(1, Math.floor(step));\n  if (seg === \"meridiem\") return [0, 1];\n  if (seg === \"hour\") {\n    if (is12) return Array.from({ length: 12 }, (_, i) => i + 1).filter((h) => (h % 12) % s === 0);\n    return Array.from({ length: 24 }, (_, i) => i).filter((h) => h % s === 0);\n  }\n  return Array.from({ length: 60 }, (_, i) => i).filter((m) => m % s === 0);\n}\n\n/* ---------------------------------- props --------------------------------- */\n\nexport type TimeInputProps = Omit<HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> & {\n  ref?: Ref<HTMLDivElement>;\n  /** 현재 값입니다. 24시간제 `\"14:30\"` · `\"14:30:12\"` 형식이고, 비었으면 `null` 입니다. */\n  value?: string | null;\n  /** 비제어로 쓸 때의 처음 값입니다. */\n  defaultValue?: string | null;\n  /** 로케일에서 끌어옵니다. 개별 화면에서 덮어쓰지 마세요. (기본: locale) */\n  format?: TimeInputFormat;\n  /** `ss` 세그먼트를 붙입니다. */\n  withSeconds?: boolean;\n  /** 시 세그먼트의 증감 폭이자 패널 시 열의 칸 간격입니다. */\n  hourStep?: number;\n  /** 분 세그먼트의 증감 폭이자 패널 분 열의 칸 간격입니다. 1보다 크면 다음 배수로 스냅합니다. */\n  minuteStep?: number;\n  /** 초 세그먼트의 증감 폭이자 패널 초 열의 칸 간격입니다. `withSeconds` 일 때만 씁니다. */\n  secondStep?: number;\n  /** 허용 하한입니다. 검증에만 쓰이고 타이핑을 막지 않습니다. */\n  min?: string;\n  /** 허용 상한입니다. `min` 과 같은 규칙입니다. */\n  max?: string;\n  /** 패널에서 고를 수 없는 값을 열별로 돌려줍니다. `min`/`max` 와 역할이 다릅니다. */\n  disabledTime?: () => TimeInputDisabledTime;\n  /** 고를 수 없는 셀을 흐리게 남기지 않고 목록에서 뺍니다. */\n  hideDisabledOptions?: boolean;\n  /** 상자 높이와 좌우 여백입니다. Text Input 과 같은 축입니다. 생략하면 Field 의 size, 없으면 \"medium\". */\n  size?: TimeInputSize;\n  /** 테두리형과 약한 배경형입니다. */\n  variant?: TimeInputVariant;\n  /** 폼 행의 폭을 채웁니다. 기본은 내용 폭입니다. */\n  block?: boolean;\n  /** 오른쪽 끝에 패널을 여는 시계 버튼을 둡니다. */\n  withTrigger?: boolean;\n  /** 패널 열림을 밖에서 제어합니다. 비제어로 쓰면 Trigger 가 소유합니다. */\n  open?: boolean;\n  /** 오류 링을 켭니다. 문구는 Field 가 그립니다. */\n  invalid?: boolean;\n  /** 초점은 받되 값이 바뀌지 않습니다. */\n  readOnly?: boolean;\n  /** 탭 순서에서 빠지고 포인터도 받지 않습니다. */\n  disabled?: boolean;\n  /** `role=\"group\"` 에 붙는 필드 이름입니다. Field 로 감싸면 그쪽에서 연결됩니다. */\n  label?: string;\n  /** 세그먼트가 모두 채워져 값이 완성될 때마다 호출됩니다. (완성됐던 값이 지워지면 null) */\n  onChange?: (value: string | null) => void;\n  /** 세그먼트 하나가 바뀔 때마다 호출됩니다. 미완성 상태를 관찰할 때 씁니다. */\n  onSegmentChange?: (segment: TimeInputSegment, value: number | null) => void;\n  /** 패널이 열리거나 닫힐 때 호출됩니다. */\n  onOpenChange?: (open: boolean) => void;\n};\n\n/* ---------------------------------- root ---------------------------------- */\n\nfunction TimeInputRoot({\n  ref,\n  value: valueProp,\n  defaultValue = null,\n  format: formatProp,\n  withSeconds = false,\n  hourStep = 1,\n  minuteStep = 1,\n  secondStep = 1,\n  min,\n  max,\n  disabledTime,\n  hideDisabledOptions = false,\n  size,\n  variant = \"outline\",\n  block = false,\n  withTrigger = false,\n  open: openProp,\n  invalid = false,\n  readOnly = false,\n  disabled = false,\n  label,\n  onChange,\n  onSegmentChange,\n  onOpenChange,\n  className,\n  onFocus,\n  onBlur,\n  \"aria-describedby\": ariaDescribedBy,\n  ...rest\n}: TimeInputProps) {\n  const field = useFieldControl();\n  const isDisabled = disabled || !!field?.disabled;\n  const isRequired = !!field?.required;\n  const resolvedSize = size ?? field?.size ?? \"medium\";\n  const describedBy = [field?.describedBy, ariaDescribedBy].filter(Boolean).join(\" \") || undefined;\n\n  const [detected] = useState(localeFormat);\n  const format = formatProp ?? detected;\n  const is12 = format === \"12h\";\n\n  const [value, setValue] = useControllableState<string | null>({ value: valueProp, defaultValue, onChange });\n  const [draft, setDraft] = useState<Draft>(() => draftOf(value, is12));\n  const composed = composeTime(draft, is12, withSeconds);\n\n  const [seenValue, setSeenValue] = useState(value);\n  if (value !== seenValue) {\n    setSeenValue(value);\n    if (value !== composed) setDraft(draftOf(value, is12));\n  }\n\n  const segments: TimeInputSegment[] = [\"hour\", \"minute\"];\n  if (withSeconds) segments.push(\"second\");\n  if (is12) segments.push(\"meridiem\");\n  const steps: Record<TimeInputSegment, number> = { hour: hourStep, minute: minuteStep, second: secondStep, meridiem: 1 };\n  const bounds = (seg: TimeInputSegment): [number, number] =>\n    seg === \"hour\" ? (is12 ? [1, 12] : [0, 23]) : seg === \"meridiem\" ? [0, 1] : [0, 59];\n\n  /* ----------------------------- focus / validity ---------------------------- */\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const [focusWithin, setFocusWithin] = useState(false);\n  const [activeSeg, setActiveSeg] = useState<TimeInputSegment | null>(null);\n  // 검증은 값이 완성되고 필드를 떠났을 때 (치는 동안 오류를 띄우지 않음)\n  const outOfRange = composed !== null && (min || max) ? !isTimeInRange(composed, min, max) : false;\n  const isInvalid = invalid || !!field?.invalid || (outOfRange && !focusWithin);\n\n  /* ------------------------------ segment logic ------------------------------ */\n  const segRefs = useRef<Partial<Record<TimeInputSegment, HTMLSpanElement | null>>>({});\n  const typingRef = useRef<{ seg: TimeInputSegment; text: string } | null>(null);\n  const [typing, setTypingState] = useState<{ seg: TimeInputSegment; text: string } | null>(null);\n  const setTyping = (t: { seg: TimeInputSegment; text: string } | null) => {\n    typingRef.current = t;\n    setTypingState(t);\n  };\n\n  const commitDraft = (next: Draft) => {\n    setDraft(next);\n    const nextValue = composeTime(next, is12, withSeconds);\n    if (nextValue !== composed) setValue(nextValue);\n  };\n\n  const update = (seg: TimeInputSegment, v: number | null) => {\n    if (draft[seg] === v) return;\n    commitDraft({ ...draft, [seg]: v });\n    onSegmentChange?.(seg, v);\n  };\n\n  const focusSeg = (index: number) => {\n    const seg = segments[Math.max(0, Math.min(segments.length - 1, index))]!;\n    segRefs.current[seg]?.focus();\n  };\n\n  const typeDigit = (index: number, digit: string) => {\n    const seg = segments[index]!;\n    if (seg === \"meridiem\") return;\n    const [lo, hi] = bounds(seg);\n    const prev = typingRef.current?.seg === seg ? typingRef.current.text : \"\";\n    let text = prev + digit;\n    if (text.length > 2 || Number(text) > hi) text = digit;\n    const n = Number(text);\n    if (n < lo) {\n      setTyping({ seg, text: text.length === 2 ? \"\" : text });\n      update(seg, null);\n      return;\n    }\n    update(seg, n);\n    if (text.length === 2 || n * 10 > hi) {\n      setTyping(null);\n      focusSeg(index + 1);\n    } else {\n      setTyping({ seg, text });\n    }\n  };\n\n  const stepSegment = (seg: TimeInputSegment, dir: 1 | -1) => {\n    const values = stepValues(seg, is12, steps[seg]);\n    const cur = draft[seg];\n    let next: number;\n    if (cur === null) next = dir > 0 ? values[0]! : values[values.length - 1]!;\n    else if (dir > 0) next = values.find((v) => v > cur) ?? values[0]!;\n    else next = [...values].reverse().find((v) => v < cur) ?? values[values.length - 1]!;\n    // 순환은 그 세그먼트 안에서만 — 다른 세그먼트는 바꾸지 않음\n    update(seg, next);\n  };\n\n  const onSegmentKeyDown = (index: number, e: KeyboardEvent<HTMLSpanElement>) => {\n    const seg = segments[index]!;\n    const key = e.key;\n    if (key === \"ArrowLeft\" || key === \"ArrowRight\") {\n      e.preventDefault();\n      focusSeg(index + (key === \"ArrowLeft\" ? -1 : 1));\n      return;\n    }\n    if (key === \"ArrowDown\" && e.altKey && canOpen) {\n      e.preventDefault();\n      setOpen(true);\n      return;\n    }\n    if (readOnly || isDisabled) return;\n    if (/^\\d$/.test(key)) {\n      e.preventDefault();\n      typeDigit(index, key);\n    } else if (key === \"ArrowUp\" || key === \"ArrowDown\") {\n      e.preventDefault();\n      setTyping(null);\n      stepSegment(seg, key === \"ArrowUp\" ? 1 : -1);\n    } else if (key === \"Home\" || key === \"End\") {\n      e.preventDefault();\n      setTyping(null);\n      const values = stepValues(seg, is12, steps[seg]);\n      update(seg, key === \"Home\" ? values[0]! : values[values.length - 1]!);\n    } else if (key === \"Backspace\" || key === \"Delete\") {\n      // 한 자리씩이 아니라 세그먼트를 통째로 비움. 이미 비었으면 이전 세그먼트로\n      e.preventDefault();\n      const hasValue = draft[seg] !== null || typingRef.current?.seg === seg;\n      setTyping(null);\n      if (hasValue) update(seg, null);\n      else if (key === \"Backspace\") focusSeg(index - 1);\n    } else if (is12 && /^[aApP]$/.test(key)) {\n      e.preventDefault();\n      update(\"meridiem\", /[aA]/.test(key) ? 0 : 1);\n    } else if (key === \":\") {\n      e.preventDefault();\n      if (draft[seg] !== null) focusSeg(index + 1);\n    }\n  };\n\n  /* ---------------------------------- panel ---------------------------------- */\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const popoverRef = useRef<HTMLDivElement | null>(null);\n  const baseId = useId();\n  const popoverId = `${baseId}-panel`;\n  const [openState, setOpenState] = useControllableState<boolean>({\n    value: openProp,\n    defaultValue: false,\n    onChange: onOpenChange,\n  });\n  const canOpen = !readOnly && !isDisabled;\n  const open = openState && canOpen;\n  const setOpen = (next: boolean) => {\n    if (next !== openState) setOpenState(next);\n  };\n  const [cursors, setCursors] = useState<Partial<Record<TimeInputSegment, number>>>({});\n  const typeahead = useRef<{ text: string; at: number }>({ text: \"\", at: 0 });\n\n  const closePanel = (focusField: boolean) => {\n    setOpen(false);\n    if (focusField) segRefs.current[segments[0]!]?.focus();\n  };\n\n  useDismiss(open, [rootRef], (reason) => closePanel(reason === \"escape\"));\n\n  const dt = disabledTime?.();\n  const hour24 = hour24Of(draft, is12);\n  const isCellDisabled = (seg: TimeInputSegment, v: number): boolean => {\n    if (!dt) return false;\n    if (seg === \"hour\") return !!dt.disabledHours?.().includes(is12 ? to24(v, draft.meridiem ?? 0) : v);\n    if (seg === \"minute\") return hour24 !== null && !!dt.disabledMinutes?.(hour24).includes(v);\n    if (seg === \"second\")\n      return hour24 !== null && draft.minute !== null && !!dt.disabledSeconds?.(hour24, draft.minute).includes(v);\n    return false;\n  };\n  const columnValues = (seg: TimeInputSegment) => {\n    const values = stepValues(seg, is12, steps[seg]);\n    return hideDisabledOptions ? values.filter((v) => !isCellDisabled(seg, v)) : values;\n  };\n  const cellId = (seg: TimeInputSegment, v: number) => `${baseId}-${seg}-${v}`;\n  const cellText = (seg: TimeInputSegment, v: number) => (seg === \"meridiem\" ? MERIDIEM_KO[v]! : pad2(v));\n\n  // 열면 각 열의 커서를 현재 값(없으면 첫 칸)에 두고 첫 열로 초점\n  const [prevOpen, setPrevOpen] = useState(open);\n  if (open !== prevOpen) {\n    setPrevOpen(open);\n    if (open) {\n      const next: Partial<Record<TimeInputSegment, number>> = {};\n      for (const seg of segments) {\n        const values = columnValues(seg);\n        next[seg] = draft[seg] !== null && values.includes(draft[seg]!) ? draft[seg]! : values[0];\n      }\n      setCursors(next);\n    }\n  }\n\n  // 위치 · 위로 열기 · 끝 맞춤(원본 --align_end 역할)은 useAnchoredPosition 이 처리 (초점 이동보다 먼저)\n  useAnchoredPosition(open, rootRef, popoverRef);\n\n  useLayoutEffect(() => {\n    if (!open || !popoverRef.current) return;\n    popoverRef.current.querySelector<HTMLElement>(`.${B}__column`)?.focus();\n  }, [open]);\n\n  // 커서 칸이 보이도록 스크롤\n  useEffect(() => {\n    if (!open) return;\n    for (const seg of segments) {\n      const v = cursors[seg];\n      if (v !== undefined) document.getElementById(cellId(seg, v))?.scrollIntoView({ block: \"nearest\" });\n    }\n  });\n\n  const commitCell = (seg: TimeInputSegment, v: number) => {\n    if (isCellDisabled(seg, v) || readOnly || isDisabled) return;\n    setCursors((c) => ({ ...c, [seg]: v }));\n    const next = { ...draft, [seg]: v };\n    if (draft[seg] !== v) {\n      commitDraft(next);\n      onSegmentChange?.(seg, v);\n    }\n    // 마지막 열까지 채워지면 닫고 초점은 필드로\n    if (seg === segments[segments.length - 1] && composeTime(next, is12, withSeconds) !== null) closePanel(true);\n  };\n\n  const onColumnKeyDown = (seg: TimeInputSegment, e: KeyboardEvent<HTMLDivElement>) => {\n    const values = columnValues(seg);\n    const cur = cursors[seg];\n    const i = cur === undefined ? -1 : values.indexOf(cur);\n    let nextIndex: number | null = null;\n    if (e.key === \"ArrowDown\") nextIndex = Math.min(values.length - 1, i + 1);\n    else if (e.key === \"ArrowUp\") nextIndex = Math.max(0, i - 1);\n    else if (e.key === \"Home\") nextIndex = 0;\n    else if (e.key === \"End\") nextIndex = values.length - 1;\n    else if (/^\\d$/.test(e.key) && seg !== \"meridiem\") {\n      const now = Date.now();\n      const text = now - typeahead.current.at < 1000 ? typeahead.current.text + e.key : e.key;\n      typeahead.current = { text, at: now };\n      const found = values.findIndex((v) => v === Number(text));\n      const prefix = values.findIndex((v) => pad2(v).startsWith(text));\n      nextIndex = found >= 0 ? found : prefix >= 0 ? prefix : null;\n    } else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (cur !== undefined) commitCell(seg, cur);\n      return;\n    } else {\n      return;\n    }\n    e.preventDefault();\n    if (nextIndex !== null && values[nextIndex] !== undefined) {\n      const v = values[nextIndex]!;\n      setCursors((c) => ({ ...c, [seg]: v }));\n    }\n  };\n\n  /* --------------------------------- render --------------------------------- */\n  // 완성된 시각만 알림 (\"오전 9시 41분\") — 훑는 동안의 값마다 읽지 않음\n  const announcement = (() => {\n    if (composed === null) return \"\";\n    const { hour, minute, second } = parseTime(composed);\n    const h = is12 ? `${MERIDIEM_KO[hour! >= 12 ? 1 : 0]} ${hour! % 12 || 12}시` : `${hour}시`;\n    return `${h} ${minute}분${withSeconds ? ` ${second}초` : \"\"}`;\n  })();\n  const placeholder = (seg: TimeInputSegment) =>\n    seg === \"hour\" ? (is12 ? \"hh\" : \"HH\") : seg === \"minute\" ? \"mm\" : seg === \"second\" ? \"ss\" : \"AM\";\n\n  return (\n    <div\n      {...rest}\n      ref={(el) => {\n        rootRef.current = el;\n        if (typeof ref === \"function\") ref(el);\n        else if (ref) ref.current = el;\n      }}\n      className={cn(\n        B,\n        mod(B, \"size\", resolvedSize, \"medium\"),\n        mod(B, \"variant\", variant, \"outline\"),\n        block && `${B}--block`,\n        className,\n      )}\n      data-invalid={isInvalid ? \"\" : undefined}\n      data-readonly={readOnly ? \"\" : undefined}\n      data-disabled={isDisabled ? \"\" : undefined}\n      onFocus={(e) => {\n        setFocusWithin(true);\n        onFocus?.(e);\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);\n        onBlur?.(e);\n      }}\n    >\n      <span\n        className={`${B}__group`}\n        role=\"group\"\n        aria-label={label}\n        aria-labelledby={label ? undefined : field?.labelId}\n        aria-describedby={describedBy}\n        aria-invalid={isInvalid || undefined}\n        aria-readonly={readOnly || undefined}\n        aria-disabled={isDisabled || undefined}\n      >\n        {segments.map((seg, index) => {\n          const v = draft[seg];\n          const typed = typing?.seg === seg && typing.text !== \"\" ? typing.text : null;\n          const empty = v === null && typed === null;\n          const [lo, hi] = bounds(seg);\n          const isMeridiem = seg === \"meridiem\";\n          const text = typed ?? (v === null ? placeholder(seg) : isMeridiem ? MERIDIEM_TEXT[v]! : pad2(v));\n          const nextSeg = segments[index + 1];\n          return [\n            <span\n              key={seg}\n              ref={(el) => {\n                segRefs.current[seg] = el;\n              }}\n              id={index === 0 ? field?.controlId : undefined}\n              className={`${B}__segment`}\n              role=\"spinbutton\"\n              tabIndex={isDisabled ? -1 : 0}\n              data-segment={seg}\n              aria-label={SEG_LABEL[seg]}\n              aria-valuenow={isMeridiem ? undefined : (v ?? undefined)}\n              aria-valuemin={isMeridiem ? undefined : lo}\n              aria-valuemax={isMeridiem ? undefined : hi}\n              aria-valuetext={isMeridiem && v !== null ? MERIDIEM_KO[v] : undefined}\n              aria-required={isRequired || undefined}\n              aria-readonly={readOnly || undefined}\n              aria-disabled={isDisabled || undefined}\n              data-placeholder={empty ? \"\" : undefined}\n              data-active={activeSeg === seg ? \"\" : undefined}\n              onFocus={() => setActiveSeg(seg)}\n              onBlur={() => {\n                setActiveSeg((s) => (s === seg ? null : s));\n                if (typingRef.current?.seg === seg) setTyping(null);\n              }}\n              onKeyDown={(e) => onSegmentKeyDown(index, e)}\n            >\n              {text}\n            </span>,\n            nextSeg !== undefined && nextSeg !== \"meridiem\" && (\n              <span key={`${seg}-sep`} className={`${B}__separator`} aria-hidden=\"true\">\n                :\n              </span>\n            ),\n          ];\n        })}\n      </span>\n      <span className=\"atom-sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n      {withTrigger && (\n        <button\n          ref={triggerRef}\n          className={`${B}__trigger`}\n          type=\"button\"\n          aria-label=\"시간 선택기 열기\"\n          aria-expanded={open}\n          aria-controls={open ? popoverId : undefined}\n          disabled={!canOpen}\n          onClick={() => setOpen(!open)}\n        >\n          <ClockIcon />\n        </button>\n      )}\n      {open && (\n        <div\n          ref={popoverRef}\n          id={popoverId}\n          className={`${B}__popover`}\n          data-state=\"open\"\n        >\n          {segments.map((seg) => {\n            const cursor = cursors[seg];\n            return (\n              <div\n                key={seg}\n                className={`${B}__column`}\n                role=\"listbox\"\n                aria-label={SEG_LABEL[seg]}\n                tabIndex={0}\n                aria-activedescendant={cursor !== undefined ? cellId(seg, cursor) : undefined}\n                onKeyDown={(e) => onColumnKeyDown(seg, e)}\n              >\n                {columnValues(seg).map((v) => {\n                  const cellDisabled = isCellDisabled(seg, v);\n                  return (\n                    <span\n                      key={v}\n                      id={cellId(seg, v)}\n                      className={`${B}__cell`}\n                      role=\"option\"\n                      aria-selected={draft[seg] === v}\n                      aria-disabled={cellDisabled ? \"true\" : undefined}\n                      onClick={() => commitCell(seg, v)}\n                    >\n                      {cellText(seg, v)}\n                    </span>\n                  );\n                })}\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n\n/* 원본 데모의 시계 아이콘 (스프라이트에 없는 인라인 SVG) */\nfunction ClockIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n      <circle cx=\"12\" cy=\"12\" r=\"8.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" />\n      <path d=\"M12 7.2V12l3.2 1.9\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport const TimeInput = Object.assign(TimeInputRoot, {});\n",
      "type": "registry:ui",
      "target": "components/ui/time-input.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/time-input.css",
      "content": "/* ============================================================================\n   Element — Time Input\n   ----------------------------------------------------------------------------\n   A time typed segment by segment: `HH`, `mm`, an optional `ss`, and an AM/PM\n   segment when the locale runs on 12 hours. The keyboard is the primary path,\n   for a time the user already knows.\n\n   The trigger opens a second path on the same control: a panel of value\n   columns, for the time the user has to look for. Columns, not wheels — a wheel\n   that shows one row above and below cannot answer \"what times are open?\",\n   which is the only reason the browse path exists. Ant's TimePicker is the\n   reference; `format` decides which columns appear.\n\n   The box is Text Input's box on purpose: same heights, same radius, same ring,\n   so a time field and a text field line up in one form row.\n\n   What differs is inside. Focus lands on a *segment*, not on the field, so\n   there are two markers that must stay apart — the field ring says \"you are in\n   this control\", the segment fill says \"this is the number the arrow keys will\n   change\". One marker for both reads as a single blurred highlight.\n\n   atom-time-input\n     --size_{small|medium|large}          medium is the default\n     --variant_{outline|weak}             outline is the default\n     --block                              stretch to the form row's width\n     [data-focused]                       static stand-in for :focus-within\n     [data-invalid]  [data-readonly]  [data-disabled]\n     __group      role=\"group\" — carries the field's accessible name\n     __segment    role=\"spinbutton\"  [data-segment=\"hour|minute|second|meridiem\"]\n                  [data-placeholder]  [data-active]\n     __separator  the \":\" between segments. Never focusable\n     __trigger    icon button slot that opens the panel. Optional\n     __popover    [data-state=\"open|closed\"]  --align_end\n     __column     role=\"listbox\" — one per unit, named by aria-label\n     __cell       role=\"option\"  [aria-selected]  [aria-disabled]\n   ========================================================================== */\n\n/* A time value has a fixed length, so the field sizes to its content rather\n   than to the row. `--block` is the opt-out for forms that align every control\n   on one edge.\n\n   `position: relative` is here for __popover to anchor against, the same way\n   date-input.css does it. */\n.atom-time-input {\n  position: relative;\n  display: flex;\n  align-items: center;\n  gap: var(--atom-x2);\n  width: fit-content;\n  max-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-time-input--block {\n  width: 100%;\n}\n\n/* Tabular figures are not a nicety here: without them `11` and `41` are\n   different widths, and the separator shifts sideways on every keystroke. */\n.atom-time-input__group {\n  display: flex;\n  align-items: center;\n  min-width: 0;\n  font-variant-numeric: tabular-nums;\n}\n\n/* 2ch holds two digits at any weight the theme picks, so an empty segment and a\n   filled one occupy the same slot — the placeholder must not resize the field. */\n.atom-time-input__segment {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-width: 2ch;\n  padding: 1px var(--atom-x1);\n  border-radius: var(--atom-radius-r1);\n  background: none;\n  font: inherit;\n  font-variant-numeric: inherit;\n  color: inherit;\n  text-align: center;\n  white-space: nowrap;\n  user-select: none;\n  cursor: text;\n  transition: background-color var(--atom-duration-fast) ease,\n    color var(--atom-duration-fast) ease;\n}\n\n/* AM/PM is letters, not digits, and `ch` is measured on the zero glyph. */\n.atom-time-input__segment[data-segment=\"meridiem\"] {\n  min-width: 2.75ch;\n  cursor: default;\n}\n\n.atom-time-input__segment[data-placeholder] {\n  color: var(--atom-fg-neutral-subtle);\n}\n\n.atom-time-input__segment:hover {\n  background: var(--atom-bg-neutral-weak);\n}\n\n/* The second of the two markers. A solid fill — not an outline — because the\n   segment is a value being replaced wholesale, the same reading a text\n   selection gives. */\n.atom-time-input__segment:focus-visible,\n.atom-time-input__segment[data-active] {\n  outline: none;\n  background: var(--atom-fg-brand);\n  color: var(--atom-fg-on-solid);\n}\n\n.atom-time-input__separator {\n  padding-inline: 1px;\n  color: var(--atom-fg-neutral-muted);\n  user-select: none;\n}\n\n/* `margin-left: auto` only bites under `--block`, where the field is wider than\n   its content and the trigger belongs on the far edge. */\n.atom-time-input__trigger {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 24px;\n  height: 24px;\n  flex-shrink: 0;\n  margin-left: auto;\n  border: 0;\n  border-radius: var(--atom-radius-r1);\n  background: none;\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-time-input__trigger svg {\n  width: 24px;\n  height: 24px;\n  display: block;\n}\n\n.atom-time-input__trigger:hover {\n  background: var(--atom-bg-neutral-weak);\n  color: var(--atom-fg-neutral);\n}\n\n/* The trigger sits inside the field's own ring, so an outline drawn outwards\n   would collide with it. */\n.atom-time-input__trigger:focus-visible {\n  outline: 2px solid var(--atom-fg-brand);\n  outline-offset: -2px;\n}\n\n/* --- sizes ---------------------------------------------------------------\n   Heights match text-input.css exactly (42 / 53 / 60) so the two controls can\n   share a row without a half-pixel step between them. */\n.atom-time-input--size_small {\n  min-height: var(--atom-control-h-small);\n  padding: var(--atom-x2) var(--atom-x3);\n  font-size: 14px;\n}\n\n.atom-time-input--size_small .atom-time-input__trigger {\n  width: 24px;\n  height: 24px;\n}\n\n.atom-time-input--size_small .atom-time-input__trigger svg {\n  width: 24px;\n  height: 24px;\n}\n\n.atom-time-input--size_large {\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-time-input--variant_weak {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-time-input:hover:not([data-focused]):not([data-invalid]):not(\n    [data-readonly]\n  ):not([data-disabled]) {\n  box-shadow: var(--atom-control-border-hover);\n}\n\n.atom-time-input:focus-within,\n.atom-time-input[data-focused] {\n  box-shadow: var(--atom-control-border-focus);\n}\n\n/* Invalid outranks focus: a field that is both must keep saying so while the\n   user is fixing it. */\n.atom-time-input[data-invalid],\n.atom-time-input[data-invalid]:focus-within {\n  box-shadow: var(--atom-control-border-invalid);\n}\n\n/* Readonly keeps the value readable and the segments reachable by Tab — only\n   the arrow keys and typing stop. Disabled removes it from the tab order. */\n.atom-time-input[data-readonly] {\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n  color: var(--atom-fg-neutral-muted);\n}\n\n.atom-time-input[data-readonly] .atom-time-input__segment {\n  cursor: default;\n}\n\n.atom-time-input[data-readonly] .atom-time-input__segment:hover {\n  background: none;\n}\n\n.atom-time-input[data-disabled] {\n  opacity: 0.45;\n  background: var(--atom-bg-neutral-weak);\n  box-shadow: none;\n  cursor: not-allowed;\n}\n\n.atom-time-input[data-disabled] .atom-time-input__segment,\n.atom-time-input[data-disabled] .atom-time-input__trigger {\n  pointer-events: none;\n}\n\n.atom-time-input__trigger[disabled] {\n  pointer-events: none;\n  opacity: 0.45;\n}\n\n/* --- popover -------------------------------------------------------------\n   Anchored to the field, which is `position: relative` above. Absolute rather\n   than in flow (select.css keeps its listbox in flow) because this field is\n   `width: fit-content` — an in-flow panel would be trapped at the width of\n   `09 : 41`, which is narrower than three columns. Production code measures the\n   field and flips this above when the viewport runs out below.\n   Offset, radius, surface and z-index are date-input__popover's, so the two\n   segment fields open the same-looking layer. */\n.atom-time-input__popover {\n  position: absolute;\n  top: calc(100% + 6px);\n  inset-inline-start: 0;\n  z-index: 20;\n  display: flex;\n  gap: var(--atom-x1);\n  padding: var(--atom-x2);\n  border-radius: var(--atom-radius-r3);\n  background: var(--atom-bg-layer-raised);\n  box-shadow: var(--atom-shadow-s3), inset 0 0 0 1px var(--atom-border-subtle);\n  cursor: default;\n}\n\n.atom-time-input__popover[data-state=\"closed\"] {\n  display: none;\n}\n\n.atom-time-input__popover--align_end {\n  inset-inline: auto 0;\n}\n\n/* --- column --------------------------------------------------------------\n   `max-height` is 8 cells exactly (32 x 8). The ninth row is cut in half at the\n   bottom edge, and that half row is the only affordance saying the column\n   scrolls — a column that ends flush reads as a complete list.\n\n   The divider goes on the column, not between flex items, so it survives the\n   column scrolling: an inset shadow would ride up with the content. */\n.atom-time-input__column {\n  display: flex;\n  flex-direction: column;\n  min-width: 56px;\n  max-height: 256px;\n  overflow-y: auto;\n  overscroll-behavior: contain;\n  scrollbar-width: thin;\n}\n\n.atom-time-input__column + .atom-time-input__column {\n  padding-inline-start: var(--atom-x1);\n  border-inline-start: 1px solid var(--atom-border-subtle);\n}\n\n/* --- cell ----------------------------------------------------------------\n   32px and tabular figures are date-input__day's, so a date panel and a time\n   panel opened from the same form row have rows of the same rhythm. 14px is one\n   step under the field's 15px: the panel holds many more numbers than the\n   field, and density is what makes it scannable. */\n.atom-time-input__cell {\n  display: grid;\n  place-items: center;\n  flex-shrink: 0;\n  height: 32px;\n  padding-inline: var(--atom-x2);\n  border: 0;\n  border-radius: var(--atom-radius-r1);\n  background: none;\n  font: inherit;\n  font-size: 14px;\n  font-variant-numeric: tabular-nums;\n  color: var(--atom-fg-neutral);\n  cursor: pointer;\n  transition: background-color var(--atom-duration-fast) ease,\n    color var(--atom-duration-fast) ease;\n}\n\n.atom-time-input__cell:hover:not([aria-disabled=\"true\"]):not(\n    [aria-selected=\"true\"]\n  ) {\n  background: var(--atom-bg-neutral-weak);\n}\n\n.atom-time-input__cell:focus-visible {\n  outline: 2px solid var(--atom-fg-brand);\n  outline-offset: -2px;\n}\n\n.atom-time-input__cell[aria-selected=\"true\"] {\n  background: var(--atom-bg-neutral-solid);\n  color: var(--atom-fg-on-solid);\n  font-weight: 600;\n}\n\n/* Dimmed and kept, not removed. `hideDisabledOptions` is the opt-out and it is\n   off by default: a closed slot still answers \"why can't I pick 3?\", and\n   dropping rows makes the scroll position stop matching the values. */\n.atom-time-input__cell[aria-disabled=\"true\"] {\n  opacity: 0.3;\n  cursor: not-allowed;\n}\n\n/* The panel is a layer over the page, so a field that cannot take input must\n   not leave one open behind it. */\n.atom-time-input[data-readonly] .atom-time-input__popover,\n.atom-time-input[data-disabled] .atom-time-input__popover {\n  display: none;\n}\n\n/* --- motion -------------------------------------------------------------- */\n@media (prefers-reduced-motion: reduce) {\n  .atom-time-input,\n  .atom-time-input__segment,\n  .atom-time-input__trigger,\n  .atom-time-input__cell {\n    transition: none;\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/time-input.css"
    }
  ],
  "categories": [
    "action-input"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "component",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "TimeInput"
    ],
    "docs": "poc/design-system/components/time-input.md"
  }
}
