@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
|
@@ -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
|
+
}
|
|
@@ -5,6 +5,7 @@ import { DropdownMenu as MenuPrimitive } from "radix-ui";
|
|
|
5
5
|
|
|
6
6
|
import { useDismissOnIframeFocus } from "../../lib/dismiss-on-iframe.ts";
|
|
7
7
|
import { cn } from "../../lib/cn.ts";
|
|
8
|
+
import { layerModalMarker, useLayerPortal } from "../../lib/modal-layers.ts";
|
|
8
9
|
|
|
9
10
|
function Menu({ ...props }: React.ComponentProps<typeof MenuPrimitive.Root>) {
|
|
10
11
|
return <MenuPrimitive.Root data-slot="menu" {...props} />;
|
|
@@ -16,9 +17,17 @@ function Menu({ ...props }: React.ComponentProps<typeof MenuPrimitive.Root>) {
|
|
|
16
17
|
* próxima versão major.
|
|
17
18
|
*/
|
|
18
19
|
function MenuPortal({
|
|
20
|
+
container,
|
|
19
21
|
...props
|
|
20
22
|
}: React.ComponentProps<typeof MenuPrimitive.Portal>) {
|
|
21
|
-
|
|
23
|
+
const layer = useLayerPortal();
|
|
24
|
+
return (
|
|
25
|
+
<MenuPrimitive.Portal
|
|
26
|
+
data-slot="menu-portal"
|
|
27
|
+
container={container ?? layer.container}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
function MenuTrigger({
|
|
@@ -35,14 +44,18 @@ function MenuContent({
|
|
|
35
44
|
// Mesma razão do Popover: clique dentro de iframe não chega aqui fora.
|
|
36
45
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
37
46
|
useDismissOnIframeFocus(ref);
|
|
47
|
+
// Aberto de dentro de uma camada flutuante, vai para o hospedeiro dela (ADR 0052).
|
|
48
|
+
const layer = useLayerPortal();
|
|
38
49
|
return (
|
|
39
|
-
<MenuPrimitive.Portal>
|
|
50
|
+
<MenuPrimitive.Portal container={layer.container}>
|
|
40
51
|
<MenuPrimitive.Content
|
|
41
52
|
ref={ref}
|
|
42
53
|
data-slot="menu-content"
|
|
54
|
+
{...layerModalMarker(layer)}
|
|
43
55
|
sideOffset={sideOffset}
|
|
44
56
|
className={cn(
|
|
45
57
|
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[10rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md will-change-[transform,opacity] 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",
|
|
58
|
+
layer.zIndexClass,
|
|
46
59
|
className,
|
|
47
60
|
)}
|
|
48
61
|
{...props}
|
|
@@ -215,11 +228,13 @@ function MenuSubContent({
|
|
|
215
228
|
className,
|
|
216
229
|
...props
|
|
217
230
|
}: React.ComponentProps<typeof MenuPrimitive.SubContent>) {
|
|
231
|
+
const layer = useLayerPortal();
|
|
218
232
|
return (
|
|
219
233
|
<MenuPrimitive.SubContent
|
|
220
234
|
data-slot="menu-sub-content"
|
|
221
235
|
className={cn(
|
|
222
236
|
"z-50 min-w-[10rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md will-change-[transform,opacity] 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",
|
|
237
|
+
layer.zIndexClass,
|
|
223
238
|
className,
|
|
224
239
|
)}
|
|
225
240
|
{...props}
|
|
@@ -2,6 +2,7 @@ import * as React from "react"
|
|
|
2
2
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
|
3
3
|
|
|
4
4
|
import { useDismissOnIframeFocus } from "../../lib/dismiss-on-iframe.ts"
|
|
5
|
+
import { layerModalMarker, useLayerPortal } from "../../lib/modal-layers.ts"
|
|
5
6
|
import { cn } from '../../lib/cn.ts'
|
|
6
7
|
|
|
7
8
|
function Popover({
|
|
@@ -26,15 +27,19 @@ function PopoverContent({
|
|
|
26
27
|
// pendurado sobre o conteúdo. Na cabine, o preview É um iframe.
|
|
27
28
|
const ref = React.useRef<HTMLDivElement>(null)
|
|
28
29
|
useDismissOnIframeFocus(ref)
|
|
30
|
+
// Aberto de dentro de uma camada flutuante, vai para o hospedeiro dela (ADR 0052).
|
|
31
|
+
const layer = useLayerPortal()
|
|
29
32
|
return (
|
|
30
|
-
<PopoverPrimitive.Portal>
|
|
33
|
+
<PopoverPrimitive.Portal container={layer.container}>
|
|
31
34
|
<PopoverPrimitive.Content
|
|
32
35
|
ref={ref}
|
|
33
36
|
data-slot="popover-content"
|
|
37
|
+
{...layerModalMarker(layer)}
|
|
34
38
|
align={align}
|
|
35
39
|
sideOffset={sideOffset}
|
|
36
40
|
className={cn(
|
|
37
41
|
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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",
|
|
42
|
+
layer.zIndexClass,
|
|
38
43
|
className
|
|
39
44
|
)}
|
|
40
45
|
{...props}
|
|
@@ -2,6 +2,7 @@ import * as React from "react"
|
|
|
2
2
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
|
3
3
|
|
|
4
4
|
import { cn } from '../../lib/cn.ts'
|
|
5
|
+
import { layerModalMarker, useLayerPortal } from "../../lib/modal-layers.ts"
|
|
5
6
|
|
|
6
7
|
function TooltipProvider({
|
|
7
8
|
delayDuration = 0,
|
|
@@ -34,13 +35,17 @@ function TooltipContent({
|
|
|
34
35
|
children,
|
|
35
36
|
...props
|
|
36
37
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
|
38
|
+
// Aberto de dentro de uma camada flutuante, vai para o hospedeiro dela (ADR 0052).
|
|
39
|
+
const layer = useLayerPortal()
|
|
37
40
|
return (
|
|
38
|
-
<TooltipPrimitive.Portal>
|
|
41
|
+
<TooltipPrimitive.Portal container={layer.container}>
|
|
39
42
|
<TooltipPrimitive.Content
|
|
40
43
|
data-slot="tooltip-content"
|
|
44
|
+
{...layerModalMarker(layer)}
|
|
41
45
|
sideOffset={sideOffset}
|
|
42
46
|
className={cn(
|
|
43
47
|
"z-50 w-fit max-w-sm origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-pretty text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
|
48
|
+
layer.zIndexClass,
|
|
44
49
|
className
|
|
45
50
|
)}
|
|
46
51
|
{...props}
|
|
@@ -441,6 +441,9 @@ botão. O texto informa o efeito real, como `Excluir`, `Revogar acesso` ou `Arqu
|
|
|
441
441
|
- O comportamento selecionado por `mode` determina o papel de acessibilidade; `role` não configura
|
|
442
442
|
o comportamento.
|
|
443
443
|
- O foco permanece dentro do diálogo aberto e retorna ao gatilho após o fechamento.
|
|
444
|
+
- No modo padrão, uma [`FloatingLayer`](/ui/floating-layer), como o painel de um assistente,
|
|
445
|
+
continua utilizável com o diálogo aberto: o Tab passa por ela, o foco não é puxado de volta e o Esc
|
|
446
|
+
age onde a pessoa está. O modo `alert` cobre a camada até a resposta.
|
|
444
447
|
- `DialogHost` apresenta uma solicitação por vez para que cada resposta tenha um alvo inequívoco.
|
|
445
448
|
|
|
446
449
|
## Propriedades de Dialog
|
|
@@ -170,6 +170,9 @@ de continuar, use [`Dialog mode="alert"`](/ui/dialog), que não fecha pelo cliqu
|
|
|
170
170
|
- Com `modal` ativo, o padrão, o foco permanece dentro do painel e o restante da página fica
|
|
171
171
|
inacessível até o fechamento.
|
|
172
172
|
- Ao fechar, o foco retorna ao gatilho.
|
|
173
|
+
- Uma [`FloatingLayer`](/ui/floating-layer), como o painel de um assistente, continua utilizável
|
|
174
|
+
com o painel lateral aberto: o Tab passa por ela, o foco não é puxado de volta e o Esc age onde a
|
|
175
|
+
pessoa está. Ela recebe o lado do Drawer em `data-modal-side` para sair da frente dele.
|
|
173
176
|
- O botão de fechar tem o nome acessível `Fechar`. Botões só com ícone no cabeçalho precisam de
|
|
174
177
|
`aria-label`.
|
|
175
178
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Use `FloatingLayer` para uma região que acompanha a aplicação inteira e precisa continuar utilizável
|
|
2
|
+
por cima de qualquer `Dialog` ou `Drawer`, como o botão e o painel de um assistente. Quem está
|
|
3
|
+
preenchendo um formulário em um modal pode abrir o painel, perguntar e voltar ao formulário, que
|
|
4
|
+
continua aberto e com o que já foi digitado.
|
|
5
|
+
|
|
6
|
+
No exemplo, abra o formulário, digite um nome e use o assistente no canto inferior direito. O modal
|
|
7
|
+
não fecha, e fechar o painel devolve o foco ao campo em que você estava.
|
|
8
|
+
|
|
9
|
+
```tsx preview
|
|
10
|
+
function AssistantDemo() {
|
|
11
|
+
const [editing, setEditing] = useState(false);
|
|
12
|
+
const [assistant, setAssistant] = useState(false);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<Dialog
|
|
17
|
+
open={editing}
|
|
18
|
+
onOpenChange={(open) => {
|
|
19
|
+
setEditing(open);
|
|
20
|
+
if (!open) setAssistant(false);
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
<DialogTrigger asChild>
|
|
24
|
+
<Button variant="outline">Editar cliente</Button>
|
|
25
|
+
</DialogTrigger>
|
|
26
|
+
<DialogContent>
|
|
27
|
+
<DialogHeader>
|
|
28
|
+
<DialogTitle>Editar cliente</DialogTitle>
|
|
29
|
+
</DialogHeader>
|
|
30
|
+
<DialogBody>
|
|
31
|
+
<Input aria-label="Nome do cliente" placeholder="Nome do cliente" />
|
|
32
|
+
</DialogBody>
|
|
33
|
+
<DialogFooter>
|
|
34
|
+
<DialogClose asChild>
|
|
35
|
+
<Button>Salvar</Button>
|
|
36
|
+
</DialogClose>
|
|
37
|
+
</DialogFooter>
|
|
38
|
+
</DialogContent>
|
|
39
|
+
</Dialog>
|
|
40
|
+
{editing && (
|
|
41
|
+
<FloatingLayer onEscapeKeyDown={() => setAssistant(false)}>
|
|
42
|
+
{assistant ? (
|
|
43
|
+
<aside
|
|
44
|
+
aria-label="Assistente"
|
|
45
|
+
className="absolute right-4 bottom-4 flex w-80 flex-col gap-3 rounded-xl border border-border bg-background p-4 shadow-lg"
|
|
46
|
+
>
|
|
47
|
+
<p className="text-sm">Pergunte sobre o cliente que você está editando.</p>
|
|
48
|
+
<Textarea aria-label="Mensagem" placeholder="Sua pergunta" />
|
|
49
|
+
<Button variant="ghost" onClick={() => setAssistant(false)}>
|
|
50
|
+
Fechar o assistente
|
|
51
|
+
</Button>
|
|
52
|
+
</aside>
|
|
53
|
+
) : (
|
|
54
|
+
<Button className="absolute right-4 bottom-4" onClick={() => setAssistant(true)}>
|
|
55
|
+
Abrir o assistente
|
|
56
|
+
</Button>
|
|
57
|
+
)}
|
|
58
|
+
</FloatingLayer>
|
|
59
|
+
)}
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
render(<AssistantDemo />);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
O exemplo monta a camada só enquanto o formulário está aberto para não deixar um botão fixo nesta
|
|
68
|
+
página. Em uma aplicação, a camada costuma ficar montada o tempo todo, na raiz.
|
|
69
|
+
|
|
70
|
+
## Posicionar os filhos
|
|
71
|
+
|
|
72
|
+
`FloatingLayer` porta os filhos para um hospedeiro próprio, `div[data-slot="floating-layer"]`, no
|
|
73
|
+
fim do `body`. O hospedeiro ocupa a janela inteira, fica acima dos modais e não recebe clique; cada
|
|
74
|
+
filho direto recebe. Por isso, posicione cada filho com `absolute` contra a janela, como `right-4
|
|
75
|
+
bottom-4`, e não espere que ele siga a moldura da página.
|
|
76
|
+
|
|
77
|
+
Com um modal aberto, o hospedeiro informa qual `Dialog` ou `Drawer` está no topo. Diálogos que exigem
|
|
78
|
+
resposta não entram nesse sinal, porque cobrem a camada enquanto estão abertos:
|
|
79
|
+
|
|
80
|
+
- `data-modal` vale `none`, `dialog` ou `drawer`;
|
|
81
|
+
- `data-modal-side` traz o lado do Drawer: `left`, `right`, `top` ou `bottom`.
|
|
82
|
+
|
|
83
|
+
O Opus não desloca nem redimensiona modais por causa da camada. Use esses atributos para tirar o
|
|
84
|
+
painel de cima do que a pessoa precisa ver. Um Drawer abre, por padrão, do lado direito, o mesmo
|
|
85
|
+
canto do botão do exemplo; com a variante `in-data-*` do Tailwind, o painel troca de lado enquanto
|
|
86
|
+
esse Drawer estiver aberto:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
<aside
|
|
90
|
+
aria-label="Assistente"
|
|
91
|
+
className="absolute right-4 bottom-4 in-data-[modal-side=right]:right-auto in-data-[modal-side=right]:left-4"
|
|
92
|
+
>
|
|
93
|
+
…
|
|
94
|
+
</aside>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Podem existir várias camadas ao mesmo tempo. A que monta depois fica por cima.
|
|
98
|
+
|
|
99
|
+
## O que muda enquanto um modal está aberto
|
|
100
|
+
|
|
101
|
+
Sem nenhum modal aberto, a camada é uma região comum da página. Com um `Dialog` ou `Drawer` aberto,
|
|
102
|
+
o modal passa a tratar a camada como parte de dentro dele:
|
|
103
|
+
|
|
104
|
+
| Aspecto | Comportamento |
|
|
105
|
+
| --------------- | ------------------------------------------------------------------------------------------------------ |
|
|
106
|
+
| Clique | A camada recebe clique e não fecha o modal, mesmo que o elemento clicado suma no próprio clique. |
|
|
107
|
+
| Véu | Clicar no véu continua fechando o modal, respeitando `closeDisabled`. |
|
|
108
|
+
| Foco | O foco pode ir para a camada por clique ou por código e não é puxado de volta. |
|
|
109
|
+
| Tab | Depois do último elemento do modal vem a camada; depois da camada, o primeiro elemento do modal. |
|
|
110
|
+
| Rolagem | A camada rola; a página atrás continua travada. |
|
|
111
|
+
| Leitor de tela | O modal e a camada ficam visíveis; o resto da página continua escondido. |
|
|
112
|
+
|
|
113
|
+
Quando o painel fecha com o foco dentro dele, ou a camada inteira sai da tela, o foco volta ao
|
|
114
|
+
elemento do modal que a pessoa usava antes de entrar na camada. Se esse elemento não existir mais,
|
|
115
|
+
vai para o primeiro elemento do modal. No sentido inverso, um modal que fecha enquanto a pessoa
|
|
116
|
+
digita na camada não leva o foco de volta ao botão que o abriu: o foco fica na camada.
|
|
117
|
+
|
|
118
|
+
Os patterns construídos sobre `Dialog` e `Drawer`, como [`ActionFormDialog`](/ui/action-form-dialog),
|
|
119
|
+
[`ActionListDialog`](/ui/action-list-dialog) e [`Presentation`](/ui/presentation), ganham esse
|
|
120
|
+
comportamento sem nenhuma configuração.
|
|
121
|
+
|
|
122
|
+
## Esc
|
|
123
|
+
|
|
124
|
+
O Esc age onde a pessoa está. Com o foco na camada, ou depois de um clique em uma área do painel que
|
|
125
|
+
não recebe foco, nenhum modal fecha e o Opus chama `onEscapeKeyDown` uma vez por tecla. Com o foco
|
|
126
|
+
no modal, o Esc fecha o modal, como sempre. Sem modal aberto, `onEscapeKeyDown` também é chamado
|
|
127
|
+
quando a pessoa está na camada.
|
|
128
|
+
|
|
129
|
+
Trate o Esc da camada só por `onEscapeKeyDown`. Para impedir que o modal feche, o Opus marca o evento
|
|
130
|
+
com `preventDefault` antes de ele chegar aos elementos da camada; um `onKeyDown` próprio para Esc no
|
|
131
|
+
painel trataria a tecla duas vezes ou leria um evento já marcado.
|
|
132
|
+
|
|
133
|
+
Um popover ou menu aberto dentro da camada fecha primeiro, e só o Esc seguinte chega a ela. O mesmo
|
|
134
|
+
vale, no sentido inverso, para um popover aberto dentro do modal: ele fecha antes, mesmo com a
|
|
135
|
+
pessoa na camada.
|
|
136
|
+
|
|
137
|
+
## Conteúdo aberto de dentro da camada
|
|
138
|
+
|
|
139
|
+
`Popover`, `Menu`, `Tooltip`, `Select`, `Dialog` e `Drawer` abertos de dentro da camada vão para o
|
|
140
|
+
hospedeiro dela. Assim, o menu de uma conversa abre por cima do painel que o abriu, e não por baixo
|
|
141
|
+
dele.
|
|
142
|
+
|
|
143
|
+
Um `Dialog` ou `Drawer` aberto de dentro da camada é modal para tudo, inclusive para a própria
|
|
144
|
+
camada: prende o foco, fecha pelo véu e pelo Esc e não chama o `onEscapeKeyDown` da camada.
|
|
145
|
+
|
|
146
|
+
## Diálogos de resposta obrigatória
|
|
147
|
+
|
|
148
|
+
`Dialog mode="alert"` e as confirmações de [`dialog.confirm`](/ui/dialog) exigem uma resposta antes
|
|
149
|
+
de qualquer outra coisa. Enquanto um deles está aberto, o véu dele cobre a camada, o foco não chega
|
|
150
|
+
a ela e o leitor de tela a esconde. Isso vale também para um `Dialog` aberto de dentro do alerta.
|
|
151
|
+
Ao responder, a camada volta a funcionar com o modal que estava aberto antes.
|
|
152
|
+
|
|
153
|
+
## Rolagem no toque
|
|
154
|
+
|
|
155
|
+
Num arrasto de toque, a rolagem que chega ao fim de uma área do painel pode passar a rolar a página
|
|
156
|
+
atrás do modal. Aplique `overscroll-contain` nas áreas roláveis da camada, como o histórico de uma
|
|
157
|
+
conversa. A propriedade só age no contêiner que rola, por isso o Opus não consegue aplicá-la por
|
|
158
|
+
você.
|
|
159
|
+
|
|
160
|
+
## Acessibilidade
|
|
161
|
+
|
|
162
|
+
- O hospedeiro não tem papel ARIA. Dê semântica aos filhos, como um `aside` com `aria-label` para o
|
|
163
|
+
painel e um nome claro para o botão que o abre.
|
|
164
|
+
- O hospedeiro carrega `aria-live="off"`, o valor padrão do atributo: a camada não anuncia mudanças
|
|
165
|
+
por conta própria, e regiões vivas de dentro dela, como a de um [`Ask`](/ui/ask), mantêm o próprio
|
|
166
|
+
comportamento. O atributo é o que mantém a camada visível ao leitor de tela com um modal aberto.
|
|
167
|
+
- O foco e o Tab descritos acima são verificados em Chromium por testes automatizados. Safari,
|
|
168
|
+
Firefox e a leitura com VoiceOver ainda não foram verificados: o roteiro manual da ADR 0052 roda
|
|
169
|
+
na adoção pela Grand Brasil, em Safari com VoiceOver e no Firefox, antes de ela chegar à versão
|
|
170
|
+
principal da plataforma. Leitores de tela do Windows, como o NVDA, ficam fora dessa verificação.
|
|
171
|
+
|
|
172
|
+
## Propriedades de FloatingLayer
|
|
173
|
+
|
|
174
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
175
|
+
| ----------------- | -------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
176
|
+
| `children` | `ReactNode` | | Elementos da camada. Cada filho direto recebe clique e se posiciona contra a janela. |
|
|
177
|
+
| `className` | `string` | | Classes do hospedeiro. A posição de cada filho continua sendo sua. |
|
|
178
|
+
| `onEscapeKeyDown` | `(event: KeyboardEvent) => void` | | Chamado uma vez por tecla quando a pessoa aperta Esc na camada, com ou sem modal aberto. Nenhum modal fecha nesse caso, e o Opus não fecha a camada. |
|
package/src/ui/docs/registry.tsx
CHANGED
|
@@ -78,6 +78,7 @@ import detailMd from "./content/detail.md?raw";
|
|
|
78
78
|
import dictionaryValueMd from "./content/dictionary-value.md?raw";
|
|
79
79
|
import emptyValueMd from "./content/empty-value.md?raw";
|
|
80
80
|
import drawerMd from "./content/drawer.md?raw";
|
|
81
|
+
import floatingLayerMd from "./content/floating-layer.md?raw";
|
|
81
82
|
import menuMd from "./content/menu.md?raw";
|
|
82
83
|
import emptyMd from "./content/empty.md?raw";
|
|
83
84
|
import fieldMd from "./content/field.md?raw";
|
|
@@ -793,6 +794,11 @@ export const UI_SECTIONS: DocSection[] = [
|
|
|
793
794
|
title: "Drawer",
|
|
794
795
|
render: comp("Drawer", "drawer", drawerMd),
|
|
795
796
|
},
|
|
797
|
+
{
|
|
798
|
+
slug: "floating-layer",
|
|
799
|
+
title: "Floating Layer",
|
|
800
|
+
render: comp("Floating Layer", "floating-layer", floatingLayerMd),
|
|
801
|
+
},
|
|
796
802
|
{ slug: "menu", title: "Menu", render: comp("Menu", "menu", menuMd) },
|
|
797
803
|
{
|
|
798
804
|
slug: "popover",
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Fecha
|
|
4
|
+
* Fecha um popover ou menu aberto quando o clique cai DENTRO de um iframe.
|
|
5
5
|
*
|
|
6
6
|
* O navegador entrega o clique ao documento do iframe, e o documento de fora nunca fica
|
|
7
7
|
* sabendo. Como toda dispensa por "clique fora" escuta eventos do documento de fora, o
|
|
8
8
|
* popover/menu simplesmente NÃO fecha — fica pendurado sobre o conteúdo, e a pessoa
|
|
9
9
|
* precisa achar um pedaço de página livre pra clicar.
|
|
10
10
|
*
|
|
11
|
+
* Não confundir com a camada flutuante (`FloatingLayer`, ADR 0052), a região que convive com
|
|
12
|
+
* Dialog e Drawer modais; aqui o assunto são sobreposições transitórias presas a um gatilho.
|
|
13
|
+
*
|
|
11
14
|
* Não é caso de borda pra nós: a cabine embute o preview num iframe, então metade da tela
|
|
12
15
|
* é justamente a área onde clicar não fecha nada.
|
|
13
16
|
*
|