@pantoia/ui 0.16.3 → 0.17.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/date-picker/index.tsx +66 -7
- package/package.json +1 -1
- package/skills/pantoia-design/SKILL.md +1 -0
- package/time-picker/index.tsx +22 -5
package/date-picker/index.tsx
CHANGED
|
@@ -23,6 +23,11 @@ export const pickerShell = tv({
|
|
|
23
23
|
+ ' read-only:text-fg disabled:cursor-not-allowed',
|
|
24
24
|
text: 'min-w-0 truncate tabular-nums',
|
|
25
25
|
icon: 'text-fg-subtle size-4 shrink-0',
|
|
26
|
+
root: 'relative flex min-w-0',
|
|
27
|
+
spacer: 'ms-auto size-5 shrink-0',
|
|
28
|
+
clear: 'text-fg-subtle absolute top-1/2 right-8 grid size-5 -translate-y-1/2 place-items-center rounded-full'
|
|
29
|
+
+ ' transition-state press-shrink hover:text-fg'
|
|
30
|
+
+ ' focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-line-focus-ring',
|
|
26
31
|
},
|
|
27
32
|
|
|
28
33
|
variants: {
|
|
@@ -251,6 +256,10 @@ export interface PickerTriggerProps {
|
|
|
251
256
|
'aria-invalid'?: boolean
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
export type PickerClearProps =
|
|
260
|
+
| { clearable?: false, clearLabel?: never, onValueChange?: (value: string) => void }
|
|
261
|
+
| { clearable: true, clearLabel?: string, onValueChange?: (value: string | null) => void }
|
|
262
|
+
|
|
254
263
|
export type PickerShellProps = PickerTriggerProps & {
|
|
255
264
|
value: string | null
|
|
256
265
|
text: ReactNode
|
|
@@ -258,13 +267,18 @@ export type PickerShellProps = PickerTriggerProps & {
|
|
|
258
267
|
title: string
|
|
259
268
|
open: boolean
|
|
260
269
|
onOpenChange: (open: boolean) => void
|
|
270
|
+
clearable?: boolean
|
|
271
|
+
clearLabel?: string
|
|
272
|
+
onClear?: () => void
|
|
261
273
|
children: ReactNode
|
|
262
274
|
}
|
|
263
275
|
|
|
264
|
-
export function PickerShell({ title, open, onOpenChange, children, value, text, placeholder, id, disabled, surface = 'bare', size, className, ...aria }: PickerShellProps) {
|
|
276
|
+
export function PickerShell({ title, open, onOpenChange, clearable = false, clearLabel = '지우기', onClear, children, value, text, placeholder, id, disabled, surface = 'bare', size, className, ...aria }: PickerShellProps) {
|
|
265
277
|
const wide = useSyncExternalStore(subscribe, () => media()?.matches ?? false, () => false)
|
|
266
278
|
const popup = useRef<HTMLDivElement>(null)
|
|
279
|
+
const root = useRef<HTMLSpanElement>(null)
|
|
267
280
|
const ui = pickerShell({ surface, empty: value === null })
|
|
281
|
+
const showClear = clearable && value !== null && !disabled
|
|
268
282
|
|
|
269
283
|
const trigger = (onClick?: () => void) => (
|
|
270
284
|
<FieldPrimitive.Control
|
|
@@ -286,6 +300,7 @@ export function PickerShell({ title, open, onOpenChange, children, value, text,
|
|
|
286
300
|
className={input({ surface, size, className: ui.trigger({ className }) })}
|
|
287
301
|
>
|
|
288
302
|
<span className={ui.text()}>{value === null ? placeholder : text}</span>
|
|
303
|
+
{showClear ? <span aria-hidden="true" className={ui.spacer()} /> : null}
|
|
289
304
|
<svg className={ui.icon()} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
290
305
|
<path d="m6 9 6 6 6-6" />
|
|
291
306
|
</svg>
|
|
@@ -295,10 +310,38 @@ export function PickerShell({ title, open, onOpenChange, children, value, text,
|
|
|
295
310
|
/>
|
|
296
311
|
)
|
|
297
312
|
|
|
313
|
+
const withClear = (node: ReactNode) => {
|
|
314
|
+
if (!clearable) return node
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<span ref={root} className={ui.root()}>
|
|
318
|
+
{node}
|
|
319
|
+
{showClear
|
|
320
|
+
? (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
aria-label={clearLabel}
|
|
324
|
+
className={ui.clear()}
|
|
325
|
+
onClick={() => {
|
|
326
|
+
onClear?.()
|
|
327
|
+
root.current?.querySelector('button')?.focus()
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
|
|
331
|
+
<path d="M18 6 6 18" />
|
|
332
|
+
<path d="m6 6 12 12" />
|
|
333
|
+
</svg>
|
|
334
|
+
</button>
|
|
335
|
+
)
|
|
336
|
+
: null}
|
|
337
|
+
</span>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
298
341
|
if (!wide) {
|
|
299
342
|
return (
|
|
300
343
|
<>
|
|
301
|
-
{trigger(() => onOpenChange(true))}
|
|
344
|
+
{withClear(trigger(() => onOpenChange(true)))}
|
|
302
345
|
<BottomSheet open={open} onOpenChange={next => onOpenChange(next)}>
|
|
303
346
|
<BottomSheetContent initialFocus={false}>
|
|
304
347
|
<BottomSheetHeader closable>
|
|
@@ -313,7 +356,7 @@ export function PickerShell({ title, open, onOpenChange, children, value, text,
|
|
|
313
356
|
|
|
314
357
|
return (
|
|
315
358
|
<Popover open={open} onOpenChange={next => onOpenChange(next)}>
|
|
316
|
-
<PopoverTrigger render={trigger()} />
|
|
359
|
+
{withClear(<PopoverTrigger render={trigger()} />)}
|
|
317
360
|
<PopoverContent
|
|
318
361
|
ref={popup}
|
|
319
362
|
aria-label={title}
|
|
@@ -328,10 +371,9 @@ export function PickerShell({ title, open, onOpenChange, children, value, text,
|
|
|
328
371
|
)
|
|
329
372
|
}
|
|
330
373
|
|
|
331
|
-
export type DatePickerProps = PickerTriggerProps & {
|
|
374
|
+
export type DatePickerProps = PickerTriggerProps & PickerClearProps & {
|
|
332
375
|
value?: string | null
|
|
333
376
|
defaultValue?: string | null
|
|
334
|
-
onValueChange?: (value: string) => void
|
|
335
377
|
min?: string
|
|
336
378
|
max?: string
|
|
337
379
|
today?: string
|
|
@@ -344,6 +386,8 @@ export function DatePicker({
|
|
|
344
386
|
value,
|
|
345
387
|
defaultValue = null,
|
|
346
388
|
onValueChange,
|
|
389
|
+
clearable,
|
|
390
|
+
clearLabel,
|
|
347
391
|
min,
|
|
348
392
|
max,
|
|
349
393
|
today,
|
|
@@ -355,9 +399,24 @@ export function DatePicker({
|
|
|
355
399
|
const [open, setOpen] = useState(false)
|
|
356
400
|
const [inner, setInner] = useState<string | null>(defaultValue)
|
|
357
401
|
const current = value === undefined ? inner : value
|
|
402
|
+
const change = onValueChange as ((next: string | null) => void) | undefined
|
|
358
403
|
|
|
359
404
|
return (
|
|
360
|
-
<PickerShell
|
|
405
|
+
<PickerShell
|
|
406
|
+
{...trigger}
|
|
407
|
+
title={title}
|
|
408
|
+
value={current}
|
|
409
|
+
text={current === null ? null : format(current)}
|
|
410
|
+
placeholder={placeholder}
|
|
411
|
+
open={open}
|
|
412
|
+
onOpenChange={setOpen}
|
|
413
|
+
clearable={clearable}
|
|
414
|
+
clearLabel={clearLabel}
|
|
415
|
+
onClear={() => {
|
|
416
|
+
change?.(null)
|
|
417
|
+
if (value === undefined) setInner(null)
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
361
420
|
<Calendar
|
|
362
421
|
value={current}
|
|
363
422
|
min={min}
|
|
@@ -367,7 +426,7 @@ export function DatePicker({
|
|
|
367
426
|
setOpen(false)
|
|
368
427
|
if (next === current) return
|
|
369
428
|
|
|
370
|
-
|
|
429
|
+
change?.(next)
|
|
371
430
|
if (value === undefined) setInner(next)
|
|
372
431
|
}}
|
|
373
432
|
/>
|
package/package.json
CHANGED
|
@@ -228,6 +228,7 @@ Dialog, AlertDialog, Drawer, BottomSheet의 Footer는 layout으로 버튼을 배
|
|
|
228
228
|
Modal은 기준 폭(기본 xs, 480px)보다 넓으면 Dialog, 좁으면 BottomSheet로 연다.
|
|
229
229
|
|
|
230
230
|
DatePicker는 `YYYY-MM-DD`, TimePicker는 `HH:mm` 글자 값을 쓴다. 48rem보다 넓으면 Popover, 좁으면 BottomSheet로 연다. 폭은 wrapper로 정한다.
|
|
231
|
+
DatePicker와 TimePicker는 기본으로 값을 비울 수 없다. 비울 수 있어야 하면 `clearable`을 켠다. 값이 있을 때 지우기 버튼이 보이고, 누르면 `onValueChange`가 `null`을 받는다. 버튼 이름은 `clearLabel`로 바꾼다.
|
|
231
232
|
|
|
232
233
|
Input, Textarea, NumberField, Select, ComboboxAnchor, DatePicker, TimePicker의 surface는 폼에서는 bare나 well을 쓰고, 표 셀, 헤더, 문장 안에서 값을 바로 고칠 때는 ghost를 쓴다.
|
|
233
234
|
|
package/time-picker/index.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, type KeyboardEvent } from 'react'
|
|
4
4
|
|
|
5
|
-
import { PickerShell, type PickerTriggerProps } from '../date-picker'
|
|
5
|
+
import { type PickerClearProps, PickerShell, type PickerTriggerProps } from '../date-picker'
|
|
6
6
|
import { tv } from '../tv'
|
|
7
7
|
|
|
8
8
|
const DEFAULT_HOUR = 9
|
|
@@ -89,10 +89,9 @@ function Column({ label, items, anchor, picked, focusFirst, onPick }: ColumnProp
|
|
|
89
89
|
)
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export type TimePickerProps = PickerTriggerProps & {
|
|
92
|
+
export type TimePickerProps = PickerTriggerProps & PickerClearProps & {
|
|
93
93
|
value?: string | null
|
|
94
94
|
defaultValue?: string | null
|
|
95
|
-
onValueChange?: (value: string) => void
|
|
96
95
|
step?: number
|
|
97
96
|
title?: string
|
|
98
97
|
placeholder?: string
|
|
@@ -102,6 +101,8 @@ export function TimePicker({
|
|
|
102
101
|
value,
|
|
103
102
|
defaultValue = null,
|
|
104
103
|
onValueChange,
|
|
104
|
+
clearable,
|
|
105
|
+
clearLabel,
|
|
105
106
|
step = 1,
|
|
106
107
|
title = '시각 선택',
|
|
107
108
|
placeholder = '시각을 선택하세요',
|
|
@@ -114,10 +115,12 @@ export function TimePicker({
|
|
|
114
115
|
const { hour, minute } = partsOf(current)
|
|
115
116
|
const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, index) => index * step)
|
|
116
117
|
|
|
118
|
+
const change = onValueChange as ((next: string | null) => void) | undefined
|
|
119
|
+
|
|
117
120
|
const set = (nextHour: number, nextMinute: number) => {
|
|
118
121
|
const next = `${pad(nextHour)}:${pad(nextMinute)}`
|
|
119
122
|
|
|
120
|
-
|
|
123
|
+
change?.(next)
|
|
121
124
|
if (value === undefined) setInner(next)
|
|
122
125
|
}
|
|
123
126
|
|
|
@@ -127,7 +130,21 @@ export function TimePicker({
|
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
return (
|
|
130
|
-
<PickerShell
|
|
133
|
+
<PickerShell
|
|
134
|
+
{...trigger}
|
|
135
|
+
title={title}
|
|
136
|
+
value={current}
|
|
137
|
+
text={current === null ? null : clockText(current)}
|
|
138
|
+
placeholder={placeholder}
|
|
139
|
+
open={open}
|
|
140
|
+
onOpenChange={setOpen}
|
|
141
|
+
clearable={clearable}
|
|
142
|
+
clearLabel={clearLabel}
|
|
143
|
+
onClear={() => {
|
|
144
|
+
change?.(null)
|
|
145
|
+
if (value === undefined) setInner(null)
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
131
148
|
<div className={timePicker().root()}>
|
|
132
149
|
<Column
|
|
133
150
|
label="오전 오후"
|