@trackunit/react-drawer 2.6.6 → 2.6.9

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.esm.js CHANGED
@@ -1,11 +1,16 @@
1
1
  import { jsx, jsxs } from 'react/jsx-runtime';
2
- import { registerTranslations } from '@trackunit/i18n-library-translation';
3
- import { useViewportBreakpoints, Portal, Icon } from '@trackunit/react-components';
4
- import { useEffect, useReducer, useRef, useCallback } from 'react';
2
+ import { registerTranslations, useNamespaceTranslation } from '@trackunit/i18n-library-translation';
3
+ import { useMergeRefs, FloatingFocusManager, useFloating, useDismiss, useInteractions } from '@floating-ui/react';
4
+ import { useViewportBreakpoints, Portal, IconButton, Icon, MoreMenu } from '@trackunit/react-components';
5
+ import { useRef, useState, useReducer, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
5
6
  import { cvaMerge } from '@trackunit/css-class-variance-utilities';
7
+ import { twMerge } from 'tailwind-merge';
6
8
 
7
9
  var defaultTranslations = {
8
-
10
+ "drawer.header.back": "Back",
11
+ "drawer.header.close": "Close",
12
+ "drawer.header.forward": "Forward",
13
+ "drawer.header.moreMenu": "More actions"
9
14
  };
10
15
 
11
16
  /** The translation namespace for this library */
@@ -39,6 +44,10 @@ const translations = {
39
44
  th: () => import('./translation17.esm.js'),
40
45
  },
41
46
  };
47
+ /**
48
+ * Local useTranslation for this specific library
49
+ */
50
+ const useTranslation = () => useNamespaceTranslation(namespace);
42
51
  /**
43
52
  * Registers the translations for this library
44
53
  */
@@ -49,34 +58,18 @@ const setupLibraryTranslations = () => {
49
58
  /**
50
59
  * Overlay Component
51
60
  *
61
+ * Purely visual dimming backdrop rendered behind an open modal `<Drawer />`. Dismiss
62
+ * handling (Escape, outside-press) is owned by `<Drawer />`'s Floating UI wiring —
63
+ * clicking the overlay dispatches `onClose` via `useDismiss`'s `outsidePress`
64
+ * detection, not via a click handler on this component. Rendered only when the parent
65
+ * drawer resolves to `variant="modal"`.
66
+ *
52
67
  * @param {object} props - The Overlay component properties
53
68
  * @param {boolean} props.open - Open status of the Overlay
54
- * @param {Function} props.onClose - Callback function when Overlay is closed
55
- * @returns {ReactElement|null} The Overlay component
69
+ * @returns {ReactElement} The Overlay component
56
70
  */
57
- const Overlay = ({ open, onClose }) => {
58
- useEffect(() => {
59
- if (!onClose) {
60
- return;
61
- }
62
- const handleEscape = (event) => {
63
- if (event.key === "Escape") {
64
- onClose();
65
- }
66
- };
67
- if (open) {
68
- window.addEventListener("keyup", handleEscape);
69
- }
70
- return () => {
71
- window.removeEventListener("keyup", handleEscape);
72
- };
73
- }, [open, onClose]);
74
- const handleClick = () => {
75
- if (onClose) {
76
- onClose();
77
- }
78
- };
79
- return (jsx("div", { "aria-hidden": open, className: cvaOverlayContainer({ open }), "data-testid": "drawer-overlay", onClick: handleClick }));
71
+ const Overlay = ({ open }) => {
72
+ return jsx("div", { "aria-hidden": open, className: cvaOverlayContainer({ open }), "data-testid": "drawer-overlay" });
80
73
  };
81
74
  const cvaOverlayContainer = cvaMerge([
82
75
  "absolute",
@@ -84,281 +77,499 @@ const cvaOverlayContainer = cvaMerge([
84
77
  "items-center",
85
78
  "justify-center",
86
79
  "inset-0",
87
- "bg-black/30",
88
- "bg-opacity-50",
80
+ "bg-black/50",
89
81
  "transition-opacity",
90
- "duration-500",
91
- "ease-[cubic-bezier(0.32_0.72_0_1)]",
82
+ // Match panel motion so backdrop and slide stay in sync.
83
+ "duration-400",
84
+ "[transition-timing-function:cubic-bezier(0.4,0,0.2,1)]",
92
85
  "opacity-0",
86
+ // Parent viewport is pointer-events-none; re-enable so modal dismiss works.
87
+ "pointer-events-none",
93
88
  ], {
94
89
  variants: {
95
90
  open: {
96
- true: "z-overlay opacity-100",
97
- false: "z-hidden pointer-events-none opacity-0",
91
+ true: "z-overlay pointer-events-auto opacity-100",
92
+ false: "z-hidden opacity-0",
98
93
  },
99
94
  },
100
95
  });
101
96
 
97
+ /**
98
+ * Clip layer for the slide. Right-side enter uses `translateX(100%)`, which would
99
+ * otherwise extend past the edge and expand scroll width — shifting the page.
100
+ *
101
+ * - `portaled` → `fixed` (viewport-relative; escapes parent layout)
102
+ * - in-tree → `absolute` (parent must establish a containing block, e.g. `relative`)
103
+ */
104
+ const cvaDrawerViewport = cvaMerge([
105
+ "inset-0",
106
+ "z-overlay",
107
+ "overflow-hidden",
108
+ "overscroll-none",
109
+ // Let non-modal drawers keep the page interactive; panel/overlay opt back in.
110
+ "pointer-events-none",
111
+ ], {
112
+ variants: {
113
+ portaled: {
114
+ true: ["fixed"],
115
+ false: ["absolute"],
116
+ },
117
+ },
118
+ defaultVariants: {
119
+ portaled: true,
120
+ },
121
+ });
102
122
  const cvaDrawer = cvaMerge([
103
123
  "z-overlay",
104
124
  "pointer-events-auto",
105
125
  "absolute",
106
126
  "flex-col",
107
- "transition-all",
108
- "duration-300",
109
- "ease-in-out",
110
127
  "bg-white",
128
+ // Transform + transition are applied via getDrawerMotionStyle (inline) so
129
+ // motion does not depend on host Tailwind picking up arbitrary utilities.
130
+ //
131
+ // Width contract: every drawer opens at the same width at the same viewport.
132
+ // On mobile the panel spans the viewport; from `sm` (480 px in this repo)
133
+ // up it locks to a fixed 28rem regardless of viewport size. Consumers that
134
+ // genuinely need a different width pass a width utility via `className` and
135
+ // `twMerge` (inside `cvaMerge`) lets the caller's utility win at its
136
+ // breakpoint. `max-w` is a safety cap so no override can exceed the
137
+ // viewport.
138
+ "w-full",
139
+ "sm:w-[28rem]",
140
+ "max-w-[100dvw]",
111
141
  ], {
112
142
  variants: {
113
143
  position: {
114
- left: ["left-0", "top-0", "h-full", "w-fit"],
115
- right: ["right-0", "top-0", "h-full", "w-fit"],
116
- top: ["left-0", "top-0", "h-fit", "w-full", "pb-2"],
117
- bottom: ["bottom-0", "left-0", "right-0", "max-h-screen", "w-full", "pt-3"],
144
+ left: ["left-0", "top-0", "h-full"],
145
+ right: ["right-0", "top-0", "h-full"],
118
146
  },
119
147
  mode: {
120
- open: [], // No additional classes needed; position determines translation
121
- closed: [], // Translation handled in compoundVariants
148
+ // shadow toggles with mode so it fades out alongside the slide-out
149
+ // transform and doesn't leave a residual band bleeding into the
150
+ // viewport once the drawer is fully off-screen.
151
+ open: ["shadow-lg"],
152
+ closed: ["shadow-none"],
122
153
  },
123
154
  },
124
- compoundVariants: [
125
- {
126
- mode: "open",
127
- position: "left",
128
- className: ["translate-x-0"],
129
- },
130
- {
131
- mode: "closed",
132
- position: "left",
133
- className: ["-translate-x-full"],
134
- },
135
- {
136
- mode: "open",
137
- position: "right",
138
- className: "translate-x-0",
139
- },
140
- {
141
- mode: "closed",
142
- position: "right",
143
- className: "translate-x-full",
144
- },
145
- {
146
- mode: "open",
147
- position: "top",
148
- className: "translate-y-0",
149
- },
150
- {
151
- mode: "closed",
152
- position: "top",
153
- className: "-translate-y-full",
154
- },
155
- {
156
- mode: "open",
157
- position: "bottom",
158
- className: "translate-y-0",
159
- },
160
- {
161
- mode: "closed",
162
- position: "bottom",
163
- className: "translate-y-full",
164
- },
165
- ],
166
155
  defaultVariants: {
167
156
  position: "left",
168
157
  mode: "closed",
169
158
  },
170
159
  });
171
- const cvaDrawerContent = cvaMerge(["flex", "flex-col", "h-full", "shadow-lg"], {
160
+ const cvaDrawerContent = cvaMerge(["flex", "flex-col", "h-full"], {
172
161
  variants: {
173
162
  position: {
174
163
  left: "rounded-r-lg",
175
164
  right: "rounded-l-lg",
176
- top: "rounded-b-lg",
177
- bottom: ["max-h-[calc(100dvh-10px)]", "rounded-t-lg"],
178
165
  },
179
166
  },
180
167
  });
181
168
 
182
- const TRANSITION_DURATION = 100;
183
- const DEFAULT_POSITION = "left";
184
- const NOOP$1 = () => void 0;
185
- const drawerReducer = (state, action) => {
169
+ const INITIAL_DRAWER_ANIMATION_STATE = {
170
+ shouldRender: false,
171
+ mode: "closed",
172
+ enterSettled: false,
173
+ };
174
+ /** Reducer managing the Drawer panel's mount / enter / exit animation lifecycle. */
175
+ const drawerAnimationReducer = (state, action) => {
186
176
  switch (action.type) {
187
177
  case "open":
188
- return { shouldRender: true, mode: "open" };
178
+ // Already fully open ignore duplicate syncs (avoids a closed flash).
179
+ if (state.shouldRender && state.mode === "open") {
180
+ return state;
181
+ }
182
+ // Already mounted off-screen (initial enter frame or mid-close) — keep it;
183
+ // `openVisual` after reflow drives the slide-in.
184
+ if (state.shouldRender) {
185
+ return { ...state, enterSettled: false };
186
+ }
187
+ // Fresh mount: render off-screen first so the enter transition has a from-state.
188
+ return { shouldRender: true, mode: "closed", enterSettled: false };
189
+ case "openVisual":
190
+ return state.mode === "open" ? state : { ...state, mode: "open", enterSettled: false };
189
191
  case "close":
190
- return { ...state, mode: "closed" };
192
+ return state.mode === "closed" && !state.enterSettled ? state : { ...state, mode: "closed", enterSettled: false };
191
193
  case "transitionEnd":
192
- return state.mode === "closed" ? { ...state, shouldRender: false } : state;
194
+ if (state.mode === "closed") {
195
+ return { shouldRender: false, mode: "closed", enterSettled: false };
196
+ }
197
+ // Enter finished — safe to activate focus without scrollIntoView fighting the slide.
198
+ return state.enterSettled ? state : { ...state, enterSettled: true };
193
199
  default:
194
200
  return state;
195
201
  }
196
202
  };
203
+
204
+ /**
205
+ * Side drawers travel farther than sheets (often 400px+). Use a standard
206
+ * decelerate curve with no overshoot (y-values stay in 0–1) so the panel does
207
+ * not slide past flush and bounce back — that looked like a gap on the right.
208
+ */
209
+ const DRAWER_TRANSITION_DURATION_MS = 400;
210
+ const DRAWER_TRANSITION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
211
+ const DRAWER_MOTION_TRANSITION = `transform ${DRAWER_TRANSITION_DURATION_MS}ms ${DRAWER_TRANSITION_EASING}`;
197
212
  /**
198
- * Drawers components can be switched between open and closed states.
199
- * They start closed but can be temporarily opened, appearing on top of other content until the user chooses a section.
200
- * To close the drawer, users can either click outside of it or press the Esc key.
213
+ * Inline transform/transition for the drawer panel.
214
+ *
215
+ * Kept as inline styles (same approach as Sheet) so enter/exit motion does not
216
+ * depend on Tailwind emitting arbitrary `transition-[…]` utilities into the
217
+ * host app's CSS bundle — missing those utilities makes the panel appear/disappear
218
+ * with no slide.
219
+ *
220
+ * Only `transform` is transitioned — animating `box-shadow` alongside a wide
221
+ * slide makes the stop feel abrupt when the shadow pops in.
222
+ */
223
+ const getDrawerMotionStyle = ({ mode, position, }) => ({
224
+ transform: mode === "open" ? "translateX(0)" : position === "left" ? "translateX(-100%)" : "translateX(100%)",
225
+ transition: DRAWER_MOTION_TRANSITION,
226
+ });
227
+
228
+ /** First tabbable control inside the panel — used after enter settles (preventScroll). */
229
+ const PANEL_FOCUSABLE_SELECTOR = [
230
+ "button:not([disabled])",
231
+ "a[href]",
232
+ "input:not([disabled])",
233
+ "select:not([disabled])",
234
+ "textarea:not([disabled])",
235
+ '[tabindex]:not([tabindex="-1"])',
236
+ ].join(", ");
237
+ /**
238
+ * Drawers slide in from the left or right edge of the viewport as either a modal
239
+ * dialog or a docked inspector panel.
201
240
  *
202
241
  * ### When to use
203
- * - For secondary content or navigation that doesn't need to be always visible
204
- * - For filters, settings panels, or detail views that slide in from the side
242
+ * - For secondary content that doesn't need to be always visible
243
+ * - For inspector panels or item detail views that slide in from the side
205
244
  * - When you need to preserve context of the underlying page
206
245
  *
207
246
  * ### When not to use
208
247
  * - For critical actions requiring user confirmation (use Modal instead)
209
248
  * - For simple tooltips or small contextual information (use Popover)
249
+ * - To show a table selection and bulk actions (use ActionSheet instead)
210
250
  *
211
- * @example Basic drawer with content
251
+ * ### API
252
+ * `Drawer` is a presentation component. Call `useDrawer()` to own the drawer's
253
+ * open state, dismiss handling, and (optional) `onBeforeClose` guard, then spread
254
+ * its return value onto `<Drawer>`.
255
+ *
256
+ * @example Basic modal drawer with the standard toolbar
212
257
  * ```tsx
213
- * import { Drawer } from "@trackunit/react-drawer";
258
+ * import { Drawer, DrawerHeader, useDrawer } from "@trackunit/react-drawer";
214
259
  * import { Button } from "@trackunit/react-components";
215
- * import { useState } from "react";
216
260
  *
217
261
  * const FilterDrawer = () => {
218
- * const [isOpen, setIsOpen] = useState(false);
262
+ * const drawer = useDrawer({ position: "right", variant: "modal" });
219
263
  *
220
264
  * return (
221
265
  * <>
222
- * <Button onClick={() => setIsOpen(true)}>Open Filters</Button>
223
- * <Drawer
224
- * open={isOpen}
225
- * onClose={() => setIsOpen(false)}
226
- * position="right"
227
- * >
228
- * <div className="p-4">
229
- * <h2>Filter Options</h2>
230
- * <p>Filter controls go here</p>
231
- * </div>
266
+ * <Button onClick={drawer.open}>Open Filters</Button>
267
+ * <Drawer {...drawer} ariaLabel="Filters">
268
+ * <DrawerHeader onClickClose={drawer.close} />
269
+ * <div className="p-4">Filter controls go here</div>
232
270
  * </Drawer>
233
271
  * </>
234
272
  * );
235
273
  * };
236
274
  * ```
237
- * @example Drawer without overlay for side panels
275
+ * @example Guard dismissal with `onBeforeClose`
276
+ * ```tsx
277
+ * const drawer = useDrawer({
278
+ * variant: "modal",
279
+ * onBeforeClose: async () => (await confirmDiscard()) === "discard",
280
+ * });
281
+ * ```
282
+ * @example Non-modal inspector — background stays interactive
238
283
  * ```tsx
239
- * import { Drawer } from "@trackunit/react-drawer";
284
+ * const drawer = useDrawer({
285
+ * isOpen: Boolean(selectedAssetId),
286
+ * onClose: () => setSelectedAssetId(null),
287
+ * position: "right",
288
+ * variant: "default",
289
+ * });
240
290
  *
241
- * const SidePanel = ({ isOpen, content }) => (
242
- * <Drawer
243
- * open={isOpen}
244
- * hasOverlay={false}
245
- * position="left"
246
- * keepMountedWhenClosed={true}
247
- * >
248
- * {content}
249
- * </Drawer>
250
- * );
291
+ * <Drawer {...drawer} ariaLabelledBy="asset-inspector-title">
292
+ * <h2 id="asset-inspector-title">{selectedAsset?.name}</h2>
293
+ * </Drawer>
251
294
  * ```
252
295
  * @param {DrawerProps} props - The props for the Drawer component
253
296
  */
254
- const Drawer = ({ open = false, // Default to closed
255
- onClose = NOOP$1, onOpen = NOOP$1, hasOverlay = true, position = DEFAULT_POSITION, children, "data-testid": dataTestId, className, renderInPortal = false, keepMountedWhenClosed = false, containerClassName, ref, ...others }) => {
297
+ const Drawer = ({ isOpen, variant, trapFocus, position, floatingUi, open: _open, close: _close, toggle: _toggle, requestClose: _requestClose, children, "data-testid": dataTestId, className, renderInPortal = false, containerClassName, ariaLabel, ariaLabelledBy, ...others }) => {
256
298
  const { isSm } = useViewportBreakpoints();
257
299
  const shouldUsePortal = !isSm || renderInPortal;
258
- const [drawerState, dispatch] = useReducer(drawerReducer, {
259
- shouldRender: open,
260
- mode: open ? "open" : "closed",
300
+ const panelRef = useRef(null);
301
+ // State copy of the panel node so the enter-animation layout effect re-runs once
302
+ // the portal (or first paint) has attached the DOM node — refs alone do not.
303
+ const [panelElement, setPanelElement] = useState(null);
304
+ const isOpenRef = useRef(isOpen);
305
+ const [drawerState, dispatch] = useReducer(drawerAnimationReducer, {
306
+ ...INITIAL_DRAWER_ANIMATION_STATE,
307
+ // Mount off-screen when initially open so the enter slide still plays.
308
+ shouldRender: isOpen,
309
+ mode: "closed",
261
310
  });
262
- const onOpenRef = useRef(onOpen);
263
311
  useEffect(() => {
264
- onOpenRef.current = onOpen;
265
- }, [onOpen]);
312
+ isOpenRef.current = isOpen;
313
+ }, [isOpen]);
314
+ useLayoutEffect(() => {
315
+ dispatch({ type: isOpen ? "open" : "close" });
316
+ }, [isOpen]);
317
+ // Defer the open flip until after paint. A single useLayoutEffect flip commits
318
+ // the open transform before the first paint (no slide). Double-rAF waits until
319
+ // the off-screen baseline has been painted, then opens.
266
320
  useEffect(() => {
267
- if (open) {
268
- dispatch({ type: "open" });
269
- setTimeout(() => {
270
- onOpenRef.current();
271
- }, TRANSITION_DURATION);
321
+ if (!isOpen || !drawerState.shouldRender || drawerState.mode === "open" || !panelElement) {
322
+ return;
272
323
  }
273
- else {
274
- dispatch({ type: "close" });
324
+ let raf2Id = 0;
325
+ const raf1Id = requestAnimationFrame(() => {
326
+ raf2Id = requestAnimationFrame(() => {
327
+ dispatch({ type: "openVisual" });
328
+ });
329
+ });
330
+ return () => {
331
+ cancelAnimationFrame(raf1Id);
332
+ cancelAnimationFrame(raf2Id);
333
+ };
334
+ }, [isOpen, drawerState.shouldRender, drawerState.mode, panelElement]);
335
+ // Headless / test environments suppress CSS transitions, so transitionend never
336
+ // fires on close. After one frame, unmount when no transform animation ran.
337
+ useEffect(() => {
338
+ if (drawerState.mode !== "closed" || !drawerState.shouldRender) {
339
+ return;
340
+ }
341
+ const rafId = requestAnimationFrame(() => {
342
+ const panelNode = panelRef.current;
343
+ if (!panelNode || isOpenRef.current) {
344
+ return;
345
+ }
346
+ const runningAnimations = typeof panelNode.getAnimations === "function" ? panelNode.getAnimations() : [];
347
+ if (runningAnimations.length === 0) {
348
+ dispatch({ type: "transitionEnd" });
349
+ }
350
+ });
351
+ return () => cancelAnimationFrame(rafId);
352
+ }, [drawerState.mode, drawerState.shouldRender]);
353
+ // If transitionend is suppressed, settle enter after the motion duration so focus
354
+ // still activates. Do not use a zero-delay rAF here — getAnimations() can be empty
355
+ // for a frame before the CSS transition starts, which would focus mid-slide and
356
+ // reintroduce the right-edge scrollIntoView gap/bounce.
357
+ useEffect(() => {
358
+ if (drawerState.mode !== "open" || drawerState.enterSettled) {
359
+ return;
275
360
  }
276
- }, [open]);
361
+ const timeoutId = window.setTimeout(() => {
362
+ dispatch({ type: "transitionEnd" });
363
+ }, DRAWER_TRANSITION_DURATION_MS + 50);
364
+ return () => window.clearTimeout(timeoutId);
365
+ }, [drawerState.mode, drawerState.enterSettled]);
366
+ // Keep FloatingFocusManager enabled (so outside stays aria-hidden during enter) but
367
+ // skip its autofocus via initialFocus={-1}. After the slide settles, move focus
368
+ // ourselves with preventScroll so the browser does not scrollIntoView an off-screen panel.
369
+ // Bail out if focus is already inside the panel — the panel is interactive during the
370
+ // slide, so a user who clicked or typed into a control in the first ~400ms would
371
+ // otherwise have focus yanked to the first tabbable element mid-interaction.
372
+ useEffect(() => {
373
+ if (!drawerState.enterSettled || variant !== "modal" || !trapFocus) {
374
+ return;
375
+ }
376
+ const panelNode = panelRef.current;
377
+ if (!panelNode || panelNode.contains(document.activeElement)) {
378
+ return;
379
+ }
380
+ const focusTarget = panelNode.querySelector(PANEL_FOCUSABLE_SELECTOR);
381
+ if (focusTarget instanceof HTMLElement) {
382
+ focusTarget.focus({ preventScroll: true });
383
+ }
384
+ }, [drawerState.enterSettled, variant, trapFocus]);
385
+ const handlePanelElement = useCallback((node) => {
386
+ setPanelElement(node instanceof HTMLDivElement ? node : null);
387
+ }, []);
388
+ const mergedPanelRef = useMergeRefs([panelRef, floatingUi.refs.setFloating, handlePanelElement]);
277
389
  const handleAnimationEnd = useCallback((transitionEvent) => {
390
+ if (transitionEvent.target !== transitionEvent.currentTarget) {
391
+ return;
392
+ }
278
393
  if (transitionEvent.propertyName === "transform") {
279
394
  dispatch({ type: "transitionEnd" });
280
395
  }
281
396
  }, []);
282
- if (!drawerState.shouldRender && !keepMountedWhenClosed) {
397
+ if (!drawerState.shouldRender) {
283
398
  return null;
284
399
  }
285
- const content = (jsxs("div", { className: containerClassName, children: [hasOverlay ? jsx(Overlay, { onClose: onClose, open: drawerState.mode === "open" }) : null, jsx("div", { className: cvaDrawer({
286
- mode: drawerState.mode,
287
- position: position,
288
- className,
289
- }), "data-testid": dataTestId, onTransitionEnd: handleAnimationEnd, ref: ref, ...others, children: jsx("div", { className: cvaDrawerContent({ position }), children: children }) })] }));
400
+ const isModal = variant === "modal";
401
+ // Keep FloatingFocusManager mounted for the whole shouldRender lifetime so the
402
+ // panel DOM node is not remounted when mode flips (which would cancel the CSS
403
+ // transition). Autofocus is deferred via initialFocus={-1} + preventScroll focus
404
+ // after enterSettled focusing mid-slide triggers scrollIntoView (right-edge bounce).
405
+ const showFocusManager = isModal && trapFocus && drawerState.shouldRender;
406
+ const floatingProps = floatingUi.getFloatingProps();
407
+ const panel = (jsx("div", { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-modal": isModal ? true : undefined, className: cvaDrawer({
408
+ mode: drawerState.mode,
409
+ position: position,
410
+ className,
411
+ }), "data-testid": dataTestId, onTransitionEnd: handleAnimationEnd, ref: mergedPanelRef, role: isModal ? "dialog" : "complementary", ...floatingProps, ...others, style: {
412
+ ...getDrawerMotionStyle({ mode: drawerState.mode, position }),
413
+ }, children: jsx("div", { className: cvaDrawerContent({ position }), children: children }) }));
414
+ const content = (jsxs("div", { className: cvaDrawerViewport({ portaled: shouldUsePortal, className: containerClassName }), "data-testid": "drawer-viewport", children: [isModal ? jsx(Overlay, { open: isOpen ? drawerState.mode === "open" : false }) : null, showFocusManager ? (jsx(FloatingFocusManager, { context: floatingUi.context, initialFocus: -1, returnFocus: true, children: panel })) : (panel)] }));
290
415
  return shouldUsePortal ? jsx(Portal, { children: content }) : content;
291
416
  };
292
417
  Drawer.displayName = "Drawer";
293
418
 
294
- const NOOP = () => void 0;
295
- const getIconName = (open, position) => {
296
- switch (position) {
297
- case "left":
298
- return open ? "ChevronLeft" : "ChevronRight";
299
- case "right":
300
- return open ? "ChevronRight" : "ChevronLeft";
301
- case "top":
302
- return open ? "ChevronUp" : "ChevronDown";
303
- case "bottom":
304
- return open ? "ChevronDown" : "ChevronUp";
305
- default:
306
- return open ? "ChevronLeft" : "ChevronRight";
307
- }
419
+ const DEFAULT_POSITION = "left";
420
+ const DEFAULT_VARIANT = "modal";
421
+ /**
422
+ * Hook for managing Drawer open/close state, dismiss handling, and floating UI wiring.
423
+ *
424
+ * Aligns with `useSheet` and `useModal`: consumers use `useDrawer()` to own the
425
+ * drawer's state and callbacks, then spread the return value onto `Drawer`.
426
+ *
427
+ * Supports controlled (`isOpen`) and uncontrolled (`defaultOpen`) modes, stable
428
+ * `open` / `close` / `toggle` identities (latest-ref pattern for callbacks), and
429
+ * an `onBeforeClose` guard that can be sync or async — return `false` (or a
430
+ * `Promise<false>`) to keep the drawer open in response to a close attempt.
431
+ *
432
+ * Owns ESC and outside-press dismiss via Floating UI's `useDismiss`. Outside-press
433
+ * is only active when `variant === "modal"` (the only variant with a backdrop).
434
+ * The `gesture` field of `DismissOptions` is accepted for API parity with Sheet
435
+ * but has no effect — Drawer has no swipe gesture.
436
+ *
437
+ * @example Controlled
438
+ * ```tsx
439
+ * const drawer = useDrawer({ isOpen, onClose: () => setOpen(false), position: "right" });
440
+ * return <Drawer {...drawer}>...</Drawer>;
441
+ * ```
442
+ * @example Uncontrolled with a beforeClose guard
443
+ * ```tsx
444
+ * const drawer = useDrawer({
445
+ * variant: "modal",
446
+ * onBeforeClose: async () => (await confirmDiscard()) === "discard",
447
+ * });
448
+ * return (
449
+ * <>
450
+ * <Button onClick={drawer.open}>Open</Button>
451
+ * <Drawer {...drawer}>
452
+ * <DrawerHeader onClickClose={drawer.close} />
453
+ * </Drawer>
454
+ * </>
455
+ * );
456
+ * ```
457
+ */
458
+ const useDrawer = (props) => {
459
+ const { isOpen: controlledIsOpen, defaultOpen, onClose, onOpen, onOpenChange, onBeforeClose, dismiss: dismissProp, variant = DEFAULT_VARIANT, trapFocus = true, position = DEFAULT_POSITION, } = props ?? {};
460
+ const dismiss = useMemo(() => ({
461
+ escapeKey: dismissProp?.escapeKey ?? true,
462
+ outsidePress: dismissProp?.outsidePress ?? true,
463
+ }), [dismissProp]);
464
+ const [internalIsOpen, setIsOpen] = useState(defaultOpen ?? false);
465
+ const isOpen = typeof controlledIsOpen === "boolean" ? controlledIsOpen : internalIsOpen;
466
+ const isPendingCloseRef = useRef(false);
467
+ const onCloseRef = useRef(onClose);
468
+ const onOpenRef = useRef(onOpen);
469
+ const onOpenChangeRef = useRef(onOpenChange);
470
+ const onBeforeCloseRef = useRef(onBeforeClose);
471
+ useLayoutEffect(() => {
472
+ onCloseRef.current = onClose;
473
+ onOpenRef.current = onOpen;
474
+ onOpenChangeRef.current = onOpenChange;
475
+ onBeforeCloseRef.current = onBeforeClose;
476
+ });
477
+ const handleClose = useCallback((event, reason) => {
478
+ setIsOpen(false);
479
+ onCloseRef.current?.(event, reason);
480
+ onOpenChangeRef.current?.(false, event, reason);
481
+ }, []);
482
+ const requestClose = useCallback((event, reason) => {
483
+ if (onBeforeCloseRef.current) {
484
+ if (isPendingCloseRef.current) {
485
+ return;
486
+ }
487
+ isPendingCloseRef.current = true;
488
+ void Promise.resolve(onBeforeCloseRef.current(event, reason))
489
+ .then(shouldClose => {
490
+ if (shouldClose) {
491
+ handleClose(event, reason);
492
+ }
493
+ })
494
+ .finally(() => {
495
+ isPendingCloseRef.current = false;
496
+ });
497
+ return;
498
+ }
499
+ handleClose(event, reason);
500
+ }, [handleClose]);
501
+ const close = useCallback(() => requestClose(undefined, "programmatic"), [requestClose]);
502
+ const open = useCallback(() => {
503
+ onOpenRef.current?.();
504
+ onOpenChangeRef.current?.(true);
505
+ setIsOpen(true);
506
+ }, []);
507
+ const toggle = useCallback(() => {
508
+ if (isOpen) {
509
+ close();
510
+ }
511
+ else {
512
+ open();
513
+ }
514
+ }, [isOpen, open, close]);
515
+ const { context: floatingContext, refs: floatingRefs } = useFloating({
516
+ open: isOpen,
517
+ onOpenChange: (nextOpen, event, reason) => {
518
+ if (nextOpen)
519
+ return;
520
+ const closeReason = reason === "escape-key" || reason === "outside-press" ? reason : undefined;
521
+ requestClose(event, closeReason ?? "programmatic");
522
+ },
523
+ });
524
+ const dismissInteraction = useDismiss(floatingContext, {
525
+ escapeKey: dismiss.escapeKey,
526
+ outsidePress: variant === "modal" && dismiss.outsidePress,
527
+ });
528
+ const { getFloatingProps } = useInteractions([dismissInteraction]);
529
+ const floatingUi = useMemo(() => ({ context: floatingContext, refs: floatingRefs, getFloatingProps }), [floatingContext, floatingRefs, getFloatingProps]);
530
+ return useMemo(() => ({
531
+ isOpen,
532
+ open,
533
+ close,
534
+ toggle,
535
+ requestClose,
536
+ variant,
537
+ trapFocus,
538
+ position,
539
+ floatingUi,
540
+ }), [isOpen, open, close, toggle, requestClose, variant, trapFocus, position, floatingUi]);
308
541
  };
542
+
309
543
  /**
310
- * DrawerToggle is a React functional component that returns a button with a chevron icon.
311
- * The direction of the chevron changes depending on the state of the 'open' prop and
312
- * the side the button is positioned ('position' prop).
313
- * The button might be disabled based on the 'disableButton' prop.
544
+ * Standard drawer toolbar header. Compose it as a child of `<Drawer />`:
545
+ *
546
+ * ```tsx
547
+ * const drawer = useDrawer({ position: "right" });
548
+ *
549
+ * <Drawer {...drawer}>
550
+ * <DrawerHeader menuContent={…} onClickBack={goBack} onClickClose={drawer.close} />
551
+ * {\/* body *\/}
552
+ * </Drawer>
553
+ * ```
554
+ *
555
+ * Wire the X button to `useDrawer`'s `close` so it shares the same dismiss pipeline
556
+ * (Escape, outside-press, `onBeforeClose` guard) as the rest of the drawer.
314
557
  *
315
- * @param {object} props - The properties passed to the component
316
- * @param {Function} [props.onClick] - Optional callback function for when the button is clicked
317
- * @param {boolean} props.open - Indicates if the button is in "open" state
318
- * @param {DrawerPosition} props.position - The position of the button relative to its container
319
- * @param props.ref - Ref forwarded to the root DOM element
320
- * @param props.style - Inline styles applied to the root DOM element
558
+ * Button labels come from this library's translation namespace and cannot be overridden — the
559
+ * affordances are universal ("Close", "Back", "Forward", "More actions").
321
560
  */
322
- const DrawerToggle = ({ open, position, onClick = NOOP, ref, style }) => {
323
- const name = getIconName(open, position);
324
- return (jsx("div", { className: cvaToggleContainer({ position }), ref: ref, style: style, children: jsx("button", { className: cvaDrawerToggle({ position }), "data-testid": "toggle-button", onClick: onClick, children: jsx(Icon, { name: name }) }) }));
561
+ const DrawerHeader = ({ onClickClose, onClickBack, onClickForward, menuContent, hideCloseButton = false, "data-testid": dataTestId, className, style, ref, }) => {
562
+ const [t] = useTranslation();
563
+ const showCloseButton = !hideCloseButton && onClickClose !== undefined;
564
+ const showMoreMenu = menuContent !== undefined;
565
+ const handleClickClose = () => onClickClose?.();
566
+ const testId = (suffix) => (dataTestId !== undefined ? `${dataTestId}-${suffix}` : `drawer-${suffix}`);
567
+ return (jsxs("div", { className: twMerge("flex items-center justify-between gap-2 border-b border-neutral-200 bg-white px-4 py-1", className), "data-testid": dataTestId, ref: ref, style: style, children: [jsxs("div", { className: "flex items-center gap-0", children: [onClickBack ? (jsx(IconButton, { "data-testid": testId("back-button"), icon: jsx(Icon, { name: "ArrowLeft", size: "small" }), onClick: onClickBack, title: t("drawer.header.back"), variant: "ghost-neutral" })) : null, onClickForward ? (jsx(IconButton, { "data-testid": testId("forward-button"), icon: jsx(Icon, { name: "ArrowRight", size: "small" }), onClick: onClickForward, title: t("drawer.header.forward"), variant: "ghost-neutral" })) : null] }), jsxs("div", { className: "flex items-center gap-0", children: [showMoreMenu ? (jsx(MoreMenu, { buttonLabel: t("drawer.header.moreMenu"), "data-testid": testId("more-menu"), iconButtonProps: {
568
+ "data-testid": testId("more-menu-button"),
569
+ variant: "ghost-neutral",
570
+ }, iconProps: { size: "small" }, children: menuContent })) : null, showCloseButton ? (jsx(IconButton, { "data-testid": testId("close-button"), icon: jsx(Icon, { name: "XMark", size: "small" }), onClick: handleClickClose, title: t("drawer.header.close"), variant: "ghost-neutral" })) : null] })] }));
325
571
  };
326
- const cvaDrawerToggle = cvaMerge([
327
- "flex",
328
- "cursor-pointer",
329
- "items-center",
330
- "justify-center",
331
- "border-neutral-300",
332
- "bg-neutral-50",
333
- "bg-center",
334
- "bg-no-repeat",
335
- "shadow-md",
336
- ], {
337
- variants: {
338
- position: {
339
- left: "h-12 w-6 rounded-r-lg",
340
- right: "h-12 w-6 rounded-l-lg",
341
- top: "h-6 w-12 rounded-b-lg",
342
- bottom: "h-6 w-12 rounded-t-lg",
343
- },
344
- },
345
- defaultVariants: {
346
- position: "left",
347
- },
348
- });
349
- const cvaToggleContainer = cvaMerge(["z-8", "absolute"], {
350
- variants: {
351
- position: {
352
- left: "right-[-24px] top-[calc(50%-24px)]",
353
- right: "left-[-24px] top-[calc(50%-24px)]",
354
- top: "bottom-[-24px] left-[calc(50%-24px)]",
355
- bottom: "left-[calc(50%-24px)] top-[-24px]",
356
- },
357
- },
358
- defaultVariants: {
359
- position: "left",
360
- },
361
- });
572
+ DrawerHeader.displayName = "DrawerHeader";
362
573
 
363
574
  /*
364
575
  * ----------------------------
@@ -369,4 +580,4 @@ const cvaToggleContainer = cvaMerge(["z-8", "absolute"], {
369
580
  */
370
581
  setupLibraryTranslations();
371
582
 
372
- export { Drawer, DrawerToggle, TRANSITION_DURATION };
583
+ export { Drawer, DrawerHeader, useDrawer };