@bearmenu/ui 0.8.42 → 0.8.43
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/dist/components/responsive-overlay.d.ts +16 -0
- package/dist/components/responsive-overlay.js +17 -7
- package/package.json +1 -1
- package/src/components/responsive-overlay.stories.tsx +59 -0
- package/src/components/responsive-overlay.test.tsx +86 -0
- package/src/components/responsive-overlay.tsx +41 -4
|
@@ -64,6 +64,22 @@ type ResponsiveOverlayProps = {
|
|
|
64
64
|
* keeps today's default in every mode (dialog, sheet, drawer).
|
|
65
65
|
*/
|
|
66
66
|
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
|
67
|
+
/**
|
|
68
|
+
* Whether closing the overlay returns focus to whatever had it before the
|
|
69
|
+
* overlay opened. Two things do that today: Radix's `onCloseAutoFocus` —
|
|
70
|
+
* the FocusScope's own auto-focus-on-unmount, refocusing the element that
|
|
71
|
+
* was active before the overlay trapped focus — and this component's own
|
|
72
|
+
* `useFocusReturn`, which refocuses that same element a frame later so it
|
|
73
|
+
* survives the overlay's own unmount. `true` (the default) keeps today's
|
|
74
|
+
* behaviour in every mode (dialog, sheet, drawer): the opener gets focus
|
|
75
|
+
* back.
|
|
76
|
+
*
|
|
77
|
+
* Pass `false` when closing hands focus to another surface that takes it
|
|
78
|
+
* itself — a pick-mode bar docking over the header, say. Both paths are
|
|
79
|
+
* disabled and focus stays wherever the consumer puts it (typically inside
|
|
80
|
+
* the same `onOpenChange(false)` handler that closes the overlay).
|
|
81
|
+
*/
|
|
82
|
+
returnFocus?: boolean;
|
|
67
83
|
/**
|
|
68
84
|
* A fixed height for the phone drawer instead of sizing to its content —
|
|
69
85
|
* `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
|
|
@@ -15,7 +15,7 @@ var CONTAINER_DESCRIPTION = {
|
|
|
15
15
|
dialog: DialogDescription,
|
|
16
16
|
sheet: SheetDescription
|
|
17
17
|
};
|
|
18
|
-
function useFocusReturn(open) {
|
|
18
|
+
function useFocusReturn(open, returnFocus) {
|
|
19
19
|
const openerRef = React.useRef(null);
|
|
20
20
|
const wasOpen = React.useRef(false);
|
|
21
21
|
React.useEffect(() => {
|
|
@@ -26,7 +26,7 @@ function useFocusReturn(open) {
|
|
|
26
26
|
if (wasOpen.current) {
|
|
27
27
|
wasOpen.current = false;
|
|
28
28
|
const opener = openerRef.current;
|
|
29
|
-
if (opener && opener.isConnected) {
|
|
29
|
+
if (returnFocus && opener && opener.isConnected) {
|
|
30
30
|
requestAnimationFrame(() => opener.focus());
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -39,7 +39,7 @@ function useFocusReturn(open) {
|
|
|
39
39
|
}
|
|
40
40
|
document.addEventListener("focusin", track);
|
|
41
41
|
return () => document.removeEventListener("focusin", track);
|
|
42
|
-
}, [open]);
|
|
42
|
+
}, [open, returnFocus]);
|
|
43
43
|
}
|
|
44
44
|
var ResponsiveOverlay = React.forwardRef(
|
|
45
45
|
({
|
|
@@ -57,11 +57,12 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
57
57
|
className,
|
|
58
58
|
bodyClassName,
|
|
59
59
|
initialFocusRef,
|
|
60
|
+
returnFocus = true,
|
|
60
61
|
drawerHeight
|
|
61
62
|
}, ref) => {
|
|
62
63
|
const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
|
|
63
64
|
const container = isWide ? desktop : "drawer";
|
|
64
|
-
useFocusReturn(open);
|
|
65
|
+
useFocusReturn(open, returnFocus);
|
|
65
66
|
const handleOpenAutoFocus = React.useCallback(
|
|
66
67
|
(event) => {
|
|
67
68
|
if (!(initialFocusRef == null ? void 0 : initialFocusRef.current)) return;
|
|
@@ -70,6 +71,12 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
70
71
|
},
|
|
71
72
|
[initialFocusRef]
|
|
72
73
|
);
|
|
74
|
+
const handleCloseAutoFocus = React.useCallback(
|
|
75
|
+
(event) => {
|
|
76
|
+
if (!returnFocus) event.preventDefault();
|
|
77
|
+
},
|
|
78
|
+
[returnFocus]
|
|
79
|
+
);
|
|
73
80
|
const describedBy = subtitle ? {} : { "aria-describedby": void 0 };
|
|
74
81
|
const Title = CONTAINER_TITLE[container];
|
|
75
82
|
const Description = CONTAINER_DESCRIPTION[container];
|
|
@@ -129,7 +136,8 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
129
136
|
attachment: "inset",
|
|
130
137
|
hideClose: true,
|
|
131
138
|
overlayClassName: "bg-scrim-overlay backdrop-blur-none",
|
|
132
|
-
onOpenAutoFocus: handleOpenAutoFocus
|
|
139
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
140
|
+
onCloseAutoFocus: handleCloseAutoFocus
|
|
133
141
|
}, describedBy), {
|
|
134
142
|
"data-slot": "responsive-overlay-sheet",
|
|
135
143
|
"data-modal": modal ? "true" : "false"
|
|
@@ -154,7 +162,8 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
154
162
|
size,
|
|
155
163
|
showCloseButton: false,
|
|
156
164
|
overlayClassName: "bg-scrim-overlay backdrop-blur-none",
|
|
157
|
-
onOpenAutoFocus: handleOpenAutoFocus
|
|
165
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
166
|
+
onCloseAutoFocus: handleCloseAutoFocus
|
|
158
167
|
}, describedBy), {
|
|
159
168
|
className: cn(
|
|
160
169
|
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
@@ -175,7 +184,8 @@ var ResponsiveOverlay = React.forwardRef(
|
|
|
175
184
|
__spreadProps(__spreadValues(__spreadValues({
|
|
176
185
|
ref,
|
|
177
186
|
overlayClassName: "bg-scrim-overlay",
|
|
178
|
-
onOpenAutoFocus: handleOpenAutoFocus
|
|
187
|
+
onOpenAutoFocus: handleOpenAutoFocus,
|
|
188
|
+
onCloseAutoFocus: handleCloseAutoFocus
|
|
179
189
|
}, drawerHeight !== void 0 ? { snapHeight: drawerHeight } : {}), describedBy), {
|
|
180
190
|
className: cn("flex max-h-[85vh] flex-col p-0", className),
|
|
181
191
|
children: [
|
package/package.json
CHANGED
|
@@ -122,6 +122,65 @@ export const FormRail: Story = {
|
|
|
122
122
|
},
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* `returnFocus={false}` — closing hands off to a surface that takes focus
|
|
127
|
+
* itself, rather than returning it to the opener. Here that surface is a
|
|
128
|
+
* pick-mode bar docking over the header: click "Add item", then "Pick a
|
|
129
|
+
* table" inside it — focus lands on the bar's own button, not back on
|
|
130
|
+
* "Add item".
|
|
131
|
+
*/
|
|
132
|
+
export const HandsOffFocusOnClose: Story = {
|
|
133
|
+
render: () => {
|
|
134
|
+
const [open, setOpen] = React.useState(false);
|
|
135
|
+
const [pickMode, setPickMode] = React.useState(false);
|
|
136
|
+
const pickButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="relative">
|
|
140
|
+
{pickMode ? (
|
|
141
|
+
<div className="mb-3 flex items-center justify-between rounded-lg border border-border bg-surface-2 p-3">
|
|
142
|
+
<span className="text-[14px] font-medium">Pick a table on the floor</span>
|
|
143
|
+
<Button ref={pickButtonRef} variant="outline" onClick={() => setPickMode(false)}>
|
|
144
|
+
Cancel
|
|
145
|
+
</Button>
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
148
|
+
<Button variant="cta" onClick={() => setOpen(true)}>
|
|
149
|
+
Add item
|
|
150
|
+
</Button>
|
|
151
|
+
<ResponsiveOverlay
|
|
152
|
+
open={open}
|
|
153
|
+
onOpenChange={(next) => {
|
|
154
|
+
if (!next) {
|
|
155
|
+
// The overlay hands off to the pick-mode bar itself — it moves
|
|
156
|
+
// focus into its own "Cancel" button before the overlay even
|
|
157
|
+
// starts unmounting, so the overlay must not fight it back.
|
|
158
|
+
setPickMode(true);
|
|
159
|
+
requestAnimationFrame(() => pickButtonRef.current?.focus());
|
|
160
|
+
}
|
|
161
|
+
setOpen(next);
|
|
162
|
+
}}
|
|
163
|
+
title="Add an item"
|
|
164
|
+
subtitle="Table 18 · from the menu"
|
|
165
|
+
closeLabel="Close"
|
|
166
|
+
returnFocus={false}
|
|
167
|
+
footer={
|
|
168
|
+
<ActionBar
|
|
169
|
+
secondary={<Button variant="outline">Pick a table</Button>}
|
|
170
|
+
primary={<Button variant="cta">Add 1 item</Button>}
|
|
171
|
+
/>
|
|
172
|
+
}
|
|
173
|
+
>
|
|
174
|
+
<p className="text-[14px] text-muted-foreground">
|
|
175
|
+
Closing this does not return focus to "Add item" — the pick-mode bar above takes it
|
|
176
|
+
instead.
|
|
177
|
+
</p>
|
|
178
|
+
</ResponsiveOverlay>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
125
184
|
/** T1 — an overlay with no footer note: the two actions divide the band. */
|
|
126
185
|
export const JoinTheTable: Story = {
|
|
127
186
|
render: () => {
|
|
@@ -191,6 +191,92 @@ describe("ResponsiveOverlay", () => {
|
|
|
191
191
|
await waitFor(() => expect(opener).toHaveFocus());
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
describe("returnFocus", () => {
|
|
195
|
+
/**
|
|
196
|
+
* Mirrors a pick-mode bar docking over the header: the consumer decides,
|
|
197
|
+
* from inside the same `onOpenChange(false)` handler that closes the
|
|
198
|
+
* overlay, to move focus itself — scheduled a frame out, same as a bar
|
|
199
|
+
* that focuses itself once it mounts. The overlay is still trapping
|
|
200
|
+
* focus synchronously at the moment `onOpenChange` fires (it hasn't
|
|
201
|
+
* unmounted yet), so an immediate `.focus()` call here would just be
|
|
202
|
+
* bounced back inside by that trap; that is Radix's ordinary modal
|
|
203
|
+
* behaviour, unrelated to `returnFocus`, which is what a caller works
|
|
204
|
+
* around by deferring.
|
|
205
|
+
*/
|
|
206
|
+
function Harness({
|
|
207
|
+
returnFocus,
|
|
208
|
+
...props
|
|
209
|
+
}: Partial<React.ComponentProps<typeof ResponsiveOverlay>>) {
|
|
210
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
211
|
+
const elsewhereRef = React.useRef<HTMLButtonElement>(null);
|
|
212
|
+
return (
|
|
213
|
+
<>
|
|
214
|
+
<button type="button" onClick={() => setIsOpen(true)}>
|
|
215
|
+
Add item
|
|
216
|
+
</button>
|
|
217
|
+
<button type="button" ref={elsewhereRef}>
|
|
218
|
+
Elsewhere
|
|
219
|
+
</button>
|
|
220
|
+
<ResponsiveOverlay
|
|
221
|
+
open={isOpen}
|
|
222
|
+
onOpenChange={(next) => {
|
|
223
|
+
if (!next && returnFocus === false) {
|
|
224
|
+
requestAnimationFrame(() => elsewhereRef.current?.focus());
|
|
225
|
+
}
|
|
226
|
+
setIsOpen(next);
|
|
227
|
+
}}
|
|
228
|
+
title="Add an item"
|
|
229
|
+
closeLabel="Close"
|
|
230
|
+
returnFocus={returnFocus}
|
|
231
|
+
{...props}
|
|
232
|
+
>
|
|
233
|
+
<p>body</p>
|
|
234
|
+
</ResponsiveOverlay>
|
|
235
|
+
</>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Two rAF ticks — where `useFocusReturn`'s own refocus would land. */
|
|
240
|
+
async function nextFrames(count: number) {
|
|
241
|
+
for (let i = 0; i < count; i++) {
|
|
242
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const MODES: Array<{
|
|
247
|
+
name: string;
|
|
248
|
+
wide: boolean;
|
|
249
|
+
props: Partial<React.ComponentProps<typeof ResponsiveOverlay>>;
|
|
250
|
+
}> = [
|
|
251
|
+
{ name: "drawer", wide: false, props: {} },
|
|
252
|
+
{ name: "dialog", wide: true, props: {} },
|
|
253
|
+
{ name: "sheet", wide: true, props: { desktop: "sheet" } },
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
for (const mode of MODES) {
|
|
257
|
+
it(`${mode.name}: default (true) returns focus to the opener`, async () => {
|
|
258
|
+
setWidth(mode.wide);
|
|
259
|
+
render(<Harness {...mode.props} />);
|
|
260
|
+
const opener = screen.getByRole("button", { name: "Add item" });
|
|
261
|
+
await userEvent.click(opener);
|
|
262
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
263
|
+
await waitFor(() => expect(opener).toHaveFocus());
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it(`${mode.name}: returnFocus={false} leaves focus where the consumer puts it`, async () => {
|
|
267
|
+
setWidth(mode.wide);
|
|
268
|
+
render(<Harness {...mode.props} returnFocus={false} />);
|
|
269
|
+
const opener = screen.getByRole("button", { name: "Add item" });
|
|
270
|
+
const elsewhere = screen.getByRole("button", { name: "Elsewhere" });
|
|
271
|
+
await userEvent.click(opener);
|
|
272
|
+
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
273
|
+
await nextFrames(2);
|
|
274
|
+
expect(elsewhere).toHaveFocus();
|
|
275
|
+
expect(opener).not.toHaveFocus();
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
194
280
|
describe('desktop="sheet"', () => {
|
|
195
281
|
/** A width-aware matchMedia, for the md / lg split. */
|
|
196
282
|
function setViewport(px: number) {
|
|
@@ -32,8 +32,12 @@ const CONTAINER_DESCRIPTION = {
|
|
|
32
32
|
* The opener is tracked while the overlay is CLOSED rather than captured when
|
|
33
33
|
* it opens: by the time an effect could run on the opening render, the dialog
|
|
34
34
|
* has already mounted and pulled focus into itself.
|
|
35
|
+
*
|
|
36
|
+
* `returnFocus={false}` disables the `requestAnimationFrame` refocus below,
|
|
37
|
+
* but tracking keeps running regardless — cheap, and it means flipping the
|
|
38
|
+
* prop back on mid-lifecycle has an opener ready immediately.
|
|
35
39
|
*/
|
|
36
|
-
function useFocusReturn(open: boolean) {
|
|
40
|
+
function useFocusReturn(open: boolean, returnFocus: boolean) {
|
|
37
41
|
const openerRef = React.useRef<HTMLElement | null>(null);
|
|
38
42
|
const wasOpen = React.useRef(false);
|
|
39
43
|
|
|
@@ -48,7 +52,7 @@ function useFocusReturn(open: boolean) {
|
|
|
48
52
|
const opener = openerRef.current;
|
|
49
53
|
// The overlay is still unmounting on this tick, and it takes focus with
|
|
50
54
|
// it; restoring on the next frame lands after that.
|
|
51
|
-
if (opener && opener.isConnected) {
|
|
55
|
+
if (returnFocus && opener && opener.isConnected) {
|
|
52
56
|
requestAnimationFrame(() => opener.focus());
|
|
53
57
|
}
|
|
54
58
|
}
|
|
@@ -62,7 +66,7 @@ function useFocusReturn(open: boolean) {
|
|
|
62
66
|
}
|
|
63
67
|
document.addEventListener("focusin", track);
|
|
64
68
|
return () => document.removeEventListener("focusin", track);
|
|
65
|
-
}, [open]);
|
|
69
|
+
}, [open, returnFocus]);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
export type ResponsiveOverlayProps = {
|
|
@@ -124,6 +128,22 @@ export type ResponsiveOverlayProps = {
|
|
|
124
128
|
* keeps today's default in every mode (dialog, sheet, drawer).
|
|
125
129
|
*/
|
|
126
130
|
initialFocusRef?: React.RefObject<HTMLElement | null>;
|
|
131
|
+
/**
|
|
132
|
+
* Whether closing the overlay returns focus to whatever had it before the
|
|
133
|
+
* overlay opened. Two things do that today: Radix's `onCloseAutoFocus` —
|
|
134
|
+
* the FocusScope's own auto-focus-on-unmount, refocusing the element that
|
|
135
|
+
* was active before the overlay trapped focus — and this component's own
|
|
136
|
+
* `useFocusReturn`, which refocuses that same element a frame later so it
|
|
137
|
+
* survives the overlay's own unmount. `true` (the default) keeps today's
|
|
138
|
+
* behaviour in every mode (dialog, sheet, drawer): the opener gets focus
|
|
139
|
+
* back.
|
|
140
|
+
*
|
|
141
|
+
* Pass `false` when closing hands focus to another surface that takes it
|
|
142
|
+
* itself — a pick-mode bar docking over the header, say. Both paths are
|
|
143
|
+
* disabled and focus stays wherever the consumer puts it (typically inside
|
|
144
|
+
* the same `onOpenChange(false)` handler that closes the overlay).
|
|
145
|
+
*/
|
|
146
|
+
returnFocus?: boolean;
|
|
127
147
|
/**
|
|
128
148
|
* A fixed height for the phone drawer instead of sizing to its content —
|
|
129
149
|
* `DrawerContent`'s `snapHeight` (the frames' ~640px table sheet). Applies
|
|
@@ -178,13 +198,14 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
178
198
|
className,
|
|
179
199
|
bodyClassName,
|
|
180
200
|
initialFocusRef,
|
|
201
|
+
returnFocus = true,
|
|
181
202
|
drawerHeight,
|
|
182
203
|
},
|
|
183
204
|
ref
|
|
184
205
|
) => {
|
|
185
206
|
const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
|
|
186
207
|
const container: "drawer" | "dialog" | "sheet" = isWide ? desktop : "drawer";
|
|
187
|
-
useFocusReturn(open);
|
|
208
|
+
useFocusReturn(open, returnFocus);
|
|
188
209
|
|
|
189
210
|
// Radix (and vaul, underneath the drawer) call this before moving focus
|
|
190
211
|
// themselves; preventing the default there is what stops the first-
|
|
@@ -198,6 +219,19 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
198
219
|
[initialFocusRef]
|
|
199
220
|
);
|
|
200
221
|
|
|
222
|
+
// Radix's FocusScope calls this on unmount before it runs its own default
|
|
223
|
+
// action — refocusing whatever was active before the scope trapped focus,
|
|
224
|
+
// i.e. the opener. Vaul forwards it straight through to the same Radix
|
|
225
|
+
// primitive underneath the drawer, so one handler covers all three modes.
|
|
226
|
+
// Leaving it a no-op when `returnFocus` is true reproduces today's
|
|
227
|
+
// behaviour exactly: the prop was never passed before this change.
|
|
228
|
+
const handleCloseAutoFocus = React.useCallback(
|
|
229
|
+
(event: Event) => {
|
|
230
|
+
if (!returnFocus) event.preventDefault();
|
|
231
|
+
},
|
|
232
|
+
[returnFocus]
|
|
233
|
+
);
|
|
234
|
+
|
|
201
235
|
// The name and the description are Radix's own: its `Title` and
|
|
202
236
|
// `Description` register ids in the dialog context and `Content` points
|
|
203
237
|
// `aria-labelledby` / `aria-describedby` at them. Passing our own ids
|
|
@@ -278,6 +312,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
278
312
|
hideClose
|
|
279
313
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
280
314
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
315
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
281
316
|
{...describedBy}
|
|
282
317
|
data-slot="responsive-overlay-sheet"
|
|
283
318
|
data-modal={modal ? "true" : "false"}
|
|
@@ -310,6 +345,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
310
345
|
showCloseButton={false}
|
|
311
346
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
312
347
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
348
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
313
349
|
{...describedBy}
|
|
314
350
|
className={cn(
|
|
315
351
|
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
@@ -332,6 +368,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
332
368
|
ref={ref}
|
|
333
369
|
overlayClassName="bg-scrim-overlay"
|
|
334
370
|
onOpenAutoFocus={handleOpenAutoFocus}
|
|
371
|
+
onCloseAutoFocus={handleCloseAutoFocus}
|
|
335
372
|
{...(drawerHeight !== undefined ? { snapHeight: drawerHeight } : {})}
|
|
336
373
|
{...describedBy}
|
|
337
374
|
className={cn("flex max-h-[85vh] flex-col p-0", className)}
|