@softize/opus 8.7.1 → 8.8.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.7.1",
3
+ "version": "8.8.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",
@@ -2,6 +2,7 @@ import * as React from "react"
2
2
  import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
3
3
  import { DropdownMenu as MenuPrimitive } from "radix-ui"
4
4
 
5
+ import { useDismissOnIframeFocus } from "../../lib/dismiss-on-iframe.ts"
5
6
  import { cn } from '../../lib/cn.ts'
6
7
 
7
8
  function Menu({
@@ -34,9 +35,13 @@ function MenuContent({
34
35
  sideOffset = 4,
35
36
  ...props
36
37
  }: React.ComponentProps<typeof MenuPrimitive.Content>) {
38
+ // Mesma razão do Popover: clique dentro de iframe não chega aqui fora.
39
+ const ref = React.useRef<HTMLDivElement>(null)
40
+ useDismissOnIframeFocus(ref)
37
41
  return (
38
42
  <MenuPrimitive.Portal>
39
43
  <MenuPrimitive.Content
44
+ ref={ref}
40
45
  data-slot="menu-content"
41
46
  sideOffset={sideOffset}
42
47
  className={cn(
@@ -1,6 +1,7 @@
1
1
  import * as React from "react"
2
2
  import { Popover as PopoverPrimitive } from "radix-ui"
3
3
 
4
+ import { useDismissOnIframeFocus } from "../../lib/dismiss-on-iframe.ts"
4
5
  import { cn } from '../../lib/cn.ts'
5
6
 
6
7
  function Popover({
@@ -21,9 +22,14 @@ function PopoverContent({
21
22
  sideOffset = 4,
22
23
  ...props
23
24
  }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
25
+ // Clique dentro de um iframe não chega no documento de fora — sem isto, o popover fica
26
+ // pendurado sobre o conteúdo. Na cabine, o preview É um iframe.
27
+ const ref = React.useRef<HTMLDivElement>(null)
28
+ useDismissOnIframeFocus(ref)
24
29
  return (
25
30
  <PopoverPrimitive.Portal>
26
31
  <PopoverPrimitive.Content
32
+ ref={ref}
27
33
  data-slot="popover-content"
28
34
  align={align}
29
35
  sideOffset={sideOffset}
@@ -305,13 +305,18 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
305
305
  * onde não existe seleção persistente pra exibir.
306
306
  */
307
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
312
308
 
313
309
  return (
314
- <Popover open={open} onOpenChange={setOpen}>
310
+ <Popover
311
+ open={open}
312
+ onOpenChange={(v) => {
313
+ // Fechar devolve o foco pro gatilho, e o gatilho abre AO RECEBER FOCO. Sem armar a
314
+ // guarda em TODO fechamento (não só no Esc), o seletor reabria sozinho — o
315
+ // "abre, fecha e abre de novo" que aparecia no primeiro clique.
316
+ if (!v) closingRef.current = true
317
+ setOpen(v)
318
+ }}
319
+ >
315
320
  <div ref={rootRef} data-slot="select" className={cn(ghost ? 'min-w-0' : 'min-w-40', className)}>
316
321
  <CommandPrimitive data-slot="select-command" shouldFilter={canSearch && onSearch === undefined} className="overflow-visible">
317
322
  <PopoverAnchor asChild>
@@ -361,13 +366,13 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
361
366
  </span>
362
367
  )
363
368
  })}
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 && (
369
- <span data-slot="select-trigger-label" className="min-w-0 truncate text-sm">
370
- {selectedOption?.triggerLabel}
369
+ {/* O rótulo é SEMPRE um `<span>`. Quando ele morava no `value` de um input,
370
+ o texto rolava pro fim ao ganhar foco (o browser leva o cursor pro final)
371
+ e o começo do título sumia num campo estreito, a pessoa via o meio de
372
+ uma frase. `<span>` trunca com reticências, que é o certo. */}
373
+ {!props.multiple && (
374
+ <span data-slot="select-trigger-label" className={cn('min-w-0 flex-1 truncate text-left text-sm', selectedOption === undefined && 'text-muted-foreground')}>
375
+ {selectedOption?.triggerLabel ?? (selectedOption?.label ?? placeholder)}
371
376
  </span>
372
377
  )}
373
378
  <input
@@ -404,10 +409,7 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
404
409
  }
405
410
  }}
406
411
  placeholder={props.multiple && selectedValues.length > 0 ? '' : placeholder}
407
- className={cn(
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',
410
- )}
412
+ className="sr-only"
411
413
  />
412
414
  {clearable === true && selectedValues.length > 0 && disabled !== true && (
413
415
  <button
@@ -461,11 +463,16 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
461
463
  'pointer-events-auto overflow-hidden p-0',
462
464
  ghost ? 'w-auto min-w-56 max-w-80' : 'w-(--radix-popover-trigger-width) min-w-40',
463
465
  )}
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
- }}
466
+ /*
467
+ * O foco ao abrir é SEMPRE nosso o Radix nunca mexe.
468
+ *
469
+ * Deixá-lo agir com busca parecia certo (ele focaria o primeiro elemento
470
+ * focável, que é a caixa), mas ele roda DEPOIS do nosso efeito e movia o foco
471
+ * pro contêiner do conteúdo: a busca ganhava o cursor e o perdia em seguida,
472
+ * que é exatamente o "ganha foco e some" relatado. Com dois donos do foco, o
473
+ * último a escrever vence — e quem é o último depende da ordem de montagem.
474
+ */
475
+ onOpenAutoFocus={(e) => e.preventDefault()}
469
476
  onCloseAutoFocus={(e) => e.preventDefault()}
470
477
  onInteractOutside={(e) => {
471
478
  const target = e.target as HTMLElement
@@ -0,0 +1,36 @@
1
+ import * as React from 'react'
2
+
3
+ /**
4
+ * Fecha a camada flutuante quando o clique cai DENTRO de um iframe.
5
+ *
6
+ * O navegador entrega o clique ao documento do iframe, e o documento de fora nunca fica
7
+ * sabendo. Como toda dispensa por "clique fora" escuta eventos do documento de fora, o
8
+ * popover/menu simplesmente NÃO fecha — fica pendurado sobre o conteúdo, e a pessoa
9
+ * precisa achar um pedaço de página livre pra clicar.
10
+ *
11
+ * Não é caso de borda pra nós: a cabine embute o preview num iframe, então metade da tela
12
+ * é justamente a área onde clicar não fecha nada.
13
+ *
14
+ * O sinal utilizável é o `blur` da JANELA somado a "quem está com o foco agora é um
15
+ * iframe". Trocar de aba ou de aplicativo também dispara `blur`, mas ali o elemento ativo
16
+ * não é iframe — e fechar o menu de quem só foi ver outra janela seria pior que o defeito.
17
+ *
18
+ * A dispensa sai como um Escape sintético em vez de uma chamada direta: quem renderiza o
19
+ * conteúdo (Popover, Menu) não tem acesso ao estado de aberto — ele é de quem usa. Escape
20
+ * é o canal que a própria biblioteca já escuta pra dispensar, então funciona sem obrigar
21
+ * cada call site a passar um `onClose`.
22
+ */
23
+ export function useDismissOnIframeFocus(ref: React.RefObject<HTMLElement | null>): void {
24
+ React.useEffect(() => {
25
+ const onBlur = (): void => {
26
+ // `requestAnimationFrame`: no instante do `blur` o `activeElement` ainda pode ser o
27
+ // anterior; o navegador atualiza no tick seguinte.
28
+ requestAnimationFrame(() => {
29
+ if (document.activeElement?.tagName !== 'IFRAME') return
30
+ ref.current?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
31
+ })
32
+ }
33
+ window.addEventListener('blur', onBlur)
34
+ return () => window.removeEventListener('blur', onBlur)
35
+ }, [ref])
36
+ }