@bearmenu/ui 0.8.41 → 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/{chunk-TB2K5JN3.js → chunk-BFS4M7KD.js} +26 -9
- package/dist/{chunk-KGXTBTCJ.js → chunk-CLFE4U5T.js} +1 -1
- package/dist/{chunk-NF5UMLSL.js → chunk-FVFAOW36.js} +46 -40
- package/dist/{chunk-ZUBBMLBD.js → chunk-RQPUBKFU.js} +1 -1
- package/dist/{chunk-FQAIYKAS.js → chunk-WHUG6QEI.js} +8 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/booking-row.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/command.d.ts +3 -3
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/drawer.d.ts +9 -0
- package/dist/components/drawer.js +1 -1
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/responsive-overlay.d.ts +23 -0
- package/dist/components/responsive-overlay.js +57 -34
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/dist/components/toggle-group.js +9 -5
- package/dist/components/toggle.d.ts +11 -2
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +22 -1
- package/src/components/badge.test.tsx +37 -0
- package/src/components/badge.tsx +8 -2
- package/src/components/drawer.stories.tsx +25 -0
- package/src/components/drawer.test.tsx +50 -0
- package/src/components/drawer.tsx +70 -47
- package/src/components/responsive-overlay.stories.tsx +62 -0
- package/src/components/responsive-overlay.test.tsx +134 -0
- package/src/components/responsive-overlay.tsx +66 -7
- package/src/components/toggle-group.stories.tsx +17 -0
- package/src/components/toggle-group.test.tsx +65 -0
- package/src/components/toggle-group.tsx +13 -5
- package/src/components/toggle.test.tsx +50 -1
- package/src/components/toggle.tsx +35 -4
- package/src/globals.css +7 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { toggleVariants } from '../chunk-
|
|
1
|
+
import { toggleVariants, SegmentedToggleContent } from '../chunk-BFS4M7KD.js';
|
|
2
2
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
3
3
|
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
4
4
|
import * as React from 'react';
|
|
@@ -30,8 +30,12 @@ function ToggleGroup(_a) {
|
|
|
30
30
|
className: cn(
|
|
31
31
|
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
|
32
32
|
// The segmented control's capsule: muted ground, the on cell a raised
|
|
33
|
-
// card inside it (see `toggleVariants.segmented`).
|
|
34
|
-
|
|
33
|
+
// card inside it (see `toggleVariants.segmented`). No padding here —
|
|
34
|
+
// each cell is the full 44px capsule height (the hit target), and
|
|
35
|
+
// draws its own smaller 36px card centred inside
|
|
36
|
+
// (`SegmentedToggleContent`), so the capsule stays the 44px the
|
|
37
|
+
// booking frames draw it at.
|
|
38
|
+
"data-[variant=segmented]:gap-0.5 data-[variant=segmented]:rounded-full data-[variant=segmented]:bg-muted",
|
|
35
39
|
// Chips wrap onto as many rows as they need, 7px apart.
|
|
36
40
|
"data-[variant=chip]:flex-wrap data-[variant=chip]:gap-[7px] data-[variant=chip]:rounded-none",
|
|
37
41
|
// The tab bar spans its container: a muted capsule, 4px inset.
|
|
@@ -71,7 +75,7 @@ function ToggleGroupItem(_a) {
|
|
|
71
75
|
size: context.size || size
|
|
72
76
|
}),
|
|
73
77
|
"min-w-0 flex-1 shrink-0 shadow-none focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
|
74
|
-
resolved === "segmented" ? "rounded-full
|
|
78
|
+
resolved === "segmented" ? "rounded-full" : (
|
|
75
79
|
// Tabs and chips size themselves (`toggleVariants`' compounds);
|
|
76
80
|
// the item's own rounding must not square them off.
|
|
77
81
|
resolved === "tab" ? "rounded-full" : resolved === "chip" ? (
|
|
@@ -82,7 +86,7 @@ function ToggleGroupItem(_a) {
|
|
|
82
86
|
className
|
|
83
87
|
)
|
|
84
88
|
}, props), {
|
|
85
|
-
children
|
|
89
|
+
children: resolved === "segmented" ? /* @__PURE__ */ jsx(SegmentedToggleContent, { children }) : children
|
|
86
90
|
})
|
|
87
91
|
);
|
|
88
92
|
}
|
|
@@ -14,6 +14,15 @@ declare const toggleVariants: (props?: ({
|
|
|
14
14
|
variant?: "default" | "tab" | "outline" | "chip" | "segmented" | null | undefined;
|
|
15
15
|
size?: "default" | "sm" | "lg" | null | undefined;
|
|
16
16
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The segmented variant's drawn pill — 36px tall, centred in the cell's 44px
|
|
19
|
+
* hit box. Painted here, on `group-data-[state=on]/segment:`, so the outer
|
|
20
|
+
* button (`toggleVariants({variant: "segmented"})`) can stay unpainted and
|
|
21
|
+
* full height. Shared by `Toggle` and `ToggleGroupItem`.
|
|
22
|
+
*/
|
|
23
|
+
declare function SegmentedToggleContent({ children }: {
|
|
24
|
+
children?: React.ReactNode;
|
|
25
|
+
}): react_jsx_runtime.JSX.Element;
|
|
26
|
+
declare function Toggle({ className, variant, size, children, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>): react_jsx_runtime.JSX.Element;
|
|
18
27
|
|
|
19
|
-
export { Toggle, toggleVariants };
|
|
28
|
+
export { SegmentedToggleContent, Toggle, toggleVariants };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { MenuItem } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
import { MenuItem } from '../chunk-RQPUBKFU.js';
|
|
2
|
+
import '../chunk-CLFE4U5T.js';
|
|
3
3
|
import { Separator } from '../chunk-NWZGSLPU.js';
|
|
4
4
|
import { Skeleton } from '../chunk-XBBEX4SF.js';
|
|
5
|
-
import '../chunk-
|
|
5
|
+
import '../chunk-WHUG6QEI.js';
|
|
6
6
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
7
7
|
import { __objRest, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
8
8
|
import { MapPin, Star, MessageSquareQuote } from 'lucide-react';
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { Armchair, Check, ChevronDown, CircleHelp, Clock, Users } from "lucide-react";
|
|
2
|
+
import { Armchair, Check, ChevronDown, CircleCheck, CircleHelp, Clock, Users } from "lucide-react";
|
|
3
3
|
import { Avatar, AvatarFallback, AvatarGroup } from "./avatar";
|
|
4
4
|
import { Badge } from "./badge";
|
|
5
5
|
import { Button } from "./button";
|
|
@@ -91,6 +91,27 @@ export const BookingStatus: Story = {
|
|
|
91
91
|
),
|
|
92
92
|
};
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* `size="status"` — the taller pill beside a summary, not the inline row chip
|
|
96
|
+
* `BookingStatus` above draws. Measured off the booking details panel
|
|
97
|
+
* (Bookings Calendar 09): a 24px pill at 12px semibold, icon included.
|
|
98
|
+
*/
|
|
99
|
+
export const StatusSize: Story = {
|
|
100
|
+
render: () => (
|
|
101
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
102
|
+
<Badge as="span" variant="confirmed" size="status" icon={<CircleCheck />} className="gap-[5px]">
|
|
103
|
+
Confirmed
|
|
104
|
+
</Badge>
|
|
105
|
+
<Badge as="span" variant="pending" size="status">
|
|
106
|
+
Pending
|
|
107
|
+
</Badge>
|
|
108
|
+
<Badge as="span" variant="completed" size="status">
|
|
109
|
+
Completed
|
|
110
|
+
</Badge>
|
|
111
|
+
</div>
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
|
|
94
115
|
/**
|
|
95
116
|
* `assignee` — who an item belongs to, in the shared table's organise view. It
|
|
96
117
|
* is a control, not a status: the whole pill opens "Who had this?", so it keeps
|
|
@@ -42,4 +42,41 @@ describe("Badge", () => {
|
|
|
42
42
|
render(<Badge variant="success">Solid</Badge>);
|
|
43
43
|
expect(screen.getByText("Solid")).toHaveClass("bg-success", "text-success-foreground");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
describe('size="status"', () => {
|
|
47
|
+
// Measured off the booking details panel (Bookings Calendar 09): a 24px
|
|
48
|
+
// pill (18px line-height + 3px top/bottom), not the old py-[11px]'s 38px.
|
|
49
|
+
it("draws a 24px pill, not the old 38px one", () => {
|
|
50
|
+
render(<Badge size="status">Confirmed</Badge>);
|
|
51
|
+
const badge = screen.getByText("Confirmed");
|
|
52
|
+
expect(badge).toHaveClass("py-[3px]", "leading-[18px]");
|
|
53
|
+
expect(badge).not.toHaveClass("py-[11px]");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("overrides a colour variant's compact, inline-row type scale", () => {
|
|
57
|
+
// `confirmed` alone is the small 10.5px bold row chip; `status` is the
|
|
58
|
+
// taller 12px semibold scale the booking details header draws, and
|
|
59
|
+
// must win since it is applied after the variant in the merge.
|
|
60
|
+
render(<Badge size="status">Confirmed</Badge>);
|
|
61
|
+
const badge = screen.getByText("Confirmed");
|
|
62
|
+
expect(badge).toHaveClass("text-xs", "font-semibold");
|
|
63
|
+
expect(badge.className).not.toContain("text-[10.5px]");
|
|
64
|
+
|
|
65
|
+
render(
|
|
66
|
+
<Badge variant="confirmed" size="status">
|
|
67
|
+
Confirmed
|
|
68
|
+
</Badge>
|
|
69
|
+
);
|
|
70
|
+
const withVariant = screen.getAllByText("Confirmed")[1];
|
|
71
|
+
expect(withVariant).toHaveClass("text-xs", "font-semibold", "py-[3px]");
|
|
72
|
+
expect(withVariant.className).not.toContain("text-[10.5px]");
|
|
73
|
+
expect(withVariant.className).not.toContain("font-bold");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("leaves the default chip size's compact padding alone", () => {
|
|
77
|
+
render(<Badge variant="confirmed">Confirmed</Badge>);
|
|
78
|
+
const badge = screen.getByText("Confirmed");
|
|
79
|
+
expect(badge).toHaveClass("py-[3px]", "text-[10.5px]", "font-bold");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
45
82
|
});
|
package/src/components/badge.tsx
CHANGED
|
@@ -78,11 +78,17 @@ const badgeVariants = cva(
|
|
|
78
78
|
/**
|
|
79
79
|
* `status` is the taller pill the booking design draws beside a
|
|
80
80
|
* summary — the same ink and radius, more air. `chip` (the default) is
|
|
81
|
-
* the inline one that sits in a row of text.
|
|
81
|
+
* the inline one that sits in a row of text. Measured off the booking
|
|
82
|
+
* details panel (Bookings Calendar 09, the header status badge): a 24px
|
|
83
|
+
* pill, not the 38px `py-[11px]` previously drew. Resets text size and
|
|
84
|
+
* weight too, because the colour variants (`confirmed`/`pending`/…)
|
|
85
|
+
* tune those for their compact, inline-with-a-row scale (10.5px bold);
|
|
86
|
+
* `status` is a different scale (12px semibold) and wins the merge by
|
|
87
|
+
* coming after the variant classes.
|
|
82
88
|
*/
|
|
83
89
|
size: {
|
|
84
90
|
chip: "",
|
|
85
|
-
status: "py-[
|
|
91
|
+
status: "gap-1 px-2 py-[3px] text-xs leading-[18px] font-semibold",
|
|
86
92
|
},
|
|
87
93
|
// v1.0 motion: "live" surface signal — applies `live-pulse` archetype.
|
|
88
94
|
// Pair with a colored variant (e.g. variant="destructive" live).
|
|
@@ -45,3 +45,28 @@ export const Default: Story = {
|
|
|
45
45
|
</Drawer>
|
|
46
46
|
),
|
|
47
47
|
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `snapHeight` — a fixed sheet instead of one that grows with its content,
|
|
51
|
+
* for a phone drawer the frames draw at a set height (the floor plan's table
|
|
52
|
+
* sheet, ~640px). Opt-in: every other drawer keeps sizing to its content.
|
|
53
|
+
*/
|
|
54
|
+
export const FixedHeight: Story = {
|
|
55
|
+
render: () => (
|
|
56
|
+
<Drawer>
|
|
57
|
+
<DrawerTrigger asChild>
|
|
58
|
+
<Button variant="outline">Open table sheet</Button>
|
|
59
|
+
</DrawerTrigger>
|
|
60
|
+
<DrawerContent snapHeight={420}>
|
|
61
|
+
<DrawerHeader>
|
|
62
|
+
<DrawerTitle>T5</DrawerTitle>
|
|
63
|
+
<DrawerDescription>6 seats · Inside</DrawerDescription>
|
|
64
|
+
</DrawerHeader>
|
|
65
|
+
<div className="flex-1 p-4 text-muted-foreground">
|
|
66
|
+
The sheet holds this height regardless of how little content sits
|
|
67
|
+
inside it.
|
|
68
|
+
</div>
|
|
69
|
+
</DrawerContent>
|
|
70
|
+
</Drawer>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
@@ -52,4 +52,54 @@ describe("Drawer", () => {
|
|
|
52
52
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
53
53
|
expect(dialog.contains(document.activeElement)).toBe(false);
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
// Measured off the booking frames' table sheet (floor/14-w1): a 40×4px
|
|
57
|
+
// handle, not the previous 60×6.
|
|
58
|
+
it("draws the smaller handle by default", () => {
|
|
59
|
+
render(<Harness />);
|
|
60
|
+
const handle = document.querySelector('[data-slot="drawer-content"] > [aria-hidden]');
|
|
61
|
+
expect(handle).toHaveClass("h-1", "w-10");
|
|
62
|
+
expect(handle).not.toHaveClass("h-1.5", "w-[60px]");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("snapHeight", () => {
|
|
66
|
+
it("is content-sized (no inline height) when omitted", () => {
|
|
67
|
+
render(<Harness />);
|
|
68
|
+
const dialog = screen.getByRole("dialog");
|
|
69
|
+
expect(dialog.style.height).toBe("");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("sets a fixed pixel height from a number", () => {
|
|
73
|
+
render(
|
|
74
|
+
<Drawer open onOpenChange={() => {}}>
|
|
75
|
+
<DrawerContent snapHeight={640}>
|
|
76
|
+
<DrawerTitle>T5</DrawerTitle>
|
|
77
|
+
</DrawerContent>
|
|
78
|
+
</Drawer>
|
|
79
|
+
);
|
|
80
|
+
expect(screen.getByRole("dialog").style.height).toBe("640px");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("passes a string height through as-is", () => {
|
|
84
|
+
render(
|
|
85
|
+
<Drawer open onOpenChange={() => {}}>
|
|
86
|
+
<DrawerContent snapHeight="70vh">
|
|
87
|
+
<DrawerTitle>T5</DrawerTitle>
|
|
88
|
+
</DrawerContent>
|
|
89
|
+
</Drawer>
|
|
90
|
+
);
|
|
91
|
+
expect(screen.getByRole("dialog").style.height).toBe("70vh");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("is ignored on a horizontal (left/right) drawer", () => {
|
|
95
|
+
render(
|
|
96
|
+
<Drawer open onOpenChange={() => {}} direction="right">
|
|
97
|
+
<DrawerContent snapHeight={640}>
|
|
98
|
+
<DrawerTitle>T5</DrawerTitle>
|
|
99
|
+
</DrawerContent>
|
|
100
|
+
</Drawer>
|
|
101
|
+
);
|
|
102
|
+
expect(screen.getByRole("dialog").style.height).toBe("");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
55
105
|
});
|
|
@@ -68,58 +68,81 @@ type DrawerContentProps = React.ComponentPropsWithoutRef<typeof DrawerPrimitive.
|
|
|
68
68
|
* `--scrim-overlay` in globals.css, and why it is not `--scrim`).
|
|
69
69
|
*/
|
|
70
70
|
overlayClassName?: string;
|
|
71
|
+
/**
|
|
72
|
+
* A fixed sheet height instead of sizing to content — the booking frames'
|
|
73
|
+
* ~640px table sheet (floor/14-w1) draws one instead of growing with its
|
|
74
|
+
* content. A number is px; a string is passed through (`"70vh"`). Only
|
|
75
|
+
* meaningful on a vertical (`top`/`bottom`) drawer, and always capped by the
|
|
76
|
+
* shared `max-h-[85vh]` on short viewports. Omit it to keep every existing
|
|
77
|
+
* caller's content-sized sheet — this is opt-in, not a new default.
|
|
78
|
+
*/
|
|
79
|
+
snapHeight?: number | string;
|
|
71
80
|
};
|
|
72
81
|
|
|
73
82
|
const DrawerContent = React.forwardRef<
|
|
74
83
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
|
75
84
|
DrawerContentProps
|
|
76
|
-
>(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
direction === "bottom"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
?
|
|
88
|
-
:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
85
|
+
>(
|
|
86
|
+
(
|
|
87
|
+
{ className, variant = "flush", showHandle, overlayClassName, snapHeight, style, children, ...props },
|
|
88
|
+
ref
|
|
89
|
+
) => {
|
|
90
|
+
const { direction, modal } = React.useContext(DrawerContext);
|
|
91
|
+
const isVertical = direction === "top" || direction === "bottom";
|
|
92
|
+
const handleEnabled = showHandle ?? (direction === "bottom");
|
|
93
|
+
const fixedHeight =
|
|
94
|
+
isVertical && snapHeight !== undefined
|
|
95
|
+
? typeof snapHeight === "number"
|
|
96
|
+
? `${snapHeight}px`
|
|
97
|
+
: snapHeight
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
const positionClasses =
|
|
101
|
+
direction === "bottom"
|
|
102
|
+
? "inset-x-0 bottom-0 flex-col rounded-t-2xl border-t [padding-bottom:max(env(safe-area-inset-bottom),0px)]"
|
|
103
|
+
: direction === "top"
|
|
104
|
+
? "inset-x-0 top-0 flex-col rounded-b-2xl border-b [padding-top:max(env(safe-area-inset-top),0px)]"
|
|
105
|
+
: direction === "left"
|
|
106
|
+
? "inset-y-0 left-0 h-full w-3/4 max-w-sm flex-col rounded-r-2xl border-r [padding-left:max(env(safe-area-inset-left),0px)]"
|
|
107
|
+
: "inset-y-0 right-0 h-full w-3/4 max-w-sm flex-col rounded-l-2xl border-l [padding-right:max(env(safe-area-inset-right),0px)]";
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<DrawerPortal>
|
|
111
|
+
<DrawerOverlay className={overlayClassName} />
|
|
112
|
+
<DrawerPrimitive.Content
|
|
113
|
+
ref={ref}
|
|
114
|
+
data-slot="drawer-content"
|
|
115
|
+
// Radix hides the page from assistive tech but never says so on the
|
|
116
|
+
// dialog itself; `aria-modal` is what tells a screen reader to stay in.
|
|
117
|
+
aria-modal={modal ? true : undefined}
|
|
118
|
+
style={fixedHeight !== undefined ? { ...style, height: fixedHeight } : style}
|
|
119
|
+
className={cn(
|
|
120
|
+
"fixed z-[1000] flex border-border bg-card shadow-[var(--shadow-overlay)]",
|
|
121
|
+
positionClasses,
|
|
122
|
+
// The single height cap for every vertical drawer, fixed height or
|
|
123
|
+
// not (a `snapHeight` taller than the viewport still backs off to
|
|
124
|
+
// this on a short phone). Consumers should NOT restate it: six app
|
|
125
|
+
// call sites carried an identical `!max-h-[90vh]`, so this default
|
|
126
|
+
// governed nothing and changing it here would have reached none of
|
|
127
|
+
// them.
|
|
128
|
+
isVertical ? "max-h-[85vh]" : "",
|
|
129
|
+
variant === "floating" && "m-3 rounded-2xl border",
|
|
130
|
+
className
|
|
131
|
+
)}
|
|
132
|
+
{...props}
|
|
133
|
+
>
|
|
134
|
+
{handleEnabled && (
|
|
135
|
+
<div
|
|
136
|
+
aria-hidden
|
|
137
|
+
className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-muted-foreground/30"
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
{children}
|
|
141
|
+
</DrawerPrimitive.Content>
|
|
142
|
+
</DrawerPortal>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
);
|
|
123
146
|
DrawerContent.displayName = "DrawerContent";
|
|
124
147
|
|
|
125
148
|
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
@@ -62,6 +62,8 @@ export const AddAnItem: Story = {
|
|
|
62
62
|
* floor/14-w1 — a working rail. `desktop="sheet"` with `modal={false}` and
|
|
63
63
|
* `wideFrom="lg"`: from 1024 the page stays live beside it (click a table and
|
|
64
64
|
* the rail shows that one instead of closing); under 1024 it is the drawer.
|
|
65
|
+
* `drawerHeight={640}` matches the frame's fixed-height phone sheet — resize
|
|
66
|
+
* the preview under 1024px and open a table to see it hold its height.
|
|
65
67
|
*/
|
|
66
68
|
export const WorkingRail: Story = {
|
|
67
69
|
render: () => {
|
|
@@ -83,6 +85,7 @@ export const WorkingRail: Story = {
|
|
|
83
85
|
desktop="sheet"
|
|
84
86
|
modal={false}
|
|
85
87
|
wideFrom="lg"
|
|
88
|
+
drawerHeight={640}
|
|
86
89
|
closeLabel="Close"
|
|
87
90
|
>
|
|
88
91
|
<p className="text-[14px] text-muted-foreground">
|
|
@@ -119,6 +122,65 @@ export const FormRail: Story = {
|
|
|
119
122
|
},
|
|
120
123
|
};
|
|
121
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
|
+
|
|
122
184
|
/** T1 — an overlay with no footer note: the two actions divide the band. */
|
|
123
185
|
export const JoinTheTable: Story = {
|
|
124
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) {
|
|
@@ -340,6 +426,54 @@ describe("ResponsiveOverlay", () => {
|
|
|
340
426
|
});
|
|
341
427
|
});
|
|
342
428
|
|
|
429
|
+
describe("phone drawer chrome", () => {
|
|
430
|
+
// Measured off the booking frames' table sheet (floor/14-w1): 16px
|
|
431
|
+
// gutter and a 22px title on the phone drawer; the desktop dialog/sheet
|
|
432
|
+
// keep their existing 20px / 19px.
|
|
433
|
+
it("tightens the gutter and grows the title on the drawer only", () => {
|
|
434
|
+
open();
|
|
435
|
+
const header = document.querySelector('[data-slot="responsive-overlay-header"]');
|
|
436
|
+
expect(header).toHaveClass("px-4", "pt-4");
|
|
437
|
+
expect(screen.getByText("Add an item")).toHaveClass("text-[22px]");
|
|
438
|
+
const body = document.querySelector('[data-slot="responsive-overlay-body"]');
|
|
439
|
+
expect(body).toHaveClass("px-4");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("leaves the desktop dialog's gutter and title alone", () => {
|
|
443
|
+
setWidth(true);
|
|
444
|
+
open();
|
|
445
|
+
const header = document.querySelector('[data-slot="responsive-overlay-header"]');
|
|
446
|
+
expect(header).toHaveClass("px-5", "pt-5");
|
|
447
|
+
expect(screen.getByText("Add an item")).toHaveClass("text-[19px]");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("leaves the desktop sheet's gutter and title alone", () => {
|
|
451
|
+
setWidth(true);
|
|
452
|
+
open({ desktop: "sheet" });
|
|
453
|
+
const header = document.querySelector('[data-slot="responsive-overlay-header"]');
|
|
454
|
+
expect(header).toHaveClass("px-5", "pt-5");
|
|
455
|
+
expect(screen.getByText("Add an item")).toHaveClass("text-[19px]");
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe("drawerHeight", () => {
|
|
460
|
+
it("is content-sized when omitted", () => {
|
|
461
|
+
open();
|
|
462
|
+
expect(screen.getByRole("dialog").style.height).toBe("");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("fixes the drawer's height when given", () => {
|
|
466
|
+
open({ drawerHeight: 640 });
|
|
467
|
+
expect(screen.getByRole("dialog").style.height).toBe("640px");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("does not affect the desktop dialog", () => {
|
|
471
|
+
setWidth(true);
|
|
472
|
+
open({ drawerHeight: 640 });
|
|
473
|
+
expect(screen.getByRole("dialog").style.height).toBe("");
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
343
477
|
it("stays out of the DOM while closed", () => {
|
|
344
478
|
render(
|
|
345
479
|
<ResponsiveOverlay open={false} onOpenChange={() => {}} title="Add an item">
|