@bearmenu/ui 0.8.37 → 0.8.39
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-XLHMF6F5.js → chunk-NF5UMLSL.js} +17 -6
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/command.d.ts +3 -3
- package/dist/components/drawer.d.ts +8 -1
- package/dist/components/drawer.js +1 -1
- package/dist/components/empty-state.d.ts +26 -1
- package/dist/components/empty-state.js +41 -17
- package/dist/components/pan-zoom-viewport.d.ts +25 -3
- package/dist/components/pan-zoom-viewport.js +43 -21
- package/dist/components/responsive-overlay.js +16 -20
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/drawer.test.tsx +55 -0
- package/src/components/drawer.tsx +33 -11
- package/src/components/empty-state.stories.tsx +14 -0
- package/src/components/empty-state.test.tsx +60 -0
- package/src/components/empty-state.tsx +66 -21
- package/src/components/floor-tokens.stories.tsx +223 -0
- package/src/components/pan-zoom-viewport.stories.tsx +21 -1
- package/src/components/pan-zoom-viewport.test.tsx +63 -14
- package/src/components/pan-zoom-viewport.tsx +82 -27
- package/src/components/responsive-overlay.test.tsx +40 -0
- package/src/components/responsive-overlay.tsx +13 -10
- package/src/globals.css +161 -0
|
@@ -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.
|
|
116
|
-
expect(transformOf()).toEqual({ x:
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
146
|
-
*
|
|
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(
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
|
221
|
-
y: clampAxis(
|
|
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,
|
|
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 =
|
|
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
|
|
235
|
-
return clampTransform({ scale, x:
|
|
236
|
-
}, [viewport,
|
|
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 +
|
|
581
|
-
else if (rect.right > bounds.right -
|
|
582
|
-
if (rect.top < bounds.top +
|
|
583
|
-
else if (rect.bottom > bounds.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
|
-
|
|
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
|
|
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
|
|
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
|
|
757
|
+
size="icon"
|
|
703
758
|
className="text-xs font-semibold"
|
|
704
759
|
onClick={fit}
|
|
705
760
|
>
|
|
@@ -78,6 +78,46 @@ describe("ResponsiveOverlay", () => {
|
|
|
78
78
|
expect(dialog).toHaveAccessibleDescription("Table 18 · from the menu");
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
describe("wires its title as the Radix title in every mode", () => {
|
|
82
|
+
const MODES: Array<{
|
|
83
|
+
name: string;
|
|
84
|
+
wide: boolean;
|
|
85
|
+
props: Partial<React.ComponentProps<typeof ResponsiveOverlay>>;
|
|
86
|
+
}> = [
|
|
87
|
+
{ name: "drawer", wide: false, props: {} },
|
|
88
|
+
{ name: "dialog", wide: true, props: {} },
|
|
89
|
+
{ name: "modal sheet", wide: true, props: { desktop: "sheet" } },
|
|
90
|
+
{ name: "live sheet", wide: true, props: { desktop: "sheet", modal: false } },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const logged = (...spies: Array<ReturnType<typeof vi.spyOn>>) =>
|
|
94
|
+
spies.flatMap((spy) => spy.mock.calls.flat()).join(" ");
|
|
95
|
+
|
|
96
|
+
for (const mode of MODES) {
|
|
97
|
+
it(`${mode.name}: named, described, and no Radix title warning`, () => {
|
|
98
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
99
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
100
|
+
setWidth(mode.wide);
|
|
101
|
+
open(mode.props);
|
|
102
|
+
const dialog = screen.getByRole("dialog");
|
|
103
|
+
expect(dialog).toHaveAccessibleName("Add an item");
|
|
104
|
+
expect(dialog).toHaveAccessibleDescription("Table 18 · from the menu");
|
|
105
|
+
expect(logged(error, warn)).not.toMatch(/DialogTitle|Description/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it(`${mode.name}: no description warning without a subtitle`, () => {
|
|
109
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
110
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
111
|
+
setWidth(mode.wide);
|
|
112
|
+
open({ ...mode.props, subtitle: undefined });
|
|
113
|
+
const dialog = screen.getByRole("dialog");
|
|
114
|
+
expect(dialog).toHaveAccessibleName("Add an item");
|
|
115
|
+
expect(dialog).not.toHaveAttribute("aria-describedby");
|
|
116
|
+
expect(logged(error, warn)).not.toMatch(/DialogTitle|Description/);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
81
121
|
it("closes through the round button", async () => {
|
|
82
122
|
const onOpenChange = vi.fn();
|
|
83
123
|
open({ onOpenChange });
|
|
@@ -164,21 +164,27 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
164
164
|
) => {
|
|
165
165
|
const isWide = useMediaQuery(wideFrom === "lg" ? DESKTOP_MEDIA_QUERY : MD_MEDIA_QUERY);
|
|
166
166
|
const container: "drawer" | "dialog" | "sheet" = isWide ? desktop : "drawer";
|
|
167
|
-
const titleId = React.useId();
|
|
168
|
-
const subtitleId = React.useId();
|
|
169
167
|
useFocusReturn(open);
|
|
170
168
|
|
|
169
|
+
// The name and the description are Radix's own: its `Title` and
|
|
170
|
+
// `Description` register ids in the dialog context and `Content` points
|
|
171
|
+
// `aria-labelledby` / `aria-describedby` at them. Passing our own ids
|
|
172
|
+
// overrode the registered one, so Radix found no title under its id and
|
|
173
|
+
// logged "DialogContent requires a DialogTitle" in every mode. Without a subtitle, `aria-describedby={undefined}` is Radix's
|
|
174
|
+
// documented opt-out, rather than a pointer at an element that never renders.
|
|
175
|
+
const describedBy = subtitle ? {} : { "aria-describedby": undefined };
|
|
176
|
+
|
|
171
177
|
const Title = CONTAINER_TITLE[container];
|
|
172
178
|
const Description = CONTAINER_DESCRIPTION[container];
|
|
173
179
|
|
|
174
180
|
const header = (
|
|
175
181
|
<div data-slot="responsive-overlay-header" className="flex items-start gap-3 px-5 pt-5">
|
|
176
182
|
<div className="min-w-0 flex-1">
|
|
177
|
-
<Title
|
|
183
|
+
<Title className="text-[19px] font-bold leading-tight">
|
|
178
184
|
{title}
|
|
179
185
|
</Title>
|
|
180
186
|
{subtitle ? (
|
|
181
|
-
<Description
|
|
187
|
+
<Description className="mt-1 text-[13px] text-muted-foreground">
|
|
182
188
|
{subtitle}
|
|
183
189
|
</Description>
|
|
184
190
|
) : null}
|
|
@@ -226,8 +232,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
226
232
|
attachment="inset"
|
|
227
233
|
hideClose
|
|
228
234
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
229
|
-
|
|
230
|
-
aria-describedby={subtitle ? subtitleId : undefined}
|
|
235
|
+
{...describedBy}
|
|
231
236
|
data-slot="responsive-overlay-sheet"
|
|
232
237
|
data-modal={modal ? "true" : "false"}
|
|
233
238
|
// A working rail must survive the page being used: a click on the
|
|
@@ -258,8 +263,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
258
263
|
size={size}
|
|
259
264
|
showCloseButton={false}
|
|
260
265
|
overlayClassName="bg-scrim-overlay backdrop-blur-none"
|
|
261
|
-
|
|
262
|
-
aria-describedby={subtitle ? subtitleId : undefined}
|
|
266
|
+
{...describedBy}
|
|
263
267
|
className={cn(
|
|
264
268
|
// The dialog's own `grid gap-4 p-6` would put air between the
|
|
265
269
|
// header, the body and the band the frames draw flush.
|
|
@@ -280,8 +284,7 @@ const ResponsiveOverlay = React.forwardRef<HTMLDivElement, ResponsiveOverlayProp
|
|
|
280
284
|
<DrawerContent
|
|
281
285
|
ref={ref}
|
|
282
286
|
overlayClassName="bg-scrim-overlay"
|
|
283
|
-
|
|
284
|
-
aria-describedby={subtitle ? subtitleId : undefined}
|
|
287
|
+
{...describedBy}
|
|
285
288
|
className={cn("flex max-h-[85vh] flex-col p-0", className)}
|
|
286
289
|
>
|
|
287
290
|
{header}
|
package/src/globals.css
CHANGED
|
@@ -312,6 +312,70 @@ h6 {
|
|
|
312
312
|
than a black one before it looks dirty. */
|
|
313
313
|
--icon-depth-neutral: drop-shadow(0 1px 1px oklch(0.38 0 0 / 0.29))
|
|
314
314
|
drop-shadow(0 2px 4px oklch(0.38 0 0 / 0.22));
|
|
315
|
+
|
|
316
|
+
/* ─── Floor plan (5a "Jewel", contrast-corrected) ─────────────────────
|
|
317
|
+
The table map's status palette. 5a as drawn failed contrast (white on
|
|
318
|
+
booked/seated/late/blocked 2.4–4.6:1, the empty seat track 1.49:1 on the
|
|
319
|
+
card); these keep its hue, chroma and gradient direction and correct
|
|
320
|
+
lightness only. Worst point over 15–85% of each gradient: text >= 4.79:1
|
|
321
|
+
light / 6.29:1 dark, seats >= 3.03:1 / 3.11:1 against card and room.
|
|
322
|
+
|
|
323
|
+
Per status: `-from` / `-to` are the table fill, a horizontal gradient
|
|
324
|
+
inside the table's rotated group (SVG `stop-color: var(--floor-…-from)`);
|
|
325
|
+
`-seat` is the status overlay on the chair ring, drawn over the neutral
|
|
326
|
+
`--floor-seat` track; `-foreground` is the label and time on the fill.
|
|
327
|
+
Booked, seated, late and blocked share `--floor-on-fill-foreground`.
|
|
328
|
+
Halos (late, over time) pulse outside the ring; `--floor-offer` is the
|
|
329
|
+
dashed offer ring and `--floor-selected` the solid selection ring.
|
|
330
|
+
`--floor-shadow` is the flood colour of the table's three drop shadows,
|
|
331
|
+
their alphas multiplied by `--floor-shadow-strength`.
|
|
332
|
+
Tailwind: `fill-floor-*`, `stroke-floor-*`, `bg-floor-*`, `text-floor-*`. */
|
|
333
|
+
--floor-free-from: oklch(1 0 0);
|
|
334
|
+
--floor-free-to: oklch(0.955 0.012 65);
|
|
335
|
+
--floor-free-foreground: oklch(0.22 0.025 45);
|
|
336
|
+
--floor-muted-foreground: oklch(0.48 0.02 45);
|
|
337
|
+
--floor-seat: oklch(0.66 0.012 60);
|
|
338
|
+
--floor-booked-from: oklch(0.47 0.13 258);
|
|
339
|
+
--floor-booked-to: oklch(0.555 0.115 236);
|
|
340
|
+
--floor-booked-seat: oklch(0.6 0.12 248);
|
|
341
|
+
--floor-seated-from: oklch(0.45 0.095 163);
|
|
342
|
+
--floor-seated-to: oklch(0.545 0.145 150);
|
|
343
|
+
--floor-seated-seat: oklch(0.53 0.12 158);
|
|
344
|
+
--floor-over-from: oklch(0.74 0.15 62);
|
|
345
|
+
--floor-over-to: oklch(0.85 0.15 92);
|
|
346
|
+
--floor-over-seat: oklch(0.64 0.145 62);
|
|
347
|
+
--floor-over-foreground: oklch(0.22 0.025 45);
|
|
348
|
+
--floor-late-from: oklch(0.5 0.19 18);
|
|
349
|
+
--floor-late-to: oklch(0.57 0.18 32);
|
|
350
|
+
--floor-late-seat: oklch(0.56 0.19 22);
|
|
351
|
+
--floor-blocked-from: oklch(0.48 0.015 60);
|
|
352
|
+
--floor-blocked-to: oklch(0.555 0.01 70);
|
|
353
|
+
--floor-blocked-seat: oklch(0.6 0.012 60);
|
|
354
|
+
--floor-out-of-service-from: oklch(0.93 0.006 60);
|
|
355
|
+
--floor-out-of-service-to: oklch(0.96 0.004 70);
|
|
356
|
+
--floor-offline-from: oklch(0.86 0.004 60);
|
|
357
|
+
--floor-offline-to: oklch(0.9 0.004 70);
|
|
358
|
+
--floor-offline-seat: oklch(0.64 0.006 60);
|
|
359
|
+
--floor-offline-foreground: oklch(0.3 0.012 50);
|
|
360
|
+
--floor-on-fill-foreground: oklch(0.99 0.005 70);
|
|
361
|
+
--floor-late-halo: oklch(0.56 0.2 27);
|
|
362
|
+
--floor-over-halo: oklch(0.64 0.145 62);
|
|
363
|
+
--floor-busy: oklch(0.42 0.02 45);
|
|
364
|
+
--floor-error: oklch(0.56 0.2 27);
|
|
365
|
+
--floor-error-foreground: oklch(0.99 0 0);
|
|
366
|
+
--floor-offer: var(--primary);
|
|
367
|
+
--floor-selected: oklch(0.25 0.05 35);
|
|
368
|
+
--floor-room: oklch(0.99 0.005 75);
|
|
369
|
+
--floor-wall: oklch(0.33 0.02 45);
|
|
370
|
+
--floor-fixture: oklch(0.62 0.018 50);
|
|
371
|
+
--floor-shadow: oklch(0.222 0.036 53.5);
|
|
372
|
+
--floor-shadow-strength: 1;
|
|
373
|
+
/* Per-status foreground aliases, so a status maps to one token family. */
|
|
374
|
+
--floor-booked-foreground: var(--floor-on-fill-foreground);
|
|
375
|
+
--floor-seated-foreground: var(--floor-on-fill-foreground);
|
|
376
|
+
--floor-late-foreground: var(--floor-on-fill-foreground);
|
|
377
|
+
--floor-blocked-foreground: var(--floor-on-fill-foreground);
|
|
378
|
+
--floor-out-of-service-foreground: var(--floor-muted-foreground);
|
|
315
379
|
}
|
|
316
380
|
|
|
317
381
|
.dark {
|
|
@@ -424,6 +488,57 @@ h6 {
|
|
|
424
488
|
pushed harder since the panel absorbs most of the shadow. */
|
|
425
489
|
--icon-depth-neutral: drop-shadow(0 1px 1px oklch(0 0 0 / 0.47))
|
|
426
490
|
drop-shadow(0 2px 4px oklch(0 0 0 / 0.34));
|
|
491
|
+
|
|
492
|
+
/* Floor plan, dark — derived from the light set (see :root). Fills sit
|
|
493
|
+
deeper, seats and halos lift off the dark card, the over-time amber keeps
|
|
494
|
+
dark type. The shadow goes black at twice the alpha: a warm shadow has
|
|
495
|
+
nothing to darken on a 0.225 room. Aliases are restated, as the sidebar's
|
|
496
|
+
are, so a `.dark` below the root does not inherit the light values. */
|
|
497
|
+
--floor-free-from: oklch(0.32 0.012 60);
|
|
498
|
+
--floor-free-to: oklch(0.285 0.01 60);
|
|
499
|
+
--floor-free-foreground: oklch(0.96 0.005 60);
|
|
500
|
+
--floor-muted-foreground: oklch(0.76 0.01 60);
|
|
501
|
+
--floor-seat: oklch(0.54 0.012 60);
|
|
502
|
+
--floor-booked-from: oklch(0.4 0.12 258);
|
|
503
|
+
--floor-booked-to: oklch(0.48 0.1 236);
|
|
504
|
+
--floor-booked-seat: oklch(0.64 0.12 245);
|
|
505
|
+
--floor-seated-from: oklch(0.39 0.082 163);
|
|
506
|
+
--floor-seated-to: oklch(0.475 0.125 150);
|
|
507
|
+
--floor-seated-seat: oklch(0.66 0.15 155);
|
|
508
|
+
--floor-over-from: oklch(0.7 0.14 62);
|
|
509
|
+
--floor-over-to: oklch(0.8 0.14 88);
|
|
510
|
+
--floor-over-seat: oklch(0.78 0.15 78);
|
|
511
|
+
--floor-over-foreground: oklch(0.2 0.03 40);
|
|
512
|
+
--floor-late-from: oklch(0.42 0.16 18);
|
|
513
|
+
--floor-late-to: oklch(0.5 0.165 30);
|
|
514
|
+
--floor-late-seat: oklch(0.64 0.19 25);
|
|
515
|
+
--floor-blocked-from: oklch(0.38 0.012 60);
|
|
516
|
+
--floor-blocked-to: oklch(0.44 0.01 70);
|
|
517
|
+
--floor-blocked-seat: oklch(0.58 0.012 60);
|
|
518
|
+
--floor-out-of-service-from: oklch(0.26 0.006 60);
|
|
519
|
+
--floor-out-of-service-to: oklch(0.24 0.005 60);
|
|
520
|
+
--floor-offline-from: oklch(0.3 0.004 60);
|
|
521
|
+
--floor-offline-to: oklch(0.27 0.004 60);
|
|
522
|
+
--floor-offline-seat: oklch(0.52 0.004 60);
|
|
523
|
+
--floor-offline-foreground: oklch(0.82 0.004 60);
|
|
524
|
+
--floor-on-fill-foreground: oklch(0.98 0.005 70);
|
|
525
|
+
--floor-late-halo: oklch(0.66 0.2 25);
|
|
526
|
+
--floor-over-halo: oklch(0.82 0.16 82);
|
|
527
|
+
--floor-busy: oklch(0.82 0.01 60);
|
|
528
|
+
--floor-error: oklch(0.58 0.19 25);
|
|
529
|
+
--floor-error-foreground: oklch(0.99 0 0);
|
|
530
|
+
--floor-offer: var(--primary);
|
|
531
|
+
--floor-selected: oklch(0.96 0.005 60);
|
|
532
|
+
--floor-room: oklch(0.225 0.012 45);
|
|
533
|
+
--floor-wall: oklch(0.78 0.01 60);
|
|
534
|
+
--floor-fixture: oklch(0.55 0.012 60);
|
|
535
|
+
--floor-shadow: oklch(0 0 0);
|
|
536
|
+
--floor-shadow-strength: 2;
|
|
537
|
+
--floor-booked-foreground: var(--floor-on-fill-foreground);
|
|
538
|
+
--floor-seated-foreground: var(--floor-on-fill-foreground);
|
|
539
|
+
--floor-late-foreground: var(--floor-on-fill-foreground);
|
|
540
|
+
--floor-blocked-foreground: var(--floor-on-fill-foreground);
|
|
541
|
+
--floor-out-of-service-foreground: var(--floor-muted-foreground);
|
|
427
542
|
}
|
|
428
543
|
|
|
429
544
|
/* Named 11px step (`text-2xs`) for micro-labels: overlay pills, hour
|
|
@@ -477,6 +592,52 @@ h6 {
|
|
|
477
592
|
--color-avatar-blue: var(--avatar-blue);
|
|
478
593
|
--color-avatar-violet: var(--avatar-violet);
|
|
479
594
|
--color-avatar-rose: var(--avatar-rose);
|
|
595
|
+
/* `fill-floor-booked-from`, `stroke-floor-seat`, `text-floor-on-fill-foreground` … —
|
|
596
|
+
the floor plan's status palette (see `--floor-*` in :root). */
|
|
597
|
+
--color-floor-free-from: var(--floor-free-from);
|
|
598
|
+
--color-floor-free-to: var(--floor-free-to);
|
|
599
|
+
--color-floor-free-foreground: var(--floor-free-foreground);
|
|
600
|
+
--color-floor-muted-foreground: var(--floor-muted-foreground);
|
|
601
|
+
--color-floor-seat: var(--floor-seat);
|
|
602
|
+
--color-floor-booked-from: var(--floor-booked-from);
|
|
603
|
+
--color-floor-booked-to: var(--floor-booked-to);
|
|
604
|
+
--color-floor-booked-seat: var(--floor-booked-seat);
|
|
605
|
+
--color-floor-booked-foreground: var(--floor-booked-foreground);
|
|
606
|
+
--color-floor-seated-from: var(--floor-seated-from);
|
|
607
|
+
--color-floor-seated-to: var(--floor-seated-to);
|
|
608
|
+
--color-floor-seated-seat: var(--floor-seated-seat);
|
|
609
|
+
--color-floor-seated-foreground: var(--floor-seated-foreground);
|
|
610
|
+
--color-floor-over-from: var(--floor-over-from);
|
|
611
|
+
--color-floor-over-to: var(--floor-over-to);
|
|
612
|
+
--color-floor-over-seat: var(--floor-over-seat);
|
|
613
|
+
--color-floor-over-foreground: var(--floor-over-foreground);
|
|
614
|
+
--color-floor-late-from: var(--floor-late-from);
|
|
615
|
+
--color-floor-late-to: var(--floor-late-to);
|
|
616
|
+
--color-floor-late-seat: var(--floor-late-seat);
|
|
617
|
+
--color-floor-late-foreground: var(--floor-late-foreground);
|
|
618
|
+
--color-floor-blocked-from: var(--floor-blocked-from);
|
|
619
|
+
--color-floor-blocked-to: var(--floor-blocked-to);
|
|
620
|
+
--color-floor-blocked-seat: var(--floor-blocked-seat);
|
|
621
|
+
--color-floor-blocked-foreground: var(--floor-blocked-foreground);
|
|
622
|
+
--color-floor-out-of-service-from: var(--floor-out-of-service-from);
|
|
623
|
+
--color-floor-out-of-service-to: var(--floor-out-of-service-to);
|
|
624
|
+
--color-floor-out-of-service-foreground: var(--floor-out-of-service-foreground);
|
|
625
|
+
--color-floor-offline-from: var(--floor-offline-from);
|
|
626
|
+
--color-floor-offline-to: var(--floor-offline-to);
|
|
627
|
+
--color-floor-offline-seat: var(--floor-offline-seat);
|
|
628
|
+
--color-floor-offline-foreground: var(--floor-offline-foreground);
|
|
629
|
+
--color-floor-on-fill-foreground: var(--floor-on-fill-foreground);
|
|
630
|
+
--color-floor-late-halo: var(--floor-late-halo);
|
|
631
|
+
--color-floor-over-halo: var(--floor-over-halo);
|
|
632
|
+
--color-floor-busy: var(--floor-busy);
|
|
633
|
+
--color-floor-error: var(--floor-error);
|
|
634
|
+
--color-floor-error-foreground: var(--floor-error-foreground);
|
|
635
|
+
--color-floor-offer: var(--floor-offer);
|
|
636
|
+
--color-floor-selected: var(--floor-selected);
|
|
637
|
+
--color-floor-room: var(--floor-room);
|
|
638
|
+
--color-floor-wall: var(--floor-wall);
|
|
639
|
+
--color-floor-fixture: var(--floor-fixture);
|
|
640
|
+
--color-floor-shadow: var(--floor-shadow);
|
|
480
641
|
|
|
481
642
|
/* Radius scale */
|
|
482
643
|
--radius-sm: calc(var(--radius) - 4px);
|