@pantoia/ui 0.16.2 → 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 +7 -6
- 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
|
@@ -151,7 +151,7 @@ disabled는 이럴 때만 준다.
|
|
|
151
151
|
```
|
|
152
152
|
팔레트 단계 그 단계를 참조하는 토큰이 모두 바뀐다
|
|
153
153
|
토큰 그 토큰을 쓰는 곳이 모두 바뀐다
|
|
154
|
-
className 그
|
|
154
|
+
className 그 1곳만 바뀐다
|
|
155
155
|
```
|
|
156
156
|
|
|
157
157
|
## 컴포넌트
|
|
@@ -211,15 +211,15 @@ Select와 Combobox 목록에서 고르면 Select, 입력해서 찾으
|
|
|
211
211
|
Checkbox와 Switch 여러 개를 고르면 Checkbox, 설정을 바로 켜고 끄면 Switch
|
|
212
212
|
Toggle과 Switch 도구 모음에서 상태를 누르면 Toggle, 설정이면 Switch
|
|
213
213
|
Badge와 Button 누를 수 없으면 Badge, 누르면 Button
|
|
214
|
-
Tabs와 Accordion 한 번에
|
|
214
|
+
Tabs와 Accordion 한 번에 1개만 보이면 Tabs, 여러 개를 펴 두면 Accordion
|
|
215
215
|
SegmentedControl과 Tabs 같은 내용의 보기 방식을 바꾸면 SegmentedControl, 다른 내용으로 바꾸면 Tabs
|
|
216
|
-
SegmentedControl과 Toggle 여럿 중
|
|
216
|
+
SegmentedControl과 Toggle 여럿 중 1개를 고르면 SegmentedControl, 1개를 켜고 끄면 Toggle
|
|
217
217
|
SegmentedControl과 ToggleGroup 이어진 트랙 안에서 고르면 SegmentedControl, 떨어진 pill 여러 개 중에 고르면 ToggleGroup
|
|
218
|
-
Collapsible과 Accordion
|
|
218
|
+
Collapsible과 Accordion 영역 1개를 접으면 Collapsible, 여러 영역을 함께 다루면 Accordion
|
|
219
219
|
Tooltip과 Popover 짧은 설명이면 Tooltip, 안에서 조작하면 Popover
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
Avatar의 배경색은 color나 seed 중
|
|
222
|
+
Avatar의 배경색은 color나 seed 중 1개로 정한다. seed에는 사용자 id처럼 바뀌지 않는 값을 넘기고, 같은 seed는 어느 앱에서나 같은 색이다.
|
|
223
223
|
|
|
224
224
|
Dialog, AlertDialog, Drawer, BottomSheet의 Footer는 layout으로 버튼을 배치한다. end, between, fill, grow-last 중에 고르고, 화면 폭마다 다르면 { narrow, wide }를 넘긴다. 기준은 sm이고, ModalFooter는 Dialog 모양이면 wide, BottomSheet 모양이면 narrow를 쓴다.
|
|
225
225
|
|
|
@@ -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
|
|
|
@@ -238,7 +239,7 @@ Field는 라벨, 도움말, 오류 메시지를 입력 컨트롤에 묶는다. I
|
|
|
238
239
|
Select는 고른 항목의 `SelectItemText`나 글자를 트리거에 보인다. `SelectItem`을 다른 컴포넌트로 감싸면 `items`로 라벨을 넘긴다.
|
|
239
240
|
Combobox 항목이 `{ value, label }`이 아닌 객체면 `itemToStringLabel`로 `ComboboxInput`에 보일 글자를 정한다.
|
|
240
241
|
|
|
241
|
-
앱에
|
|
242
|
+
아래 컴포넌트는 앱에 1번만 둔다. 앱은 `isolate`로 감싼다.
|
|
242
243
|
|
|
243
244
|
```tsx
|
|
244
245
|
import { Toast, toast } from '@/components/ui/toast'
|
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="오전 오후"
|