@astryxdesign/lab 0.6.3-canary.2bb99ed → 0.6.3-canary.2bdba84
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/README.md +23 -0
- package/dist/Drawer/Drawer.d.ts +12 -13
- package/dist/Drawer/Drawer.d.ts.map +1 -1
- package/dist/Drawer/Drawer.js +90 -123
- package/dist/Drawer/useDrawerDialogPresence.d.ts +14 -12
- package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -1
- package/dist/Drawer/useDrawerDialogPresence.js +86 -31
- package/dist/lab.css +9 -0
- package/package.json +4 -4
- package/src/Drawer/Drawer.doc.mjs +182 -4
- package/src/Drawer/Drawer.spec.md +99 -92
- package/src/Drawer/Drawer.test.tsx +188 -28
- package/src/Drawer/Drawer.tsx +93 -133
- package/src/Drawer/useDrawerDialogPresence.ts +93 -32
package/README.md
CHANGED
|
@@ -50,6 +50,29 @@ import '@astryxdesign/lab/lab.css';
|
|
|
50
50
|
|
|
51
51
|
> Canary builds track the latest commit on `main` (`0.x.y-canary.<sha>`). They can break between any two versions — pin an exact version if you need stability.
|
|
52
52
|
|
|
53
|
+
## Documenting a Lab component (canary docsite)
|
|
54
|
+
|
|
55
|
+
Lab appears **only on the canary docsite** — the production site documents the published stable release and never loads this package (see the target gates in `apps/docsite/scripts/` and the exclusion tests in `apps/docsite/src/__tests__/integration-targets.test.ts`).
|
|
56
|
+
|
|
57
|
+
Authoring is the same two-artifact flow a Core author uses; the only difference is where the runnable demos live (Core keeps its blocks centrally in `packages/cli/assets/templates/blocks/`, Lab owns its own `blocks/` directory here):
|
|
58
|
+
|
|
59
|
+
1. **Component doc** — `src/<Name>/<Name>.doc.mjs` exporting `docs` (props, usage, playground config, `examples`). Picked up automatically on canary; no registration anywhere.
|
|
60
|
+
2. **Runnable demos** — same-stem pairs in `blocks/`: `<BlockName>.tsx` + `<BlockName>.doc.mjs` (a `TemplateDoc` stamped `type: 'block'`). Discovered automatically once this package declares the directory — nothing per-component. The docsite renders the pair as the component page's showcase/examples and the playground can import anything the package exports.
|
|
61
|
+
|
|
62
|
+
**First block only:** the pull request that adds Lab's first block also adds `templates: './blocks'` to `astryx.integration.mjs` and `"blocks"` to the `files` list in `package.json`. Declaring the root earlier would fail `astryx integration pack --check`, which rejects a declared root with no contributions. After that one-time step, a new demo is just its block pair.
|
|
63
|
+
|
|
64
|
+
How a demo reaches a component page: **`exampleFor: '<Component>'` (or `alsoExampleFor`) is what attaches a block** — the page renders every block attributed to it, whatever the block is named. The block's `name` is the demo's display name; component-doc example `labels` are CLI-snippet headings. The two are independent mechanisms.
|
|
65
|
+
|
|
66
|
+
Use these conventions for new demos:
|
|
67
|
+
|
|
68
|
+
- Give the block descriptor the same `name` as the component doc's example `label`, so the snippet and its runnable demo read as one documented set (this naming convention is what the `example-coverage` report keys on — it is not how the docsite attaches demos).
|
|
69
|
+
- Exactly one attributed block sets `isShowcase: true` (the hero demo — conventionally the first example).
|
|
70
|
+
- `displayName` and `description` are required by the docsite build; set `componentsUsed` and `aspectRatio` for the gallery.
|
|
71
|
+
|
|
72
|
+
A doc example with no corresponding block exists only as a CLI/code snippet — the docsite page's demos come solely from blocks. Note the converse does not follow from names alone: an example label with no _same-named_ block does not by itself mean the demo is missing, because the component may render demos under other names (several Core components do). The `example-coverage` docsite test output reports both sides of that pairing; it is a _report_, not a gate — Core has the same non-guarantee, and whether pairing should gate CI is an open repo-wide decision.
|
|
73
|
+
|
|
74
|
+
> Note: `astryx integration add template` scaffolds a `./templates` root for a package that declares none. Declare `templates: './blocks'` explicitly instead, matching charts and richtext; unifying the two conventions is a pending repo decision.
|
|
75
|
+
|
|
53
76
|
## Why no stable release?
|
|
54
77
|
|
|
55
78
|
`package.json` keeps `"private": true` plus an `"astryx": { "canaryOnly": true }` marker. The release workflow's stable (`latest`) job skips both private and `canaryOnly` packages, while the canary job strips `private` in its ephemeral CI checkout only (never in git) to publish the `@canary` tag. The committed `private: true` is npm's hard guarantee that no stable publish can ever happen — **do not remove it.**
|
package/dist/Drawer/Drawer.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Drawer.tsx
|
|
3
|
-
* @input Uses React, StyleX, theme tokens, Icon/IconButton,
|
|
3
|
+
* @input Uses React, StyleX, theme tokens and text defaults, Icon/IconButton, shared focus/dismissal/depth primitives, i18n, scroll locking/dialog presence, BaseProps, merged refs/props, themeProps
|
|
4
4
|
* @output Exports Drawer component and DrawerProps
|
|
5
5
|
* @position Lab implementation; consumed by index.ts, tested by Drawer.test.tsx, demonstrated in Storybook
|
|
6
6
|
*
|
|
@@ -19,18 +19,17 @@
|
|
|
19
19
|
* Uses the native `<dialog>` element (same precedent as Dialog/MobileNav):
|
|
20
20
|
* - `showModal()` when `hasScrim` (default) — top-layer rendering, focus
|
|
21
21
|
* trapping, `::backdrop`, no z-index management.
|
|
22
|
-
* - `
|
|
23
|
-
* stays interactive (e.g. master-detail inspectors).
|
|
22
|
+
* - `showPopover()` when `hasScrim={false}` — non-modal top-layer overlay;
|
|
23
|
+
* the page behind stays interactive (e.g. master-detail inspectors).
|
|
24
24
|
*
|
|
25
|
-
* Entry animation uses `@starting-style`; exit slides out before
|
|
26
|
-
*
|
|
27
|
-
* that opened the drawer. React owns `display` for both legs
|
|
28
|
-
* discrete `display` transition, so the panel stops painting in
|
|
29
|
-
* commit as
|
|
25
|
+
* Entry animation uses `@starting-style`; exit slides out before the active
|
|
26
|
+
* modal-dialog or manual-popover host releases the top layer and focus returns
|
|
27
|
+
* to the element that opened the drawer. React owns `display` for both legs
|
|
28
|
+
* rather than a discrete `display` transition, so the panel stops painting in
|
|
29
|
+
* the same commit as the host closes — see the `rendered` style for why.
|
|
30
30
|
*
|
|
31
|
-
* Sibling drawers
|
|
32
|
-
*
|
|
33
|
-
* last-opened-on-top via registry-assigned z-indexes.
|
|
31
|
+
* Sibling drawers use the shared layer dismissal stack for topmost-only Escape
|
|
32
|
+
* handling and the browser top layer's chronological paint order.
|
|
34
33
|
*
|
|
35
34
|
* SYNC: When modified, update these files to stay in sync:
|
|
36
35
|
* - /packages/lab/src/Drawer/Drawer.doc.mjs (props table, features, usage)
|
|
@@ -86,8 +85,8 @@ export interface DrawerProps extends BaseProps<HTMLDialogElement> {
|
|
|
86
85
|
* Whether to render a modal scrim behind the drawer.
|
|
87
86
|
* - `true` (default) — `showModal()`: top layer, focus trap, body scroll
|
|
88
87
|
* lock, click-outside-to-close.
|
|
89
|
-
* - `false` — `
|
|
90
|
-
* interactive. Escape still closes
|
|
88
|
+
* - `false` — `showPopover()`: non-modal top-layer overlay; the page behind
|
|
89
|
+
* stays interactive. Escape still closes through the shared layer stack.
|
|
91
90
|
* @default true
|
|
92
91
|
*/
|
|
93
92
|
hasScrim?: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Drawer.d.ts","sourceRoot":"","sources":["../../src/Drawer/Drawer.tsx"],"names":[],"mappings":"AAIA
|
|
1
|
+
{"version":3,"file":"Drawer.d.ts","sourceRoot":"","sources":["../../src/Drawer/Drawer.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,EAAgC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAEpE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AA4NlD,MAAM,WAAW,WAAY,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IAC/D,iDAAiD;IACjD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAEnC;;OAEG;IACH,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;;OAKG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAExC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG,KAAK,CAAC;IAEvB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAExB;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAE9B;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB;;;OAGG;IACH,QAAQ,EAAE,SAAS,CAAC;IAEpB;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAMD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,MAAM,CAAC,EACrB,MAAM,EACN,YAAY,EACZ,IAAY,EACZ,KAAW,EACX,mBAA2B,EAC3B,KAAK,EACL,QAAe,EACf,cAAqB,EACrB,QAAQ,EACR,MAAM,EACN,SAAS,EACT,KAAK,EACL,OAAO,EAAE,WAAW,EACpB,SAAS,EAAE,aAAa,EACxB,GAAG,EACH,GAAG,KAAK,EACT,EAAE,WAAW,+BAwIb;yBAzJe,MAAM"}
|
package/dist/Drawer/Drawer.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Drawer.tsx
|
|
7
|
-
* @input Uses React, StyleX, theme tokens, Icon/IconButton,
|
|
7
|
+
* @input Uses React, StyleX, theme tokens and text defaults, Icon/IconButton, shared focus/dismissal/depth primitives, i18n, scroll locking/dialog presence, BaseProps, merged refs/props, themeProps
|
|
8
8
|
* @output Exports Drawer component and DrawerProps
|
|
9
9
|
* @position Lab implementation; consumed by index.ts, tested by Drawer.test.tsx, demonstrated in Storybook
|
|
10
10
|
*
|
|
@@ -23,18 +23,17 @@
|
|
|
23
23
|
* Uses the native `<dialog>` element (same precedent as Dialog/MobileNav):
|
|
24
24
|
* - `showModal()` when `hasScrim` (default) — top-layer rendering, focus
|
|
25
25
|
* trapping, `::backdrop`, no z-index management.
|
|
26
|
-
* - `
|
|
27
|
-
* stays interactive (e.g. master-detail inspectors).
|
|
26
|
+
* - `showPopover()` when `hasScrim={false}` — non-modal top-layer overlay;
|
|
27
|
+
* the page behind stays interactive (e.g. master-detail inspectors).
|
|
28
28
|
*
|
|
29
|
-
* Entry animation uses `@starting-style`; exit slides out before
|
|
30
|
-
*
|
|
31
|
-
* that opened the drawer. React owns `display` for both legs
|
|
32
|
-
* discrete `display` transition, so the panel stops painting in
|
|
33
|
-
* commit as
|
|
29
|
+
* Entry animation uses `@starting-style`; exit slides out before the active
|
|
30
|
+
* modal-dialog or manual-popover host releases the top layer and focus returns
|
|
31
|
+
* to the element that opened the drawer. React owns `display` for both legs
|
|
32
|
+
* rather than a discrete `display` transition, so the panel stops painting in
|
|
33
|
+
* the same commit as the host closes — see the `rendered` style for why.
|
|
34
34
|
*
|
|
35
|
-
* Sibling drawers
|
|
36
|
-
*
|
|
37
|
-
* last-opened-on-top via registry-assigned z-indexes.
|
|
35
|
+
* Sibling drawers use the shared layer dismissal stack for topmost-only Escape
|
|
36
|
+
* handling and the browser top layer's chronological paint order.
|
|
38
37
|
*
|
|
39
38
|
* SYNC: When modified, update these files to stay in sync:
|
|
40
39
|
* - /packages/lab/src/Drawer/Drawer.doc.mjs (props table, features, usage)
|
|
@@ -42,55 +41,19 @@
|
|
|
42
41
|
* - /packages/lab/src/Drawer/index.ts (exports if types change)
|
|
43
42
|
* - /apps/storybook/stories/Drawer.stories.tsx (examples and visual coverage)
|
|
44
43
|
*/
|
|
45
|
-
import { useCallback,
|
|
44
|
+
import { useCallback, useRef, useState } from 'react';
|
|
46
45
|
import * as stylex from '@stylexjs/stylex';
|
|
47
46
|
import '@astryxdesign/core/theme/tokens.stylex';
|
|
48
|
-
import { borderVars, colorVars, durationVars, easeVars, shadowVars, spacingVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
47
|
+
import { borderVars, colorVars, durationVars, easeVars, shadowVars, spacingVars, typeScaleVars, typographyVars } from '@astryxdesign/core/theme/tokens.stylex';
|
|
49
48
|
import { Icon } from '@astryxdesign/core/Icon';
|
|
50
49
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
|
51
|
-
import { useScrollLock } from '@astryxdesign/core/hooks';
|
|
52
|
-
import {
|
|
50
|
+
import { useFocusTrap, useMergedRefs, useScrollLock } from '@astryxdesign/core/hooks';
|
|
51
|
+
import { LayerDepthProvider, useLayerDismissal } from '@astryxdesign/core/Layer';
|
|
52
|
+
import { useTranslator } from '@astryxdesign/core/i18n';
|
|
53
|
+
import { composeEventHandlers, mergeProps, themeProps } from '@astryxdesign/core/utils';
|
|
53
54
|
import { overlayPaddingReset } from '@astryxdesign/core/Layout';
|
|
54
55
|
import { useDrawerDialogPresence } from "./useDrawerDialogPresence.js";
|
|
55
56
|
|
|
56
|
-
// =============================================================================
|
|
57
|
-
// LIFO stacking registry (internal)
|
|
58
|
-
// =============================================================================
|
|
59
|
-
|
|
60
|
-
// Module-level registry of currently open drawers, in open order (last entry
|
|
61
|
-
// is the top of the stack). SSR-safe: only mutated inside effects. Escape
|
|
62
|
-
// handling consults isTopDrawer() so sibling drawers close innermost-first,
|
|
63
|
-
// and non-modal (show()) drawers get incrementing z-indexes so the
|
|
64
|
-
// last-opened one paints on top; modal drawers rely on the native top
|
|
65
|
-
// layer's chronological stacking instead.
|
|
66
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
67
|
-
// Without the top layer (hasScrim={false} uses show(), not showModal())
|
|
68
|
-
// the panel needs explicit stacking. No z-index token exists in the theme;
|
|
69
|
-
// 1000 matches the app-level drawer convention.
|
|
70
|
-
const NON_MODAL_BASE_Z = 1000;
|
|
71
|
-
const openDrawerStack = [];
|
|
72
|
-
let registrationCounter = 0;
|
|
73
|
-
function registerDrawer(id, close) {
|
|
74
|
-
openDrawerStack.push({
|
|
75
|
-
id,
|
|
76
|
-
close
|
|
77
|
-
});
|
|
78
|
-
registrationCounter += 1;
|
|
79
|
-
return NON_MODAL_BASE_Z + registrationCounter - 1;
|
|
80
|
-
}
|
|
81
|
-
function unregisterDrawer(id) {
|
|
82
|
-
const index = openDrawerStack.findIndex(entry => entry.id === id);
|
|
83
|
-
if (index !== -1) {
|
|
84
|
-
openDrawerStack.splice(index, 1);
|
|
85
|
-
}
|
|
86
|
-
if (openDrawerStack.length === 0) {
|
|
87
|
-
registrationCounter = 0;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
function isTopDrawer(id) {
|
|
91
|
-
return openDrawerStack[openDrawerStack.length - 1]?.id === id;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
57
|
// =============================================================================
|
|
95
58
|
// Styles
|
|
96
59
|
// =============================================================================
|
|
@@ -98,6 +61,7 @@ function isTopDrawer(id) {
|
|
|
98
61
|
// Below this viewport width the drawer preserves a fixed reveal of the page
|
|
99
62
|
// behind instead of growing proportionally with the viewport. 640px is the
|
|
100
63
|
// repo's mobile breakpoint.
|
|
64
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
101
65
|
const MOBILE_BREAKPOINT = 640;
|
|
102
66
|
|
|
103
67
|
// Material's established mobile drawer pattern leaves a 56dp reveal. Using
|
|
@@ -107,6 +71,22 @@ const MOBILE_PAGE_REVEAL = 56;
|
|
|
107
71
|
const MOBILE_WIDTH_FULL = '100dvw';
|
|
108
72
|
const styles = {
|
|
109
73
|
dialog: {
|
|
74
|
+
kMv6JI: "x9ynric",
|
|
75
|
+
kGuDYH: "xjm74w1",
|
|
76
|
+
k63SB2: "xxovm9e",
|
|
77
|
+
kLWn49: "xw6l6zx",
|
|
78
|
+
kKX8nH: "x1j61x8r",
|
|
79
|
+
k9WMMc: "x1yc453h",
|
|
80
|
+
kJI5tL: "x7ssn7h",
|
|
81
|
+
kTHuQy: "xzugeeo",
|
|
82
|
+
kP9fke: "x6mezaz",
|
|
83
|
+
kb6lSQ: "x1i21sxh",
|
|
84
|
+
k4JVr9: "xp3md9m",
|
|
85
|
+
kKMj4B: "x19pm5ym",
|
|
86
|
+
khDVqt: "xeaf4i8",
|
|
87
|
+
kTgw9: "x1lldw8n",
|
|
88
|
+
kHjlTd: "x1h4wwuj",
|
|
89
|
+
kE4Cay: "xxydokm",
|
|
110
90
|
kVAEAm: "xixxii4",
|
|
111
91
|
kogj98: "x1ghz6dp",
|
|
112
92
|
kmVPX3: "x1717udv",
|
|
@@ -186,12 +166,6 @@ const dynamicStyles = {
|
|
|
186
166
|
}, {
|
|
187
167
|
"--x-1xmrurk": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(desktopWidth),
|
|
188
168
|
"--x-fqzwsx": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(mobileWidth)
|
|
189
|
-
}],
|
|
190
|
-
stackZ: z => [{
|
|
191
|
-
kY2c9j: z != null ? "xr3buco" : z,
|
|
192
|
-
$$css: true
|
|
193
|
-
}, {
|
|
194
|
-
"--x-zIndex": z != null ? z : undefined
|
|
195
169
|
}]
|
|
196
170
|
};
|
|
197
171
|
|
|
@@ -245,15 +219,13 @@ export function Drawer({
|
|
|
245
219
|
...props
|
|
246
220
|
}) {
|
|
247
221
|
const dialogRef = useRef(null);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// z-index assigned by the registry on open (non-modal stacking only).
|
|
256
|
-
const [stackZ, setStackZ] = useState(NON_MODAL_BASE_Z);
|
|
222
|
+
const {
|
|
223
|
+
containerRef: focusTrapRef
|
|
224
|
+
} = useFocusTrap({
|
|
225
|
+
isActive: isOpen && hasScrim
|
|
226
|
+
});
|
|
227
|
+
const mergedDialogRef = useMergedRefs(ref, dialogRef, focusTrapRef);
|
|
228
|
+
const t = useTranslator();
|
|
257
229
|
// Whether the panel paints: true while open and for the whole slide-out.
|
|
258
230
|
const [isRendered, setIsRendered] = useState(isOpen);
|
|
259
231
|
|
|
@@ -269,44 +241,36 @@ export function Drawer({
|
|
|
269
241
|
isModal: hasScrim,
|
|
270
242
|
setIsRendered
|
|
271
243
|
});
|
|
244
|
+
const handleDismiss = useCallback(() => {
|
|
245
|
+
onOpenChange(false);
|
|
246
|
+
}, [onOpenChange]);
|
|
272
247
|
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
248
|
+
// The shared stack owns Escape delivery across every overlay family. The
|
|
249
|
+
// provider below gives layers opened from Drawer content a greater logical
|
|
250
|
+
// depth even when they render elsewhere in the DOM or browser top layer.
|
|
251
|
+
const {
|
|
252
|
+
shouldDismissOnCloseRequest
|
|
253
|
+
} = useLayerDismissal({
|
|
254
|
+
// The native host stays present through the slide-out. Keep this entry on
|
|
255
|
+
// the stack until the same commit that hides the host, so a second Escape
|
|
256
|
+
// cannot fall through to a lower layer during the exit animation.
|
|
257
|
+
isActive: isRendered,
|
|
258
|
+
onDismiss: handleDismiss,
|
|
259
|
+
getContainer: () => dialogRef.current
|
|
260
|
+
});
|
|
283
261
|
|
|
284
262
|
// Lock body scroll while a modal drawer is open (iOS Safari workaround).
|
|
285
263
|
useScrollLock(isOpen && hasScrim);
|
|
286
264
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// top
|
|
290
|
-
// innermost-first.
|
|
291
|
-
const handleKeyDown = useCallback(event => {
|
|
292
|
-
if (event.key === 'Escape') {
|
|
293
|
-
event.preventDefault();
|
|
294
|
-
if (isTopDrawer(drawerId)) {
|
|
295
|
-
onOpenChange(false);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}, [onOpenChange, drawerId]);
|
|
299
|
-
|
|
300
|
-
// Native cancel event (browser Escape handling) — prevent the browser
|
|
301
|
-
// from closing the dialog directly and route through onOpenChange so the
|
|
302
|
-
// caller's state stays the source of truth. Same top-of-stack rule as
|
|
303
|
-
// the keydown path.
|
|
265
|
+
// A modal dialog's native cancel event is a platform close request (for
|
|
266
|
+
// example Android Back). Keep controlled state authoritative and apply the
|
|
267
|
+
// same top-most/IME rules as the shared Escape listener.
|
|
304
268
|
const handleCancel = useCallback(event => {
|
|
305
269
|
event.preventDefault();
|
|
306
|
-
if (
|
|
307
|
-
|
|
270
|
+
if (shouldDismissOnCloseRequest()) {
|
|
271
|
+
handleDismiss();
|
|
308
272
|
}
|
|
309
|
-
}, [
|
|
273
|
+
}, [handleDismiss, shouldDismissOnCloseRequest]);
|
|
310
274
|
|
|
311
275
|
// Clicks on the ::backdrop target the <dialog> element itself; clicks on
|
|
312
276
|
// drawer content always target a child (the content area fills the panel).
|
|
@@ -337,38 +301,41 @@ export function Drawer({
|
|
|
337
301
|
open: _open,
|
|
338
302
|
...safeProps
|
|
339
303
|
} = props;
|
|
340
|
-
return /*#__PURE__*/
|
|
341
|
-
ref:
|
|
304
|
+
return /*#__PURE__*/_jsx("dialog", {
|
|
305
|
+
ref: mergedDialogRef,
|
|
342
306
|
...mergeProps(themeProps('drawer', {
|
|
343
307
|
side: anchoredSide
|
|
344
|
-
}), stylex.props(styles.dialog, overlayPaddingReset.reset, sideStyle, dynamicStyles.inlineSize(widthValue, mobileWidth), isRendered && styles.rendered, isOpen && sideOpenStyle, hasScrim
|
|
308
|
+
}), stylex.props(styles.dialog, overlayPaddingReset.reset, sideStyle, dynamicStyles.inlineSize(widthValue, mobileWidth), isRendered && styles.rendered, isOpen && sideOpenStyle, hasScrim && styles.scrim, hasScrim && isOpen && styles.scrimOpen, xstyle), className, style),
|
|
345
309
|
...safeProps,
|
|
310
|
+
popover: hasScrim ? undefined : 'manual',
|
|
346
311
|
"aria-label": label,
|
|
347
312
|
"aria-modal": hasScrim ? 'true' : undefined,
|
|
348
313
|
onClick: composeEventHandlers(onClickProp, handleClick),
|
|
349
|
-
onKeyDown:
|
|
314
|
+
onKeyDown: onKeyDownProp,
|
|
350
315
|
onCancel: handleCancel,
|
|
351
|
-
children:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
icon:
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
316
|
+
children: /*#__PURE__*/_jsxs(LayerDepthProvider, {
|
|
317
|
+
children: [/*#__PURE__*/_jsx("div", {
|
|
318
|
+
tabIndex: -1,
|
|
319
|
+
...{
|
|
320
|
+
className: "x1iyjqo2 x2lwn1j xh8yej3 x1odjw0f x6ikm8r xish69e xx69xxh x1a148e8 x1a2a7pz"
|
|
321
|
+
},
|
|
322
|
+
children: children
|
|
323
|
+
}), hasCloseButton && /*#__PURE__*/_jsx("div", {
|
|
324
|
+
...{
|
|
325
|
+
className: "x10l6tqk xctzyg x72tfeb x78zum5 xzye2dw x1vjfegm"
|
|
326
|
+
},
|
|
327
|
+
children: /*#__PURE__*/_jsx(IconButton, {
|
|
328
|
+
icon: /*#__PURE__*/_jsx(Icon, {
|
|
329
|
+
icon: "close",
|
|
330
|
+
size: "sm",
|
|
331
|
+
color: "inherit"
|
|
332
|
+
}),
|
|
333
|
+
label: t('@astryx.dialog.close'),
|
|
334
|
+
variant: "ghost",
|
|
335
|
+
onClick: handleDismiss
|
|
336
|
+
})
|
|
337
|
+
})]
|
|
338
|
+
})
|
|
372
339
|
});
|
|
373
340
|
}
|
|
374
341
|
Drawer.displayName = 'Drawer';
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file useDrawerDialogPresence.ts
|
|
3
3
|
* @input Controlled open state, modal mode, dialog ref, and rendered-state setter
|
|
4
|
-
* @output Coordinates native
|
|
4
|
+
* @output Coordinates native top-layer presence, exit timing, focus restoration, and unmount cleanup
|
|
5
5
|
* @position Drawer-internal hook; consumed only by Drawer.tsx
|
|
6
6
|
*
|
|
7
7
|
* The drawer has two independent notions of presence:
|
|
8
8
|
* - React's rendered state keeps the panel visible for its CSS exit.
|
|
9
|
-
* -
|
|
9
|
+
* - Native `showModal()` or `showPopover()` state keeps it in the browser top
|
|
10
|
+
* layer.
|
|
10
11
|
*
|
|
11
12
|
* Their close ordering is a browser-visible invariant: the panel must finish
|
|
12
|
-
* its exit, then
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* its exit, then leave the active native host and hide in the same task. If the
|
|
14
|
+
* hide lands a frame later, a transformed ancestor becomes the containing block
|
|
15
|
+
* for the now-non-top-layer `position: fixed` panel and it paints back inside
|
|
16
|
+
* the page for one frame.
|
|
16
17
|
*
|
|
17
18
|
* SYNC: When modified, update:
|
|
18
19
|
* - /packages/lab/src/Drawer/Drawer.test.tsx
|
|
@@ -28,12 +29,13 @@ type UseDrawerDialogPresenceOptions = {
|
|
|
28
29
|
/**
|
|
29
30
|
* Coordinates the native dialog and React-rendered presence for Drawer.
|
|
30
31
|
*
|
|
31
|
-
* Opening captures the trigger,
|
|
32
|
-
* component's `data-autofocus` contract. Closing waits for the
|
|
33
|
-
* transform transition (with a computed-duration backstop), then
|
|
34
|
-
* native
|
|
35
|
-
* it outside the top layer
|
|
36
|
-
*
|
|
32
|
+
* Opening captures the trigger, enters the modal-dialog or manual-popover host,
|
|
33
|
+
* and honours the component's `data-autofocus` contract. Closing waits for the
|
|
34
|
+
* actual transform transition (with a computed-duration backstop), then leaves
|
|
35
|
+
* the native host and synchronously hides the panel before the browser can paint
|
|
36
|
+
* it outside the top layer, restores focus to the captured trigger, and only
|
|
37
|
+
* then dispatches the popover host's synthetic `close`. Unmount cleanup closes
|
|
38
|
+
* a host left open by React Activity or a removed subtree.
|
|
37
39
|
*/
|
|
38
40
|
export declare function useDrawerDialogPresence({ dialogRef, isOpen, isModal, setIsRendered, }: UseDrawerDialogPresenceOptions): void;
|
|
39
41
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useDrawerDialogPresence.d.ts","sourceRoot":"","sources":["../../src/Drawer/useDrawerDialogPresence.ts"],"names":[],"mappings":"AAIA
|
|
1
|
+
{"version":3,"file":"useDrawerDialogPresence.d.ts","sourceRoot":"","sources":["../../src/Drawer/useDrawerDialogPresence.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAGL,KAAK,QAAQ,EACb,KAAK,SAAS,EACd,KAAK,cAAc,EACpB,MAAM,OAAO,CAAC;AAaf,KAAK,8BAA8B,GAAG;IACpC,SAAS,EAAE,SAAS,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAC/C,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,QAAQ,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC;CAClD,CAAC;AAuDF;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CAAC,EACtC,SAAS,EACT,MAAM,EACN,OAAO,EACP,aAAa,GACd,EAAE,8BAA8B,GAAG,IAAI,CAmEvC"}
|
|
@@ -5,18 +5,19 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file useDrawerDialogPresence.ts
|
|
7
7
|
* @input Controlled open state, modal mode, dialog ref, and rendered-state setter
|
|
8
|
-
* @output Coordinates native
|
|
8
|
+
* @output Coordinates native top-layer presence, exit timing, focus restoration, and unmount cleanup
|
|
9
9
|
* @position Drawer-internal hook; consumed only by Drawer.tsx
|
|
10
10
|
*
|
|
11
11
|
* The drawer has two independent notions of presence:
|
|
12
12
|
* - React's rendered state keeps the panel visible for its CSS exit.
|
|
13
|
-
* -
|
|
13
|
+
* - Native `showModal()` or `showPopover()` state keeps it in the browser top
|
|
14
|
+
* layer.
|
|
14
15
|
*
|
|
15
16
|
* Their close ordering is a browser-visible invariant: the panel must finish
|
|
16
|
-
* its exit, then
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* its exit, then leave the active native host and hide in the same task. If the
|
|
18
|
+
* hide lands a frame later, a transformed ancestor becomes the containing block
|
|
19
|
+
* for the now-non-top-layer `position: fixed` panel and it paints back inside
|
|
20
|
+
* the page for one frame.
|
|
20
21
|
*
|
|
21
22
|
* SYNC: When modified, update:
|
|
22
23
|
* - /packages/lab/src/Drawer/Drawer.test.tsx
|
|
@@ -34,15 +35,63 @@ const EXIT_BACKSTOP_BUFFER_MS = 50;
|
|
|
34
35
|
* an assumption about the consumer's theme.
|
|
35
36
|
*/
|
|
36
37
|
const EXIT_FALLBACK_MS = 250;
|
|
38
|
+
function isPopoverOpen(dialog) {
|
|
39
|
+
try {
|
|
40
|
+
return dialog.matches(':popover-open');
|
|
41
|
+
} catch {
|
|
42
|
+
// jsdom and pre-Popover browsers do not recognize :popover-open.
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isDrawerHostOpen(dialog, isModal) {
|
|
47
|
+
return isModal ? dialog.open : isPopoverOpen(dialog) || dialog.open;
|
|
48
|
+
}
|
|
49
|
+
function showDrawerHost(dialog, isModal) {
|
|
50
|
+
if (isModal) {
|
|
51
|
+
dialog.showModal();
|
|
52
|
+
} else if (typeof dialog.showPopover === 'function') {
|
|
53
|
+
dialog.showPopover();
|
|
54
|
+
} else {
|
|
55
|
+
// Reduced fallback for browsers below the Popover API support floor.
|
|
56
|
+
dialog.show();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function dispatchDialogClose(dialog) {
|
|
60
|
+
const EventConstructor = dialog.ownerDocument.defaultView?.Event ?? Event;
|
|
61
|
+
dialog.dispatchEvent(new EventConstructor('close'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Leaves the active native host. Returns whether the caller owes a synthetic
|
|
66
|
+
* `close` dispatch: `dialog.close()` used to power the non-modal path, and
|
|
67
|
+
* consumers observe its native `close` event through refs/onClose. Popover
|
|
68
|
+
* dismissal has no equivalent event, so that public DOM contract is preserved
|
|
69
|
+
* explicitly — but by the caller, not here. Native `close` is queued as a
|
|
70
|
+
* task, so a consumer's listener always ran after this hook's own focus
|
|
71
|
+
* restore; the synthetic event must keep that ordering (and fire exactly once
|
|
72
|
+
* per exit, including the unmount path).
|
|
73
|
+
*/
|
|
74
|
+
function hideDrawerHost(dialog, isModal) {
|
|
75
|
+
if (!isModal && typeof dialog.hidePopover === 'function') {
|
|
76
|
+
dialog.hidePopover();
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (dialog.open) {
|
|
80
|
+
dialog.close();
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
37
85
|
/**
|
|
38
86
|
* Coordinates the native dialog and React-rendered presence for Drawer.
|
|
39
87
|
*
|
|
40
|
-
* Opening captures the trigger,
|
|
41
|
-
* component's `data-autofocus` contract. Closing waits for the
|
|
42
|
-
* transform transition (with a computed-duration backstop), then
|
|
43
|
-
* native
|
|
44
|
-
* it outside the top layer
|
|
45
|
-
*
|
|
88
|
+
* Opening captures the trigger, enters the modal-dialog or manual-popover host,
|
|
89
|
+
* and honours the component's `data-autofocus` contract. Closing waits for the
|
|
90
|
+
* actual transform transition (with a computed-duration backstop), then leaves
|
|
91
|
+
* the native host and synchronously hides the panel before the browser can paint
|
|
92
|
+
* it outside the top layer, restores focus to the captured trigger, and only
|
|
93
|
+
* then dispatches the popover host's synthetic `close`. Unmount cleanup closes
|
|
94
|
+
* a host left open by React Activity or a removed subtree.
|
|
46
95
|
*/
|
|
47
96
|
export function useDrawerDialogPresence({
|
|
48
97
|
dialogRef,
|
|
@@ -58,25 +107,21 @@ export function useDrawerDialogPresence({
|
|
|
58
107
|
return;
|
|
59
108
|
}
|
|
60
109
|
if (isOpen) {
|
|
61
|
-
if (!dialog
|
|
110
|
+
if (!isDrawerHostOpen(dialog, isModal)) {
|
|
62
111
|
triggerElementRef.current = document.activeElement;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
dialog.show();
|
|
67
|
-
}
|
|
68
|
-
// React's autoFocus calls .focus() during commit, before the dialog is
|
|
69
|
-
// shown, so it silently fails — honour data-autofocus instead (same
|
|
112
|
+
showDrawerHost(dialog, isModal);
|
|
113
|
+
// React's autoFocus calls .focus() during commit, before the native host
|
|
114
|
+
// is visible, so it silently fails — honour data-autofocus instead (same
|
|
70
115
|
// contract as Dialog).
|
|
71
116
|
dialog.querySelector('[data-autofocus]')?.focus();
|
|
72
117
|
}
|
|
73
118
|
return;
|
|
74
119
|
}
|
|
75
|
-
if (!dialog
|
|
120
|
+
if (!isDrawerHostOpen(dialog, isModal)) {
|
|
76
121
|
return;
|
|
77
122
|
}
|
|
78
123
|
return waitForDrawerExit(dialog, () => {
|
|
79
|
-
dialog
|
|
124
|
+
const owesCloseEvent = hideDrawerHost(dialog, isModal);
|
|
80
125
|
// flushSync, not a plain setState: React's default scheduling can land
|
|
81
126
|
// the commit after the next paint, and that one frame is exactly the
|
|
82
127
|
// bug — the panel paints outside the top layer. Both happen in this
|
|
@@ -84,27 +129,37 @@ export function useDrawerDialogPresence({
|
|
|
84
129
|
flushSync(() => {
|
|
85
130
|
setIsRendered(false);
|
|
86
131
|
});
|
|
87
|
-
// Return focus after
|
|
88
|
-
// document inert, so focusing
|
|
132
|
+
// Return focus after leaving the native host: a modal dialog makes the
|
|
133
|
+
// rest of the document inert, so focusing earlier silently fails.
|
|
89
134
|
triggerElementRef.current?.focus();
|
|
90
135
|
triggerElementRef.current = null;
|
|
136
|
+
// The synthetic popover close fires after the focus restore above,
|
|
137
|
+
// mirroring the task-queued native event: a consumer close listener
|
|
138
|
+
// that retargets focus (master-detail row switching) gets the last word.
|
|
139
|
+
if (owesCloseEvent) {
|
|
140
|
+
dispatchDialogClose(dialog);
|
|
141
|
+
}
|
|
91
142
|
});
|
|
92
143
|
}, [dialogRef, isModal, isOpen, setIsRendered]);
|
|
93
144
|
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
145
|
+
// Leave the active native host on unmount. When the drawer is mounted inside
|
|
146
|
+
// an <Activity> that flips to mode="hidden", React runs effect cleanups (with
|
|
147
|
+
// stale isOpen) instead of re-running the effect with isOpen=false. Leaving
|
|
148
|
+
// the host active would strand the top-layer surface and skip the next open.
|
|
98
149
|
// This is deliberately separate from the open/close effect: putting it in
|
|
99
150
|
// that cleanup would cut off every delayed slide-out.
|
|
100
151
|
useEffect(() => {
|
|
101
152
|
const dialog = dialogRef.current;
|
|
102
153
|
return () => {
|
|
103
|
-
if (dialog
|
|
104
|
-
dialog
|
|
154
|
+
if (dialog && isDrawerHostOpen(dialog, isModal)) {
|
|
155
|
+
if (hideDrawerHost(dialog, isModal)) {
|
|
156
|
+
// No focus restore on this path — the drawer is being torn down, so
|
|
157
|
+
// the owed synthetic close fires immediately (still exactly once).
|
|
158
|
+
dispatchDialogClose(dialog);
|
|
159
|
+
}
|
|
105
160
|
}
|
|
106
161
|
};
|
|
107
|
-
}, [dialogRef]);
|
|
162
|
+
}, [dialogRef, isModal]);
|
|
108
163
|
}
|
|
109
164
|
|
|
110
165
|
/**
|