@bearmenu/ui 0.8.34 → 0.8.36
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-EGU6EG73.js +39 -0
- package/dist/{chunk-NIAL4ZU2.js → chunk-FQAIYKAS.js} +45 -8
- package/dist/{chunk-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
- package/dist/chunk-LAEVMNJN.js +135 -0
- package/dist/{chunk-XNBDACL7.js → chunk-ZUBBMLBD.js} +1 -1
- package/dist/components/badge.d.ts +8 -2
- package/dist/components/badge.js +1 -1
- package/dist/components/banner.d.ts +17 -2
- package/dist/components/banner.js +10 -7
- package/dist/components/booking-row.d.ts +11 -1
- package/dist/components/booking-row.js +4 -1
- package/dist/components/button-group.js +3 -39
- package/dist/components/concept-convergence.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 +8 -3
- package/dist/components/pan-zoom-viewport.d.ts +137 -0
- package/dist/components/pan-zoom-viewport.js +531 -0
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/responsive-overlay.d.ts +29 -1
- package/dist/components/responsive-overlay.js +45 -6
- package/dist/components/sheet.d.ts +6 -0
- package/dist/components/sheet.js +3 -133
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/tabs.d.ts +16 -1
- package/dist/components/tabs.js +21 -3
- package/dist/components/top-ranking-entry.js +3 -3
- package/package.json +23 -216
- package/src/components/badge.stories.tsx +32 -1
- package/src/components/badge.test.tsx +45 -0
- package/src/components/badge.tsx +41 -3
- package/src/components/banner.stories.tsx +32 -0
- package/src/components/banner.test.tsx +58 -0
- package/src/components/banner.tsx +28 -4
- package/src/components/booking-row.tsx +13 -0
- package/src/components/multi-select-combobox.tsx +7 -1
- package/src/components/pan-zoom-viewport.stories.tsx +134 -0
- package/src/components/pan-zoom-viewport.test.tsx +381 -0
- package/src/components/pan-zoom-viewport.tsx +727 -0
- package/src/components/responsive-overlay.stories.tsx +61 -0
- package/src/components/responsive-overlay.test.tsx +104 -0
- package/src/components/responsive-overlay.tsx +87 -22
- package/src/components/sheet.tsx +14 -3
- package/src/components/tabs.stories.tsx +23 -0
- package/src/components/tabs.test.tsx +64 -0
- package/src/components/tabs.tsx +36 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { ChevronDown, Users } from "lucide-react";
|
|
2
|
+
import { Armchair, Check, ChevronDown, 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";
|
|
@@ -22,6 +22,10 @@ const meta = {
|
|
|
22
22
|
"warning",
|
|
23
23
|
"brown",
|
|
24
24
|
"onPrimary",
|
|
25
|
+
"softSuccess",
|
|
26
|
+
"softWarning",
|
|
27
|
+
"softDestructive",
|
|
28
|
+
"softNeutral",
|
|
25
29
|
],
|
|
26
30
|
},
|
|
27
31
|
},
|
|
@@ -126,6 +130,33 @@ export const Assignee: Story = {
|
|
|
126
130
|
),
|
|
127
131
|
};
|
|
128
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Soft tones with an icon — a booking's live status in a table row
|
|
135
|
+
* (calendar/11-dp): a wash of the status colour, its ink on top, a 14px glyph
|
|
136
|
+
* so the status never rests on colour alone.
|
|
137
|
+
*/
|
|
138
|
+
export const SoftTones: Story = {
|
|
139
|
+
render: () => (
|
|
140
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
141
|
+
<Badge as="span" variant="softSuccess" icon={<Armchair />}>
|
|
142
|
+
Seated
|
|
143
|
+
</Badge>
|
|
144
|
+
<Badge as="span" variant="softDestructive" icon={<Clock />}>
|
|
145
|
+
25 min late
|
|
146
|
+
</Badge>
|
|
147
|
+
<Badge as="span" variant="softWarning" icon={<CircleHelp />}>
|
|
148
|
+
Needs an answer
|
|
149
|
+
</Badge>
|
|
150
|
+
<Badge as="span" variant="softNeutral" icon={<Check />}>
|
|
151
|
+
Confirmed
|
|
152
|
+
</Badge>
|
|
153
|
+
<Badge as="span" variant="softNeutral">
|
|
154
|
+
Now
|
|
155
|
+
</Badge>
|
|
156
|
+
</div>
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
|
|
129
160
|
export const AllVariants: Story = {
|
|
130
161
|
render: () => (
|
|
131
162
|
<div className="flex flex-wrap items-center gap-3">
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { Armchair } from "lucide-react";
|
|
4
|
+
import { Badge } from "./badge";
|
|
5
|
+
|
|
6
|
+
describe("Badge", () => {
|
|
7
|
+
it("renders the icon before the label and hides it from assistive tech", () => {
|
|
8
|
+
render(
|
|
9
|
+
<Badge variant="softSuccess" icon={<Armchair data-testid="glyph" />}>
|
|
10
|
+
Seated
|
|
11
|
+
</Badge>
|
|
12
|
+
);
|
|
13
|
+
const badge = screen.getByText("Seated");
|
|
14
|
+
const icon = badge.querySelector('[data-slot="badge-icon"]');
|
|
15
|
+
expect(icon).toHaveAttribute("aria-hidden", "true");
|
|
16
|
+
expect(icon).toContainElement(screen.getByTestId("glyph"));
|
|
17
|
+
expect(badge.firstElementChild).toBe(icon);
|
|
18
|
+
expect(badge).toHaveClass("gap-1");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders no icon slot and no gap when there is no icon", () => {
|
|
22
|
+
render(<Badge variant="softNeutral">Confirmed</Badge>);
|
|
23
|
+
const badge = screen.getByText("Confirmed");
|
|
24
|
+
expect(badge.querySelector('[data-slot="badge-icon"]')).toBeNull();
|
|
25
|
+
expect(badge).not.toHaveClass("gap-1");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each([
|
|
29
|
+
["softSuccess", "bg-success/15", "text-success-ink"],
|
|
30
|
+
["softWarning", "bg-warning/20", "text-warning-ink"],
|
|
31
|
+
["softDestructive", "bg-destructive/12", "text-destructive-ink"],
|
|
32
|
+
["softNeutral", "bg-surface-2", "text-ink-secondary"],
|
|
33
|
+
] as const)("%s paints a wash with the tone's ink token", (variant, wash, ink) => {
|
|
34
|
+
render(<Badge variant={variant}>Status</Badge>);
|
|
35
|
+
const badge = screen.getByText("Status");
|
|
36
|
+
expect(badge).toHaveClass(wash, ink);
|
|
37
|
+
// The solid fill tokens fall short of AA as text on their own wash.
|
|
38
|
+
expect(badge.className).not.toMatch(/\btext-(success|warning|destructive)\b(?!-)/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("keeps an existing variant unchanged", () => {
|
|
42
|
+
render(<Badge variant="success">Solid</Badge>);
|
|
43
|
+
expect(screen.getByText("Solid")).toHaveClass("bg-success", "text-success-foreground");
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/components/badge.tsx
CHANGED
|
@@ -58,6 +58,22 @@ const badgeVariants = cva(
|
|
|
58
58
|
*/
|
|
59
59
|
assignee:
|
|
60
60
|
"rounded-full border-0 bg-surface-2 py-1 pl-1 pr-2 gap-1.5 text-[12px] leading-[1.2] font-semibold text-foreground",
|
|
61
|
+
/**
|
|
62
|
+
* Soft tones — a wash of the status colour with its INK on top, for a
|
|
63
|
+
* status that sits in a list and should not shout: the booking
|
|
64
|
+
* design's "Seated" (success), "25 min late" (destructive), "Needs an
|
|
65
|
+
* answer" (warning) and "Confirmed" (neutral) pills in a table row
|
|
66
|
+
* (calendar/11-dp, floor/14-w1, queue/02-q0).
|
|
67
|
+
*
|
|
68
|
+
* The ink tokens, not the solid ones: `--success` / `--warning` /
|
|
69
|
+
* `--destructive` are tuned as fills and fall short of AA as text on
|
|
70
|
+
* their own wash; `--*-ink` exists for exactly that. Usually paired
|
|
71
|
+
* with an `icon`, so the status does not rest on colour alone.
|
|
72
|
+
*/
|
|
73
|
+
softSuccess: "rounded-md border-0 bg-success/15 px-2 py-1 text-success-ink",
|
|
74
|
+
softWarning: "rounded-md border-0 bg-warning/20 px-2 py-1 text-warning-ink",
|
|
75
|
+
softDestructive: "rounded-md border-0 bg-destructive/12 px-2 py-1 text-destructive-ink",
|
|
76
|
+
softNeutral: "rounded-md border-0 bg-surface-2 px-2 py-1 text-ink-secondary",
|
|
61
77
|
},
|
|
62
78
|
/**
|
|
63
79
|
* `status` is the taller pill the booking design draws beside a
|
|
@@ -94,15 +110,37 @@ export interface BadgeProps
|
|
|
94
110
|
* clickable row.
|
|
95
111
|
*/
|
|
96
112
|
as?: "div" | "span";
|
|
113
|
+
/**
|
|
114
|
+
* A lucide glyph drawn at 14px before the label — the armchair on
|
|
115
|
+
* "Seated", the clock on "25 min late". Decorative: the label carries the
|
|
116
|
+
* meaning, so the slot is `aria-hidden`.
|
|
117
|
+
*/
|
|
118
|
+
icon?: React.ReactNode;
|
|
97
119
|
}
|
|
98
120
|
|
|
99
|
-
function Badge({
|
|
121
|
+
function Badge({
|
|
122
|
+
className,
|
|
123
|
+
variant,
|
|
124
|
+
size,
|
|
125
|
+
live,
|
|
126
|
+
as: Tag = "div",
|
|
127
|
+
icon,
|
|
128
|
+
children,
|
|
129
|
+
...props
|
|
130
|
+
}: BadgeProps) {
|
|
100
131
|
return (
|
|
101
132
|
<Tag
|
|
102
133
|
data-slot="badge"
|
|
103
|
-
className={cn(badgeVariants({ variant, size, live }), className)}
|
|
134
|
+
className={cn(badgeVariants({ variant, size, live }), icon ? "gap-1" : undefined, className)}
|
|
104
135
|
{...props}
|
|
105
|
-
|
|
136
|
+
>
|
|
137
|
+
{icon ? (
|
|
138
|
+
<span data-slot="badge-icon" aria-hidden className="inline-flex shrink-0 [&>svg]:size-3.5">
|
|
139
|
+
{icon}
|
|
140
|
+
</span>
|
|
141
|
+
) : null}
|
|
142
|
+
{children}
|
|
143
|
+
</Tag>
|
|
106
144
|
);
|
|
107
145
|
}
|
|
108
146
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { TriangleAlert } from "lucide-react";
|
|
3
3
|
import { Banner } from "./banner";
|
|
4
|
+
import { Button } from "./button";
|
|
4
5
|
|
|
5
6
|
const meta: Meta<typeof Banner> = {
|
|
6
7
|
title: "Booking/Banner",
|
|
@@ -30,6 +31,37 @@ export const Warning: Story = {
|
|
|
30
31
|
},
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
/** today/04-d3: a read that failed, on a page that is otherwise live. */
|
|
35
|
+
export const Destructive: Story = {
|
|
36
|
+
args: {
|
|
37
|
+
tone: "destructive",
|
|
38
|
+
children: (
|
|
39
|
+
<>
|
|
40
|
+
<b className="font-bold">The queue didn't load.</b> This is on our side; everything
|
|
41
|
+
else is live.
|
|
42
|
+
</>
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** reports/03-p9: the failure carries its own way out. */
|
|
48
|
+
export const DestructiveWithAction: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
tone: "destructive",
|
|
51
|
+
children: (
|
|
52
|
+
<>
|
|
53
|
+
<b className="font-bold">Walk-ins didn't load.</b> Covers and walk-in figures read —
|
|
54
|
+
until they do.
|
|
55
|
+
</>
|
|
56
|
+
),
|
|
57
|
+
action: (
|
|
58
|
+
<Button variant="link" className="h-auto p-0 font-bold text-destructive-ink">
|
|
59
|
+
Retry
|
|
60
|
+
</Button>
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
33
65
|
/** B0c: a venue that takes no bookings. */
|
|
34
66
|
export const Info: Story = {
|
|
35
67
|
args: { tone: "info", children: "This venue does not take bookings. Walk-ins only." },
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { Banner } from "./banner";
|
|
5
|
+
|
|
6
|
+
describe("Banner", () => {
|
|
7
|
+
it("paints the destructive tone with the ink token and a hairline", () => {
|
|
8
|
+
render(<Banner tone="destructive">The queue didn't load.</Banner>);
|
|
9
|
+
const banner = document.querySelector('[data-slot="banner"]');
|
|
10
|
+
expect(banner).toHaveClass("bg-destructive/10", "text-destructive-ink", "border");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("draws the destructive tone's own glyph unless told otherwise", () => {
|
|
14
|
+
const { rerender } = render(<Banner tone="destructive">Failed</Banner>);
|
|
15
|
+
const banner = document.querySelector('[data-slot="banner"]');
|
|
16
|
+
expect(banner?.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
|
17
|
+
rerender(
|
|
18
|
+
<Banner tone="destructive" icon={null}>
|
|
19
|
+
Failed
|
|
20
|
+
</Banner>
|
|
21
|
+
);
|
|
22
|
+
expect(banner?.querySelector("svg")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("places the action after the text and leaves it working", async () => {
|
|
26
|
+
const onRetry = vi.fn();
|
|
27
|
+
render(
|
|
28
|
+
<Banner
|
|
29
|
+
tone="destructive"
|
|
30
|
+
action={
|
|
31
|
+
<button type="button" onClick={onRetry}>
|
|
32
|
+
Retry
|
|
33
|
+
</button>
|
|
34
|
+
}
|
|
35
|
+
>
|
|
36
|
+
Walk-ins didn't load.
|
|
37
|
+
</Banner>
|
|
38
|
+
);
|
|
39
|
+
const banner = document.querySelector('[data-slot="banner"]');
|
|
40
|
+
const slot = document.querySelector('[data-slot="banner-action"]');
|
|
41
|
+
expect(banner?.lastElementChild).toBe(slot);
|
|
42
|
+
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
43
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders no action slot without an action", () => {
|
|
47
|
+
render(<Banner>Walk-ins only.</Banner>);
|
|
48
|
+
expect(document.querySelector('[data-slot="banner-action"]')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps the info and warning tones borderless", () => {
|
|
52
|
+
const { rerender } = render(<Banner tone="info">Note</Banner>);
|
|
53
|
+
const banner = document.querySelector('[data-slot="banner"]');
|
|
54
|
+
expect(banner).not.toHaveClass("border");
|
|
55
|
+
rerender(<Banner tone="warning">Note</Banner>);
|
|
56
|
+
expect(banner).not.toHaveClass("border");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
-
import { Info, TriangleAlert } from "lucide-react";
|
|
3
|
+
import { CircleAlert, Info, TriangleAlert } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -21,6 +21,14 @@ import { cn } from "../lib/utils";
|
|
|
21
21
|
* - `warning`: a 16% amber wash with the design's amber ink — darker than
|
|
22
22
|
* the solid `--warning`, which falls short of AA on its own wash at this
|
|
23
23
|
* size; dark mode uses the lifted token.
|
|
24
|
+
* - `destructive`: something did not load or did not happen — "The queue
|
|
25
|
+
* didn't load", "Walk-ins didn't load" (today/04-d3, floor/02-s2,
|
|
26
|
+
* reports/03-p9). A red wash with `--destructive-ink`, and the one tone
|
|
27
|
+
* with a hairline: the booking frames draw it, because a failure has to
|
|
28
|
+
* read as a boundary on a page already full of soft grounds.
|
|
29
|
+
*
|
|
30
|
+
* `action` takes the trailing control — the frames' "Retry". It sits on the
|
|
31
|
+
* right edge, centred on the text, and never wraps under it.
|
|
24
32
|
*/
|
|
25
33
|
const bannerVariants = cva(
|
|
26
34
|
"flex gap-2.5 rounded-xl px-3.5 py-3 text-[12.5px] leading-normal [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
@@ -30,6 +38,7 @@ const bannerVariants = cva(
|
|
|
30
38
|
info: "bg-surface-2 text-ink-secondary",
|
|
31
39
|
warning:
|
|
32
40
|
"bg-[oklch(0.62_0.15_70/0.16)] text-[oklch(0.42_0.1_70)] dark:bg-warning/15 dark:text-warning",
|
|
41
|
+
destructive: "border border-destructive/25 bg-destructive/10 text-destructive-ink",
|
|
33
42
|
},
|
|
34
43
|
},
|
|
35
44
|
defaultVariants: { tone: "info" },
|
|
@@ -45,22 +54,37 @@ export type BannerProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> &
|
|
|
45
54
|
icon?: React.ReactNode;
|
|
46
55
|
/** The bold first line — "19:30 just went". */
|
|
47
56
|
title?: React.ReactNode;
|
|
57
|
+
/**
|
|
58
|
+
* A trailing control — "Retry". Pass a real button (a `Button
|
|
59
|
+
* variant="link"` reads as the frames draw it); the banner only places it.
|
|
60
|
+
* A banner that announces a failure the user did not just cause is
|
|
61
|
+
* usually worth `role="status"` from the caller.
|
|
62
|
+
*/
|
|
63
|
+
action?: React.ReactNode;
|
|
48
64
|
};
|
|
49
65
|
|
|
50
66
|
/** Each tone's own glyph, so a caller never has to remember which. */
|
|
51
67
|
const TONE_ICON = {
|
|
52
68
|
info: <Info aria-hidden />,
|
|
53
69
|
warning: <TriangleAlert aria-hidden />,
|
|
70
|
+
destructive: <CircleAlert aria-hidden />,
|
|
54
71
|
} as const;
|
|
55
72
|
|
|
56
|
-
function Banner({ tone, icon, title, className, children, ...props }: BannerProps) {
|
|
73
|
+
function Banner({ tone, icon, title, action, className, children, ...props }: BannerProps) {
|
|
57
74
|
return (
|
|
58
75
|
<div data-slot="banner" className={cn(bannerVariants({ tone }), className)} {...props}>
|
|
59
76
|
{icon === undefined ? TONE_ICON[tone ?? "info"] : icon}
|
|
60
|
-
<div className="min-w-0">
|
|
77
|
+
<div className="min-w-0 flex-1">
|
|
61
78
|
{title ? <b className="mb-[3px] block text-[13px] font-bold">{title}</b> : null}
|
|
62
|
-
<div className={cn(tone === "warning" && title ? "opacity-90" : undefined)}>
|
|
79
|
+
<div className={cn((tone === "warning" || tone === "destructive") && title ? "opacity-90" : undefined)}>
|
|
80
|
+
{children}
|
|
81
|
+
</div>
|
|
63
82
|
</div>
|
|
83
|
+
{action ? (
|
|
84
|
+
<div data-slot="banner-action" className="shrink-0 self-center">
|
|
85
|
+
{action}
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
64
88
|
</div>
|
|
65
89
|
);
|
|
66
90
|
}
|
|
@@ -52,6 +52,16 @@ export type BookingRowProps = {
|
|
|
52
52
|
href: string;
|
|
53
53
|
/** The venue photo, rendered by the app; a quiet placeholder when absent. */
|
|
54
54
|
thumbnail?: React.ReactNode;
|
|
55
|
+
/**
|
|
56
|
+
* One more chip about this booking, beside the status — "on this phone" for
|
|
57
|
+
* a booking the signed-in guest has not put in their account yet.
|
|
58
|
+
*
|
|
59
|
+
* A node rather than a string because the app owns what it says and how it
|
|
60
|
+
* is marked; the row only owns where it sits. It rides with the status on
|
|
61
|
+
* both layouts, so the two read as one cluster of facts about the booking
|
|
62
|
+
* rather than a second row of chrome.
|
|
63
|
+
*/
|
|
64
|
+
note?: React.ReactNode;
|
|
55
65
|
/** The next booking up — tinted on desktop. */
|
|
56
66
|
highlighted?: boolean;
|
|
57
67
|
linkComponent?: React.ComponentType<BookingRowLinkProps>;
|
|
@@ -84,6 +94,7 @@ export function BookingRow({
|
|
|
84
94
|
code,
|
|
85
95
|
href,
|
|
86
96
|
thumbnail,
|
|
97
|
+
note,
|
|
87
98
|
highlighted,
|
|
88
99
|
linkComponent: LinkComponent = DefaultLink,
|
|
89
100
|
className,
|
|
@@ -127,6 +138,7 @@ export function BookingRow({
|
|
|
127
138
|
<div className="mt-2 flex flex-wrap items-center gap-[7px]">
|
|
128
139
|
<span className="text-[12.5px] font-medium">{when}</span>
|
|
129
140
|
<span className="text-xs text-muted-foreground">{party}</span>
|
|
141
|
+
{note}
|
|
130
142
|
{statusPill}
|
|
131
143
|
</div>
|
|
132
144
|
</div>
|
|
@@ -142,6 +154,7 @@ export function BookingRow({
|
|
|
142
154
|
</div>
|
|
143
155
|
</div>
|
|
144
156
|
<span className="hidden md:contents">
|
|
157
|
+
{note}
|
|
145
158
|
{statusPill}
|
|
146
159
|
{codeChip}
|
|
147
160
|
<ChevronRight aria-hidden className="size-[15px] shrink-0 text-muted-foreground" />
|
|
@@ -93,7 +93,13 @@ export function MultiSelectCombobox({
|
|
|
93
93
|
align="start"
|
|
94
94
|
>
|
|
95
95
|
<Command shouldFilter={shouldFilter}>
|
|
96
|
-
|
|
96
|
+
{/* Spread rather than `onValueChange={onSearchChange}`: under a
|
|
97
|
+
consumer's `exactOptionalPropertyTypes`, passing a possibly
|
|
98
|
+
`undefined` value to cmdk's optional prop does not typecheck. */}
|
|
99
|
+
<CommandInput
|
|
100
|
+
placeholder={searchPlaceholder}
|
|
101
|
+
{...(onSearchChange ? { onValueChange: onSearchChange } : {})}
|
|
102
|
+
/>
|
|
97
103
|
<CommandList>
|
|
98
104
|
<CommandEmpty>{loading ? "Loading…" : emptyText}</CommandEmpty>
|
|
99
105
|
<CommandGroup>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { PanZoomViewport, type PanZoomViewportHandle } from "./pan-zoom-viewport";
|
|
5
|
+
|
|
6
|
+
const labels = {
|
|
7
|
+
viewport: "Floor plan, Inside",
|
|
8
|
+
instructions: "Arrow keys move the plan. Plus and minus zoom; 0 fits it.",
|
|
9
|
+
zoomIn: "Zoom in",
|
|
10
|
+
zoomOut: "Zoom out",
|
|
11
|
+
fit: "Fit",
|
|
12
|
+
controls: "Zoom",
|
|
13
|
+
zoomLevel: (percent: number) => `${percent}%`,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A stand-in plan, painted with tokens only: the real canvas is the app's. */
|
|
17
|
+
const TABLES = [
|
|
18
|
+
{ id: "T1", x: 120, y: 240, w: 90, h: 90, round: true },
|
|
19
|
+
{ id: "T2", x: 270, y: 240, w: 90, h: 90, round: true },
|
|
20
|
+
{ id: "T3", x: 420, y: 240, w: 90, h: 90, round: false },
|
|
21
|
+
{ id: "T4", x: 120, y: 420, w: 120, h: 120, round: false },
|
|
22
|
+
{ id: "T5", x: 300, y: 420, w: 220, h: 90, round: false },
|
|
23
|
+
{ id: "T6", x: 300, y: 600, w: 180, h: 90, round: false },
|
|
24
|
+
{ id: "T7", x: 900, y: 220, w: 180, h: 120, round: false },
|
|
25
|
+
{ id: "T8", x: 700, y: 240, w: 120, h: 120, round: false },
|
|
26
|
+
{ id: "T9", x: 700, y: 420, w: 120, h: 120, round: false },
|
|
27
|
+
{ id: "T12", x: 900, y: 600, w: 180, h: 90, round: false },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function DemoPlan({ onPick }: { onPick?: (id: string) => void }) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="relative size-full rounded-sm border border-border bg-[length:24px_24px] bg-[linear-gradient(to_right,var(--line-soft)_1px,transparent_1px),linear-gradient(to_bottom,var(--line-soft)_1px,transparent_1px)]">
|
|
33
|
+
<div className="absolute left-16 top-16 grid h-20 w-96 place-items-center border border-border text-xs text-muted-foreground">
|
|
34
|
+
Bar counter
|
|
35
|
+
</div>
|
|
36
|
+
{TABLES.map((table) => (
|
|
37
|
+
<button
|
|
38
|
+
key={table.id}
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={() => onPick?.(table.id)}
|
|
41
|
+
aria-label={`Table ${table.id}`}
|
|
42
|
+
className={
|
|
43
|
+
"absolute grid place-items-center border border-border bg-card text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring " +
|
|
44
|
+
(table.round ? "rounded-full" : "rounded-md")
|
|
45
|
+
}
|
|
46
|
+
style={{ left: table.x, top: table.y, width: table.w, height: table.h }}
|
|
47
|
+
>
|
|
48
|
+
{table.id}
|
|
49
|
+
</button>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const meta: Meta<typeof PanZoomViewport> = {
|
|
56
|
+
title: "Booking/PanZoomViewport",
|
|
57
|
+
component: PanZoomViewport,
|
|
58
|
+
tags: ["autodocs"],
|
|
59
|
+
parameters: {
|
|
60
|
+
docs: {
|
|
61
|
+
description: {
|
|
62
|
+
component:
|
|
63
|
+
"A window onto a plan that pans and zooms: drag, pinch, trackpad pinch or ctrl-wheel, arrows / + / − / 0 on the focused viewport, and the + / − / Fit controls. Fit never goes below `minScale`; past the floor the plan anchors top-left and the edges it runs past are shaded. Every string is injected through `labels`.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default meta;
|
|
70
|
+
type Story = StoryObj<typeof PanZoomViewport>;
|
|
71
|
+
|
|
72
|
+
/** floor/07-f0 desktop: the plan fits the viewport. Tap a table: the click still lands. */
|
|
73
|
+
export const Desktop: Story = {
|
|
74
|
+
render: () => {
|
|
75
|
+
const [picked, setPicked] = React.useState<string | null>(null);
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col gap-2">
|
|
78
|
+
<PanZoomViewport
|
|
79
|
+
contentWidth={1200}
|
|
80
|
+
contentHeight={800}
|
|
81
|
+
labels={labels}
|
|
82
|
+
className="h-[560px] w-[900px] rounded-xl border border-border bg-background"
|
|
83
|
+
>
|
|
84
|
+
<DemoPlan onPick={setPicked} />
|
|
85
|
+
</PanZoomViewport>
|
|
86
|
+
<p className="text-sm text-muted-foreground">Picked: {picked ?? "none"}</p>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** floor/07-f0 phone: the fit would be unreadable, so it stops at the floor and pans. */
|
|
93
|
+
export const PhoneAtTheFloor: Story = {
|
|
94
|
+
render: () => (
|
|
95
|
+
<PanZoomViewport
|
|
96
|
+
contentWidth={1200}
|
|
97
|
+
contentHeight={800}
|
|
98
|
+
minScale={0.55}
|
|
99
|
+
labels={labels}
|
|
100
|
+
className="h-[480px] w-[390px] border-y border-border bg-background"
|
|
101
|
+
>
|
|
102
|
+
<DemoPlan />
|
|
103
|
+
</PanZoomViewport>
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Driving it from outside: a "Show T12" link, a reset. */
|
|
108
|
+
export const Imperative: Story = {
|
|
109
|
+
render: () => {
|
|
110
|
+
const api = React.useRef<PanZoomViewportHandle>(null);
|
|
111
|
+
return (
|
|
112
|
+
<div className="flex flex-col gap-2">
|
|
113
|
+
<div className="flex gap-2">
|
|
114
|
+
<Button variant="outline" size="sm" onClick={() => api.current?.centerOn(990, 645)}>
|
|
115
|
+
Show T12
|
|
116
|
+
</Button>
|
|
117
|
+
<Button variant="outline" size="sm" onClick={() => api.current?.fit()}>
|
|
118
|
+
Reset
|
|
119
|
+
</Button>
|
|
120
|
+
</div>
|
|
121
|
+
<PanZoomViewport
|
|
122
|
+
apiRef={api}
|
|
123
|
+
contentWidth={1200}
|
|
124
|
+
contentHeight={800}
|
|
125
|
+
minScale={0.8}
|
|
126
|
+
labels={labels}
|
|
127
|
+
className="h-[420px] w-[640px] rounded-xl border border-border bg-background"
|
|
128
|
+
>
|
|
129
|
+
<DemoPlan />
|
|
130
|
+
</PanZoomViewport>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
},
|
|
134
|
+
};
|