@bearmenu/ui 0.8.35 → 0.8.37

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 (76) 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-TVEIUTBQ.js → chunk-IQIJCMG2.js} +1 -1
  4. package/dist/{chunk-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
  5. package/dist/chunk-LAEVMNJN.js +135 -0
  6. package/dist/{chunk-VCRXL7H5.js → chunk-PGQXVJXW.js} +2 -2
  7. package/dist/{chunk-F3XK34DV.js → chunk-TB2K5JN3.js} +2 -2
  8. package/dist/{chunk-XNBDACL7.js → chunk-ZUBBMLBD.js} +1 -1
  9. package/dist/components/badge.d.ts +8 -2
  10. package/dist/components/badge.js +1 -1
  11. package/dist/components/banner.d.ts +17 -2
  12. package/dist/components/banner.js +10 -7
  13. package/dist/components/booking-row.js +1 -1
  14. package/dist/components/button-group.js +3 -39
  15. package/dist/components/calendar.js +1 -1
  16. package/dist/components/combobox.js +3 -3
  17. package/dist/components/command.js +2 -2
  18. package/dist/components/concept-convergence.js +1 -1
  19. package/dist/components/dialog.js +1 -1
  20. package/dist/components/dropdown-menu.js +6 -6
  21. package/dist/components/filter-category-row.js +1 -1
  22. package/dist/components/input-group.js +1 -1
  23. package/dist/components/menu-item.js +3 -3
  24. package/dist/components/multi-select-combobox.js +10 -5
  25. package/dist/components/multi-select-menu.js +1 -1
  26. package/dist/components/pan-zoom-viewport.d.ts +137 -0
  27. package/dist/components/pan-zoom-viewport.js +531 -0
  28. package/dist/components/place-card.js +2 -2
  29. package/dist/components/place-details-mobile.js +1 -1
  30. package/dist/components/responsive-overlay.d.ts +29 -1
  31. package/dist/components/responsive-overlay.js +46 -7
  32. package/dist/components/searchable-select.js +3 -3
  33. package/dist/components/select.js +2 -2
  34. package/dist/components/selection-bar.js +1 -1
  35. package/dist/components/sheet.d.ts +6 -0
  36. package/dist/components/sheet.js +3 -133
  37. package/dist/components/signature-chip.js +2 -2
  38. package/dist/components/tabs.d.ts +16 -1
  39. package/dist/components/tabs.js +21 -3
  40. package/dist/components/toggle-group.js +1 -1
  41. package/dist/components/toggle.d.ts +6 -0
  42. package/dist/components/toggle.js +1 -1
  43. package/dist/components/top-ranking-entry.js +3 -3
  44. package/package.json +1 -1
  45. package/src/components/badge.stories.tsx +32 -1
  46. package/src/components/badge.test.tsx +45 -0
  47. package/src/components/badge.tsx +41 -3
  48. package/src/components/banner.stories.tsx +32 -0
  49. package/src/components/banner.test.tsx +58 -0
  50. package/src/components/banner.tsx +28 -4
  51. package/src/components/calendar.tsx +1 -1
  52. package/src/components/combobox.tsx +1 -1
  53. package/src/components/command.test.tsx +10 -0
  54. package/src/components/command.tsx +1 -1
  55. package/src/components/dialog.tsx +1 -1
  56. package/src/components/dropdown-menu.test.tsx +31 -0
  57. package/src/components/dropdown-menu.tsx +6 -6
  58. package/src/components/input-group.tsx +1 -1
  59. package/src/components/multi-select-combobox.tsx +7 -1
  60. package/src/components/multi-select-menu.tsx +1 -1
  61. package/src/components/pan-zoom-viewport.stories.tsx +134 -0
  62. package/src/components/pan-zoom-viewport.test.tsx +381 -0
  63. package/src/components/pan-zoom-viewport.tsx +727 -0
  64. package/src/components/responsive-overlay.stories.tsx +61 -0
  65. package/src/components/responsive-overlay.test.tsx +104 -0
  66. package/src/components/responsive-overlay.tsx +87 -22
  67. package/src/components/searchable-select.tsx +1 -1
  68. package/src/components/select.tsx +2 -2
  69. package/src/components/selection-bar.tsx +1 -1
  70. package/src/components/sheet.tsx +14 -3
  71. package/src/components/tabs.stories.tsx +23 -0
  72. package/src/components/tabs.test.tsx +64 -0
  73. package/src/components/tabs.tsx +36 -3
  74. package/src/components/toggle.test.tsx +19 -0
  75. package/src/components/toggle.tsx +8 -2
  76. package/src/globals.css +28 -19
@@ -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
  }
@@ -238,7 +238,7 @@ function CalendarDayButton({
238
238
  data-range-end={modifiers.range_end}
239
239
  data-range-middle={modifiers.range_middle}
240
240
  className={cn(
241
- "group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground flex aspect-square h-(--cell-size) w-(--cell-size) size-auto min-w-0 flex-col items-center justify-center gap-1 rounded-md p-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
241
+ "group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground flex aspect-square h-(--cell-size) w-(--cell-size) size-auto min-w-0 flex-col items-center justify-center gap-1 rounded-md p-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
242
242
  defaultClassNames.day,
243
243
  className
244
244
  )}
@@ -124,7 +124,7 @@ function Combobox({
124
124
  e.stopPropagation();
125
125
  onValueChange(undefined);
126
126
  }}
127
- className="absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
127
+ className="absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
128
128
  >
129
129
  <X className="size-3.5" />
130
130
  </button>
@@ -88,4 +88,14 @@ describe("Command", () => {
88
88
  await user.keyboard("{ArrowDown}{Enter}");
89
89
  expect(onSelect).toHaveBeenCalledWith("banana");
90
90
  });
91
+
92
+ it("highlights the selected row with the subtle well, not the accent fill", () => {
93
+ render(<CommandSample />);
94
+ const row = screen.getByRole("option", { name: "Apple" });
95
+ expect(row).toHaveClass(
96
+ "data-[selected=true]:bg-accent-subtle",
97
+ "data-[selected=true]:text-foreground"
98
+ );
99
+ expect(row).not.toHaveClass("data-[selected=true]:bg-accent");
100
+ });
91
101
  });
@@ -138,7 +138,7 @@ const CommandItem = React.forwardRef<
138
138
  ref={ref}
139
139
  className={cn(
140
140
  "relative flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none transition-colors",
141
- "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground",
141
+ "data-[selected=true]:bg-accent-subtle data-[selected=true]:text-foreground",
142
142
  "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
143
143
  className
144
144
  )}
@@ -79,7 +79,7 @@ const DialogContent = React.forwardRef<
79
79
  >
80
80
  {children}
81
81
  {showCloseButton && (
82
- <DialogPrimitive.Close className="absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-accent-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent">
82
+ <DialogPrimitive.Close className="absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent-subtle hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none">
83
83
  <X className="size-4" />
84
84
  <span className="sr-only">Close</span>
85
85
  </DialogPrimitive.Close>
@@ -177,4 +177,35 @@ describe("DropdownMenu", () => {
177
177
  expect(screen.getByText("Group")).toBeInTheDocument();
178
178
  expect(document.querySelector("[data-slot=dropdown-menu-separator]")).not.toBeNull();
179
179
  });
180
+
181
+ // A highlighted row is a tinted well with ink type; terracotta is kept for
182
+ // the checked indicators. A solid-accent row read as a second primary.
183
+ it("highlights rows with the subtle well and marks checked items in the accent", async () => {
184
+ const user = userEvent.setup();
185
+ render(
186
+ <DropdownMenu>
187
+ <DropdownMenuTrigger>Open</DropdownMenuTrigger>
188
+ <DropdownMenuContent>
189
+ <DropdownMenuItem>One</DropdownMenuItem>
190
+ <DropdownMenuCheckboxItem checked>Two</DropdownMenuCheckboxItem>
191
+ <DropdownMenuRadioGroup value="a">
192
+ <DropdownMenuRadioItem value="a">Three</DropdownMenuRadioItem>
193
+ </DropdownMenuRadioGroup>
194
+ </DropdownMenuContent>
195
+ </DropdownMenu>
196
+ );
197
+ await user.click(screen.getByText("Open"));
198
+ const rows = [
199
+ screen.getByRole("menuitem", { name: "One" }),
200
+ screen.getByRole("menuitemcheckbox", { name: "Two" }),
201
+ screen.getByRole("menuitemradio", { name: "Three" }),
202
+ ];
203
+ for (const row of rows) {
204
+ expect(row).toHaveClass("focus:bg-accent-subtle", "focus:text-foreground");
205
+ expect(row).not.toHaveClass("focus:bg-accent");
206
+ expect(row).not.toHaveClass("focus:text-accent-foreground");
207
+ }
208
+ expect(rows[1]?.querySelector("svg")).toHaveClass("text-accent");
209
+ expect(rows[2]?.querySelector("svg")).toHaveClass("text-accent");
210
+ });
180
211
  });
@@ -67,7 +67,7 @@ function DropdownMenuItem({
67
67
  data-inset={inset}
68
68
  data-variant={variant}
69
69
  className={cn(
70
- "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
70
+ "focus:bg-accent-subtle focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
71
71
  className
72
72
  )}
73
73
  {...props}
@@ -89,7 +89,7 @@ function DropdownMenuCheckboxItem({
89
89
  <DropdownMenuPrimitive.CheckboxItem
90
90
  data-slot="dropdown-menu-checkbox-item"
91
91
  className={cn(
92
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
92
+ "focus:bg-accent-subtle focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
93
93
  className
94
94
  )}
95
95
  checked={checked}
@@ -111,7 +111,7 @@ function DropdownMenuCheckboxItem({
111
111
  </span>
112
112
  ) : (
113
113
  <DropdownMenuPrimitive.ItemIndicator>
114
- <CheckIcon className="size-4" />
114
+ <CheckIcon className="size-4 text-accent" />
115
115
  </DropdownMenuPrimitive.ItemIndicator>
116
116
  )}
117
117
  </span>
@@ -135,14 +135,14 @@ function DropdownMenuRadioItem({
135
135
  <DropdownMenuPrimitive.RadioItem
136
136
  data-slot="dropdown-menu-radio-item"
137
137
  className={cn(
138
- "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
138
+ "focus:bg-accent-subtle focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
139
139
  className
140
140
  )}
141
141
  {...props}
142
142
  >
143
143
  <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
144
144
  <DropdownMenuPrimitive.ItemIndicator>
145
- <CircleIcon className="size-2 fill-current" />
145
+ <CircleIcon className="size-2 fill-current text-accent" />
146
146
  </DropdownMenuPrimitive.ItemIndicator>
147
147
  </span>
148
148
  {children}
@@ -207,7 +207,7 @@ function DropdownMenuSubTrigger({
207
207
  data-slot="dropdown-menu-sub-trigger"
208
208
  data-inset={inset}
209
209
  className={cn(
210
- "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
210
+ "focus:bg-accent-subtle focus:text-foreground data-[state=open]:bg-accent-subtle data-[state=open]:text-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
211
211
  className
212
212
  )}
213
213
  {...props}
@@ -92,7 +92,7 @@ const InputGroupButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAtt
92
92
  type={type}
93
93
  data-slot="input-group-button"
94
94
  className={cn(
95
- "inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&:has(svg:only-child)]:w-7 [&:has(svg:only-child)]:px-0 [&_svg]:size-4",
95
+ "inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&:has(svg:only-child)]:w-7 [&:has(svg:only-child)]:px-0 [&_svg]:size-4",
96
96
  className
97
97
  )}
98
98
  {...props}
@@ -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>
@@ -81,7 +81,7 @@ export function MultiSelectMenu({
81
81
  onCheckedChange={() => handleToggle(option.value)}
82
82
  className={cn(
83
83
  "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none",
84
- "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
84
+ "data-[highlighted]:bg-accent-subtle data-[highlighted]:text-foreground",
85
85
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
86
86
  )}
87
87
  >
@@ -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
+ };