@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "8.6.9",
3
+ "version": "8.7.1",
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,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
- * ABRIR limpa a busca.
225
+ * Abrir com busca FOCA a caixa — abrir e já poder digitar é o ponto do padrão.
228
226
  *
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 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.
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
- * 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.
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 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
- // 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
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
- {/* 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 && (
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
- <CommandPrimitive.Input
373
+ <input
346
374
  ref={inputRef}
347
375
  data-slot="select-input"
348
376
  id={id}
349
377
  disabled={disabled}
350
- readOnly={!canSearch}
378
+ readOnly
379
+ role="combobox"
380
+ aria-expanded={open}
351
381
  aria-label={props['aria-label']}
352
- value={triggerText ?? ''}
353
- onValueChange={(v) => {
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 ? '' : (canSearch ? (searchPlaceholder ?? placeholder) : placeholder)}
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
- canSearch ? '' : 'cursor-pointer caret-transparent',
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: 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
- )}
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
- onOpenAutoFocus={(e) => e.preventDefault()}
464
+ // Com busca, o foco VAI pra caixa dentro da lista: abrir e 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