@softize/opus 8.6.9 → 8.7.1
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/package.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* lista longa ou que cresce → `searchable`; item que precisa de JSX → `content`.
|
|
22
22
|
*/
|
|
23
23
|
import * as React from 'react'
|
|
24
|
-
import { Check, ChevronDownIcon, X } from 'lucide-react'
|
|
24
|
+
import { Check, ChevronDownIcon, SearchIcon, X } from 'lucide-react'
|
|
25
25
|
import { Command as CommandPrimitive } from 'cmdk'
|
|
26
26
|
|
|
27
27
|
import { cn } from '../../lib/cn.ts'
|
|
@@ -215,29 +215,34 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
215
215
|
const [query, setQuery] = React.useState('')
|
|
216
216
|
const rootRef = React.useRef<HTMLDivElement>(null)
|
|
217
217
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
218
|
+
/** Fechando por Esc: o foco volta pro gatilho, e focar abre. Marca pra ignorar UM foco. */
|
|
219
|
+
const closingRef = React.useRef(false)
|
|
220
|
+
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
218
221
|
|
|
219
222
|
const selectedOption = options.find((o) => o.value === singleValue)
|
|
220
|
-
// Single: fechado, o campo mostra o rótulo escolhido (sincroniza com value/options).
|
|
221
|
-
React.useEffect(() => {
|
|
222
|
-
if (singleValue === undefined || open) return
|
|
223
|
-
setQuery(selectedOption?.label ?? '')
|
|
224
|
-
}, [singleValue, selectedOption, open])
|
|
225
223
|
|
|
226
224
|
/**
|
|
227
|
-
*
|
|
225
|
+
* Abrir com busca FOCA a caixa — abrir e já poder digitar é o ponto do padrão.
|
|
228
226
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
|
|
227
|
+
* Explícito e não pelo `autoFocus`: aqui dependeria de o Radix (que move o foco ao
|
|
228
|
+
* abrir o popover) e o cmdk concordarem sobre quem manda, e o resultado varia com a
|
|
229
|
+
* ordem de montagem. `requestAnimationFrame` porque o conteúdo do popover entra no DOM
|
|
230
|
+
* depois deste efeito.
|
|
231
|
+
*/
|
|
232
|
+
React.useEffect(() => {
|
|
233
|
+
if (!open || !canSearch) return
|
|
234
|
+
const r = requestAnimationFrame(() => searchRef.current?.focus())
|
|
235
|
+
return () => cancelAnimationFrame(r)
|
|
236
|
+
}, [open, canSearch])
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* O termo de busca zera ao FECHAR.
|
|
234
240
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* pessoa via lista vazia com dados no servidor.
|
|
241
|
+
* Ele é estado da consulta, não do valor — deixá-lo vivo entre aberturas faria a lista
|
|
242
|
+
* reabrir filtrada por algo que a pessoa procurou minutos antes e já esqueceu.
|
|
238
243
|
*/
|
|
239
244
|
React.useEffect(() => {
|
|
240
|
-
if (open) setQuery('')
|
|
245
|
+
if (!open) setQuery('')
|
|
241
246
|
}, [open])
|
|
242
247
|
|
|
243
248
|
// O cmdk carimba o id DELE no input (a11y interna) e descarta o nosso — reaplicamos por
|
|
@@ -280,9 +285,30 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
280
285
|
}
|
|
281
286
|
|
|
282
287
|
const ghost = variant === 'ghost'
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* O GATILHO nunca é caixa de busca — em nenhum modo.
|
|
291
|
+
*
|
|
292
|
+
* Antes o mesmo campo fazia dois trabalhos: exibir o escolhido e receber o que se
|
|
293
|
+
* digita. Os dois dividiam um estado, e daí vinham os defeitos: abrir herdava o rótulo
|
|
294
|
+
* como consulta (a lista sumia justo quando se ia trocar) e, com busca remota, o
|
|
295
|
+
* rótulo virava `q=` no servidor. Não era azar — é o que acontece quando duas
|
|
296
|
+
* responsabilidades ocupam o mesmo lugar.
|
|
297
|
+
*
|
|
298
|
+
* Agora o gatilho só EXIBE (readOnly, sem caret) e a busca mora dentro da lista. Some a
|
|
299
|
+
* ambiguidade, e some a classe de bug: a caixa de busca nasce vazia por construção, não
|
|
300
|
+
* porque alguém lembrou de limpar. De quebra, a pessoa vê onde está enquanto procura
|
|
301
|
+
* pra onde ir — antes, começar a procurar apagava a referência.
|
|
302
|
+
*
|
|
303
|
+
* É o padrão de quem escolhe de um CONJUNTO (Linear, GitHub, VS Code). O formato
|
|
304
|
+
* anterior — digitar no próprio campo — é de quem digita um VALOR (endereço, cidade),
|
|
305
|
+
* onde não existe seleção persistente pra exibir.
|
|
306
|
+
*/
|
|
307
|
+
const triggerText = selectedOption?.label ?? ''
|
|
308
|
+
/** `triggerLabel` pode ser JSX (a barra do composer mostra a chave curta), e `value` de
|
|
309
|
+
* input só aceita texto — então ele vai num `<span>` por cima, com o input `sr-only`
|
|
310
|
+
* guardando o `id` e o rótulo textual pro leitor de tela. */
|
|
311
|
+
const customTrigger = !props.multiple && selectedOption?.triggerLabel !== undefined
|
|
286
312
|
|
|
287
313
|
return (
|
|
288
314
|
<Popover open={open} onOpenChange={setOpen}>
|
|
@@ -335,41 +361,52 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
335
361
|
</span>
|
|
336
362
|
)
|
|
337
363
|
})}
|
|
338
|
-
{/*
|
|
339
|
-
|
|
340
|
-
|
|
364
|
+
{/* Input somente-leitura em vez de `<span>`: ele carrega o `id`, e é o que
|
|
365
|
+
faz o `htmlFor` do Label achar um campo (clicar no rótulo foca e abre).
|
|
366
|
+
Um `<div>` não é rotulável, e um `<button>` não pode conter os botões de
|
|
367
|
+
remover chip do modo múltiplo. */}
|
|
368
|
+
{customTrigger && (
|
|
341
369
|
<span data-slot="select-trigger-label" className="min-w-0 truncate text-sm">
|
|
342
370
|
{selectedOption?.triggerLabel}
|
|
343
371
|
</span>
|
|
344
372
|
)}
|
|
345
|
-
<
|
|
373
|
+
<input
|
|
346
374
|
ref={inputRef}
|
|
347
375
|
data-slot="select-input"
|
|
348
376
|
id={id}
|
|
349
377
|
disabled={disabled}
|
|
350
|
-
readOnly
|
|
378
|
+
readOnly
|
|
379
|
+
role="combobox"
|
|
380
|
+
aria-expanded={open}
|
|
351
381
|
aria-label={props['aria-label']}
|
|
352
|
-
value={
|
|
353
|
-
|
|
354
|
-
if (!canSearch) return
|
|
355
|
-
setQuery(v)
|
|
356
|
-
setOpen(true)
|
|
357
|
-
}}
|
|
382
|
+
value={props.multiple ? '' : triggerText}
|
|
383
|
+
onChange={() => {}}
|
|
358
384
|
onFocus={() => {
|
|
385
|
+
// Fechar por Esc devolve o foco pra cá — e focar abre. Sem esta guarda,
|
|
386
|
+
// Esc REABRE a lista e o seletor fica impossível de fechar pelo teclado.
|
|
387
|
+
if (closingRef.current) {
|
|
388
|
+
closingRef.current = false
|
|
389
|
+
return
|
|
390
|
+
}
|
|
359
391
|
setOpen(true)
|
|
360
|
-
if (!props.multiple && canSearch) inputRef.current?.select()
|
|
361
392
|
}}
|
|
362
393
|
onKeyDown={(e) => {
|
|
363
394
|
if (e.key === 'Escape' && open) {
|
|
364
395
|
e.stopPropagation()
|
|
365
396
|
setOpen(false)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
// Teclado abre a lista como o clique abre: sem isto, quem navega por Tab
|
|
400
|
+
// chega no campo e não tem como escolher nada.
|
|
401
|
+
if (!open && (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown')) {
|
|
402
|
+
e.preventDefault()
|
|
403
|
+
setOpen(true)
|
|
366
404
|
}
|
|
367
405
|
}}
|
|
368
|
-
placeholder={props.multiple && selectedValues.length > 0 ? '' :
|
|
406
|
+
placeholder={props.multiple && selectedValues.length > 0 ? '' : placeholder}
|
|
369
407
|
className={cn(
|
|
370
|
-
'min-w-0 flex-1 bg-transparent text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed',
|
|
371
|
-
|
|
372
|
-
triggerText === undefined && 'sr-only',
|
|
408
|
+
'min-w-0 flex-1 cursor-pointer bg-transparent text-sm caret-transparent outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed',
|
|
409
|
+
customTrigger && 'sr-only',
|
|
373
410
|
)}
|
|
374
411
|
/>
|
|
375
412
|
{clearable === true && selectedValues.length > 0 && disabled !== true && (
|
|
@@ -409,11 +446,10 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
409
446
|
{trailing}
|
|
410
447
|
</span>
|
|
411
448
|
)}
|
|
412
|
-
{/* Chevron:
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
)}
|
|
449
|
+
{/* Chevron SEMPRE: o gatilho agora abre uma lista nos dois modos, e essa é a
|
|
450
|
+
affordance que diz isso. Antes ele sumia no buscável, porque ali o sinal
|
|
451
|
+
era o cursor de texto — que não existe mais no campo. */}
|
|
452
|
+
<ChevronDownIcon data-slot="select-icon" className={cn('size-4 shrink-0 opacity-50', ghost ? 'h-3.5 w-3.5 opacity-60' : '')} aria-hidden="true" />
|
|
417
453
|
</div>
|
|
418
454
|
</PopoverAnchor>
|
|
419
455
|
<PopoverContent
|
|
@@ -425,7 +461,11 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
425
461
|
'pointer-events-auto overflow-hidden p-0',
|
|
426
462
|
ghost ? 'w-auto min-w-56 max-w-80' : 'w-(--radix-popover-trigger-width) min-w-40',
|
|
427
463
|
)}
|
|
428
|
-
|
|
464
|
+
// Com busca, o foco VAI pra caixa dentro da lista: abrir e já poder digitar é
|
|
465
|
+
// o ponto do padrão. Sem busca, segura o foco no gatilho (nada pra digitar).
|
|
466
|
+
onOpenAutoFocus={(e) => {
|
|
467
|
+
if (!canSearch) e.preventDefault()
|
|
468
|
+
}}
|
|
429
469
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
430
470
|
onInteractOutside={(e) => {
|
|
431
471
|
const target = e.target as HTMLElement
|
|
@@ -436,6 +476,29 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
436
476
|
onWheel={(e) => e.stopPropagation()}
|
|
437
477
|
onTouchMove={(e) => e.stopPropagation()}
|
|
438
478
|
>
|
|
479
|
+
{/* A busca mora AQUI, e não no gatilho. Nasce vazia porque é um campo
|
|
480
|
+
próprio — não há rótulo pra herdar. */}
|
|
481
|
+
{canSearch && (
|
|
482
|
+
<div data-slot="select-search-row" className="flex items-center gap-2 border-b px-3">
|
|
483
|
+
<SearchIcon className="size-4 shrink-0 opacity-50" aria-hidden="true" />
|
|
484
|
+
<CommandPrimitive.Input
|
|
485
|
+
ref={searchRef}
|
|
486
|
+
data-slot="select-search"
|
|
487
|
+
value={query}
|
|
488
|
+
onValueChange={setQuery}
|
|
489
|
+
placeholder={searchPlaceholder ?? 'Buscar…'}
|
|
490
|
+
className="flex-1 bg-transparent py-2.5 text-sm outline-hidden placeholder:text-muted-foreground"
|
|
491
|
+
onKeyDown={(e) => {
|
|
492
|
+
if (e.key === 'Escape') {
|
|
493
|
+
e.stopPropagation()
|
|
494
|
+
closingRef.current = true
|
|
495
|
+
setOpen(false)
|
|
496
|
+
inputRef.current?.focus() // devolve o foco ao gatilho, senão ele se perde
|
|
497
|
+
}
|
|
498
|
+
}}
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
)}
|
|
439
502
|
{showToggleAll && (
|
|
440
503
|
<div className="border-b p-1">
|
|
441
504
|
<button
|