@pantoia/ui 0.1.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/accordion/AGENTS.md +60 -0
- package/accordion/index.module.css +10 -0
- package/accordion/index.tsx +91 -0
- package/alert-dialog/AGENTS.md +78 -0
- package/alert-dialog/index.tsx +119 -0
- package/badge/AGENTS.md +35 -0
- package/badge/index.tsx +34 -0
- package/button/AGENTS.md +39 -0
- package/button/index.tsx +98 -0
- package/callout/AGENTS.md +42 -0
- package/callout/index.tsx +70 -0
- package/checkbox/AGENTS.md +54 -0
- package/checkbox/index.tsx +151 -0
- package/code-block/AGENTS.md +60 -0
- package/code-block/index.module.css +46 -0
- package/code-block/index.tsx +127 -0
- package/combobox/AGENTS.md +150 -0
- package/combobox/index.tsx +420 -0
- package/command-palette/AGENTS.md +44 -0
- package/command-palette/index.tsx +108 -0
- package/copy-button/AGENTS.md +43 -0
- package/copy-button/index.tsx +119 -0
- package/dialog/AGENTS.md +81 -0
- package/dialog/index.tsx +108 -0
- package/dist/index.js +189 -0
- package/drawer/AGENTS.md +66 -0
- package/drawer/index.module.css +17 -0
- package/drawer/index.tsx +198 -0
- package/dropdown-menu/AGENTS.md +133 -0
- package/dropdown-menu/index.tsx +223 -0
- package/field/AGENTS.md +96 -0
- package/field/index.tsx +149 -0
- package/input/AGENTS.md +35 -0
- package/input/index.tsx +36 -0
- package/kbd/AGENTS.md +17 -0
- package/kbd/index.tsx +13 -0
- package/label/AGENTS.md +21 -0
- package/label/index.tsx +13 -0
- package/number-field/AGENTS.md +53 -0
- package/number-field/index.tsx +227 -0
- package/package.json +73 -0
- package/popover/AGENTS.md +64 -0
- package/popover/index.tsx +66 -0
- package/press.css +37 -0
- package/radio-group/AGENTS.md +81 -0
- package/radio-group/index.tsx +139 -0
- package/registry.json +726 -0
- package/select/AGENTS.md +90 -0
- package/select/index.tsx +169 -0
- package/styles.css +347 -0
- package/switch/AGENTS.md +37 -0
- package/switch/index.tsx +42 -0
- package/table/AGENTS.md +82 -0
- package/table/index.tsx +120 -0
- package/tabs/AGENTS.md +60 -0
- package/tabs/index.module.css +14 -0
- package/tabs/index.tsx +105 -0
- package/test-utils.ts +10 -0
- package/textarea/AGENTS.md +35 -0
- package/textarea/index.tsx +37 -0
- package/toast/AGENTS.md +42 -0
- package/toast/index.tsx +71 -0
- package/toggle/AGENTS.md +37 -0
- package/toggle/index.tsx +36 -0
- package/tooltip/AGENTS.md +50 -0
- package/tooltip/index.tsx +57 -0
- package/tv.ts +23 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Check, ChevronDown, X } from 'lucide-react'
|
|
4
|
+
import { Popover as PopoverPrimitive } from 'radix-ui'
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
use,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useState,
|
|
11
|
+
type ComponentProps,
|
|
12
|
+
type InputHTMLAttributes,
|
|
13
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from 'react'
|
|
16
|
+
|
|
17
|
+
import { tv } from '../tv'
|
|
18
|
+
|
|
19
|
+
interface ComboboxContext {
|
|
20
|
+
id: string
|
|
21
|
+
value: string | undefined
|
|
22
|
+
open: boolean
|
|
23
|
+
active: string | undefined
|
|
24
|
+
search: string
|
|
25
|
+
setSearch: (search: string) => void
|
|
26
|
+
setOpen: (open: boolean) => void
|
|
27
|
+
pick: (value: string) => void
|
|
28
|
+
move: (step: number) => void
|
|
29
|
+
setActive: (value: string | undefined) => void
|
|
30
|
+
itemId: (value: string) => string
|
|
31
|
+
count: number
|
|
32
|
+
ignoreFilter: boolean
|
|
33
|
+
see: (visible: boolean) => () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const USABLE = '[role="option"]:not([data-disabled])'
|
|
37
|
+
|
|
38
|
+
const Context = createContext<ComboboxContext | null>(null)
|
|
39
|
+
|
|
40
|
+
const useCombobox = () => {
|
|
41
|
+
const ctx = use(Context)
|
|
42
|
+
if (!ctx) throw new Error('Combobox 부품은 Combobox 안에서만 선다')
|
|
43
|
+
return ctx
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ComboboxProps {
|
|
47
|
+
value?: string
|
|
48
|
+
defaultValue?: string
|
|
49
|
+
onValueChange?: (value: string) => void
|
|
50
|
+
open?: boolean
|
|
51
|
+
defaultOpen?: boolean
|
|
52
|
+
onOpenChange?: (open: boolean) => void
|
|
53
|
+
ignoreFilter?: boolean
|
|
54
|
+
className?: string
|
|
55
|
+
children?: ReactNode
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function Combobox({
|
|
59
|
+
value,
|
|
60
|
+
defaultValue,
|
|
61
|
+
onValueChange,
|
|
62
|
+
open,
|
|
63
|
+
defaultOpen = false,
|
|
64
|
+
onOpenChange,
|
|
65
|
+
ignoreFilter = true,
|
|
66
|
+
className,
|
|
67
|
+
children,
|
|
68
|
+
}: ComboboxProps) {
|
|
69
|
+
const id = useId()
|
|
70
|
+
const [ownValue, setOwnValue] = useState(defaultValue)
|
|
71
|
+
const [ownOpen, setOwnOpen] = useState(defaultOpen)
|
|
72
|
+
const [active, setActive] = useState<string | undefined>(undefined)
|
|
73
|
+
const [search, setSearch] = useState('')
|
|
74
|
+
const [count, setCount] = useState(0)
|
|
75
|
+
const [seen, setSeen] = useState(false)
|
|
76
|
+
|
|
77
|
+
const isOpen = open ?? ownOpen
|
|
78
|
+
const current = value ?? ownValue
|
|
79
|
+
|
|
80
|
+
if (seen !== isOpen) {
|
|
81
|
+
setSeen(isOpen)
|
|
82
|
+
if (!isOpen) setActive(undefined)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const setOpen = (next: boolean) => {
|
|
86
|
+
if (open === undefined) setOwnOpen(next)
|
|
87
|
+
onOpenChange?.(next)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pick = (next: string) => {
|
|
91
|
+
if (value === undefined) setOwnValue(next)
|
|
92
|
+
onValueChange?.(next)
|
|
93
|
+
setSearch(next)
|
|
94
|
+
setOpen(false)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const see = (visible: boolean) => {
|
|
98
|
+
if (!visible) return () => {}
|
|
99
|
+
setCount(n => n + 1)
|
|
100
|
+
return () => setCount(n => n - 1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const move = (step: number) => {
|
|
104
|
+
const list = document.getElementById(`${id}-list`)
|
|
105
|
+
if (!list) return
|
|
106
|
+
|
|
107
|
+
const usable = [...list.querySelectorAll<HTMLElement>(USABLE)].map(el => el.dataset.value!)
|
|
108
|
+
if (usable.length === 0) return
|
|
109
|
+
|
|
110
|
+
const at = active === undefined ? -1 : usable.indexOf(active)
|
|
111
|
+
const next = at === -1
|
|
112
|
+
? (step > 0 ? 0 : usable.length - 1)
|
|
113
|
+
: (at + step + usable.length) % usable.length
|
|
114
|
+
|
|
115
|
+
setActive(usable[next])
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<Context value={{
|
|
120
|
+
id,
|
|
121
|
+
value: current,
|
|
122
|
+
open: isOpen,
|
|
123
|
+
active,
|
|
124
|
+
search,
|
|
125
|
+
setSearch,
|
|
126
|
+
setOpen,
|
|
127
|
+
pick,
|
|
128
|
+
see,
|
|
129
|
+
move,
|
|
130
|
+
setActive,
|
|
131
|
+
itemId: (item: string) => `${id}-${item}`,
|
|
132
|
+
count,
|
|
133
|
+
ignoreFilter,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<PopoverPrimitive.Root open={isOpen} onOpenChange={setOpen}>
|
|
137
|
+
<div className={className}>{children}</div>
|
|
138
|
+
</PopoverPrimitive.Root>
|
|
139
|
+
</Context>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const comboboxAnchor = tv({
|
|
144
|
+
base: 'flex items-center gap-tight rounded-r8 px-3'
|
|
145
|
+
+ ' transition-state'
|
|
146
|
+
+ ' invalid-within:ring-2 invalid-within:ring-danger'
|
|
147
|
+
+ ' has-disabled:bg-disabled has-disabled:cursor-not-allowed'
|
|
148
|
+
+ ' has-focus-visible:outline-2 has-focus-visible:outline-offset-2'
|
|
149
|
+
+ ' has-focus-visible:outline-ring',
|
|
150
|
+
|
|
151
|
+
variants: {
|
|
152
|
+
surface: {
|
|
153
|
+
bare: 'ring-1 ring-inset ring-line-strong',
|
|
154
|
+
well: 'bg-well',
|
|
155
|
+
},
|
|
156
|
+
size: { sm: 'h-8', md: 'h-9', lg: 'h-10 [&_input]:text-t16' },
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
defaultVariants: { surface: 'bare', size: 'md' },
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
export interface ComboboxAnchorProps {
|
|
163
|
+
surface?: 'bare' | 'well'
|
|
164
|
+
size?: 'sm' | 'md' | 'lg'
|
|
165
|
+
className?: string
|
|
166
|
+
children?: ReactNode
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function ComboboxAnchor({ surface, size, className, children }: ComboboxAnchorProps) {
|
|
170
|
+
return (
|
|
171
|
+
<PopoverPrimitive.Anchor className={comboboxAnchor({ surface, size, className })}>
|
|
172
|
+
{children}
|
|
173
|
+
</PopoverPrimitive.Anchor>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ComboboxInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> {
|
|
178
|
+
value?: string
|
|
179
|
+
onValueChange?: (value: string) => void
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function ComboboxInput({ value, onValueChange, className, ...rest }: ComboboxInputProps) {
|
|
183
|
+
const ctx = useCombobox()
|
|
184
|
+
const text = value ?? ctx.search
|
|
185
|
+
const [seen, setSeen] = useState(ctx.search)
|
|
186
|
+
|
|
187
|
+
if (seen !== ctx.search) {
|
|
188
|
+
setSeen(ctx.search)
|
|
189
|
+
if (ctx.search !== text) onValueChange?.(ctx.search)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const change = (next: string) => {
|
|
193
|
+
ctx.setSearch(next)
|
|
194
|
+
onValueChange?.(next)
|
|
195
|
+
if (!ctx.open) ctx.setOpen(true)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
199
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
if (!ctx.open) ctx.setOpen(true)
|
|
202
|
+
else ctx.move(e.key === 'ArrowDown' ? 1 : -1)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (e.key === 'Enter' && ctx.open && ctx.active !== undefined) {
|
|
207
|
+
e.preventDefault()
|
|
208
|
+
ctx.pick(ctx.active)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (e.key === 'Escape' && ctx.open) {
|
|
213
|
+
e.preventDefault()
|
|
214
|
+
ctx.setOpen(false)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<input
|
|
220
|
+
{...rest}
|
|
221
|
+
role="combobox"
|
|
222
|
+
aria-expanded={ctx.open}
|
|
223
|
+
aria-controls={ctx.open ? `${ctx.id}-list` : undefined}
|
|
224
|
+
aria-autocomplete="list"
|
|
225
|
+
aria-activedescendant={ctx.active === undefined ? undefined : ctx.itemId(ctx.active)}
|
|
226
|
+
value={text}
|
|
227
|
+
onChange={e => change(e.target.value)}
|
|
228
|
+
onKeyDown={onKeyDown}
|
|
229
|
+
className={tv({ base: 'text-value text-fg placeholder:text-fg-placeholder'
|
|
230
|
+
+ ' w-full min-w-0 bg-transparent outline-none'
|
|
231
|
+
+ ' disabled:text-fg-disabled disabled:cursor-not-allowed' })({ className })}
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function ComboboxTrigger({ label = '목록 열기', disabled, className }: {
|
|
237
|
+
label?: string
|
|
238
|
+
disabled?: boolean
|
|
239
|
+
className?: string
|
|
240
|
+
}) {
|
|
241
|
+
const ctx = useCombobox()
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
tabIndex={-1}
|
|
247
|
+
aria-label={label}
|
|
248
|
+
disabled={disabled}
|
|
249
|
+
data-state={ctx.open ? 'open' : 'closed'}
|
|
250
|
+
onClick={() => ctx.setOpen(!ctx.open)}
|
|
251
|
+
className={tv({ base: 'text-fg-subtle grid size-5 shrink-0 place-items-center rounded-r8'
|
|
252
|
+
+ ' transition-state press-shrink hover:text-fg'
|
|
253
|
+
+ ' focus-visible:outline-2 focus-visible:outline-offset-2'
|
|
254
|
+
+ ' focus-visible:outline-ring' })({ className })}
|
|
255
|
+
>
|
|
256
|
+
<ChevronDown
|
|
257
|
+
className="size-4 transition-transform open:rotate-180 motion-reduce:transition-none"
|
|
258
|
+
/>
|
|
259
|
+
</button>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function ComboboxCancel({ label = '지우기', className }: { label?: string, className?: string }) {
|
|
264
|
+
const ctx = useCombobox()
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
tabIndex={-1}
|
|
270
|
+
aria-label={label}
|
|
271
|
+
onClick={() => ctx.setSearch('')}
|
|
272
|
+
className={tv({ base: 'text-fg-subtle grid size-5 shrink-0 place-items-center rounded-full'
|
|
273
|
+
+ ' transition-state press-shrink hover:text-fg'
|
|
274
|
+
+ ' focus-visible:outline-2 focus-visible:outline-offset-2'
|
|
275
|
+
+ ' focus-visible:outline-ring' })({ className })}
|
|
276
|
+
>
|
|
277
|
+
<X className="size-4" />
|
|
278
|
+
</button>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export type ComboboxContentProps =
|
|
283
|
+
Omit<ComponentProps<typeof PopoverPrimitive.Content>, 'align' | 'sideOffset' | 'onOpenAutoFocus'>
|
|
284
|
+
|
|
285
|
+
export function ComboboxContent({ className, children, ...rest }: ComboboxContentProps) {
|
|
286
|
+
const label = rest['aria-label']
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<PopoverPrimitive.Portal>
|
|
290
|
+
<PopoverPrimitive.Content
|
|
291
|
+
{...rest}
|
|
292
|
+
align="start"
|
|
293
|
+
sideOffset={4}
|
|
294
|
+
onOpenAutoFocus={e => e.preventDefault()}
|
|
295
|
+
className={tv({ base: 'bg-float text-fg rounded-float shadow-s3 z-overlay'
|
|
296
|
+
+ ' w-(--radix-popover-trigger-width) p-1'
|
|
297
|
+
+ ' open:animate-enter closed:animate-exit' })({ className })}
|
|
298
|
+
>
|
|
299
|
+
<ComboboxViewport label={label} className="max-h-72">
|
|
300
|
+
{children}
|
|
301
|
+
</ComboboxViewport>
|
|
302
|
+
</PopoverPrimitive.Content>
|
|
303
|
+
</PopoverPrimitive.Portal>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function ComboboxViewport({ label, className, children }: {
|
|
308
|
+
label?: string
|
|
309
|
+
className?: string
|
|
310
|
+
children?: ReactNode
|
|
311
|
+
}) {
|
|
312
|
+
const ctx = useCombobox()
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
<div
|
|
316
|
+
id={`${ctx.id}-list`}
|
|
317
|
+
role={ctx.count > 0 ? 'listbox' : undefined}
|
|
318
|
+
aria-label={ctx.count > 0 ? label : undefined}
|
|
319
|
+
className={tv({ base: 'flex flex-col gap-px overflow-y-auto' })({ className })}
|
|
320
|
+
>
|
|
321
|
+
{children}
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export interface ComboboxItemProps {
|
|
327
|
+
value: string
|
|
328
|
+
textValue?: string
|
|
329
|
+
disabled?: boolean
|
|
330
|
+
className?: string
|
|
331
|
+
children?: ReactNode
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function ComboboxItem({ value, textValue, disabled = false, className, children }: ComboboxItemProps) {
|
|
335
|
+
const ctx = useCombobox()
|
|
336
|
+
const shown = ctx.ignoreFilter || contains(textValue ?? value, ctx.search)
|
|
337
|
+
|
|
338
|
+
useEffect(() => ctx.see(shown), [ctx, shown])
|
|
339
|
+
|
|
340
|
+
if (!shown) return null
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<div
|
|
344
|
+
id={ctx.itemId(value)}
|
|
345
|
+
role="option"
|
|
346
|
+
data-value={value}
|
|
347
|
+
aria-selected={ctx.value === value}
|
|
348
|
+
data-highlighted={ctx.active === value ? '' : undefined}
|
|
349
|
+
data-disabled={disabled ? '' : undefined}
|
|
350
|
+
onPointerMove={() => !disabled && ctx.setActive(value)}
|
|
351
|
+
onClick={() => !disabled && ctx.pick(value)}
|
|
352
|
+
className={tv({ base: 'flex min-h-9 cursor-default items-center justify-between gap-stack'
|
|
353
|
+
+ ' shrink-0 rounded-r8 px-2 py-1 transition-state'
|
|
354
|
+
+ ' data-highlighted:bg-hover data-highlighted:outline-none'
|
|
355
|
+
+ ' data-disabled:text-fg-disabled data-disabled:pointer-events-none' })({ className })}
|
|
356
|
+
>
|
|
357
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
358
|
+
{children}
|
|
359
|
+
</span>
|
|
360
|
+
|
|
361
|
+
{ctx.value === value && (
|
|
362
|
+
<span className="text-fg-muted flex shrink-0">
|
|
363
|
+
<Check className="size-4" />
|
|
364
|
+
</span>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function ComboboxItemText({ className, children }: { className?: string, children?: ReactNode }) {
|
|
371
|
+
return (
|
|
372
|
+
<span className={tv({ base: 'text-label text-fg' })({ className })}>
|
|
373
|
+
{children}
|
|
374
|
+
</span>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function ComboboxItemDescription({ className, children }: { className?: string, children?: ReactNode }) {
|
|
379
|
+
return (
|
|
380
|
+
<span className={tv({ base: 'text-caption text-fg-subtle' })({ className })}>
|
|
381
|
+
{children}
|
|
382
|
+
</span>
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function ComboboxGroup({ label, className, children }: {
|
|
387
|
+
label?: string
|
|
388
|
+
className?: string
|
|
389
|
+
children?: ReactNode
|
|
390
|
+
}) {
|
|
391
|
+
return (
|
|
392
|
+
<div role="group" className={tv({ base: 'flex flex-col gap-px' })({ className })}>
|
|
393
|
+
{label && (
|
|
394
|
+
<span className="text-caption text-fg-subtle px-2 pb-1 pt-2">
|
|
395
|
+
{label}
|
|
396
|
+
</span>
|
|
397
|
+
)}
|
|
398
|
+
{children}
|
|
399
|
+
</div>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function ComboboxEmpty({ className, children }: { className?: string, children?: ReactNode }) {
|
|
404
|
+
const ctx = useCombobox()
|
|
405
|
+
if (ctx.count > 0) return null
|
|
406
|
+
|
|
407
|
+
return (
|
|
408
|
+
<div role="presentation" className={tv({ base: 'text-caption text-fg-subtle px-2 py-3 text-center' })({ className })}>
|
|
409
|
+
{children}
|
|
410
|
+
</div>
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function ComboboxSeparator({ className }: { className?: string }) {
|
|
415
|
+
return <div className={tv({ base: 'bg-line -mx-1 my-1 h-px' })({ className })} />
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function contains(text: string, search: string) {
|
|
419
|
+
return text.normalize('NFC').toLocaleLowerCase().includes(search.normalize('NFC').toLocaleLowerCase())
|
|
420
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# CommandPalette
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
받기 npx @pantoia/ui add command-palette
|
|
5
|
+
층 derived
|
|
6
|
+
파일 command-palette/index.tsx · command-palette/AGENTS.md
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 부위
|
|
10
|
+
|
|
11
|
+
| 이름 | props |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `CommandPalette` | `CommandPaletteProps` |
|
|
14
|
+
|
|
15
|
+
## props
|
|
16
|
+
|
|
17
|
+
### `CommandPaletteItem`
|
|
18
|
+
|
|
19
|
+
| 이름 | 타입 | 필수 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `value` | `string` | 예 |
|
|
22
|
+
| `label` | `string` | 예 |
|
|
23
|
+
| `group` | `string` | |
|
|
24
|
+
|
|
25
|
+
### `CommandPaletteProps`
|
|
26
|
+
|
|
27
|
+
| 이름 | 타입 | 필수 |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `open` | `boolean` | |
|
|
30
|
+
| `defaultOpen` | `boolean` | |
|
|
31
|
+
| `onOpenChange` | `(open: boolean) => void` | |
|
|
32
|
+
| `items` | `CommandPaletteItem[]` | 예 |
|
|
33
|
+
| `title` | `string` | 예 |
|
|
34
|
+
| `placeholder` | `string` | 예 |
|
|
35
|
+
| `emptyText` | `string` | 예 |
|
|
36
|
+
| `onSelect` | `(value: string) => void` | |
|
|
37
|
+
|
|
38
|
+
## 함께 받는 것
|
|
39
|
+
|
|
40
|
+
`combobox` · `dialog` · `kbd`
|
|
41
|
+
|
|
42
|
+
## npm 의존
|
|
43
|
+
|
|
44
|
+
`lucide-react`
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Search } from 'lucide-react'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Combobox,
|
|
8
|
+
ComboboxEmpty,
|
|
9
|
+
ComboboxInput,
|
|
10
|
+
ComboboxItem,
|
|
11
|
+
ComboboxItemDescription,
|
|
12
|
+
ComboboxItemText,
|
|
13
|
+
ComboboxViewport,
|
|
14
|
+
contains,
|
|
15
|
+
} from '../combobox'
|
|
16
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../dialog'
|
|
17
|
+
import { Kbd } from '../kbd'
|
|
18
|
+
|
|
19
|
+
export interface CommandPaletteItem {
|
|
20
|
+
value: string
|
|
21
|
+
label: string
|
|
22
|
+
group?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CommandPaletteProps {
|
|
26
|
+
open?: boolean
|
|
27
|
+
defaultOpen?: boolean
|
|
28
|
+
onOpenChange?: (open: boolean) => void
|
|
29
|
+
items: CommandPaletteItem[]
|
|
30
|
+
title: string
|
|
31
|
+
placeholder: string
|
|
32
|
+
emptyText: string
|
|
33
|
+
onSelect?: (value: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function CommandPalette({
|
|
37
|
+
open,
|
|
38
|
+
defaultOpen = false,
|
|
39
|
+
onOpenChange,
|
|
40
|
+
items,
|
|
41
|
+
title,
|
|
42
|
+
placeholder,
|
|
43
|
+
emptyText,
|
|
44
|
+
onSelect,
|
|
45
|
+
}: CommandPaletteProps) {
|
|
46
|
+
const [own, setOwn] = useState(defaultOpen)
|
|
47
|
+
const [search, setSearch] = useState('')
|
|
48
|
+
const [seen, setSeen] = useState(defaultOpen)
|
|
49
|
+
|
|
50
|
+
const isOpen = open ?? own
|
|
51
|
+
|
|
52
|
+
if (seen !== isOpen) {
|
|
53
|
+
setSeen(isOpen)
|
|
54
|
+
if (!isOpen) setSearch('')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const setOpen = (next: boolean) => {
|
|
58
|
+
if (open === undefined) setOwn(next)
|
|
59
|
+
onOpenChange?.(next)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const filtered = search ? items.filter(i => contains(i.label, search)) : items
|
|
63
|
+
|
|
64
|
+
const pick = (value: string) => {
|
|
65
|
+
onSelect?.(value)
|
|
66
|
+
setOpen(false)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Dialog open={isOpen} onOpenChange={setOpen}>
|
|
71
|
+
<DialogContent size="top">
|
|
72
|
+
<DialogTitle className="sr-only">{title}</DialogTitle>
|
|
73
|
+
<DialogDescription className="sr-only">
|
|
74
|
+
{placeholder}
|
|
75
|
+
</DialogDescription>
|
|
76
|
+
|
|
77
|
+
<Combobox open onValueChange={pick}>
|
|
78
|
+
<div className="border-line flex h-14 items-center gap-stack border-b px-4">
|
|
79
|
+
<Search className="text-fg-subtle size-5 shrink-0" />
|
|
80
|
+
<ComboboxInput
|
|
81
|
+
value={search}
|
|
82
|
+
onValueChange={setSearch}
|
|
83
|
+
placeholder={placeholder}
|
|
84
|
+
className="text-t16"
|
|
85
|
+
autoFocus
|
|
86
|
+
/>
|
|
87
|
+
<Kbd>Esc</Kbd>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<ComboboxViewport className="max-h-80 gap-1 p-2">
|
|
91
|
+
<ComboboxEmpty className="py-6">
|
|
92
|
+
{emptyText}
|
|
93
|
+
</ComboboxEmpty>
|
|
94
|
+
|
|
95
|
+
{filtered.map(item => (
|
|
96
|
+
<ComboboxItem key={item.value} value={item.value}>
|
|
97
|
+
<ComboboxItemText>{item.label}</ComboboxItemText>
|
|
98
|
+
{item.group && (
|
|
99
|
+
<ComboboxItemDescription>{item.group}</ComboboxItemDescription>
|
|
100
|
+
)}
|
|
101
|
+
</ComboboxItem>
|
|
102
|
+
))}
|
|
103
|
+
</ComboboxViewport>
|
|
104
|
+
</Combobox>
|
|
105
|
+
</DialogContent>
|
|
106
|
+
</Dialog>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# CopyButton
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
받기 npx @pantoia/ui add copy-button
|
|
5
|
+
층 derived
|
|
6
|
+
파일 copy-button/index.tsx · copy-button/AGENTS.md
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## 부위
|
|
10
|
+
|
|
11
|
+
| 이름 | props |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `CopyButton` | `CopyButtonProps` |
|
|
14
|
+
|
|
15
|
+
## 도우미
|
|
16
|
+
|
|
17
|
+
`useCopy(resetAfter = 2000)`
|
|
18
|
+
|
|
19
|
+
## props
|
|
20
|
+
|
|
21
|
+
### `CopyState`
|
|
22
|
+
|
|
23
|
+
`'idle' | 'copied' | 'failed'`
|
|
24
|
+
|
|
25
|
+
### `CopyButtonProps`
|
|
26
|
+
|
|
27
|
+
| 이름 | 타입 | 필수 |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `value` | `string` | 예 |
|
|
30
|
+
| `color` | `'primary' \| 'neutral' \| 'danger'` | |
|
|
31
|
+
| `emphasis` | `'solid' \| 'weak' \| 'outline' \| 'ghost'` | |
|
|
32
|
+
| `size` | `'sm' \| 'md' \| 'lg'` | |
|
|
33
|
+
| `shape` | `'rect' \| 'square' \| 'pill' \| 'circle'` | |
|
|
34
|
+
| `onCopied` | `(value: string) => void` | |
|
|
35
|
+
| `onFailed` | `() => void` | |
|
|
36
|
+
| `disabled` | `boolean` | |
|
|
37
|
+
| `className` | `string` | |
|
|
38
|
+
| `aria-label` | `string` | |
|
|
39
|
+
| `children` | `ReactNode` | |
|
|
40
|
+
|
|
41
|
+
## 함께 받는 것
|
|
42
|
+
|
|
43
|
+
`button`
|