@softize/opus 25.0.0 → 25.1.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 +44 -0
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
- package/docs/releasing.md +8 -2
- package/docs/stacking-scale.md +53 -0
- package/package.json +6 -1
- 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/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/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
package/docs/releasing.md
CHANGED
|
@@ -59,14 +59,20 @@ O token usado para publicar é exclusivamente o token do npm no `~/.npmrc` globa
|
|
|
59
59
|
pra testar publicação seria a guarda atrapalhando quem está experimentando.
|
|
60
60
|
|
|
61
61
|
**Gate de qualidade**: depois do bump e da materialização, mas antes de publicar, roda
|
|
62
|
-
`pnpm typecheck` + `pnpm test` + `pnpm copy:check` + `base copy check` e
|
|
63
|
-
se qualquer um falhar**. O Opus declara `@softize/base ^2.4.0` em `dependencies`, pois usa
|
|
62
|
+
`pnpm typecheck` + `pnpm test` + `pnpm test:browser` + `pnpm copy:check` + `base copy check` e
|
|
63
|
+
**aborta a release se qualquer um falhar**. O Opus declara `@softize/base ^2.4.0` em `dependencies`, pois usa
|
|
64
64
|
suas APIs públicas de filesystem em runtime, e materializa os artefatos Base no próprio repo;
|
|
65
65
|
a release não baixa uma política ad hoc. Como o
|
|
66
66
|
Opus **ship source** (`.ts`, sem build), essa é a última barreira antes do tarball — sem ela,
|
|
67
67
|
um `tsc` vermelho, inventário desatualizado ou violação da política vaza pro cliente. Emergência (evite):
|
|
68
68
|
`pnpm release --skip-checks`.
|
|
69
69
|
|
|
70
|
+
`pnpm test:browser` roda em Chromium real, pelo modo navegador do Vitest com Playwright, os testes
|
|
71
|
+
que o `happy-dom` não reproduz com fidelidade, como o foco e o Tab da camada flutuante (ADR 0052).
|
|
72
|
+
Ele fica fora do `pnpm test` para que o ciclo comum não dependa de navegador. O Chromium é o da
|
|
73
|
+
revisão fixada pela versão exata do `playwright` nas devDependencies; se a estação não o tiver, o
|
|
74
|
+
comando falha dizendo isso, e `pnpm exec playwright install chromium` o instala.
|
|
75
|
+
|
|
70
76
|
O gate roda `pnpm test`, não `pnpm test:cov`: o threshold de 100% de cobertura em
|
|
71
77
|
`vitest.config.ts` é uma meta aspiracional e **não** faz parte do gate de release. Uma release
|
|
72
78
|
não é bloqueada por cobertura abaixo de 100%; a cobertura é acompanhada com `pnpm test:cov`.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Escala de empilhamento
|
|
2
|
+
|
|
3
|
+
> **Status: decidido (out/2026).** As sobreposições do Opus ocupam quatro faixas de `z-index`,
|
|
4
|
+
> definidas pela ADR 0052. Os valores são detalhe de implementação; a ordem entre as faixas é o
|
|
5
|
+
> contrato.
|
|
6
|
+
|
|
7
|
+
## Contexto
|
|
8
|
+
|
|
9
|
+
Até a camada flutuante, toda sobreposição do Opus usava `z-50`: véu e conteúdo de Dialog e Drawer,
|
|
10
|
+
Popover, Menu e Tooltip. A ordem no DOM desempatava, e o que abria depois ficava por cima. Isso
|
|
11
|
+
bastava enquanto só existiam modais e sobreposições presas a um gatilho.
|
|
12
|
+
|
|
13
|
+
A camada flutuante (`FloatingLayer`) precisa ficar acima de Dialog e Drawer e, ao mesmo tempo,
|
|
14
|
+
abaixo dos diálogos de resposta obrigatória, que a cobrem com o véu (decisão 4 da seção 9 da ADR
|
|
15
|
+
0052). A ordem no DOM não garante isso: o hospedeiro da camada é montado uma vez e continua no
|
|
16
|
+
`body` enquanto modais abrem e fecham depois dele.
|
|
17
|
+
|
|
18
|
+
## Decisão
|
|
19
|
+
|
|
20
|
+
Do fundo para a frente:
|
|
21
|
+
|
|
22
|
+
| Faixa | `z-index` | Quem ocupa |
|
|
23
|
+
| ------------------------- | ------------- | ---------------------------------------------------------------------------------------------- |
|
|
24
|
+
| Conteúdo | `auto` | A página. |
|
|
25
|
+
| Modal | `50` | Dialog, Drawer, seus véus e Popover, Menu e Tooltip abertos fora da camada. |
|
|
26
|
+
| Camada flutuante | `60` | O hospedeiro `div[data-slot="floating-layer"]`. |
|
|
27
|
+
| Resposta obrigatória | `70` | Véu e conteúdo de `Dialog mode="alert"`, que também serve às confirmações do `DialogHost`. |
|
|
28
|
+
| Avisos | `999999999` | O `Toaster`, com o valor do Sonner. |
|
|
29
|
+
|
|
30
|
+
- Dentro de uma faixa, a ordem no DOM continua desempatando, como antes.
|
|
31
|
+
- O hospedeiro da camada cria um contexto de empilhamento próprio. Popover, Menu, Tooltip, Dialog e
|
|
32
|
+
Drawer abertos de dentro dela são portados para o hospedeiro e mantêm o `z-50` lá dentro, acima
|
|
33
|
+
do painel que os abriu.
|
|
34
|
+
- Popover, Menu e Tooltip abertos de dentro de um alerta herdam a faixa do alerta (`z-70`); sem
|
|
35
|
+
isso, ficariam por baixo do véu que o próprio alerta desenha.
|
|
36
|
+
- Componente novo que abre uma sobreposição usa as faixas acima e lê o contêiner da camada; o
|
|
37
|
+
teste `tests/architecture/floating-layer.test.ts` cobra o contêiner de todo portal.
|
|
38
|
+
- Um alerta em animação de saída deixa de receber clique (`pointer-events: none`). Sem isso, o véu
|
|
39
|
+
dele, ainda em `z-70`, cobriria por cerca de 150 ms o Dialog que a resposta abre na faixa de
|
|
40
|
+
baixo.
|
|
41
|
+
|
|
42
|
+
## Consequências
|
|
43
|
+
|
|
44
|
+
- Um consumidor que posiciona elementos próprios com `z-index` acima de `50` passa a competir com a
|
|
45
|
+
camada. Use a camada para o que precisa ficar acima dos modais, em vez de subir o `z-index`.
|
|
46
|
+
- Um elemento do consumidor com `z-index` entre `51` e `69` ficava acima de um alerta, que estava em
|
|
47
|
+
`z-50`, e agora fica abaixo dele.
|
|
48
|
+
- Uma sobreposição de outra biblioteca que fixa `z-50` e é aberta de dentro de um alerta fica abaixo
|
|
49
|
+
do véu do alerta, porque não lê a faixa que os primitives do Opus herdam. Suba a faixa dela para
|
|
50
|
+
`z-70` ou troque-a por um primitive do Opus.
|
|
51
|
+
- Mudar um valor exige mudar todos os pontos que dependem dele: `overlayClasses` e o conteúdo em
|
|
52
|
+
`dialog.tsx` e `drawer.tsx`, o hospedeiro em `floating-layer.tsx` e a faixa do alerta em
|
|
53
|
+
`modal-layers.ts`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.1.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",
|
|
@@ -362,6 +362,7 @@
|
|
|
362
362
|
"@opentelemetry/api": "^1.9.1",
|
|
363
363
|
"@opentelemetry/context-async-hooks": "^2.10.0",
|
|
364
364
|
"@opentelemetry/sdk-trace-base": "^2.10.0",
|
|
365
|
+
"@tailwindcss/vite": "^4.3.2",
|
|
365
366
|
"@tanstack/react-query": "^5.62.0",
|
|
366
367
|
"@testing-library/dom": "^10.4.0",
|
|
367
368
|
"@testing-library/react": "^16.1.0",
|
|
@@ -371,6 +372,7 @@
|
|
|
371
372
|
"@types/pg": "^8.11.0",
|
|
372
373
|
"@types/react": "^19.0.0",
|
|
373
374
|
"@types/react-dom": "^19.0.0",
|
|
375
|
+
"@vitest/browser": "2.1.9",
|
|
374
376
|
"@vitest/coverage-v8": "^2.1.0",
|
|
375
377
|
"better-sqlite3": "^11.0.0",
|
|
376
378
|
"bullmq": "^5.0.0",
|
|
@@ -381,9 +383,11 @@
|
|
|
381
383
|
"node-cron": "^3.0.3",
|
|
382
384
|
"pg": "^8.13.0",
|
|
383
385
|
"pino": "^9.5.0",
|
|
386
|
+
"playwright": "1.62.1",
|
|
384
387
|
"react": "^19.0.0",
|
|
385
388
|
"react-dom": "^19.0.0",
|
|
386
389
|
"react-hook-form": "^7.54.0",
|
|
390
|
+
"tailwindcss": "^4.3.2",
|
|
387
391
|
"tsx": "^4.19.0",
|
|
388
392
|
"vite": "^6.0.0",
|
|
389
393
|
"vitest": "^2.1.0",
|
|
@@ -400,6 +404,7 @@
|
|
|
400
404
|
"copy:check": "node ./bin/cli.mjs copy --check",
|
|
401
405
|
"typecheck": "tsc --noEmit",
|
|
402
406
|
"test": "vitest run",
|
|
407
|
+
"test:browser": "vitest run --config vitest.browser.config.ts",
|
|
403
408
|
"test:watch": "vitest",
|
|
404
409
|
"test:cov": "vitest run --coverage",
|
|
405
410
|
"registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
|
|
@@ -19,16 +19,34 @@ import {
|
|
|
19
19
|
type StructuralMediaProps,
|
|
20
20
|
} from "../patterns/structural-media.tsx";
|
|
21
21
|
import { CloseButton, type CloseButtonSize } from "./close-button.tsx";
|
|
22
|
+
import {
|
|
23
|
+
ANSWER_BAND_CLASS,
|
|
24
|
+
LAYER_MODAL_ATTRIBUTE,
|
|
25
|
+
LayerPortalProvider,
|
|
26
|
+
exemptLayerFirst,
|
|
27
|
+
focusIsInLayer,
|
|
28
|
+
handleModalTabKey,
|
|
29
|
+
isLayerInteraction,
|
|
30
|
+
routeEscapeToLayer,
|
|
31
|
+
useLayerPortal,
|
|
32
|
+
useModalLayerEntry,
|
|
33
|
+
type ModalMark,
|
|
34
|
+
} from "../../lib/modal-layers.ts";
|
|
22
35
|
|
|
23
36
|
export type DialogMode = "default" | "alert";
|
|
24
37
|
|
|
25
38
|
interface DialogContextValue {
|
|
26
39
|
mode: DialogMode;
|
|
27
40
|
onResult?: (result: string) => void;
|
|
41
|
+
/** Estado de aberto, que a pilha de modais da camada flutuante acompanha (ADR 0052). */
|
|
42
|
+
open: boolean;
|
|
43
|
+
modal: boolean;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
const DialogContext = React.createContext<DialogContextValue>({
|
|
31
47
|
mode: "default",
|
|
48
|
+
open: false,
|
|
49
|
+
modal: true,
|
|
32
50
|
});
|
|
33
51
|
|
|
34
52
|
interface DialogContentContextValue {
|
|
@@ -67,6 +85,139 @@ function useDialogContext(): DialogContextValue {
|
|
|
67
85
|
return React.useContext(DialogContext);
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Estado de aberto controlado ou não, como o do Radix, mas visível ao Opus: a pilha de modais da
|
|
90
|
+
* camada flutuante precisa saber quando o modal fecha, e não só quando desmonta (ADR 0052).
|
|
91
|
+
*/
|
|
92
|
+
export function useModalOpenState({
|
|
93
|
+
open: openProp,
|
|
94
|
+
defaultOpen,
|
|
95
|
+
onOpenChange,
|
|
96
|
+
}: {
|
|
97
|
+
open?: boolean;
|
|
98
|
+
defaultOpen?: boolean;
|
|
99
|
+
onOpenChange?: (open: boolean) => void;
|
|
100
|
+
}): [boolean, (open: boolean) => void] {
|
|
101
|
+
const [uncontrolled, setUncontrolled] = React.useState(defaultOpen ?? false);
|
|
102
|
+
const controlled = openProp !== undefined;
|
|
103
|
+
const open = controlled ? openProp : uncontrolled;
|
|
104
|
+
const onOpenChangeRef = React.useRef(onOpenChange);
|
|
105
|
+
onOpenChangeRef.current = onOpenChange;
|
|
106
|
+
const setOpen = React.useCallback(
|
|
107
|
+
(next: boolean) => {
|
|
108
|
+
if (!controlled) setUncontrolled(next);
|
|
109
|
+
onOpenChangeRef.current?.(next);
|
|
110
|
+
},
|
|
111
|
+
[controlled],
|
|
112
|
+
);
|
|
113
|
+
return [open, setOpen];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface OutsideEvent {
|
|
117
|
+
defaultPrevented: boolean;
|
|
118
|
+
preventDefault(): void;
|
|
119
|
+
detail: { originalEvent: Event };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Isenções da camada flutuante compostas antes dos handlers de dispensa do consumidor e do
|
|
124
|
+
* `closeDisabled`, para Dialog e Drawer (seção 8 da ADR 0052). Fora de um modal de trabalho,
|
|
125
|
+
* devolve só a composição de sempre.
|
|
126
|
+
*/
|
|
127
|
+
export function composeModalDismissHandlers<
|
|
128
|
+
Escape extends KeyboardEvent,
|
|
129
|
+
Pointer extends OutsideEvent,
|
|
130
|
+
Interact extends OutsideEvent,
|
|
131
|
+
CloseFocus extends Event,
|
|
132
|
+
>({
|
|
133
|
+
mark,
|
|
134
|
+
closeDisabled,
|
|
135
|
+
onEscapeKeyDown,
|
|
136
|
+
onPointerDownOutside,
|
|
137
|
+
onInteractOutside,
|
|
138
|
+
onCloseAutoFocus,
|
|
139
|
+
}: {
|
|
140
|
+
mark: ModalMark;
|
|
141
|
+
closeDisabled: boolean;
|
|
142
|
+
onEscapeKeyDown: ((event: Escape) => void) | undefined;
|
|
143
|
+
onPointerDownOutside: ((event: Pointer) => void) | undefined;
|
|
144
|
+
onInteractOutside: ((event: Interact) => void) | undefined;
|
|
145
|
+
onCloseAutoFocus: ((event: CloseFocus) => void) | undefined;
|
|
146
|
+
}): {
|
|
147
|
+
onEscapeKeyDown: ((event: Escape) => void) | undefined;
|
|
148
|
+
onPointerDownOutside: ((event: Pointer) => void) | undefined;
|
|
149
|
+
onInteractOutside: ((event: Interact) => void) | undefined;
|
|
150
|
+
onCloseAutoFocus: (event: CloseFocus) => void;
|
|
151
|
+
} {
|
|
152
|
+
const escape = preventDismissWhen(closeDisabled, onEscapeKeyDown);
|
|
153
|
+
const interact = preventDismissWhen(closeDisabled, onInteractOutside);
|
|
154
|
+
// O modal que fecha com a pessoa na camada não leva o foco ao gatilho dele: o foco fica onde
|
|
155
|
+
// ela está digitando. O handler do consumidor roda antes e pode decidir outra coisa.
|
|
156
|
+
const closeAutoFocus = (event: CloseFocus): void => {
|
|
157
|
+
onCloseAutoFocus?.(event);
|
|
158
|
+
if (!event.defaultPrevented && focusIsInLayer()) event.preventDefault();
|
|
159
|
+
};
|
|
160
|
+
if (mark !== "work") {
|
|
161
|
+
return {
|
|
162
|
+
onEscapeKeyDown: escape,
|
|
163
|
+
onPointerDownOutside,
|
|
164
|
+
onInteractOutside: interact,
|
|
165
|
+
onCloseAutoFocus: closeAutoFocus,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
onEscapeKeyDown: exemptLayerFirst(routeEscapeToLayer, escape),
|
|
170
|
+
onPointerDownOutside: exemptLayerFirst(
|
|
171
|
+
isLayerInteraction,
|
|
172
|
+
onPointerDownOutside,
|
|
173
|
+
),
|
|
174
|
+
onInteractOutside: exemptLayerFirst(isLayerInteraction, interact),
|
|
175
|
+
onCloseAutoFocus: closeAutoFocus,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type RefCleanup = () => void;
|
|
180
|
+
|
|
181
|
+
function setRef<T>(
|
|
182
|
+
ref: React.Ref<T> | undefined,
|
|
183
|
+
value: T | null,
|
|
184
|
+
): RefCleanup | void {
|
|
185
|
+
if (typeof ref === "function") {
|
|
186
|
+
const cleanup = ref(value);
|
|
187
|
+
return typeof cleanup === "function" ? cleanup : undefined;
|
|
188
|
+
}
|
|
189
|
+
if (ref !== null && ref !== undefined) {
|
|
190
|
+
(ref as React.RefObject<T | null>).current = value;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compõe a ref do consumidor com a do Opus sem perder nenhuma das duas, como o `composeRefs` do
|
|
196
|
+
* Radix: quando alguma callback ref devolve limpeza (React 19), a composição também devolve, e o
|
|
197
|
+
* React passa a chamá-la em vez de chamar as refs com `null`. As refs sem limpeza recebem `null`
|
|
198
|
+
* nesse momento.
|
|
199
|
+
*/
|
|
200
|
+
export function useComposedRef<T>(
|
|
201
|
+
...refs: Array<React.Ref<T> | undefined>
|
|
202
|
+
): (node: T | null) => RefCleanup | undefined {
|
|
203
|
+
return React.useCallback(
|
|
204
|
+
(node: T | null) => {
|
|
205
|
+
const cleanups = refs.map((ref) => setRef(ref, node));
|
|
206
|
+
if (!cleanups.some((cleanup) => typeof cleanup === "function")) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return () => {
|
|
210
|
+
cleanups.forEach((cleanup, index) => {
|
|
211
|
+
if (typeof cleanup === "function") cleanup();
|
|
212
|
+
else setRef(refs[index], null);
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
217
|
+
refs,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
70
221
|
export interface DialogProps extends React.ComponentProps<
|
|
71
222
|
typeof DialogPrimitive.Root
|
|
72
223
|
> {
|
|
@@ -80,16 +231,40 @@ export function Dialog({
|
|
|
80
231
|
mode = "default",
|
|
81
232
|
onResult,
|
|
82
233
|
modal,
|
|
234
|
+
open: openProp,
|
|
235
|
+
defaultOpen,
|
|
236
|
+
onOpenChange,
|
|
83
237
|
...props
|
|
84
238
|
}: DialogProps) {
|
|
85
|
-
const
|
|
239
|
+
const [open, setOpen] = useModalOpenState({
|
|
240
|
+
open: openProp,
|
|
241
|
+
defaultOpen,
|
|
242
|
+
onOpenChange,
|
|
243
|
+
});
|
|
244
|
+
// O `AlertDialog` do Radix força o modo modal e ignora `modal`.
|
|
245
|
+
const isModal = mode === "alert" || modal !== false;
|
|
246
|
+
const context = React.useMemo(
|
|
247
|
+
() => ({ mode, onResult, open, modal: isModal }),
|
|
248
|
+
[mode, onResult, open, isModal],
|
|
249
|
+
);
|
|
86
250
|
|
|
87
251
|
return (
|
|
88
252
|
<DialogContext.Provider value={context}>
|
|
89
253
|
{mode === "alert" ? (
|
|
90
|
-
<AlertDialogPrimitive.Root
|
|
254
|
+
<AlertDialogPrimitive.Root
|
|
255
|
+
data-slot="dialog"
|
|
256
|
+
open={open}
|
|
257
|
+
onOpenChange={setOpen}
|
|
258
|
+
{...props}
|
|
259
|
+
/>
|
|
91
260
|
) : (
|
|
92
|
-
<DialogPrimitive.Root
|
|
261
|
+
<DialogPrimitive.Root
|
|
262
|
+
data-slot="dialog"
|
|
263
|
+
modal={modal}
|
|
264
|
+
open={open}
|
|
265
|
+
onOpenChange={setOpen}
|
|
266
|
+
{...props}
|
|
267
|
+
/>
|
|
93
268
|
)}
|
|
94
269
|
</DialogContext.Provider>
|
|
95
270
|
);
|
|
@@ -107,13 +282,25 @@ export function DialogTrigger({
|
|
|
107
282
|
}
|
|
108
283
|
|
|
109
284
|
export function DialogPortal({
|
|
285
|
+
container,
|
|
110
286
|
...props
|
|
111
287
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
|
112
288
|
const { mode } = useDialogContext();
|
|
289
|
+
// Aberto de dentro de uma camada flutuante, o modal vai para o hospedeiro dela (ADR 0052).
|
|
290
|
+
const layer = useLayerPortal();
|
|
291
|
+
const target = container ?? layer.container;
|
|
113
292
|
return mode === "alert" ? (
|
|
114
|
-
<AlertDialogPrimitive.Portal
|
|
293
|
+
<AlertDialogPrimitive.Portal
|
|
294
|
+
data-slot="dialog-portal"
|
|
295
|
+
container={target}
|
|
296
|
+
{...props}
|
|
297
|
+
/>
|
|
115
298
|
) : (
|
|
116
|
-
<DialogPrimitive.Portal
|
|
299
|
+
<DialogPrimitive.Portal
|
|
300
|
+
data-slot="dialog-portal"
|
|
301
|
+
container={target}
|
|
302
|
+
{...props}
|
|
303
|
+
/>
|
|
117
304
|
);
|
|
118
305
|
}
|
|
119
306
|
|
|
@@ -163,6 +350,13 @@ export function DialogClose({
|
|
|
163
350
|
);
|
|
164
351
|
}
|
|
165
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Alerta em animação de saída deixa de receber clique: o Dialog que a resposta abre em seguida fica
|
|
355
|
+
* na faixa de baixo e seria coberto pelo véu do alerta até a animação terminar. O `!` vence o
|
|
356
|
+
* `pointer-events` que o Radix grava no estilo do véu e do conteúdo.
|
|
357
|
+
*/
|
|
358
|
+
const answerClosingClasses = "data-[state=closed]:pointer-events-none!";
|
|
359
|
+
|
|
166
360
|
/** Véu compartilhado por Dialog e Drawer: mesmo escurecimento e animação. */
|
|
167
361
|
export const overlayClasses =
|
|
168
362
|
"fixed inset-0 z-50 bg-black/30 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0";
|
|
@@ -172,17 +366,25 @@ export function DialogOverlay({
|
|
|
172
366
|
...props
|
|
173
367
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
|
174
368
|
const { mode } = useDialogContext();
|
|
175
|
-
const
|
|
369
|
+
const layer = useLayerPortal();
|
|
370
|
+
const internal = layer.container !== undefined || undefined;
|
|
176
371
|
return mode === "alert" ? (
|
|
177
372
|
<AlertDialogPrimitive.Overlay
|
|
178
373
|
data-slot="dialog-overlay"
|
|
179
|
-
|
|
374
|
+
{...{ [LAYER_MODAL_ATTRIBUTE]: internal }}
|
|
375
|
+
className={cn(
|
|
376
|
+
overlayClasses,
|
|
377
|
+
ANSWER_BAND_CLASS,
|
|
378
|
+
answerClosingClasses,
|
|
379
|
+
className,
|
|
380
|
+
)}
|
|
180
381
|
{...props}
|
|
181
382
|
/>
|
|
182
383
|
) : (
|
|
183
384
|
<DialogPrimitive.Overlay
|
|
184
385
|
data-slot="dialog-overlay"
|
|
185
|
-
|
|
386
|
+
{...{ [LAYER_MODAL_ATTRIBUTE]: internal }}
|
|
387
|
+
className={cn(overlayClasses, layer.zIndexClass, className)}
|
|
186
388
|
{...props}
|
|
187
389
|
/>
|
|
188
390
|
);
|
|
@@ -211,7 +413,11 @@ export function DialogContent({
|
|
|
211
413
|
layout = "sectioned",
|
|
212
414
|
"aria-describedby": ariaDescribedBy,
|
|
213
415
|
onEscapeKeyDown,
|
|
416
|
+
onPointerDownOutside,
|
|
214
417
|
onInteractOutside,
|
|
418
|
+
onCloseAutoFocus,
|
|
419
|
+
onKeyDown,
|
|
420
|
+
ref,
|
|
215
421
|
...props
|
|
216
422
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
217
423
|
/** Mostra a ação “Fechar” no modo padrão. O modo `alert` exige uma resposta identificável. */
|
|
@@ -226,7 +432,32 @@ export function DialogContent({
|
|
|
226
432
|
/** Anatomia da superfície: `sectioned` (default) ou `continuous` — ADR 0023. */
|
|
227
433
|
layout?: SurfaceLayout;
|
|
228
434
|
}) {
|
|
229
|
-
const { mode } = useDialogContext();
|
|
435
|
+
const { mode, open, modal } = useDialogContext();
|
|
436
|
+
const layer = useLayerPortal();
|
|
437
|
+
const internal = layer.container !== undefined;
|
|
438
|
+
// Seção 6 da ADR 0052: o alerta é resposta obrigatória; o modal portado para uma camada é
|
|
439
|
+
// interno a ela; os demais são de trabalho e convivem com a camada.
|
|
440
|
+
const mark: ModalMark =
|
|
441
|
+
mode === "alert" ? "answer" : internal ? "internal" : "work";
|
|
442
|
+
const entryRef = useModalLayerEntry({ open, modal, mark, surface: "dialog" });
|
|
443
|
+
const composedRef = useComposedRef(ref, entryRef);
|
|
444
|
+
const dismiss = composeModalDismissHandlers({
|
|
445
|
+
mark,
|
|
446
|
+
closeDisabled,
|
|
447
|
+
onEscapeKeyDown,
|
|
448
|
+
onPointerDownOutside,
|
|
449
|
+
onInteractOutside,
|
|
450
|
+
onCloseAutoFocus,
|
|
451
|
+
});
|
|
452
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
453
|
+
onKeyDown?.(event);
|
|
454
|
+
handleModalTabKey(event);
|
|
455
|
+
};
|
|
456
|
+
// Os portais abertos daqui herdam a marca de modal quando este Dialog mora numa camada.
|
|
457
|
+
const nestedPortal = React.useMemo(
|
|
458
|
+
() => ({ ...layer, modalSubtree: layer.modalSubtree || internal }),
|
|
459
|
+
[layer, internal],
|
|
460
|
+
);
|
|
230
461
|
const showClose = showCloseButton ?? mode !== "alert";
|
|
231
462
|
const contentClasses =
|
|
232
463
|
"fixed left-[50%] top-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-clip rounded-xl border border-transparent bg-background text-foreground shadow-xl duration-200 outline-none [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/70";
|
|
@@ -236,25 +467,45 @@ export function DialogContent({
|
|
|
236
467
|
<DialogPortal>
|
|
237
468
|
<DialogOverlay />
|
|
238
469
|
<AlertDialogPrimitive.Content
|
|
470
|
+
ref={composedRef}
|
|
239
471
|
data-slot="dialog-content"
|
|
240
472
|
data-mode="alert"
|
|
241
|
-
|
|
473
|
+
{...{ [LAYER_MODAL_ATTRIBUTE]: internal || undefined }}
|
|
474
|
+
className={cn(
|
|
475
|
+
contentClasses,
|
|
476
|
+
ANSWER_BAND_CLASS,
|
|
477
|
+
answerClosingClasses,
|
|
478
|
+
"grid max-w-xs gap-4 p-6",
|
|
479
|
+
className,
|
|
480
|
+
)}
|
|
242
481
|
aria-describedby={ariaDescribedBy}
|
|
243
|
-
|
|
482
|
+
// O `AlertDialog` do Radix cancela sozinho o clique fora; só o Esc é composto.
|
|
483
|
+
onEscapeKeyDown={dismiss.onEscapeKeyDown}
|
|
484
|
+
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
|
485
|
+
onKeyDown={onKeyDown}
|
|
244
486
|
{...props}
|
|
245
487
|
>
|
|
246
488
|
{/* Sem anatomia seccionada (exceção da ADR 0023), mas a altura continua sendo
|
|
247
489
|
decisão do diálogo: a cadeia `fill` da página não atravessa o portal. */}
|
|
248
490
|
<FillBoundary>
|
|
249
|
-
|
|
491
|
+
{/* Popover, Menu e Tooltip abertos daqui sobem para a faixa do alerta. */}
|
|
492
|
+
<LayerPortalProvider
|
|
250
493
|
value={{
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
494
|
+
container: layer.container,
|
|
495
|
+
zIndexClass: ANSWER_BAND_CLASS,
|
|
496
|
+
modalSubtree: internal,
|
|
254
497
|
}}
|
|
255
498
|
>
|
|
256
|
-
|
|
257
|
-
|
|
499
|
+
<DialogContentContext.Provider
|
|
500
|
+
value={{
|
|
501
|
+
showCloseButton: false,
|
|
502
|
+
closeDisabled: true,
|
|
503
|
+
closeSize,
|
|
504
|
+
}}
|
|
505
|
+
>
|
|
506
|
+
{children}
|
|
507
|
+
</DialogContentContext.Provider>
|
|
508
|
+
</LayerPortalProvider>
|
|
258
509
|
</FillBoundary>
|
|
259
510
|
</AlertDialogPrimitive.Content>
|
|
260
511
|
</DialogPortal>
|
|
@@ -265,26 +516,34 @@ export function DialogContent({
|
|
|
265
516
|
<DialogPortal>
|
|
266
517
|
<DialogOverlay />
|
|
267
518
|
<DialogPrimitive.Content
|
|
519
|
+
ref={composedRef}
|
|
268
520
|
data-slot="dialog-content"
|
|
269
521
|
data-mode="default"
|
|
270
522
|
data-layout={layout}
|
|
523
|
+
{...{ [LAYER_MODAL_ATTRIBUTE]: internal || undefined }}
|
|
271
524
|
className={cn(
|
|
272
525
|
contentClasses,
|
|
273
526
|
"flex max-h-[85vh] max-w-lg flex-col",
|
|
527
|
+
layer.zIndexClass,
|
|
274
528
|
className,
|
|
275
529
|
)}
|
|
276
530
|
aria-describedby={ariaDescribedBy}
|
|
277
|
-
onEscapeKeyDown={
|
|
278
|
-
|
|
531
|
+
onEscapeKeyDown={dismiss.onEscapeKeyDown}
|
|
532
|
+
onPointerDownOutside={dismiss.onPointerDownOutside}
|
|
533
|
+
onInteractOutside={dismiss.onInteractOutside}
|
|
534
|
+
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
|
535
|
+
onKeyDown={handleKeyDown}
|
|
279
536
|
{...props}
|
|
280
537
|
>
|
|
281
|
-
<
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
538
|
+
<LayerPortalProvider value={nestedPortal}>
|
|
539
|
+
<SurfaceLayoutProvider value={layout}>
|
|
540
|
+
<DialogContentContext.Provider
|
|
541
|
+
value={{ showCloseButton: showClose, closeDisabled, closeSize }}
|
|
542
|
+
>
|
|
543
|
+
{children}
|
|
544
|
+
</DialogContentContext.Provider>
|
|
545
|
+
</SurfaceLayoutProvider>
|
|
546
|
+
</LayerPortalProvider>
|
|
288
547
|
</DialogPrimitive.Content>
|
|
289
548
|
</DialogPortal>
|
|
290
549
|
);
|