# Time Input 시와 분을 세그먼트 단위로 타이핑해 넣거나, 시계 버튼으로 값 목록 패널을 열어 고르는 시간 입력 필드입니다. > 원본: `03_ASSET_GUIDE/catalog/time-input.js` · CSS: `03_ASSET_GUIDE/styles/elements/time-input.css` > kind `component` · category `action-input` · status `stable` · specSource `measured` --- ## 1. 구성 하나의 상자 안에 **Segment** 여러 개가 들어 있고, 그 사이를 **Separator** 가 나눕니다. 초점은 세그먼트 하나에 놓이고, 표시는 두 겹입니다. 상자에는 초점 링이, 그 안의 세그먼트 하나에는 채움 표시가 붙습니다. 상자 밖으로는 **Popover** 가 한 겹 더 있습니다. Trigger 를 누르면 열리는 값 목록이고, 같은 컨트롤의 두 번째 입력 경로입니다. [Panel](#panel) 에서 다룹니다. ``` ┌─────────────────────────────┐ │ [07] : [30] [AM] [🕒] │ └─────────────────────────────┘ ① ②③ ④ ⑤ ⑥ ⑦ ┌──────┬──────┬──────┐ │ 08 │ 29 │ 오전 │ ← ⑧ Popover · ⑨ Column · ⑩ Cell │ [09] │ [30] │ 오후 │ └──────┴──────┴──────┘ ``` | # | 요소 | 필수 | 클래스 | 설명 | |---|---|---|---|---| | ① | Field | 필수 | `atom-time-input` | 상자와 초점 링을 소유합니다. 높이·모서리·테두리는 [Text Input](./text-input.md) 과 같습니다. | | ② | Group | 필수 | `atom-time-input__group` | `role="group"` 으로 세그먼트들을 하나의 컨트롤로 묶고, 필드의 이름을 답니다. | | ③ | Hour segment | 필수 | `atom-time-input__segment` `[data-segment="hour"]` | `HH`. `role="spinbutton"` 이고 따로 초점을 받습니다. | | ④ | Separator | 필수 | `atom-time-input__separator` | 세그먼트 사이의 `:` 입니다. 초점을 받지 않고 스크린리더에서도 감춥니다. | | ⑤ | Minute segment | 필수 | `atom-time-input__segment` `[data-segment="minute"]` | `mm`. (`withSeconds` 면 `ss` 가 뒤에 붙습니다) | | ⑥ | Meridiem segment | 12시간제만 | `atom-time-input__segment` `[data-segment="meridiem"]` | `AM/PM`. | | ⑦ | Trigger | 선택 | `atom-time-input__trigger` | Popover 를 여는 아이콘 버튼입니다. 선택 사항입니다. | | ⑧ | Popover | Trigger 가 있을 때 | `atom-time-input__popover` | 필드 아래에 뜨는 판입니다. 필드가 앵커이고, 열리지 않았을 때는 `[data-state="closed"]` 로 그려지지 않습니다. | | ⑨ | Column | Popover 안 | `atom-time-input__column` | 단위 하나당 하나입니다. 각각 `role="listbox"` 이고 이름은 `aria-label` 이 답니다. 눈에 보이는 열 제목은 두지 않습니다. | | ⑩ | Cell | Popover 안 | `atom-time-input__cell` | 열 안의 값 하나입니다. `role="option"` + `aria-selected` 이고, 고를 수 없는 값은 `aria-disabled` 입니다. | > [!NOTE] > **초점 표시가 두 개인 이유** > 상자의 링은 "지금 이 컨트롤 안에 있다", 세그먼트의 채움은 "화살표 키가 바꿀 숫자는 이것" > 입니다. 둘을 같은 색·같은 모양으로 그리면 `↑` 를 눌렀을 때 바뀔 값을 구분할 수 > 없습니다. --- ## 2. 종류 / 크기 ### Format 24시간제는 `HH` 가 `00`–`23` 이고 세그먼트가 둘입니다. 12시간제는 `hh` 가 `01`–`12` 이고 뒤에 `AM`/`PM` 세그먼트가 하나 더 붙습니다. | format | 시 범위 | 세그먼트 | 표시 예 | | --- | --- | --- | --- | | `24h` | 00–23 | HH · mm | `18:05` | | `12h` | 01–12 | hh · mm · AM/PM | `06:05 PM` | > [!WARNING] > **형식은 로케일이 정합니다** > `ko-KR` · `de-DE` 는 24시간제, `en-US` 는 12시간제로 읽고 씁니다. 화면 안에 12/24 를 > 바꾸는 토글을 두면 같은 폼의 두 필드가 서로 다른 형식이 되고, 저장된 값을 다시 열었을 때 > 어느 쪽으로 읽어야 할지 알 수 없습니다. `format` 은 로케일에서 끌어오고, 개별 화면에서 > 덮어쓰지 마세요. > [!NOTE] > **표시 형식과 저장 형식은 별개입니다** > 화면에 `06:05 PM` 이 보여도 값은 24시간제 `18:05` 로 다룹니다. 12시간제를 그대로 > 저장하면 자정·정오(`12 AM` · `12 PM`)에서 해석이 갈립니다. ### Seconds `ss` 세그먼트는 **선택 사항이고 기본은 꺼짐**입니다. 켜면 채워야 할 칸이 둘에서 셋으로 늘고, 키 입력이 4번에서 6번으로 늘어납니다. 대부분의 폼에서는 끕니다. 예약·근무 시간·알림처럼 사람이 정하는 시각은 분 단위이므로 초는 늘 `00` 입니다. 로그 시각·타이머·측정값처럼 **초가 실제로 다른 값을 가질 때만** 켜세요. ### Step 세그먼트마다 `↑` `↓` 가 한 번에 얼마나 옮길지 정합니다. `hourStep` · `minuteStep` · `secondStep` 세 축이고 기본은 모두 `1` 입니다. 30분 단위로만 예약을 받는 화면이라면 `minuteStep: 30` 을 줍니다. | minuteStep | 동작 | 쓰는 곳 | | --- | --- | --- | | `1` | 1분씩 | 정확한 시각을 그대로 적는 경우 (기본) | | `5` | 다음 5의 배수로 | 회의·통화 예약 | | `10` | 다음 10의 배수로 | 배차·픽업 시간 | | `30` | 다음 30의 배수로 | 진료·상담 슬롯 | > [!NOTE] > **step 은 패널의 칸 수도 정합니다** > step 은 세그먼트의 증감 폭이면서 [Panel](#panel) 해당 열의 값 목록입니다. > `minuteStep: 30` 이면 분 열에는 `00` 과 `30` 두 칸만 있습니다. 두 경로의 유효값 > 집합을 같게 유지하기 위해서입니다. `step` 이 1보다 크면 증감은 **다음 배수로 스냅**합니다. `14:31` 에서 `step: 5` 로 `↑` 를 누르면 `14:36` 이 아니라 `14:35` 입니다. 다음 시로 넘어갈 때도 같습니다. `14:45` 에서 `minuteStep: 30` 이면 `15:00` 입니다. > [!WARNING] > **타이핑까지 step 으로 막지 않습니다** > `14:37` 을 직접 입력하면 `14:35` 로 고치지 않고 그대로 둡니다. `step` 은 `↑` `↓` 의 > 폭에만 적용되고, 타이핑한 값은 **커밋 시점에 검증**해 [Field](./field.md) 의 오류로 > 알립니다. > [!CAUTION] > **원본 안의 충돌** — 위 "`14:45` 에서 `minuteStep: 30` 이면 `15:00`" 은 시가 함께 올라가는 동작이지만, > [접근성](#6-접근성) 의 "23시에서 `↑` 는 0시로 순환합니다" 는 **"분이 `59` 에서 `00` 으로 넘어가도 시는 올라가지 않습니다"** 라고 적습니다. > 구현은 접근성 쪽(세그먼트 안에서만 순환)을 따라 `14:45` → `14:00` 입니다. ### Size 높이는 [Text Input](./text-input.md) 과 같은 축을 씁니다. 시간 필드와 텍스트 필드가 한 줄에 섞이는 폼에서 두 상자의 아랫변이 어긋나지 않게 하려는 것입니다. | size | 높이 | 좌우 여백 | 글자 | 모서리 | | --- | --- | --- | --- | --- | | `small` | 40px | 12px | 14px | `8px` | | `medium` | 48px | 16px | 15px | `8px` | | `large` | 56px | 20px | 16px | `12px` | ### Variant `outline` 이 기본이고, 회색 표면 위에서는 `weak` 를 씁니다. 두 값 모두 초점·오류 링은 같습니다. ### Trigger 오른쪽 끝의 시계 아이콘 버튼입니다. 누르면 [Panel](#panel) 이 열려서, **아는 시각은 타이핑으로 · 찾아야 하는 시각은 목록으로** 두 경로를 한 필드에서 제공합니다. 트리거 없이 타이핑만 받는 형태도 그대로 지원합니다. 필드의 기본 폭은 **내용 폭**입니다. 시간 값은 길이가 고정되어 있기 때문입니다. 다른 컨트롤과 왼쪽·오른쪽 끝을 맞춰야 하면 `--block` 을 주세요. 트리거는 그때 오른쪽 끝으로 밀려납니다. ### Panel Trigger 로 열리는 값 목록입니다. 열마다 그 단위의 값 전체가 들어 있고 스크롤됩니다. 패널은 휠이 아닌 스크롤 목록입니다. 패널은 고를 수 있는 시각을 훑어보는 용도이고, 한 칸씩만 보이는 휠은 여기에 맞지 않습니다. 아는 시각은 세그먼트에서 4번의 키 입력으로 넣습니다. [Ant Design TimePicker](https://ant.design/components/time-picker) 를 따랐습니다. 한 열에 8칸이 보이고 9번째가 아래 경계에서 잘립니다. 잘린 반 칸으로 값이 더 있음을 표시합니다. 목록이 경계에 맞춰 끝나면 목록이 끝난 것으로 보입니다. 패널은 필드의 **글줄 시작 쪽 끝에 맞춰** 열립니다. 오른쪽으로 넘칠 자리가 없으면 `--align_end` 로 반대쪽 끝에 붙입니다. 넘침 여부는 CSS 로 판단할 수 없으므로, 구현에서 필드의 좌표를 측정해 이 modifier 를 바꿉니다. #### 열 구성은 format 이 정합니다 별도 프롭 없이 `format` 과 `withSeconds` 가 열을 결정합니다. | 설정 | 열 | | --- | --- | | `24h` (기본) | 시 `00`–`23` · 분 | | `24h` + `withSeconds` | 시 · 분 · 초 | | `12h` | 시 `01`–`12` · 분 · 오전 오후 | **오전·오후 열은 맨 오른쪽**입니다. 한국어 어순만 보면 "오전 10시 30분" 이므로 왼쪽이 자연스럽지만, 12시간제 필드의 세그먼트가 `hour : minute meridiem` 순서로 `AM` 을 뒤에 둡니다. 두 경로의 순서를 맞추기 위해 필드 쪽을 따릅니다. 값은 패널에서도 24시간제입니다. 화면에 `06:05 PM` 이 보여도 값은 `18:05` 입니다. #### 언제 확정되나 셀을 누르면 그 열의 값이 **즉시** 반영됩니다. 확인 버튼은 없습니다. 마지막 열까지 채워지면 패널이 닫히고 초점은 필드로 돌아옵니다. `readonly` · `disabled` 필드에서는 열리지 않습니다. Trigger 가 그 두 상태를 함께 받습니다. ### 선택할 수 없는 시각 `disabledTime` 이 열별로 막을 값을 돌려줍니다. 마감된 예약 슬롯, 이미 지난 시각, 영업 시간 밖이 여기에 해당합니다. ```ts disabledTime?: () => { disabledHours?: () => number[]; disabledMinutes?: (hour: number) => number[]; disabledSeconds?: (hour: number, minute: number) => number[]; }; ``` 분과 초는 앞 단위를 인자로 받습니다. "09시에는 30분부터" 처럼 시각에 따라 달라지는 규칙을 표현하려면 그 정보가 필요합니다. #### min · max 와 역할이 다릅니다 두 프롭은 적용 시점과 대상이 다릅니다. | | 시점 | 대상 | 동작 | | --- | --- | --- | --- | | `min` · `max` | 값이 완성된 뒤 | 필드 | **검증만.** 타이핑을 막지 않습니다 | | `disabledTime` | 패널을 그릴 때 | 패널 | 해당 셀을 **고를 수 없게** 만듭니다 | `min`/`max` 는 입력 후에 검증하고, `disabledTime` 은 입력 전에 선택지를 막습니다. > [!WARNING] > **패널이 있으면 둘을 함께 채웁니다** > `min`/`max` 만 두고 `disabledTime` 을 비우면, 패널이 고를 수 있게 보여준 값이 고른 > 직후에 오류가 됩니다. 반대로 `disabledTime` 만 두면 타이핑 경로에는 아무 규칙이 없습니다. ### 클래스 계약 `styles/elements/time-input.css` 헤더와 원본 `registry.json` `css.blocks` 기준입니다. 클래스가 없는 값(—)은 기본 스타일 그대로입니다. | Prop · 축 | 값 | 클래스 | |---|---|---| | `size` | `small` · `medium` · `large` | `--size_small` · — · `--size_large` | | `variant` | `outline` · `weak` | — · `--variant_weak` | | `block` | `false` · `true` | — · `--block` | | (align) | 패널 끝 맞춤 | `__popover--align_end` | 상태 선택자: `:hover` · `:focus-within` · `[data-focused]` · `[data-invalid]` · `[data-readonly]` · `[data-disabled]` 세그먼트: `[data-segment="hour|minute|second|meridiem"]` · `[data-placeholder]` · `[data-active]` · `:focus-visible` 트리거: `[disabled]` · 패널: `[data-state="open|closed"]` · 셀: `[aria-selected]` · `[aria-disabled]` · `:focus-visible` --- ## 3. 상태 상태는 필드 전체에 붙는 것과 세그먼트 하나에 붙는 것으로 나뉩니다. 둘을 구분해야 화살표 키가 바꿀 세그먼트가 화면에 드러납니다. | 대상 | 속성 | 결과 | | --- | --- | --- | | Field | `:focus-within` | 2px 초점 링. `data-focused` 는 같은 모양의 정적 대역입니다 | | Field | `data-invalid` | 링이 critical 색. 초점보다 우선합니다 | | Field | `data-readonly` | 약한 배경 + 테두리 없음. Tab 은 그대로 되고 값만 못 바꿉니다 | | Field | `data-disabled` | 투명도 45% + 포인터 차단. 탭 순서에서도 뺍니다 | | Segment | `data-active` | 브랜드 채움. `:focus-visible` 과 같은 모양입니다 | | Segment | `data-placeholder` | `HH` · `mm` 을 subtle 색으로 | > [!NOTE] > **`readonly` 와 `disabled` 는 다릅니다** > `readonly` 는 값을 보여주는 상태로, 초점을 받고 복사할 수 있고 스크린리더가 > 읽습니다. `disabled` 는 지금 상관없는 필드입니다. 확정된 예약 시각을 보여주는 자리에 > `disabled` 를 쓰면 값 자체가 읽히지 않습니다. > [!NOTE] > 구현은 `[data-focused]` 를 붙이지 않고 `:focus-within` 에 맡깁니다. 초점을 가진 세그먼트에는 `data-active` 를 붙입니다 > (포인터로 누른 세그먼트는 `:focus-visible` 이 맞지 않아서). `min`/`max` 를 벗어난 완성 값은 필드를 떠난 뒤 `data-invalid` 가 됩니다. --- ## 4. Props 값은 24시간제 `HH:mm` 또는 `HH:mm:ss` 문자열로 주고받습니다. 화면 표시가 12시간제여도 마찬가지입니다. | Name | Type | Default | Required | Description | | --- | --- | --- | --- | --- | | `value` | `string \| null` | | | 현재 값입니다. 24시간제 `"14:30"` · `"14:30:12"` 형식이고, 비었으면 `null` 입니다. | | `defaultValue` | `string \| null` | | | 비제어로 쓸 때의 처음 값입니다. | | `format` | `"12h" \| "24h"` | locale | | 로케일에서 끌어옵니다. 개별 화면에서 덮어쓰지 마세요. | | `withSeconds` | `boolean` | `false` | | `ss` 세그먼트를 붙입니다. | | `hourStep` | `number` | `1` | | 시 세그먼트의 증감 폭이자 패널 시 열의 칸 간격입니다. | | `minuteStep` | `number` | `1` | | 분 세그먼트의 증감 폭이자 패널 분 열의 칸 간격입니다. 1보다 크면 다음 배수로 스냅합니다. | | `secondStep` | `number` | `1` | | 초 세그먼트의 증감 폭이자 패널 초 열의 칸 간격입니다. `withSeconds` 일 때만 씁니다. | | `min` | `string` | | | 허용 하한입니다. 검증에만 쓰이고 타이핑을 막지 않습니다. | | `max` | `string` | | | 허용 상한입니다. `min` 과 같은 규칙입니다. | | `disabledTime` | `() => DisabledTime` | | | 패널에서 고를 수 없는 값을 열별로 돌려줍니다. `min`/`max` 와 역할이 다릅니다. | | `hideDisabledOptions` | `boolean` | `false` | | 고를 수 없는 셀을 흐리게 남기지 않고 목록에서 뺍니다. | | `size` | `"small" \| "medium" \| "large"` | `"medium"` | | 상자 높이와 좌우 여백입니다. Text Input 과 같은 축입니다. | | `variant` | `"outline" \| "weak"` | `"outline"` | | 테두리형과 약한 배경형입니다. | | `block` | `boolean` | `false` | | 폼 행의 폭을 채웁니다. 기본은 내용 폭입니다. | | `withTrigger` | `boolean` | `false` | | 오른쪽 끝에 패널을 여는 시계 버튼을 둡니다. | | `open` | `boolean` | | | 패널 열림을 밖에서 제어합니다. 비제어로 쓰면 Trigger 가 소유합니다. | | `invalid` | `boolean` | `false` | | 오류 링을 켭니다. 문구는 Field 가 그립니다. | | `readOnly` | `boolean` | `false` | | 초점은 받되 값이 바뀌지 않습니다. | | `disabled` | `boolean` | `false` | | 탭 순서에서 빠지고 포인터도 받지 않습니다. | | `label` | `string` | | ● | `role="group"` 에 붙는 필드 이름입니다. Field 로 감싸면 그쪽에서 연결됩니다. | | `onChange` | `(value: string \| null) => void` | | | 세그먼트가 모두 채워져 값이 완성될 때마다 호출됩니다. | | `onSegmentChange` | `(segment, value) => void` | | | 세그먼트 하나가 바뀔 때마다 호출됩니다. 미완성 상태를 관찰할 때 씁니다. | | `onOpenChange` | `(open: boolean) => void` | | | 패널이 열리거나 닫힐 때 호출됩니다. | > [!NOTE] > 구현(`@atom/ui`)에서 `label` 은 타입상 선택입니다 — Field 안에서는 Field 의 라벨이 `aria-labelledby` 로 연결되기 때문입니다. Field 밖에서는 반드시 줍니다. > `DisabledTime` 타입은 `TimeInputDisabledTime` 으로 export 됩니다. 완성됐던 값이 지워지면 `onChange(null)` 이 호출됩니다. ### Parts (compound) | Part | 요소 | 설명 | |---|---|---| | `TimeInput` | `
` | Field. 위 Props 를 받습니다. Group · Segment · Separator · Trigger · Popover · Column · Cell 을 모두 그립니다. 별도 파트는 없습니다. | --- ## 5. Component tokens | 대상 | 속성 | Token | Value | | --- | --- | --- | --- | | Field | background | `{color.bg.layer}` | 테마 표면 | | Field | min-height | `{control.h}` | 48px | | Field (`small` · `large`) | min-height | `{control.h-small}` · `{control.h-large}` | 40 · 56px | | Field | border | `{control.border}` | `{color.border.default}` inset 1px | | Field | radius | `{radius.r2}` | 8px (large 는 r3 · 12px) | | Field | padding | `{space.x3}` `{space.x4}` | 12px 16px | | Field | gap | `{space.x2}` | 8px | | Field · focus | border | `{control.border-focus}` | `{color.primary.600}` inset 2px | | Field · invalid | border | `{control.border-invalid}` | `{color.fg.critical}` inset 2px | | Field · readonly | background | `{color.bg.neutral-weak}` | 테두리 없음 | | Segment | radius | `{radius.r1}` | 4px | | Segment · active | background | `{color.fg.brand}` | 채움 | | Segment · active | color | `{color.fg.on-solid}` | 채움 위 글자 | | Segment · placeholder | color | `{color.fg.neutral-subtle}` | `HH` · `mm` | | Separator | color | `{color.fg.neutral-muted}` | `:` | | Trigger | color | `{color.fg.neutral-muted}` | 24px · 아이콘 24px | | Popover | background | `{color.bg.layer-raised}` | 필드보다 한 겹 위 | | Popover | shadow | `{shadow.s3}` | + inset 1px `{color.border.subtle}` | | Popover | radius | `{radius.r3}` | 12px | | Popover | padding | `{space.x2}` | 8px · 열 간격 `{space.x1}` | | Popover | offset | — | `top: calc(100% + 6px)` · `z-index: 20` | | Column | border | `{color.border.subtle}` | 두 번째 열부터 1px 구분선 | | Column | 치수 | — | 최소 폭 56px · 최대 높이 256px (32×8) | | Cell | height | — | 32px · 글자 14px `tabular-nums` | | Cell | radius | `{radius.r1}` | 4px | | Cell · hover | background | `{color.bg.neutral-weak}` | | | Cell · selected | background | `{color.bg.neutral-solid}` | 글자 `{color.fg.on-solid}` · 600 | | Cell · disabled | opacity | — | 0.3 · `cursor: not-allowed` | | 전체 | transition | `{duration.fast}` | 120ms · reduce 에서 제거 | ```json { "time-input": { "bg": { "$type": "color", "$value": "{color.bg.layer}" }, "min-height": { "$type": "dimension", "small": { "$value": "{control.h-small}" }, "medium": { "$value": "{control.h}" }, "large": { "$value": "{control.h-large}" } }, "border": { "default": { "$value": "{control.border}" }, "hover": { "$value": "{control.border-hover}" }, "focus": { "$value": "{control.border-focus}" }, "invalid": { "$value": "{control.border-invalid}" } }, "radius": { "$type": "dimension", "default": { "$value": "{radius.r2}" }, "large": { "$value": "{radius.r3}" } }, "padding": { "$type": "dimension", "block": { "$value": "{space.x3}" }, "inline": { "$value": "{space.x4}" } }, "gap": { "$type": "dimension", "$value": "{space.x2}" }, "readonly-bg": { "$type": "color", "$value": "{color.bg.neutral-weak}" }, "weak-bg": { "$type": "color", "$value": "{color.bg.neutral-weak}" }, "segment": { "radius": { "$type": "dimension", "$value": "{radius.r1}" }, "bg-active": { "$type": "color", "$value": "{color.fg.brand}" }, "fg-active": { "$type": "color", "$value": "{color.fg.on-solid}" }, "fg-placeholder": { "$type": "color", "$value": "{color.fg.neutral-subtle}" } }, "separator": { "fg": { "$type": "color", "$value": "{color.fg.neutral-muted}" } }, "trigger": { "fg": { "$type": "color", "$value": "{color.fg.neutral-muted}" } }, "popover": { "bg": { "$type": "color", "$value": "{color.bg.layer-raised}" }, "shadow": { "$type": "shadow", "$value": "{shadow.s3}" }, "border": { "$type": "color", "$value": "{color.border.subtle}" }, "radius": { "$type": "dimension", "$value": "{radius.r3}" }, "padding": { "$type": "dimension", "$value": "{space.x2}" }, "gap": { "$type": "dimension", "$value": "{space.x1}" } }, "column": { "border": { "$type": "color", "$value": "{color.border.subtle}" } }, "cell": { "radius": { "$type": "dimension", "$value": "{radius.r1}" }, "bg-hover": { "$type": "color", "$value": "{color.bg.neutral-weak}" }, "bg-selected": { "$type": "color", "$value": "{color.bg.neutral-solid}" }, "fg-selected": { "$type": "color", "$value": "{color.fg.on-solid}" } }, "transition": { "$type": "duration", "$value": "{duration.fast}" } } } ``` --- ## 6. 접근성 세그먼트는 **값을 가진 컨트롤**로 다룹니다. WAI-ARIA 의 `spinbutton` 패턴을 세그먼트마다 하나씩 적용합니다. - 세그먼트마다 `role="spinbutton"` · `tabindex="0"` · `aria-label`(시 · 분 · 초 · 오전 오후) 을 줍니다. 현재 값은 `aria-valuenow`, 범위는 `aria-valuemin` · `aria-valuemax` 입니다. - `AM`/`PM` 은 값이 둘뿐이라 숫자로 읽히면 뜻이 없습니다. `aria-valuetext` 로 "오전" · "오후" 를 읽힙니다. - 전체를 `role="group"` 으로 묶고 그 그룹에 필드 이름을 답니다. 그룹 이름이 없으면 스크린리더가 "시" · "분" 만 읽습니다. - `:` 는 `aria-hidden="true"` 로 감춥니다. 세그먼트 사이를 지날 때마다 "콜론" 이 읽히면 값이 끊깁니다. - **키보드** — `↑` `↓` 는 `step` 만큼 증감, `←` `→` 는 세그먼트 이동, 숫자 키는 입력 후 자동 진행, `Backspace` 는 현재 세그먼트를 지웁니다. 12시간제에서는 `A` · `P` 로 오전· 오후를 바꿉니다. - 숫자 자동 진행은 **되돌릴 수 있어야 합니다.** `09` 를 쳐서 분으로 넘어간 뒤 `←` 나 `Backspace` 로 시로 돌아갈 수 있어야 하고, 돌아간 세그먼트는 값 전체가 선택된 상태여야 다시 칠 수 있습니다. - `Backspace` 는 한 자리씩 지우지 않고 세그먼트를 통째로 비웁니다. 두 자리 값에서 한 자리만 남으면 `9` 가 `09` 인지 `9x` 인지 알 수 없습니다. - 포인터로 세그먼트를 누르면 그 세그먼트의 값 전체가 선택됩니다. 자릿수 사이에는 커서를 놓을 수 없으므로 텍스트 캐럿을 그리지 않습니다. - 초점 표시는 두 겹입니다. 필드 링은 `--atom-primary-600`, 세그먼트 채움은 `--atom-fg-brand` + `--atom-fg-on-solid` 로 서로 다른 대비를 씁니다. - **대비** — 채워진 값은 4.5:1, 상자 테두리와 세그먼트 채움은 3:1 을 넘깁니다. 플레이스홀더(`HH`)는 subtle 색이라 그 자체로는 4.5:1 에 못 미치므로, 형식 안내를 플레이스홀더에만 의존하지 말고 Field 의 설명에도 남깁니다. > [!NOTE] > **23시에서 `↑` 는 0시로 순환합니다** > 시 세그먼트는 0–23 을 순환합니다. 23에서 멈추면 `00` 으로 가려고 `↓` 를 스물세 번 > 눌러야 합니다. 순환은 그 세그먼트 안에서만 일어나므로, 분이 `59` 에서 `00` 으로 넘어가도 > **시는 올라가지 않습니다.** 다른 세그먼트 값이 함께 바뀌면 확인하지 않은 변경이 저장됩니다. 요약: - **역할** — 세그먼트마다 `spinbutton`, 전체는 `group`. 구분자는 `aria-hidden`. - **키보드** — `↑` `↓` 증감(`step` 반영) · `←` `→` 세그먼트 이동 · 숫자 자동 진행 · `Backspace` 세그먼트 비우기 · 12시간제에서 `A` `P`. - **스크린리더** — 그룹 이름 + 세그먼트 이름 + `aria-valuenow`. AM/PM 만 `aria-valuetext`. - **대비** — 값 4.5:1, 테두리·세그먼트 채움 3:1. 플레이스홀더는 보조 수단으로만. ### 패널 - **역할** — 열마다 `listbox`, 값마다 `option` + `aria-selected`. 목록 구조이므로 표준 listbox 패턴을 따릅니다. 보이는 열 제목이 없으므로 열 이름은 `aria-label` 로 답니다. - **키보드** — 열 하나가 탭 정거장 하나입니다. | 키 | 동작 | | --- | --- | | `Tab` | 다음 열로 (시 → 분 → 초 → 오전 오후) | | `↑` `↓` | 그 열의 값을 위아래로. step 을 따릅니다 | | `Home` · `End` | 그 열의 첫 · 마지막 값으로 | | 숫자 | 그 값으로 점프합니다 (시 · 분 · 초 열) | | `Enter` · `Space` | 현재 값을 확정합니다 | | `Esc` | 패널을 닫고 초점을 필드로 되돌립니다 | - **`aria-activedescendant`** — 포커스는 열에 두고 현재 값을 이 속성으로 가리킵니다. 값마다 포커스를 옮기면 스크롤할 때마다 포커스가 튀어 위치를 잃습니다. - **막힌 값도 읽힙니다** — `aria-disabled="true"` 셀은 초점을 받되 선택되지 않습니다. 탭 순서에서 빼면 스크린리더 사용자는 그 시각이 막혀 있다는 것을 알 수 없습니다. - **확정 알림** — 빠르게 훑을 때 모든 값을 읽으면 소음이 되므로, 확정 시점에만 완성된 시각("오전 9시 41분")을 알립니다. - **대비** — 막힌 셀은 `opacity: 0.3` 이라 본문 대비 기준을 넘지 못합니다. 흐림은 보조 표시이고, 선택 불가 상태는 `aria-disabled` 로 전달합니다. > [!CAUTION] > **셀 높이 32px 은 권장 터치 최소치에 못 미칩니다** > WCAG 2.5.8 의 24px 은 넘지만 2.5.5 의 44px 에는 미달입니다. > [Date Input](./date-input.md) 의 날짜 셀과 같은 값이라 두 패널의 리듬은 맞습니다. > 터치가 주 입력인 화면에서는 세그먼트 타이핑 경로를 반드시 함께 열어 두세요. > [!NOTE] > **구현 메모** — 패널을 열면 첫 열(시)로 초점이 갑니다. 완성된 시각은 `.atom-sr-only` live region 으로 알립니다. > `:` 키는 다음 세그먼트로, `Alt + ↓` 는 패널을 엽니다. 빈 세그먼트에서 `Backspace` 는 이전 세그먼트로 갑니다. --- ## 7. 사용 가이드 ### 타이핑과 패널을 나눠 쓰기 한 컨트롤에 입력 경로가 둘입니다. 어느 쪽을 쓸지는 **사용자가 시각을 이미 알고 있는가**로 정하며, 화면을 설계할 때 판단합니다. | | 세그먼트 타이핑 | Panel | | --- | --- | --- | | 여는 방법 | 필드에 초점을 두고 바로 | Trigger 를 눌러서 | | 강점 | 아는 시각을 4번의 키 입력으로 | 고를 수 있는 값을 훑어봄 | | 적합한 환경 | 데스크톱 폼, 키보드 사용자 | 후보를 비교해야 하는 화면 | | 값의 범위 | step 이 허용하는 임의의 값 | 같은 집합을 목록으로 | | 고를 수 없는 값 | `min`/`max` 로 사후 검증 | `disabledTime` 으로 사전 차단 | - 출근 시각·회의 시각처럼 **사용자가 답을 알고 있는** 값 → 타이핑만으로 충분합니다. `withTrigger` 를 끄세요. - "몇 시가 비어 있지?" 처럼 **후보를 둘러봐야 하는** 값 → `withTrigger` 를 켜고 `disabledTime` 을 채웁니다. - 패널을 켜도 **타이핑 경로는 유지합니다.** 세그먼트를 읽기 전용으로 만들면 아는 시각도 목록을 스크롤해 찾아야 합니다. > [!WARNING] > **고를 값이 10개 미만이면 Select 를 씁니다** > `09:00` · `13:00` · `18:00` 셋 중 하나를 고르는 화면이라면 > [Select](./select.md) 를 씁니다. Time Input 은 값의 범위가 넓을 때 씁니다. ### 고를 수 없는 시각은 숨기지 말고 남깁니다 `hideDisabledOptions` 의 기본값은 `false` 입니다. 막힌 값을 남겨 두면 해당 시간대가 존재하고 이미 마감됐다는 것을 알 수 있습니다. 막힌 값을 빼면 같은 위치의 칸이 매번 다른 시각이 되어, 반복 사용 시 위치를 기억할 수 없습니다. - ✅ **Do** 막힌 시각을 흐리게 남겨 왜 못 고르는지 보이게 합니다. - ❌ **Don't** 막힌 시각을 목록에서 빼서 값과 위치가 어긋나게 하지 않습니다. > [!NOTE] > **켜는 경우가 있습니다** > 막힌 값이 대다수일 때입니다. 24칸 중 22칸이 막혀 있으면 남은 두 칸을 찾으려고 > 스크롤해야 합니다. 이때는 `hideDisabledOptions` 를 켜거나, > 애초에 [Select](./select.md) 로 남은 슬롯만 나열하는 편이 낫습니다. ### 형식은 플레이스홀더로 미리 보여줍니다 빈 필드에 `HH:mm` 을 표시해 입력 전에 형식을 알려줍니다. 표시가 없으면 오류 메시지를 보고 나서야 형식을 알 수 있습니다. - ✅ **Do** 빈 세그먼트에 `HH` · `mm` 을 세워 자리와 형식을 함께 보여줍니다. - ❌ **Don't** `--:--` 처럼 자리만 있고 형식을 알 수 없는 표시는 쓰지 않습니다. 12시간제라면 `hh:mm AM` 까지 보여줍니다. AM/PM 칸으로 12시간제임을 알 수 있습니다. ### 초는 실제로 쓸 때만 붙입니다 초 칸을 켜면 항상 채워야 합니다. 값이 늘 `00` 이라면 입력만 늘어납니다. - ✅ **Do** 사람이 정하는 시각은 `HH:mm` 까지만 받고, 초는 저장 시점에 `00` 으로 채웁니다. - ❌ **Don't** 예약·근무 시간 폼에 `ss` 를 켜서 늘 `00` 을 치게 하지 않습니다. - ⚠️ **Caution** 로그·타이머·측정처럼 초가 실제로 달라지는 화면에서는 반대로 켜야 합니다. 초를 버리면 값이 달라집니다. ### 오류 문구에는 어긴 규칙을 적습니다 형식은 플레이스홀더가 보여주므로, 오류는 대개 **규칙** 위반입니다. 영업 시간 밖, 시작보다 앞선 종료, `step` 에 맞지 않는 분 등이 해당합니다. 어긴 규칙과 허용 범위를 함께 적습니다. - ✅ **Do** 어긴 규칙과 허용 범위를 함께 적습니다 — "영업 시간 09:00–18:00 안에서 골라주세요". - ❌ **Don't** "올바른 시간을 입력하세요" 처럼 무엇이 틀렸는지 알 수 없는 문장은 쓰지 않습니다. > [!CAUTION] > **치는 동안 오류를 띄우지 않습니다** > `1` 만 입력한 `1:mm` 은 **미완성 값**입니다. 검증은 필드를 떠나거나 제출할 때 합니다. > 입력 중에 검사하면 두 자리 필드에서 거의 매 키마다 오류 링이 뜹니다. ### 라벨과 오류는 Field 에 맡깁니다 Time Input 은 상자와 세그먼트만 소유합니다. 라벨·필수 표시·설명·오류 문구는 [Field](./field.md) 가 감싸서 붙입니다. 두 곳에서 각자 라벨을 그리면 스크린리더가 이름을 두 번 읽습니다. - 오류일 때는 Field 와 Time Input 양쪽에 `data-invalid` 를 겁니다. 하나는 문구를, 하나는 링을 담당합니다. - Field 의 라벨을 클릭하면 **첫 세그먼트**(`HH`)로 초점이 갑니다. - 시작·종료처럼 두 필드가 짝을 이루면 각각 Field 로 감싸고, 두 값을 함께 보는 검증은 종료 쪽 Field 에 붙입니다. ### 관련 컴포넌트 - [Text Input](./text-input.md) — 상자·높이·초점 링의 기준입니다. - [Field](./field.md) — 라벨·설명·오류 문구를 붙입니다. - [Select](./select.md) — 고를 시각이 몇 개 안 될 때. - [Date Input](./date-input.md) — 같은 세그먼트 + 패널 구조입니다. 날짜와 시각을 함께 받을 때는 두 필드를 나란히 놓습니다. --- ## 8. 예시 ```tsx ({ disabledHours: () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 19, 20, 21, 22, 23] })} value={time} onChange={setTime} /> ``` ```html
09 41 AM
08 09 10
41
```