@bearmenu/ui 0.8.37 → 0.8.38

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.
@@ -4,7 +4,7 @@ import * as React from 'react';
4
4
  import { VariantProps } from 'class-variance-authority';
5
5
 
6
6
  declare const badgeVariants: (props?: ({
7
- variant?: "secondary" | "default" | "success" | "warning" | "destructive" | "outline" | "glass" | "overlay" | "brown" | "code" | "onPrimary" | "confirmed" | "pending" | "completed" | "assignee" | "softSuccess" | "softWarning" | "softDestructive" | "softNeutral" | null | undefined;
7
+ variant?: "default" | "secondary" | "success" | "warning" | "destructive" | "outline" | "glass" | "overlay" | "brown" | "code" | "onPrimary" | "confirmed" | "pending" | "completed" | "assignee" | "softSuccess" | "softWarning" | "softDestructive" | "softNeutral" | null | undefined;
8
8
  size?: "status" | "chip" | null | undefined;
9
9
  live?: boolean | null | undefined;
10
10
  } & class_variance_authority_types.ClassProp) | undefined) => string;
@@ -3,8 +3,8 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const buttonVariants: (props?: ({
6
- variant?: "secondary" | "default" | "link" | "warning" | "destructive" | "outline" | "ghost" | "glass" | "overlay" | "ink" | "brown" | "cta" | null | undefined;
7
- size?: "default" | "icon" | "cta" | "xs" | "sm" | "lg" | "pill" | "cta-sm" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
6
+ variant?: "default" | "link" | "secondary" | "warning" | "destructive" | "outline" | "ghost" | "glass" | "overlay" | "ink" | "brown" | "cta" | null | undefined;
7
+ size?: "icon" | "default" | "cta" | "xs" | "sm" | "lg" | "pill" | "cta-sm" | "icon-xs" | "icon-sm" | "icon-lg" | null | undefined;
8
8
  fullWidth?: boolean | null | undefined;
9
9
  } & class_variance_authority_types.ClassProp) | undefined) => string;
10
10
  interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
@@ -28,7 +28,7 @@ declare const CommandInput: React.ForwardRefExoticComponent<Omit<Omit<Pick<Pick<
28
28
  ref?: React.Ref<HTMLInputElement>;
29
29
  } & {
30
30
  asChild?: boolean;
31
- }, "asChild" | "key" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "onChange" | "value" | "type"> & {
31
+ }, "asChild" | "key" | keyof React.InputHTMLAttributes<HTMLInputElement>>, "value" | "onChange" | "type"> & {
32
32
  value?: string;
33
33
  onValueChange?: (search: string) => void;
34
34
  } & React.RefAttributes<HTMLInputElement>, "ref"> & React.RefAttributes<HTMLInputElement>>;
@@ -64,7 +64,7 @@ declare const CommandGroup: React.ForwardRefExoticComponent<Omit<{
64
64
  ref?: React.Ref<HTMLDivElement>;
65
65
  } & {
66
66
  asChild?: boolean;
67
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "heading" | "value"> & {
67
+ }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "value" | "heading"> & {
68
68
  heading?: React.ReactNode;
69
69
  value?: string;
70
70
  forceMount?: boolean;
@@ -82,7 +82,7 @@ declare const CommandItem: React.ForwardRefExoticComponent<Omit<{
82
82
  ref?: React.Ref<HTMLDivElement>;
83
83
  } & {
84
84
  asChild?: boolean;
85
- }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "onSelect" | "value" | "disabled"> & {
85
+ }, keyof React.HTMLAttributes<HTMLDivElement> | "asChild" | "key">, "value" | "onSelect" | "disabled"> & {
86
86
  disabled?: boolean;
87
87
  onSelect?: (value: string) => void;
88
88
  value?: string;
@@ -23,7 +23,9 @@ import * as React from 'react';
23
23
  * - Keyboard, with the viewport focused: arrows pan (Shift for four times the
24
24
  * step); `+` / `-` zoom and `0` fits, from anywhere inside it. Tabbing onto
25
25
  * a table that sits off screen pans it into view.
26
- * - The + / − / Fit controls, bottom right.
26
+ * - The + / − / Fit controls: a raised card 12px in from the bottom-right
27
+ * corner, 44 × 44 per button. Fit and the pan bounds keep the content clear
28
+ * of them.
27
29
  *
28
30
  * The min-scale floor: `Fit` never shrinks below `minScale`. On a phone the
29
31
  * plan would otherwise fit at a size no finger can hit, so below the floor it
@@ -96,8 +98,18 @@ type PanZoomViewportProps = Omit<React.HTMLAttributes<HTMLDivElement>, "children
96
98
  zoomStep?: number;
97
99
  /** One arrow press, in viewport px. Default 48. */
98
100
  panStep?: number;
99
- /** The air Fit leaves around the content, and how far past it a pan may go. Default 24. */
101
+ /**
102
+ * The air Fit leaves around the content, and how far past it a pan may go.
103
+ * Default 24. With `controls` on, the side the control stack would cover
104
+ * (right or bottom, whichever costs the fit less) is widened to clear it.
105
+ */
100
106
  fitPadding?: number;
107
+ /**
108
+ * Where fitted content sits vertically when it is shorter than the
109
+ * viewport: `center` (default), or `top` — pinned under the top padding,
110
+ * as a phone shows a plan.
111
+ */
112
+ fitAlign?: "center" | "top";
101
113
  /** Render the + / − / Fit controls. Default true. */
102
114
  controls?: boolean;
103
115
  /** Called with every transform — gestures included, so keep it cheap. */
@@ -122,8 +134,18 @@ declare const PanZoomViewport: React.ForwardRefExoticComponent<Omit<React.HTMLAt
122
134
  zoomStep?: number;
123
135
  /** One arrow press, in viewport px. Default 48. */
124
136
  panStep?: number;
125
- /** The air Fit leaves around the content, and how far past it a pan may go. Default 24. */
137
+ /**
138
+ * The air Fit leaves around the content, and how far past it a pan may go.
139
+ * Default 24. With `controls` on, the side the control stack would cover
140
+ * (right or bottom, whichever costs the fit less) is widened to clear it.
141
+ */
126
142
  fitPadding?: number;
143
+ /**
144
+ * Where fitted content sits vertically when it is shorter than the
145
+ * viewport: `center` (default), or `top` — pinned under the top padding,
146
+ * as a phone shows a plan.
147
+ */
148
+ fitAlign?: "center" | "top";
127
149
  /** Render the + / − / Fit controls. Default true. */
128
150
  controls?: boolean;
129
151
  /** Called with every transform — gestures included, so keep it cheap. */
@@ -12,12 +12,21 @@ var DRAG_THRESHOLD = 4;
12
12
  var MAX_WHEEL_ZOOM_DELTA = 25;
13
13
  var WHEEL_ZOOM_SPEED = 0.01;
14
14
  var EPSILON = 1e-3;
15
+ var CONTROLS_RESERVE_X = 12 + 44 + 8;
16
+ var CONTROLS_RESERVE_Y = 12 + 130 + 8;
15
17
  function clamp(value, min, max) {
16
18
  return Math.min(max, Math.max(min, value));
17
19
  }
18
- function clampAxis(offset, content, viewport, pad) {
19
- if (content + pad * 2 <= viewport) return (viewport - content) / 2;
20
- return clamp(offset, viewport - content - pad, pad);
20
+ function clampAxis(offset, content, viewport, start, end, align) {
21
+ const room = viewport - start - end;
22
+ if (content <= room) return align === "start" ? start : start + (room - content) / 2;
23
+ return clamp(offset, viewport - content - end, start);
24
+ }
25
+ function fitScale(viewport, insets, contentWidth, contentHeight) {
26
+ return Math.min(
27
+ (viewport.width - insets.left - insets.right) / contentWidth,
28
+ (viewport.height - insets.top - insets.bottom) / contentHeight
29
+ );
21
30
  }
22
31
  function distance(a, b) {
23
32
  return Math.hypot(a.x - b.x, a.y - b.y);
@@ -40,6 +49,7 @@ var PanZoomViewport = React.forwardRef(
40
49
  zoomStep = 1.25,
41
50
  panStep = 48,
42
51
  fitPadding = 24,
52
+ fitAlign = "center",
43
53
  controls = true,
44
54
  onTransformChange,
45
55
  apiRef,
@@ -55,6 +65,7 @@ var PanZoomViewport = React.forwardRef(
55
65
  "zoomStep",
56
66
  "panStep",
57
67
  "fitPadding",
68
+ "fitAlign",
58
69
  "controls",
59
70
  "onTransformChange",
60
71
  "apiRef",
@@ -79,26 +90,37 @@ var PanZoomViewport = React.forwardRef(
79
90
  onTransformChangeRef.current = onTransformChange;
80
91
  zoomLevelRef.current = labels.zoomLevel;
81
92
  });
93
+ const insets = React.useMemo(() => {
94
+ const base = { top: fitPadding, right: fitPadding, bottom: fitPadding, left: fitPadding };
95
+ if (!controls || !viewport) return base;
96
+ const right = __spreadProps(__spreadValues({}, base), { right: Math.max(fitPadding, CONTROLS_RESERVE_X) });
97
+ const bottom = __spreadProps(__spreadValues({}, base), { bottom: Math.max(fitPadding, CONTROLS_RESERVE_Y) });
98
+ return fitScale(viewport, bottom, contentWidth, contentHeight) > fitScale(viewport, right, contentWidth, contentHeight) ? bottom : right;
99
+ }, [controls, viewport, fitPadding, contentWidth, contentHeight]);
82
100
  const clampTransform = React.useCallback(
83
101
  (next) => {
84
102
  if (!viewport || viewport.width <= 0 || viewport.height <= 0) return next;
85
103
  const scale = clamp(next.scale, minScale, maxScale);
86
104
  return {
87
105
  scale,
88
- x: clampAxis(next.x, contentWidth * scale, viewport.width, fitPadding),
89
- y: clampAxis(next.y, contentHeight * scale, viewport.height, fitPadding)
106
+ x: clampAxis(next.x, contentWidth * scale, viewport.width, insets.left, insets.right, "center"),
107
+ y: clampAxis(
108
+ next.y,
109
+ contentHeight * scale,
110
+ viewport.height,
111
+ insets.top,
112
+ insets.bottom,
113
+ fitAlign === "top" ? "start" : "center"
114
+ )
90
115
  };
91
116
  },
92
- [viewport, minScale, maxScale, contentWidth, contentHeight, fitPadding]
117
+ [viewport, minScale, maxScale, contentWidth, contentHeight, insets, fitAlign]
93
118
  );
94
119
  const fitted = React.useCallback(() => {
95
120
  if (!viewport || viewport.width <= 0 || viewport.height <= 0) return null;
96
- const scale = Math.min(
97
- (viewport.width - fitPadding * 2) / contentWidth,
98
- (viewport.height - fitPadding * 2) / contentHeight
99
- );
100
- return clampTransform({ scale, x: fitPadding, y: fitPadding });
101
- }, [viewport, fitPadding, contentWidth, contentHeight, clampTransform]);
121
+ const scale = fitScale(viewport, insets, contentWidth, contentHeight);
122
+ return clampTransform({ scale, x: insets.left, y: insets.top });
123
+ }, [viewport, insets, contentWidth, contentHeight, clampTransform]);
102
124
  const commit = React.useCallback(
103
125
  (next, options) => {
104
126
  var _a2;
@@ -382,13 +404,13 @@ var PanZoomViewport = React.forwardRef(
382
404
  if (pressing.current || event.target === event.currentTarget) return;
383
405
  const bounds = event.currentTarget.getBoundingClientRect();
384
406
  const rect = event.target.getBoundingClientRect();
385
- const margin = fitPadding;
386
407
  let dx = 0;
387
408
  let dy = 0;
388
- if (rect.left < bounds.left + margin) dx = bounds.left + margin - rect.left;
389
- else if (rect.right > bounds.right - margin) dx = bounds.right - margin - rect.right;
390
- if (rect.top < bounds.top + margin) dy = bounds.top + margin - rect.top;
391
- else if (rect.bottom > bounds.bottom - margin) dy = bounds.bottom - margin - rect.bottom;
409
+ if (rect.left < bounds.left + insets.left) dx = bounds.left + insets.left - rect.left;
410
+ else if (rect.right > bounds.right - insets.right) dx = bounds.right - insets.right - rect.right;
411
+ if (rect.top < bounds.top + insets.top) dy = bounds.top + insets.top - rect.top;
412
+ else if (rect.bottom > bounds.bottom - insets.bottom)
413
+ dy = bounds.bottom - insets.bottom - rect.bottom;
392
414
  if (dx !== 0 || dy !== 0) panBy(dx, dy);
393
415
  };
394
416
  const scaledWidth = contentWidth * transform.scale;
@@ -479,14 +501,14 @@ var PanZoomViewport = React.forwardRef(
479
501
  orientation: "vertical",
480
502
  "aria-label": labels.controls,
481
503
  "data-slot": "pan-zoom-viewport-controls",
482
- className: "absolute bottom-3 right-3 rounded-xl bg-card shadow-sm",
504
+ className: "absolute bottom-3 right-3 rounded-xl bg-card shadow-[var(--shadow-card)]",
483
505
  children: [
484
506
  /* @__PURE__ */ jsx(
485
507
  Button,
486
508
  {
487
509
  type: "button",
488
510
  variant: "outline",
489
- size: "icon-sm",
511
+ size: "icon",
490
512
  "aria-label": labels.zoomIn,
491
513
  disabled: atMax,
492
514
  onClick: zoomIn,
@@ -498,7 +520,7 @@ var PanZoomViewport = React.forwardRef(
498
520
  {
499
521
  type: "button",
500
522
  variant: "outline",
501
- size: "icon-sm",
523
+ size: "icon",
502
524
  "aria-label": labels.zoomOut,
503
525
  disabled: atMin,
504
526
  onClick: zoomOut,
@@ -510,7 +532,7 @@ var PanZoomViewport = React.forwardRef(
510
532
  {
511
533
  type: "button",
512
534
  variant: "outline",
513
- size: "icon-sm",
535
+ size: "icon",
514
536
  className: "text-xs font-semibold",
515
537
  onClick: fit,
516
538
  children: labels.fit
@@ -4,11 +4,11 @@ import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const spinnerVariants: (props?: ({
6
6
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
7
- tone?: "primary" | "default" | "inherit" | "destructive" | null | undefined;
7
+ tone?: "default" | "inherit" | "primary" | "destructive" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Spinner: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
10
  size?: "default" | "xs" | "sm" | "lg" | "xl" | null | undefined;
11
- tone?: "primary" | "default" | "inherit" | "destructive" | null | undefined;
11
+ tone?: "default" | "inherit" | "primary" | "destructive" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  /** Visually-hidden label for screen readers. Defaults to "Loading". */
14
14
  label?: string;
@@ -3,11 +3,11 @@ import * as React from 'react';
3
3
  import { VariantProps } from 'class-variance-authority';
4
4
 
5
5
  declare const tagVariants: (props?: ({
6
- variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
6
+ variant?: "default" | "primary" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
7
7
  size?: "default" | "sm" | "lg" | null | undefined;
8
8
  } & class_variance_authority_types.ClassProp) | undefined) => string;
9
9
  declare const Tag: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ({
10
- variant?: "primary" | "default" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
10
+ variant?: "default" | "primary" | "success" | "warning" | "destructive" | "outline" | "quiet" | null | undefined;
11
11
  size?: "default" | "sm" | "lg" | null | undefined;
12
12
  } & class_variance_authority_types.ClassProp) | undefined) => string> & {
13
13
  onRemove?: () => void;
@@ -22,8 +22,8 @@ import { VariantProps } from 'class-variance-authority';
22
22
  * Weights are 400 / 600 / 700. 500 is not in the system.
23
23
  */
24
24
  declare const textVariants: (props?: ({
25
- variant?: "small" | "body" | "display" | "h2" | "h3" | "label" | "caption" | "h1" | "h4" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
- color?: "primary" | "default" | "inherit" | "destructive" | "muted" | "primary-foreground" | null | undefined;
25
+ variant?: "body" | "small" | "display" | "h2" | "h3" | "label" | "caption" | "h1" | "h4" | "body-lg" | "body-sm" | "overline" | null | undefined;
26
+ color?: "default" | "inherit" | "primary" | "destructive" | "muted" | "primary-foreground" | null | undefined;
27
27
  weight?: "bold" | "medium" | "normal" | "semibold" | null | undefined;
28
28
  leading?: "none" | "normal" | "tight" | "snug" | "relaxed" | "loose" | null | undefined;
29
29
  tracking?: "normal" | "tight" | "wide" | "wider" | null | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.37",
3
+ "version": "0.8.38",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -60,7 +60,7 @@ const meta: Meta<typeof PanZoomViewport> = {
60
60
  docs: {
61
61
  description: {
62
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`.",
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 (44px each, on a raised card inset 12px; Fit keeps the plan clear of them). `fitAlign=\"top\"` pins a short plan to the top. 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
64
  },
65
65
  },
66
66
  },
@@ -104,6 +104,26 @@ export const PhoneAtTheFloor: Story = {
104
104
  ),
105
105
  };
106
106
 
107
+ /**
108
+ * floor/07-f0 phone, pinned to the top: `fitAlign="top"` holds a plan shorter
109
+ * than the viewport under the top padding instead of centring it. The controls
110
+ * float 12px in on a raised card, 44px per button, and the fit keeps clear of them.
111
+ */
112
+ export const PhonePinnedTop: Story = {
113
+ render: () => (
114
+ <PanZoomViewport
115
+ contentWidth={1200}
116
+ contentHeight={800}
117
+ minScale={0.25}
118
+ fitAlign="top"
119
+ labels={labels}
120
+ className="h-[640px] w-[390px] border-y border-border bg-background"
121
+ >
122
+ <DemoPlan />
123
+ </PanZoomViewport>
124
+ ),
125
+ };
126
+
107
127
  /** Driving it from outside: a "Show T12" link, a reset. */
108
128
  export const Imperative: Story = {
109
129
  render: () => {
@@ -85,14 +85,34 @@ function renderOnAPhone(props: Partial<PanZoomViewportProps> = {}) {
85
85
  describe("PanZoomViewport", () => {
86
86
  describe("fit", () => {
87
87
  it("fits the content inside its padding and centres it", () => {
88
- renderViewport();
88
+ renderViewport({ controls: false });
89
89
  // min((800 - 48) / 400, (600 - 48) / 300) = 1.84 → 736 × 552.
90
90
  expect(transformOf()).toEqual({ x: 32, y: 24, scale: 1.84 });
91
91
  expect(content()).not.toHaveClass("invisible");
92
92
  });
93
93
 
94
+ it("keeps the fitted content clear of the controls, on the side that costs least", () => {
95
+ renderViewport();
96
+ // Right side widened to 64: min((800 - 88) / 400, (600 - 48) / 300) = 1.78
97
+ // → 712 × 534. Widening the bottom to 150 would cost more: 426 / 300 = 1.42.
98
+ // Its right edge lands at 736, 64 in: past the 12px inset and the 44px stack.
99
+ expect(transformOf()).toEqual({ x: 24, y: 33, scale: 1.78 });
100
+ });
101
+
102
+ it("widens the bottom instead when that leaves the bigger fit", () => {
103
+ viewportSize = { width: 400, height: 800 };
104
+ renderViewport({ contentWidth: 400, contentHeight: 300 });
105
+ // Bottom: min(352 / 400, 626 / 300) = 0.88. Right: 312 / 400 = 0.78.
106
+ expect(transformOf()).toEqual({ x: 24, y: 24 + (626 - 264) / 2, scale: 0.88 });
107
+ });
108
+
109
+ it("pins fitted content to the top with fitAlign top", () => {
110
+ renderViewport({ fitAlign: "top" });
111
+ expect(transformOf()).toEqual({ x: 24, y: 24, scale: 1.78 });
112
+ });
113
+
94
114
  it("stops at the floor, anchors top-left and shades the edge that runs on", () => {
95
- renderOnAPhone();
115
+ renderOnAPhone({ controls: false });
96
116
  // The fit would be 0.285; the floor holds it at 0.5 → 600 × 400.
97
117
  // Too wide: anchored at the padding. Short enough: centred.
98
118
  expect(transformOf()).toEqual({ x: 24, y: 50, scale: 0.5 });
@@ -102,6 +122,17 @@ describe("PanZoomViewport", () => {
102
122
  expect(edgeVisible("bottom")).toBe(false);
103
123
  });
104
124
 
125
+ it("lets a plan under the controls pan clear of them", () => {
126
+ renderOnAPhone();
127
+ // Bottom widened to 150 (the smaller loss here): 400 tall no longer fits
128
+ // the 326 left, so it anchors at the top and may pan up by 74.
129
+ expect(transformOf()).toEqual({ x: 24, y: 24, scale: 0.5 });
130
+ expect(edgeVisible("bottom")).toBe(false);
131
+ surface().focus();
132
+ fireEvent.keyDown(surface(), { key: "ArrowDown", shiftKey: true });
133
+ expect(transformOf().y).toBe(500 - 400 - 150);
134
+ });
135
+
105
136
  it("never fits above the ceiling", () => {
106
137
  renderViewport({ contentWidth: 100, contentHeight: 100, maxScale: 2 });
107
138
  expect(transformOf().scale).toBe(2);
@@ -112,10 +143,22 @@ describe("PanZoomViewport", () => {
112
143
  it("zooms in and out by one step about the centre", async () => {
113
144
  renderViewport();
114
145
  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 });
146
+ // 1.78 / 1.25 = 1.424 → 569.6 × 427.2, centred between the paddings.
147
+ expect(transformOf()).toEqual({ x: 95.2, y: 86.4, scale: 1.424 });
117
148
  await userEvent.click(screen.getByRole("button", { name: "Zoom in" }));
118
- expect(transformOf().scale).toBe(1.84);
149
+ expect(transformOf().scale).toBe(1.78);
150
+ });
151
+
152
+ it("gives every control a 44px hit area on a raised card", () => {
153
+ renderViewport();
154
+ for (const name of ["Zoom in", "Zoom out", "Fit"]) {
155
+ expect(screen.getByRole("button", { name })).toHaveClass("h-11", "w-11");
156
+ }
157
+ expect(screen.getByRole("group", { name: "Zoom" })).toHaveClass(
158
+ "bottom-3",
159
+ "right-3",
160
+ "shadow-[var(--shadow-card)]"
161
+ );
119
162
  });
120
163
 
121
164
  it("disables zoom out at the floor and zoom in at the ceiling", async () => {
@@ -175,7 +218,7 @@ describe("PanZoomViewport", () => {
175
218
  });
176
219
 
177
220
  it("zooms with + and -, and fits with 0", async () => {
178
- renderViewport();
221
+ renderViewport({ controls: false });
179
222
  surface().focus();
180
223
  await userEvent.keyboard("+");
181
224
  expect(transformOf().scale).toBe(2.3);
@@ -195,7 +238,7 @@ describe("PanZoomViewport", () => {
195
238
  });
196
239
 
197
240
  it("announces the zoom level through the injected formatter", async () => {
198
- renderViewport();
241
+ renderViewport({ controls: false });
199
242
  surface().focus();
200
243
  await userEvent.keyboard("+");
201
244
  expect(screen.getByText("230%")).toBeInTheDocument();
@@ -281,7 +324,7 @@ describe("PanZoomViewport", () => {
281
324
  });
282
325
 
283
326
  it("zooms with a two-finger pinch about the fingers", () => {
284
- renderViewport();
327
+ renderViewport({ controls: false });
285
328
  const touch = (id: number, x: number, y: number) => ({
286
329
  pointerId: id,
287
330
  pointerType: "touch",
@@ -316,7 +359,7 @@ describe("PanZoomViewport", () => {
316
359
  });
317
360
 
318
361
  it("caps one ctrl-wheel notch so a mouse wheel does not jump", () => {
319
- renderViewport({ maxScale: 10 });
362
+ renderViewport({ maxScale: 10, controls: false });
320
363
  wheel({ deltaY: -100, ctrlKey: true, clientX: 400, clientY: 300 });
321
364
  expect(transformOf().scale).toBeCloseTo(1.84 * Math.exp(0.25), 2);
322
365
  });
@@ -339,7 +382,7 @@ describe("PanZoomViewport", () => {
339
382
  it("drives the view from outside and reports every transform", () => {
340
383
  const api = React.createRef<PanZoomViewportHandle>();
341
384
  const onTransformChange = vi.fn();
342
- renderOnAPhone({ apiRef: api, onTransformChange });
385
+ renderOnAPhone({ apiRef: api, onTransformChange, controls: false });
343
386
  expect(onTransformChange).toHaveBeenLastCalledWith({ scale: 0.5, x: 24, y: 50 });
344
387
 
345
388
  act(() => api.current?.centerOn(1000, 400));
@@ -355,9 +398,9 @@ describe("PanZoomViewport", () => {
355
398
  });
356
399
 
357
400
  it("refits when the plan changes size", () => {
358
- const view = renderViewport();
401
+ const view = renderViewport({ controls: false });
359
402
  view.rerender(
360
- <PanZoomViewport contentWidth={800} contentHeight={600} labels={labels}>
403
+ <PanZoomViewport contentWidth={800} contentHeight={600} labels={labels} controls={false}>
361
404
  <button type="button">T1</button>
362
405
  </PanZoomViewport>
363
406
  );
@@ -366,11 +409,17 @@ describe("PanZoomViewport", () => {
366
409
  });
367
410
 
368
411
  it("keeps a panned view, pulled inside the bounds, when the plan changes", async () => {
369
- const view = renderOnAPhone();
412
+ const view = renderOnAPhone({ controls: false });
370
413
  surface().focus();
371
414
  await userEvent.keyboard("{Shift>}{ArrowRight}{/Shift}");
372
415
  view.rerender(
373
- <PanZoomViewport contentWidth={700} contentHeight={800} minScale={0.5} labels={labels}>
416
+ <PanZoomViewport
417
+ contentWidth={700}
418
+ contentHeight={800}
419
+ minScale={0.5}
420
+ labels={labels}
421
+ controls={false}
422
+ >
374
423
  <button type="button">T1</button>
375
424
  </PanZoomViewport>
376
425
  );
@@ -29,7 +29,9 @@ import { ButtonGroup } from "./button-group";
29
29
  * - Keyboard, with the viewport focused: arrows pan (Shift for four times the
30
30
  * step); `+` / `-` zoom and `0` fits, from anywhere inside it. Tabbing onto
31
31
  * a table that sits off screen pans it into view.
32
- * - The + / − / Fit controls, bottom right.
32
+ * - The + / − / Fit controls: a raised card 12px in from the bottom-right
33
+ * corner, 44 × 44 per button. Fit and the pan bounds keep the content clear
34
+ * of them.
33
35
  *
34
36
  * The min-scale floor: `Fit` never shrinks below `minScale`. On a phone the
35
37
  * plan would otherwise fit at a size no finger can hit, so below the floor it
@@ -106,8 +108,18 @@ export type PanZoomViewportProps = Omit<React.HTMLAttributes<HTMLDivElement>, "c
106
108
  zoomStep?: number;
107
109
  /** One arrow press, in viewport px. Default 48. */
108
110
  panStep?: number;
109
- /** The air Fit leaves around the content, and how far past it a pan may go. Default 24. */
111
+ /**
112
+ * The air Fit leaves around the content, and how far past it a pan may go.
113
+ * Default 24. With `controls` on, the side the control stack would cover
114
+ * (right or bottom, whichever costs the fit less) is widened to clear it.
115
+ */
110
116
  fitPadding?: number;
117
+ /**
118
+ * Where fitted content sits vertically when it is shorter than the
119
+ * viewport: `center` (default), or `top` — pinned under the top padding,
120
+ * as a phone shows a plan.
121
+ */
122
+ fitAlign?: "center" | "top";
111
123
  /** Render the + / − / Fit controls. Default true. */
112
124
  controls?: boolean;
113
125
  /** Called with every transform — gestures included, so keep it cheap. */
@@ -120,6 +132,7 @@ export type PanZoomViewportProps = Omit<React.HTMLAttributes<HTMLDivElement>, "c
120
132
 
121
133
  type Size = { width: number; height: number };
122
134
  type Point = { x: number; y: number };
135
+ type Insets = { top: number; right: number; bottom: number; left: number };
123
136
 
124
137
  type Gesture =
125
138
  | { kind: "idle" }
@@ -136,19 +149,40 @@ const DRAG_THRESHOLD = 4;
136
149
  const MAX_WHEEL_ZOOM_DELTA = 25;
137
150
  const WHEEL_ZOOM_SPEED = 0.01;
138
151
  const EPSILON = 0.001;
152
+ /**
153
+ * The control stack's footprint plus air: `bottom-3 right-3` (12px), three
154
+ * 44px buttons sharing borders (44 × 130), and 8px before the content.
155
+ */
156
+ const CONTROLS_RESERVE_X = 12 + 44 + 8;
157
+ const CONTROLS_RESERVE_Y = 12 + 130 + 8;
139
158
 
140
159
  function clamp(value: number, min: number, max: number) {
141
160
  return Math.min(max, Math.max(min, value));
142
161
  }
143
162
 
144
163
  /**
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.
164
+ * One axis. Content that fits between its paddings is centred between them
165
+ * (or held at the start one) and cannot move; content that does not may pan
166
+ * until its far edge is `end` inside the viewport's, and no further.
148
167
  */
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);
168
+ function clampAxis(
169
+ offset: number,
170
+ content: number,
171
+ viewport: number,
172
+ start: number,
173
+ end: number,
174
+ align: "center" | "start"
175
+ ) {
176
+ const room = viewport - start - end;
177
+ if (content <= room) return align === "start" ? start : start + (room - content) / 2;
178
+ return clamp(offset, viewport - content - end, start);
179
+ }
180
+
181
+ function fitScale(viewport: Size, insets: Insets, contentWidth: number, contentHeight: number) {
182
+ return Math.min(
183
+ (viewport.width - insets.left - insets.right) / contentWidth,
184
+ (viewport.height - insets.top - insets.bottom) / contentHeight
185
+ );
152
186
  }
153
187
 
154
188
  function distance(a: Point, b: Point) {
@@ -178,6 +212,7 @@ const PanZoomViewport = React.forwardRef<HTMLDivElement, PanZoomViewportProps>(
178
212
  zoomStep = 1.25,
179
213
  panStep = 48,
180
214
  fitPadding = 24,
215
+ fitAlign = "center",
181
216
  controls = true,
182
217
  onTransformChange,
183
218
  apiRef,
@@ -211,29 +246,47 @@ const PanZoomViewport = React.forwardRef<HTMLDivElement, PanZoomViewportProps>(
211
246
  zoomLevelRef.current = labels.zoomLevel;
212
247
  });
213
248
 
249
+ // The padding on each side. The control stack covers the bottom-right
250
+ // corner: keep the content clear of it by widening the right side or the
251
+ // bottom one — whichever leaves the bigger fit.
252
+ const insets = React.useMemo((): Insets => {
253
+ const base = { top: fitPadding, right: fitPadding, bottom: fitPadding, left: fitPadding };
254
+ if (!controls || !viewport) return base;
255
+ const right = { ...base, right: Math.max(fitPadding, CONTROLS_RESERVE_X) };
256
+ const bottom = { ...base, bottom: Math.max(fitPadding, CONTROLS_RESERVE_Y) };
257
+ return fitScale(viewport, bottom, contentWidth, contentHeight) >
258
+ fitScale(viewport, right, contentWidth, contentHeight)
259
+ ? bottom
260
+ : right;
261
+ }, [controls, viewport, fitPadding, contentWidth, contentHeight]);
262
+
214
263
  const clampTransform = React.useCallback(
215
264
  (next: PanZoomTransform): PanZoomTransform => {
216
265
  if (!viewport || viewport.width <= 0 || viewport.height <= 0) return next;
217
266
  const scale = clamp(next.scale, minScale, maxScale);
218
267
  return {
219
268
  scale,
220
- x: clampAxis(next.x, contentWidth * scale, viewport.width, fitPadding),
221
- y: clampAxis(next.y, contentHeight * scale, viewport.height, fitPadding),
269
+ x: clampAxis(next.x, contentWidth * scale, viewport.width, insets.left, insets.right, "center"),
270
+ y: clampAxis(
271
+ next.y,
272
+ contentHeight * scale,
273
+ viewport.height,
274
+ insets.top,
275
+ insets.bottom,
276
+ fitAlign === "top" ? "start" : "center"
277
+ ),
222
278
  };
223
279
  },
224
- [viewport, minScale, maxScale, contentWidth, contentHeight, fitPadding]
280
+ [viewport, minScale, maxScale, contentWidth, contentHeight, insets, fitAlign]
225
281
  );
226
282
 
227
283
  const fitted = React.useCallback((): PanZoomTransform | null => {
228
284
  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
- );
285
+ const scale = fitScale(viewport, insets, contentWidth, contentHeight);
233
286
  // 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]);
287
+ // stays anchored there; content that fits is placed by the clamp.
288
+ return clampTransform({ scale, x: insets.left, y: insets.top });
289
+ }, [viewport, insets, contentWidth, contentHeight, clampTransform]);
237
290
 
238
291
  const commit = React.useCallback(
239
292
  (
@@ -574,13 +627,13 @@ const PanZoomViewport = React.forwardRef<HTMLDivElement, PanZoomViewportProps>(
574
627
  if (pressing.current || event.target === event.currentTarget) return;
575
628
  const bounds = event.currentTarget.getBoundingClientRect();
576
629
  const rect = event.target.getBoundingClientRect();
577
- const margin = fitPadding;
578
630
  let dx = 0;
579
631
  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;
632
+ if (rect.left < bounds.left + insets.left) dx = bounds.left + insets.left - rect.left;
633
+ else if (rect.right > bounds.right - insets.right) dx = bounds.right - insets.right - rect.right;
634
+ if (rect.top < bounds.top + insets.top) dy = bounds.top + insets.top - rect.top;
635
+ else if (rect.bottom > bounds.bottom - insets.bottom)
636
+ dy = bounds.bottom - insets.bottom - rect.bottom;
584
637
  if (dx !== 0 || dy !== 0) panBy(dx, dy);
585
638
  };
586
639
 
@@ -674,12 +727,14 @@ const PanZoomViewport = React.forwardRef<HTMLDivElement, PanZoomViewportProps>(
674
727
  orientation="vertical"
675
728
  aria-label={labels.controls}
676
729
  data-slot="pan-zoom-viewport-controls"
677
- className="absolute bottom-3 right-3 rounded-xl bg-card shadow-sm"
730
+ // A raised card floating 12px in. The DS's default icon size, so
731
+ // every hit area is 44 × 44 at any width.
732
+ className="absolute bottom-3 right-3 rounded-xl bg-card shadow-[var(--shadow-card)]"
678
733
  >
679
734
  <Button
680
735
  type="button"
681
736
  variant="outline"
682
- size="icon-sm"
737
+ size="icon"
683
738
  aria-label={labels.zoomIn}
684
739
  disabled={atMax}
685
740
  onClick={zoomIn}
@@ -689,7 +744,7 @@ const PanZoomViewport = React.forwardRef<HTMLDivElement, PanZoomViewportProps>(
689
744
  <Button
690
745
  type="button"
691
746
  variant="outline"
692
- size="icon-sm"
747
+ size="icon"
693
748
  aria-label={labels.zoomOut}
694
749
  disabled={atMin}
695
750
  onClick={zoomOut}
@@ -699,7 +754,7 @@ const PanZoomViewport = React.forwardRef<HTMLDivElement, PanZoomViewportProps>(
699
754
  <Button
700
755
  type="button"
701
756
  variant="outline"
702
- size="icon-sm"
757
+ size="icon"
703
758
  className="text-xs font-semibold"
704
759
  onClick={fit}
705
760
  >