@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.
- package/dist/chunk-EGU6EG73.js +39 -0
- package/dist/{chunk-NIAL4ZU2.js → chunk-FQAIYKAS.js} +45 -8
- package/dist/{chunk-TVEIUTBQ.js → chunk-IQIJCMG2.js} +1 -1
- package/dist/{chunk-6W25S5CF.js → chunk-KGXTBTCJ.js} +1 -1
- package/dist/chunk-LAEVMNJN.js +135 -0
- package/dist/{chunk-VCRXL7H5.js → chunk-PGQXVJXW.js} +2 -2
- package/dist/{chunk-F3XK34DV.js → chunk-TB2K5JN3.js} +2 -2
- package/dist/{chunk-XNBDACL7.js → chunk-ZUBBMLBD.js} +1 -1
- package/dist/components/badge.d.ts +8 -2
- package/dist/components/badge.js +1 -1
- package/dist/components/banner.d.ts +17 -2
- package/dist/components/banner.js +10 -7
- package/dist/components/booking-row.js +1 -1
- package/dist/components/button-group.js +3 -39
- package/dist/components/calendar.js +1 -1
- package/dist/components/combobox.js +3 -3
- package/dist/components/command.js +2 -2
- package/dist/components/concept-convergence.js +1 -1
- package/dist/components/dialog.js +1 -1
- package/dist/components/dropdown-menu.js +6 -6
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/input-group.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/multi-select-combobox.js +10 -5
- package/dist/components/multi-select-menu.js +1 -1
- package/dist/components/pan-zoom-viewport.d.ts +137 -0
- package/dist/components/pan-zoom-viewport.js +531 -0
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/responsive-overlay.d.ts +29 -1
- package/dist/components/responsive-overlay.js +46 -7
- package/dist/components/searchable-select.js +3 -3
- package/dist/components/select.js +2 -2
- package/dist/components/selection-bar.js +1 -1
- package/dist/components/sheet.d.ts +6 -0
- package/dist/components/sheet.js +3 -133
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/tabs.d.ts +16 -1
- package/dist/components/tabs.js +21 -3
- package/dist/components/toggle-group.js +1 -1
- package/dist/components/toggle.d.ts +6 -0
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +32 -1
- package/src/components/badge.test.tsx +45 -0
- package/src/components/badge.tsx +41 -3
- package/src/components/banner.stories.tsx +32 -0
- package/src/components/banner.test.tsx +58 -0
- package/src/components/banner.tsx +28 -4
- package/src/components/calendar.tsx +1 -1
- package/src/components/combobox.tsx +1 -1
- package/src/components/command.test.tsx +10 -0
- package/src/components/command.tsx +1 -1
- package/src/components/dialog.tsx +1 -1
- package/src/components/dropdown-menu.test.tsx +31 -0
- package/src/components/dropdown-menu.tsx +6 -6
- package/src/components/input-group.tsx +1 -1
- package/src/components/multi-select-combobox.tsx +7 -1
- package/src/components/multi-select-menu.tsx +1 -1
- package/src/components/pan-zoom-viewport.stories.tsx +134 -0
- package/src/components/pan-zoom-viewport.test.tsx +381 -0
- package/src/components/pan-zoom-viewport.tsx +727 -0
- package/src/components/responsive-overlay.stories.tsx +61 -0
- package/src/components/responsive-overlay.test.tsx +104 -0
- package/src/components/responsive-overlay.tsx +87 -22
- package/src/components/searchable-select.tsx +1 -1
- package/src/components/select.tsx +2 -2
- package/src/components/selection-bar.tsx +1 -1
- package/src/components/sheet.tsx +14 -3
- package/src/components/tabs.stories.tsx +23 -0
- package/src/components/tabs.test.tsx +64 -0
- package/src/components/tabs.tsx +36 -3
- package/src/components/toggle.test.tsx +19 -0
- package/src/components/toggle.tsx +8 -2
- 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 };
|