@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/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 **aborta a release
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.0.0",
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 context = React.useMemo(() => ({ mode, onResult }), [mode, onResult]);
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 data-slot="dialog" {...props} />
254
+ <AlertDialogPrimitive.Root
255
+ data-slot="dialog"
256
+ open={open}
257
+ onOpenChange={setOpen}
258
+ {...props}
259
+ />
91
260
  ) : (
92
- <DialogPrimitive.Root data-slot="dialog" modal={modal} {...props} />
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 data-slot="dialog-portal" {...props} />
293
+ <AlertDialogPrimitive.Portal
294
+ data-slot="dialog-portal"
295
+ container={target}
296
+ {...props}
297
+ />
115
298
  ) : (
116
- <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
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 overlay = cn(overlayClasses, className);
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
- className={overlay}
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
- className={overlay}
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
- className={cn(contentClasses, "grid max-w-xs gap-4 p-6", className)}
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
- onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
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
- <DialogContentContext.Provider
491
+ {/* Popover, Menu e Tooltip abertos daqui sobem para a faixa do alerta. */}
492
+ <LayerPortalProvider
250
493
  value={{
251
- showCloseButton: false,
252
- closeDisabled: true,
253
- closeSize,
494
+ container: layer.container,
495
+ zIndexClass: ANSWER_BAND_CLASS,
496
+ modalSubtree: internal,
254
497
  }}
255
498
  >
256
- {children}
257
- </DialogContentContext.Provider>
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={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
278
- onInteractOutside={preventDismissWhen(closeDisabled, onInteractOutside)}
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
- <SurfaceLayoutProvider value={layout}>
282
- <DialogContentContext.Provider
283
- value={{ showCloseButton: showClose, closeDisabled, closeSize }}
284
- >
285
- {children}
286
- </DialogContentContext.Provider>
287
- </SurfaceLayoutProvider>
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
  );