@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.42",
3
+ "version": "0.8.43",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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)}