@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
@@ -0,0 +1,727 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Minus, Plus } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Button } from "./button";
7
+ import { ButtonGroup } from "./button-group";
8
+
9
+ /**
10
+ * PanZoomViewport — a window onto content larger or smaller than itself, that
11
+ * pans and zooms: the floor plan in service and in the plan editor
12
+ * (floor/07-f0, floor/12-s11, seating/01-k2, registry/10-p0, registry/21-p15).
13
+ *
14
+ * The content is laid out at its natural size (`contentWidth` ×
15
+ * `contentHeight`, in CSS px) and moved with ONE transform —
16
+ * `translate(x, y) scale(s)` from its top-left corner — so what it draws is
17
+ * the caller's business and nothing here knows about tables.
18
+ *
19
+ * Input, all of it through the same clamp:
20
+ * - Drag with a mouse, a pen or one finger pans. The pan starts after 4px, so
21
+ * a tap still reaches a table underneath as a click, and the click a drag
22
+ * would otherwise become is swallowed. An element marked
23
+ * `data-pan-zoom-ignore` never starts a pan (the plan editor's drag handles).
24
+ * - Two fingers pinch; a trackpad pinch (a ctrl-wheel in Chromium and
25
+ * Firefox, a gesture event in Safari) and ctrl / ⌘ + wheel zoom about the
26
+ * pointer. A plain wheel or two-finger scroll pans — and hands the scroll
27
+ * back to the page once the content cannot move further that way, so the
28
+ * viewport never traps a page scroll.
29
+ * - Keyboard, with the viewport focused: arrows pan (Shift for four times the
30
+ * step); `+` / `-` zoom and `0` fits, from anywhere inside it. Tabbing onto
31
+ * a table that sits off screen pans it into view.
32
+ * - The + / − / Fit controls, bottom right.
33
+ *
34
+ * The min-scale floor: `Fit` never shrinks below `minScale`. On a phone the
35
+ * plan would otherwise fit at a size no finger can hit, so below the floor it
36
+ * stops shrinking, anchors top-left, and the rest is a pan away — with a
37
+ * shaded edge on every side where content runs past the viewport, so the
38
+ * clipped tables read as "more this way", not as a broken layout.
39
+ *
40
+ * Zooms from the controls and the keyboard ease over `--duration-quick`;
41
+ * gestures follow the finger with no easing; `prefers-reduced-motion` drops
42
+ * the easing entirely. Every string is injected through `labels`: the design
43
+ * system holds no copy.
44
+ *
45
+ * The viewport takes its size from the caller — give it a height. It refits
46
+ * when the viewport resizes or the content changes size, until the user moves
47
+ * it; after that it keeps their view and only pulls it back inside the
48
+ * bounds. To start a different plan fitted (switching Inside → Terrace),
49
+ * give the viewport a `key` per plan, or call `apiRef.current.fit()`.
50
+ */
51
+
52
+ export type PanZoomTransform = {
53
+ /** 1 is the content's natural size. */
54
+ scale: number;
55
+ /** The content's top-left corner, in viewport px. */
56
+ x: number;
57
+ y: number;
58
+ };
59
+
60
+ export type PanZoomViewportLabels = {
61
+ /** The viewport's accessible name — "Floor plan, Inside". */
62
+ viewport: string;
63
+ /** Read after the name — "Arrow keys move the plan; plus and minus zoom; 0 fits it." */
64
+ instructions?: string;
65
+ /** The + button — "Zoom in". */
66
+ zoomIn: string;
67
+ /** The − button — "Zoom out". */
68
+ zoomOut: string;
69
+ /** The fit button's visible text — "Fit". */
70
+ fit: string;
71
+ /** The controls group — "Zoom". */
72
+ controls?: string;
73
+ /**
74
+ * Announced politely after a zoom from the controls or the keyboard —
75
+ * `(percent) => \`${percent}%\``. Omit it and nothing is announced.
76
+ */
77
+ zoomLevel?: (percent: number) => string;
78
+ };
79
+
80
+ /** Drive the viewport from outside: a "Show T5" link, a reset on area change. */
81
+ export type PanZoomViewportHandle = {
82
+ zoomIn: () => void;
83
+ zoomOut: () => void;
84
+ fit: () => void;
85
+ /** Zoom to `scale` about the viewport's centre. */
86
+ zoomTo: (scale: number) => void;
87
+ /** Move the content by `dx`, `dy` viewport px. */
88
+ panBy: (dx: number, dy: number) => void;
89
+ /** Bring the content point (`x`, `y`, in natural px) to the viewport's centre. */
90
+ centerOn: (x: number, y: number) => void;
91
+ getTransform: () => PanZoomTransform;
92
+ };
93
+
94
+ export type PanZoomViewportProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children"> & {
95
+ /** The content's natural width, in CSS px. */
96
+ contentWidth: number;
97
+ /** The content's natural height, in CSS px. */
98
+ contentHeight: number;
99
+ children: React.ReactNode;
100
+ labels: PanZoomViewportLabels;
101
+ /** The floor: neither Fit nor zooming out goes below it. Default 0.5. */
102
+ minScale?: number;
103
+ /** Default 2.5. */
104
+ maxScale?: number;
105
+ /** The factor one + / − step multiplies by. Default 1.25. */
106
+ zoomStep?: number;
107
+ /** One arrow press, in viewport px. Default 48. */
108
+ panStep?: number;
109
+ /** The air Fit leaves around the content, and how far past it a pan may go. Default 24. */
110
+ fitPadding?: number;
111
+ /** Render the + / − / Fit controls. Default true. */
112
+ controls?: boolean;
113
+ /** Called with every transform — gestures included, so keep it cheap. */
114
+ onTransformChange?: (transform: PanZoomTransform) => void;
115
+ /** An imperative handle; see `PanZoomViewportHandle`. */
116
+ apiRef?: React.Ref<PanZoomViewportHandle>;
117
+ /** Classes for the transformed content layer. */
118
+ contentClassName?: string;
119
+ };
120
+
121
+ type Size = { width: number; height: number };
122
+ type Point = { x: number; y: number };
123
+
124
+ type Gesture =
125
+ | { kind: "idle" }
126
+ /** One pointer down, not yet past the drag threshold. */
127
+ | { kind: "press"; id: number; start: Point; origin: PanZoomTransform }
128
+ | { kind: "pan"; id: number; start: Point; origin: PanZoomTransform }
129
+ | { kind: "pinch"; startDistance: number; startMid: Point; origin: PanZoomTransform };
130
+
131
+ /** Safari's trackpad pinch. Not in the DOM lib: it is WebKit-only. */
132
+ type WebKitGestureEvent = UIEvent & { scale: number; clientX: number; clientY: number };
133
+
134
+ const DRAG_THRESHOLD = 4;
135
+ /** One ctrl-wheel event never zooms by more than this many delta units — a mouse wheel's 100 would jump. */
136
+ const MAX_WHEEL_ZOOM_DELTA = 25;
137
+ const WHEEL_ZOOM_SPEED = 0.01;
138
+ const EPSILON = 0.001;
139
+
140
+ function clamp(value: number, min: number, max: number) {
141
+ return Math.min(max, Math.max(min, value));
142
+ }
143
+
144
+ /**
145
+ * One axis. Content that fits (with its padding) is centred and cannot move;
146
+ * content that does not may pan until its far edge is `pad` inside the
147
+ * viewport's, and no further.
148
+ */
149
+ function clampAxis(offset: number, content: number, viewport: number, pad: number) {
150
+ if (content + pad * 2 <= viewport) return (viewport - content) / 2;
151
+ return clamp(offset, viewport - content - pad, pad);
152
+ }
153
+
154
+ function distance(a: Point, b: Point) {
155
+ return Math.hypot(a.x - b.x, a.y - b.y);
156
+ }
157
+
158
+ function midpoint(a: Point, b: Point): Point {
159
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
160
+ }
161
+
162
+ function isEditable(target: EventTarget) {
163
+ return (
164
+ target instanceof HTMLElement &&
165
+ (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))
166
+ );
167
+ }
168
+
169
+ const PanZoomViewport = React.forwardRef<HTMLDivElement, PanZoomViewportProps>(
170
+ (
171
+ {
172
+ contentWidth,
173
+ contentHeight,
174
+ children,
175
+ labels,
176
+ minScale = 0.5,
177
+ maxScale = 2.5,
178
+ zoomStep = 1.25,
179
+ panStep = 48,
180
+ fitPadding = 24,
181
+ controls = true,
182
+ onTransformChange,
183
+ apiRef,
184
+ className,
185
+ contentClassName,
186
+ ...props
187
+ },
188
+ ref
189
+ ) => {
190
+ const surfaceRef = React.useRef<HTMLDivElement | null>(null);
191
+ const [viewport, setViewport] = React.useState<Size | null>(null);
192
+ const [transform, setTransform] = React.useState<PanZoomTransform>({ scale: 1, x: 0, y: 0 });
193
+ const [animate, setAnimate] = React.useState(false);
194
+ const [announcement, setAnnouncement] = React.useState("");
195
+ const transformRef = React.useRef(transform);
196
+ /** Still showing the fitted view: a resize or a new plan refits instead of clamping. */
197
+ const fittedRef = React.useRef(true);
198
+ const pointers = React.useRef(new Map<number, Point>());
199
+ const gesture = React.useRef<Gesture>({ kind: "idle" });
200
+ const didDrag = React.useRef(false);
201
+ /** A pointer is down: the focus it causes must not pan the plan under the finger. */
202
+ const pressing = React.useRef(false);
203
+ const instructionsId = React.useId();
204
+
205
+ // Callers pass `labels` and `onTransformChange` inline, a new identity
206
+ // every render; read them through refs so no effect re-runs on them.
207
+ const onTransformChangeRef = React.useRef(onTransformChange);
208
+ const zoomLevelRef = React.useRef(labels.zoomLevel);
209
+ React.useEffect(() => {
210
+ onTransformChangeRef.current = onTransformChange;
211
+ zoomLevelRef.current = labels.zoomLevel;
212
+ });
213
+
214
+ const clampTransform = React.useCallback(
215
+ (next: PanZoomTransform): PanZoomTransform => {
216
+ if (!viewport || viewport.width <= 0 || viewport.height <= 0) return next;
217
+ const scale = clamp(next.scale, minScale, maxScale);
218
+ return {
219
+ scale,
220
+ x: clampAxis(next.x, contentWidth * scale, viewport.width, fitPadding),
221
+ y: clampAxis(next.y, contentHeight * scale, viewport.height, fitPadding),
222
+ };
223
+ },
224
+ [viewport, minScale, maxScale, contentWidth, contentHeight, fitPadding]
225
+ );
226
+
227
+ const fitted = React.useCallback((): PanZoomTransform | null => {
228
+ if (!viewport || viewport.width <= 0 || viewport.height <= 0) return null;
229
+ const scale = Math.min(
230
+ (viewport.width - fitPadding * 2) / contentWidth,
231
+ (viewport.height - fitPadding * 2) / contentHeight
232
+ );
233
+ // Start from the padded top-left: content under the floor overflows and
234
+ // stays anchored there; content that fits is centred by the clamp.
235
+ return clampTransform({ scale, x: fitPadding, y: fitPadding });
236
+ }, [viewport, fitPadding, contentWidth, contentHeight, clampTransform]);
237
+
238
+ const commit = React.useCallback(
239
+ (
240
+ next: PanZoomTransform,
241
+ options: { animate: boolean; fitted?: boolean; announce?: boolean }
242
+ ) => {
243
+ const previous = transformRef.current;
244
+ fittedRef.current = options.fitted ?? false;
245
+ setAnimate(options.animate);
246
+ if (
247
+ previous.scale === next.scale &&
248
+ previous.x === next.x &&
249
+ previous.y === next.y
250
+ ) {
251
+ return;
252
+ }
253
+ transformRef.current = next;
254
+ setTransform(next);
255
+ const zoomLevel = zoomLevelRef.current;
256
+ if (options.announce && zoomLevel && Math.abs(next.scale - previous.scale) > EPSILON) {
257
+ setAnnouncement(zoomLevel(Math.round(next.scale * 100)));
258
+ }
259
+ },
260
+ []
261
+ );
262
+
263
+ const zoomAbout = React.useCallback(
264
+ (scale: number, at: Point, options: { animate: boolean; announce?: boolean }) => {
265
+ const current = transformRef.current;
266
+ const nextScale = clamp(scale, minScale, maxScale);
267
+ const ratio = nextScale / current.scale;
268
+ commit(
269
+ clampTransform({
270
+ scale: nextScale,
271
+ x: at.x - (at.x - current.x) * ratio,
272
+ y: at.y - (at.y - current.y) * ratio,
273
+ }),
274
+ options
275
+ );
276
+ },
277
+ [commit, clampTransform, minScale, maxScale]
278
+ );
279
+
280
+ const centre = React.useCallback(
281
+ (): Point => ({ x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 }),
282
+ [viewport]
283
+ );
284
+
285
+ const zoomIn = React.useCallback(
286
+ () => zoomAbout(transformRef.current.scale * zoomStep, centre(), { animate: true, announce: true }),
287
+ [zoomAbout, zoomStep, centre]
288
+ );
289
+ const zoomOut = React.useCallback(
290
+ () => zoomAbout(transformRef.current.scale / zoomStep, centre(), { animate: true, announce: true }),
291
+ [zoomAbout, zoomStep, centre]
292
+ );
293
+ const fit = React.useCallback(() => {
294
+ const next = fitted();
295
+ if (next) commit(next, { animate: true, fitted: true, announce: true });
296
+ }, [fitted, commit]);
297
+ const panBy = React.useCallback(
298
+ (dx: number, dy: number) => {
299
+ const current = transformRef.current;
300
+ commit(clampTransform({ ...current, x: current.x + dx, y: current.y + dy }), { animate: true });
301
+ },
302
+ [commit, clampTransform]
303
+ );
304
+
305
+ React.useImperativeHandle(
306
+ apiRef,
307
+ () => ({
308
+ zoomIn,
309
+ zoomOut,
310
+ fit,
311
+ zoomTo: (scale: number) => zoomAbout(scale, centre(), { animate: true, announce: true }),
312
+ panBy,
313
+ centerOn: (x: number, y: number) => {
314
+ const current = transformRef.current;
315
+ const middle = centre();
316
+ commit(
317
+ clampTransform({
318
+ scale: current.scale,
319
+ x: middle.x - x * current.scale,
320
+ y: middle.y - y * current.scale,
321
+ }),
322
+ { animate: true }
323
+ );
324
+ },
325
+ getTransform: () => transformRef.current,
326
+ }),
327
+ [zoomIn, zoomOut, fit, zoomAbout, centre, panBy, commit, clampTransform]
328
+ );
329
+
330
+ // Measure the viewport, and keep measuring it.
331
+ React.useLayoutEffect(() => {
332
+ const surface = surfaceRef.current;
333
+ if (!surface) return;
334
+ setViewport({ width: surface.clientWidth, height: surface.clientHeight });
335
+ if (typeof ResizeObserver === "undefined") return;
336
+ const observer = new ResizeObserver(([entry]) => {
337
+ if (!entry) return;
338
+ const { width, height } = entry.contentRect;
339
+ setViewport((previous) =>
340
+ previous && previous.width === width && previous.height === height
341
+ ? previous
342
+ : { width, height }
343
+ );
344
+ });
345
+ observer.observe(surface);
346
+ return () => observer.disconnect();
347
+ }, []);
348
+
349
+ // A new size or a new plan: refit while the user has not moved it, else
350
+ // keep their view and only pull it back inside the bounds. Layout effect,
351
+ // so the first painted frame is already fitted.
352
+ React.useLayoutEffect(() => {
353
+ if (fittedRef.current) {
354
+ const next = fitted();
355
+ if (next) commit(next, { animate: false, fitted: true });
356
+ } else {
357
+ commit(clampTransform(transformRef.current), { animate: false });
358
+ }
359
+ }, [fitted, clampTransform, commit]);
360
+
361
+ React.useEffect(() => {
362
+ onTransformChangeRef.current?.(transform);
363
+ }, [transform]);
364
+
365
+ // Wheel and Safari's gesture events are attached natively: React's wheel
366
+ // listener is passive, and a passive listener cannot keep the page from
367
+ // zooming or scrolling underneath.
368
+ React.useEffect(() => {
369
+ const surface = surfaceRef.current;
370
+ if (!surface) return;
371
+
372
+ const local = (clientX: number, clientY: number): Point => {
373
+ const rect = surface.getBoundingClientRect();
374
+ return { x: clientX - rect.left, y: clientY - rect.top };
375
+ };
376
+
377
+ const onWheel = (event: WheelEvent) => {
378
+ const unit =
379
+ event.deltaMode === WheelEvent.DOM_DELTA_LINE
380
+ ? 16
381
+ : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
382
+ ? surface.clientHeight
383
+ : 1;
384
+ const current = transformRef.current;
385
+ if (event.ctrlKey || event.metaKey) {
386
+ event.preventDefault();
387
+ const delta = clamp(event.deltaY * unit, -MAX_WHEEL_ZOOM_DELTA, MAX_WHEEL_ZOOM_DELTA);
388
+ zoomAbout(current.scale * Math.exp(-delta * WHEEL_ZOOM_SPEED), local(event.clientX, event.clientY), {
389
+ animate: false,
390
+ });
391
+ return;
392
+ }
393
+ const next = clampTransform({
394
+ ...current,
395
+ x: current.x - event.deltaX * unit,
396
+ y: current.y - event.deltaY * unit,
397
+ });
398
+ // Nothing left to reveal this way: the scroll belongs to the page.
399
+ if (Math.abs(next.x - current.x) < 0.5 && Math.abs(next.y - current.y) < 0.5) return;
400
+ event.preventDefault();
401
+ commit(next, { animate: false });
402
+ };
403
+
404
+ let gestureStartScale = 1;
405
+ const onGestureStart = (event: Event) => {
406
+ event.preventDefault();
407
+ gestureStartScale = transformRef.current.scale;
408
+ };
409
+ const onGestureChange = (event: Event) => {
410
+ event.preventDefault();
411
+ // iOS fires these beside the pointer stream for a touch pinch, which
412
+ // the pointer handlers already own.
413
+ if (pointers.current.size >= 2) return;
414
+ const gestureEvent = event as WebKitGestureEvent;
415
+ zoomAbout(
416
+ gestureStartScale * gestureEvent.scale,
417
+ local(gestureEvent.clientX, gestureEvent.clientY),
418
+ { animate: false }
419
+ );
420
+ };
421
+
422
+ surface.addEventListener("wheel", onWheel, { passive: false });
423
+ surface.addEventListener("gesturestart", onGestureStart);
424
+ surface.addEventListener("gesturechange", onGestureChange);
425
+ return () => {
426
+ surface.removeEventListener("wheel", onWheel);
427
+ surface.removeEventListener("gesturestart", onGestureStart);
428
+ surface.removeEventListener("gesturechange", onGestureChange);
429
+ };
430
+ }, [zoomAbout, clampTransform, commit]);
431
+
432
+ const localPoint = (event: React.PointerEvent<HTMLDivElement>): Point => {
433
+ const rect = event.currentTarget.getBoundingClientRect();
434
+ return { x: event.clientX - rect.left, y: event.clientY - rect.top };
435
+ };
436
+
437
+ const capture = (element: HTMLElement, pointerId: number) => {
438
+ // A pointer that is already gone (a fast tap) cannot be captured.
439
+ try {
440
+ element.setPointerCapture(pointerId);
441
+ } catch {
442
+ /* nothing to capture */
443
+ }
444
+ };
445
+
446
+ const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
447
+ if (event.pointerType === "mouse" && event.button !== 0) return;
448
+ if (event.target instanceof Element && event.target.closest("[data-pan-zoom-ignore]")) return;
449
+ pressing.current = true;
450
+ // A mouse press released outside before it became a drag (so before it
451
+ // was captured) never reports its pointerup here. There is only ever one
452
+ // mouse: drop the stale entry rather than read two as a pinch.
453
+ if (event.pointerType === "mouse") pointers.current.clear();
454
+ const point = localPoint(event);
455
+ pointers.current.set(event.pointerId, point);
456
+
457
+ if (pointers.current.size === 1) {
458
+ didDrag.current = false;
459
+ gesture.current = {
460
+ kind: "press",
461
+ id: event.pointerId,
462
+ start: point,
463
+ origin: transformRef.current,
464
+ };
465
+ return;
466
+ }
467
+
468
+ if (pointers.current.size === 2) {
469
+ const [a, b] = [...pointers.current.values()] as [Point, Point];
470
+ didDrag.current = true;
471
+ for (const id of pointers.current.keys()) capture(event.currentTarget, id);
472
+ gesture.current = {
473
+ kind: "pinch",
474
+ startDistance: Math.max(distance(a, b), 1),
475
+ startMid: midpoint(a, b),
476
+ origin: transformRef.current,
477
+ };
478
+ }
479
+ };
480
+
481
+ const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
482
+ if (!pointers.current.has(event.pointerId)) return;
483
+ const point = localPoint(event);
484
+ pointers.current.set(event.pointerId, point);
485
+ const current = gesture.current;
486
+
487
+ if ((current.kind === "press" || current.kind === "pan") && current.id === event.pointerId) {
488
+ const dx = point.x - current.start.x;
489
+ const dy = point.y - current.start.y;
490
+ if (current.kind === "press") {
491
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
492
+ gesture.current = { ...current, kind: "pan" };
493
+ didDrag.current = true;
494
+ capture(event.currentTarget, event.pointerId);
495
+ }
496
+ commit(
497
+ clampTransform({ ...current.origin, x: current.origin.x + dx, y: current.origin.y + dy }),
498
+ { animate: false }
499
+ );
500
+ return;
501
+ }
502
+
503
+ if (current.kind === "pinch" && pointers.current.size >= 2) {
504
+ const [a, b] = [...pointers.current.values()] as [Point, Point];
505
+ const mid = midpoint(a, b);
506
+ const { origin } = current;
507
+ const scale = clamp(
508
+ origin.scale * (distance(a, b) / current.startDistance),
509
+ minScale,
510
+ maxScale
511
+ );
512
+ // The content point that was under the fingers stays under them.
513
+ const anchorX = (current.startMid.x - origin.x) / origin.scale;
514
+ const anchorY = (current.startMid.y - origin.y) / origin.scale;
515
+ commit(clampTransform({ scale, x: mid.x - anchorX * scale, y: mid.y - anchorY * scale }), {
516
+ animate: false,
517
+ });
518
+ }
519
+ };
520
+
521
+ const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
522
+ if (!pointers.current.delete(event.pointerId)) return;
523
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
524
+ event.currentTarget.releasePointerCapture(event.pointerId);
525
+ }
526
+ const remaining = [...pointers.current.entries()];
527
+ if (remaining.length === 1 && gesture.current.kind === "pinch") {
528
+ // One finger lifted from a pinch: the other carries on as a pan.
529
+ const [[id, point]] = remaining as [[number, Point]];
530
+ gesture.current = { kind: "pan", id, start: point, origin: transformRef.current };
531
+ return;
532
+ }
533
+ if (remaining.length === 0) {
534
+ gesture.current = { kind: "idle" };
535
+ pressing.current = false;
536
+ }
537
+ };
538
+
539
+ /** The click a drag would otherwise become, on a table under the finger. */
540
+ const onClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
541
+ if (!didDrag.current) return;
542
+ event.preventDefault();
543
+ event.stopPropagation();
544
+ didDrag.current = false;
545
+ };
546
+
547
+ const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
548
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
549
+ if (isEditable(event.target)) return;
550
+
551
+ if (event.key === "+" || event.key === "=") zoomIn();
552
+ else if (event.key === "-" || event.key === "_") zoomOut();
553
+ else if (event.key === "0") fit();
554
+ else {
555
+ // Arrows belong to whatever is focused inside — the plan editor
556
+ // nudges a table with them. Only the viewport itself pans.
557
+ if (event.target !== event.currentTarget) return;
558
+ const step = panStep * (event.shiftKey ? 4 : 1);
559
+ const move: Record<string, Point> = {
560
+ ArrowLeft: { x: step, y: 0 },
561
+ ArrowRight: { x: -step, y: 0 },
562
+ ArrowUp: { x: 0, y: step },
563
+ ArrowDown: { x: 0, y: -step },
564
+ };
565
+ const delta = move[event.key];
566
+ if (!delta) return;
567
+ panBy(delta.x, delta.y);
568
+ }
569
+ event.preventDefault();
570
+ };
571
+
572
+ /** Tabbing onto a table past the edge brings it into view. */
573
+ const onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
574
+ if (pressing.current || event.target === event.currentTarget) return;
575
+ const bounds = event.currentTarget.getBoundingClientRect();
576
+ const rect = event.target.getBoundingClientRect();
577
+ const margin = fitPadding;
578
+ let dx = 0;
579
+ let dy = 0;
580
+ if (rect.left < bounds.left + margin) dx = bounds.left + margin - rect.left;
581
+ else if (rect.right > bounds.right - margin) dx = bounds.right - margin - rect.right;
582
+ if (rect.top < bounds.top + margin) dy = bounds.top + margin - rect.top;
583
+ else if (rect.bottom > bounds.bottom - margin) dy = bounds.bottom - margin - rect.bottom;
584
+ if (dx !== 0 || dy !== 0) panBy(dx, dy);
585
+ };
586
+
587
+ const scaledWidth = contentWidth * transform.scale;
588
+ const scaledHeight = contentHeight * transform.scale;
589
+ const overflow = viewport
590
+ ? {
591
+ left: transform.x < -0.5,
592
+ right: transform.x + scaledWidth > viewport.width + 0.5,
593
+ top: transform.y < -0.5,
594
+ bottom: transform.y + scaledHeight > viewport.height + 0.5,
595
+ }
596
+ : { left: false, right: false, top: false, bottom: false };
597
+ const atMin = transform.scale <= minScale + EPSILON;
598
+ const atMax = transform.scale >= maxScale - EPSILON;
599
+
600
+ return (
601
+ <div
602
+ ref={ref}
603
+ data-slot="pan-zoom-viewport"
604
+ className={cn("relative overflow-hidden", className)}
605
+ {...props}
606
+ >
607
+ <div
608
+ ref={surfaceRef}
609
+ data-slot="pan-zoom-viewport-surface"
610
+ role="group"
611
+ tabIndex={0}
612
+ aria-label={labels.viewport}
613
+ aria-describedby={labels.instructions ? instructionsId : undefined}
614
+ className={cn(
615
+ "absolute inset-0 touch-none select-none outline-none",
616
+ "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
617
+ "cursor-grab active:cursor-grabbing"
618
+ )}
619
+ onPointerDown={onPointerDown}
620
+ onPointerMove={onPointerMove}
621
+ onPointerUp={onPointerEnd}
622
+ onPointerCancel={onPointerEnd}
623
+ onClickCapture={onClickCapture}
624
+ onDragStart={(event) => event.preventDefault()}
625
+ onKeyDown={onKeyDown}
626
+ onFocus={onFocus}
627
+ >
628
+ <div
629
+ data-slot="pan-zoom-viewport-content"
630
+ className={cn(
631
+ "absolute left-0 top-0 origin-top-left",
632
+ // Hidden for the one render before the first measurement, so the
633
+ // unfitted plan never flashes.
634
+ !viewport && "invisible",
635
+ animate &&
636
+ "transition-transform duration-[var(--duration-quick)] ease-[var(--ease-standard)] motion-reduce:transition-none",
637
+ contentClassName
638
+ )}
639
+ style={{
640
+ width: contentWidth,
641
+ height: contentHeight,
642
+ transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
643
+ }}
644
+ >
645
+ {children}
646
+ </div>
647
+ </div>
648
+
649
+ {/* "More this way": a shade on each edge the content runs past. */}
650
+ {(
651
+ [
652
+ ["left", "inset-y-0 left-0 w-6 bg-gradient-to-r"],
653
+ ["right", "inset-y-0 right-0 w-6 bg-gradient-to-l"],
654
+ ["top", "inset-x-0 top-0 h-6 bg-gradient-to-b"],
655
+ ["bottom", "inset-x-0 bottom-0 h-6 bg-gradient-to-t"],
656
+ ] as const
657
+ ).map(([edge, position]) => (
658
+ <div
659
+ key={edge}
660
+ aria-hidden
661
+ data-slot="pan-zoom-viewport-edge"
662
+ data-edge={edge}
663
+ data-visible={overflow[edge] ? "true" : "false"}
664
+ className={cn(
665
+ "pointer-events-none absolute from-foreground/10 to-transparent transition-opacity duration-[var(--duration-quick)] motion-reduce:transition-none",
666
+ position,
667
+ overflow[edge] ? "opacity-100" : "opacity-0"
668
+ )}
669
+ />
670
+ ))}
671
+
672
+ {controls ? (
673
+ <ButtonGroup
674
+ orientation="vertical"
675
+ aria-label={labels.controls}
676
+ data-slot="pan-zoom-viewport-controls"
677
+ className="absolute bottom-3 right-3 rounded-xl bg-card shadow-sm"
678
+ >
679
+ <Button
680
+ type="button"
681
+ variant="outline"
682
+ size="icon-sm"
683
+ aria-label={labels.zoomIn}
684
+ disabled={atMax}
685
+ onClick={zoomIn}
686
+ >
687
+ <Plus aria-hidden />
688
+ </Button>
689
+ <Button
690
+ type="button"
691
+ variant="outline"
692
+ size="icon-sm"
693
+ aria-label={labels.zoomOut}
694
+ disabled={atMin}
695
+ onClick={zoomOut}
696
+ >
697
+ <Minus aria-hidden />
698
+ </Button>
699
+ <Button
700
+ type="button"
701
+ variant="outline"
702
+ size="icon-sm"
703
+ className="text-xs font-semibold"
704
+ onClick={fit}
705
+ >
706
+ {labels.fit}
707
+ </Button>
708
+ </ButtonGroup>
709
+ ) : null}
710
+
711
+ {labels.instructions ? (
712
+ <span id={instructionsId} className="sr-only">
713
+ {labels.instructions}
714
+ </span>
715
+ ) : null}
716
+ {labels.zoomLevel ? (
717
+ <span className="sr-only" aria-live="polite">
718
+ {announcement}
719
+ </span>
720
+ ) : null}
721
+ </div>
722
+ );
723
+ }
724
+ );
725
+ PanZoomViewport.displayName = "PanZoomViewport";
726
+
727
+ export { PanZoomViewport };