@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.
- package/dist/Drawer/Drawer.d.ts +6 -5
- package/dist/Drawer/Drawer.d.ts.map +1 -1
- package/dist/Drawer/Drawer.js +39 -78
- package/dist/Drawer/useDrawerDialogPresence.d.ts +40 -0
- package/dist/Drawer/useDrawerDialogPresence.d.ts.map +1 -0
- package/dist/Drawer/useDrawerDialogPresence.js +180 -0
- package/dist/TransferList/TransferList.d.ts.map +1 -1
- package/dist/TransferList/TransferList.js +24 -34
- package/dist/lab.css +2 -2
- package/package.json +3 -3
- package/src/Drawer/Drawer.test.tsx +75 -1
- package/src/Drawer/Drawer.tsx +54 -90
- package/src/Drawer/useDrawerDialogPresence.ts +222 -0
- package/src/TransferList/TransferList.test.tsx +9 -5
- package/src/TransferList/TransferList.tsx +20 -33
package/dist/Drawer/Drawer.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Drawer.tsx
|
|
3
|
-
* @input Uses React, StyleX, theme tokens, Icon/IconButton, 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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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"}
|
package/dist/Drawer/Drawer.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file Drawer.tsx
|
|
7
|
-
* @input Uses React, StyleX, theme tokens, Icon/IconButton, 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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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: "
|
|
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
|
-
|
|
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
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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),
|
|
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":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAWL,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"TransferList.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferList.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAWL,KAAK,SAAS,EAEf,MAAM,OAAO,CAAC;AAGf,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAoElD,MAAM,WAAW,kBAAkB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM;IAC3D,0DAA0D;IAC1D,KAAK,EAAE,CAAC,CAAC;IACT,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,WAAW,CAAC,EAAE,SAAS,CAAC;IACxB,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,4EAA4E;IAC5E,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,gEAAgE;IAChE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,IAAI,CACxE,SAAS,CAAC,cAAc,CAAC,EACzB,UAAU,CACX;IACC,uCAAuC;IACvC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAChC,gDAAgD;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,iDAAiD;IACjD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6DAA6D;IAC7D,OAAO,EAAE,SAAS,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1C,yEAAyE;IACzE,KAAK,EAAE,SAAS,CAAC,EAAE,CAAC;IACpB,uEAAuE;IACvE,QAAQ,EAAE,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,KAAK,IAAI,CAAC;IACxC,0DAA0D;IAC1D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sEAAsE;IACtE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mDAAmD;IACnD,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,+EAA+E;IAC/E,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,8CAA8C;IAC9C,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yEAAyE;IACzE,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC,KAAK,SAAS,CAAC;IAC5D,yCAAyC;IACzC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,0CAA0C;IAC1C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,iDAAiD;IACjD,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAoND;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,EAAE,EACtD,KAAK,EACL,aAAqB,EACrB,WAAW,EACX,OAAO,EACP,KAAK,EACL,QAAQ,EACR,aAAa,EAAE,sBAAsB,EACrC,cAAc,EAAE,uBAAuB,EACvC,SAAiB,EACjB,WAAW,EACX,iBAAiB,EAAE,0BAA0B,EAC7C,aAAoB,EACpB,YAAoB,EACpB,QAAgB,EAChB,YAAY,EACZ,iBAAiB,EAAE,0BAA0B,EAC7C,kBAAkB,EAAE,2BAA2B,EAC/C,aAAa,EAAE,sBAAsB,EACrC,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,EACH,IAAI,EACJ,iBAAiB,EAAE,cAAc,EACjC,kBAAkB,EAAE,eAAe,EACnC,GAAG,SAAS,EACb,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,SAAS,CAo8BlC;yBA/9Be,YAAY"}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 || '
|
|
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
|
-
.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
656
|
+
<Drawer isOpen onOpenChange={() => {}} label="Details">
|
|
583
657
|
Content
|
|
584
658
|
</Drawer>,
|
|
585
659
|
);
|
package/src/Drawer/Drawer.tsx
CHANGED
|
@@ -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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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`
|
|
153
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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={<
|
|
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 || '
|
|
1336
|
+
{group || t('@astryx.transferList.ungroupedLabel')}
|
|
1350
1337
|
</Text>
|
|
1351
1338
|
</li>
|
|
1352
1339
|
)}
|