@softize/opus 8.6.9 → 8.7.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/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,19 @@ 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)
|
|
218
220
|
|
|
219
221
|
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
222
|
|
|
226
223
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
* O rótulo do item escolhido vive no mesmo estado que o termo de busca (é o mesmo
|
|
230
|
-
* input). Sem isto, abrir o seletor herda esse rótulo como CONSULTA: a lista nasce
|
|
231
|
-
* filtrada pelo nome do que já está selecionado — e some justamente quando a pessoa
|
|
232
|
-
* abriu pra trocar. Com `onSearch`, o efeito abaixo ainda dispara uma busca remota por
|
|
233
|
-
* essa frase, que quase nunca casa com outra coisa.
|
|
224
|
+
* O termo de busca zera ao FECHAR.
|
|
234
225
|
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* pessoa via lista vazia com dados no servidor.
|
|
226
|
+
* Ele é estado da consulta, não do valor — deixá-lo vivo entre aberturas faria a lista
|
|
227
|
+
* reabrir filtrada por algo que a pessoa procurou minutos antes e já esqueceu.
|
|
238
228
|
*/
|
|
239
229
|
React.useEffect(() => {
|
|
240
|
-
if (open) setQuery('')
|
|
230
|
+
if (!open) setQuery('')
|
|
241
231
|
}, [open])
|
|
242
232
|
|
|
243
233
|
// O cmdk carimba o id DELE no input (a11y interna) e descarta o nosso — reaplicamos por
|
|
@@ -280,9 +270,30 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
280
270
|
}
|
|
281
271
|
|
|
282
272
|
const ghost = variant === 'ghost'
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* O GATILHO nunca é caixa de busca — em nenhum modo.
|
|
276
|
+
*
|
|
277
|
+
* Antes o mesmo campo fazia dois trabalhos: exibir o escolhido e receber o que se
|
|
278
|
+
* digita. Os dois dividiam um estado, e daí vinham os defeitos: abrir herdava o rótulo
|
|
279
|
+
* como consulta (a lista sumia justo quando se ia trocar) e, com busca remota, o
|
|
280
|
+
* rótulo virava `q=` no servidor. Não era azar — é o que acontece quando duas
|
|
281
|
+
* responsabilidades ocupam o mesmo lugar.
|
|
282
|
+
*
|
|
283
|
+
* Agora o gatilho só EXIBE (readOnly, sem caret) e a busca mora dentro da lista. Some a
|
|
284
|
+
* ambiguidade, e some a classe de bug: a caixa de busca nasce vazia por construção, não
|
|
285
|
+
* porque alguém lembrou de limpar. De quebra, a pessoa vê onde está enquanto procura
|
|
286
|
+
* pra onde ir — antes, começar a procurar apagava a referência.
|
|
287
|
+
*
|
|
288
|
+
* É o padrão de quem escolhe de um CONJUNTO (Linear, GitHub, VS Code). O formato
|
|
289
|
+
* anterior — digitar no próprio campo — é de quem digita um VALOR (endereço, cidade),
|
|
290
|
+
* onde não existe seleção persistente pra exibir.
|
|
291
|
+
*/
|
|
292
|
+
const triggerText = selectedOption?.label ?? ''
|
|
293
|
+
/** `triggerLabel` pode ser JSX (a barra do composer mostra a chave curta), e `value` de
|
|
294
|
+
* input só aceita texto — então ele vai num `<span>` por cima, com o input `sr-only`
|
|
295
|
+
* guardando o `id` e o rótulo textual pro leitor de tela. */
|
|
296
|
+
const customTrigger = !props.multiple && selectedOption?.triggerLabel !== undefined
|
|
286
297
|
|
|
287
298
|
return (
|
|
288
299
|
<Popover open={open} onOpenChange={setOpen}>
|
|
@@ -335,41 +346,52 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
335
346
|
</span>
|
|
336
347
|
)
|
|
337
348
|
})}
|
|
338
|
-
{/*
|
|
339
|
-
|
|
340
|
-
|
|
349
|
+
{/* Input somente-leitura em vez de `<span>`: ele carrega o `id`, e é o que
|
|
350
|
+
faz o `htmlFor` do Label achar um campo (clicar no rótulo foca e abre).
|
|
351
|
+
Um `<div>` não é rotulável, e um `<button>` não pode conter os botões de
|
|
352
|
+
remover chip do modo múltiplo. */}
|
|
353
|
+
{customTrigger && (
|
|
341
354
|
<span data-slot="select-trigger-label" className="min-w-0 truncate text-sm">
|
|
342
355
|
{selectedOption?.triggerLabel}
|
|
343
356
|
</span>
|
|
344
357
|
)}
|
|
345
|
-
<
|
|
358
|
+
<input
|
|
346
359
|
ref={inputRef}
|
|
347
360
|
data-slot="select-input"
|
|
348
361
|
id={id}
|
|
349
362
|
disabled={disabled}
|
|
350
|
-
readOnly
|
|
363
|
+
readOnly
|
|
364
|
+
role="combobox"
|
|
365
|
+
aria-expanded={open}
|
|
351
366
|
aria-label={props['aria-label']}
|
|
352
|
-
value={
|
|
353
|
-
|
|
354
|
-
if (!canSearch) return
|
|
355
|
-
setQuery(v)
|
|
356
|
-
setOpen(true)
|
|
357
|
-
}}
|
|
367
|
+
value={props.multiple ? '' : triggerText}
|
|
368
|
+
onChange={() => {}}
|
|
358
369
|
onFocus={() => {
|
|
370
|
+
// Fechar por Esc devolve o foco pra cá — e focar abre. Sem esta guarda,
|
|
371
|
+
// Esc REABRE a lista e o seletor fica impossível de fechar pelo teclado.
|
|
372
|
+
if (closingRef.current) {
|
|
373
|
+
closingRef.current = false
|
|
374
|
+
return
|
|
375
|
+
}
|
|
359
376
|
setOpen(true)
|
|
360
|
-
if (!props.multiple && canSearch) inputRef.current?.select()
|
|
361
377
|
}}
|
|
362
378
|
onKeyDown={(e) => {
|
|
363
379
|
if (e.key === 'Escape' && open) {
|
|
364
380
|
e.stopPropagation()
|
|
365
381
|
setOpen(false)
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
// Teclado abre a lista como o clique abre: sem isto, quem navega por Tab
|
|
385
|
+
// chega no campo e não tem como escolher nada.
|
|
386
|
+
if (!open && (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown')) {
|
|
387
|
+
e.preventDefault()
|
|
388
|
+
setOpen(true)
|
|
366
389
|
}
|
|
367
390
|
}}
|
|
368
|
-
placeholder={props.multiple && selectedValues.length > 0 ? '' :
|
|
391
|
+
placeholder={props.multiple && selectedValues.length > 0 ? '' : placeholder}
|
|
369
392
|
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',
|
|
393
|
+
'min-w-0 flex-1 cursor-pointer bg-transparent text-sm caret-transparent outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed',
|
|
394
|
+
customTrigger && 'sr-only',
|
|
373
395
|
)}
|
|
374
396
|
/>
|
|
375
397
|
{clearable === true && selectedValues.length > 0 && disabled !== true && (
|
|
@@ -409,11 +431,10 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
409
431
|
{trailing}
|
|
410
432
|
</span>
|
|
411
433
|
)}
|
|
412
|
-
{/* Chevron:
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
)}
|
|
434
|
+
{/* Chevron SEMPRE: o gatilho agora abre uma lista nos dois modos, e essa é a
|
|
435
|
+
affordance que diz isso. Antes ele sumia no buscável, porque ali o sinal
|
|
436
|
+
era o cursor de texto — que não existe mais no campo. */}
|
|
437
|
+
<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
438
|
</div>
|
|
418
439
|
</PopoverAnchor>
|
|
419
440
|
<PopoverContent
|
|
@@ -425,7 +446,11 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
425
446
|
'pointer-events-auto overflow-hidden p-0',
|
|
426
447
|
ghost ? 'w-auto min-w-56 max-w-80' : 'w-(--radix-popover-trigger-width) min-w-40',
|
|
427
448
|
)}
|
|
428
|
-
|
|
449
|
+
// Com busca, o foco VAI pra caixa dentro da lista: abrir e já poder digitar é
|
|
450
|
+
// o ponto do padrão. Sem busca, segura o foco no gatilho (nada pra digitar).
|
|
451
|
+
onOpenAutoFocus={(e) => {
|
|
452
|
+
if (!canSearch) e.preventDefault()
|
|
453
|
+
}}
|
|
429
454
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
430
455
|
onInteractOutside={(e) => {
|
|
431
456
|
const target = e.target as HTMLElement
|
|
@@ -436,6 +461,29 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
|
|
|
436
461
|
onWheel={(e) => e.stopPropagation()}
|
|
437
462
|
onTouchMove={(e) => e.stopPropagation()}
|
|
438
463
|
>
|
|
464
|
+
{/* A busca mora AQUI, e não no gatilho. Nasce vazia porque é um campo
|
|
465
|
+
próprio — não há rótulo pra herdar. */}
|
|
466
|
+
{canSearch && (
|
|
467
|
+
<div data-slot="select-search-row" className="flex items-center gap-2 border-b px-3">
|
|
468
|
+
<SearchIcon className="size-4 shrink-0 opacity-50" aria-hidden="true" />
|
|
469
|
+
<CommandPrimitive.Input
|
|
470
|
+
data-slot="select-search"
|
|
471
|
+
autoFocus
|
|
472
|
+
value={query}
|
|
473
|
+
onValueChange={setQuery}
|
|
474
|
+
placeholder={searchPlaceholder ?? 'Buscar…'}
|
|
475
|
+
className="flex-1 bg-transparent py-2.5 text-sm outline-hidden placeholder:text-muted-foreground"
|
|
476
|
+
onKeyDown={(e) => {
|
|
477
|
+
if (e.key === 'Escape') {
|
|
478
|
+
e.stopPropagation()
|
|
479
|
+
closingRef.current = true
|
|
480
|
+
setOpen(false)
|
|
481
|
+
inputRef.current?.focus() // devolve o foco ao gatilho, senão ele se perde
|
|
482
|
+
}
|
|
483
|
+
}}
|
|
484
|
+
/>
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
439
487
|
{showToggleAll && (
|
|
440
488
|
<div className="border-b p-1">
|
|
441
489
|
<button
|