@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 +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 +7 -3
- package/swatch-picker/index.tsx +1 -1
- 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.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": "^
|
|
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
|
## 디자인 규칙
|
|
@@ -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
|
-
|
|
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`에 보일 글자를 정한다.
|
package/swatch-picker/index.tsx
CHANGED
|
@@ -134,7 +134,7 @@ export interface SwatchPickerCustomProps {
|
|
|
134
134
|
disabled?: boolean
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
export function SwatchPickerCustom({ label = '직접
|
|
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
|
+
}
|