@bearmenu/ui 0.8.35 → 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 (50) 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.js +1 -1
  11. package/dist/components/button-group.js +3 -39
  12. package/dist/components/button.d.ts +2 -2
  13. package/dist/components/command.d.ts +3 -3
  14. package/dist/components/concept-convergence.js +1 -1
  15. package/dist/components/filter-category-row.js +1 -1
  16. package/dist/components/menu-item.js +3 -3
  17. package/dist/components/multi-select-combobox.js +8 -3
  18. package/dist/components/pan-zoom-viewport.d.ts +137 -0
  19. package/dist/components/pan-zoom-viewport.js +531 -0
  20. package/dist/components/place-card.js +2 -2
  21. package/dist/components/place-details-mobile.js +1 -1
  22. package/dist/components/responsive-overlay.d.ts +29 -1
  23. package/dist/components/responsive-overlay.js +45 -6
  24. package/dist/components/sheet.d.ts +6 -0
  25. package/dist/components/sheet.js +3 -133
  26. package/dist/components/signature-chip.js +2 -2
  27. package/dist/components/spinner.d.ts +2 -2
  28. package/dist/components/tabs.d.ts +16 -1
  29. package/dist/components/tabs.js +21 -3
  30. package/dist/components/tag.d.ts +2 -2
  31. package/dist/components/text.d.ts +2 -2
  32. package/dist/components/top-ranking-entry.js +3 -3
  33. package/package.json +1 -1
  34. package/src/components/badge.stories.tsx +32 -1
  35. package/src/components/badge.test.tsx +45 -0
  36. package/src/components/badge.tsx +41 -3
  37. package/src/components/banner.stories.tsx +32 -0
  38. package/src/components/banner.test.tsx +58 -0
  39. package/src/components/banner.tsx +28 -4
  40. package/src/components/multi-select-combobox.tsx +7 -1
  41. package/src/components/pan-zoom-viewport.stories.tsx +134 -0
  42. package/src/components/pan-zoom-viewport.test.tsx +381 -0
  43. package/src/components/pan-zoom-viewport.tsx +727 -0
  44. package/src/components/responsive-overlay.stories.tsx +61 -0
  45. package/src/components/responsive-overlay.test.tsx +104 -0
  46. package/src/components/responsive-overlay.tsx +87 -22
  47. package/src/components/sheet.tsx +14 -3
  48. package/src/components/tabs.stories.tsx +23 -0
  49. package/src/components/tabs.test.tsx +64 -0
  50. package/src/components/tabs.tsx +36 -3
@@ -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
  }
@@ -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
+ };
@@ -0,0 +1,381 @@
1
+ import * as React from "react";
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import {
6
+ PanZoomViewport,
7
+ type PanZoomViewportHandle,
8
+ type PanZoomViewportProps,
9
+ } from "./pan-zoom-viewport";
10
+
11
+ /**
12
+ * jsdom lays nothing out, so each test states the viewport's size: the
13
+ * surface reports it as its client box. Its bounding rect stays at 0,0, so
14
+ * client coordinates in the pointer events are viewport coordinates.
15
+ */
16
+ let viewportSize = { width: 800, height: 600 };
17
+
18
+ function isSurface(element: HTMLElement) {
19
+ return element.getAttribute("data-slot") === "pan-zoom-viewport-surface";
20
+ }
21
+
22
+ beforeEach(() => {
23
+ viewportSize = { width: 800, height: 600 };
24
+ vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(function (
25
+ this: HTMLElement
26
+ ) {
27
+ return isSurface(this) ? viewportSize.width : 0;
28
+ });
29
+ vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(function (
30
+ this: HTMLElement
31
+ ) {
32
+ return isSurface(this) ? viewportSize.height : 0;
33
+ });
34
+ });
35
+
36
+ afterEach(() => vi.restoreAllMocks());
37
+
38
+ const labels = {
39
+ viewport: "Floor plan, Inside",
40
+ instructions: "Arrow keys move the plan.",
41
+ zoomIn: "Zoom in",
42
+ zoomOut: "Zoom out",
43
+ fit: "Fit",
44
+ controls: "Zoom",
45
+ zoomLevel: (percent: number) => `${percent}%`,
46
+ };
47
+
48
+ function renderViewport(props: Partial<PanZoomViewportProps> = {}) {
49
+ return render(
50
+ <PanZoomViewport contentWidth={400} contentHeight={300} labels={labels} {...props}>
51
+ <button type="button">T1</button>
52
+ </PanZoomViewport>
53
+ );
54
+ }
55
+
56
+ const surface = () => screen.getByRole("group", { name: "Floor plan, Inside" });
57
+ const content = () =>
58
+ document.querySelector<HTMLElement>('[data-slot="pan-zoom-viewport-content"]');
59
+
60
+ /** The transform as numbers, rounded so float noise does not fail a test. */
61
+ function transformOf() {
62
+ const match = /translate\((-?[\d.]+)px, (-?[\d.]+)px\) scale\(([\d.]+)\)/.exec(
63
+ content()?.style.transform ?? ""
64
+ );
65
+ if (!match) throw new Error(`no transform on the content: ${content()?.style.transform}`);
66
+ const [, x, y, scale] = match.map(Number);
67
+ const round = (value: number | undefined) => Math.round((value ?? NaN) * 1000) / 1000;
68
+ return { x: round(x), y: round(y), scale: round(scale) };
69
+ }
70
+
71
+ function edgeVisible(edge: "left" | "right" | "top" | "bottom") {
72
+ return (
73
+ document
74
+ .querySelector(`[data-slot="pan-zoom-viewport-edge"][data-edge="${edge}"]`)
75
+ ?.getAttribute("data-visible") === "true"
76
+ );
77
+ }
78
+
79
+ /** A phone-sized viewport onto a plan that would fit far below the floor. */
80
+ function renderOnAPhone(props: Partial<PanZoomViewportProps> = {}) {
81
+ viewportSize = { width: 390, height: 500 };
82
+ return renderViewport({ contentWidth: 1200, contentHeight: 800, minScale: 0.5, ...props });
83
+ }
84
+
85
+ describe("PanZoomViewport", () => {
86
+ describe("fit", () => {
87
+ it("fits the content inside its padding and centres it", () => {
88
+ renderViewport();
89
+ // min((800 - 48) / 400, (600 - 48) / 300) = 1.84 → 736 × 552.
90
+ expect(transformOf()).toEqual({ x: 32, y: 24, scale: 1.84 });
91
+ expect(content()).not.toHaveClass("invisible");
92
+ });
93
+
94
+ it("stops at the floor, anchors top-left and shades the edge that runs on", () => {
95
+ renderOnAPhone();
96
+ // The fit would be 0.285; the floor holds it at 0.5 → 600 × 400.
97
+ // Too wide: anchored at the padding. Short enough: centred.
98
+ expect(transformOf()).toEqual({ x: 24, y: 50, scale: 0.5 });
99
+ expect(edgeVisible("right")).toBe(true);
100
+ expect(edgeVisible("left")).toBe(false);
101
+ expect(edgeVisible("top")).toBe(false);
102
+ expect(edgeVisible("bottom")).toBe(false);
103
+ });
104
+
105
+ it("never fits above the ceiling", () => {
106
+ renderViewport({ contentWidth: 100, contentHeight: 100, maxScale: 2 });
107
+ expect(transformOf().scale).toBe(2);
108
+ });
109
+ });
110
+
111
+ describe("controls", () => {
112
+ it("zooms in and out by one step about the centre", async () => {
113
+ renderViewport();
114
+ await userEvent.click(screen.getByRole("button", { name: "Zoom out" }));
115
+ // 1.84 / 1.25 = 1.472 → 588.8 × 441.6, centred.
116
+ expect(transformOf()).toEqual({ x: 105.6, y: 79.2, scale: 1.472 });
117
+ await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
118
+ expect(transformOf().scale).toBe(1.84);
119
+ });
120
+
121
+ it("disables zoom out at the floor and zoom in at the ceiling", async () => {
122
+ renderOnAPhone({ maxScale: 0.625 });
123
+ expect(screen.getByRole("button", { name: "Zoom out" })).toBeDisabled();
124
+ await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
125
+ expect(transformOf().scale).toBe(0.625);
126
+ expect(screen.getByRole("button", { name: "Zoom in" })).toBeDisabled();
127
+ });
128
+
129
+ it("returns to the fitted view", async () => {
130
+ renderViewport();
131
+ const fitted = transformOf();
132
+ await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
133
+ expect(transformOf()).not.toEqual(fitted);
134
+ await userEvent.click(screen.getByRole("button", { name: "Fit" }));
135
+ expect(transformOf()).toEqual(fitted);
136
+ });
137
+
138
+ it("names the controls from the injected labels and can be left out", () => {
139
+ const view = renderViewport();
140
+ expect(screen.getByRole("group", { name: "Zoom" })).toBeInTheDocument();
141
+ view.unmount();
142
+ renderViewport({ controls: false });
143
+ expect(screen.queryByRole("button", { name: "Zoom in" })).toBeNull();
144
+ });
145
+
146
+ it("eases a zoom from the controls, and never under reduced motion", async () => {
147
+ renderViewport();
148
+ expect(content()?.className).not.toContain("transition-transform");
149
+ await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
150
+ expect(content()).toHaveClass("transition-transform", "motion-reduce:transition-none");
151
+ });
152
+ });
153
+
154
+ describe("keyboard", () => {
155
+ it("pans with the arrows when the viewport has focus, four steps with Shift", async () => {
156
+ renderOnAPhone();
157
+ surface().focus();
158
+ await userEvent.keyboard("{ArrowRight}");
159
+ expect(transformOf().x).toBe(24 - 48);
160
+ await userEvent.keyboard("{Shift>}{ArrowRight}{/Shift}");
161
+ expect(transformOf().x).toBe(24 - 48 - 192);
162
+ // The far edge stops `fitPadding` inside the viewport: 390 − 600 − 24.
163
+ await userEvent.keyboard("{Shift>}{ArrowRight}{/Shift}");
164
+ expect(transformOf().x).toBe(-234);
165
+ await userEvent.keyboard("{ArrowLeft}");
166
+ expect(transformOf().x).toBe(-186);
167
+ });
168
+
169
+ it("does not pan content that already fits", async () => {
170
+ renderViewport();
171
+ surface().focus();
172
+ const before = transformOf();
173
+ await userEvent.keyboard("{ArrowRight}{ArrowDown}");
174
+ expect(transformOf()).toEqual(before);
175
+ });
176
+
177
+ it("zooms with + and -, and fits with 0", async () => {
178
+ renderViewport();
179
+ surface().focus();
180
+ await userEvent.keyboard("+");
181
+ expect(transformOf().scale).toBe(2.3);
182
+ await userEvent.keyboard("-");
183
+ expect(transformOf().scale).toBe(1.84);
184
+ await userEvent.keyboard("-");
185
+ await userEvent.keyboard("0");
186
+ expect(transformOf()).toEqual({ x: 32, y: 24, scale: 1.84 });
187
+ });
188
+
189
+ it("leaves the arrows to a focused child", async () => {
190
+ renderOnAPhone();
191
+ screen.getByRole("button", { name: "T1" }).focus();
192
+ const before = transformOf();
193
+ await userEvent.keyboard("{ArrowRight}");
194
+ expect(transformOf()).toEqual(before);
195
+ });
196
+
197
+ it("announces the zoom level through the injected formatter", async () => {
198
+ renderViewport();
199
+ surface().focus();
200
+ await userEvent.keyboard("+");
201
+ expect(screen.getByText("230%")).toBeInTheDocument();
202
+ });
203
+
204
+ it("is named and described by its labels", () => {
205
+ renderViewport();
206
+ expect(surface()).toHaveAccessibleName("Floor plan, Inside");
207
+ expect(surface()).toHaveAccessibleDescription("Arrow keys move the plan.");
208
+ expect(surface()).toHaveAttribute("tabindex", "0");
209
+ });
210
+ });
211
+
212
+ describe("pointer", () => {
213
+ function drag(target: Element, from: [number, number], to: [number, number], id = 1) {
214
+ fireEvent.pointerDown(target, {
215
+ pointerId: id,
216
+ pointerType: "mouse",
217
+ button: 0,
218
+ clientX: from[0],
219
+ clientY: from[1],
220
+ });
221
+ fireEvent.pointerMove(surface(), {
222
+ pointerId: id,
223
+ pointerType: "mouse",
224
+ clientX: to[0],
225
+ clientY: to[1],
226
+ });
227
+ fireEvent.pointerUp(surface(), {
228
+ pointerId: id,
229
+ pointerType: "mouse",
230
+ clientX: to[0],
231
+ clientY: to[1],
232
+ });
233
+ }
234
+
235
+ it("pans with a drag and swallows the click the drag would become", () => {
236
+ const onClick = vi.fn();
237
+ viewportSize = { width: 390, height: 500 };
238
+ render(
239
+ <PanZoomViewport contentWidth={1200} contentHeight={800} labels={labels}>
240
+ <button type="button" onClick={onClick}>
241
+ T1
242
+ </button>
243
+ </PanZoomViewport>
244
+ );
245
+ const table = screen.getByRole("button", { name: "T1" });
246
+ drag(table, [200, 200], [140, 200]);
247
+ fireEvent.click(table);
248
+ expect(transformOf().x).toBe(24 - 60);
249
+ expect(onClick).not.toHaveBeenCalled();
250
+ expect(edgeVisible("left")).toBe(true);
251
+ });
252
+
253
+ it("lets a tap through as a click", () => {
254
+ const onClick = vi.fn();
255
+ viewportSize = { width: 390, height: 500 };
256
+ render(
257
+ <PanZoomViewport contentWidth={1200} contentHeight={800} labels={labels}>
258
+ <button type="button" onClick={onClick}>
259
+ T1
260
+ </button>
261
+ </PanZoomViewport>
262
+ );
263
+ const table = screen.getByRole("button", { name: "T1" });
264
+ const before = transformOf();
265
+ drag(table, [200, 200], [202, 201]);
266
+ fireEvent.click(table);
267
+ expect(transformOf()).toEqual(before);
268
+ expect(onClick).toHaveBeenCalledOnce();
269
+ });
270
+
271
+ it("never starts a pan on an element marked data-pan-zoom-ignore", () => {
272
+ viewportSize = { width: 390, height: 500 };
273
+ render(
274
+ <PanZoomViewport contentWidth={1200} contentHeight={800} labels={labels}>
275
+ <div data-pan-zoom-ignore>handle</div>
276
+ </PanZoomViewport>
277
+ );
278
+ const before = transformOf();
279
+ drag(screen.getByText("handle"), [200, 200], [100, 200]);
280
+ expect(transformOf()).toEqual(before);
281
+ });
282
+
283
+ it("zooms with a two-finger pinch about the fingers", () => {
284
+ renderViewport();
285
+ const touch = (id: number, x: number, y: number) => ({
286
+ pointerId: id,
287
+ pointerType: "touch",
288
+ clientX: x,
289
+ clientY: y,
290
+ });
291
+ fireEvent.pointerDown(surface(), touch(1, 300, 300));
292
+ fireEvent.pointerDown(surface(), touch(2, 500, 300));
293
+ // The fingers spread from 200px apart to 250px apart: × 1.25.
294
+ fireEvent.pointerMove(surface(), touch(2, 550, 300));
295
+ fireEvent.pointerMove(surface(), touch(1, 300, 300));
296
+ expect(transformOf().scale).toBe(2.3);
297
+ fireEvent.pointerUp(surface(), touch(1, 300, 300));
298
+ fireEvent.pointerUp(surface(), touch(2, 550, 300));
299
+ });
300
+ });
301
+
302
+ describe("wheel", () => {
303
+ function wheel(init: WheelEventInit) {
304
+ const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, ...init });
305
+ act(() => {
306
+ surface().dispatchEvent(event);
307
+ });
308
+ return event;
309
+ }
310
+
311
+ it("zooms on a trackpad pinch (a ctrl-wheel) and keeps the page from zooming", () => {
312
+ renderViewport();
313
+ const event = wheel({ deltaY: -10, ctrlKey: true, clientX: 400, clientY: 300 });
314
+ expect(event.defaultPrevented).toBe(true);
315
+ expect(transformOf().scale).toBeGreaterThan(1.84);
316
+ });
317
+
318
+ it("caps one ctrl-wheel notch so a mouse wheel does not jump", () => {
319
+ renderViewport({ maxScale: 10 });
320
+ wheel({ deltaY: -100, ctrlKey: true, clientX: 400, clientY: 300 });
321
+ expect(transformOf().scale).toBeCloseTo(1.84 * Math.exp(0.25), 2);
322
+ });
323
+
324
+ it("pans on a plain scroll while there is content that way", () => {
325
+ renderOnAPhone();
326
+ const event = wheel({ deltaX: 50 });
327
+ expect(event.defaultPrevented).toBe(true);
328
+ expect(transformOf().x).toBe(24 - 50);
329
+ });
330
+
331
+ it("hands the scroll back to the page when nothing can move", () => {
332
+ renderViewport();
333
+ const event = wheel({ deltaY: 80 });
334
+ expect(event.defaultPrevented).toBe(false);
335
+ });
336
+ });
337
+
338
+ describe("api", () => {
339
+ it("drives the view from outside and reports every transform", () => {
340
+ const api = React.createRef<PanZoomViewportHandle>();
341
+ const onTransformChange = vi.fn();
342
+ renderOnAPhone({ apiRef: api, onTransformChange });
343
+ expect(onTransformChange).toHaveBeenLastCalledWith({ scale: 0.5, x: 24, y: 50 });
344
+
345
+ act(() => api.current?.centerOn(1000, 400));
346
+ // The point (1000, 400) at 0.5 is 500 px in; centred it would put the
347
+ // content at 195 − 500 = −305, which the clamp holds at −234.
348
+ expect(api.current?.getTransform()).toEqual({ scale: 0.5, x: -234, y: 50 });
349
+ expect(onTransformChange).toHaveBeenLastCalledWith({ scale: 0.5, x: -234, y: 50 });
350
+
351
+ act(() => api.current?.zoomTo(1));
352
+ expect(api.current?.getTransform().scale).toBe(1);
353
+ act(() => api.current?.fit());
354
+ expect(api.current?.getTransform()).toEqual({ scale: 0.5, x: 24, y: 50 });
355
+ });
356
+
357
+ it("refits when the plan changes size", () => {
358
+ const view = renderViewport();
359
+ view.rerender(
360
+ <PanZoomViewport contentWidth={800} contentHeight={600} labels={labels}>
361
+ <button type="button">T1</button>
362
+ </PanZoomViewport>
363
+ );
364
+ // min(752 / 800, 552 / 600) = 0.92.
365
+ expect(transformOf().scale).toBe(0.92);
366
+ });
367
+
368
+ it("keeps a panned view, pulled inside the bounds, when the plan changes", async () => {
369
+ const view = renderOnAPhone();
370
+ surface().focus();
371
+ await userEvent.keyboard("{Shift>}{ArrowRight}{/Shift}");
372
+ view.rerender(
373
+ <PanZoomViewport contentWidth={700} contentHeight={800} minScale={0.5} labels={labels}>
374
+ <button type="button">T1</button>
375
+ </PanZoomViewport>
376
+ );
377
+ // 350 wide at 0.5 now: x may go no further than 390 − 350 − 24 = 16.
378
+ expect(transformOf()).toEqual({ scale: 0.5, x: 16, y: 50 });
379
+ });
380
+ });
381
+ });