@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.
@@ -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 {...trigger} title={title} value={current} text={current === null ? null : format(current)} placeholder={placeholder} open={open} onOpenChange={setOpen}>
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
- onValueChange?.(next)
429
+ change?.(next)
371
430
  if (value === undefined) setInner(next)
372
431
  }}
373
432
  />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantoia/ui",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Pantoia Design System",
5
5
  "homepage": "https://design.pantoia.com",
6
6
  "repository": {
@@ -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 한 번에 하나만 보이면 Tabs, 여러 개를 펴 두면 Accordion
214
+ Tabs와 Accordion 한 번에 1개만 보이면 Tabs, 여러 개를 펴 두면 Accordion
215
215
  SegmentedControl과 Tabs 같은 내용의 보기 방식을 바꾸면 SegmentedControl, 다른 내용으로 바꾸면 Tabs
216
- SegmentedControl과 Toggle 여럿 중 하나를 고르면 SegmentedControl, 하나를 켜고 끄면 Toggle
216
+ SegmentedControl과 Toggle 여럿 중 1개를 고르면 SegmentedControl, 1개를 켜고 끄면 Toggle
217
217
  SegmentedControl과 ToggleGroup 이어진 트랙 안에서 고르면 SegmentedControl, 떨어진 pill 여러 개 중에 고르면 ToggleGroup
218
- Collapsible과 Accordion 한 영역을 접으면 Collapsible, 여러 영역을 함께 다루면 Accordion
218
+ Collapsible과 Accordion 영역 1개를 접으면 Collapsible, 여러 영역을 함께 다루면 Accordion
219
219
  Tooltip과 Popover 짧은 설명이면 Tooltip, 안에서 조작하면 Popover
220
220
  ```
221
221
 
222
- Avatar의 배경색은 color나 seed 중 하나로 정한다. seed에는 사용자 id처럼 바뀌지 않는 값을 넘기고, 같은 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
- 앱에 한 번만 두는 것이다. 앱은 `isolate`로 감싼다.
242
+ 아래 컴포넌트는 앱에 1번만 둔다. 앱은 `isolate`로 감싼다.
242
243
 
243
244
  ```tsx
244
245
  import { Toast, toast } from '@/components/ui/toast'
@@ -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
- onValueChange?.(next)
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 {...trigger} title={title} value={current} text={current === null ? null : clockText(current)} placeholder={placeholder} open={open} onOpenChange={setOpen}>
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="오전 오후"