@bearmenu/ui 0.8.36 → 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.
- package/dist/{chunk-TVEIUTBQ.js → chunk-IQIJCMG2.js} +1 -1
- package/dist/{chunk-VCRXL7H5.js → chunk-PGQXVJXW.js} +2 -2
- package/dist/{chunk-F3XK34DV.js → chunk-TB2K5JN3.js} +2 -2
- package/dist/components/calendar.js +1 -1
- package/dist/components/combobox.js +3 -3
- package/dist/components/command.js +2 -2
- package/dist/components/dialog.js +1 -1
- package/dist/components/dropdown-menu.js +6 -6
- package/dist/components/input-group.js +1 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/multi-select-menu.js +1 -1
- 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 +1 -1
- 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/toggle-group.js +1 -1
- package/dist/components/toggle.d.ts +6 -0
- package/dist/components/toggle.js +1 -1
- package/package.json +1 -1
- 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-menu.tsx +1 -1
- 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/searchable-select.tsx +1 -1
- package/src/components/select.tsx +2 -2
- package/src/components/selection-bar.tsx +1 -1
- package/src/components/toggle.test.tsx +19 -0
- package/src/components/toggle.tsx +8 -2
- package/src/globals.css +28 -19
|
@@ -61,7 +61,7 @@ var DialogContent = React.forwardRef(
|
|
|
61
61
|
}, props), {
|
|
62
62
|
children: [
|
|
63
63
|
children,
|
|
64
|
-
showCloseButton && /* @__PURE__ */ jsxs(DialogPrimitive.Close, { className: "absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-
|
|
64
|
+
showCloseButton && /* @__PURE__ */ jsxs(DialogPrimitive.Close, { className: "absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent-subtle hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none", children: [
|
|
65
65
|
/* @__PURE__ */ jsx(X, { className: "size-4" }),
|
|
66
66
|
/* @__PURE__ */ jsx("span", { className: "sr-only", children: "Close" })
|
|
67
67
|
] })
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './chunk-
|
|
1
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './chunk-IQIJCMG2.js';
|
|
2
2
|
import { cn } from './chunk-FEK5XGZW.js';
|
|
3
3
|
import { __objRest, __spreadValues, __spreadProps } from './chunk-MMUBH76A.js';
|
|
4
4
|
import * as React from 'react';
|
|
@@ -122,7 +122,7 @@ var CommandItem = React.forwardRef((_a, ref) => {
|
|
|
122
122
|
ref,
|
|
123
123
|
className: cn(
|
|
124
124
|
"relative flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none transition-colors",
|
|
125
|
-
"data-[selected=true]:bg-accent data-[selected=true]:text-
|
|
125
|
+
"data-[selected=true]:bg-accent-subtle data-[selected=true]:text-foreground",
|
|
126
126
|
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
|
127
127
|
className
|
|
128
128
|
)
|
|
@@ -5,12 +5,12 @@ import { cva } from 'class-variance-authority';
|
|
|
5
5
|
import { jsx } from 'react/jsx-runtime';
|
|
6
6
|
|
|
7
7
|
var toggleVariants = cva(
|
|
8
|
-
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold hover:bg-accent
|
|
8
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold hover:bg-accent-subtle hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors aria-invalid:ring-2 aria-invalid:ring-destructive/40 whitespace-nowrap",
|
|
9
9
|
{
|
|
10
10
|
variants: {
|
|
11
11
|
variant: {
|
|
12
12
|
default: "bg-transparent",
|
|
13
|
-
outline: "border border-border bg-transparent hover:bg-accent
|
|
13
|
+
outline: "border border-border bg-transparent hover:bg-accent-subtle hover:text-foreground data-[state=on]:border-primary",
|
|
14
14
|
/**
|
|
15
15
|
* One cell of a segmented control: a raised card on the group's
|
|
16
16
|
* muted capsule when on, quiet type when off. The group
|
|
@@ -211,7 +211,7 @@ function CalendarDayButton(_a) {
|
|
|
211
211
|
"data-range-end": modifiers.range_end,
|
|
212
212
|
"data-range-middle": modifiers.range_middle,
|
|
213
213
|
className: cn(
|
|
214
|
-
"group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground
|
|
214
|
+
"group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground flex aspect-square h-(--cell-size) w-(--cell-size) size-auto min-w-0 flex-col items-center justify-center gap-1 rounded-md p-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
|
215
215
|
defaultClassNames.day,
|
|
216
216
|
className
|
|
217
217
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
|
|
2
|
-
import { Command, CommandInput, CommandList, CommandLoading, CommandEmpty, CommandGroup, CommandItem } from '../chunk-
|
|
3
|
-
import '../chunk-
|
|
2
|
+
import { Command, CommandInput, CommandList, CommandLoading, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
|
|
3
|
+
import '../chunk-IQIJCMG2.js';
|
|
4
4
|
import { Button } from '../chunk-5SGKGFMN.js';
|
|
5
5
|
import '../chunk-RIH2IS7X.js';
|
|
6
6
|
import '../chunk-5YNIQ2BL.js';
|
|
@@ -93,7 +93,7 @@ function Combobox({
|
|
|
93
93
|
e.stopPropagation();
|
|
94
94
|
onValueChange(void 0);
|
|
95
95
|
},
|
|
96
|
-
className: "absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-
|
|
96
|
+
className: "absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
97
97
|
children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
|
|
98
98
|
}
|
|
99
99
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading, CommandSeparator, CommandShortcut } from '../chunk-
|
|
2
|
-
import '../chunk-
|
|
1
|
+
export { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading, CommandSeparator, CommandShortcut } from '../chunk-PGQXVJXW.js';
|
|
2
|
+
import '../chunk-IQIJCMG2.js';
|
|
3
3
|
import '../chunk-FEK5XGZW.js';
|
|
4
4
|
import '../chunk-MMUBH76A.js';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, dialogContentVariants } from '../chunk-
|
|
1
|
+
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, dialogContentVariants } from '../chunk-IQIJCMG2.js';
|
|
2
2
|
import '../chunk-FEK5XGZW.js';
|
|
3
3
|
import '../chunk-MMUBH76A.js';
|
|
@@ -62,7 +62,7 @@ function DropdownMenuItem(_a) {
|
|
|
62
62
|
"data-inset": inset,
|
|
63
63
|
"data-variant": variant,
|
|
64
64
|
className: cn(
|
|
65
|
-
"focus:bg-accent focus:text-
|
|
65
|
+
"focus:bg-accent-subtle focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
66
66
|
className
|
|
67
67
|
)
|
|
68
68
|
}, props)
|
|
@@ -85,7 +85,7 @@ function DropdownMenuCheckboxItem(_a) {
|
|
|
85
85
|
__spreadProps(__spreadValues({
|
|
86
86
|
"data-slot": "dropdown-menu-checkbox-item",
|
|
87
87
|
className: cn(
|
|
88
|
-
"focus:bg-accent focus:text-
|
|
88
|
+
"focus:bg-accent-subtle focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
89
89
|
className
|
|
90
90
|
),
|
|
91
91
|
checked
|
|
@@ -100,7 +100,7 @@ function DropdownMenuCheckboxItem(_a) {
|
|
|
100
100
|
),
|
|
101
101
|
children: /* @__PURE__ */ jsx(DropdownMenuPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-3" }) })
|
|
102
102
|
}
|
|
103
|
-
) : /* @__PURE__ */ jsx(DropdownMenuPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-4" }) }) }),
|
|
103
|
+
) : /* @__PURE__ */ jsx(DropdownMenuPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-4 text-accent" }) }) }),
|
|
104
104
|
children
|
|
105
105
|
]
|
|
106
106
|
})
|
|
@@ -123,12 +123,12 @@ function DropdownMenuRadioItem(_a) {
|
|
|
123
123
|
__spreadProps(__spreadValues({
|
|
124
124
|
"data-slot": "dropdown-menu-radio-item",
|
|
125
125
|
className: cn(
|
|
126
|
-
"focus:bg-accent focus:text-
|
|
126
|
+
"focus:bg-accent-subtle focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
127
127
|
className
|
|
128
128
|
)
|
|
129
129
|
}, props), {
|
|
130
130
|
children: [
|
|
131
|
-
/* @__PURE__ */ jsx("span", { className: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center", children: /* @__PURE__ */ jsx(DropdownMenuPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(CircleIcon, { className: "size-2 fill-current" }) }) }),
|
|
131
|
+
/* @__PURE__ */ jsx("span", { className: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center", children: /* @__PURE__ */ jsx(DropdownMenuPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(CircleIcon, { className: "size-2 fill-current text-accent" }) }) }),
|
|
132
132
|
children
|
|
133
133
|
]
|
|
134
134
|
})
|
|
@@ -195,7 +195,7 @@ function DropdownMenuSubTrigger(_a) {
|
|
|
195
195
|
"data-slot": "dropdown-menu-sub-trigger",
|
|
196
196
|
"data-inset": inset,
|
|
197
197
|
className: cn(
|
|
198
|
-
"focus:bg-accent focus:text-
|
|
198
|
+
"focus:bg-accent-subtle focus:text-foreground data-[state=open]:bg-accent-subtle data-[state=open]:text-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
|
199
199
|
className
|
|
200
200
|
)
|
|
201
201
|
}, props), {
|
|
@@ -85,7 +85,7 @@ var InputGroupButton = React.forwardRef(
|
|
|
85
85
|
type,
|
|
86
86
|
"data-slot": "input-group-button",
|
|
87
87
|
className: cn(
|
|
88
|
-
"inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent hover:text-
|
|
88
|
+
"inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&:has(svg:only-child)]:w-7 [&:has(svg:only-child)]:px-0 [&_svg]:size-4",
|
|
89
89
|
className
|
|
90
90
|
)
|
|
91
91
|
}, props)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Checkbox } from '../chunk-L3CDXVEP.js';
|
|
2
2
|
import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
|
|
3
|
-
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-
|
|
4
|
-
import '../chunk-
|
|
3
|
+
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
|
|
4
|
+
import '../chunk-IQIJCMG2.js';
|
|
5
5
|
import { Button } from '../chunk-5SGKGFMN.js';
|
|
6
6
|
import '../chunk-RIH2IS7X.js';
|
|
7
7
|
import '../chunk-5YNIQ2BL.js';
|
|
@@ -54,7 +54,7 @@ function MultiSelectMenu({
|
|
|
54
54
|
onCheckedChange: () => handleToggle(option.value),
|
|
55
55
|
className: cn(
|
|
56
56
|
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none",
|
|
57
|
-
"data-[highlighted]:bg-accent data-[highlighted]:text-
|
|
57
|
+
"data-[highlighted]:bg-accent-subtle data-[highlighted]:text-foreground",
|
|
58
58
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
|
59
59
|
),
|
|
60
60
|
children: [
|
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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,
|
|
19
|
-
|
|
20
|
-
return
|
|
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,
|
|
89
|
-
y: clampAxis(
|
|
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,
|
|
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 =
|
|
97
|
-
|
|
98
|
-
|
|
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 +
|
|
389
|
-
else if (rect.right > bounds.right -
|
|
390
|
-
if (rect.top < bounds.top +
|
|
391
|
-
else if (rect.bottom > bounds.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-
|
|
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
|
|
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
|
|
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
|
|
535
|
+
size: "icon",
|
|
514
536
|
className: "text-xs font-semibold",
|
|
515
537
|
onClick: fit,
|
|
516
538
|
children: labels.fit
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMediaQuery, DESKTOP_MEDIA_QUERY } from '../chunk-OPVKL4VS.js';
|
|
2
2
|
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '../chunk-LAEVMNJN.js';
|
|
3
3
|
import { Drawer, DrawerContent, DrawerTitle, DrawerDescription } from '../chunk-XLHMF6F5.js';
|
|
4
|
-
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '../chunk-
|
|
4
|
+
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '../chunk-IQIJCMG2.js';
|
|
5
5
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
6
6
|
import { __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
7
7
|
import * as React from 'react';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Popover, PopoverTrigger, PopoverContent } from '../chunk-2ADCZXZJ.js';
|
|
2
|
-
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-
|
|
3
|
-
import '../chunk-
|
|
2
|
+
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../chunk-PGQXVJXW.js';
|
|
3
|
+
import '../chunk-IQIJCMG2.js';
|
|
4
4
|
import { Button } from '../chunk-5SGKGFMN.js';
|
|
5
5
|
import '../chunk-RIH2IS7X.js';
|
|
6
6
|
import '../chunk-5YNIQ2BL.js';
|
|
@@ -99,7 +99,7 @@ function SearchableSelect({
|
|
|
99
99
|
e.stopPropagation();
|
|
100
100
|
onValueChange(void 0);
|
|
101
101
|
},
|
|
102
|
-
className: "absolute right-9 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-
|
|
102
|
+
className: "absolute right-9 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors",
|
|
103
103
|
children: /* @__PURE__ */ jsx(X, { className: "size-3.5" })
|
|
104
104
|
}
|
|
105
105
|
)
|
|
@@ -118,12 +118,12 @@ var SelectItem = React.forwardRef((_a, ref) => {
|
|
|
118
118
|
ref,
|
|
119
119
|
"data-slot": "select-item",
|
|
120
120
|
className: cn(
|
|
121
|
-
"relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-
|
|
121
|
+
"relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent-subtle data-[highlighted]:text-foreground data-[state=checked]:font-semibold",
|
|
122
122
|
className
|
|
123
123
|
)
|
|
124
124
|
}, props), {
|
|
125
125
|
children: [
|
|
126
|
-
/* @__PURE__ */ jsx("span", { className: "absolute left-2 flex size-3.5 items-center justify-center", children: /* @__PURE__ */ jsx(SelectPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(Check, { className: "size-4" }) }) }),
|
|
126
|
+
/* @__PURE__ */ jsx("span", { className: "absolute left-2 flex size-3.5 items-center justify-center", children: /* @__PURE__ */ jsx(SelectPrimitive.ItemIndicator, { children: /* @__PURE__ */ jsx(Check, { className: "size-4 text-accent" }) }) }),
|
|
127
127
|
/* @__PURE__ */ jsx(SelectPrimitive.ItemText, { children })
|
|
128
128
|
]
|
|
129
129
|
})
|
|
@@ -43,7 +43,7 @@ var SelectionBar = React.forwardRef(
|
|
|
43
43
|
type: "button",
|
|
44
44
|
"aria-label": "Clear selection",
|
|
45
45
|
onClick: onClear,
|
|
46
|
-
className: "inline-flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-
|
|
46
|
+
className: "inline-flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
47
47
|
children: /* @__PURE__ */ jsx(X, { className: "size-4" })
|
|
48
48
|
}
|
|
49
49
|
),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { toggleVariants } from '../chunk-
|
|
1
|
+
import { toggleVariants } from '../chunk-TB2K5JN3.js';
|
|
2
2
|
import { cn } from '../chunk-FEK5XGZW.js';
|
|
3
3
|
import { __objRest, __spreadProps, __spreadValues } from '../chunk-MMUBH76A.js';
|
|
4
4
|
import * as React from 'react';
|
|
@@ -4,6 +4,12 @@ import * as React from 'react';
|
|
|
4
4
|
import * as TogglePrimitive from '@radix-ui/react-toggle';
|
|
5
5
|
import { VariantProps } from 'class-variance-authority';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Hover is a step of the surface (`--accent-subtle`), as Button's quiet
|
|
9
|
+
* variants do. The shadcn default, `hover:bg-accent/50 hover:text-accent-foreground`,
|
|
10
|
+
* put white type on a half-strength terracotta: 2.2:1. The on-state's
|
|
11
|
+
* `data-[state=on]:` utilities sort after `hover:` and still win while on.
|
|
12
|
+
*/
|
|
7
13
|
declare const toggleVariants: (props?: ({
|
|
8
14
|
variant?: "default" | "tab" | "outline" | "chip" | "segmented" | null | undefined;
|
|
9
15
|
size?: "default" | "sm" | "lg" | null | undefined;
|
package/package.json
CHANGED
|
@@ -238,7 +238,7 @@ function CalendarDayButton({
|
|
|
238
238
|
data-range-end={modifiers.range_end}
|
|
239
239
|
data-range-middle={modifiers.range_middle}
|
|
240
240
|
className={cn(
|
|
241
|
-
"group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground
|
|
241
|
+
"group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground flex aspect-square h-(--cell-size) w-(--cell-size) size-auto min-w-0 flex-col items-center justify-center gap-1 rounded-md p-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
|
242
242
|
defaultClassNames.day,
|
|
243
243
|
className
|
|
244
244
|
)}
|
|
@@ -124,7 +124,7 @@ function Combobox({
|
|
|
124
124
|
e.stopPropagation();
|
|
125
125
|
onValueChange(undefined);
|
|
126
126
|
}}
|
|
127
|
-
className="absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-
|
|
127
|
+
className="absolute right-9 top-1/2 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
128
128
|
>
|
|
129
129
|
<X className="size-3.5" />
|
|
130
130
|
</button>
|
|
@@ -88,4 +88,14 @@ describe("Command", () => {
|
|
|
88
88
|
await user.keyboard("{ArrowDown}{Enter}");
|
|
89
89
|
expect(onSelect).toHaveBeenCalledWith("banana");
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
it("highlights the selected row with the subtle well, not the accent fill", () => {
|
|
93
|
+
render(<CommandSample />);
|
|
94
|
+
const row = screen.getByRole("option", { name: "Apple" });
|
|
95
|
+
expect(row).toHaveClass(
|
|
96
|
+
"data-[selected=true]:bg-accent-subtle",
|
|
97
|
+
"data-[selected=true]:text-foreground"
|
|
98
|
+
);
|
|
99
|
+
expect(row).not.toHaveClass("data-[selected=true]:bg-accent");
|
|
100
|
+
});
|
|
91
101
|
});
|
|
@@ -138,7 +138,7 @@ const CommandItem = React.forwardRef<
|
|
|
138
138
|
ref={ref}
|
|
139
139
|
className={cn(
|
|
140
140
|
"relative flex cursor-default select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none transition-colors",
|
|
141
|
-
"data-[selected=true]:bg-accent data-[selected=true]:text-
|
|
141
|
+
"data-[selected=true]:bg-accent-subtle data-[selected=true]:text-foreground",
|
|
142
142
|
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
|
143
143
|
className
|
|
144
144
|
)}
|
|
@@ -79,7 +79,7 @@ const DialogContent = React.forwardRef<
|
|
|
79
79
|
>
|
|
80
80
|
{children}
|
|
81
81
|
{showCloseButton && (
|
|
82
|
-
<DialogPrimitive.Close className="absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-
|
|
82
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition-opacity hover:bg-accent-subtle hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none">
|
|
83
83
|
<X className="size-4" />
|
|
84
84
|
<span className="sr-only">Close</span>
|
|
85
85
|
</DialogPrimitive.Close>
|
|
@@ -177,4 +177,35 @@ describe("DropdownMenu", () => {
|
|
|
177
177
|
expect(screen.getByText("Group")).toBeInTheDocument();
|
|
178
178
|
expect(document.querySelector("[data-slot=dropdown-menu-separator]")).not.toBeNull();
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
// A highlighted row is a tinted well with ink type; terracotta is kept for
|
|
182
|
+
// the checked indicators. A solid-accent row read as a second primary.
|
|
183
|
+
it("highlights rows with the subtle well and marks checked items in the accent", async () => {
|
|
184
|
+
const user = userEvent.setup();
|
|
185
|
+
render(
|
|
186
|
+
<DropdownMenu>
|
|
187
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
188
|
+
<DropdownMenuContent>
|
|
189
|
+
<DropdownMenuItem>One</DropdownMenuItem>
|
|
190
|
+
<DropdownMenuCheckboxItem checked>Two</DropdownMenuCheckboxItem>
|
|
191
|
+
<DropdownMenuRadioGroup value="a">
|
|
192
|
+
<DropdownMenuRadioItem value="a">Three</DropdownMenuRadioItem>
|
|
193
|
+
</DropdownMenuRadioGroup>
|
|
194
|
+
</DropdownMenuContent>
|
|
195
|
+
</DropdownMenu>
|
|
196
|
+
);
|
|
197
|
+
await user.click(screen.getByText("Open"));
|
|
198
|
+
const rows = [
|
|
199
|
+
screen.getByRole("menuitem", { name: "One" }),
|
|
200
|
+
screen.getByRole("menuitemcheckbox", { name: "Two" }),
|
|
201
|
+
screen.getByRole("menuitemradio", { name: "Three" }),
|
|
202
|
+
];
|
|
203
|
+
for (const row of rows) {
|
|
204
|
+
expect(row).toHaveClass("focus:bg-accent-subtle", "focus:text-foreground");
|
|
205
|
+
expect(row).not.toHaveClass("focus:bg-accent");
|
|
206
|
+
expect(row).not.toHaveClass("focus:text-accent-foreground");
|
|
207
|
+
}
|
|
208
|
+
expect(rows[1]?.querySelector("svg")).toHaveClass("text-accent");
|
|
209
|
+
expect(rows[2]?.querySelector("svg")).toHaveClass("text-accent");
|
|
210
|
+
});
|
|
180
211
|
});
|
|
@@ -67,7 +67,7 @@ function DropdownMenuItem({
|
|
|
67
67
|
data-inset={inset}
|
|
68
68
|
data-variant={variant}
|
|
69
69
|
className={cn(
|
|
70
|
-
"focus:bg-accent focus:text-
|
|
70
|
+
"focus:bg-accent-subtle focus:text-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
71
71
|
className
|
|
72
72
|
)}
|
|
73
73
|
{...props}
|
|
@@ -89,7 +89,7 @@ function DropdownMenuCheckboxItem({
|
|
|
89
89
|
<DropdownMenuPrimitive.CheckboxItem
|
|
90
90
|
data-slot="dropdown-menu-checkbox-item"
|
|
91
91
|
className={cn(
|
|
92
|
-
"focus:bg-accent focus:text-
|
|
92
|
+
"focus:bg-accent-subtle focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
93
93
|
className
|
|
94
94
|
)}
|
|
95
95
|
checked={checked}
|
|
@@ -111,7 +111,7 @@ function DropdownMenuCheckboxItem({
|
|
|
111
111
|
</span>
|
|
112
112
|
) : (
|
|
113
113
|
<DropdownMenuPrimitive.ItemIndicator>
|
|
114
|
-
<CheckIcon className="size-4" />
|
|
114
|
+
<CheckIcon className="size-4 text-accent" />
|
|
115
115
|
</DropdownMenuPrimitive.ItemIndicator>
|
|
116
116
|
)}
|
|
117
117
|
</span>
|
|
@@ -135,14 +135,14 @@ function DropdownMenuRadioItem({
|
|
|
135
135
|
<DropdownMenuPrimitive.RadioItem
|
|
136
136
|
data-slot="dropdown-menu-radio-item"
|
|
137
137
|
className={cn(
|
|
138
|
-
"focus:bg-accent focus:text-
|
|
138
|
+
"focus:bg-accent-subtle focus:text-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
139
139
|
className
|
|
140
140
|
)}
|
|
141
141
|
{...props}
|
|
142
142
|
>
|
|
143
143
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
144
144
|
<DropdownMenuPrimitive.ItemIndicator>
|
|
145
|
-
<CircleIcon className="size-2 fill-current" />
|
|
145
|
+
<CircleIcon className="size-2 fill-current text-accent" />
|
|
146
146
|
</DropdownMenuPrimitive.ItemIndicator>
|
|
147
147
|
</span>
|
|
148
148
|
{children}
|
|
@@ -207,7 +207,7 @@ function DropdownMenuSubTrigger({
|
|
|
207
207
|
data-slot="dropdown-menu-sub-trigger"
|
|
208
208
|
data-inset={inset}
|
|
209
209
|
className={cn(
|
|
210
|
-
"focus:bg-accent focus:text-
|
|
210
|
+
"focus:bg-accent-subtle focus:text-foreground data-[state=open]:bg-accent-subtle data-[state=open]:text-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
|
211
211
|
className
|
|
212
212
|
)}
|
|
213
213
|
{...props}
|
|
@@ -92,7 +92,7 @@ const InputGroupButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAtt
|
|
|
92
92
|
type={type}
|
|
93
93
|
data-slot="input-group-button"
|
|
94
94
|
className={cn(
|
|
95
|
-
"inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent hover:text-
|
|
95
|
+
"inline-flex h-7 min-w-7 cursor-pointer items-center justify-center gap-1 rounded-md px-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&:has(svg:only-child)]:w-7 [&:has(svg:only-child)]:px-0 [&_svg]:size-4",
|
|
96
96
|
className
|
|
97
97
|
)}
|
|
98
98
|
{...props}
|
|
@@ -81,7 +81,7 @@ export function MultiSelectMenu({
|
|
|
81
81
|
onCheckedChange={() => handleToggle(option.value)}
|
|
82
82
|
className={cn(
|
|
83
83
|
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none",
|
|
84
|
-
"data-[highlighted]:bg-accent data-[highlighted]:text-
|
|
84
|
+
"data-[highlighted]:bg-accent-subtle data-[highlighted]:text-foreground",
|
|
85
85
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
|
86
86
|
)}
|
|
87
87
|
>
|
|
@@ -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.
|
|
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
|
>
|
|
@@ -120,7 +120,7 @@ export function SearchableSelect({
|
|
|
120
120
|
e.stopPropagation();
|
|
121
121
|
onValueChange(undefined);
|
|
122
122
|
}}
|
|
123
|
-
className="absolute right-9 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-
|
|
123
|
+
className="absolute right-9 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
|
|
124
124
|
>
|
|
125
125
|
<X className="size-3.5" />
|
|
126
126
|
</button>
|
|
@@ -126,14 +126,14 @@ const SelectItem = React.forwardRef<
|
|
|
126
126
|
ref={ref}
|
|
127
127
|
data-slot="select-item"
|
|
128
128
|
className={cn(
|
|
129
|
-
"relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-
|
|
129
|
+
"relative flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent-subtle data-[highlighted]:text-foreground data-[state=checked]:font-semibold",
|
|
130
130
|
className
|
|
131
131
|
)}
|
|
132
132
|
{...props}
|
|
133
133
|
>
|
|
134
134
|
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
|
135
135
|
<SelectPrimitive.ItemIndicator>
|
|
136
|
-
<Check className="size-4" />
|
|
136
|
+
<Check className="size-4 text-accent" />
|
|
137
137
|
</SelectPrimitive.ItemIndicator>
|
|
138
138
|
</span>
|
|
139
139
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
@@ -46,7 +46,7 @@ const SelectionBar = React.forwardRef<HTMLDivElement, SelectionBarProps>(
|
|
|
46
46
|
type="button"
|
|
47
47
|
aria-label="Clear selection"
|
|
48
48
|
onClick={onClear}
|
|
49
|
-
className="inline-flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-
|
|
49
|
+
className="inline-flex size-6 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent-subtle hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
50
50
|
>
|
|
51
51
|
<X className="size-4" />
|
|
52
52
|
</button>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { toggleVariants } from "./toggle";
|
|
4
|
+
|
|
5
|
+
describe("toggleVariants", () => {
|
|
6
|
+
// White on a half-strength terracotta measured 2.2:1. Hover steps the
|
|
7
|
+
// surface instead, as Button's quiet variants do; the on-state keeps the fill.
|
|
8
|
+
it("default and outline tint on hover and keep the accent fill for the on-state", () => {
|
|
9
|
+
for (const variant of ["default", "outline"] as const) {
|
|
10
|
+
const classes = toggleVariants({ variant });
|
|
11
|
+
expect(classes).toContain("hover:bg-accent-subtle");
|
|
12
|
+
expect(classes).toContain("hover:text-foreground");
|
|
13
|
+
expect(classes).not.toContain("hover:bg-accent/50");
|
|
14
|
+
expect(classes).not.toContain("hover:text-accent-foreground");
|
|
15
|
+
expect(classes).toContain("data-[state=on]:bg-accent");
|
|
16
|
+
expect(classes).toContain("data-[state=on]:text-accent-foreground");
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -6,14 +6,20 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Hover is a step of the surface (`--accent-subtle`), as Button's quiet
|
|
11
|
+
* variants do. The shadcn default, `hover:bg-accent/50 hover:text-accent-foreground`,
|
|
12
|
+
* put white type on a half-strength terracotta: 2.2:1. The on-state's
|
|
13
|
+
* `data-[state=on]:` utilities sort after `hover:` and still win while on.
|
|
14
|
+
*/
|
|
9
15
|
const toggleVariants = cva(
|
|
10
|
-
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold hover:bg-accent
|
|
16
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold hover:bg-accent-subtle hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors aria-invalid:ring-2 aria-invalid:ring-destructive/40 whitespace-nowrap",
|
|
11
17
|
{
|
|
12
18
|
variants: {
|
|
13
19
|
variant: {
|
|
14
20
|
default: "bg-transparent",
|
|
15
21
|
outline:
|
|
16
|
-
"border border-border bg-transparent hover:bg-accent
|
|
22
|
+
"border border-border bg-transparent hover:bg-accent-subtle hover:text-foreground data-[state=on]:border-primary",
|
|
17
23
|
/**
|
|
18
24
|
* One cell of a segmented control: a raised card on the group's
|
|
19
25
|
* muted capsule when on, quiet type when off. The group
|
package/src/globals.css
CHANGED
|
@@ -130,14 +130,19 @@ h6 {
|
|
|
130
130
|
--ink-secondary: oklch(0.45 0 0);
|
|
131
131
|
--ink-tertiary: oklch(0.52 0 0);
|
|
132
132
|
--radius: 0.625rem;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
--sidebar
|
|
139
|
-
--sidebar-
|
|
140
|
-
--sidebar-
|
|
133
|
+
/* Chrome is the card surface — no third tone. The sidebar aliases the card
|
|
134
|
+
and the shared roles instead of carrying shadcn's factory greys (and, in
|
|
135
|
+
dark, a stray blue primary). `.dark` restates the same aliases: a custom
|
|
136
|
+
property resolves where it is declared, so a `.dark` placed below :root
|
|
137
|
+
would otherwise inherit the light values. */
|
|
138
|
+
--sidebar: var(--card);
|
|
139
|
+
--sidebar-foreground: var(--card-foreground);
|
|
140
|
+
--sidebar-primary: var(--primary);
|
|
141
|
+
--sidebar-primary-foreground: var(--primary-foreground);
|
|
142
|
+
--sidebar-accent: var(--accent-subtle);
|
|
143
|
+
--sidebar-accent-foreground: var(--foreground);
|
|
144
|
+
--sidebar-border: var(--border);
|
|
145
|
+
--sidebar-ring: var(--ring);
|
|
141
146
|
--success: oklch(0.48 0.17 150);
|
|
142
147
|
--success-foreground: oklch(0.985 0 0);
|
|
143
148
|
--warning: oklch(0.72 0.18 68);
|
|
@@ -330,8 +335,9 @@ h6 {
|
|
|
330
335
|
--accent: oklch(0.66 0.09 45);
|
|
331
336
|
--accent-foreground: oklch(0.155 0.01 45);
|
|
332
337
|
--accent-subtle: oklch(0.255 0.022 45);
|
|
333
|
-
/*
|
|
334
|
-
|
|
338
|
+
/* Dialed back from 0.68 to soften eye strain; 0.575 rather than 0.58 so
|
|
339
|
+
white on it clears AA (4.56:1, was 4.46:1). */
|
|
340
|
+
--destructive: oklch(0.575 0.17 25.7);
|
|
335
341
|
--destructive-foreground: oklch(0.985 0 0);
|
|
336
342
|
/** Lifted for text on `--card`, where the solid token measures ~3.8:1. */
|
|
337
343
|
--destructive-ink: oklch(0.72 0.15 25);
|
|
@@ -368,16 +374,19 @@ h6 {
|
|
|
368
374
|
in dark, elevation is carried by the scrim and by lightness, not by a
|
|
369
375
|
shadow nobody can see. */
|
|
370
376
|
--scrim-overlay: oklch(0.05 0.01 55 / 0.78);
|
|
371
|
-
--sidebar:
|
|
372
|
-
--sidebar-foreground:
|
|
373
|
-
--sidebar-primary:
|
|
374
|
-
--sidebar-primary-foreground:
|
|
375
|
-
--sidebar-accent:
|
|
376
|
-
--sidebar-accent-foreground:
|
|
377
|
-
--sidebar-border:
|
|
378
|
-
--sidebar-ring:
|
|
377
|
+
--sidebar: var(--card);
|
|
378
|
+
--sidebar-foreground: var(--card-foreground);
|
|
379
|
+
--sidebar-primary: var(--primary);
|
|
380
|
+
--sidebar-primary-foreground: var(--primary-foreground);
|
|
381
|
+
--sidebar-accent: var(--accent-subtle);
|
|
382
|
+
--sidebar-accent-foreground: var(--foreground);
|
|
383
|
+
--sidebar-border: var(--border);
|
|
384
|
+
--sidebar-ring: var(--ring);
|
|
379
385
|
--success: oklch(0.63 0.17 150);
|
|
380
|
-
--
|
|
386
|
+
/* Dark type on the fill, as --primary and --accent do in dark: white on
|
|
387
|
+
this green measured 3.12:1, and darkening the fill to reach 4.5:1 would
|
|
388
|
+
cost it a tenth of lightness and its presence on the card. 6.01:1. */
|
|
389
|
+
--success-foreground: oklch(0.155 0.01 45);
|
|
381
390
|
--warning: oklch(0.76 0.15 68);
|
|
382
391
|
--warning-foreground: oklch(0.18 0.04 45);
|
|
383
392
|
|