@astryxdesign/lab 0.5.0-canary.7b60efa → 0.5.0-canary.7e6b73b

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,6 +1,6 @@
1
1
  /**
2
2
  * @file TransferList.tsx
3
- * @input Controlled option data, React DOM portals, local action glyphs, and shared Lab reorder styles
3
+ * @input Controlled option data, React DOM portals, local action glyphs, shared Lab reorder styles, and the core i18n catalog
4
4
  * @output Exports TransferList with immediate transfers, vertical reordering, and responsive scroll ownership
5
5
  * @position Lab collection input; consumed by index.ts, TransferListSelector, docs, and tests
6
6
  *
@@ -83,7 +83,7 @@ export interface TransferListProps<T extends string = string> extends Omit<BaseP
83
83
  * />
84
84
  * ```
85
85
  */
86
- export declare function TransferList<T extends string = string>({ label, isLabelHidden, description, options, value, onChange, selectedLabel, availableLabel, hasSearch, searchLabel, searchPlaceholder, isReorderable, hasSelectAll, hasClear, renderOption, selectedEmptyText, availableEmptyText, noResultsText, xstyle, className, style, ref, role, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...restProps }: TransferListProps<T>): ReactNode;
86
+ export declare function TransferList<T extends string = string>({ label, isLabelHidden, description, options, value, onChange, selectedLabel: selectedLabelFromProps, availableLabel: availableLabelFromProps, hasSearch, searchLabel, searchPlaceholder: searchPlaceholderFromProps, isReorderable, hasSelectAll, hasClear, renderOption, selectedEmptyText: selectedEmptyTextFromProps, availableEmptyText: availableEmptyTextFromProps, noResultsText: noResultsTextFromProps, xstyle, className, style, ref, role, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, ...restProps }: TransferListProps<T>): ReactNode;
87
87
  export declare namespace TransferList {
88
88
  var displayName: string;
89
89
  }
@@ -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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"}