@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 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.**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file Drawer.tsx
3
- * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock/useDrawerDialogPresence, BaseProps, mergeProps/mergeRefs, themeProps
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
- * - `show()` when `hasScrim={false}` — non-modal overlay; the page behind
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
- * `dialog.close()` releases the top layer and restores focus to the element
27
- * that opened the drawer. React owns `display` for both legs rather than a
28
- * discrete `display` transition, so the panel stops painting in the same
29
- * commit as `close()` — see the `rendered` style for why that matters.
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 coordinate through a module-level LIFO registry: Escape
32
- * closes only the top (last-opened) drawer, and non-modal drawers stack
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` — `show()`: non-modal overlay; the page behind stays
90
- * interactive. Escape still closes while focus is inside the drawer.
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AA+OlD,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,+BAqJb;yBAtKe,MAAM"}
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"}
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Drawer.tsx
7
- * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock/useDrawerDialogPresence, BaseProps, mergeProps/mergeRefs, themeProps
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
- * - `show()` when `hasScrim={false}` — non-modal overlay; the page behind
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
- * `dialog.close()` releases the top layer and restores focus to the element
31
- * that opened the drawer. React owns `display` for both legs rather than a
32
- * discrete `display` transition, so the panel stops painting in the same
33
- * commit as `close()` — see the `rendered` style for why that matters.
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 coordinate through a module-level LIFO registry: Escape
36
- * closes only the top (last-opened) drawer, and non-modal drawers stack
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, useEffect, useId, useRef, useState } from 'react';
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 { composeEventHandlers, mergeProps, mergeRefs, themeProps } from '@astryxdesign/core/utils';
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
- // Registry identity + latest onOpenChange (stable across re-renders so the
249
- // registration effect doesn't churn on every onOpenChange identity change).
250
- const drawerId = useId();
251
- const onOpenChangeRef = useRef(onOpenChange);
252
- useEffect(() => {
253
- onOpenChangeRef.current = onOpenChange;
254
- }, [onOpenChange]);
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
- // LIFO registry membership: register on open, unregister on close or
274
- // unmount. The returned z-index stacks non-modal siblings in open order.
275
- useEffect(() => {
276
- if (!isOpen) {
277
- return;
278
- }
279
- const z = registerDrawer(drawerId, () => onOpenChangeRef.current(false));
280
- setStackZ(z);
281
- return () => unregisterDrawer(drawerId);
282
- }, [isOpen, drawerId]);
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
- // Escape closes. The native `cancel` event only fires for showModal();
288
- // this React keydown handler covers the non-modal show() path too. Only the
289
- // top of the drawer stack closes, so stacked siblings peel off
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 (isTopDrawer(drawerId)) {
307
- onOpenChange(false);
270
+ if (shouldDismissOnCloseRequest()) {
271
+ handleDismiss();
308
272
  }
309
- }, [onOpenChange, drawerId]);
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__*/_jsxs("dialog", {
341
- ref: mergeRefs(ref, dialogRef),
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 ? styles.scrim : dynamicStyles.stackZ(stackZ), hasScrim && isOpen && styles.scrimOpen, xstyle), className, style),
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: composeEventHandlers(onKeyDownProp, handleKeyDown),
314
+ onKeyDown: onKeyDownProp,
350
315
  onCancel: handleCancel,
351
- children: [/*#__PURE__*/_jsx("div", {
352
- tabIndex: -1,
353
- ...{
354
- className: "x1iyjqo2 x2lwn1j xh8yej3 x1odjw0f x6ikm8r xish69e xx69xxh x1a148e8 x1a2a7pz"
355
- },
356
- children: children
357
- }), hasCloseButton && /*#__PURE__*/_jsx("div", {
358
- ...{
359
- className: "x10l6tqk xctzyg x72tfeb x78zum5 xzye2dw x1vjfegm"
360
- },
361
- children: /*#__PURE__*/_jsx(IconButton, {
362
- icon: /*#__PURE__*/_jsx(Icon, {
363
- icon: "close",
364
- size: "sm",
365
- color: "inherit"
366
- }),
367
- label: "Close",
368
- variant: "ghost",
369
- onClick: () => onOpenChange(false)
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 dialog presence, exit timing, focus restoration, and unmount cleanup
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
- * - The native <dialog> `open` state keeps it in the top layer.
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 `dialog.close()` and the React hide must happen in the same
13
- * task. If the hide lands a frame later, a transformed ancestor becomes the
14
- * containing block for the now-non-top-layer `position: fixed` panel and it
15
- * paints back inside the page for one frame.
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, opens the native dialog, and honours the
32
- * component's `data-autofocus` contract. Closing waits for the actual
33
- * transform transition (with a computed-duration backstop), then closes the
34
- * native dialog and synchronously hides the panel before the browser can paint
35
- * it outside the top layer. Unmount cleanup closes a dialog left open by React
36
- * Activity or a removed subtree.
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;;;;;;;;;;;;;;;;;;;GAmBG;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;AAEF;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CAAC,EACtC,SAAS,EACT,MAAM,EACN,OAAO,EACP,aAAa,GACd,EAAE,8BAA8B,GAAG,IAAI,CA6DvC"}
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 dialog presence, exit timing, focus restoration, and unmount cleanup
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
- * - The native <dialog> `open` state keeps it in the top layer.
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 `dialog.close()` and the React hide must happen in the same
17
- * task. If the hide lands a frame later, a transformed ancestor becomes the
18
- * containing block for the now-non-top-layer `position: fixed` panel and it
19
- * paints back inside the page for one frame.
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, opens the native dialog, and honours the
41
- * component's `data-autofocus` contract. Closing waits for the actual
42
- * transform transition (with a computed-duration backstop), then closes the
43
- * native dialog and synchronously hides the panel before the browser can paint
44
- * it outside the top layer. Unmount cleanup closes a dialog left open by React
45
- * Activity or a removed subtree.
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.open) {
110
+ if (!isDrawerHostOpen(dialog, isModal)) {
62
111
  triggerElementRef.current = document.activeElement;
63
- if (isModal) {
64
- dialog.showModal();
65
- } else {
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.open) {
120
+ if (!isDrawerHostOpen(dialog, isModal)) {
76
121
  return;
77
122
  }
78
123
  return waitForDrawerExit(dialog, () => {
79
- dialog.close();
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 close(): a modal dialog makes the rest of the
88
- // document inert, so focusing the trigger before close() silently fails.
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
- // Close the native dialog on unmount if it is still open. When the drawer is
95
- // mounted inside an <Activity> that flips to mode="hidden", React runs effect
96
- // cleanups (with stale isOpen) instead of re-running the effect with
97
- // isOpen=false. Leaving `open` set would skip showModal() on the next open.
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?.open) {
104
- dialog.close();
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
  /**