@trackunit/react-drawer 2.6.5 → 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 +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/migrations/entry.js.map +0 -1
- package/src/components/DrawerToggle/DrawerToggle.d.ts +0 -23
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
|
-
* @
|
|
57
|
-
* @returns {ReactElement|null} The Overlay component
|
|
71
|
+
* @returns {ReactElement} The Overlay component
|
|
58
72
|
*/
|
|
59
|
-
const Overlay = ({ open
|
|
60
|
-
|
|
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/
|
|
90
|
-
"bg-opacity-50",
|
|
82
|
+
"bg-black/50",
|
|
91
83
|
"transition-opacity",
|
|
92
|
-
|
|
93
|
-
"
|
|
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
|
|
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"
|
|
117
|
-
right: ["right-0", "top-0", "h-full"
|
|
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
|
-
|
|
123
|
-
|
|
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"
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
|
206
|
-
* - For
|
|
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
|
-
*
|
|
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
|
|
264
|
+
* const drawer = useDrawer({ position: "right", variant: "modal" });
|
|
221
265
|
*
|
|
222
266
|
* return (
|
|
223
267
|
* <>
|
|
224
|
-
* <Button onClick={
|
|
225
|
-
* <Drawer
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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
|
|
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
|
-
*
|
|
286
|
+
* const drawer = useDrawer({
|
|
287
|
+
* isOpen: Boolean(selectedAssetId),
|
|
288
|
+
* onClose: () => setSelectedAssetId(null),
|
|
289
|
+
* position: "right",
|
|
290
|
+
* variant: "default",
|
|
291
|
+
* });
|
|
242
292
|
*
|
|
243
|
-
*
|
|
244
|
-
* <
|
|
245
|
-
*
|
|
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,
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
267
|
-
}, [
|
|
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
|
-
|
|
271
|
-
setTimeout(() => {
|
|
272
|
-
onOpenRef.current();
|
|
273
|
-
}, TRANSITION_DURATION);
|
|
323
|
+
if (!isOpen || !drawerState.shouldRender || drawerState.mode === "open" || !panelElement) {
|
|
324
|
+
return;
|
|
274
325
|
}
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
399
|
+
if (!drawerState.shouldRender) {
|
|
285
400
|
return null;
|
|
286
401
|
}
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
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
|
-
*
|
|
318
|
-
*
|
|
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
|
|
325
|
-
const
|
|
326
|
-
|
|
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
|
-
|
|
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.
|
|
376
|
-
exports.
|
|
586
|
+
exports.DrawerHeader = DrawerHeader;
|
|
587
|
+
exports.useDrawer = useDrawer;
|