@astryxdesign/lab 0.5.0-canary.70988c2 → 0.5.0-canary.73b00a5

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * @file Drawer.tsx
3
- * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock, BaseProps, mergeProps/mergeRefs, themeProps
3
+ * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock/useDrawerDialogPresence, BaseProps, mergeProps/mergeRefs, 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
  *
@@ -22,10 +22,11 @@
22
22
  * - `show()` when `hasScrim={false}` — non-modal overlay; the page behind
23
23
  * stays interactive (e.g. master-detail inspectors).
24
24
  *
25
- * Entry animation uses `@starting-style` + `transition-behavior:
26
- * allow-discrete` (see CLAUDE.md dialog-entry pattern); exit slides out
27
- * before a delayed `dialog.close()` releases the top layer and restores
28
- * focus to the element that opened the drawer.
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.
29
30
  *
30
31
  * Sibling drawers coordinate through a module-level LIFO registry: Escape
31
32
  * closes only the top (last-opened) drawer, and non-modal drawers stack
@@ -1 +1 @@
1
- {"version":3,"file":"Drawer.d.ts","sourceRoot":"","sources":["../../src/Drawer/Drawer.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,EAML,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAsOlD,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,+BAmMb;yBApNe,MAAM"}
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"}
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Drawer.tsx
7
- * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock, BaseProps, mergeProps/mergeRefs, themeProps
7
+ * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock/useDrawerDialogPresence, BaseProps, mergeProps/mergeRefs, 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
  *
@@ -26,10 +26,11 @@
26
26
  * - `show()` when `hasScrim={false}` — non-modal overlay; the page behind
27
27
  * stays interactive (e.g. master-detail inspectors).
28
28
  *
29
- * Entry animation uses `@starting-style` + `transition-behavior:
30
- * allow-discrete` (see CLAUDE.md dialog-entry pattern); exit slides out
31
- * before a delayed `dialog.close()` releases the top layer and restores
32
- * focus to the element that opened the drawer.
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.
33
34
  *
34
35
  * Sibling drawers coordinate through a module-level LIFO registry: Escape
35
36
  * closes only the top (last-opened) drawer, and non-modal drawers stack
@@ -50,6 +51,7 @@ import { IconButton } from '@astryxdesign/core/IconButton';
50
51
  import { useScrollLock } from '@astryxdesign/core/hooks';
51
52
  import { composeEventHandlers, mergeProps, mergeRefs, themeProps } from '@astryxdesign/core/utils';
52
53
  import { overlayPaddingReset } from '@astryxdesign/core/Layout';
54
+ import { useDrawerDialogPresence } from "./useDrawerDialogPresence.js";
53
55
 
54
56
  // =============================================================================
55
57
  // LIFO stacking registry (internal)
@@ -122,14 +124,13 @@ const styles = {
122
124
  krVfgx: "x1ey2m1c",
123
125
  kZKoxP: "xtdtrs8",
124
126
  k1xSpc: "x1s85apg",
125
- k1ekBW: "xayqnl7",
127
+ k1ekBW: "x11xpdln",
126
128
  kIyJzY: "x80gvsz",
127
129
  kAMwcw: "xlr8y92",
128
- kzIqYQ: "xd00j3c",
129
130
  k6CgDc: "xzg1mie",
130
131
  $$css: true
131
132
  },
132
- open: {
133
+ rendered: {
133
134
  k1xSpc: "x78zum5",
134
135
  $$css: true
135
136
  },
@@ -244,9 +245,6 @@ export function Drawer({
244
245
  ...props
245
246
  }) {
246
247
  const dialogRef = useRef(null);
247
- const closeTimeoutRef = useRef(null);
248
- // Element focused when the drawer opened — restored on close.
249
- const triggerElementRef = useRef(null);
250
248
  // Registry identity + latest onOpenChange (stable across re-renders so the
251
249
  // registration effect doesn't churn on every onOpenChange identity change).
252
250
  const drawerId = useId();
@@ -256,70 +254,21 @@ export function Drawer({
256
254
  }, [onOpenChange]);
257
255
  // z-index assigned by the registry on open (non-modal stacking only).
258
256
  const [stackZ, setStackZ] = useState(NON_MODAL_BASE_Z);
257
+ // Whether the panel paints: true while open and for the whole slide-out.
258
+ const [isRendered, setIsRendered] = useState(isOpen);
259
259
 
260
- // Open/close the native dialog. close() is delayed so the slide-out
261
- // transition can play; focus restore happens after close() because a
262
- // modal dialog makes the rest of the document inert (focus() on the
263
- // trigger would silently fail while the dialog is still open).
264
- useEffect(() => {
265
- const dialog = dialogRef.current;
266
- if (!dialog) {
267
- return;
268
- }
269
- if (closeTimeoutRef.current) {
270
- clearTimeout(closeTimeoutRef.current);
271
- closeTimeoutRef.current = null;
272
- }
273
- if (isOpen) {
274
- if (!dialog.open) {
275
- triggerElementRef.current = document.activeElement;
276
- if (hasScrim) {
277
- dialog.showModal();
278
- } else {
279
- dialog.show();
280
- }
281
- // React's autoFocus calls .focus() during commit, before the dialog
282
- // is shown, so it silently fails — honor data-autofocus instead
283
- // (same contract as Dialog).
284
- const autofocusTarget = dialog.querySelector('[data-autofocus]');
285
- if (autofocusTarget) {
286
- autofocusTarget.focus();
287
- }
288
- }
289
- } else if (dialog.open) {
290
- const duration = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 10 : 250;
291
- closeTimeoutRef.current = setTimeout(() => {
292
- dialog.close();
293
- // Return focus to the element that opened the drawer.
294
- triggerElementRef.current?.focus();
295
- triggerElementRef.current = null;
296
- }, duration);
297
- }
298
- return () => {
299
- if (closeTimeoutRef.current) {
300
- clearTimeout(closeTimeoutRef.current);
301
- closeTimeoutRef.current = null;
302
- }
303
- };
304
- }, [isOpen, hasScrim]);
305
-
306
- // Close the native dialog on unmount if it's still open. When the drawer
307
- // is mounted inside an <Activity> that flips to mode="hidden", React runs
308
- // effect cleanups (with a stale isOpen) instead of re-running the effect
309
- // with isOpen=false — leaving the <dialog> `open` would skip showModal()
310
- // on the next open and the drawer could never be re-opened (see
311
- // MobileNavReopen.test.tsx for the original repro). This must be a
312
- // separate unmount-only effect: putting it in the open/close effect above
313
- // would close the dialog on every isOpen flip and cut off the delayed
314
- // slide-out close.
315
- useEffect(() => {
316
- const dialog = dialogRef.current;
317
- return () => {
318
- if (dialog?.open) {
319
- dialog.close();
320
- }
321
- };
322
- }, []);
260
+ // Adjusted during render, not in an effect: the panel has to be rendered in
261
+ // the same commit that targets the open transform, or @starting-style has
262
+ // nothing to animate from.
263
+ if (isOpen && !isRendered) {
264
+ setIsRendered(true);
265
+ }
266
+ useDrawerDialogPresence({
267
+ dialogRef,
268
+ isOpen,
269
+ isModal: hasScrim,
270
+ setIsRendered
271
+ });
323
272
 
324
273
  // LIFO registry membership: register on open, unregister on close or
325
274
  // unmount. The returned z-index stacks non-modal siblings in open order.
@@ -368,8 +317,20 @@ export function Drawer({
368
317
  }, [hasScrim, onOpenChange]);
369
318
  const widthValue = typeof width === 'number' ? `${width}px` : width;
370
319
  const mobileWidth = isFullWidthOnMobile ? MOBILE_WIDTH_FULL : `min(${widthValue}, calc(100dvw - ${MOBILE_PAGE_REVEAL}px))`;
371
- const sideStyle = side === 'start' ? styles.start : styles.end;
372
- const sideOpenStyle = side === 'start' ? styles.startOpen : styles.endOpen;
320
+
321
+ // The side the panel is ANCHORED to, which is the side it must slide back
322
+ // out to. Latched at open, because a consumer commonly derives `side` from
323
+ // the same state that drives `isOpen` (`side={selected?.side ?? 'end'}`):
324
+ // that state clears on close, so the live prop flips mid-exit and the panel
325
+ // teleports to the other edge and slides out the wrong way. Children stay
326
+ // mounted for the exit for the same reason; so does the anchor.
327
+ const exitSideRef = useRef(side);
328
+ if (isOpen) {
329
+ exitSideRef.current = side;
330
+ }
331
+ const anchoredSide = isOpen ? side : exitSideRef.current;
332
+ const sideStyle = anchoredSide === 'start' ? styles.start : styles.end;
333
+ const sideOpenStyle = anchoredSide === 'start' ? styles.startOpen : styles.endOpen;
373
334
 
374
335
  // Filter out native `open` to prevent InvalidStateError when passed
375
336
  const {
@@ -379,8 +340,8 @@ export function Drawer({
379
340
  return /*#__PURE__*/_jsxs("dialog", {
380
341
  ref: mergeRefs(ref, dialogRef),
381
342
  ...mergeProps(themeProps('drawer', {
382
- side
383
- }), stylex.props(styles.dialog, overlayPaddingReset.reset, sideStyle, dynamicStyles.inlineSize(widthValue, mobileWidth), isOpen && styles.open, isOpen && sideOpenStyle, hasScrim ? styles.scrim : dynamicStyles.stackZ(stackZ), hasScrim && isOpen && styles.scrimOpen, xstyle), className, style),
343
+ 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),
384
345
  ...safeProps,
385
346
  "aria-label": label,
386
347
  "aria-modal": hasScrim ? 'true' : undefined,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file useDrawerDialogPresence.ts
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
5
+ * @position Drawer-internal hook; consumed only by Drawer.tsx
6
+ *
7
+ * The drawer has two independent notions of presence:
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.
10
+ *
11
+ * 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.
16
+ *
17
+ * SYNC: When modified, update:
18
+ * - /packages/lab/src/Drawer/Drawer.test.tsx
19
+ * - /.github/scripts/modal-close-visibility.js
20
+ */
21
+ import { type Dispatch, type RefObject, type SetStateAction } from 'react';
22
+ type UseDrawerDialogPresenceOptions = {
23
+ dialogRef: RefObject<HTMLDialogElement | null>;
24
+ isOpen: boolean;
25
+ isModal: boolean;
26
+ setIsRendered: Dispatch<SetStateAction<boolean>>;
27
+ };
28
+ /**
29
+ * Coordinates the native dialog and React-rendered presence for Drawer.
30
+ *
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.
37
+ */
38
+ export declare function useDrawerDialogPresence({ dialogRef, isOpen, isModal, setIsRendered, }: UseDrawerDialogPresenceOptions): void;
39
+ export {};
40
+ //# sourceMappingURL=useDrawerDialogPresence.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,180 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useDrawerDialogPresence.ts
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
9
+ * @position Drawer-internal hook; consumed only by Drawer.tsx
10
+ *
11
+ * The drawer has two independent notions of presence:
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.
14
+ *
15
+ * 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.
20
+ *
21
+ * SYNC: When modified, update:
22
+ * - /packages/lab/src/Drawer/Drawer.test.tsx
23
+ * - /.github/scripts/modal-close-visibility.js
24
+ */
25
+ import { useEffect, useRef } from 'react';
26
+ import { flushSync } from 'react-dom';
27
+
28
+ /** Slack past the computed transition before the backstop gives up waiting. */
29
+ const EXIT_BACKSTOP_BUFFER_MS = 50;
30
+
31
+ /**
32
+ * Hold used when the transition duration cannot be read — an unresolved
33
+ * `var()` outside a real browser. Picking a fixed number would otherwise make
34
+ * an assumption about the consumer's theme.
35
+ */
36
+ const EXIT_FALLBACK_MS = 250;
37
+ /**
38
+ * Coordinates the native dialog and React-rendered presence for Drawer.
39
+ *
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.
46
+ */
47
+ export function useDrawerDialogPresence({
48
+ dialogRef,
49
+ isOpen,
50
+ isModal,
51
+ setIsRendered
52
+ }) {
53
+ // Element focused when the drawer opened — restored on close.
54
+ const triggerElementRef = useRef(null);
55
+ useEffect(() => {
56
+ const dialog = dialogRef.current;
57
+ if (!dialog) {
58
+ return;
59
+ }
60
+ if (isOpen) {
61
+ if (!dialog.open) {
62
+ 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
70
+ // contract as Dialog).
71
+ dialog.querySelector('[data-autofocus]')?.focus();
72
+ }
73
+ return;
74
+ }
75
+ if (!dialog.open) {
76
+ return;
77
+ }
78
+ return waitForDrawerExit(dialog, () => {
79
+ dialog.close();
80
+ // flushSync, not a plain setState: React's default scheduling can land
81
+ // the commit after the next paint, and that one frame is exactly the
82
+ // bug — the panel paints outside the top layer. Both happen in this
83
+ // task, so the browser never gets to paint between them.
84
+ flushSync(() => {
85
+ setIsRendered(false);
86
+ });
87
+ // Return focus after close(): a modal dialog makes the rest of the
88
+ // document inert, so focusing the trigger before close() silently fails.
89
+ triggerElementRef.current?.focus();
90
+ triggerElementRef.current = null;
91
+ });
92
+ }, [dialogRef, isModal, isOpen, setIsRendered]);
93
+
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.
98
+ // This is deliberately separate from the open/close effect: putting it in
99
+ // that cleanup would cut off every delayed slide-out.
100
+ useEffect(() => {
101
+ const dialog = dialogRef.current;
102
+ return () => {
103
+ if (dialog?.open) {
104
+ dialog.close();
105
+ }
106
+ };
107
+ }, [dialogRef]);
108
+ }
109
+
110
+ /**
111
+ * Wait for the panel's transform transition, with a computed-duration
112
+ * backstop. Mirrors BottomSheetPanel.waitForTransition: the native event is
113
+ * authoritative, and the timeout only prevents a lost event from stranding an
114
+ * open dialog.
115
+ */
116
+ function waitForDrawerExit(element, complete) {
117
+ let done = false;
118
+ let timer = null;
119
+ const finish = () => {
120
+ if (done) {
121
+ return;
122
+ }
123
+ done = true;
124
+ if (timer != null) {
125
+ clearTimeout(timer);
126
+ }
127
+ element.removeEventListener('transitionend', handleTransitionEnd);
128
+ element.removeEventListener('transitioncancel', handleTransitionEnd);
129
+ complete();
130
+ };
131
+ const handleTransitionEnd = event => {
132
+ if (event.target === element && event.propertyName === 'transform') {
133
+ finish();
134
+ }
135
+ };
136
+ element.addEventListener('transitionend', handleTransitionEnd);
137
+ element.addEventListener('transitioncancel', handleTransitionEnd);
138
+ const transitionMs = readTransformTransitionMs(element);
139
+ timer = setTimeout(finish, (transitionMs ?? EXIT_FALLBACK_MS) + EXIT_BACKSTOP_BUFFER_MS);
140
+ return () => {
141
+ done = true;
142
+ if (timer != null) {
143
+ clearTimeout(timer);
144
+ }
145
+ element.removeEventListener('transitionend', handleTransitionEnd);
146
+ element.removeEventListener('transitioncancel', handleTransitionEnd);
147
+ };
148
+ }
149
+
150
+ /** Read the transform transition's duration + delay from computed CSS. */
151
+ function readTransformTransitionMs(element) {
152
+ const computed = window.getComputedStyle(element);
153
+ const properties = computed.transitionProperty.split(',').map(value => value.trim());
154
+ const durations = parseTimes(computed.transitionDuration);
155
+ const delays = parseTimes(computed.transitionDelay);
156
+ if (properties.length === 0 || durations.length === 0 || delays.length === 0 || durations.includes(null) || delays.includes(null)) {
157
+ return null;
158
+ }
159
+ return properties.reduce((longest, property, index) => {
160
+ if (property !== 'transform' && property !== 'all') {
161
+ return longest;
162
+ }
163
+ const duration = durations[index % durations.length];
164
+ const delay = delays[index % delays.length];
165
+ return Math.max(longest, (duration ?? 0) + (delay ?? 0));
166
+ }, 0);
167
+ }
168
+ function parseTimes(value) {
169
+ return value.split(',').map(part => {
170
+ const trimmed = part.trim();
171
+ const time = Number.parseFloat(trimmed);
172
+ if (!Number.isFinite(time)) {
173
+ return null;
174
+ }
175
+ if (trimmed.endsWith('ms')) {
176
+ return time;
177
+ }
178
+ return trimmed.endsWith('s') ? time * 1000 : null;
179
+ });
180
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"TransferList.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferList.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"TransferList.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferList.tsx"],"names":[],"mappings":"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"}
@@ -28,24 +28,26 @@ import { VisuallyHidden } from '@astryxdesign/core/VisuallyHidden';
28
28
  import { useAnnounce } from '@astryxdesign/core/hooks';
29
29
  import { useTranslator } from '@astryxdesign/core/i18n';
30
30
  import '@astryxdesign/core/theme/tokens.stylex';
31
- import { borderVars, colorVars, spacingVars, typeScaleVars } from '@astryxdesign/core/theme/tokens.stylex';
31
+ import { borderVars, colorVars, sizeVars, spacingVars, typeScaleVars } from '@astryxdesign/core/theme/tokens.stylex';
32
32
  import { focusOutlineStyles, mergeProps, mergeRefs, themeProps } from '@astryxdesign/core/utils';
33
33
  import { reorderStyles } from "../reorderStyles.js";
34
34
  import "./tokens.stylex.js";
35
35
  import { transferListVars } from "./tokens.stylex.js";
36
+
37
+ // Lucide glyphs the shared icon registry does not carry (there is no `add` or
38
+ // `grip` semantic name). Typed as icon components and rendered through `Icon`
39
+ // so sizing, colour and a11y come from the icon pipeline rather than a raw
40
+ // inline `<svg>`. Removal reuses the registry `close` glyph instead.
36
41
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
37
- function PlusIcon() {
42
+ function PlusIcon(props) {
38
43
  return /*#__PURE__*/_jsxs("svg", {
39
- className: "lucide lucide-plus",
40
- width: 16,
41
- height: 16,
42
44
  viewBox: "0 0 24 24",
43
45
  fill: "none",
44
46
  stroke: "currentColor",
45
47
  strokeWidth: 1.5,
46
48
  strokeLinecap: "round",
47
49
  strokeLinejoin: "round",
48
- "aria-hidden": true,
50
+ ...props,
49
51
  children: [/*#__PURE__*/_jsx("path", {
50
52
  d: "M5 12h14"
51
53
  }), /*#__PURE__*/_jsx("path", {
@@ -53,37 +55,15 @@ function PlusIcon() {
53
55
  })]
54
56
  });
55
57
  }
56
- function RemoveIcon() {
57
- return /*#__PURE__*/_jsxs("svg", {
58
- className: "lucide lucide-x",
59
- width: 16,
60
- height: 16,
61
- viewBox: "0 0 24 24",
62
- fill: "none",
63
- stroke: "currentColor",
64
- strokeWidth: 1.5,
65
- strokeLinecap: "round",
66
- strokeLinejoin: "round",
67
- "aria-hidden": true,
68
- children: [/*#__PURE__*/_jsx("path", {
69
- d: "M18 6 6 18"
70
- }), /*#__PURE__*/_jsx("path", {
71
- d: "m6 6 12 12"
72
- })]
73
- });
74
- }
75
- function GripVerticalIcon() {
58
+ function GripVerticalIcon(props) {
76
59
  return /*#__PURE__*/_jsxs("svg", {
77
- className: "lucide lucide-grip-vertical",
78
- width: 16,
79
- height: 16,
80
60
  viewBox: "0 0 24 24",
81
61
  fill: "none",
82
62
  stroke: "currentColor",
83
63
  strokeWidth: 1.5,
84
64
  strokeLinecap: "round",
85
65
  strokeLinejoin: "round",
86
- "aria-hidden": true,
66
+ ...props,
87
67
  children: [/*#__PURE__*/_jsx("circle", {
88
68
  cx: "9",
89
69
  cy: "5",
@@ -144,6 +124,7 @@ const styles = {
144
124
  },
145
125
  headerAction: {
146
126
  kZKoxP: "xt7dq6l",
127
+ kAzted: "x1gdqauq",
147
128
  k8WAf4: "xt970qd",
148
129
  kg3NbH: "xnjsko4",
149
130
  kaIpWk: "x2u8bby",
@@ -696,7 +677,10 @@ export function TransferList({
696
677
  label: t('@astryx.transferList.addOption', {
697
678
  label: option.label
698
679
  }),
699
- icon: /*#__PURE__*/_jsx(PlusIcon, {}),
680
+ icon: /*#__PURE__*/_jsx(Icon, {
681
+ icon: PlusIcon,
682
+ size: "sm"
683
+ }),
700
684
  size: "sm",
701
685
  variant: "ghost",
702
686
  isDisabled: isTransferDisabled,
@@ -709,7 +693,10 @@ export function TransferList({
709
693
  label: t('@astryx.transferList.removeOption', {
710
694
  label: option.label
711
695
  }),
712
- icon: /*#__PURE__*/_jsx(RemoveIcon, {}),
696
+ icon: /*#__PURE__*/_jsx(Icon, {
697
+ icon: "close",
698
+ size: "sm"
699
+ }),
713
700
  size: "sm",
714
701
  variant: "ghost",
715
702
  isDisabled: isTransferDisabled,
@@ -750,7 +737,10 @@ export function TransferList({
750
737
  }),
751
738
  "aria-describedby": reorderInstructionsId,
752
739
  "aria-pressed": active,
753
- icon: /*#__PURE__*/_jsx(GripVerticalIcon, {}),
740
+ icon: /*#__PURE__*/_jsx(Icon, {
741
+ icon: GripVerticalIcon,
742
+ size: "sm"
743
+ }),
754
744
  size: "sm",
755
745
  variant: "ghost",
756
746
  isDisabled: isReorderDisabled,
@@ -954,7 +944,7 @@ export function TransferList({
954
944
  type: "body",
955
945
  weight: "bold",
956
946
  color: "primary",
957
- children: group || 'Other'
947
+ children: group || t('@astryx.transferList.ungroupedLabel')
958
948
  })
959
949
  }), groupOptions.map(option => transferItem(option, 'available', availableOptions.indexOf(option)))]
960
950
  }, group || 'ungrouped'))
package/dist/lab.css CHANGED
@@ -437,7 +437,6 @@
437
437
  .xumwmo6:not(#\#):not(#\#):not(#\#){transform:translateX(100%)}
438
438
  .x1cb1t30:not(#\#):not(#\#):not(#\#){transform:translateY(-50%)}
439
439
  .xsqj5wx:not(#\#):not(#\#):not(#\#){transform:var(--x-transform)}
440
- .xd00j3c:not(#\#):not(#\#):not(#\#){transition-behavior:allow-discrete}
441
440
  .xkvfbh3:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast-min)}
442
441
  .xuedmi6:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-fast)}
443
442
  .x80gvsz:not(#\#):not(#\#):not(#\#){transition-duration:var(--duration-medium)}
@@ -447,7 +446,7 @@
447
446
  .x19991ni:not(#\#):not(#\#):not(#\#){transition-property:opacity}
448
447
  .xxnu56j:not(#\#):not(#\#):not(#\#){transition-property:stroke-dashoffset}
449
448
  .x1gfj591:not(#\#):not(#\#):not(#\#){transition-property:top,left,width,height}
450
- .xayqnl7:not(#\#):not(#\#):not(#\#){transition-property:transform,display}
449
+ .x11xpdln:not(#\#):not(#\#):not(#\#){transition-property:transform}
451
450
  .x9lcvmn:not(#\#):not(#\#):not(#\#){transition-timing-function:ease-out}
452
451
  .xlr8y92:not(#\#):not(#\#):not(#\#){transition-timing-function:var(--ease-standard)}
453
452
  .x87ps6o:not(#\#):not(#\#):not(#\#){user-select:none}
@@ -535,6 +534,7 @@
535
534
  .xjwf9q1:not(#\#):not(#\#):not(#\#):not(#\#){min-height:24px}
536
535
  .x1cy360x:not(#\#):not(#\#):not(#\#):not(#\#){min-height:3em}
537
536
  .x1uogy3y:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--size-element-md)}
537
+ .x1gdqauq:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--size-element-sm)}
538
538
  .xu95rz8:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--transfer-list-panel-min-block-size)}
539
539
  .x82snj4:not(#\#):not(#\#):not(#\#):not(#\#){min-height:var(--x-minHeight)}
540
540
  .xeuugli:not(#\#):not(#\#):not(#\#):not(#\#){min-width:0}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.5.0-canary.70988c2",
3
+ "version": "0.5.0-canary.73b00a5",
4
4
  "description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@stylexjs/stylex": ">=0.10.0",
48
- "@astryxdesign/core": "0.5.0-canary.70988c2",
48
+ "@astryxdesign/core": "0.5.0-canary.73b00a5",
49
49
  "react": ">=19.0.0",
50
50
  "react-dom": ">=19.0.0"
51
51
  },
@@ -55,7 +55,7 @@
55
55
  "d3-shape": "^3.2.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@astryxdesign/cli": "0.5.0-canary.70988c2",
58
+ "@astryxdesign/cli": "0.5.0-canary.73b00a5",
59
59
  "@types/d3-array": "^3.2.2",
60
60
  "@types/d3-scale": "^4.0.9",
61
61
  "@types/d3-shape": "^3.1.8",
@@ -255,6 +255,10 @@ describe('Drawer', () => {
255
255
  fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
256
256
  // Still open while the slide-out transition plays
257
257
  expect(dialog).toHaveAttribute('open');
258
+ act(() => {
259
+ vi.advanceTimersByTime(250);
260
+ });
261
+ expect(dialog).toHaveAttribute('open');
258
262
  act(() => {
259
263
  vi.advanceTimersByTime(300);
260
264
  });
@@ -264,6 +268,43 @@ describe('Drawer', () => {
264
268
  }
265
269
  });
266
270
 
271
+ it('closes as soon as the slide-out transition ends', () => {
272
+ vi.useFakeTimers();
273
+ try {
274
+ render(<Harness />);
275
+ fireEvent.click(screen.getByRole('button', {name: 'Open inspector'}));
276
+ const dialog = screen.getByRole('dialog', {hidden: true});
277
+
278
+ fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
279
+ expect(dialog).toHaveAttribute('open');
280
+
281
+ // The transition is authoritative — no need to wait out the backstop.
282
+ act(() => {
283
+ fireEvent.transitionEnd(dialog, {propertyName: 'transform'});
284
+ });
285
+ expect(dialog).not.toHaveAttribute('open');
286
+ } finally {
287
+ vi.useRealTimers();
288
+ }
289
+ });
290
+
291
+ it('keeps sliding while a transitionend for another property arrives', () => {
292
+ vi.useFakeTimers();
293
+ try {
294
+ render(<Harness />);
295
+ fireEvent.click(screen.getByRole('button', {name: 'Open inspector'}));
296
+ const dialog = screen.getByRole('dialog', {hidden: true});
297
+
298
+ fireEvent.click(screen.getByRole('button', {name: 'Close inspector'}));
299
+ act(() => {
300
+ fireEvent.transitionEnd(dialog, {propertyName: 'opacity'});
301
+ });
302
+ expect(dialog).toHaveAttribute('open');
303
+ } finally {
304
+ vi.useRealTimers();
305
+ }
306
+ });
307
+
267
308
  it('restores focus to the trigger element on close', () => {
268
309
  vi.useFakeTimers();
269
310
  try {
@@ -576,10 +617,43 @@ describe('Drawer', () => {
576
617
  });
577
618
  });
578
619
 
620
+ describe('exit anchoring', () => {
621
+ it('slides out to the side it opened from, even if the prop flips', () => {
622
+ // The common consumer shape: `side` is derived from the same state that
623
+ // drives isOpen, so it reverts to the default the moment the drawer
624
+ // closes. The panel must still leave by the edge it came in from.
625
+ function Harness() {
626
+ const [side, setSide] = useState<'start' | 'end' | null>(null);
627
+ return (
628
+ <>
629
+ <button type="button" onClick={() => setSide('start')}>
630
+ Open from start
631
+ </button>
632
+ <Drawer
633
+ isOpen={side != null}
634
+ onOpenChange={isOpen => !isOpen && setSide(null)}
635
+ label="Filters"
636
+ side={side ?? 'end'}>
637
+ Content
638
+ </Drawer>
639
+ </>
640
+ );
641
+ }
642
+ render(<Harness />);
643
+ fireEvent.click(screen.getByRole('button', {name: 'Open from start'}));
644
+ const dialog = screen.getByRole('dialog', {hidden: true});
645
+ expect(dialog).toHaveAttribute('data-side', 'start');
646
+
647
+ fireEvent.keyDown(dialog, {key: 'Escape'});
648
+ // Mid-exit: the live prop is now 'end', the anchor must still be 'start'.
649
+ expect(dialog).toHaveAttribute('data-side', 'start');
650
+ });
651
+ });
652
+
579
653
  describe('container padding isolation', () => {
580
654
  it('resets container padding custom properties on the root dialog element', () => {
581
655
  render(
582
- <Drawer isOpen onClose={() => {}} label="Details">
656
+ <Drawer isOpen onOpenChange={() => {}} label="Details">
583
657
  Content
584
658
  </Drawer>,
585
659
  );
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file Drawer.tsx
7
- * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock, BaseProps, mergeProps/mergeRefs, themeProps
7
+ * @input Uses React, StyleX, theme tokens, Icon/IconButton, useScrollLock/useDrawerDialogPresence, BaseProps, mergeProps/mergeRefs, 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
  *
@@ -26,10 +26,11 @@
26
26
  * - `show()` when `hasScrim={false}` — non-modal overlay; the page behind
27
27
  * stays interactive (e.g. master-detail inspectors).
28
28
  *
29
- * Entry animation uses `@starting-style` + `transition-behavior:
30
- * allow-discrete` (see CLAUDE.md dialog-entry pattern); exit slides out
31
- * before a delayed `dialog.close()` releases the top layer and restores
32
- * focus to the element that opened the drawer.
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.
33
34
  *
34
35
  * Sibling drawers coordinate through a module-level LIFO registry: Escape
35
36
  * closes only the top (last-opened) drawer, and non-modal drawers stack
@@ -70,6 +71,7 @@ import {
70
71
  themeProps,
71
72
  } from '@astryxdesign/core/utils';
72
73
  import {overlayPaddingReset} from '@astryxdesign/core/Layout';
74
+ import {useDrawerDialogPresence} from './useDrawerDialogPresence';
73
75
 
74
76
  // =============================================================================
75
77
  // LIFO stacking registry (internal)
@@ -149,22 +151,30 @@ const styles = stylex.create({
149
151
  insetBlockStart: 0,
150
152
  insetBlockEnd: 0,
151
153
  height: '100dvh',
152
- // Closed state. `display` participates in the transition with
153
- // allow-discrete so it flips to none only after the slide-out finishes
154
- // (@starting-style covers the none -> flex entry).
154
+ // Closed state. `display` is owned by React (see `rendered`), not by a
155
+ // discrete `display` transition, so only `transform` animates here.
155
156
  display: 'none',
156
- transitionProperty: 'transform, display',
157
+ transitionProperty: 'transform',
157
158
  transitionDuration: durationVars['--duration-medium'],
158
159
  transitionTimingFunction: easeVars['--ease-standard'],
159
- transitionBehavior: 'allow-discrete',
160
160
  '@media (prefers-reduced-motion: reduce)': {
161
161
  transitionDuration: '0.01s',
162
162
  },
163
163
  },
164
- // Open state applied via isOpen prop, not :where([open]) — attribute
164
+ // Rendered while the drawer is open AND while it slides out. Applied from
165
+ // React state, not from a discrete `display` transition: `close()` drops the
166
+ // dialog out of the top layer, and any ancestor that establishes a
167
+ // containing block for fixed positioning (transform, filter, container-type,
168
+ // contain) then becomes the origin for the panel's `position: fixed`. A
169
+ // panel still painting after `close()` therefore snaps back INTO the layout
170
+ // and covers the page for the rest of the hold. Owning `display` lets the
171
+ // hide land in the same commit as `close()`, so no frame is ever painted
172
+ // outside the top layer.
173
+ //
174
+ // Applied via the isOpen/exit state, not :where([open]) — attribute
165
175
  // selectors have zero specificity and can lose to default styles
166
176
  // depending on CSS source order (same rationale as Dialog/MobileNav).
167
- open: {
177
+ rendered: {
168
178
  display: 'flex',
169
179
  },
170
180
  // start/end transforms flip under RTL so the panel always slides in from
@@ -406,9 +416,6 @@ export function Drawer({
406
416
  ...props
407
417
  }: DrawerProps) {
408
418
  const dialogRef = useRef<HTMLDialogElement>(null);
409
- const closeTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
410
- // Element focused when the drawer opened — restored on close.
411
- const triggerElementRef = useRef<HTMLElement | null>(null);
412
419
  // Registry identity + latest onOpenChange (stable across re-renders so the
413
420
  // registration effect doesn't churn on every onOpenChange identity change).
414
421
  const drawerId = useId();
@@ -418,78 +425,22 @@ export function Drawer({
418
425
  }, [onOpenChange]);
419
426
  // z-index assigned by the registry on open (non-modal stacking only).
420
427
  const [stackZ, setStackZ] = useState(NON_MODAL_BASE_Z);
428
+ // Whether the panel paints: true while open and for the whole slide-out.
429
+ const [isRendered, setIsRendered] = useState(isOpen);
430
+
431
+ // Adjusted during render, not in an effect: the panel has to be rendered in
432
+ // the same commit that targets the open transform, or @starting-style has
433
+ // nothing to animate from.
434
+ if (isOpen && !isRendered) {
435
+ setIsRendered(true);
436
+ }
421
437
 
422
- // Open/close the native dialog. close() is delayed so the slide-out
423
- // transition can play; focus restore happens after close() because a
424
- // modal dialog makes the rest of the document inert (focus() on the
425
- // trigger would silently fail while the dialog is still open).
426
- useEffect(() => {
427
- const dialog = dialogRef.current;
428
- if (!dialog) {
429
- return;
430
- }
431
-
432
- if (closeTimeoutRef.current) {
433
- clearTimeout(closeTimeoutRef.current);
434
- closeTimeoutRef.current = null;
435
- }
436
-
437
- if (isOpen) {
438
- if (!dialog.open) {
439
- triggerElementRef.current =
440
- document.activeElement as HTMLElement | null;
441
- if (hasScrim) {
442
- dialog.showModal();
443
- } else {
444
- dialog.show();
445
- }
446
- // React's autoFocus calls .focus() during commit, before the dialog
447
- // is shown, so it silently fails — honor data-autofocus instead
448
- // (same contract as Dialog).
449
- const autofocusTarget =
450
- dialog.querySelector<HTMLElement>('[data-autofocus]');
451
- if (autofocusTarget) {
452
- autofocusTarget.focus();
453
- }
454
- }
455
- } else if (dialog.open) {
456
- const duration = window.matchMedia('(prefers-reduced-motion: reduce)')
457
- .matches
458
- ? 10
459
- : 250;
460
- closeTimeoutRef.current = setTimeout(() => {
461
- dialog.close();
462
- // Return focus to the element that opened the drawer.
463
- triggerElementRef.current?.focus();
464
- triggerElementRef.current = null;
465
- }, duration);
466
- }
467
-
468
- return () => {
469
- if (closeTimeoutRef.current) {
470
- clearTimeout(closeTimeoutRef.current);
471
- closeTimeoutRef.current = null;
472
- }
473
- };
474
- }, [isOpen, hasScrim]);
475
-
476
- // Close the native dialog on unmount if it's still open. When the drawer
477
- // is mounted inside an <Activity> that flips to mode="hidden", React runs
478
- // effect cleanups (with a stale isOpen) instead of re-running the effect
479
- // with isOpen=false — leaving the <dialog> `open` would skip showModal()
480
- // on the next open and the drawer could never be re-opened (see
481
- // MobileNavReopen.test.tsx for the original repro). This must be a
482
- // separate unmount-only effect: putting it in the open/close effect above
483
- // would close the dialog on every isOpen flip and cut off the delayed
484
- // slide-out close.
485
- useEffect(() => {
486
- const dialog = dialogRef.current;
487
- return () => {
488
- if (dialog?.open) {
489
- dialog.close();
490
- }
491
- };
492
- }, []);
438
+ useDrawerDialogPresence({
439
+ dialogRef,
440
+ isOpen,
441
+ isModal: hasScrim,
442
+ setIsRendered,
443
+ });
493
444
 
494
445
  // LIFO registry membership: register on open, unregister on close or
495
446
  // unmount. The returned z-index stacks non-modal siblings in open order.
@@ -551,8 +502,21 @@ export function Drawer({
551
502
  ? MOBILE_WIDTH_FULL
552
503
  : `min(${widthValue}, calc(100dvw - ${MOBILE_PAGE_REVEAL}px))`;
553
504
 
554
- const sideStyle = side === 'start' ? styles.start : styles.end;
555
- const sideOpenStyle = side === 'start' ? styles.startOpen : styles.endOpen;
505
+ // The side the panel is ANCHORED to, which is the side it must slide back
506
+ // out to. Latched at open, because a consumer commonly derives `side` from
507
+ // the same state that drives `isOpen` (`side={selected?.side ?? 'end'}`):
508
+ // that state clears on close, so the live prop flips mid-exit and the panel
509
+ // teleports to the other edge and slides out the wrong way. Children stay
510
+ // mounted for the exit for the same reason; so does the anchor.
511
+ const exitSideRef = useRef(side);
512
+ if (isOpen) {
513
+ exitSideRef.current = side;
514
+ }
515
+ const anchoredSide = isOpen ? side : exitSideRef.current;
516
+
517
+ const sideStyle = anchoredSide === 'start' ? styles.start : styles.end;
518
+ const sideOpenStyle =
519
+ anchoredSide === 'start' ? styles.startOpen : styles.endOpen;
556
520
 
557
521
  // Filter out native `open` to prevent InvalidStateError when passed
558
522
  const {open: _open, ...safeProps} = props as Record<string, unknown>;
@@ -561,13 +525,13 @@ export function Drawer({
561
525
  <dialog
562
526
  ref={mergeRefs(ref, dialogRef)}
563
527
  {...mergeProps(
564
- themeProps('drawer', {side}),
528
+ themeProps('drawer', {side: anchoredSide}),
565
529
  stylex.props(
566
530
  styles.dialog,
567
531
  overlayPaddingReset.reset,
568
532
  sideStyle,
569
533
  dynamicStyles.inlineSize(widthValue, mobileWidth),
570
- isOpen && styles.open,
534
+ isRendered && styles.rendered,
571
535
  isOpen && sideOpenStyle,
572
536
  hasScrim ? styles.scrim : dynamicStyles.stackZ(stackZ),
573
537
  hasScrim && isOpen && styles.scrimOpen,
@@ -0,0 +1,222 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useDrawerDialogPresence.ts
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
9
+ * @position Drawer-internal hook; consumed only by Drawer.tsx
10
+ *
11
+ * The drawer has two independent notions of presence:
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.
14
+ *
15
+ * 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.
20
+ *
21
+ * SYNC: When modified, update:
22
+ * - /packages/lab/src/Drawer/Drawer.test.tsx
23
+ * - /.github/scripts/modal-close-visibility.js
24
+ */
25
+
26
+ import {
27
+ useEffect,
28
+ useRef,
29
+ type Dispatch,
30
+ type RefObject,
31
+ type SetStateAction,
32
+ } from 'react';
33
+ import {flushSync} from 'react-dom';
34
+
35
+ /** Slack past the computed transition before the backstop gives up waiting. */
36
+ const EXIT_BACKSTOP_BUFFER_MS = 50;
37
+
38
+ /**
39
+ * Hold used when the transition duration cannot be read — an unresolved
40
+ * `var()` outside a real browser. Picking a fixed number would otherwise make
41
+ * an assumption about the consumer's theme.
42
+ */
43
+ const EXIT_FALLBACK_MS = 250;
44
+
45
+ type UseDrawerDialogPresenceOptions = {
46
+ dialogRef: RefObject<HTMLDialogElement | null>;
47
+ isOpen: boolean;
48
+ isModal: boolean;
49
+ setIsRendered: Dispatch<SetStateAction<boolean>>;
50
+ };
51
+
52
+ /**
53
+ * Coordinates the native dialog and React-rendered presence for Drawer.
54
+ *
55
+ * Opening captures the trigger, opens the native dialog, and honours the
56
+ * component's `data-autofocus` contract. Closing waits for the actual
57
+ * transform transition (with a computed-duration backstop), then closes the
58
+ * native dialog and synchronously hides the panel before the browser can paint
59
+ * it outside the top layer. Unmount cleanup closes a dialog left open by React
60
+ * Activity or a removed subtree.
61
+ */
62
+ export function useDrawerDialogPresence({
63
+ dialogRef,
64
+ isOpen,
65
+ isModal,
66
+ setIsRendered,
67
+ }: UseDrawerDialogPresenceOptions): void {
68
+ // Element focused when the drawer opened — restored on close.
69
+ const triggerElementRef = useRef<HTMLElement | null>(null);
70
+
71
+ useEffect(() => {
72
+ const dialog = dialogRef.current;
73
+ if (!dialog) {
74
+ return;
75
+ }
76
+
77
+ if (isOpen) {
78
+ if (!dialog.open) {
79
+ triggerElementRef.current =
80
+ document.activeElement as HTMLElement | null;
81
+ if (isModal) {
82
+ dialog.showModal();
83
+ } else {
84
+ dialog.show();
85
+ }
86
+ // React's autoFocus calls .focus() during commit, before the dialog is
87
+ // shown, so it silently fails — honour data-autofocus instead (same
88
+ // contract as Dialog).
89
+ dialog.querySelector<HTMLElement>('[data-autofocus]')?.focus();
90
+ }
91
+ return;
92
+ }
93
+
94
+ if (!dialog.open) {
95
+ return;
96
+ }
97
+
98
+ return waitForDrawerExit(dialog, () => {
99
+ dialog.close();
100
+ // flushSync, not a plain setState: React's default scheduling can land
101
+ // the commit after the next paint, and that one frame is exactly the
102
+ // bug — the panel paints outside the top layer. Both happen in this
103
+ // task, so the browser never gets to paint between them.
104
+ flushSync(() => {
105
+ setIsRendered(false);
106
+ });
107
+ // Return focus after close(): a modal dialog makes the rest of the
108
+ // document inert, so focusing the trigger before close() silently fails.
109
+ triggerElementRef.current?.focus();
110
+ triggerElementRef.current = null;
111
+ });
112
+ }, [dialogRef, isModal, isOpen, setIsRendered]);
113
+
114
+ // Close the native dialog on unmount if it is still open. When the drawer is
115
+ // mounted inside an <Activity> that flips to mode="hidden", React runs effect
116
+ // cleanups (with stale isOpen) instead of re-running the effect with
117
+ // isOpen=false. Leaving `open` set would skip showModal() on the next open.
118
+ // This is deliberately separate from the open/close effect: putting it in
119
+ // that cleanup would cut off every delayed slide-out.
120
+ useEffect(() => {
121
+ const dialog = dialogRef.current;
122
+ return () => {
123
+ if (dialog?.open) {
124
+ dialog.close();
125
+ }
126
+ };
127
+ }, [dialogRef]);
128
+ }
129
+
130
+ /**
131
+ * Wait for the panel's transform transition, with a computed-duration
132
+ * backstop. Mirrors BottomSheetPanel.waitForTransition: the native event is
133
+ * authoritative, and the timeout only prevents a lost event from stranding an
134
+ * open dialog.
135
+ */
136
+ function waitForDrawerExit(
137
+ element: HTMLDialogElement,
138
+ complete: () => void,
139
+ ): () => void {
140
+ let done = false;
141
+ let timer: ReturnType<typeof setTimeout> | null = null;
142
+
143
+ const finish = () => {
144
+ if (done) {
145
+ return;
146
+ }
147
+ done = true;
148
+ if (timer != null) {
149
+ clearTimeout(timer);
150
+ }
151
+ element.removeEventListener('transitionend', handleTransitionEnd);
152
+ element.removeEventListener('transitioncancel', handleTransitionEnd);
153
+ complete();
154
+ };
155
+
156
+ const handleTransitionEnd = (event: TransitionEvent) => {
157
+ if (event.target === element && event.propertyName === 'transform') {
158
+ finish();
159
+ }
160
+ };
161
+
162
+ element.addEventListener('transitionend', handleTransitionEnd);
163
+ element.addEventListener('transitioncancel', handleTransitionEnd);
164
+
165
+ const transitionMs = readTransformTransitionMs(element);
166
+ timer = setTimeout(
167
+ finish,
168
+ (transitionMs ?? EXIT_FALLBACK_MS) + EXIT_BACKSTOP_BUFFER_MS,
169
+ );
170
+
171
+ return () => {
172
+ done = true;
173
+ if (timer != null) {
174
+ clearTimeout(timer);
175
+ }
176
+ element.removeEventListener('transitionend', handleTransitionEnd);
177
+ element.removeEventListener('transitioncancel', handleTransitionEnd);
178
+ };
179
+ }
180
+
181
+ /** Read the transform transition's duration + delay from computed CSS. */
182
+ function readTransformTransitionMs(element: HTMLElement): number | null {
183
+ const computed = window.getComputedStyle(element);
184
+ const properties = computed.transitionProperty
185
+ .split(',')
186
+ .map(value => value.trim());
187
+ const durations = parseTimes(computed.transitionDuration);
188
+ const delays = parseTimes(computed.transitionDelay);
189
+
190
+ if (
191
+ properties.length === 0 ||
192
+ durations.length === 0 ||
193
+ delays.length === 0 ||
194
+ durations.includes(null) ||
195
+ delays.includes(null)
196
+ ) {
197
+ return null;
198
+ }
199
+
200
+ return properties.reduce((longest, property, index) => {
201
+ if (property !== 'transform' && property !== 'all') {
202
+ return longest;
203
+ }
204
+ const duration = durations[index % durations.length];
205
+ const delay = delays[index % delays.length];
206
+ return Math.max(longest, (duration ?? 0) + (delay ?? 0));
207
+ }, 0);
208
+ }
209
+
210
+ function parseTimes(value: string): Array<number | null> {
211
+ return value.split(',').map(part => {
212
+ const trimmed = part.trim();
213
+ const time = Number.parseFloat(trimmed);
214
+ if (!Number.isFinite(time)) {
215
+ return null;
216
+ }
217
+ if (trimmed.endsWith('ms')) {
218
+ return time;
219
+ }
220
+ return trimmed.endsWith('s') ? time * 1000 : null;
221
+ });
222
+ }
@@ -187,8 +187,14 @@ describe('TransferList', () => {
187
187
  );
188
188
  const addButton = screen.getByRole('button', {name: 'Add Owner'});
189
189
  const removeButton = screen.getByRole('button', {name: 'Remove Name'});
190
- expect(addButton.querySelector('svg')).toHaveClass('lucide-plus');
191
- expect(removeButton.querySelector('svg')).toHaveClass('lucide-x');
190
+ // Glyphs render through the shared Icon primitive rather than a raw
191
+ // inline <svg>: the add glyph is an icon component (astryx-icon on the
192
+ // svg) and remove reuses the registry `close` glyph (astryx-icon on its
193
+ // wrapping span). Assert the icon primitive is present and a glyph drew.
194
+ expect(addButton.querySelector('.astryx-icon')).toBeInTheDocument();
195
+ expect(addButton.querySelector('svg')).toBeInTheDocument();
196
+ expect(removeButton.querySelector('.astryx-icon')).toBeInTheDocument();
197
+ expect(removeButton.querySelector('svg')).toBeInTheDocument();
192
198
 
193
199
  const selectedList = screen.getByRole('list', {
194
200
  name: 'Selected columns',
@@ -199,9 +205,7 @@ describe('TransferList', () => {
199
205
  'button',
200
206
  );
201
207
  expect(rowButtons[0]).toHaveAccessibleName('Reorder Name');
202
- expect(rowButtons[0].querySelector('svg')).toHaveClass(
203
- 'lucide-grip-vertical',
204
- );
208
+ expect(rowButtons[0].querySelector('.astryx-icon')).toBeInTheDocument();
205
209
  expect(rowButtons[1]).toHaveAccessibleName('Remove Name');
206
210
  });
207
211
 
@@ -26,6 +26,7 @@ import {
26
26
  type KeyboardEvent as ReactKeyboardEvent,
27
27
  type PointerEvent as ReactPointerEvent,
28
28
  type ReactNode,
29
+ type SVGProps,
29
30
  } from 'react';
30
31
  import * as stylex from '@stylexjs/stylex';
31
32
  import {flushSync} from 'react-dom';
@@ -44,6 +45,7 @@ import {useTranslator} from '@astryxdesign/core/i18n';
44
45
  import {
45
46
  borderVars,
46
47
  colorVars,
48
+ sizeVars,
47
49
  spacingVars,
48
50
  typeScaleVars,
49
51
  } from '@astryxdesign/core/theme/tokens.stylex';
@@ -56,57 +58,36 @@ import {
56
58
  import {reorderStyles} from '../reorderStyles';
57
59
  import {transferListVars} from './tokens.stylex';
58
60
 
59
- function PlusIcon() {
61
+ // Lucide glyphs the shared icon registry does not carry (there is no `add` or
62
+ // `grip` semantic name). Typed as icon components and rendered through `Icon`
63
+ // so sizing, colour and a11y come from the icon pipeline rather than a raw
64
+ // inline `<svg>`. Removal reuses the registry `close` glyph instead.
65
+ function PlusIcon(props: SVGProps<SVGSVGElement>) {
60
66
  return (
61
67
  <svg
62
- className="lucide lucide-plus"
63
- width={16}
64
- height={16}
65
68
  viewBox="0 0 24 24"
66
69
  fill="none"
67
70
  stroke="currentColor"
68
71
  strokeWidth={1.5}
69
72
  strokeLinecap="round"
70
73
  strokeLinejoin="round"
71
- aria-hidden>
74
+ {...props}>
72
75
  <path d="M5 12h14" />
73
76
  <path d="M12 5v14" />
74
77
  </svg>
75
78
  );
76
79
  }
77
80
 
78
- function RemoveIcon() {
81
+ function GripVerticalIcon(props: SVGProps<SVGSVGElement>) {
79
82
  return (
80
83
  <svg
81
- className="lucide lucide-x"
82
- width={16}
83
- height={16}
84
84
  viewBox="0 0 24 24"
85
85
  fill="none"
86
86
  stroke="currentColor"
87
87
  strokeWidth={1.5}
88
88
  strokeLinecap="round"
89
89
  strokeLinejoin="round"
90
- aria-hidden>
91
- <path d="M18 6 6 18" />
92
- <path d="m6 6 12 12" />
93
- </svg>
94
- );
95
- }
96
-
97
- function GripVerticalIcon() {
98
- return (
99
- <svg
100
- className="lucide lucide-grip-vertical"
101
- width={16}
102
- height={16}
103
- viewBox="0 0 24 24"
104
- fill="none"
105
- stroke="currentColor"
106
- strokeWidth={1.5}
107
- strokeLinecap="round"
108
- strokeLinejoin="round"
109
- aria-hidden>
90
+ {...props}>
110
91
  <circle cx="9" cy="5" r="1" />
111
92
  <circle cx="9" cy="12" r="1" />
112
93
  <circle cx="9" cy="19" r="1" />
@@ -276,7 +257,13 @@ const styles = stylex.create({
276
257
  borderBlockEndColor: colorVars['--color-border'],
277
258
  },
278
259
  headerAction: {
260
+ // A text-link treatment, but the hit target still has to clear the WCAG
261
+ // 2.5.8 24px floor. The link keeps `height: auto` so its baseline aligns
262
+ // with the panel heading; `minBlockSize` gives the button a >=24px target
263
+ // (the sm element height) without changing where the text sits, and the
264
+ // width already exceeds 24px.
279
265
  height: 'auto',
266
+ minBlockSize: sizeVars['--size-element-sm'],
280
267
  paddingBlock: 0,
281
268
  paddingInline: 0,
282
269
  borderRadius: 0,
@@ -1036,7 +1023,7 @@ export function TransferList<T extends string = string>({
1036
1023
  return (
1037
1024
  <IconButton
1038
1025
  label={t('@astryx.transferList.addOption', {label: option.label})}
1039
- icon={<PlusIcon />}
1026
+ icon={<Icon icon={PlusIcon} size="sm" />}
1040
1027
  size="sm"
1041
1028
  variant="ghost"
1042
1029
  isDisabled={isTransferDisabled}
@@ -1049,7 +1036,7 @@ export function TransferList<T extends string = string>({
1049
1036
  return (
1050
1037
  <IconButton
1051
1038
  label={t('@astryx.transferList.removeOption', {label: option.label})}
1052
- icon={<RemoveIcon />}
1039
+ icon={<Icon icon="close" size="sm" />}
1053
1040
  size="sm"
1054
1041
  variant="ghost"
1055
1042
  isDisabled={isTransferDisabled}
@@ -1096,7 +1083,7 @@ export function TransferList<T extends string = string>({
1096
1083
  label={t('@astryx.transferList.reorderOption', {label: option.label})}
1097
1084
  aria-describedby={reorderInstructionsId}
1098
1085
  aria-pressed={active}
1099
- icon={<GripVerticalIcon />}
1086
+ icon={<Icon icon={GripVerticalIcon} size="sm" />}
1100
1087
  size="sm"
1101
1088
  variant="ghost"
1102
1089
  isDisabled={isReorderDisabled}
@@ -1346,7 +1333,7 @@ export function TransferList<T extends string = string>({
1346
1333
  role="presentation"
1347
1334
  {...stylex.props(styles.groupHeading)}>
1348
1335
  <Text type="body" weight="bold" color="primary">
1349
- {group || 'Other'}
1336
+ {group || t('@astryx.transferList.ungroupedLabel')}
1350
1337
  </Text>
1351
1338
  </li>
1352
1339
  )}