@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.
- 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 +2 -2
- package/dist/TransferList/TransferList.d.ts.map +1 -1
- package/dist/TransferList/TransferList.js +128 -78
- package/dist/TransferList/TransferListSelector.d.ts +2 -2
- package/dist/TransferList/TransferListSelector.d.ts.map +1 -1
- package/dist/TransferList/TransferListSelector.js +24 -7
- package/dist/lab.css +3 -2
- package/package.json +6 -6
- 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 +123 -79
- package/src/TransferList/TransferListSelector.test.tsx +45 -2
- package/src/TransferList/TransferListSelector.tsx +34 -6
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,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file TransferList.tsx
|
|
3
|
-
* @input Controlled option data, React DOM portals, local action glyphs,
|
|
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":"AAIA;;;;;;;;;;GAUG;AAEH,OAAO,EAWL,KAAK,SAAS,
|
|
1
|
+
{"version":3,"file":"TransferList.d.ts","sourceRoot":"","sources":["../../src/TransferList/TransferList.tsx"],"names":[],"mappings":"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"}
|