@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.
Files changed (47) hide show
  1. package/dist/chunk-EGU6EG73.js +39 -0
  2. package/dist/{chunk-NIAL4ZU2.js → chunk-FQAIYKAS.js} +45 -8
  3. package/dist/{chunk-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
  4. package/dist/chunk-LAEVMNJN.js +135 -0
  5. package/dist/{chunk-XNBDACL7.js → chunk-ZUBBMLBD.js} +1 -1
  6. package/dist/components/badge.d.ts +8 -2
  7. package/dist/components/badge.js +1 -1
  8. package/dist/components/banner.d.ts +17 -2
  9. package/dist/components/banner.js +10 -7
  10. package/dist/components/booking-row.d.ts +11 -1
  11. package/dist/components/booking-row.js +4 -1
  12. package/dist/components/button-group.js +3 -39
  13. package/dist/components/concept-convergence.js +1 -1
  14. package/dist/components/filter-category-row.js +1 -1
  15. package/dist/components/menu-item.js +3 -3
  16. package/dist/components/multi-select-combobox.js +8 -3
  17. package/dist/components/pan-zoom-viewport.d.ts +137 -0
  18. package/dist/components/pan-zoom-viewport.js +531 -0
  19. package/dist/components/place-card.js +2 -2
  20. package/dist/components/place-details-mobile.js +1 -1
  21. package/dist/components/responsive-overlay.d.ts +29 -1
  22. package/dist/components/responsive-overlay.js +45 -6
  23. package/dist/components/sheet.d.ts +6 -0
  24. package/dist/components/sheet.js +3 -133
  25. package/dist/components/signature-chip.js +2 -2
  26. package/dist/components/tabs.d.ts +16 -1
  27. package/dist/components/tabs.js +21 -3
  28. package/dist/components/top-ranking-entry.js +3 -3
  29. package/package.json +23 -216
  30. package/src/components/badge.stories.tsx +32 -1
  31. package/src/components/badge.test.tsx +45 -0
  32. package/src/components/badge.tsx +41 -3
  33. package/src/components/banner.stories.tsx +32 -0
  34. package/src/components/banner.test.tsx +58 -0
  35. package/src/components/banner.tsx +28 -4
  36. package/src/components/booking-row.tsx +13 -0
  37. package/src/components/multi-select-combobox.tsx +7 -1
  38. package/src/components/pan-zoom-viewport.stories.tsx +134 -0
  39. package/src/components/pan-zoom-viewport.test.tsx +381 -0
  40. package/src/components/pan-zoom-viewport.tsx +727 -0
  41. package/src/components/responsive-overlay.stories.tsx +61 -0
  42. package/src/components/responsive-overlay.test.tsx +104 -0
  43. package/src/components/responsive-overlay.tsx +87 -22
  44. package/src/components/sheet.tsx +14 -3
  45. package/src/components/tabs.stories.tsx +23 -0
  46. package/src/components/tabs.test.tsx +64 -0
  47. 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
+ });
@@ -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({ className, variant, size, live, as: Tag = "div", ...props }: BadgeProps) {
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&apos;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&apos;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&apos;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&apos;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)}>{children}</div>
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
- <CommandInput placeholder={searchPlaceholder} onValueChange={onSearchChange} />
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
+ };