@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.
- package/CHANGELOG.md +121 -0
- package/README.md +1 -1
- package/bin/cli.mjs +9 -0
- package/bin/lib/check.mjs +390 -1
- package/bin/lib/copy.mjs +39 -2
- package/bin/lib/gen-runner.mjs +25 -12
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
- package/docs/data-layer.md +31 -3
- package/docs/protocol.md +98 -2
- package/docs/releasing.md +8 -2
- package/docs/stacking-scale.md +53 -0
- package/package.json +6 -1
- package/src/ai/drivers/anthropic.ts +3 -0
- package/src/core/domain.ts +189 -7
- package/src/core/index.ts +19 -2
- package/src/core/retention.ts +493 -0
- package/src/core/runtime.ts +93 -29
- package/src/core/system-actor.ts +33 -0
- package/src/core/types.ts +12 -0
- package/src/data/drivers/kysely.ts +47 -5
- package/src/data/index.ts +24 -6
- package/src/mcp/index.ts +3 -2
- package/src/schema/entity.ts +30 -9
- package/src/schema/index.ts +4 -0
- package/src/schema/openapi.ts +4 -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/cli.md +5 -3
- package/src/ui/docs/content/data.md +42 -0
- 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/scheduler.md +4 -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
|
@@ -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
|
);
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
132
|
-
|
|
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
|
-
<
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|