@pantoia/ui 0.15.3 → 0.16.1

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.3",
3
+ "version": "0.16.1",
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
  ## 디자인 규칙
@@ -171,6 +171,7 @@ className 그 한 곳만 바뀐다
171
171
  | Combobox | combobox | Combobox, ComboboxAnchor, ComboboxInput, ComboboxTrigger, ComboboxCancel, ComboboxContent, ComboboxViewport, ComboboxCollection, ComboboxItem, ComboboxItemText, ComboboxItemDescription, ComboboxGroup, ComboboxEmpty, ComboboxSeparator | surface, size |
172
172
  | CommandPalette | command-palette | CommandPalette | - |
173
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 |
174
175
  | Dialog | dialog | Dialog, DialogTrigger, DialogClose, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogBody, DialogFooter | size |
175
176
  | Drawer | drawer | Drawer, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerBody, DrawerFooter | side |
176
177
  | DropdownMenu | dropdown-menu | DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemDescription, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent | color |
@@ -192,6 +193,7 @@ className 그 한 곳만 바뀐다
192
193
  | Table | table | Table, TableHeader, TableHeadCell, TableBody, TableRow, TableCell | fixed, mono |
193
194
  | Tabs | tabs | Tabs, TabsList, TabsTrigger, TabsContent | - |
194
195
  | Textarea | textarea | Textarea | surface, size |
196
+ | TimePicker | time-picker | TimePicker | surface, size |
195
197
  | Toast | toast | Toast | - |
196
198
  | Toggle | toggle | Toggle | size, shape, color, surface |
197
199
  | ToggleGroup | toggle-group | ToggleGroup, ToggleGroupItem | size, shape, surface |
@@ -225,11 +227,13 @@ Dialog, AlertDialog, Drawer, BottomSheet의 Footer는 layout으로 버튼을 배
225
227
 
226
228
  Modal은 기준 폭(기본 xs, 480px)보다 넓으면 Dialog, 좁으면 BottomSheet로 연다.
227
229
 
228
- 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를 쓴다.
229
233
 
230
234
  Fieldset 안의 컨트롤은 Field로 감싼다. Fieldset의 disabled는 Field를 거쳐 컨트롤에 전달된다.
231
235
 
232
- Field는 라벨, 도움말, 오류 메시지를 입력 컨트롤에 묶는다. Input, Textarea, NumberField, Select, Combobox와 함께 쓴다.
236
+ Field는 라벨, 도움말, 오류 메시지를 입력 컨트롤에 묶는다. Input, Textarea, NumberField, Select, Combobox, DatePicker, TimePicker와 함께 쓴다.
233
237
 
234
238
  Select는 고른 항목의 `SelectItemText`나 글자를 트리거에 보인다. `SelectItem`을 다른 컴포넌트로 감싸면 `items`로 라벨을 넘긴다.
235
239
  Combobox 항목이 `{ value, label }`이 아닌 객체면 `itemToStringLabel`로 `ComboboxInput`에 보일 글자를 정한다.
@@ -134,7 +134,7 @@ export interface SwatchPickerCustomProps {
134
134
  disabled?: boolean
135
135
  }
136
136
 
137
- export function SwatchPickerCustom({ label = '직접 고르기', disabled }: SwatchPickerCustomProps) {
137
+ export function SwatchPickerCustom({ label = '직접 선택', disabled }: SwatchPickerCustomProps) {
138
138
  const { size, value, choose, known } = use(Context)
139
139
  const ui = swatchPicker({ size })
140
140
  const input = useRef<HTMLInputElement>(null)
@@ -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
+ }