@strategicos/ui 0.6.1 → 0.8.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.
@@ -0,0 +1,390 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { CheckIcon, ChevronDownIcon, Loader2Icon, PlusIcon, SearchIcon, XIcon } from "lucide-react"
5
+
6
+ import { cn } from "../lib/utils.js"
7
+ import { Button } from "./button.js"
8
+ import { useFieldControl } from "./field.js"
9
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover.js"
10
+ import { selectTriggerVariants } from "./select.js"
11
+
12
+ export type ComboboxOption = {
13
+ value: string
14
+ label: string
15
+ /** Texto secundário (ex.: e-mail), só na lista. */
16
+ description?: string
17
+ /** Agrupa as opções sob um título. */
18
+ group?: string
19
+ /** Bolinha de cor antes do rótulo. */
20
+ color?: string
21
+ disabled?: boolean
22
+ }
23
+
24
+ type Entry = { kind: "option"; option: ComboboxOption } | { kind: "create" }
25
+
26
+ function errorMessage(err: unknown, fallback: string) {
27
+ return err instanceof Error && err.message ? err.message : fallback
28
+ }
29
+
30
+ /**
31
+ * Seleção de um item com busca, como o Select `searchable` do Mantine. Use no
32
+ * lugar do Select quando a lista é longa ou quando a tela precisa criar ou
33
+ * excluir opções ali mesmo.
34
+ *
35
+ * <Combobox
36
+ * options={setores.map(s => ({ value: s.id, label: s.name }))}
37
+ * value={setorId}
38
+ * onValueChange={setSetorId}
39
+ * clearable
40
+ * onCreate={async nome => (await criarSetor(nome)).id}
41
+ * />
42
+ *
43
+ * - `clearable`: um X no gatilho volta o valor para `null`.
44
+ * - `onCreate`: com texto na busca que não bate com nenhuma opção, aparece
45
+ * "Criar “texto”". Se a função devolver um valor, ele fica selecionado.
46
+ * - `onRemove`: cada opção ganha um X que pede confirmação na própria linha.
47
+ * Se a opção excluída era a selecionada, o valor volta para `null`.
48
+ *
49
+ * Erros de `onCreate`/`onRemove` (promessa rejeitada) aparecem no rodapé da
50
+ * lista, com a mensagem do erro.
51
+ *
52
+ * Teclado: digite para filtrar, ↑/↓ para andar, Enter para escolher, Delete
53
+ * (com a busca vazia) para excluir a opção ativa, Esc para fechar. Dentro de
54
+ * um <Field>, o gatilho recebe o id e os aria-*.
55
+ */
56
+ function Combobox({
57
+ options,
58
+ value,
59
+ onValueChange,
60
+ placeholder = "Selecione",
61
+ searchPlaceholder = "Buscar…",
62
+ emptyText = "Nada encontrado.",
63
+ searchable = true,
64
+ clearable = false,
65
+ onCreate,
66
+ createLabel = q => <>Criar &ldquo;{q}&rdquo;</>,
67
+ onRemove,
68
+ removeLabel = o => `Excluir ${o.label}`,
69
+ removeConfirmLabel = o => <>Excluir &ldquo;{o.label}&rdquo;?</>,
70
+ size = "default",
71
+ disabled = false,
72
+ className,
73
+ wrapperClassName,
74
+ contentClassName,
75
+ onOpenChange,
76
+ ...props
77
+ }: {
78
+ options: ComboboxOption[]
79
+ value: string | null
80
+ onValueChange: (value: string | null) => void
81
+ placeholder?: string
82
+ searchPlaceholder?: string
83
+ emptyText?: string
84
+ searchable?: boolean
85
+ clearable?: boolean
86
+ /** Cria uma opção com o texto da busca. Devolva o valor dela para
87
+ * selecioná-la. */
88
+ onCreate?: (label: string) => string | void | Promise<string | void>
89
+ createLabel?: (query: string) => React.ReactNode
90
+ /** Exclui a opção (o X na linha, depois de confirmar). */
91
+ onRemove?: (option: ComboboxOption) => void | Promise<void>
92
+ /** Rótulo acessível do X de cada opção. */
93
+ removeLabel?: (option: ComboboxOption) => string
94
+ /** Pergunta mostrada na linha ao confirmar a exclusão. */
95
+ removeConfirmLabel?: (option: ComboboxOption) => React.ReactNode
96
+ size?: "sm" | "default"
97
+ disabled?: boolean
98
+ /** Classes do gatilho. */
99
+ className?: string
100
+ /** Classes do invólucro, que só existe com `clearable`. Use para largura e
101
+ * posição no layout. */
102
+ wrapperClassName?: string
103
+ contentClassName?: string
104
+ onOpenChange?: (open: boolean) => void
105
+ id?: string
106
+ "aria-label"?: string
107
+ "aria-describedby"?: string
108
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"]
109
+ }) {
110
+ const controlProps = useFieldControl(props, { withRequired: false })
111
+ const [open, setOpen] = React.useState(false)
112
+ const [query, setQuery] = React.useState("")
113
+ const [active, setActive] = React.useState(0)
114
+ const [creating, setCreating] = React.useState(false)
115
+ const [confirming, setConfirming] = React.useState<string | null>(null)
116
+ const [removing, setRemoving] = React.useState<string | null>(null)
117
+ const [error, setError] = React.useState<string | null>(null)
118
+ const listId = React.useId()
119
+ const searchRef = React.useRef<HTMLInputElement>(null)
120
+
121
+ const selected = options.find(o => o.value === value)
122
+ const q = query.trim().toLowerCase()
123
+ const filtered = q ? options.filter(o => o.label.toLowerCase().includes(q)) : options
124
+ const canCreate = !!onCreate && !!q && !options.some(o => o.label.trim().toLowerCase() === q)
125
+
126
+ // Ordem do teclado: as opções habilitadas e, por último, o "Criar".
127
+ const entries: Entry[] = filtered.filter(o => !o.disabled).map(option => ({ kind: "option" as const, option }))
128
+ if (canCreate) entries.push({ kind: "create" })
129
+ const activeIndex = Math.min(active, entries.length - 1)
130
+ const entryId = (i: number) => `${listId}-${i}`
131
+
132
+ // Grupos na ordem em que aparecem nas opções.
133
+ const groups: { name: string | undefined; items: ComboboxOption[] }[] = []
134
+ for (const o of filtered) {
135
+ const g = groups.find(x => x.name === o.group)
136
+ if (g) g.items.push(o)
137
+ else groups.push({ name: o.group, items: [o] })
138
+ }
139
+
140
+ // Mantém a opção ativa à vista ao andar com as setas.
141
+ React.useEffect(() => {
142
+ if (!open || activeIndex < 0) return
143
+ document.getElementById(entryId(activeIndex))?.scrollIntoView({ block: "nearest" })
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [open, activeIndex])
146
+
147
+ function handleOpenChange(o: boolean) {
148
+ setOpen(o)
149
+ if (!o) {
150
+ setQuery("")
151
+ setActive(0)
152
+ setConfirming(null)
153
+ setError(null)
154
+ } else {
155
+ // Abre com a opção selecionada ativa.
156
+ const i = entries.findIndex(e => e.kind === "option" && e.option.value === value)
157
+ setActive(Math.max(i, 0))
158
+ }
159
+ onOpenChange?.(o)
160
+ }
161
+
162
+ function choose(v: string) {
163
+ onValueChange(v)
164
+ handleOpenChange(false)
165
+ }
166
+
167
+ async function create() {
168
+ if (!onCreate || creating) return
169
+ setCreating(true)
170
+ setError(null)
171
+ try {
172
+ const created = await onCreate(query.trim())
173
+ if (typeof created === "string") onValueChange(created)
174
+ handleOpenChange(false)
175
+ } catch (err) {
176
+ setError(errorMessage(err, "Não foi possível criar."))
177
+ } finally {
178
+ setCreating(false)
179
+ }
180
+ }
181
+
182
+ async function remove(option: ComboboxOption) {
183
+ if (!onRemove) return
184
+ setRemoving(option.value)
185
+ setError(null)
186
+ try {
187
+ await onRemove(option)
188
+ if (option.value === value) onValueChange(null)
189
+ } catch (err) {
190
+ setError(errorMessage(err, "Não foi possível excluir."))
191
+ } finally {
192
+ setRemoving(null)
193
+ setConfirming(null)
194
+ searchRef.current?.focus()
195
+ }
196
+ }
197
+
198
+ function cancelConfirm() {
199
+ setConfirming(null)
200
+ searchRef.current?.focus()
201
+ }
202
+
203
+ function activate(entry: Entry | undefined) {
204
+ if (!entry) return
205
+ if (entry.kind === "create") void create()
206
+ else choose(entry.option.value)
207
+ }
208
+
209
+ function onKeyDown(e: React.KeyboardEvent) {
210
+ // Na confirmação, o teclado é dos botões Excluir/Cancelar.
211
+ if (confirming) return
212
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive(Math.min(activeIndex + 1, entries.length - 1)) }
213
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive(Math.max(activeIndex - 1, 0)) }
214
+ else if (e.key === "Enter") { e.preventDefault(); activate(entries[activeIndex]) }
215
+ else if (e.key === "Delete" && onRemove && !query) {
216
+ const entry = entries[activeIndex]
217
+ if (entry?.kind === "option") { e.preventDefault(); setConfirming(entry.option.value) }
218
+ }
219
+ }
220
+
221
+ const showClear = clearable && !!selected && !disabled
222
+
223
+ const trigger = (
224
+ <PopoverTrigger asChild disabled={disabled}>
225
+ <button
226
+ type="button"
227
+ role="combobox"
228
+ aria-expanded={open}
229
+ aria-controls={listId}
230
+ aria-haspopup="listbox"
231
+ data-slot="combobox-trigger"
232
+ data-size={size}
233
+ data-placeholder={selected ? undefined : ""}
234
+ {...controlProps}
235
+ className={cn(
236
+ selectTriggerVariants({ size }),
237
+ "w-full min-w-0 cursor-pointer text-left",
238
+ showClear && "pr-9",
239
+ className
240
+ )}
241
+ >
242
+ <span className="flex min-w-0 flex-1 items-center gap-2">
243
+ {selected?.color && <span className="size-2 shrink-0 rounded-full" style={{ background: selected.color }} />}
244
+ <span className="truncate">{selected?.label ?? placeholder}</span>
245
+ </span>
246
+ {!showClear && <ChevronDownIcon className="size-4 shrink-0 opacity-70" />}
247
+ </button>
248
+ </PopoverTrigger>
249
+ )
250
+
251
+ return (
252
+ <Popover open={open} onOpenChange={handleOpenChange}>
253
+ {clearable ? (
254
+ <div data-slot="combobox-wrapper" className={cn("relative w-full min-w-0", wrapperClassName)}>
255
+ {trigger}
256
+ {showClear && (
257
+ // Irmão do gatilho, não filho: botão dentro de botão não é HTML válido.
258
+ <button
259
+ type="button"
260
+ onClick={() => onValueChange(null)}
261
+ aria-label="Limpar"
262
+ className="absolute inset-y-0 right-1.5 my-auto flex size-6 cursor-pointer items-center justify-center rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
263
+ >
264
+ <XIcon className="size-4" />
265
+ </button>
266
+ )}
267
+ </div>
268
+ ) : trigger}
269
+
270
+ <PopoverContent
271
+ className={cn("w-(--radix-popover-trigger-width) min-w-60 p-0", contentClassName)}
272
+ onKeyDown={onKeyDown}
273
+ onEscapeKeyDown={e => { if (confirming) { e.preventDefault(); cancelConfirm() } }}
274
+ >
275
+ {searchable && (
276
+ <div className="flex items-center gap-2 border-b border-border px-3">
277
+ <SearchIcon className="size-4 shrink-0 text-muted-foreground" />
278
+ <input
279
+ ref={searchRef}
280
+ autoFocus
281
+ value={query}
282
+ onChange={e => { setQuery(e.target.value); setActive(0); setError(null) }}
283
+ placeholder={searchPlaceholder}
284
+ aria-label={searchPlaceholder}
285
+ aria-controls={listId}
286
+ aria-activedescendant={activeIndex >= 0 && !confirming ? entryId(activeIndex) : undefined}
287
+ aria-autocomplete="list"
288
+ className="h-9 w-full bg-transparent text-base outline-none placeholder:text-muted-foreground md:text-sm"
289
+ />
290
+ </div>
291
+ )}
292
+
293
+ <div id={listId} role="listbox" className="max-h-64 overflow-y-auto overscroll-contain p-1">
294
+ {filtered.length === 0 && !canCreate && (
295
+ <p className="px-2.5 py-6 text-center text-sm text-muted-foreground">{emptyText}</p>
296
+ )}
297
+
298
+ {groups.map(g => (
299
+ <div key={g.name ?? "_"} role="group" aria-label={g.name}>
300
+ {g.name && <p className="px-2.5 pt-2 pb-1 text-xs font-medium text-muted-foreground">{g.name}</p>}
301
+ {g.items.map(o => {
302
+ const index = entries.findIndex(e => e.kind === "option" && e.option.value === o.value)
303
+ const isSelected = o.value === value
304
+ const isActive = index === activeIndex
305
+
306
+ if (confirming === o.value) return (
307
+ <div key={o.value} role="alert" className="flex items-center gap-1.5 rounded-md bg-destructive/10 py-1 pr-1 pl-2.5 text-sm">
308
+ <span className="min-w-0 flex-1 truncate">{removeConfirmLabel(o)}</span>
309
+ <Button
310
+ type="button"
311
+ size="sm"
312
+ variant="destructive"
313
+ autoFocus
314
+ disabled={removing === o.value}
315
+ onClick={() => remove(o)}
316
+ >
317
+ {removing === o.value ? <Loader2Icon className="animate-spin" /> : "Excluir"}
318
+ </Button>
319
+ <Button type="button" size="sm" variant="ghost" disabled={removing === o.value} onClick={cancelConfirm}>
320
+ Cancelar
321
+ </Button>
322
+ </div>
323
+ )
324
+
325
+ return (
326
+ <div
327
+ key={o.value}
328
+ id={index >= 0 ? entryId(index) : undefined}
329
+ role="option"
330
+ aria-selected={isSelected}
331
+ aria-disabled={o.disabled || undefined}
332
+ data-active={isActive || undefined}
333
+ onMouseEnter={() => { if (index >= 0) setActive(index) }}
334
+ onClick={() => { if (!o.disabled) choose(o.value) }}
335
+ className="group/option flex cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 pl-2.5 text-sm select-none data-active:bg-accent data-active:text-accent-foreground aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
336
+ >
337
+ {o.color && <span className="size-2 shrink-0 rounded-full" style={{ background: o.color }} />}
338
+ <span className="flex min-w-0 flex-1 flex-col">
339
+ <span className="truncate">{o.label}</span>
340
+ {o.description && <span className="truncate text-[11px] text-muted-foreground">{o.description}</span>}
341
+ </span>
342
+ {isSelected && <CheckIcon className="size-4 shrink-0" />}
343
+ {onRemove && !o.disabled && (
344
+ // Fora da ordem do Tab (a lista anda pelas setas); no
345
+ // teclado, a exclusão é o Delete na opção ativa.
346
+ <button
347
+ type="button"
348
+ tabIndex={-1}
349
+ aria-label={removeLabel(o)}
350
+ title={removeLabel(o)}
351
+ onClick={e => { e.stopPropagation(); setConfirming(o.value) }}
352
+ className="-m-1 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground opacity-60 outline-none hover:text-destructive hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/option:opacity-60 [@media(hover:hover)]:group-data-[active]/option:opacity-60"
353
+ >
354
+ <XIcon className="size-3.5" />
355
+ </button>
356
+ )}
357
+ </div>
358
+ )
359
+ })}
360
+ </div>
361
+ ))}
362
+
363
+ {canCreate && (() => {
364
+ const index = entries.length - 1
365
+ return (
366
+ <div
367
+ id={entryId(index)}
368
+ role="option"
369
+ aria-selected={false}
370
+ data-active={index === activeIndex || undefined}
371
+ onMouseEnter={() => setActive(index)}
372
+ onClick={() => void create()}
373
+ className="flex cursor-pointer items-center gap-2 rounded-md py-1.5 pr-2 pl-2.5 text-sm font-medium text-primary-strong select-none data-active:bg-primary/10"
374
+ >
375
+ {creating ? <Loader2Icon className="size-4 shrink-0 animate-spin" /> : <PlusIcon className="size-4 shrink-0" />}
376
+ <span className="truncate">{createLabel(query.trim())}</span>
377
+ </div>
378
+ )
379
+ })()}
380
+ </div>
381
+
382
+ {error && (
383
+ <p role="alert" className="border-t border-border px-3 py-2 text-xs text-destructive">{error}</p>
384
+ )}
385
+ </PopoverContent>
386
+ </Popover>
387
+ )
388
+ }
389
+
390
+ export { Combobox }