@trackunit/react-drawer 2.6.6 → 2.6.8

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