@pantoia/ui 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Pantoia UI
1
+ # @pantoia/ui
2
2
 
3
3
  React 디자인 시스템 패키지. 문서는 `design.pantoia.com`
4
4
 
@@ -19,7 +19,7 @@ npx @pantoia/ui diff 복사한 소스와 배포본의 차이
19
19
 
20
20
  ## 개발
21
21
 
22
- Node 20.11 이상이 필요하다.
22
+ 개발 런타임은 Node 24 LTS다.
23
23
 
24
24
  ```
25
25
  git clone git@github.com:pantoia/pantoia-ui.git
@@ -0,0 +1,376 @@
1
+ 'use client'
2
+
3
+ import { Field as FieldPrimitive } from '@base-ui/react/field'
4
+ import { useEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from 'react'
5
+
6
+ import { BottomSheet, BottomSheetBody, BottomSheetContent, BottomSheetHeader, BottomSheetTitle } from '../bottom-sheet'
7
+ import { Button } from '../button'
8
+ import { input } from '../input'
9
+ import { Popover, PopoverContent, PopoverTrigger } from '../popover'
10
+ import { tv } from '../tv'
11
+
12
+ const WIDE = '(min-width: 48rem)'
13
+
14
+ const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']
15
+
16
+ const DAY_STEPS: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }
17
+
18
+ const FOCUS_ATTR = 'data-picker-focus'
19
+
20
+ export const pickerShell = tv({
21
+ slots: {
22
+ trigger: 'flex cursor-pointer items-center justify-between gap-tight text-left whitespace-nowrap'
23
+ + ' read-only:text-fg disabled:cursor-not-allowed',
24
+ text: 'min-w-0 truncate tabular-nums',
25
+ icon: 'text-fg-subtle size-4 shrink-0',
26
+ },
27
+
28
+ variants: {
29
+ surface: {
30
+ bare: { trigger: 'read-only:bg-transparent' },
31
+ well: {},
32
+ ghost: { trigger: 'enabled:not-focus:hover:bg-bg-neutral-hover read-only:focus:bg-bg-neutral-selected' },
33
+ },
34
+ empty: {
35
+ true: { text: 'text-fg-placeholder' },
36
+ },
37
+ },
38
+ })
39
+
40
+ const datePickerCalendar = tv({
41
+ slots: {
42
+ root: 'flex w-full min-w-70 flex-col gap-1.5',
43
+ head: 'flex items-center justify-between',
44
+ title: 'text-t14 font-bold tabular-nums',
45
+ titleButton: 'text-t14 h-8 rounded-r8 px-2 font-bold tabular-nums transition-state hover:bg-bg-neutral-hover'
46
+ + ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring',
47
+ months: 'grid grid-cols-4 gap-1',
48
+ month: 'text-t13 h-11 rounded-r8 tabular-nums transition-state'
49
+ + ' not-on:hover:bg-bg-neutral-hover on:bg-bg-neutral-solid on:font-bold on:text-fg-neutral-solid-contrast'
50
+ + ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring',
51
+ days: 'grid grid-cols-7 justify-items-center gap-y-1.5',
52
+ weekday: 'text-t12 text-fg-subtle py-1',
53
+ day: 'text-t13 flex size-8 items-center justify-center rounded-full tabular-nums transition-state'
54
+ + ' enabled:not-on:hover:bg-bg-neutral-hover on:bg-bg-neutral-solid on:font-bold on:text-fg-neutral-solid-contrast'
55
+ + ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring'
56
+ + ' disabled:cursor-not-allowed disabled:opacity-40',
57
+ foot: 'flex justify-end',
58
+ navIcon: 'size-4',
59
+ },
60
+
61
+ variants: {
62
+ tone: {
63
+ plain: {},
64
+ outside: { day: 'text-fg-subtle' },
65
+ sunday: { day: 'text-fg-danger' },
66
+ },
67
+ today: {
68
+ true: { day: 'not-on:font-bold not-on:ring-1 not-on:ring-inset not-on:ring-line-strong' },
69
+ },
70
+ },
71
+ })
72
+
73
+ const pad = (value: number) => String(value).padStart(2, '0')
74
+
75
+ const toDay = (year: number, month: number, date: number) => `${year}-${pad(month + 1)}-${pad(date)}`
76
+
77
+ const toUtc = (day: string) => new Date(`${day}T00:00:00Z`)
78
+
79
+ function addDays(day: string, amount: number) {
80
+ const next = toUtc(day)
81
+ next.setUTCDate(next.getUTCDate() + amount)
82
+
83
+ return next.toISOString().slice(0, 10)
84
+ }
85
+
86
+ function localToday() {
87
+ const now = new Date()
88
+
89
+ return toDay(now.getFullYear(), now.getMonth(), now.getDate())
90
+ }
91
+
92
+ function viewOf(day: string) {
93
+ const date = toUtc(day)
94
+
95
+ return { year: date.getUTCFullYear(), month: date.getUTCMonth() }
96
+ }
97
+
98
+ function fullDate(day: string) {
99
+ const [year, month, date] = day.split('-').map(Number)
100
+
101
+ return `${year}년 ${month}월 ${date}일`
102
+ }
103
+
104
+ const media = () => (typeof window.matchMedia === 'function' ? window.matchMedia(WIDE) : null)
105
+
106
+ function subscribe(notify: () => void) {
107
+ const list = media()
108
+ list?.addEventListener('change', notify)
109
+
110
+ return () => list?.removeEventListener('change', notify)
111
+ }
112
+
113
+ function NavButton({ label, direction, onClick }: { label: string, direction: 'left' | 'right', onClick: () => void }) {
114
+ return (
115
+ <Button type="button" color="neutral" emphasis="ghost" size="sm" shape="square" aria-label={label} onClick={onClick}>
116
+ <svg className={datePickerCalendar().navIcon()} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
117
+ <path d={direction === 'left' ? 'm15 18-6-6 6-6' : 'm9 18 6-6-6-6'} />
118
+ </svg>
119
+ </Button>
120
+ )
121
+ }
122
+
123
+ interface CalendarProps {
124
+ value: string | null
125
+ min?: string
126
+ max?: string
127
+ today: string
128
+ onPick: (day: string) => void
129
+ }
130
+
131
+ function Calendar({ value, min, max, today, onPick }: CalendarProps) {
132
+ const [view, setView] = useState(() => viewOf(value ?? today))
133
+ const [mode, setMode] = useState<'days' | 'months'>('days')
134
+ const [focused, setFocused] = useState(value ?? today)
135
+ const grid = useRef<HTMLDivElement>(null)
136
+ const moved = useRef(false)
137
+ const ui = datePickerCalendar()
138
+ const allowed = (day: string) => !(min && day < min) && !(max && day > max)
139
+
140
+ useEffect(() => {
141
+ if (!moved.current) return
142
+
143
+ moved.current = false
144
+ grid.current?.querySelector<HTMLElement>(`[data-day="${focused}"]`)?.focus()
145
+ }, [focused, view])
146
+
147
+ const showMonth = (year: number, month: number, focus = toDay(year, month, 1)) => {
148
+ const first = new Date(Date.UTC(year, month, 1))
149
+
150
+ setView({ year: first.getUTCFullYear(), month: first.getUTCMonth() })
151
+ setFocused(focus)
152
+ }
153
+
154
+ const onDayKey = (event: KeyboardEvent<HTMLButtonElement>, day: string) => {
155
+ const step = DAY_STEPS[event.key]
156
+ if (!step) return
157
+
158
+ event.preventDefault()
159
+ const next = addDays(day, step)
160
+
161
+ if (allowed(next)) {
162
+ moved.current = true
163
+ showMonth(viewOf(next).year, viewOf(next).month, next)
164
+ }
165
+ }
166
+
167
+ if (mode === 'months') {
168
+ return (
169
+ <div className={ui.root()}>
170
+ <div className={ui.head()}>
171
+ <NavButton label="이전 해" direction="left" onClick={() => setView({ ...view, year: view.year - 1 })} />
172
+ <span className={ui.title()}>{view.year}년</span>
173
+ <NavButton label="다음 해" direction="right" onClick={() => setView({ ...view, year: view.year + 1 })} />
174
+ </div>
175
+ <div className={ui.months()}>
176
+ {Array.from({ length: 12 }, (_, month) => (
177
+ <button
178
+ key={month}
179
+ type="button"
180
+ aria-pressed={month === view.month}
181
+ className={ui.month()}
182
+ onClick={() => {
183
+ showMonth(view.year, month)
184
+ setMode('days')
185
+ }}
186
+ >
187
+ {month + 1}월
188
+ </button>
189
+ ))}
190
+ </div>
191
+ </div>
192
+ )
193
+ }
194
+
195
+ const start = addDays(toDay(view.year, view.month, 1), -new Date(Date.UTC(view.year, view.month, 1)).getUTCDay())
196
+ const days = Array.from({ length: 42 }, (_, index) => addDays(start, index))
197
+ const target = days.includes(focused) && allowed(focused)
198
+ ? focused
199
+ : days.find(day => viewOf(day).month === view.month && allowed(day))
200
+
201
+ return (
202
+ <div className={ui.root()}>
203
+ <div className={ui.head()}>
204
+ <NavButton label="이전 달" direction="left" onClick={() => showMonth(view.year, view.month - 1)} />
205
+ <button type="button" aria-label="연월 고르기" className={ui.titleButton()} onClick={() => setMode('months')}>
206
+ {view.year}년 {view.month + 1}월
207
+ </button>
208
+ <NavButton label="다음 달" direction="right" onClick={() => showMonth(view.year, view.month + 1)} />
209
+ </div>
210
+ <div ref={grid} className={ui.days()}>
211
+ {WEEKDAYS.map(day => <span key={day} className={ui.weekday()}>{day}</span>)}
212
+ {days.map((day, index) => {
213
+ const tone = viewOf(day).month !== view.month ? 'outside' : index % 7 === 0 ? 'sunday' : 'plain'
214
+
215
+ return (
216
+ <button
217
+ key={day}
218
+ type="button"
219
+ data-day={day}
220
+ {...{ [FOCUS_ATTR]: day === target ? '' : undefined }}
221
+ disabled={!allowed(day)}
222
+ tabIndex={day === target ? 0 : -1}
223
+ aria-label={fullDate(day)}
224
+ aria-pressed={day === value}
225
+ aria-current={day === today ? 'date' : undefined}
226
+ className={datePickerCalendar({ tone, today: day === today }).day()}
227
+ onKeyDown={event => onDayKey(event, day)}
228
+ onClick={() => onPick(day)}
229
+ >
230
+ {Number(day.slice(8))}
231
+ </button>
232
+ )
233
+ })}
234
+ </div>
235
+ <div className={ui.foot()}>
236
+ <Button type="button" color="neutral" emphasis="ghost" size="sm" onClick={() => showMonth(viewOf(today).year, viewOf(today).month, today)}>
237
+ 오늘
238
+ </Button>
239
+ </div>
240
+ </div>
241
+ )
242
+ }
243
+
244
+ export interface PickerTriggerProps {
245
+ id?: string
246
+ disabled?: boolean
247
+ surface?: 'bare' | 'well' | 'ghost'
248
+ size?: 'sm' | 'md' | 'lg'
249
+ className?: string
250
+ 'aria-label'?: string
251
+ 'aria-invalid'?: boolean
252
+ }
253
+
254
+ export type PickerShellProps = PickerTriggerProps & {
255
+ value: string | null
256
+ text: ReactNode
257
+ placeholder: string
258
+ title: string
259
+ open: boolean
260
+ onOpenChange: (open: boolean) => void
261
+ children: ReactNode
262
+ }
263
+
264
+ export function PickerShell({ title, open, onOpenChange, children, value, text, placeholder, id, disabled, surface = 'bare', size, className, ...aria }: PickerShellProps) {
265
+ const wide = useSyncExternalStore(subscribe, () => media()?.matches ?? false, () => false)
266
+ const popup = useRef<HTMLDivElement>(null)
267
+ const ui = pickerShell({ surface, empty: value === null })
268
+
269
+ const trigger = (onClick?: () => void) => (
270
+ <FieldPrimitive.Control
271
+ id={id}
272
+ disabled={disabled}
273
+ render={(props) => {
274
+ const { onChange: _onChange, defaultValue: _defaultValue, value: _value, ...rest } = props as typeof props & { value?: unknown }
275
+
276
+ return (
277
+ <button
278
+ {...rest}
279
+ {...aria}
280
+ type="button"
281
+ value={value ?? ''}
282
+ onClick={(event) => {
283
+ rest.onClick?.(event as never)
284
+ onClick?.()
285
+ }}
286
+ className={input({ surface, size, className: ui.trigger({ className }) })}
287
+ >
288
+ <span className={ui.text()}>{value === null ? placeholder : text}</span>
289
+ <svg className={ui.icon()} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
290
+ <path d="m6 9 6 6 6-6" />
291
+ </svg>
292
+ </button>
293
+ )
294
+ }}
295
+ />
296
+ )
297
+
298
+ if (!wide) {
299
+ return (
300
+ <>
301
+ {trigger(() => onOpenChange(true))}
302
+ <BottomSheet open={open} onOpenChange={next => onOpenChange(next)}>
303
+ <BottomSheetContent initialFocus={false}>
304
+ <BottomSheetHeader closable>
305
+ <BottomSheetTitle>{title}</BottomSheetTitle>
306
+ </BottomSheetHeader>
307
+ <BottomSheetBody>{children}</BottomSheetBody>
308
+ </BottomSheetContent>
309
+ </BottomSheet>
310
+ </>
311
+ )
312
+ }
313
+
314
+ return (
315
+ <Popover open={open} onOpenChange={next => onOpenChange(next)}>
316
+ <PopoverTrigger render={trigger()} />
317
+ <PopoverContent
318
+ ref={popup}
319
+ aria-label={title}
320
+ align="start"
321
+ arrow={false}
322
+ className="p-3"
323
+ initialFocus={() => popup.current?.querySelector<HTMLElement>(`[${FOCUS_ATTR}]`) ?? true}
324
+ >
325
+ {children}
326
+ </PopoverContent>
327
+ </Popover>
328
+ )
329
+ }
330
+
331
+ export type DatePickerProps = PickerTriggerProps & {
332
+ value?: string | null
333
+ defaultValue?: string | null
334
+ onValueChange?: (value: string) => void
335
+ min?: string
336
+ max?: string
337
+ today?: string
338
+ title?: string
339
+ placeholder?: string
340
+ format?: (day: string) => ReactNode
341
+ }
342
+
343
+ export function DatePicker({
344
+ value,
345
+ defaultValue = null,
346
+ onValueChange,
347
+ min,
348
+ max,
349
+ today,
350
+ title = '날짜 고르기',
351
+ placeholder = '날짜를 고르세요',
352
+ format = fullDate,
353
+ ...trigger
354
+ }: DatePickerProps) {
355
+ const [open, setOpen] = useState(false)
356
+ const [inner, setInner] = useState<string | null>(defaultValue)
357
+ const current = value === undefined ? inner : value
358
+
359
+ return (
360
+ <PickerShell {...trigger} title={title} value={current} text={current === null ? null : format(current)} placeholder={placeholder} open={open} onOpenChange={setOpen}>
361
+ <Calendar
362
+ value={current}
363
+ min={min}
364
+ max={max}
365
+ today={today ?? localToday()}
366
+ onPick={(next) => {
367
+ setOpen(false)
368
+ if (next === current) return
369
+
370
+ onValueChange?.(next)
371
+ if (value === undefined) setInner(next)
372
+ }}
373
+ />
374
+ </PickerShell>
375
+ )
376
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantoia/ui",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Pantoia Design System",
5
5
  "homepage": "https://design.pantoia.com",
6
6
  "repository": {
@@ -64,13 +64,13 @@
64
64
  "@types/node": "26.4.0",
65
65
  "@types/react": "19.2.18",
66
66
  "@types/react-dom": "19.2.5",
67
- "happy-dom": "^20.12.0",
67
+ "happy-dom": "^21.0.4",
68
68
  "oxlint": "1.81.0",
69
69
  "react": "19.2.8",
70
70
  "react-dom": "19.2.8",
71
71
  "tailwindcss": "4.3.3",
72
72
  "tsx": "4.23.12",
73
73
  "typescript": "7.0.2",
74
- "vitest": "^4.1.11"
74
+ "vitest": "^4.1.12"
75
75
  }
76
76
  }
package/registry.json CHANGED
@@ -305,6 +305,30 @@
305
305
  ]
306
306
  }
307
307
  },
308
+ {
309
+ "name": "date-picker",
310
+ "type": "registry:ui",
311
+ "title": "DatePicker",
312
+ "files": [
313
+ {
314
+ "path": "date-picker/index.tsx",
315
+ "type": "registry:ui"
316
+ }
317
+ ],
318
+ "dependencies": [
319
+ "@base-ui/react"
320
+ ],
321
+ "registryDependencies": [
322
+ "bottom-sheet",
323
+ "button",
324
+ "input",
325
+ "popover",
326
+ "tv"
327
+ ],
328
+ "meta": {
329
+ "layer": "ui"
330
+ }
331
+ },
308
332
  {
309
333
  "name": "dialog",
310
334
  "type": "registry:ui",
@@ -754,6 +778,24 @@
754
778
  "layer": "ui"
755
779
  }
756
780
  },
781
+ {
782
+ "name": "time-picker",
783
+ "type": "registry:ui",
784
+ "title": "TimePicker",
785
+ "files": [
786
+ {
787
+ "path": "time-picker/index.tsx",
788
+ "type": "registry:ui"
789
+ }
790
+ ],
791
+ "registryDependencies": [
792
+ "date-picker",
793
+ "tv"
794
+ ],
795
+ "meta": {
796
+ "layer": "derived"
797
+ }
798
+ },
757
799
  {
758
800
  "name": "toast",
759
801
  "type": "registry:ui",
@@ -48,7 +48,7 @@ import 경로는 저장 위치와 프로젝트의 경로 별칭을 따른다.
48
48
  패키지는 컴포넌트와 `tv`를 export하지 않는다. `add`로 복사한 파일만 import한다.
49
49
 
50
50
  `value`와 `defaultValue`, `onValueChange`를 짝으로 쓴다.
51
- 고르지 않은 상태는 `null`이다. Select, RadioGroup, Combobox, NumberField가 같다.
51
+ 고르지 않은 상태는 `null`이다. Select, RadioGroup, Combobox, NumberField, DatePicker, TimePicker가 같다.
52
52
  Select와 RadioGroup의 state는 `useState<string | null>`로 둔다.
53
53
 
54
54
  ## 디자인 규칙
@@ -104,6 +104,19 @@ Tailwind 기본 색 bg-blue-500, text-gray-700. 역할 토큰을 쓴다
104
104
  px 직접 지정 간격과 둥글기는 토큰으로 쓴다
105
105
  ```
106
106
 
107
+ ### 비활성
108
+
109
+ disabled는 이럴 때만 준다.
110
+
111
+ ```
112
+ 처리 중인 버튼과 메뉴 항목 끝날 때까지 다시 누르지 못하게 한다
113
+ 값을 고르거나 입력하는 컨트롤 Checkbox, RadioGroup, Select와 그 항목, Switch, NumberField. 이유를 설명이나 도움말에 적는다
114
+ 확인 문구를 입력해야 하는 버튼 입력하기 전까지
115
+ 아직 갈 수 없는 단계 단계 표시의 다음 단계
116
+ ```
117
+
118
+ 그 밖의 버튼과 메뉴 항목은 조건이 맞지 않아도 disabled로 두지 않는다. 누르면 이유를 `toast.warning`으로 알린다.
119
+
107
120
  ## 커스텀 값 추가
108
121
 
109
122
  전역 CSS에 적는다. 값은 라이트와 다크 양쪽에 모두 적는다.
@@ -158,6 +171,7 @@ className 그 한 곳만 바뀐다
158
171
  | Combobox | combobox | Combobox, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxCancel, ComboboxContent, ComboboxViewport, ComboboxCollection, ComboboxItem, ComboboxItemText, ComboboxItemDescription, ComboboxGroup, ComboboxEmpty, ComboboxSeparator | surface, size |
159
172
  | CommandPalette | command-palette | CommandPalette | - |
160
173
  | ContextMenu | context-menu | ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuItemDescription, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent | color |
174
+ | DatePicker | date-picker | DatePicker, PickerShell | surface, size |
161
175
  | Dialog | dialog | Dialog, DialogTrigger, DialogClose, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogBody, DialogFooter | size |
162
176
  | Drawer | drawer | Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter | side |
163
177
  | DropdownMenu | dropdown-menu | DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemDescription, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent | color |
@@ -179,6 +193,7 @@ className 그 한 곳만 바뀐다
179
193
  | Table | table | Table, TableHeader, TableHeadCell, TableBody, TableRow, TableCell | fixed, mono |
180
194
  | Tabs | tabs | Tabs, TabsList, TabsTrigger, TabsContent | - |
181
195
  | Textarea | textarea | Textarea | surface, size |
196
+ | TimePicker | time-picker | TimePicker | surface, size |
182
197
  | Toast | toast | Toast | - |
183
198
  | Toggle | toggle | Toggle | size, shape, color, surface |
184
199
  | ToggleGroup | toggle-group | ToggleGroup, ToggleGroupItem | size, shape, surface |
@@ -212,11 +227,13 @@ Dialog, AlertDialog, Drawer, BottomSheet의 Footer는 layout으로 버튼을 배
212
227
 
213
228
  Modal은 기준 폭(기본 xs, 480px)보다 넓으면 Dialog, 좁으면 BottomSheet로 연다.
214
229
 
215
- Input, Textarea, NumberField, Select, ComboboxAnchor의 surface는 폼에서는 bare나 well을 쓰고, 표 셀, 헤더, 문장 안에서 값을 바로 고칠 때는 ghost를 쓴다.
230
+ DatePicker는 `YYYY-MM-DD`, TimePicker는 `HH:mm` 글자 값을 쓴다. 48rem보다 넓으면 Popover, 좁으면 BottomSheet로 연다. 폭은 wrapper로 정한다.
231
+
232
+ Input, Textarea, NumberField, Select, ComboboxAnchor, DatePicker, TimePicker의 surface는 폼에서는 bare나 well을 쓰고, 표 셀, 헤더, 문장 안에서 값을 바로 고칠 때는 ghost를 쓴다.
216
233
 
217
234
  Fieldset 안의 컨트롤은 Field로 감싼다. Fieldset의 disabled는 Field를 거쳐 컨트롤에 전달된다.
218
235
 
219
- Field는 라벨, 도움말, 오류 메시지를 입력 컨트롤에 묶는다. Input, Textarea, NumberField, Select, Combobox와 함께 쓴다.
236
+ Field는 라벨, 도움말, 오류 메시지를 입력 컨트롤에 묶는다. Input, Textarea, NumberField, Select, Combobox, DatePicker, TimePicker와 함께 쓴다.
220
237
 
221
238
  Select는 고른 항목의 `SelectItemText`나 글자를 트리거에 보인다. `SelectItem`을 다른 컴포넌트로 감싸면 `items`로 라벨을 넘긴다.
222
239
  Combobox 항목이 `{ value, label }`이 아닌 객체면 `itemToStringLabel`로 `ComboboxInput`에 보일 글자를 정한다.
@@ -235,7 +252,18 @@ import { TooltipProvider } from '@/components/ui/tooltip'
235
252
  </TooltipProvider>
236
253
  ```
237
254
 
238
- 알림은 `toast('저장했어요.')`로 띄운다. `toast.success`, `toast.error`, `toast.warning`, `toast.info`, `toast.dismiss`도 있다.
255
+ 알림은 일어난 일에 맞는 함수로 띄운다. 예시는 https://design.pantoia.com/toast/ 에 있다.
256
+
257
+ ```
258
+ toast.success 요청한 작업을 끝냈다
259
+ toast.error 요청이 실패했거나 대상을 찾을 수 없다
260
+ toast.warning 작업이 일부만 끝났다. 권한, 상태, 규칙 때문에 할 수 없는 동작을 눌렀다
261
+ toast.info 작업이 진행 중이다. 작업을 되돌렸다
262
+ toast() 결과와 함께 다음 할 일이나 보이는 위치를 알린다. 바뀐 것이 없다
263
+ toast.dismiss 띄운 알림을 닫는다
264
+ ```
265
+
266
+ 고쳐야 할 입력은 알림으로 띄우지 않고 FieldError나 Callout으로 화면에 남긴다. FieldError를 둘 곳이 없을 때만 `toast.warning`을 쓴다.
239
267
 
240
268
  ## 컴포넌트 내려받기
241
269
 
@@ -0,0 +1,160 @@
1
+ 'use client'
2
+
3
+ import { useState, type KeyboardEvent } from 'react'
4
+
5
+ import { PickerShell, type PickerTriggerProps } from '../date-picker'
6
+ import { tv } from '../tv'
7
+
8
+ const DEFAULT_HOUR = 9
9
+
10
+ const timePicker = tv({
11
+ slots: {
12
+ root: 'grid w-full min-w-52 grid-cols-3 gap-1',
13
+ column: 'scrollbar-thin scrollbar-thumb-line-strong scrollbar-track-transparent flex h-54 flex-col gap-0.5 overflow-y-auto',
14
+ item: 'text-t13 h-9 shrink-0 rounded-r8 tabular-nums transition-state'
15
+ + ' not-on:hover:bg-bg-neutral-hover on:bg-bg-neutral-solid on:font-bold on:text-fg-neutral-solid-contrast'
16
+ + ' focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-line-focus-ring',
17
+ },
18
+ })
19
+
20
+ const pad = (value: number) => String(value).padStart(2, '0')
21
+
22
+ function partsOf(value: string | null) {
23
+ if (value === null) return { hour: DEFAULT_HOUR, minute: 0 }
24
+
25
+ const [hour = DEFAULT_HOUR, minute = 0] = value.split(':').map(Number)
26
+
27
+ return { hour, minute }
28
+ }
29
+
30
+ function clockText(value: string) {
31
+ const { hour, minute } = partsOf(value)
32
+
33
+ return `${hour < 12 ? '오전' : '오후'} ${hour % 12 || 12}:${pad(minute)}`
34
+ }
35
+
36
+ function centerInColumn(node: HTMLButtonElement | null) {
37
+ const column = node?.parentElement
38
+
39
+ if (node && column)
40
+ column.scrollTop = node.offsetTop - column.offsetTop - (column.clientHeight - node.offsetHeight) / 2
41
+ }
42
+
43
+ interface ColumnItem {
44
+ key: number
45
+ label: string
46
+ }
47
+
48
+ interface ColumnProps {
49
+ label: string
50
+ items: ColumnItem[]
51
+ anchor: number
52
+ picked: boolean
53
+ focusFirst?: boolean
54
+ onPick: (key: number) => void
55
+ }
56
+
57
+ function Column({ label, items, anchor, picked, focusFirst, onPick }: ColumnProps) {
58
+ const ui = timePicker()
59
+
60
+ const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
61
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
62
+
63
+ event.preventDefault()
64
+ const index = items.findIndex(item => item.key === anchor) + (event.key === 'ArrowDown' ? 1 : -1)
65
+ const next = items[index]
66
+ if (!next) return
67
+
68
+ onPick(next.key)
69
+ event.currentTarget.querySelectorAll('button')[index]?.focus()
70
+ }
71
+
72
+ return (
73
+ <div role="group" aria-label={label} className={ui.column()} onKeyDown={onKey}>
74
+ {items.map(item => (
75
+ <button
76
+ key={item.key}
77
+ ref={item.key === anchor ? centerInColumn : undefined}
78
+ type="button"
79
+ aria-pressed={picked && item.key === anchor}
80
+ data-picker-focus={focusFirst && item.key === anchor ? '' : undefined}
81
+ tabIndex={item.key === anchor ? 0 : -1}
82
+ className={ui.item()}
83
+ onClick={() => onPick(item.key)}
84
+ >
85
+ {item.label}
86
+ </button>
87
+ ))}
88
+ </div>
89
+ )
90
+ }
91
+
92
+ export type TimePickerProps = PickerTriggerProps & {
93
+ value?: string | null
94
+ defaultValue?: string | null
95
+ onValueChange?: (value: string) => void
96
+ step?: number
97
+ title?: string
98
+ placeholder?: string
99
+ }
100
+
101
+ export function TimePicker({
102
+ value,
103
+ defaultValue = null,
104
+ onValueChange,
105
+ step = 1,
106
+ title = '시각 고르기',
107
+ placeholder = '시각을 고르세요',
108
+ ...trigger
109
+ }: TimePickerProps) {
110
+ const [open, setOpen] = useState(false)
111
+ const [inner, setInner] = useState<string | null>(defaultValue)
112
+ const current = value === undefined ? inner : value
113
+ const picked = current !== null
114
+ const { hour, minute } = partsOf(current)
115
+ const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, index) => index * step)
116
+
117
+ const set = (nextHour: number, nextMinute: number) => {
118
+ const next = `${pad(nextHour)}:${pad(nextMinute)}`
119
+
120
+ onValueChange?.(next)
121
+ if (value === undefined) setInner(next)
122
+ }
123
+
124
+ if (!minutes.includes(minute)) {
125
+ minutes.push(minute)
126
+ minutes.sort((a, b) => a - b)
127
+ }
128
+
129
+ return (
130
+ <PickerShell {...trigger} title={title} value={current} text={current === null ? null : clockText(current)} placeholder={placeholder} open={open} onOpenChange={setOpen}>
131
+ <div className={timePicker().root()}>
132
+ <Column
133
+ label="오전 오후"
134
+ picked={picked}
135
+ anchor={hour < 12 ? 0 : 1}
136
+ items={[
137
+ { key: 0, label: '오전' },
138
+ { key: 1, label: '오후' },
139
+ ]}
140
+ onPick={key => set(hour % 12 + (key === 1 ? 12 : 0), minute)}
141
+ />
142
+ <Column
143
+ label="시"
144
+ focusFirst
145
+ picked={picked}
146
+ anchor={hour % 12 || 12}
147
+ items={Array.from({ length: 12 }, (_, index) => ({ key: index + 1, label: `${index + 1}시` }))}
148
+ onPick={key => set(key % 12 + (hour >= 12 ? 12 : 0), minute)}
149
+ />
150
+ <Column
151
+ label="분"
152
+ picked={picked}
153
+ anchor={minute}
154
+ items={minutes.map(key => ({ key, label: `${pad(key)}분` }))}
155
+ onPick={key => set(hour, key)}
156
+ />
157
+ </div>
158
+ </PickerShell>
159
+ )
160
+ }