@akinon/pz-theme 2.0.58-rc.0 → 2.0.58
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 +11 -31
- package/package.json +4 -4
- package/src/blocks/button-block.tsx +23 -1
- package/src/blocks/link-block.tsx +21 -10
- package/src/blocks/panel-block.tsx +67 -237
- package/src/chrome/__tests__/theme-config-source.test.ts +53 -7
- package/src/chrome/placeholder-slugs.ts +41 -0
- package/src/chrome/theme-config-source.ts +17 -2
- package/src/components/with-designer-features.tsx +49 -2
- package/src/hooks/use-block-action.ts +209 -0
- package/src/index.ts +51 -1
- package/src/overlays/__tests__/modal-preview-host.test.tsx +316 -0
- package/src/overlays/__tests__/modal-registry.test.ts +205 -0
- package/src/overlays/__tests__/modal-triggers.test.tsx +272 -0
- package/src/overlays/__tests__/overlay-bus.test.tsx +197 -0
- package/src/overlays/__tests__/theme-modal-mount.test.tsx +234 -0
- package/src/overlays/modal-dom-bridge.ts +104 -0
- package/src/overlays/modal-frequency.ts +104 -0
- package/src/overlays/modal-host-beacon.tsx +35 -0
- package/src/overlays/modal-preview-host.tsx +159 -0
- package/src/overlays/modal-registry.ts +289 -0
- package/src/overlays/overlay-bus.ts +214 -0
- package/src/overlays/overlay-surface.tsx +386 -0
- package/src/overlays/theme-modal-mount.tsx +179 -0
- package/src/overlays/theme-modals.tsx +82 -0
- package/src/overlays/use-editor-modals.ts +109 -0
- package/src/overlays/use-modal-auto-trigger.ts +187 -0
- package/src/overlays/use-modal-preview.ts +127 -0
- package/src/overlays/use-overlay-breakpoint.ts +68 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react';
|
|
4
|
+
import { devWarn } from '../utils/dev-warn';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which composed modals are open, for the whole page.
|
|
8
|
+
*
|
|
9
|
+
* A modal is opened by something that is not inside it — a header button, a
|
|
10
|
+
* footer link, a PDP badge, a timer. Those live in DIFFERENT React roots:
|
|
11
|
+
* every placeholder on the page mounts its own `ThemePlaceholderWrapper`, so
|
|
12
|
+
* header and body share no provider, no context and no state. Passing a
|
|
13
|
+
* callback down is not merely awkward there, it is impossible.
|
|
14
|
+
*
|
|
15
|
+
* Module state is the one channel that crosses those roots, which is exactly
|
|
16
|
+
* the reasoning `use-overlay.ts` already applies to its overlay stack — and the
|
|
17
|
+
* two are deliberately separate concerns. This module answers *which modals
|
|
18
|
+
* the page wants open*; `useOverlay` answers *what an open overlay does to the
|
|
19
|
+
* page* (focus, scroll, `aria-modal`, Escape). The modal surface subscribes
|
|
20
|
+
* here and hands the answer to `useOverlay`, so the stack, the focus trap and
|
|
21
|
+
* the scroll lock stay in one place for every overlay in the system.
|
|
22
|
+
*
|
|
23
|
+
* // trigger, anywhere on the page
|
|
24
|
+
* openModal('newsletter');
|
|
25
|
+
*
|
|
26
|
+
* // the surface
|
|
27
|
+
* const open = useModalOpen(modal.id);
|
|
28
|
+
* const overlay = useOverlay({ open, onClose: () => closeModal(modal.id) });
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Open modal ids, oldest first. An id appears AT MOST ONCE, and that is the
|
|
33
|
+
* loop guard: a trigger inside modal A that points at A — or a chain A → B → A,
|
|
34
|
+
* which is the same bug one indirection further out — finds A already open and
|
|
35
|
+
* stops, instead of pushing forever.
|
|
36
|
+
*/
|
|
37
|
+
let openModalIds: string[] = [];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A page cannot meaningfully stack this many dialogs, so passing it means
|
|
41
|
+
* something is opening modals in a loop the id guard cannot see (an effect that
|
|
42
|
+
* re-opens on every render, say). Refusing keeps the tab alive and names the
|
|
43
|
+
* modal that tipped it over.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_OPEN_MODALS = 8;
|
|
46
|
+
|
|
47
|
+
const listeners = new Set<() => void>();
|
|
48
|
+
|
|
49
|
+
const notify = () => {
|
|
50
|
+
listeners.forEach((listener) => listener());
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const subscribeToModals = (listener: () => void): (() => void) => {
|
|
54
|
+
listeners.add(listener);
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
listeners.delete(listener);
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Mutations are refused outside the browser.
|
|
63
|
+
*
|
|
64
|
+
* Module state on a server is per PROCESS, not per request: one request opening
|
|
65
|
+
* a modal here would leave it open for the next visitor's render, on a server
|
|
66
|
+
* that serves everyone. Nothing legitimate calls these outside an event handler
|
|
67
|
+
* or an effect, so the guard costs nothing and closes a cross-request leak that
|
|
68
|
+
* would be very hard to see.
|
|
69
|
+
*/
|
|
70
|
+
const canMutate = () => typeof window !== 'undefined';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `<ThemeModals />` is mounted, so a trigger that reaches nothing can
|
|
74
|
+
* say why. The host is one line in the brand's root layout, and forgetting it
|
|
75
|
+
* otherwise looks exactly like a broken trigger.
|
|
76
|
+
*/
|
|
77
|
+
let mountedHosts = 0;
|
|
78
|
+
|
|
79
|
+
export const registerModalHost = (): (() => void) => {
|
|
80
|
+
mountedHosts += 1;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The disposer releases ITS OWN registration and only once.
|
|
84
|
+
*
|
|
85
|
+
* A plain decrement would be called twice under React StrictMode, which
|
|
86
|
+
* double-invokes effect cleanups in development — the count would fall below
|
|
87
|
+
* the number of live hosts and every trigger would warn about a host that is
|
|
88
|
+
* mounted and working.
|
|
89
|
+
*/
|
|
90
|
+
let released = false;
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
if (released) return;
|
|
94
|
+
|
|
95
|
+
released = true;
|
|
96
|
+
mountedHosts = Math.max(0, mountedHosts - 1);
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const isModalHostMounted = (): boolean => mountedHosts > 0;
|
|
101
|
+
|
|
102
|
+
export const getOpenModalIds = (): readonly string[] => openModalIds;
|
|
103
|
+
|
|
104
|
+
export const isModalOpen = (id: string): boolean => openModalIds.includes(id);
|
|
105
|
+
|
|
106
|
+
export const openModal = (id: string): void => {
|
|
107
|
+
if (!canMutate() || !id) return;
|
|
108
|
+
|
|
109
|
+
// Already open: the no-op IS the loop guard. See `openModalIds`.
|
|
110
|
+
if (openModalIds.includes(id)) return;
|
|
111
|
+
|
|
112
|
+
if (!isModalHostMounted()) {
|
|
113
|
+
devWarn(
|
|
114
|
+
'overlayBus',
|
|
115
|
+
`Nothing can show modal "${id}": <ThemeModals /> is not mounted. Add it once to the brand's root layout, next to the footer slot.`
|
|
116
|
+
);
|
|
117
|
+
// Still queued: the host may simply not have mounted yet this tick, and
|
|
118
|
+
// refusing would turn a race into a dead trigger.
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (openModalIds.length >= MAX_OPEN_MODALS) {
|
|
122
|
+
devWarn(
|
|
123
|
+
'overlayBus',
|
|
124
|
+
`Refusing to open modal "${id}": ${MAX_OPEN_MODALS} modals are already open, which means something is opening them in a loop`
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
openModalIds = [...openModalIds, id];
|
|
130
|
+
notify();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const closeModal = (id: string): void => {
|
|
134
|
+
if (!canMutate() || !openModalIds.includes(id)) return;
|
|
135
|
+
|
|
136
|
+
openModalIds = openModalIds.filter((openId) => openId !== id);
|
|
137
|
+
notify();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const toggleModal = (id: string): void => {
|
|
141
|
+
if (isModalOpen(id)) {
|
|
142
|
+
closeModal(id);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
openModal(id);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Close the most recently opened modal — what a generic "close" control means. */
|
|
150
|
+
export const closeTopModal = (): void => {
|
|
151
|
+
const top = openModalIds[openModalIds.length - 1];
|
|
152
|
+
if (top) closeModal(top);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const closeAllModals = (): void => {
|
|
156
|
+
if (!canMutate() || openModalIds.length === 0) return;
|
|
157
|
+
|
|
158
|
+
openModalIds = [];
|
|
159
|
+
notify();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Nothing is open during SSR.
|
|
164
|
+
*
|
|
165
|
+
* This is the answer the server snapshot must give even if the module state
|
|
166
|
+
* somehow says otherwise: a modal opened during render would put a focus trap
|
|
167
|
+
* and an `aria-modal` claim into the serialized markup, and the client — which
|
|
168
|
+
* starts from an empty page-load state — would hydrate into a different tree.
|
|
169
|
+
* Auto-triggers therefore fire from an effect, never during render.
|
|
170
|
+
*/
|
|
171
|
+
const closedOnServer = () => false;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Subscription for a caller with no modal to watch.
|
|
175
|
+
*
|
|
176
|
+
* `useBlockAction` runs on EVERY block on the page — hooks cannot be called
|
|
177
|
+
* conditionally — and almost none of them target a modal. Handing those the
|
|
178
|
+
* real subscribe would put a listener per block in the set and walk all of
|
|
179
|
+
* them on every open, so a page with hundreds of blocks would pay for a
|
|
180
|
+
* feature it is not using.
|
|
181
|
+
*/
|
|
182
|
+
const neverNotifies = () => () => undefined;
|
|
183
|
+
|
|
184
|
+
const alwaysClosed = () => false;
|
|
185
|
+
|
|
186
|
+
/** Pass an empty id for "nothing to watch" — it costs no subscription. */
|
|
187
|
+
export const useModalOpen = (id: string): boolean =>
|
|
188
|
+
useSyncExternalStore(
|
|
189
|
+
id ? subscribeToModals : neverNotifies,
|
|
190
|
+
id ? () => openModalIds.includes(id) : alwaysClosed,
|
|
191
|
+
closedOnServer
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stable empty array for the server snapshot: `useSyncExternalStore` compares
|
|
196
|
+
* snapshots by reference and a fresh `[]` on every call is an infinite loop.
|
|
197
|
+
* The live snapshot is safe for the same reason — `openModalIds` is replaced
|
|
198
|
+
* only when it actually changes, never mutated in place.
|
|
199
|
+
*/
|
|
200
|
+
const NO_OPEN_MODALS: readonly string[] = Object.freeze([]);
|
|
201
|
+
|
|
202
|
+
export const useOpenModalIds = (): readonly string[] =>
|
|
203
|
+
useSyncExternalStore(
|
|
204
|
+
subscribeToModals,
|
|
205
|
+
getOpenModalIds,
|
|
206
|
+
() => NO_OPEN_MODALS
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
/** Test-only: drop all state so cases cannot leak into each other. */
|
|
210
|
+
export const __resetOverlayBusForTests = (): void => {
|
|
211
|
+
openModalIds = [];
|
|
212
|
+
listeners.clear();
|
|
213
|
+
mountedHosts = 0;
|
|
214
|
+
};
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { partAttrs } from '../utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The overlay surface: how a panel arrives over the page, and what it looks
|
|
8
|
+
* like when it does.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from `panel-block.tsx` so the composed modal host and the panel
|
|
11
|
+
* block draw the SAME thing. Two consumers of one visual language is exactly
|
|
12
|
+
* the situation that produced three incompatible focus traps before
|
|
13
|
+
* `use-overlay.ts` unified them, and geometry is the next thing that would
|
|
14
|
+
* have drifted: "what does a bottom sheet look like" must not have two
|
|
15
|
+
* answers.
|
|
16
|
+
*
|
|
17
|
+
* What lives here is the surface. What lives in `use-overlay.ts` is the page:
|
|
18
|
+
* focus, scroll lock, `aria-modal`, Escape. A consumer wires both.
|
|
19
|
+
*
|
|
20
|
+
* The data attributes are the caller's, because the panel block's published
|
|
21
|
+
* markup carries `data-size-guide-*` names that live storefronts style and
|
|
22
|
+
* `panel-legacy-golden.json` pins byte for byte. New callers pass their own.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type OverlayPresentation = 'modal' | 'drawer' | 'sheet' | 'anchored';
|
|
26
|
+
|
|
27
|
+
export const OVERLAY_PRESENTATIONS: OverlayPresentation[] = [
|
|
28
|
+
'modal',
|
|
29
|
+
'drawer',
|
|
30
|
+
'sheet',
|
|
31
|
+
'anchored'
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const clampOverlayNumber = (
|
|
35
|
+
value: unknown,
|
|
36
|
+
fallback: number,
|
|
37
|
+
min: number,
|
|
38
|
+
max: number
|
|
39
|
+
): number => {
|
|
40
|
+
const numeric = Math.round(Number(value));
|
|
41
|
+
if (!Number.isFinite(numeric)) return fallback;
|
|
42
|
+
|
|
43
|
+
return Math.min(max, Math.max(min, numeric));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The panel's own box, before geometry places it. */
|
|
47
|
+
export const overlayPanelSurface: React.CSSProperties = {
|
|
48
|
+
overflow: 'auto',
|
|
49
|
+
padding: '24px',
|
|
50
|
+
background: '#ffffff',
|
|
51
|
+
color: '#111827',
|
|
52
|
+
textAlign: 'left',
|
|
53
|
+
outline: 'none'
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface OverlayGeometryInput {
|
|
57
|
+
presentation: OverlayPresentation;
|
|
58
|
+
/** Drawer/anchored width and the centred modal's max width, in px. */
|
|
59
|
+
width: number;
|
|
60
|
+
panelSide?: string;
|
|
61
|
+
/** Sheet height as a percentage of the viewport. */
|
|
62
|
+
sheetHeight?: number;
|
|
63
|
+
anchorAlign?: 'start' | 'center' | 'end';
|
|
64
|
+
anchorOffset?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Where the panel sits for a given presentation.
|
|
69
|
+
*
|
|
70
|
+
* Anchored is the odd one: it hangs off the block's own root rather than a
|
|
71
|
+
* fixed layer, because a dropdown has to travel with the trigger that opened
|
|
72
|
+
* it. Everything else lives on a layer over the page.
|
|
73
|
+
*/
|
|
74
|
+
export const resolveOverlayPanelGeometry = ({
|
|
75
|
+
presentation,
|
|
76
|
+
width,
|
|
77
|
+
panelSide = 'right',
|
|
78
|
+
sheetHeight = 60,
|
|
79
|
+
anchorAlign = 'start',
|
|
80
|
+
anchorOffset = 8
|
|
81
|
+
}: OverlayGeometryInput): React.CSSProperties => {
|
|
82
|
+
if (presentation === 'drawer') {
|
|
83
|
+
return {
|
|
84
|
+
position: 'absolute',
|
|
85
|
+
top: 0,
|
|
86
|
+
bottom: 0,
|
|
87
|
+
[panelSide === 'left' ? 'left' : 'right']: 0,
|
|
88
|
+
width: `min(${width}px, 92vw)`,
|
|
89
|
+
boxShadow: '0 0 24px rgba(0, 0, 0, 0.2)'
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (presentation === 'sheet') {
|
|
94
|
+
return {
|
|
95
|
+
position: 'absolute',
|
|
96
|
+
left: 0,
|
|
97
|
+
right: 0,
|
|
98
|
+
bottom: 0,
|
|
99
|
+
width: '100%',
|
|
100
|
+
maxHeight: `${sheetHeight}vh`,
|
|
101
|
+
boxShadow: '0 -12px 32px rgba(0, 0, 0, 0.18)'
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const anchorPlacement: React.CSSProperties =
|
|
106
|
+
anchorAlign === 'center'
|
|
107
|
+
? { left: '50%', transform: 'translateX(-50%)' }
|
|
108
|
+
: anchorAlign === 'end'
|
|
109
|
+
? { right: 0 }
|
|
110
|
+
: { left: 0 };
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
position: 'absolute',
|
|
114
|
+
top: `calc(100% + ${anchorOffset}px)`,
|
|
115
|
+
zIndex: 1000,
|
|
116
|
+
width: `${width}px`,
|
|
117
|
+
maxWidth: '92vw',
|
|
118
|
+
maxHeight: '80vh',
|
|
119
|
+
borderRadius: '4px',
|
|
120
|
+
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.18)',
|
|
121
|
+
...anchorPlacement
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export interface OverlayCloseButtonProps {
|
|
126
|
+
label: string;
|
|
127
|
+
onClick: () => void;
|
|
128
|
+
/** Legacy marker attribute, e.g. `{ 'data-size-guide-close': '' }`. */
|
|
129
|
+
markerAttrs?: Record<string, string>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const OverlayCloseButton = ({
|
|
133
|
+
label,
|
|
134
|
+
onClick,
|
|
135
|
+
markerAttrs
|
|
136
|
+
}: OverlayCloseButtonProps) => (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
{...markerAttrs}
|
|
140
|
+
{...partAttrs('close')}
|
|
141
|
+
aria-label={label}
|
|
142
|
+
onClick={onClick}
|
|
143
|
+
style={{
|
|
144
|
+
position: 'absolute',
|
|
145
|
+
top: '12px',
|
|
146
|
+
right: '12px',
|
|
147
|
+
border: 'none',
|
|
148
|
+
background: 'transparent',
|
|
149
|
+
color: 'inherit',
|
|
150
|
+
cursor: 'pointer',
|
|
151
|
+
padding: 0
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<svg
|
|
155
|
+
width="18"
|
|
156
|
+
height="18"
|
|
157
|
+
viewBox="0 0 24 24"
|
|
158
|
+
fill="none"
|
|
159
|
+
stroke="currentColor"
|
|
160
|
+
strokeWidth="1.6"
|
|
161
|
+
strokeLinecap="round"
|
|
162
|
+
>
|
|
163
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
164
|
+
</svg>
|
|
165
|
+
</button>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/** The grab handle a bottom sheet draws above its content. */
|
|
169
|
+
export const OverlaySheetHandle = ({
|
|
170
|
+
markerAttrs
|
|
171
|
+
}: {
|
|
172
|
+
markerAttrs?: Record<string, string>;
|
|
173
|
+
}) => (
|
|
174
|
+
<div
|
|
175
|
+
{...markerAttrs}
|
|
176
|
+
{...partAttrs('handle')}
|
|
177
|
+
style={{
|
|
178
|
+
width: '40px',
|
|
179
|
+
height: '4px',
|
|
180
|
+
margin: '0 auto 12px',
|
|
181
|
+
borderRadius: '999px',
|
|
182
|
+
background: '#d1d5db'
|
|
183
|
+
}}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
/** Attributes `useOverlay` hands back for the element that IS the overlay. */
|
|
188
|
+
export interface OverlayHandleProps {
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
190
|
+
ref: any;
|
|
191
|
+
'aria-modal': 'true' | undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface OverlaySurfaceProps extends OverlayGeometryInput {
|
|
195
|
+
overlayProps: OverlayHandleProps;
|
|
196
|
+
ariaLabel: string;
|
|
197
|
+
showBackdrop: boolean;
|
|
198
|
+
closeOnOverlay: boolean;
|
|
199
|
+
onDismiss: () => void;
|
|
200
|
+
/** Rendered inside the panel, above the authored content. */
|
|
201
|
+
header?: React.ReactNode;
|
|
202
|
+
children: React.ReactNode;
|
|
203
|
+
/**
|
|
204
|
+
* Legacy marker attributes per node. Order matters: they are spread BEFORE
|
|
205
|
+
* the part attributes, which is where the panel block already put them and
|
|
206
|
+
* therefore where the golden expects them.
|
|
207
|
+
*/
|
|
208
|
+
markers?: {
|
|
209
|
+
root?: Record<string, string>;
|
|
210
|
+
backdrop?: Record<string, string>;
|
|
211
|
+
backdropRoot?: Record<string, string>;
|
|
212
|
+
panel?: Record<string, string>;
|
|
213
|
+
handle?: Record<string, string>;
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The centred modal, kept as ONE node that is both the fixed root and the
|
|
219
|
+
* backdrop.
|
|
220
|
+
*
|
|
221
|
+
* Refactoring it into the root + backdrop sibling pair the other
|
|
222
|
+
* presentations use would move markup live storefronts already style, for no
|
|
223
|
+
* gain — so it keeps its own branch, and `panel-legacy-golden.json` holds it
|
|
224
|
+
* there. It also deliberately does without the panel's `tabIndex={-1}`: adding
|
|
225
|
+
* it would move published markup, and the gap only shows in a composition with
|
|
226
|
+
* the close button off AND no focusable child.
|
|
227
|
+
*/
|
|
228
|
+
export const CentredModalSurface = ({
|
|
229
|
+
overlayProps,
|
|
230
|
+
ariaLabel,
|
|
231
|
+
closeOnOverlay,
|
|
232
|
+
onDismiss,
|
|
233
|
+
width,
|
|
234
|
+
header,
|
|
235
|
+
children,
|
|
236
|
+
markers
|
|
237
|
+
}: Omit<
|
|
238
|
+
OverlaySurfaceProps,
|
|
239
|
+
'presentation' | 'showBackdrop' | 'panelSide' | 'sheetHeight'
|
|
240
|
+
>) => (
|
|
241
|
+
<div
|
|
242
|
+
{...markers?.root}
|
|
243
|
+
{...partAttrs('overlay', { open: true })}
|
|
244
|
+
onClick={() => {
|
|
245
|
+
if (closeOnOverlay) onDismiss();
|
|
246
|
+
}}
|
|
247
|
+
style={{
|
|
248
|
+
position: 'fixed',
|
|
249
|
+
inset: 0,
|
|
250
|
+
zIndex: 1000,
|
|
251
|
+
display: 'flex',
|
|
252
|
+
alignItems: 'center',
|
|
253
|
+
justifyContent: 'center',
|
|
254
|
+
padding: '16px',
|
|
255
|
+
background: 'rgba(0, 0, 0, 0.5)'
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
<div
|
|
259
|
+
{...markers?.panel}
|
|
260
|
+
{...partAttrs('modal', { open: true })}
|
|
261
|
+
role="dialog"
|
|
262
|
+
{...overlayProps}
|
|
263
|
+
aria-label={ariaLabel}
|
|
264
|
+
// The backdrop closes the modal; clicks on the panel itself must not
|
|
265
|
+
// bubble up into that handler.
|
|
266
|
+
onClick={(event) => event.stopPropagation()}
|
|
267
|
+
style={{
|
|
268
|
+
position: 'relative',
|
|
269
|
+
width: '100%',
|
|
270
|
+
maxWidth: `${width}px`,
|
|
271
|
+
maxHeight: '80vh',
|
|
272
|
+
overflow: 'auto',
|
|
273
|
+
padding: '24px',
|
|
274
|
+
borderRadius: '4px',
|
|
275
|
+
background: '#ffffff',
|
|
276
|
+
color: '#111827',
|
|
277
|
+
textAlign: 'left'
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
{header}
|
|
281
|
+
{children}
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Drawer, sheet and anchored: a panel plus an optional backdrop.
|
|
288
|
+
*
|
|
289
|
+
* With the backdrop off, the fixed layer would still be an invisible sheet of
|
|
290
|
+
* glass over the whole viewport, so events pass through it and the panel turns
|
|
291
|
+
* them back on for itself.
|
|
292
|
+
*/
|
|
293
|
+
export const LayeredOverlaySurface = ({
|
|
294
|
+
presentation,
|
|
295
|
+
overlayProps,
|
|
296
|
+
ariaLabel,
|
|
297
|
+
showBackdrop,
|
|
298
|
+
closeOnOverlay,
|
|
299
|
+
onDismiss,
|
|
300
|
+
width,
|
|
301
|
+
panelSide,
|
|
302
|
+
sheetHeight,
|
|
303
|
+
anchorAlign,
|
|
304
|
+
anchorOffset,
|
|
305
|
+
header,
|
|
306
|
+
children,
|
|
307
|
+
markers
|
|
308
|
+
}: OverlaySurfaceProps) => {
|
|
309
|
+
const backdrop = showBackdrop ? (
|
|
310
|
+
<div
|
|
311
|
+
{...markers?.backdrop}
|
|
312
|
+
{...partAttrs('overlay', { open: true })}
|
|
313
|
+
onClick={() => {
|
|
314
|
+
if (closeOnOverlay) onDismiss();
|
|
315
|
+
}}
|
|
316
|
+
style={{
|
|
317
|
+
position: 'absolute',
|
|
318
|
+
inset: 0,
|
|
319
|
+
background: 'rgba(0, 0, 0, 0.5)'
|
|
320
|
+
}}
|
|
321
|
+
/>
|
|
322
|
+
) : null;
|
|
323
|
+
|
|
324
|
+
const panel = (
|
|
325
|
+
<div
|
|
326
|
+
{...markers?.panel}
|
|
327
|
+
{...partAttrs('modal', { open: true })}
|
|
328
|
+
role="dialog"
|
|
329
|
+
{...overlayProps}
|
|
330
|
+
aria-label={ariaLabel}
|
|
331
|
+
// Focus target of last resort, for a panel whose every control the
|
|
332
|
+
// designer turned off.
|
|
333
|
+
tabIndex={-1}
|
|
334
|
+
onClick={(event) => event.stopPropagation()}
|
|
335
|
+
style={{
|
|
336
|
+
...overlayPanelSurface,
|
|
337
|
+
...resolveOverlayPanelGeometry({
|
|
338
|
+
presentation,
|
|
339
|
+
width,
|
|
340
|
+
panelSide,
|
|
341
|
+
sheetHeight,
|
|
342
|
+
anchorAlign,
|
|
343
|
+
anchorOffset
|
|
344
|
+
}),
|
|
345
|
+
pointerEvents: showBackdrop ? undefined : 'auto'
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
{presentation === 'sheet' && (
|
|
349
|
+
<OverlaySheetHandle markerAttrs={markers?.handle} />
|
|
350
|
+
)}
|
|
351
|
+
{header}
|
|
352
|
+
{children}
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (presentation === 'anchored') {
|
|
357
|
+
return (
|
|
358
|
+
<>
|
|
359
|
+
{showBackdrop && (
|
|
360
|
+
<div
|
|
361
|
+
{...markers?.backdropRoot}
|
|
362
|
+
style={{ position: 'fixed', inset: 0, zIndex: 999 }}
|
|
363
|
+
>
|
|
364
|
+
{backdrop}
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
{panel}
|
|
368
|
+
</>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return (
|
|
373
|
+
<div
|
|
374
|
+
{...markers?.root}
|
|
375
|
+
style={{
|
|
376
|
+
position: 'fixed',
|
|
377
|
+
inset: 0,
|
|
378
|
+
zIndex: 1000,
|
|
379
|
+
pointerEvents: showBackdrop ? undefined : 'none'
|
|
380
|
+
}}
|
|
381
|
+
>
|
|
382
|
+
{backdrop}
|
|
383
|
+
{panel}
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
};
|