@trackunit/react-drawer 2.6.71 → 2.6.73

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/index.cjs.js CHANGED
@@ -308,7 +308,7 @@ const PANEL_FOCUSABLE_SELECTOR = [
308
308
  * ```
309
309
  * @param {DrawerProps} props - The props for the Drawer component
310
310
  */
311
- const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "data-testid": dataTestId, className, renderInPortal = false, containerClassName, ariaLabel, ariaLabelledBy, }) => {
311
+ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "data-testid": dataTestId, className, renderInPortal = false, containerClassName, ariaLabel, ariaLabelledBy, onExitComplete, }) => {
312
312
  const { isSm } = reactComponents.useViewportBreakpoints();
313
313
  const shouldUsePortal = !isSm || renderInPortal;
314
314
  const panelRef = react.useRef(null);
@@ -316,6 +316,10 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
316
316
  // the portal (or first paint) has attached the DOM node — refs alone do not.
317
317
  const [panelElement, setPanelElement] = react.useState(null);
318
318
  const isOpenRef = react.useRef(isOpen);
319
+ const onExitCompleteRef = react.useRef(onExitComplete);
320
+ // Guards against firing onExitComplete twice for the same close cycle if the real
321
+ // transitionend and the headless RAF fallback both resolve the same exit-complete.
322
+ const hasFiredExitCompleteRef = react.useRef(false);
319
323
  const [drawerState, dispatch] = react.useReducer(drawerAnimationReducer, {
320
324
  ...INITIAL_DRAWER_ANIMATION_STATE,
321
325
  // Mount off-screen when initially open so the enter slide still plays.
@@ -325,7 +329,22 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
325
329
  react.useEffect(() => {
326
330
  isOpenRef.current = isOpen;
327
331
  }, [isOpen]);
332
+ react.useEffect(() => {
333
+ onExitCompleteRef.current = onExitComplete;
334
+ }, [onExitComplete]);
335
+ const fireExitComplete = react.useCallback(() => {
336
+ if (hasFiredExitCompleteRef.current) {
337
+ return;
338
+ }
339
+ hasFiredExitCompleteRef.current = true;
340
+ onExitCompleteRef.current?.();
341
+ }, []);
328
342
  react.useLayoutEffect(() => {
343
+ if (!isOpen) {
344
+ // A new close cycle is starting — arm the guard so this cycle's genuine
345
+ // exit-complete (real transitionend or headless RAF fallback) can fire.
346
+ hasFiredExitCompleteRef.current = false;
347
+ }
329
348
  dispatch({ type: isOpen ? "open" : "close" });
330
349
  }, [isOpen]);
331
350
  // Defer the open flip until after paint. A single useLayoutEffect flip commits
@@ -360,9 +379,23 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
360
379
  const runningAnimations = typeof panelNode.getAnimations === "function" ? panelNode.getAnimations() : [];
361
380
  if (runningAnimations.length === 0) {
362
381
  dispatch({ type: "transitionEnd" });
382
+ fireExitComplete();
363
383
  }
364
384
  });
365
385
  return () => cancelAnimationFrame(rafId);
386
+ }, [drawerState.mode, drawerState.shouldRender, fireExitComplete]);
387
+ // If transitionend is suppressed while a CSS animation is reported as running
388
+ // (Playwright/iframe), unmount after the motion duration so close cannot stick.
389
+ react.useEffect(() => {
390
+ if (drawerState.mode !== "closed" || !drawerState.shouldRender) {
391
+ return;
392
+ }
393
+ const timeoutId = window.setTimeout(() => {
394
+ if (!isOpenRef.current) {
395
+ dispatch({ type: "transitionEnd" });
396
+ }
397
+ }, DRAWER_TRANSITION_DURATION_MS + 50);
398
+ return () => window.clearTimeout(timeoutId);
366
399
  }, [drawerState.mode, drawerState.shouldRender]);
367
400
  // If transitionend is suppressed, settle enter after the motion duration so focus
368
401
  // still activates. Do not use a zero-delay rAF here — getAnimations() can be empty
@@ -405,9 +438,20 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
405
438
  return;
406
439
  }
407
440
  if (transitionEvent.propertyName === "transform") {
441
+ // A transform transitionend while mode is still "closed" but isOpen has already
442
+ // flipped back to true is an interrupted exit (the reopen's openVisual is still
443
+ // behind its double-rAF), not an exit-complete. Same guard the headless rAF
444
+ // fallback above already applies.
445
+ if (drawerState.mode === "closed" && isOpenRef.current) {
446
+ return;
447
+ }
448
+ const isGenuineExitComplete = drawerState.mode === "closed";
408
449
  dispatch({ type: "transitionEnd" });
450
+ if (isGenuineExitComplete) {
451
+ fireExitComplete();
452
+ }
409
453
  }
410
- }, []);
454
+ }, [drawerState.mode, fireExitComplete]);
411
455
  if (!drawerState.shouldRender) {
412
456
  return null;
413
457
  }
@@ -477,6 +521,48 @@ const useDrawer = (props) => {
477
521
  }), [dismissProp]);
478
522
  const [internalIsOpen, setIsOpen] = react.useState(defaultOpen ?? false);
479
523
  const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
524
+ // `false` initially / whenever never opened. Flips to `true` the moment the
525
+ // resolved `isOpen` value above transitions `true -> false`, and back to
526
+ // `false` either when it transitions `false -> true` again or once Drawer's
527
+ // `onExitComplete` signals the panel has genuinely finished exiting
528
+ // (`handleExitComplete`). See `UseDrawerReturnValue.isExiting`.
529
+ const [isExiting, setIsExiting] = react.useState(false);
530
+ // Tracks the resolved `isOpen` value as of the last render for which
531
+ // `isExiting` was derived below, so the render-phase check can detect a
532
+ // transition without an effect.
533
+ const [prevIsOpen, setPrevIsOpen] = react.useState(isOpen);
534
+ // Derive `isExiting` from the resolved `isOpen` value's own transitions —
535
+ // the same value used everywhere else in this hook — rather than only from
536
+ // the imperative `close()` / `open()` call sites below. This is what covers
537
+ // a controlled consumer flipping the `isOpen` prop directly (the documented
538
+ // `useDrawer({ isOpen: Boolean(selectedId) })` pattern): `close()` / `open()`
539
+ // alone only ever observe imperative calls, not a controlled prop changing
540
+ // for some other reason (e.g. selecting a different row).
541
+ //
542
+ // This calls `setState` conditionally during the render body — React's
543
+ // "adjust state during render" pattern
544
+ // (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)
545
+ // — rather than a passive `useEffect` (e.g. `useWatch`). A passive effect
546
+ // only runs one commit AFTER the render where `isOpen` changes, leaving an
547
+ // intermediate commit where `isOpen` has already changed but `isExiting`
548
+ // hasn't yet. `useHold` — `isExiting`'s primary documented consumer — reads
549
+ // both in its own effect and would observe that stale `isExiting: false`
550
+ // there, permanently overwriting its held ref with the already-changed
551
+ // value before `isExiting` ever flips `true`. Calling `setState` here
552
+ // instead makes React detect the change and re-render immediately, before
553
+ // committing or running any effects, so `isOpen` and `isExiting` always
554
+ // land together in the same effective commit that `useHold` observes.
555
+ //
556
+ // `handleExitComplete` (below) is a second, separate way `isExiting` flips
557
+ // back to `false` — the normal close -> fully-exited path. It never fights
558
+ // with this derivation: by the time it fires, `isOpen` has already settled
559
+ // to `false` and `prevIsOpen` has already caught up to it (from the render
560
+ // that flipped `isExiting` to `true` in the first place), so `isOpen ===
561
+ // prevIsOpen` and this block is a no-op on the next render either way.
562
+ if (isOpen !== prevIsOpen) {
563
+ setPrevIsOpen(isOpen);
564
+ setIsExiting(!isOpen); // true when transitioning to closed, false when transitioning to open
565
+ }
480
566
  const isPendingCloseRef = react.useRef(false);
481
567
  const onCloseRef = react.useRef(onClose);
482
568
  const onOpenRef = react.useRef(onOpen);
@@ -493,6 +579,11 @@ const useDrawer = (props) => {
493
579
  onCloseRef.current?.(event, reason);
494
580
  onOpenChangeRef.current?.(false, event, reason);
495
581
  }, []);
582
+ // Fired by `Drawer`'s internal `onExitComplete` prop exactly once per genuine
583
+ // close→fully-exited cycle — the normal path back to `isExiting: false`.
584
+ const handleExitComplete = react.useCallback(() => {
585
+ setIsExiting(false);
586
+ }, []);
496
587
  const requestClose = react.useCallback((event, reason) => {
497
588
  if (onBeforeCloseRef.current) {
498
589
  if (isPendingCloseRef.current) {
@@ -517,6 +608,12 @@ const useDrawer = (props) => {
517
608
  onOpenRef.current?.();
518
609
  onOpenChangeRef.current?.(true);
519
610
  setIsOpen(true);
611
+ // `isExiting` releases automatically here via the render-phase derivation
612
+ // above (the resolved `isOpen` transitioning back to `true`) — a reopen
613
+ // before the exit signal fires releases any hold right away, since the
614
+ // interrupted exit's transitionend resolves as an enter-settle, so
615
+ // `onExitComplete` correctly never fires for that cycle. See
616
+ // `UseDrawerReturnValue.isExiting`.
520
617
  }, []);
521
618
  const toggle = react.useCallback(() => {
522
619
  if (isOpen) {
@@ -551,7 +648,21 @@ const useDrawer = (props) => {
551
648
  trapFocus,
552
649
  position,
553
650
  floatingUi,
554
- }), [isOpen, open, close, toggle, requestClose, variant, trapFocus, position, floatingUi]);
651
+ isExiting,
652
+ onExitComplete: handleExitComplete,
653
+ }), [
654
+ isOpen,
655
+ open,
656
+ close,
657
+ toggle,
658
+ requestClose,
659
+ variant,
660
+ trapFocus,
661
+ position,
662
+ floatingUi,
663
+ isExiting,
664
+ handleExitComplete,
665
+ ]);
555
666
  };
556
667
 
557
668
  /**
package/index.esm.js CHANGED
@@ -2,7 +2,7 @@ import { jsx, jsxs } from 'react/jsx-runtime';
2
2
  import { registerTranslations, useNamespaceTranslation } from '@trackunit/i18n-library-translation';
3
3
  import { useMergeRefs, FloatingFocusManager, useFloating, useDismiss, useInteractions } from '@floating-ui/react';
4
4
  import { useViewportBreakpoints, Portal, IconButton, Icon, MoreMenu } from '@trackunit/react-components';
5
- import { useRef, useState, useReducer, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
5
+ import { useRef, useState, useReducer, useEffect, useCallback, useLayoutEffect, useMemo } from 'react';
6
6
  import { cva } from '@trackunit/css-class-variance-utilities';
7
7
  import { twMerge } from 'tailwind-merge';
8
8
 
@@ -306,7 +306,7 @@ const PANEL_FOCUSABLE_SELECTOR = [
306
306
  * ```
307
307
  * @param {DrawerProps} props - The props for the Drawer component
308
308
  */
309
- const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "data-testid": dataTestId, className, renderInPortal = false, containerClassName, ariaLabel, ariaLabelledBy, }) => {
309
+ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "data-testid": dataTestId, className, renderInPortal = false, containerClassName, ariaLabel, ariaLabelledBy, onExitComplete, }) => {
310
310
  const { isSm } = useViewportBreakpoints();
311
311
  const shouldUsePortal = !isSm || renderInPortal;
312
312
  const panelRef = useRef(null);
@@ -314,6 +314,10 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
314
314
  // the portal (or first paint) has attached the DOM node — refs alone do not.
315
315
  const [panelElement, setPanelElement] = useState(null);
316
316
  const isOpenRef = useRef(isOpen);
317
+ const onExitCompleteRef = useRef(onExitComplete);
318
+ // Guards against firing onExitComplete twice for the same close cycle if the real
319
+ // transitionend and the headless RAF fallback both resolve the same exit-complete.
320
+ const hasFiredExitCompleteRef = useRef(false);
317
321
  const [drawerState, dispatch] = useReducer(drawerAnimationReducer, {
318
322
  ...INITIAL_DRAWER_ANIMATION_STATE,
319
323
  // Mount off-screen when initially open so the enter slide still plays.
@@ -323,7 +327,22 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
323
327
  useEffect(() => {
324
328
  isOpenRef.current = isOpen;
325
329
  }, [isOpen]);
330
+ useEffect(() => {
331
+ onExitCompleteRef.current = onExitComplete;
332
+ }, [onExitComplete]);
333
+ const fireExitComplete = useCallback(() => {
334
+ if (hasFiredExitCompleteRef.current) {
335
+ return;
336
+ }
337
+ hasFiredExitCompleteRef.current = true;
338
+ onExitCompleteRef.current?.();
339
+ }, []);
326
340
  useLayoutEffect(() => {
341
+ if (!isOpen) {
342
+ // A new close cycle is starting — arm the guard so this cycle's genuine
343
+ // exit-complete (real transitionend or headless RAF fallback) can fire.
344
+ hasFiredExitCompleteRef.current = false;
345
+ }
327
346
  dispatch({ type: isOpen ? "open" : "close" });
328
347
  }, [isOpen]);
329
348
  // Defer the open flip until after paint. A single useLayoutEffect flip commits
@@ -358,9 +377,23 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
358
377
  const runningAnimations = typeof panelNode.getAnimations === "function" ? panelNode.getAnimations() : [];
359
378
  if (runningAnimations.length === 0) {
360
379
  dispatch({ type: "transitionEnd" });
380
+ fireExitComplete();
361
381
  }
362
382
  });
363
383
  return () => cancelAnimationFrame(rafId);
384
+ }, [drawerState.mode, drawerState.shouldRender, fireExitComplete]);
385
+ // If transitionend is suppressed while a CSS animation is reported as running
386
+ // (Playwright/iframe), unmount after the motion duration so close cannot stick.
387
+ useEffect(() => {
388
+ if (drawerState.mode !== "closed" || !drawerState.shouldRender) {
389
+ return;
390
+ }
391
+ const timeoutId = window.setTimeout(() => {
392
+ if (!isOpenRef.current) {
393
+ dispatch({ type: "transitionEnd" });
394
+ }
395
+ }, DRAWER_TRANSITION_DURATION_MS + 50);
396
+ return () => window.clearTimeout(timeoutId);
364
397
  }, [drawerState.mode, drawerState.shouldRender]);
365
398
  // If transitionend is suppressed, settle enter after the motion duration so focus
366
399
  // still activates. Do not use a zero-delay rAF here — getAnimations() can be empty
@@ -403,9 +436,20 @@ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, children, "d
403
436
  return;
404
437
  }
405
438
  if (transitionEvent.propertyName === "transform") {
439
+ // A transform transitionend while mode is still "closed" but isOpen has already
440
+ // flipped back to true is an interrupted exit (the reopen's openVisual is still
441
+ // behind its double-rAF), not an exit-complete. Same guard the headless rAF
442
+ // fallback above already applies.
443
+ if (drawerState.mode === "closed" && isOpenRef.current) {
444
+ return;
445
+ }
446
+ const isGenuineExitComplete = drawerState.mode === "closed";
406
447
  dispatch({ type: "transitionEnd" });
448
+ if (isGenuineExitComplete) {
449
+ fireExitComplete();
450
+ }
407
451
  }
408
- }, []);
452
+ }, [drawerState.mode, fireExitComplete]);
409
453
  if (!drawerState.shouldRender) {
410
454
  return null;
411
455
  }
@@ -475,6 +519,48 @@ const useDrawer = (props) => {
475
519
  }), [dismissProp]);
476
520
  const [internalIsOpen, setIsOpen] = useState(defaultOpen ?? false);
477
521
  const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
522
+ // `false` initially / whenever never opened. Flips to `true` the moment the
523
+ // resolved `isOpen` value above transitions `true -> false`, and back to
524
+ // `false` either when it transitions `false -> true` again or once Drawer's
525
+ // `onExitComplete` signals the panel has genuinely finished exiting
526
+ // (`handleExitComplete`). See `UseDrawerReturnValue.isExiting`.
527
+ const [isExiting, setIsExiting] = useState(false);
528
+ // Tracks the resolved `isOpen` value as of the last render for which
529
+ // `isExiting` was derived below, so the render-phase check can detect a
530
+ // transition without an effect.
531
+ const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
532
+ // Derive `isExiting` from the resolved `isOpen` value's own transitions —
533
+ // the same value used everywhere else in this hook — rather than only from
534
+ // the imperative `close()` / `open()` call sites below. This is what covers
535
+ // a controlled consumer flipping the `isOpen` prop directly (the documented
536
+ // `useDrawer({ isOpen: Boolean(selectedId) })` pattern): `close()` / `open()`
537
+ // alone only ever observe imperative calls, not a controlled prop changing
538
+ // for some other reason (e.g. selecting a different row).
539
+ //
540
+ // This calls `setState` conditionally during the render body — React's
541
+ // "adjust state during render" pattern
542
+ // (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes)
543
+ // — rather than a passive `useEffect` (e.g. `useWatch`). A passive effect
544
+ // only runs one commit AFTER the render where `isOpen` changes, leaving an
545
+ // intermediate commit where `isOpen` has already changed but `isExiting`
546
+ // hasn't yet. `useHold` — `isExiting`'s primary documented consumer — reads
547
+ // both in its own effect and would observe that stale `isExiting: false`
548
+ // there, permanently overwriting its held ref with the already-changed
549
+ // value before `isExiting` ever flips `true`. Calling `setState` here
550
+ // instead makes React detect the change and re-render immediately, before
551
+ // committing or running any effects, so `isOpen` and `isExiting` always
552
+ // land together in the same effective commit that `useHold` observes.
553
+ //
554
+ // `handleExitComplete` (below) is a second, separate way `isExiting` flips
555
+ // back to `false` — the normal close -> fully-exited path. It never fights
556
+ // with this derivation: by the time it fires, `isOpen` has already settled
557
+ // to `false` and `prevIsOpen` has already caught up to it (from the render
558
+ // that flipped `isExiting` to `true` in the first place), so `isOpen ===
559
+ // prevIsOpen` and this block is a no-op on the next render either way.
560
+ if (isOpen !== prevIsOpen) {
561
+ setPrevIsOpen(isOpen);
562
+ setIsExiting(!isOpen); // true when transitioning to closed, false when transitioning to open
563
+ }
478
564
  const isPendingCloseRef = useRef(false);
479
565
  const onCloseRef = useRef(onClose);
480
566
  const onOpenRef = useRef(onOpen);
@@ -491,6 +577,11 @@ const useDrawer = (props) => {
491
577
  onCloseRef.current?.(event, reason);
492
578
  onOpenChangeRef.current?.(false, event, reason);
493
579
  }, []);
580
+ // Fired by `Drawer`'s internal `onExitComplete` prop exactly once per genuine
581
+ // close→fully-exited cycle — the normal path back to `isExiting: false`.
582
+ const handleExitComplete = useCallback(() => {
583
+ setIsExiting(false);
584
+ }, []);
494
585
  const requestClose = useCallback((event, reason) => {
495
586
  if (onBeforeCloseRef.current) {
496
587
  if (isPendingCloseRef.current) {
@@ -515,6 +606,12 @@ const useDrawer = (props) => {
515
606
  onOpenRef.current?.();
516
607
  onOpenChangeRef.current?.(true);
517
608
  setIsOpen(true);
609
+ // `isExiting` releases automatically here via the render-phase derivation
610
+ // above (the resolved `isOpen` transitioning back to `true`) — a reopen
611
+ // before the exit signal fires releases any hold right away, since the
612
+ // interrupted exit's transitionend resolves as an enter-settle, so
613
+ // `onExitComplete` correctly never fires for that cycle. See
614
+ // `UseDrawerReturnValue.isExiting`.
518
615
  }, []);
519
616
  const toggle = useCallback(() => {
520
617
  if (isOpen) {
@@ -549,7 +646,21 @@ const useDrawer = (props) => {
549
646
  trapFocus,
550
647
  position,
551
648
  floatingUi,
552
- }), [isOpen, open, close, toggle, requestClose, variant, trapFocus, position, floatingUi]);
649
+ isExiting,
650
+ onExitComplete: handleExitComplete,
651
+ }), [
652
+ isOpen,
653
+ open,
654
+ close,
655
+ toggle,
656
+ requestClose,
657
+ variant,
658
+ trapFocus,
659
+ position,
660
+ floatingUi,
661
+ isExiting,
662
+ handleExitComplete,
663
+ ]);
553
664
  };
554
665
 
555
666
  /**
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry.js","sourceRoot":"","sources":["../../../../../libs/react/drawer/migrations/entry.ts"],"names":[],"mappings":"","sourcesContent":["export {};\n"]}
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@trackunit/react-drawer",
3
- "version": "2.6.71",
3
+ "version": "2.6.73",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
7
7
  "node": ">=24.x"
8
8
  },
9
9
  "dependencies": {
10
- "@trackunit/react-components": "2.13.24",
10
+ "@trackunit/react-components": "2.13.25",
11
11
  "@trackunit/css-class-variance-utilities": "2.0.14",
12
12
  "@trackunit/i18n-library-translation": "2.4.63",
13
13
  "@floating-ui/react": "^0.26.25",
@@ -73,6 +73,17 @@ export interface DrawerProps extends CommonProps {
73
73
  * the drawer body renders a visible heading so the two stay in sync.
74
74
  */
75
75
  ariaLabelledBy?: string;
76
+ /**
77
+ * Internal signal fired exactly once per genuine open→close→fully-exited cycle,
78
+ * once the panel has actually finished animating (or would have, in headless /
79
+ * test environments where CSS transitions are suppressed) off-screen. Spread from
80
+ * `useDrawer()`, which uses it to flip its `isExiting` state back to `false`.
81
+ *
82
+ * Does not fire for the enter-settle transitionend, on initial mount, while
83
+ * remaining closed, or more than once for the same close cycle even if both the
84
+ * real `transitionend` and the headless RAF fallback resolve it.
85
+ */
86
+ readonly onExitComplete?: () => void;
76
87
  }
77
88
  /**
78
89
  * Drawers slide in from the left or right edge of the viewport as either a modal
@@ -135,6 +146,6 @@ export interface DrawerProps extends CommonProps {
135
146
  * @param {DrawerProps} props - The props for the Drawer component
136
147
  */
137
148
  export declare const Drawer: {
138
- ({ isOpen, variant, trapFocus, position, floatingUi, children, "data-testid": dataTestId, className, renderInPortal, containerClassName, ariaLabel, ariaLabelledBy, }: DrawerProps): ReactElement | null;
149
+ ({ isOpen, variant, trapFocus, position, floatingUi, children, "data-testid": dataTestId, className, renderInPortal, containerClassName, ariaLabel, ariaLabelledBy, onExitComplete, }: DrawerProps): ReactElement | null;
139
150
  displayName: string;
140
151
  };
@@ -98,6 +98,33 @@ export type UseDrawerReturnValue = {
98
98
  * its `FloatingFocusManager`.
99
99
  */
100
100
  readonly floatingUi: DrawerFloatingUiProps;
101
+ /**
102
+ * Whether the drawer is currently in its close→fully-exited cycle. `false`
103
+ * initially and whenever the drawer has never been opened.
104
+ *
105
+ * Derived from the resolved `isOpen` value's own transitions (the same value
106
+ * used everywhere else in the hook — `controlledIsOpen ?? internalIsOpen`),
107
+ * not merely from the imperative `open()` / `close()` call sites. Flips to
108
+ * `true` the moment `isOpen` transitions `true -> false` — whether via
109
+ * `close()` / `requestClose()` committing (after any `onBeforeClose` guard
110
+ * has resolved `true`) or a controlled consumer flipping its `isOpen` prop
111
+ * directly — and back to `false` the moment `isOpen` transitions
112
+ * `false -> true` again (an interrupted exit releases any hold immediately,
113
+ * since the interrupted exit's `transitionend` resolves as an enter-settle
114
+ * and never signals exit-complete), or when the panel has genuinely finished
115
+ * animating off-screen (wired internally via `onExitComplete`, below).
116
+ *
117
+ * Useful for content that wants to survive the exit animation — e.g. via the
118
+ * shared `useHold(value, isExiting)` primitive — by holding its last non-null
119
+ * value while `isExiting` is `true`.
120
+ */
121
+ readonly isExiting: boolean;
122
+ /**
123
+ * Internal signal spread onto `Drawer`'s `onExitComplete` prop. `Drawer` calls
124
+ * this exactly once per genuine close→fully-exited cycle, which flips
125
+ * `isExiting` back to `false`. Not intended for direct use by consumers.
126
+ */
127
+ readonly onExitComplete: () => void;
101
128
  };
102
129
  /**
103
130
  * Hook for managing Drawer open/close state, dismiss handling, and floating UI wiring.