@softize/opus 24.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +1 -1
  3. package/bin/cli.mjs +9 -0
  4. package/bin/lib/check.mjs +390 -1
  5. package/bin/lib/copy.mjs +39 -2
  6. package/bin/lib/gen-runner.mjs +25 -12
  7. package/bin/lib/mcp.mjs +1 -1
  8. package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
  9. package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
  10. package/docs/data-layer.md +31 -3
  11. package/docs/protocol.md +98 -2
  12. package/docs/releasing.md +8 -2
  13. package/docs/stacking-scale.md +53 -0
  14. package/package.json +6 -1
  15. package/src/ai/drivers/anthropic.ts +3 -0
  16. package/src/core/domain.ts +189 -7
  17. package/src/core/index.ts +19 -2
  18. package/src/core/retention.ts +493 -0
  19. package/src/core/runtime.ts +93 -29
  20. package/src/core/system-actor.ts +33 -0
  21. package/src/core/types.ts +12 -0
  22. package/src/data/drivers/kysely.ts +47 -5
  23. package/src/data/index.ts +24 -6
  24. package/src/mcp/index.ts +3 -2
  25. package/src/schema/entity.ts +30 -9
  26. package/src/schema/index.ts +4 -0
  27. package/src/schema/openapi.ts +4 -1
  28. package/src/ui/components/primitives/dialog.tsx +285 -26
  29. package/src/ui/components/primitives/drawer.tsx +104 -13
  30. package/src/ui/components/primitives/floating-layer.tsx +79 -0
  31. package/src/ui/components/primitives/menu.tsx +17 -2
  32. package/src/ui/components/primitives/popover.tsx +6 -1
  33. package/src/ui/components/primitives/tooltip.tsx +6 -1
  34. package/src/ui/docs/content/cli.md +5 -3
  35. package/src/ui/docs/content/data.md +42 -0
  36. package/src/ui/docs/content/dialog.md +3 -0
  37. package/src/ui/docs/content/drawer.md +3 -0
  38. package/src/ui/docs/content/floating-layer.md +178 -0
  39. package/src/ui/docs/content/scheduler.md +4 -0
  40. package/src/ui/docs/registry.tsx +6 -0
  41. package/src/ui/lib/dismiss-on-iframe.ts +4 -1
  42. package/src/ui/lib/modal-layers.ts +547 -0
  43. package/src/ui/meta.ts +6 -0
  44. package/src/ui/react.tsx +4 -0
@@ -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
  );
@@ -18,7 +18,19 @@ import {
18
18
  type StructuralMediaProps,
19
19
  } from "../patterns/structural-media.tsx";
20
20
  import { CloseButton, type CloseButtonSize } from "./close-button.tsx";
21
- import { overlayClasses, preventDismissWhen } from "./dialog.tsx";
21
+ import {
22
+ composeModalDismissHandlers,
23
+ overlayClasses,
24
+ useComposedRef,
25
+ useModalOpenState,
26
+ } from "./dialog.tsx";
27
+ import {
28
+ LAYER_MODAL_ATTRIBUTE,
29
+ LayerPortalProvider,
30
+ handleModalTabKey,
31
+ useLayerPortal,
32
+ useModalLayerEntry,
33
+ } from "../../lib/modal-layers.ts";
22
34
 
23
35
  interface DrawerContentContextValue {
24
36
  showCloseButton: boolean;
@@ -34,10 +46,36 @@ const DrawerContentContext = React.createContext<DrawerContentContextValue>({
34
46
 
35
47
  const DrawerIntroContext = React.createContext(false);
36
48
 
49
+ /** Estado de aberto que a pilha de modais da camada flutuante acompanha (ADR 0052). */
50
+ const DrawerStateContext = React.createContext({ open: false, modal: true });
51
+
37
52
  function Drawer({
53
+ open: openProp,
54
+ defaultOpen,
55
+ onOpenChange,
56
+ modal,
38
57
  ...props
39
58
  }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
40
- return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
59
+ const [open, setOpen] = useModalOpenState({
60
+ open: openProp,
61
+ defaultOpen,
62
+ onOpenChange,
63
+ });
64
+ const state = React.useMemo(
65
+ () => ({ open, modal: modal !== false }),
66
+ [open, modal],
67
+ );
68
+ return (
69
+ <DrawerStateContext.Provider value={state}>
70
+ <DrawerPrimitive.Root
71
+ data-slot="drawer"
72
+ modal={modal}
73
+ open={open}
74
+ onOpenChange={setOpen}
75
+ {...props}
76
+ />
77
+ </DrawerStateContext.Provider>
78
+ );
41
79
  }
42
80
 
43
81
  function DrawerTrigger({
@@ -53,19 +91,30 @@ function DrawerClose({
53
91
  }
54
92
 
55
93
  function DrawerPortal({
94
+ container,
56
95
  ...props
57
96
  }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
58
- return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
97
+ // Aberto de dentro de uma camada flutuante, o Drawer vai para o hospedeiro dela (ADR 0052).
98
+ const layer = useLayerPortal();
99
+ return (
100
+ <DrawerPrimitive.Portal
101
+ data-slot="drawer-portal"
102
+ container={container ?? layer.container}
103
+ {...props}
104
+ />
105
+ );
59
106
  }
60
107
 
61
108
  function DrawerOverlay({
62
109
  className,
63
110
  ...props
64
111
  }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
112
+ const layer = useLayerPortal();
65
113
  return (
66
114
  <DrawerPrimitive.Overlay
67
115
  data-slot="drawer-overlay"
68
- className={cn(overlayClasses, className)}
116
+ {...{ [LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined }}
117
+ className={cn(overlayClasses, layer.zIndexClass, className)}
69
118
  {...props}
70
119
  />
71
120
  );
@@ -95,7 +144,11 @@ function DrawerContent({
95
144
  layout = "sectioned",
96
145
  "aria-describedby": ariaDescribedBy,
97
146
  onEscapeKeyDown,
147
+ onPointerDownOutside,
98
148
  onInteractOutside,
149
+ onCloseAutoFocus,
150
+ onKeyDown,
151
+ ref,
99
152
  ...props
100
153
  }: React.ComponentProps<typeof DrawerPrimitive.Content> & {
101
154
  side?: "top" | "right" | "bottom" | "left";
@@ -109,12 +162,44 @@ function DrawerContent({
109
162
  /** Anatomia da superfície: `sectioned` (default) ou `continuous` — ADR 0023. */
110
163
  layout?: SurfaceLayout;
111
164
  }) {
165
+ const { open, modal } = React.useContext(DrawerStateContext);
166
+ const layer = useLayerPortal();
167
+ const internal = layer.container !== undefined;
168
+ // Seção 6 da ADR 0052: portado para uma camada, o Drawer é interno a ela; fora, é de trabalho.
169
+ const mark = internal ? "internal" : "work";
170
+ const entryRef = useModalLayerEntry({
171
+ open,
172
+ modal,
173
+ mark,
174
+ surface: "drawer",
175
+ side,
176
+ });
177
+ const composedRef = useComposedRef(ref, entryRef);
178
+ const dismiss = composeModalDismissHandlers({
179
+ mark,
180
+ closeDisabled,
181
+ onEscapeKeyDown,
182
+ onPointerDownOutside,
183
+ onInteractOutside,
184
+ onCloseAutoFocus,
185
+ });
186
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
187
+ onKeyDown?.(event);
188
+ handleModalTabKey(event);
189
+ };
190
+ // Os portais abertos daqui herdam a marca de modal quando este Drawer mora numa camada.
191
+ const nestedPortal = React.useMemo(
192
+ () => ({ ...layer, modalSubtree: layer.modalSubtree || internal }),
193
+ [layer, internal],
194
+ );
112
195
  return (
113
196
  <DrawerPortal>
114
197
  <DrawerOverlay />
115
198
  <DrawerPrimitive.Content
199
+ ref={composedRef}
116
200
  data-slot="drawer-content"
117
201
  data-layout={layout}
202
+ {...{ [LAYER_MODAL_ATTRIBUTE]: internal || undefined }}
118
203
  className={cn(
119
204
  "fixed z-50 flex flex-col overflow-hidden rounded-xl border bg-background shadow-lg transition ease-in-out [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
120
205
  side === "right" &&
@@ -125,20 +210,26 @@ function DrawerContent({
125
210
  "inset-x-1 top-1 h-auto data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
126
211
  side === "bottom" &&
127
212
  "inset-x-1 bottom-1 h-auto data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
213
+ layer.zIndexClass,
128
214
  className,
129
215
  )}
130
216
  aria-describedby={ariaDescribedBy}
131
- onEscapeKeyDown={preventDismissWhen(closeDisabled, onEscapeKeyDown)}
132
- onInteractOutside={preventDismissWhen(closeDisabled, onInteractOutside)}
217
+ onEscapeKeyDown={dismiss.onEscapeKeyDown}
218
+ onPointerDownOutside={dismiss.onPointerDownOutside}
219
+ onInteractOutside={dismiss.onInteractOutside}
220
+ onCloseAutoFocus={dismiss.onCloseAutoFocus}
221
+ onKeyDown={handleKeyDown}
133
222
  {...props}
134
223
  >
135
- <SurfaceLayoutProvider value={layout}>
136
- <DrawerContentContext.Provider
137
- value={{ showCloseButton, closeDisabled, closeSize }}
138
- >
139
- {children}
140
- </DrawerContentContext.Provider>
141
- </SurfaceLayoutProvider>
224
+ <LayerPortalProvider value={nestedPortal}>
225
+ <SurfaceLayoutProvider value={layout}>
226
+ <DrawerContentContext.Provider
227
+ value={{ showCloseButton, closeDisabled, closeSize }}
228
+ >
229
+ {children}
230
+ </DrawerContentContext.Provider>
231
+ </SurfaceLayoutProvider>
232
+ </LayerPortalProvider>
142
233
  </DrawerPrimitive.Content>
143
234
  </DrawerPortal>
144
235
  );
@@ -0,0 +1,79 @@
1
+ import * as React from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ import { cn } from "../../lib/cn.ts";
5
+ import {
6
+ LayerPortalProvider,
7
+ registerFloatingLayer,
8
+ } from "../../lib/modal-layers.ts";
9
+
10
+ export interface FloatingLayerProps {
11
+ children: React.ReactNode;
12
+ /** Classes do hospedeiro. A posição de cada filho continua sendo do consumidor. */
13
+ className?: string;
14
+ /**
15
+ * Esc enquanto a pessoa está na camada, com ou sem modal aberto. É chamado uma vez por tecla.
16
+ * Nenhum modal fecha nesse caso, e o Opus não fecha a camada sozinho, porque o estado de aberto
17
+ * pertence ao consumidor.
18
+ */
19
+ onEscapeKeyDown?: (event: KeyboardEvent) => void;
20
+ }
21
+
22
+ /**
23
+ * Hospedeiro fixo, na tela inteira e acima dos modais de trabalho. Ele mesmo não recebe clique;
24
+ * os filhos diretos recebem, mesmo com o `body` bloqueado por um modal.
25
+ */
26
+ const hostClasses =
27
+ "pointer-events-none fixed inset-0 z-60 *:pointer-events-auto";
28
+
29
+ /**
30
+ * Região da aplicação que fica acima do conteúdo e dos modais de trabalho (Dialog e Drawer) e
31
+ * continua utilizável enquanto eles estão abertos, como o botão e o painel de um assistente
32
+ * (ADR 0052).
33
+ *
34
+ * Os filhos são portados para `div[data-slot="floating-layer"]` no `body`. O hospedeiro publica o
35
+ * modal de trabalho do topo em `data-modal` (`none`, `dialog` ou `drawer`) e o lado do Drawer em
36
+ * `data-modal-side`, para o consumidor reposicionar o que precisar. Popover, Menu, Tooltip, Dialog
37
+ * e Drawer abertos de dentro da camada vão para o mesmo hospedeiro.
38
+ */
39
+ export function FloatingLayer({
40
+ children,
41
+ className,
42
+ onEscapeKeyDown,
43
+ }: FloatingLayerProps) {
44
+ const [host, setHost] = React.useState<HTMLElement | null>(null);
45
+ const escapeRef = React.useRef(onEscapeKeyDown);
46
+ escapeRef.current = onEscapeKeyDown;
47
+
48
+ React.useLayoutEffect(() => {
49
+ const element = document.createElement("div");
50
+ element.setAttribute("data-slot", "floating-layer");
51
+ document.body.appendChild(element);
52
+ const unregister = registerFloatingLayer(element, (event) =>
53
+ escapeRef.current?.(event),
54
+ );
55
+ setHost(element);
56
+ // Desfaz o registro, retira o hospedeiro e, se o foco estava nele com um modal aberto,
57
+ // devolve o foco ao modal.
58
+ return unregister;
59
+ }, []);
60
+
61
+ React.useLayoutEffect(() => {
62
+ if (host !== null) host.className = cn(hostClasses, className);
63
+ }, [host, className]);
64
+
65
+ const portal = React.useMemo(
66
+ () => ({
67
+ container: host ?? undefined,
68
+ zIndexClass: undefined,
69
+ modalSubtree: false,
70
+ }),
71
+ [host],
72
+ );
73
+
74
+ if (host === null) return null;
75
+ return createPortal(
76
+ <LayerPortalProvider value={portal}>{children}</LayerPortalProvider>,
77
+ host,
78
+ );
79
+ }