@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.cjs.js +422 -211
- package/index.esm.js +424 -213
- package/package.json +6 -5
- package/src/components/Drawer/Drawer.d.ts +56 -70
- package/src/components/Drawer/Drawer.variants.d.ts +13 -3
- package/src/components/Drawer/drawer-animation-reducer.d.ts +27 -0
- package/src/components/Drawer/drawer-motion-style.d.ts +24 -0
- package/src/components/Drawer/useDrawer.d.ts +121 -0
- package/src/components/DrawerHeader/DrawerHeader.d.ts +67 -0
- package/src/components/Overlay/Overlay.d.ts +8 -4
- package/src/index.d.ts +3 -1
- package/src/translation.d.ts +2 -2
- package/src/types.d.ts +1 -1
- package/src/components/DrawerToggle/DrawerToggle.d.ts +0 -23
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 {
|
|
4
|
-
import {
|
|
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
|
-
* @
|
|
55
|
-
* @returns {ReactElement|null} The Overlay component
|
|
69
|
+
* @returns {ReactElement} The Overlay component
|
|
56
70
|
*/
|
|
57
|
-
const Overlay = ({ open
|
|
58
|
-
|
|
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/
|
|
88
|
-
"bg-opacity-50",
|
|
80
|
+
"bg-black/50",
|
|
89
81
|
"transition-opacity",
|
|
90
|
-
|
|
91
|
-
"
|
|
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
|
|
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"
|
|
115
|
-
right: ["right-0", "top-0", "h-full"
|
|
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
|
-
|
|
121
|
-
|
|
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"
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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
|
|
204
|
-
* - For
|
|
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
|
-
*
|
|
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
|
|
262
|
+
* const drawer = useDrawer({ position: "right", variant: "modal" });
|
|
219
263
|
*
|
|
220
264
|
* return (
|
|
221
265
|
* <>
|
|
222
|
-
* <Button onClick={
|
|
223
|
-
* <Drawer
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
|
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
|
-
*
|
|
284
|
+
* const drawer = useDrawer({
|
|
285
|
+
* isOpen: Boolean(selectedAssetId),
|
|
286
|
+
* onClose: () => setSelectedAssetId(null),
|
|
287
|
+
* position: "right",
|
|
288
|
+
* variant: "default",
|
|
289
|
+
* });
|
|
240
290
|
*
|
|
241
|
-
*
|
|
242
|
-
* <
|
|
243
|
-
*
|
|
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,
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
265
|
-
}, [
|
|
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
|
-
|
|
269
|
-
setTimeout(() => {
|
|
270
|
-
onOpenRef.current();
|
|
271
|
-
}, TRANSITION_DURATION);
|
|
321
|
+
if (!isOpen || !drawerState.shouldRender || drawerState.mode === "open" || !panelElement) {
|
|
322
|
+
return;
|
|
272
323
|
}
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
|
397
|
+
if (!drawerState.shouldRender) {
|
|
283
398
|
return null;
|
|
284
399
|
}
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
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
|
-
*
|
|
316
|
-
*
|
|
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
|
|
323
|
-
const
|
|
324
|
-
|
|
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
|
-
|
|
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,
|
|
583
|
+
export { Drawer, DrawerHeader, useDrawer };
|