@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 +2 -2
- package/date-picker/index.tsx +376 -0
- package/package.json +3 -3
- package/registry.json +42 -0
- package/skills/pantoia-design/SKILL.md +32 -4
- package/time-picker/index.tsx +160 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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
|
|
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.
|
|
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": "^
|
|
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.
|
|
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
|
-
|
|
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
|
-
알림은
|
|
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
|
+
}
|