@softize/opus 25.0.0 → 25.2.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/CHANGELOG.md +93 -0
- package/bin/cli.mjs +5 -0
- package/bin/lib/assistant-schema.d.mts +55 -0
- package/bin/lib/assistant-schema.mjs +286 -0
- package/bin/lib/check.mjs +173 -0
- package/bin/lib/copy.mjs +2 -0
- package/bin/lib/gen-manifest.mjs +3 -0
- package/bin/lib/gen-runner.mjs +29 -1
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
- package/docs/adr/0053-read-action-declares-the-resource-assistant.md +443 -0
- package/docs/protocol.md +54 -0
- package/docs/releasing.md +8 -2
- package/docs/stacking-scale.md +53 -0
- package/package.json +10 -1
- package/registry/skills/create-opus-action/SKILL.md +8 -3
- package/registry/skills/create-opus-action/references/contract-and-binding.md +24 -1
- package/registry/skills/create-opus-action/references/evaluations.md +3 -0
- package/registry/skills/test-opus-action/SKILL.md +4 -1
- package/registry/skills/test-opus-action/references/evaluations.md +2 -0
- package/registry/skills/test-opus-action/references/harness.md +14 -0
- package/src/ai/index.ts +3 -0
- package/src/ai/resource-context.ts +113 -0
- package/src/core/assistant.ts +545 -0
- package/src/core/index.ts +23 -0
- package/src/core/runtime.ts +136 -0
- package/src/core/types.ts +34 -0
- package/src/mcp/index.ts +3 -0
- package/src/ui/components/primitives/dialog.tsx +285 -26
- package/src/ui/components/primitives/drawer.tsx +104 -13
- package/src/ui/components/primitives/floating-layer.tsx +79 -0
- package/src/ui/components/primitives/menu.tsx +17 -2
- package/src/ui/components/primitives/popover.tsx +6 -1
- package/src/ui/components/primitives/tooltip.tsx +6 -1
- package/src/ui/docs/content/actions.md +97 -1
- package/src/ui/docs/content/ai.md +4 -2
- package/src/ui/docs/content/cli.md +2 -1
- package/src/ui/docs/content/dialog.md +3 -0
- package/src/ui/docs/content/drawer.md +3 -0
- package/src/ui/docs/content/floating-layer.md +178 -0
- package/src/ui/docs/content/mcp.md +3 -1
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/lib/dismiss-on-iframe.ts +4 -1
- package/src/ui/lib/modal-layers.ts +547 -0
- package/src/ui/meta.ts +6 -0
- package/src/ui/react.tsx +4 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
# ADR 0052 — Uma camada flutuante convive com Dialog e Drawer modais
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-10-03.
|
|
5
|
+
- **Complementa:** ADR 0023, quanto a Dialog e Drawer como superfícies modais, e ADR 0017, quanto à
|
|
6
|
+
largura mínima suportada.
|
|
7
|
+
- **Relacionadas:** ADR 0007, porque o Toast já convive com o modal, mas só em parte; na plataforma
|
|
8
|
+
Grand Brasil, ADR 0087, que define o FAB e o painel do Copilot global.
|
|
9
|
+
|
|
10
|
+
## Contexto
|
|
11
|
+
|
|
12
|
+
### O pedido
|
|
13
|
+
|
|
14
|
+
Na plataforma Grand Brasil, o Copilot global aparece como um botão flutuante (FAB) no canto inferior
|
|
15
|
+
direito. Aberto, ele vira um painel de `28rem` de largura sobreposto no mesmo canto. Quem está
|
|
16
|
+
preenchendo um formulário num Dialog ou num Drawer quer perguntar ao Copilot sobre o que está vendo
|
|
17
|
+
sem perder o que já digitou.
|
|
18
|
+
|
|
19
|
+
A decisão de produto já foi tomada: o FAB e o painel ficam visíveis e utilizáveis por cima de
|
|
20
|
+
qualquer Dialog ou Drawer, e o modal continua aberto e intacto atrás. A outra saída, fechar o modal
|
|
21
|
+
quando a pessoa aciona o FAB, foi descartada porque jogaria fora o preenchimento.
|
|
22
|
+
|
|
23
|
+
Hoje isso não funciona, e a causa não está na Grand Brasil. Está no modo modal que o Opus usa.
|
|
24
|
+
|
|
25
|
+
### O que o modo modal faz hoje
|
|
26
|
+
|
|
27
|
+
O `Dialog` e o `Drawer` do Opus são o `Dialog` do Radix no modo modal, que é o padrão. O Drawer não
|
|
28
|
+
usa vaul: é o mesmo `Dialog` do Radix, importado de `radix-ui`, com outra moldura. Enquanto um deles
|
|
29
|
+
está aberto, o Radix faz cinco coisas:
|
|
30
|
+
|
|
31
|
+
1. **Prende o foco.** Um `FocusScope` com trava devolve ao último elemento focado dentro do modal
|
|
32
|
+
qualquer foco que vá para fora dele, e o Tab dá a volta dentro do modal.
|
|
33
|
+
2. **Bloqueia o ponteiro fora.** O `DismissableLayer` do modal grava `pointer-events: none` no
|
|
34
|
+
`body`; só o modal e os `DismissableLayer` abertos depois dele voltam a receber clique. O véu
|
|
35
|
+
(`z-50`, ocupando a tela inteira) fica por cima do resto.
|
|
36
|
+
3. **Trata clique fora e Esc como pedido de fechar.** Apertar o botão principal fora do modal fecha
|
|
37
|
+
quando o clique se completa; o botão direito não fecha. O Esc é ouvido no documento inteiro, e
|
|
38
|
+
quem responde é o `DismissableLayer` aberto mais recentemente, onde quer que o foco esteja. Foco
|
|
39
|
+
que sai do modal nunca o fecha: o próprio Radix cancela esse caso.
|
|
40
|
+
4. **Trava a rolagem fora.** O `react-remove-scroll` cancela roda do mouse e arrasto de toque que
|
|
41
|
+
não nasçam dentro do modal.
|
|
42
|
+
5. **Esconde o resto do leitor de tela.** O `hideOthers`, do pacote `aria-hidden`, percorre o `body`
|
|
43
|
+
e marca com `aria-hidden="true"` todo elemento fora do caminho até o modal. Um filho do `body`
|
|
44
|
+
que não contém o modal é marcado inteiro. Há uma exceção embutida: elementos com `aria-live`
|
|
45
|
+
presentes naquele momento também ficam visíveis, e o caminho até eles é preservado; nos
|
|
46
|
+
ancestrais desse caminho, só os irmãos que estão fora dele são marcados. O conteúdo do modal não
|
|
47
|
+
recebe `aria-modal`.
|
|
48
|
+
|
|
49
|
+
O `hideOthers` não é exclusivo do `Dialog`. O `Menu` do Opus é o `DropdownMenu` do Radix, que é modal
|
|
50
|
+
por padrão e também chama `hideOthers` ao abrir, assim como as confirmações do `DialogHost`.
|
|
51
|
+
|
|
52
|
+
Na Grand Brasil, o FAB e o painel ficam dentro do `#root`. Com um modal aberto, os dois ficam
|
|
53
|
+
escondidos do leitor de tela, sem ponteiro e abaixo do véu. Mesmo que a pessoa alcançasse a caixa
|
|
54
|
+
de mensagem, a trava devolveria o foco ao modal, e a caixa não receberia digitação.
|
|
55
|
+
|
|
56
|
+
### As exceções que existem
|
|
57
|
+
|
|
58
|
+
Antes de escolher um caminho, convém saber exatamente o que o Radix já permite. Cada item abaixo
|
|
59
|
+
foi conferido no código instalado (seção “Premissas verificadas”).
|
|
60
|
+
|
|
61
|
+
- **Exceções públicas, só para o fechamento.** `onEscapeKeyDown`, `onPointerDownOutside`,
|
|
62
|
+
`onFocusOutside` e `onInteractOutside` aceitam `preventDefault` e impedem que o modal feche. O
|
|
63
|
+
`DialogContent` e o `DrawerContent` do Opus repassam esses handlers e compõem `closeDisabled` sobre
|
|
64
|
+
o Esc e o clique fora. Eles não liberam foco, ponteiro, rolagem nem leitor de tela. E nenhum
|
|
65
|
+
pattern construído sobre esses componentes os repassa: `ActionFormDialog`, `ActionListDialog`,
|
|
66
|
+
`Presentation`, `ActionTrigger`, os diálogos de `ActionList`, as confirmações do `DialogHost` e o
|
|
67
|
+
`CommandDialog` não expõem esses handlers.
|
|
68
|
+
- **Peças internas do Radix.** `FocusScope` e `DismissableLayer` (com o `Branch`) saem em
|
|
69
|
+
`radix-ui/internal` e como pacotes próprios no npm, mas ficam fora da documentação pública do
|
|
70
|
+
Radix. O `Branch` faz com que clique e foco num elemento deixem de contar como “fora”; não muda
|
|
71
|
+
ponteiro, rolagem, leitor de tela nem a trava de foco. Um `FocusScope` montado depois do modal
|
|
72
|
+
pausa a trava do modal, mas a pilha de escopos do Radix só a retoma quando esse escopo desmonta.
|
|
73
|
+
Uma camada sempre montada, como o FAB, deixaria o modal sem trava e sem a volta do Tab durante
|
|
74
|
+
toda a vida dela: o Tab sairia do modal para a página escondida.
|
|
75
|
+
- **`modal={false}`.** Desliga de uma vez a trava, o bloqueio de ponteiro, o véu (o Radix deixa de
|
|
76
|
+
renderizar o `Overlay`), a trava de rolagem e o `aria-hidden`. A volta do Tab continua, e o
|
|
77
|
+
diálogo passa a fechar também quando o foco sai dele. O `mode="alert"` usa o `AlertDialog` do
|
|
78
|
+
Radix, que força o modo modal e ignora essa opção.
|
|
79
|
+
- **As bibliotecas por baixo aceitam exceções; o Radix não as repassa.** O `hideOthers` aceita uma
|
|
80
|
+
lista de elementos a manter visíveis, e o `react-remove-scroll` aceita áreas extras (`shards`),
|
|
81
|
+
mas o `Dialog` do Radix passa só o próprio conteúdo nos dois casos.
|
|
82
|
+
|
|
83
|
+
### O precedente do Toaster
|
|
84
|
+
|
|
85
|
+
O Toaster (Sonner) já aparece por cima de um modal: tem `z-index` altíssimo, e sua região tem
|
|
86
|
+
`aria-live`, então o `hideOthers` não o esconde e os avisos continuam sendo anunciados. Mas ele não
|
|
87
|
+
declara `pointer-events: auto` e herda o `none` do `body`. Pela leitura do código, as ações de um
|
|
88
|
+
aviso (ADR 0007) não respondem a clique enquanto um modal está aberto; isso ainda precisa ser
|
|
89
|
+
confirmado no navegador. Por teclado, a trava não deixa o foco chegar a ele. O Toaster convive com
|
|
90
|
+
o modal para ser lido, não para ser usado. É um precedente parcial, e o mesmo problema desta ADR.
|
|
91
|
+
|
|
92
|
+
### Forças em tensão
|
|
93
|
+
|
|
94
|
+
- **A acessibilidade do modal.** Quem usa teclado ou leitor de tela depende da trava de foco, do Esc
|
|
95
|
+
e do resto da página escondido para saber onde está e não se perder atrás do véu. Essa garantia
|
|
96
|
+
não pode piorar para quem nunca usa a camada.
|
|
97
|
+
- **Uma camada de assistência utilizável.** O painel precisa receber clique, foco, digitação, rolagem
|
|
98
|
+
e Esc sem fechar o modal nem ser puxado de volta por ele, e precisa continuar audível para o
|
|
99
|
+
leitor de tela.
|
|
100
|
+
- **A regra precisa valer em todo lugar.** Se a convivência depender de cada consumidor repassar
|
|
101
|
+
handlers, os patterns, que não os repassam, ficam de fora, e cada tela nova pode esquecer.
|
|
102
|
+
- **A dependência do Radix.** Quanto mais a solução depender de detalhes não documentados do Radix e
|
|
103
|
+
das bibliotecas por baixo, mais uma atualização pode quebrá-la sem aviso.
|
|
104
|
+
- **Reversibilidade.** A API pública deve sobreviver a uma troca de implementação, porque a parte
|
|
105
|
+
mais arriscada, o foco, ainda precisa ser provada no navegador.
|
|
106
|
+
|
|
107
|
+
## Decisão
|
|
108
|
+
|
|
109
|
+
### 1. O que é a camada flutuante
|
|
110
|
+
|
|
111
|
+
Uma **camada flutuante** é uma região da aplicação que fica acima do conteúdo e dos modais de
|
|
112
|
+
trabalho (Dialog e Drawer) e continua utilizável enquanto eles estão abertos. O FAB e o painel do
|
|
113
|
+
Copilot são o primeiro caso.
|
|
114
|
+
|
|
115
|
+
Ela não é um popover, um menu nem um tooltip, que são transitórios e presos a um gatilho. Também não
|
|
116
|
+
é o `Dock`, que flutua sobre uma única superfície, fica dentro dela e some com ela. E não é um modal:
|
|
117
|
+
não bloqueia nada. O nome repete o termo do pedido da Grand Brasil (TODO, seção 18). O comentário de
|
|
118
|
+
`src/ui/lib/dismiss-on-iframe.ts` usa “camada flutuante” no sentido solto de popover e menu, e deve
|
|
119
|
+
ser reescrito junto com a implementação para não confundir os dois conceitos.
|
|
120
|
+
|
|
121
|
+
O princípio durável é este: **o Opus é dono da convivência.** Um modal do Opus trata a camada como
|
|
122
|
+
parte de dentro quanto a foco, clique, Esc, rolagem e leitor de tela. Quem usa Dialog, Drawer ou
|
|
123
|
+
qualquer pattern construído sobre eles não repassa nada. O consumidor só declara a camada.
|
|
124
|
+
|
|
125
|
+
### 2. A API pública
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
import { FloatingLayer } from '@softize/opus/ui/react'
|
|
129
|
+
|
|
130
|
+
<FloatingLayer onEscapeKeyDown={handleEscape}>
|
|
131
|
+
{copilotOpen ? <CopilotPanel /> : <CopilotFab />}
|
|
132
|
+
</FloatingLayer>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
interface FloatingLayerProps {
|
|
137
|
+
children: React.ReactNode
|
|
138
|
+
/** Classes do hospedeiro. A posição de cada filho continua sendo do consumidor. */
|
|
139
|
+
className?: string
|
|
140
|
+
/**
|
|
141
|
+
* Esc enquanto a pessoa está na camada (seção 5), com ou sem modal aberto. É chamado uma vez por
|
|
142
|
+
* tecla. Nenhum modal fecha nesse caso, e o Opus não fecha a camada sozinho, porque o estado de
|
|
143
|
+
* aberto pertence ao consumidor.
|
|
144
|
+
*/
|
|
145
|
+
onEscapeKeyDown?: (event: KeyboardEvent) => void
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- `FloatingLayer` porta seus filhos para um hospedeiro estável no `body`:
|
|
150
|
+
`div[data-slot="floating-layer"]`, fixo, ocupando a tela inteira, com `pointer-events: none` no
|
|
151
|
+
hospedeiro e `auto` nos filhos diretos. O consumidor posiciona cada filho com `absolute` contra a
|
|
152
|
+
viewport.
|
|
153
|
+
- O hospedeiro publica qual modal de trabalho está no topo da pilha: `data-modal` vale `none`,
|
|
154
|
+
`dialog` ou `drawer`, e `data-modal-side` traz o lado do Drawer (`left`, `right`, `top` ou
|
|
155
|
+
`bottom`). É o sinal público para o consumidor adaptar a posição dos filhos (seção 7). Com um
|
|
156
|
+
modal de resposta obrigatória ou um modal interno à camada no topo, o sinal continua descrevendo
|
|
157
|
+
o modal de trabalho mais alto da pilha, ou `none` se não houver nenhum; a camada está coberta
|
|
158
|
+
nesse intervalo, e mudar sua posição só para ele faria o painel pular duas vezes.
|
|
159
|
+
- O hospedeiro não tem papel ARIA. A semântica vem dos filhos: na Grand Brasil, `aside` com
|
|
160
|
+
`aria-label="Copilot"` e o botão “Abrir o Copilot”.
|
|
161
|
+
- Podem existir várias camadas ao mesmo tempo. A ordem de montagem desempata o empilhamento entre
|
|
162
|
+
elas.
|
|
163
|
+
- O componente mora em `src/ui/components/primitives/floating-layer.tsx`. O registro das camadas e a
|
|
164
|
+
pilha dos modais do Opus moram em `src/ui/lib/modal-layers.ts`, consumido por `dialog.tsx`,
|
|
165
|
+
`drawer.tsx` e pelos primitives que usam portal. A exportação sai por `src/ui/react.tsx`.
|
|
166
|
+
- Não há provider novo. O registro tem um valor padrão global, como o próprio Radix faz com o
|
|
167
|
+
contexto do `DismissableLayer`, para funcionar com o `DialogHost`, fora do `OpusProvider` e nos
|
|
168
|
+
exemplos da documentação.
|
|
169
|
+
|
|
170
|
+
### 3. Portais dentro da camada
|
|
171
|
+
|
|
172
|
+
Popover, Menu, Tooltip, o que é construído sobre eles (como Select) e Dialog ou Drawer abertos de
|
|
173
|
+
dentro da camada passam a portar para o hospedeiro da camada em vez do `body`. Os primitives que usam
|
|
174
|
+
portal leem o contêiner de um contexto que a `FloatingLayer` fornece; fora dela, nada muda.
|
|
175
|
+
|
|
176
|
+
Sem isso, o menu de “nova conversa” ou a explicação de uma resposta do Copilot abririam no `body`,
|
|
177
|
+
na faixa dos modais, e ficariam por baixo do próprio painel que os abriu. Portados para o
|
|
178
|
+
hospedeiro, eles ficam acima do painel e contam como parte da camada para foco, rolagem e leitor de
|
|
179
|
+
tela.
|
|
180
|
+
|
|
181
|
+
Um modal aberto de dentro da camada é modal para tudo, inclusive para a própria camada. Ele entra na
|
|
182
|
+
pilha marcado como **não isento**: enquanto estiver no topo, nenhuma exceção desta ADR fica ativa
|
|
183
|
+
(seção 6), e a subárvore dele nunca conta como “dentro da camada”.
|
|
184
|
+
|
|
185
|
+
### 4. Empilhamento
|
|
186
|
+
|
|
187
|
+
Do fundo para a frente, a ordem passa a ser:
|
|
188
|
+
|
|
189
|
+
1. conteúdo da página;
|
|
190
|
+
2. faixa modal: Dialog, Drawer, seus véus e os popovers abertos fora da camada, todos em `z-50`,
|
|
191
|
+
desempatados pela ordem no DOM, como hoje;
|
|
192
|
+
3. faixa da camada flutuante;
|
|
193
|
+
4. faixa de resposta obrigatória: `mode="alert"` e o `DialogHost` (decisão 4 da seção 9);
|
|
194
|
+
5. Toaster, com o `z-index` do Sonner.
|
|
195
|
+
|
|
196
|
+
Os valores de `z-index` de cada faixa são detalhe de implementação. Eles ficam documentados numa
|
|
197
|
+
escala própria em `packages/opus/docs`, ao lado de `elevation-scale.md`.
|
|
198
|
+
|
|
199
|
+
### 5. O comportamento enquanto um modal convive com a camada
|
|
200
|
+
|
|
201
|
+
| Aspecto | Hoje | Com a camada |
|
|
202
|
+
| --- | --- | --- |
|
|
203
|
+
| Ponteiro | Só o modal recebe clique. | A camada também recebe. A página atrás continua coberta pelo véu. |
|
|
204
|
+
| Clique na camada | Fecha o modal. | Não fecha nenhum modal, mesmo que o elemento clicado suma no próprio clique. |
|
|
205
|
+
| Clique no modal | — | O Opus não impõe nada à camada; fechá-la é decisão do consumidor. |
|
|
206
|
+
| Clique no véu | Fecha o modal. | Fecha o modal, como hoje, respeitando `closeDisabled`. |
|
|
207
|
+
| Esc | Fecha o `DismissableLayer` mais recente. | Age onde a pessoa está (abaixo). |
|
|
208
|
+
| Foco | Preso no modal. | Modal e camada formam a região permitida (abaixo). |
|
|
209
|
+
| Rolagem | Só dentro do modal. | Dentro do modal e dentro da camada. A página continua travada. |
|
|
210
|
+
| Leitor de tela | Só o modal fica visível. | O modal e a camada ficam visíveis; o resto continua escondido. |
|
|
211
|
+
|
|
212
|
+
Sem nenhum modal aberto, a camada é DOM comum: nenhuma das exceções acima fica ativa, e só o
|
|
213
|
+
`onEscapeKeyDown` da camada continua valendo.
|
|
214
|
+
|
|
215
|
+
**Onde a pessoa está.** O foco decide primeiro: com o foco num elemento do modal, a pessoa está no
|
|
216
|
+
modal; com o foco num elemento da camada, está na camada; com o foco em qualquer outro elemento da
|
|
217
|
+
página, não está na camada. O ponteiro decide só quando o foco está no `body`: o último aperto
|
|
218
|
+
dentro do hospedeiro põe a pessoa na camada, e o último aperto fora dele a tira. Assim, clicar
|
|
219
|
+
numa área sem foco do painel, como o texto de uma resposta, conta como estar na camada. E clicar num botão do painel que não recebe foco (barras de ferramentas e listas do `cmdk`
|
|
220
|
+
impedem o foco no aperto) deixa o foco no campo do modal: se a pessoa volta a digitar ali e aperta
|
|
221
|
+
Esc, o modal fecha, como esperado.
|
|
222
|
+
|
|
223
|
+
**Esc age onde a pessoa está.** Na camada, nenhum modal fecha e o Opus chama o `onEscapeKeyDown` da
|
|
224
|
+
camada uma vez. No modal, o Esc fecha o modal, como hoje. A alternativa de fechar primeiro o que
|
|
225
|
+
está visualmente por cima foi descartada: quem preenche o modal e aperta Esc quer sair do modal, não
|
|
226
|
+
do painel que nem está usando.
|
|
227
|
+
|
|
228
|
+
Antes dessa regra vale a do Radix: um `DismissableLayer` aberto depois do modal consome o Esc, onde
|
|
229
|
+
quer que a pessoa esteja. Isso dá três exceções conhecidas e aceitas:
|
|
230
|
+
|
|
231
|
+
- um popover ou menu aberto dentro da camada fecha primeiro, e só o Esc seguinte chega à camada;
|
|
232
|
+
- um popover aberto dentro do modal continua no topo e consome o Esc mesmo com a pessoa na camada;
|
|
233
|
+
- o tooltip do FAB, aberto por passagem do mouse, consome o Esc mesmo com a pessoa no modal.
|
|
234
|
+
|
|
235
|
+
Para impedir o Radix de fechar o modal, o Opus precisa chamar `preventDefault` no evento de teclado.
|
|
236
|
+
Por isso o evento nativo chega aos elementos da camada já com `defaultPrevented`. A camada trata o
|
|
237
|
+
Esc só pelo `onEscapeKeyDown`; um `onKeyDown` próprio para Esc no painel trataria a tecla duas vezes.
|
|
238
|
+
|
|
239
|
+
**Foco.** O foco pode ir para a camada por clique ou por chamada de código (o Copilot focando a
|
|
240
|
+
caixa de mensagem ao abrir) sem ser puxado de volta. Foco que vai para qualquer outro lugar volta ao
|
|
241
|
+
último elemento focado no modal, como hoje. `onBlur` e `onFocus` dos campos do modal e da camada
|
|
242
|
+
continuam disparando normalmente. Dentro da camada, o Tab segue a ordem dela. Como o teclado chega à
|
|
243
|
+
camada e para onde o foco volta quando a camada some estão nas decisões 2 e 3 da seção 9.
|
|
244
|
+
|
|
245
|
+
**Leitor de tela.** O Opus não adiciona `aria-modal` ao conteúdo dos modais, que hoje não o tem.
|
|
246
|
+
Leitores que respeitam `aria-modal` passariam a ignorar a camada. A camada continua visível também
|
|
247
|
+
quando um menu modal abre e fecha sobre o modal, e quando o painel contém uma região `aria-live`
|
|
248
|
+
própria, como a do `Ask` ou do `StateSurface`.
|
|
249
|
+
|
|
250
|
+
### 6. Quando as exceções ficam ativas
|
|
251
|
+
|
|
252
|
+
Cada modal do Opus se registra numa pilha ao abrir, com uma de três marcas:
|
|
253
|
+
|
|
254
|
+
- **de trabalho:** Dialog e Drawer portados fora de qualquer hospedeiro de camada;
|
|
255
|
+
- **de resposta obrigatória:** `mode="alert"` e as confirmações do `DialogHost`;
|
|
256
|
+
- **interno à camada:** qualquer modal portado para um hospedeiro (seção 3).
|
|
257
|
+
|
|
258
|
+
As exceções desta ADR (clique, Esc, foco, rolagem e leitor de tela) ficam ativas **somente quando o
|
|
259
|
+
topo da pilha é um modal de trabalho**. Com um modal de resposta obrigatória ou um modal interno à
|
|
260
|
+
camada no topo, todas ficam suspensas: a trava, o véu, o Esc e o `aria-hidden` desse modal agem
|
|
261
|
+
sobre a camada como sobre o resto da página. Isso evita dois defeitos:
|
|
262
|
+
|
|
263
|
+
- um modal aberto de dentro da camada teria a trava cega, o véu sem efeito e o Esc desviado para a
|
|
264
|
+
camada, porque estaria dentro do hospedeiro isento;
|
|
265
|
+
- com um alerta no topo, um foco programático do Copilot escaparia da trava do alerta.
|
|
266
|
+
|
|
267
|
+
Mesmo com as exceções ativas, a subárvore de um modal interno à camada nunca conta como dentro da
|
|
268
|
+
camada.
|
|
269
|
+
|
|
270
|
+
Com modais de trabalho empilhados (Dialog sobre Drawer, Drawer sobre Drawer), a isenção vale para
|
|
271
|
+
todos; a trava de foco e o Esc continuam agindo só no modal do topo, como hoje. Fechar o modal do
|
|
272
|
+
topo devolve o controle ao de baixo, e a camada continua isenta.
|
|
273
|
+
|
|
274
|
+
### 7. Telas pequenas e sobreposição
|
|
275
|
+
|
|
276
|
+
A ADR 0017 suporta a interface a partir de `64rem`. O painel de `28rem`, ancorado à direita e com até
|
|
277
|
+
`44rem` de altura, cobre áreas que a pessoa precisa ver:
|
|
278
|
+
|
|
279
|
+
- **Drawer à direita, o caso mais comum.** O `DrawerContent` padrão tem `side="right"` e até `24rem`
|
|
280
|
+
de largura; na Grand Brasil há Drawers de `24rem`, `28rem` e `56rem`. O painel cobre os dois
|
|
281
|
+
primeiros na largura inteira e quase toda a altura, e metade do de `56rem`. Com o painel aberto,
|
|
282
|
+
perguntar sobre o que está no Drawer fica, na prática, impossível.
|
|
283
|
+
- **Drawer com assistência contextual aberta.** A `Presentation` em Drawer e o Drawer de Clientes da
|
|
284
|
+
Grand Brasil passam a `min(80rem, 100% - 2rem)` de largura quando o painel de assistência do
|
|
285
|
+
recurso está aberto. Nessa largura não sobra lado livre para o painel do Copilot, e nenhuma das
|
|
286
|
+
opções da decisão 5 da seção 9 evita a sobreposição; a pessoa já tem, ali, uma conversa sobre o
|
|
287
|
+
recurso.
|
|
288
|
+
- **Drawer à direita com o painel fechado.** O FAB fica sobre o canto do rodapé, onde ficam as ações
|
|
289
|
+
principais, alinhadas à direita.
|
|
290
|
+
- **Dialog centralizado.** A `64rem`, o painel cobre cerca de `12rem` de um Dialog de `32rem`. Numa
|
|
291
|
+
tela de `90rem`, os dois cabem lado a lado.
|
|
292
|
+
|
|
293
|
+
O Opus não desloca nem redimensiona modais por causa da camada nesta versão. Ele publica
|
|
294
|
+
`data-modal` e `data-modal-side` no hospedeiro para que o consumidor reposicione o que precisar. A
|
|
295
|
+
resposta da Grand Brasil está na decisão 5 da seção 9.
|
|
296
|
+
|
|
297
|
+
### 8. A materialização escolhida
|
|
298
|
+
|
|
299
|
+
Esta seção pode ser trocada sem mudar a API pública. A decisão é **manter o modo modal do Radix e
|
|
300
|
+
abrir exceções nas bordas**, com a reserva descrita no fim desta seção se o protótipo reprovar,
|
|
301
|
+
todas ligadas e desligadas pela regra da seção 6:
|
|
302
|
+
|
|
303
|
+
- **Pertença decidida no aperto do ponteiro.** O Dialog fecha só quando o clique se completa: no
|
|
304
|
+
aperto, o Radix guarda o evento e registra um ouvinte de `click` no documento; o handler
|
|
305
|
+
`onPointerDownOutside` recebe o evento do aperto. Até lá, o elemento apertado pode ter sumido. O
|
|
306
|
+
FAB some no próprio clique, assim como o botão de fechar o painel e os itens de um popover, e o
|
|
307
|
+
React 19 aplica essas mudanças numa microtarefa que roda antes do ouvinte do Radix. Perguntar ao
|
|
308
|
+
DOM, no fim do clique, se o alvo está dentro do hospedeiro daria “não”, e o modal fecharia. Por
|
|
309
|
+
isso o hospedeiro registra, em fase de captura, cada aperto que começa nele (num `WeakSet` de
|
|
310
|
+
eventos), e o `DialogContent` e o `DrawerContent` compõem `onPointerDownOutside` e
|
|
311
|
+
`onInteractOutside` consultando esse registro pelo `detail.originalEvent`. A isenção da camada
|
|
312
|
+
vem primeiro na composição e interrompe a cadeia, como o `DismissableLayer.Branch` do Radix: um
|
|
313
|
+
evento nascido na camada é de dentro do modal e não chega ao handler do consumidor. Para os
|
|
314
|
+
demais eventos, a ordem é handler do consumidor e, por fim, `closeDisabled`. O mesmo vale para o
|
|
315
|
+
`onEscapeKeyDown`, para que um `preventDefault` do consumidor não engula o Esc que pertence à
|
|
316
|
+
camada. Como a composição mora nesses dois componentes, os patterns ganham a isenção sem mudança.
|
|
317
|
+
- **Esc.** O `onEscapeKeyDown` composto consulta onde a pessoa está (seção 5). Na camada, chama
|
|
318
|
+
`preventDefault` e o `onEscapeKeyDown` da camada. Sem modal, a própria `FloatingLayer` ouve o Esc
|
|
319
|
+
no documento, em borbulha, e usa o mesmo rastreador de onde a pessoa está. Ouvir só no
|
|
320
|
+
hospedeiro não bastaria: depois de um clique no texto de uma resposta, o foco fica no `body`, e o
|
|
321
|
+
evento de teclado nunca passa pelo hospedeiro. Esse ouvinte ignora eventos já marcados com
|
|
322
|
+
`defaultPrevented`, que um `DismissableLayer` de dentro da camada ou a composição do modal já
|
|
323
|
+
trataram. Assim, o `onEscapeKeyDown` é chamado uma única vez por tecla, com ou sem modal.
|
|
324
|
+
- **Ponteiro e empilhamento:** o hospedeiro declara seus próprios `pointer-events` e fica na faixa
|
|
325
|
+
da camada.
|
|
326
|
+
- **Foco e rolagem, num único ouvinte no `body`.** A trava do Radix ouve `focusin` e `focusout`, e o
|
|
327
|
+
`react-remove-scroll` ouve `wheel` e `touchmove`, todos no documento, em fase de borbulha. O
|
|
328
|
+
registro mantém um ouvinte nativo no `body`, também em borbulha, que impede que chegue ao
|
|
329
|
+
documento: o `focusin` cujo alvo está no hospedeiro; o `focusout` cujo alvo ou destino está no
|
|
330
|
+
hospedeiro; e o `wheel` e o `touchmove` cujo alvo está no hospedeiro. A trava não vê a passagem e
|
|
331
|
+
não puxa o foco de volta; todo o resto da trava continua sendo do Radix. O ouvinte fica no `body`,
|
|
332
|
+
e não no conteúdo do modal, porque o React escuta eventos no contêiner de cada portal, que para os
|
|
333
|
+
modais é o próprio `body`. Parar a propagação antes dele engoliria o `onBlur` e o `onFocus` dos
|
|
334
|
+
campos do modal. No mesmo nó, o ouvinte do React continua rodando.
|
|
335
|
+
- **Rolagem encadeada no toque.** Com o `touchmove` liberado, um arrasto que chegue ao fim da área
|
|
336
|
+
rolável do painel pode passar a rolar a página. As áreas roláveis da camada, como o histórico da
|
|
337
|
+
conversa dentro do painel, usam `overscroll-behavior: contain`; a propriedade só age no contêiner
|
|
338
|
+
que rola, então aplicá-la é responsabilidade do consumidor, e a documentação da camada diz isso. A
|
|
339
|
+
ADR 0017 trata a interface como desktop, mas a regra é barata.
|
|
340
|
+
- **Leitor de tela.** O hospedeiro carrega `aria-live="off"` o tempo todo, exceto enquanto um modal
|
|
341
|
+
de resposta obrigatória ou interno à camada está no topo. É o valor padrão do atributo: não
|
|
342
|
+
transforma a camada numa região que anuncia mudanças, e regiões `aria-live` de dentro dela mantêm
|
|
343
|
+
o próprio comportamento. Mas o `hideOthers` preserva todo elemento com `aria-live`, a cada
|
|
344
|
+
chamada, e não desce para dentro dele. A camada fica visível diante do `Dialog`, do `Drawer` e de
|
|
345
|
+
um menu modal aberto sobre eles, e as regiões `aria-live` do painel não fazem o `hideOthers`
|
|
346
|
+
esconder partes dele. Quando um modal que suspende as exceções entra no topo, o registro retira o
|
|
347
|
+
atributo num efeito de layout, que roda antes do `hideOthers` desse modal. Ele devolve o atributo,
|
|
348
|
+
também num efeito de layout, quando esse modal **sai do topo**, isto é, quando fecha, e não quando
|
|
349
|
+
desmonta. A diferença importa: o `Presence` do Radix mantém o modal montado durante a animação de
|
|
350
|
+
saída. Se uma confirmação do `DialogHost` abre um Dialog na resposta, o `hideOthers` do Dialog
|
|
351
|
+
roda com a confirmação ainda montada; com o atributo devolvido só na desmontagem, o hospedeiro
|
|
352
|
+
seria contado de novo e ficaria escondido durante todo o Dialog. Limite conhecido: enquanto esse
|
|
353
|
+
modal está aberto, as regiões `aria-live` de dentro do painel continuam audíveis, como os avisos do
|
|
354
|
+
Toaster.
|
|
355
|
+
|
|
356
|
+
Efeitos colaterais aceitos:
|
|
357
|
+
|
|
358
|
+
- Enquanto as exceções estão ativas, ouvintes de foco no documento não veem o foco entrar na camada
|
|
359
|
+
nem andar dentro dela. Um popover aberto dentro do modal não fecha quando o foco vai para a
|
|
360
|
+
camada. Um popover aberto dentro da camada não fecha quando o foco passa para outro ponto da
|
|
361
|
+
camada; sem modal aberto, ele fecha. O comportamento desses popovers muda, portanto, conforme haja
|
|
362
|
+
ou não um modal de trabalho aberto.
|
|
363
|
+
- Modais que não entram na pilha do Opus, como um `Dialog` do Radix usado diretamente ou o de uma
|
|
364
|
+
biblioteca de terceiros, não escondem mais a camada do leitor de tela, porque o `aria-live` do
|
|
365
|
+
hospedeiro a protege de qualquer `hideOthers`. A trava e o véu deles continuam valendo.
|
|
366
|
+
|
|
367
|
+
Essas exceções dependem de detalhes de implementação do Radix, do React, do `react-remove-scroll` e
|
|
368
|
+
do `aria-hidden`: a fase dos ouvintes, o contêiner onde o React escuta, o momento do fechamento por
|
|
369
|
+
clique e a preservação de `[aria-live]`. Testes de premissa (seção “Verificação”) falham se uma
|
|
370
|
+
atualização mudar algum desses detalhes. Se a preservação de `[aria-live]` deixar de valer, a reserva
|
|
371
|
+
para o leitor de tela é um `MutationObserver` que reaplica a visibilidade do hospedeiro enquanto o
|
|
372
|
+
topo da pilha for um modal de trabalho.
|
|
373
|
+
|
|
374
|
+
**Alternativa de reserva para o conjunto:** usar o `Dialog` do Radix no modo não modal e fazer o Opus
|
|
375
|
+
refazer a trava, o véu, a trava de rolagem, o `aria-hidden` e a pilha de modais, com `aria-hidden` e
|
|
376
|
+
`react-remove-scroll` como dependências diretas. A API pública é a mesma.
|
|
377
|
+
|
|
378
|
+
A escolha entre as duas se fecha num protótipo em navegador real, com os critérios de teclado da
|
|
379
|
+
seção “Verificação”, antes de o código entrar no pacote (decisão 1 da seção 9).
|
|
380
|
+
|
|
381
|
+
**Nota de implementação (2026-10-03).** O protótipo da blindagem passou nos critérios de teclado em
|
|
382
|
+
Chromium (`tests/browser/floating-layer-focus.test.tsx`), e a reserva não foi usada. A implementação
|
|
383
|
+
fechou cinco pontos que esta seção deixava em aberto, sem mudar a API pública:
|
|
384
|
+
|
|
385
|
+
- **Ciclo do Tab (decisão 2).** O `DialogContent` e o `DrawerContent` compõem um `onKeyDown` que,
|
|
386
|
+
com as exceções ativas e o foco no último elemento do modal do topo, leva o Tab ao primeiro
|
|
387
|
+
elemento da camada; o Shift+Tab no primeiro elemento faz o caminho inverso. O hospedeiro ouve o
|
|
388
|
+
`keydown` e faz o mesmo nas bordas da camada. Com várias camadas, o ciclo segue a ordem de
|
|
389
|
+
montagem. O laço do Radix continua valendo quando a camada não tem onde parar.
|
|
390
|
+
- **Volta do foco (decisão 3).** Um `MutationObserver` em cada hospedeiro percebe quando o último
|
|
391
|
+
elemento focado da camada sai do DOM com o foco no `body`. Uma tarefa depois, se o foco continuar
|
|
392
|
+
no `body`, o registro o devolve ao último elemento focado fora das camadas, quando ele ainda está
|
|
393
|
+
no modal do topo, ou ao primeiro elemento focável do modal. A espera de uma tarefa deixa o
|
|
394
|
+
consumidor levar o foco a outro elemento da camada, como a caixa de mensagem que substitui o FAB.
|
|
395
|
+
- **Estado de aberto.** `Dialog` e `Drawer` passam a controlar o estado de aberto e o entregam ao
|
|
396
|
+
Radix sempre como controlado, porque a pilha precisa saber quando o modal fecha, e não quando o
|
|
397
|
+
`Presence` o desmonta. Um modal com `modal={false}` não entra na pilha.
|
|
398
|
+
- **Faixa da resposta obrigatória.** Popover, Menu e Tooltip abertos de dentro de um alerta herdam a
|
|
399
|
+
faixa dele, pelo mesmo contexto que leva os portais ao hospedeiro; sem isso, ficariam abaixo do
|
|
400
|
+
véu do próprio alerta. A escala está em `docs/stacking-scale.md`.
|
|
401
|
+
- **Alerta aberto de dentro da camada.** Vai para o hospedeiro, como qualquer modal aberto de lá, e
|
|
402
|
+
entra na pilha como resposta obrigatória. As duas marcas suspendem as exceções; a subárvore dele
|
|
403
|
+
é marcada como modal e nunca conta como camada.
|
|
404
|
+
|
|
405
|
+
**Nota de revisão (2026-10-03).** Duas revisões independentes da implementação apontaram bordas que
|
|
406
|
+
a seção 6 e as decisões 3 e 4 não cobriam por completo. A correção refina a regra sem mudar a API:
|
|
407
|
+
|
|
408
|
+
- **Suspensão em qualquer posição da pilha.** A seção 6 diz que as exceções valem só com um modal de
|
|
409
|
+
trabalho no topo. Um Dialog aberto de dentro de um alerta fica no topo como modal de trabalho,
|
|
410
|
+
mas o alerta continua aberto e cobrindo a camada. As exceções passam a exigir também que nenhum
|
|
411
|
+
modal de resposta obrigatória ou interno à camada esteja aberto, em qualquer posição da pilha; o
|
|
412
|
+
`aria-live` do hospedeiro segue a mesma regra.
|
|
413
|
+
- **Modal que fecha com a pessoa na camada.** O Radix leva o foco ao gatilho do modal que fecha.
|
|
414
|
+
Com o foco numa camada, o Dialog e o Drawer cancelam essa volta, e o foco fica onde a pessoa está
|
|
415
|
+
digitando. É comportamento novo, simétrico à decisão 3.
|
|
416
|
+
- **Camada que sai com o foco dentro.** Desmontar a `FloatingLayer` com o foco nela e um modal de
|
|
417
|
+
trabalho aberto devolve o foco ao modal, pela mesma regra da decisão 3.
|
|
418
|
+
- **Aperto num painel que some.** O último aperto deixa de pôr a pessoa na camada quando o filho
|
|
419
|
+
direto do hospedeiro que o recebeu sai da camada, como um painel fechado por código e trocado
|
|
420
|
+
pelo FAB. A âncora é esse filho, e não o elemento apertado: o `Markdown` do `Chat` troca todos os
|
|
421
|
+
nós da resposta a cada pedaço do streaming, e a pessoa que clicou no texto continua no painel.
|
|
422
|
+
- **Portais de dentro de um modal interno à camada.** Popover, Menu e Tooltip abertos de dentro dele
|
|
423
|
+
vão para o hospedeiro como irmãos do modal e recebem a mesma marca, para nunca contar como camada.
|
|
424
|
+
- **Tab nas bordas da camada.** O hospedeiro passou a decidir o Tab ouvindo o documento, em
|
|
425
|
+
borbulha, depois dos `onKeyDown` do React de dentro da camada, e respeita um `preventDefault`
|
|
426
|
+
deles.
|
|
427
|
+
|
|
428
|
+
### 9. As questões que ficaram para decisão
|
|
429
|
+
|
|
430
|
+
A proposta deixou sete questões para decidir antes da implementação. Em 2026-10-03, todas foram
|
|
431
|
+
decididas como a proposta recomendava; cada item registra a questão, a decisão e o motivo.
|
|
432
|
+
|
|
433
|
+
1. **Como materializar o foco: blindar a trava do Radix ou refazê-la.** A alternativa de empilhar
|
|
434
|
+
escopos com um `FocusScope` aninhado foi descartada (seção “As exceções que existem”).
|
|
435
|
+
*Decisão:* blindar (seção 8), porque preserva o comportamento de foco do Radix para quem não
|
|
436
|
+
usa a camada e muda pouco código. Refazer fica como reserva se o protótipo mostrar que a
|
|
437
|
+
blindagem é frágil.
|
|
438
|
+
2. **Como o teclado chega à camada com um modal aberto.** Hoje o Tab dá a volta dentro do modal, e
|
|
439
|
+
nenhuma tecla leva à camada. *Decisão:* incluir a camada no ciclo do Tab: depois do último
|
|
440
|
+
elemento do modal vem a camada, e depois da camada, o primeiro elemento do modal (Shift+Tab
|
|
441
|
+
percorre o caminho inverso). Fica descobrível sem decorar atalho. Com o painel fechado, custa uma
|
|
442
|
+
parada a mais (o FAB). Um atalho como F6 foi considerado, mas os navegadores usam F6 para a barra
|
|
443
|
+
de endereço, e ainda não foi testado se a página consegue interceptá-lo.
|
|
444
|
+
3. **Para onde volta o foco quando a camada some com um modal aberto**, por exemplo quando a pessoa
|
|
445
|
+
fecha o painel. *Decisão:* para o elemento do modal que tinha o foco antes de a pessoa
|
|
446
|
+
entrar na camada; se ele não existir mais, para o primeiro elemento focável do modal. Assim a
|
|
447
|
+
pessoa continua o preenchimento de onde parou. Voltar para o FAB segue o padrão de “devolver ao
|
|
448
|
+
gatilho”, mas obriga mais um Tab para retomar o formulário.
|
|
449
|
+
4. **A camada convive com diálogos de resposta obrigatória?** `mode="alert"` e as confirmações do
|
|
450
|
+
`DialogHost`, como “Excluir?”, exigem uma resposta antes de qualquer outra coisa.
|
|
451
|
+
*Decisão:* não. Esses diálogos ficam acima da camada e a bloqueiam, com o véu cobrindo o
|
|
452
|
+
FAB e o painel (seção 6). O estado fica visualmente honesto (nada parece clicável sem ser) e o
|
|
453
|
+
`alert` continua no `AlertDialog` do Radix, sem migração. O custo é não poder perguntar ao
|
|
454
|
+
Copilot no meio de uma confirmação; a pessoa cancela, pergunta e repete.
|
|
455
|
+
5. **O que fazer com a sobreposição** (seção 7). As opções reais são:
|
|
456
|
+
- (a) a Grand Brasil leva o painel e o FAB para o lado oposto ao do Drawer, lendo
|
|
457
|
+
`data-modal-side`;
|
|
458
|
+
- (b) a Grand Brasil encolhe o painel enquanto houver modal aberto;
|
|
459
|
+
- (c) o Opus reserva a área da camada e desloca ou estreita o modal para desviar dela;
|
|
460
|
+
- (d) aceitar a sobreposição, e a pessoa fecha o painel quando precisar ver.
|
|
461
|
+
|
|
462
|
+
*Decisão:* (a) para Drawers, porque resolve o caso mais comum sem tocar no Opus além do
|
|
463
|
+
sinal publicado. Numa tela de `64rem` com um Drawer de `56rem`, ainda sobra sobreposição; aí o
|
|
464
|
+
painel encolhe, combinando com (b). Para Dialogs centralizados, (d), porque a sobreposição só
|
|
465
|
+
aparece em telas estreitas. A opção (c) fica para quando houver um segundo consumidor.
|
|
466
|
+
6. **Teste de foco em navegador real dentro do pacote.** Os testes de UI do Opus rodam em
|
|
467
|
+
`happy-dom` 15.11.7, que não reproduz foco e Tab com fidelidade, e o pacote não tem harness de
|
|
468
|
+
navegador. *Decisão:* adicionar o modo navegador do Vitest com Playwright (Chromium) só para
|
|
469
|
+
os testes de foco desta camada, rodando no gate de release. É uma dependência de desenvolvimento
|
|
470
|
+
nova e um navegador baixado na máquina. A alternativa é um roteiro manual a cada mudança, que é
|
|
471
|
+
mais barato e mais fácil de esquecer.
|
|
472
|
+
*Nota de implementação (2026-10-03):* o harness é `vitest.browser.config.ts`, executado por
|
|
473
|
+
`pnpm test:browser` e pelo gate do `release.sh`, fora do `pnpm test`. O `playwright` é fixado
|
|
474
|
+
numa versão exata, porque cada versão aponta para uma revisão do Chromium. O harness também
|
|
475
|
+
compila o tema com `tailwindcss` e `@tailwindcss/vite`, devDependencies novas: o véu, o
|
|
476
|
+
hospedeiro e os `pointer-events` dos filhos só se comportam como num app com o CSS real.
|
|
477
|
+
7. **O Toaster adota a camada?** As ações dos avisos parecem não responder a clique com um modal
|
|
478
|
+
aberto. *Decisão:* sim, numa entrega separada, depois de confirmar o defeito no navegador e
|
|
479
|
+
de a camada estar publicada.
|
|
480
|
+
|
|
481
|
+
## Consequências
|
|
482
|
+
|
|
483
|
+
- Os patterns que usam `DialogContent` e `DrawerContent` passam a conviver com a camada sem mudança,
|
|
484
|
+
porque a regra mora nesses dois componentes.
|
|
485
|
+
- Sem nenhuma `FloatingLayer` montada, o comportamento dos modais é o mesmo de hoje. A mudança é
|
|
486
|
+
aditiva e sai numa versão menor.
|
|
487
|
+
- O custo está concentrado no foco e na pertença decidida no aperto do ponteiro. As exceções de
|
|
488
|
+
ponteiro e empilhamento são pequenas; rolagem e leitor de tela dependem de premissas que os testes
|
|
489
|
+
travam.
|
|
490
|
+
- A API é reversível no sentido que importa: a materialização da seção 8 pode ser trocada pela de
|
|
491
|
+
reserva sem que o consumidor perceba. Remover a `FloatingLayer` depois seria uma mudança major.
|
|
492
|
+
- A Grand Brasil, único consumidor hoje, adota assim:
|
|
493
|
+
- envolve o FAB e o painel do Copilot em `FloatingLayer` e passa a posicioná-los contra a
|
|
494
|
+
viewport, porque deixam de ficar dentro da moldura do shell;
|
|
495
|
+
- revisa `useDismissOnOutsidePointer`, que decide “fora” pelo `#root`; o painel sai do `#root`,
|
|
496
|
+
e o clique no véu ou no modal continua sem fechar o painel;
|
|
497
|
+
- move para o `onEscapeKeyDown` da camada qualquer tratamento de Esc do painel, por exemplo
|
|
498
|
+
fechar quando não houver texto digitado;
|
|
499
|
+
- reposiciona o painel e o FAB conforme `data-modal` e `data-modal-side` (decisão 5 da seção 9).
|
|
500
|
+
- Perguntar “sobre o que está na tela” também depende de o contexto enviado ao Copilot incluir o
|
|
501
|
+
modal aberto. Hoje o contexto vem da rota. Isso é da Grand Brasil e não bloqueia esta ADR.
|
|
502
|
+
- O Toaster tem o mesmo problema e pode adotar o mesmo mecanismo depois (decisão 7 da seção 9).
|
|
503
|
+
- A documentação ganha `floating-layer.md`, com o caso do Copilot, e as páginas de Dialog e Drawer
|
|
504
|
+
passam a mencionar a convivência.
|
|
505
|
+
|
|
506
|
+
## Alternativas consideradas
|
|
507
|
+
|
|
508
|
+
### Manter o estado atual
|
|
509
|
+
|
|
510
|
+
Não exige trabalho, mas contraria a decisão de produto: o Copilot fica inalcançável sempre que um
|
|
511
|
+
modal está aberto.
|
|
512
|
+
|
|
513
|
+
### Fechar o modal ao acionar o FAB
|
|
514
|
+
|
|
515
|
+
Simples e sem risco de acessibilidade, mas descartada pelo produto: perde o que a pessoa preencheu.
|
|
516
|
+
|
|
517
|
+
### Levar o Copilot para dentro do modal
|
|
518
|
+
|
|
519
|
+
O Opus oferece `SurfaceAssistant`, que divide a própria superfície entre conteúdo e assistência,
|
|
520
|
+
e a `Presentation` o usa quando declara assistência. A ADR 0087 da Grand Brasil usa esse mecanismo
|
|
521
|
+
para as conversas sobre um recurso e mantém o Copilot global separado delas, de propósito. Levar o
|
|
522
|
+
Copilot global para dentro de cada modal exigiria espaço em todo Dialog e Drawer, inclusive
|
|
523
|
+
confirmações e formulários estreitos, e desfaria essa separação.
|
|
524
|
+
|
|
525
|
+
### `modal={false}` em cada consumidor, com reimplementação local
|
|
526
|
+
|
|
527
|
+
Cada tela ou pattern desligaria o modo modal e refaria trava, véu, rolagem e `aria-hidden`. A
|
|
528
|
+
técnica é a mesma da materialização de reserva, mas espalhada: cada cópia poderia divergir, e os
|
|
529
|
+
patterns precisariam de uma opção nova. Descartada em favor de concentrar a regra no Opus.
|
|
530
|
+
|
|
531
|
+
### Peças internas do Radix atrás de uma API do Opus
|
|
532
|
+
|
|
533
|
+
`DismissableLayer.Branch` e `FocusScope` aninhado, importados de `radix-ui/internal`. O `Branch`
|
|
534
|
+
resolve só o clique e o foco “fora”, que os handlers públicos já resolvem. O `FocusScope` aninhado
|
|
535
|
+
deixaria o modal sem trava e sem a volta do Tab enquanto a camada estivesse montada. Ponteiro,
|
|
536
|
+
rolagem e leitor de tela continuariam sem solução, e o pacote passaria a depender de um módulo que o
|
|
537
|
+
Radix não documenta.
|
|
538
|
+
|
|
539
|
+
### A top layer do navegador
|
|
540
|
+
|
|
541
|
+
Com `popover="manual"`, o navegador põe a camada acima de qualquer `z-index`. Isso resolve só o
|
|
542
|
+
empilhamento: foco, ponteiro, rolagem e leitor de tela continuam bloqueados pelo modal. Além disso,
|
|
543
|
+
nada do Opus conseguiria ficar acima da camada, o que impede a decisão 4 da seção 9.
|
|
544
|
+
|
|
545
|
+
### Remover à mão o `aria-hidden` do hospedeiro
|
|
546
|
+
|
|
547
|
+
Depois que o Radix esconde o resto, o Opus tiraria o atributo do hospedeiro. Não resiste ao contador
|
|
548
|
+
do `hideOthers`: um menu modal aberto sobre o Dialog grava o atributo de novo e, ao fechar, não o
|
|
549
|
+
remove, porque o Dialog ainda conta; a camada fica escondida até o Dialog fechar. Também não cobre
|
|
550
|
+
as regiões `aria-live` de dentro do painel, que fazem o `hideOthers` esconder partes dele.
|
|
551
|
+
|
|
552
|
+
### Isentar cada evento consultando o DOM no fim do clique
|
|
553
|
+
|
|
554
|
+
É o uso direto de `onInteractOutside`. Falha quando o elemento clicado some no próprio clique, como
|
|
555
|
+
o FAB (seção 8).
|
|
556
|
+
|
|
557
|
+
## Premissas verificadas
|
|
558
|
+
|
|
559
|
+
Conferidas em `@radix-ui/react-dialog` 1.1.19 e 1.1.23, `@radix-ui/react-dismissable-layer` 1.1.15
|
|
560
|
+
e 1.1.19, `@radix-ui/react-focus-scope` 1.1.12 e 1.1.16, `@radix-ui/react-menu` 2.1.20, `radix-ui`
|
|
561
|
+
1.6.2 e 1.6.7, `aria-hidden` 1.2.6, `react-remove-scroll` 2.7.2, `react-dom` 19.2.7 e `sonner`
|
|
562
|
+
1.7.4. Onde há duas versões, a menor é a do lockfile do Opus e a maior, a da Grand Brasil; o
|
|
563
|
+
comportamento descrito é o mesmo nas duas. Cada pacote do Radix tem uma única cópia instalada em
|
|
564
|
+
cada repositório, o que mantém uma só pilha de escopos e um só contexto de `DismissableLayer`.
|
|
565
|
+
|
|
566
|
+
- `Dialog` modal: `hideOthers(content)`; `trapFocus` e `disableOutsidePointerEvents` ligados
|
|
567
|
+
enquanto aberto; `onFocusOutside` sempre cancelado; clique com botão direito não fecha;
|
|
568
|
+
`deferPointerDownOutside` ligado; o `Overlay` envolve o `RemoveScroll` com `shards` igual ao
|
|
569
|
+
conteúdo; nenhum `aria-modal`. No modo não modal, o `Overlay` não é renderizado.
|
|
570
|
+
- `AlertDialog`: força `modal: true` e cancela clique fora.
|
|
571
|
+
- `DropdownMenu` e `Menu`: `modal` é `true` por padrão, e o conteúdo chama `hideOthers`.
|
|
572
|
+
- `DismissableLayer`: grava `pointer-events: none` no `body`; o Esc é ouvido no documento, em
|
|
573
|
+
captura, só pelo `DismissableLayer` mais recente; com `deferPointerDownOutside`, o fechamento por
|
|
574
|
+
clique fora roda num ouvinte `click` de uso único no documento e recebe o evento do aperto;
|
|
575
|
+
clique e foco dentro de um `Branch` não contam como fora. O `Tooltip` também é um
|
|
576
|
+
`DismissableLayer`.
|
|
577
|
+
- `FocusScope`: a trava ouve `focusin` e `focusout` no documento, em borbulha; montar um escopo
|
|
578
|
+
pausa o anterior, e só a desmontagem o retoma; com o escopo pausado, a volta do Tab também para.
|
|
579
|
+
- `hideOthers`: aceita uma lista de elementos a manter; a cada chamada, junta a ela todo
|
|
580
|
+
`[aria-live]` e `script` do `body`, preserva o caminho até eles e não desce para dentro deles;
|
|
581
|
+
controla o atributo por contador e só o remove quando o contador volta a zero.
|
|
582
|
+
- `react-remove-scroll`: ouve `wheel` e `touchmove` no documento, em borbulha; só a trava mais
|
|
583
|
+
recente age.
|
|
584
|
+
- `react-dom` 19: registra os ouvintes de eventos no contêiner de cada portal; atualizações de
|
|
585
|
+
eventos discretos são aplicadas numa microtarefa.
|
|
586
|
+
- `Toaster`: `section` com `aria-live="polite"` e `z-index: 999999999`; nenhuma regra de
|
|
587
|
+
`pointer-events: auto`.
|
|
588
|
+
|
|
589
|
+
## Verificação
|
|
590
|
+
|
|
591
|
+
- **Testes do pacote (`happy-dom`):**
|
|
592
|
+
- com Dialog, Drawer, `ActionFormDialog`, `ActionListDialog`, `Presentation` e `CommandDialog`
|
|
593
|
+
abertos, clicar na camada não fecha o modal, e clicar no véu fecha;
|
|
594
|
+
- clicar no FAB que se desmonta no próprio clique não fecha o modal;
|
|
595
|
+
- fechar o painel pelo botão de fechar dele não fecha o modal;
|
|
596
|
+
- escolher um item de um popover da camada, que fecha no clique, não fecha o modal;
|
|
597
|
+
|
|
598
|
+
Nesses três testes, o fixture desmonta o elemento clicado de forma síncrona, dentro do próprio
|
|
599
|
+
handler (`flushSync(() => setOpen(true))` no `onClick` do FAB fictício). Num evento despachado
|
|
600
|
+
por script, como o do `fireEvent` ou do `user-event`, não há microtarefa entre os ouvintes, e
|
|
601
|
+
com `act` o React só aplica a mudança no fim. Sem o `flushSync`, uma implementação que consulta
|
|
602
|
+
o DOM no fim do clique passaria no teste. Os três cenários se repetem no navegador real;
|
|
603
|
+
- Esc com o foco na camada, ou depois de clicar numa área sem foco do painel, não fecha o modal e
|
|
604
|
+
chama `onEscapeKeyDown` uma única vez; com o foco num campo do modal, fecha;
|
|
605
|
+
- sem modal aberto, clicar no texto de uma resposta e apertar Esc chama `onEscapeKeyDown` uma
|
|
606
|
+
única vez;
|
|
607
|
+
|
|
608
|
+
Nos testes de clique em área sem foco, o clique é simulado com `user-event`, que leva o foco ao
|
|
609
|
+
`body`, ou o fixture tira o foco explicitamente. Um `fireEvent` de ponteiro não move o foco no
|
|
610
|
+
`happy-dom`, e o foco ficaria no campo do modal;
|
|
611
|
+
- o `onBlur` de um campo do modal dispara quando o foco vai para a camada;
|
|
612
|
+
- o hospedeiro não tem `aria-hidden` com um modal de trabalho aberto, e o resto da página tem;
|
|
613
|
+
- o hospedeiro continua visível ao leitor de tela depois que um `Menu` abre e fecha sobre um
|
|
614
|
+
Dialog;
|
|
615
|
+
- com um `Ask` aberto no painel no momento em que o modal abre, nenhuma parte do painel recebe
|
|
616
|
+
`aria-hidden`;
|
|
617
|
+
- com `mode="alert"` ou uma confirmação do `DialogHost` aberta e depois fechada sobre um Dialog,
|
|
618
|
+
a camada fica escondida e abaixo do véu durante o alerta e volta a ficar visível depois
|
|
619
|
+
(decisão 4 da seção 9);
|
|
620
|
+
- uma confirmação do `DialogHost` cuja resposta abre um Dialog em seguida: com o Dialog aberto, a
|
|
621
|
+
camada volta a ficar visível ao leitor de tela;
|
|
622
|
+
- um Dialog aberto de dentro da camada prende o foco, fecha pelo véu e pelo Esc, e não chama o
|
|
623
|
+
`onEscapeKeyDown` da camada;
|
|
624
|
+
- com um alerta no topo, um foco programático na camada é devolvido ao alerta;
|
|
625
|
+
- roda do mouse dentro da camada não é cancelada; fora dela, continua cancelada;
|
|
626
|
+
- Popover e Tooltip abertos dentro da camada são portados para o hospedeiro;
|
|
627
|
+
- `data-modal` e `data-modal-side` acompanham abertura e fechamento, inclusive com modais
|
|
628
|
+
empilhados;
|
|
629
|
+
- sem `FloatingLayer`, os testes atuais de Dialog, Drawer e patterns continuam passando sem
|
|
630
|
+
alteração.
|
|
631
|
+
- **Testes de premissa**, comportamentais, no próprio `happy-dom`: um por premissa que a
|
|
632
|
+
materialização usa. A trava do Radix devolve o foco quando o `focusin` chega ao documento e não
|
|
633
|
+
o devolve quando ele é parado no `body`; o `react-remove-scroll` cancela a roda que chega ao
|
|
634
|
+
documento; o `hideOthers` preserva `[aria-live]` em chamadas repetidas; o fechamento por clique
|
|
635
|
+
fora recebe o evento do aperto; o conteúdo do modal não tem `aria-modal`; há uma cópia única dos
|
|
636
|
+
pacotes do Radix. Se uma atualização mudar alguma delas, o teste aponta qual, antes de a
|
|
637
|
+
convivência quebrar em silêncio.
|
|
638
|
+
- **Navegador real** (no harness da decisão 6 da seção 9, ou num roteiro manual executado por quem
|
|
639
|
+
implementa, com o resultado registrado na descrição da entrega), em Chrome, Safari e Firefox:
|
|
640
|
+
- abrir um `ActionFormDialog`, digitar, abrir o Copilot, perguntar, fechar o painel e encontrar o
|
|
641
|
+
texto intacto, com o foco de volta no campo;
|
|
642
|
+
- clicar na caixa de mensagem com o modal aberto e digitar sem o foco voltar ao modal;
|
|
643
|
+
- clicar no FAB, no botão de fechar o painel e num item de popover do painel sem que o modal
|
|
644
|
+
feche;
|
|
645
|
+
- Tab e Shift+Tab percorrendo o modal e a camada conforme a decisão 2 da seção 9, sem nunca parar
|
|
646
|
+
na página escondida;
|
|
647
|
+
- Esc nos dois lados, com e sem popover aberto, e depois de clicar no texto de uma resposta;
|
|
648
|
+
- VoiceOver no macOS anunciando o modal e a camada, e não a página atrás; as regiões vivas do
|
|
649
|
+
painel, como a resposta em streaming e o `Ask`, continuam anunciadas, e a camada não anuncia
|
|
650
|
+
mudanças por conta própria;
|
|
651
|
+
- Drawer sobre Drawer, Dialog sobre Drawer e confirmação do `DialogHost` sobre Drawer.
|
|
652
|
+
|
|
653
|
+
Leitores de tela do Windows, como o NVDA com o Chrome, ficam fora desta verificação por falta de
|
|
654
|
+
ambiente. É um limite declarado, não uma garantia.
|
|
655
|
+
|
|
656
|
+
*Nota (2026-10-03):* a implementação no pacote cobre Chrome pelo harness da decisão 6. Safari,
|
|
657
|
+
Firefox e VoiceOver não foram verificados: a estação onde o pacote foi construído não tem esses
|
|
658
|
+
navegadores. O roteiro manual acima fica para a adoção na Grand Brasil, executado no Mac do João
|
|
659
|
+
(Safari com VoiceOver) e no Firefox antes de a adoção chegar à `main` da Grand Brasil, com o
|
|
660
|
+
resultado registrado na entrega da adoção.
|
|
661
|
+
- **Arquitetura:**
|
|
662
|
+
- todo uso de portal em `src/ui` lê o contêiner da camada;
|
|
663
|
+
- só `modal-layers.ts`, `dialog.tsx`, `drawer.tsx` e `floating-layer.tsx` aplicam as exceções;
|
|
664
|
+
nenhum pattern repassa handlers para contornar a regra;
|
|
665
|
+
- o teste de API pública passa a exigir `FloatingLayer`;
|
|
666
|
+
- o teste de numeração de ADRs continua passando.
|
|
667
|
+
- **Na Grand Brasil:** um teste do shell confirma que o FAB e o painel ficam dentro de
|
|
668
|
+
`[data-slot="floating-layer"]`, e o roteiro de navegador acima roda na pilha de desenvolvimento.
|