@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "8.6.9",
3
+ "version": "8.7.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- * ABRIR limpa a busca.
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
- * Visto em produção duas vezes no mesmo dia: o seletor de task da cabine buscando
236
- * "Sem task", e o de modelo de IA buscando o nome do modelo corrente. Nos dois, a
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 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
- // Não-buscável: o campo é só gatilho — readOnly (sem caret, sem digitar), o clique abre.
284
- // Um render só pros dois modos; o que muda é poder digitar.
285
- const triggerText = !props.multiple && !open && selectedOption?.triggerLabel !== undefined ? undefined : query
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
- {/* triggerLabel: o gatilho mostra o rótulo CURTO da opção (o input fica
339
- escondido atrás dele até focar) o caso da barra do composer. */}
340
- {triggerText === undefined && (
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
- <CommandPrimitive.Input
358
+ <input
346
359
  ref={inputRef}
347
360
  data-slot="select-input"
348
361
  id={id}
349
362
  disabled={disabled}
350
- readOnly={!canSearch}
363
+ readOnly
364
+ role="combobox"
365
+ aria-expanded={open}
351
366
  aria-label={props['aria-label']}
352
- value={triggerText ?? ''}
353
- onValueChange={(v) => {
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 ? '' : (canSearch ? (searchPlaceholder ?? placeholder) : placeholder)}
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
- canSearch ? '' : 'cursor-pointer caret-transparent',
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: a affordance de "abre uma lista". Sempre no não-buscável; no
413
- buscável some (ali quem manda o sinal é o cursor de texto). */}
414
- {!canSearch && (
415
- <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" />
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
- onOpenAutoFocus={(e) => e.preventDefault()}
449
+ // Com busca, o foco VAI pra caixa dentro da lista: abrir e 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