@bearmenu/ui 0.7.1 → 0.8.1
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-4JOV7FXW.js → chunk-2DEIXTBN.js} +1 -1
- package/dist/{chunk-UUJVXN2O.js → chunk-66MAPLXS.js} +3 -1
- package/dist/{chunk-Y5D5LP4A.js → chunk-7BWTYWM3.js} +14 -3
- package/dist/{chunk-ZFNOCP6W.js → chunk-H3BV4VAG.js} +15 -1
- package/dist/{chunk-TL4YU2O5.js → chunk-UKBNQO6Q.js} +2 -2
- package/dist/{chunk-UX6SWAKH.js → chunk-UZ3PCIE5.js} +119 -28
- package/dist/{chunk-YLNYXPIH.js → chunk-VBA45GLP.js} +1 -1
- package/dist/{chunk-A5ONAJCE.js → chunk-VG3PND3D.js} +1 -1
- package/dist/{chunk-ZBBUKXM5.js → chunk-X3WMRKOW.js} +1 -1
- package/dist/{chunk-HCM6LKM5.js → chunk-XNA2INVQ.js} +2 -2
- package/dist/{chunk-AV777R4G.js → chunk-YYGB4G4Q.js} +1 -1
- package/dist/components/accordion.js +1 -1
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/alert.js +1 -1
- package/dist/components/avatar.d.ts +10 -1
- package/dist/components/avatar.js +41 -14
- package/dist/components/badge.d.ts +11 -2
- package/dist/components/badge.js +1 -1
- package/dist/components/breadcrumb.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +3 -3
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +1 -1
- package/dist/components/carousel.js +1 -1
- package/dist/components/circular-progress.js +1 -1
- package/dist/components/combobox.js +3 -3
- package/dist/components/command.d.ts +15 -15
- package/dist/components/command.js +1 -1
- package/dist/components/currency-input.js +2 -2
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/date-input.js +1 -1
- package/dist/components/description-list.js +1 -1
- package/dist/components/dropdown-menu.js +1 -1
- package/dist/components/empty-state.js +2 -2
- package/dist/components/field.js +2 -2
- package/dist/components/filter-category-row.js +2 -2
- package/dist/components/form.js +1 -1
- package/dist/components/haptic-button.js +1 -1
- package/dist/components/input-group.js +1 -1
- package/dist/components/input.js +1 -1
- package/dist/components/item.d.ts +2 -2
- package/dist/components/item.js +1 -1
- package/dist/components/kbd.js +1 -1
- package/dist/components/label.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/multi-select-combobox.js +4 -4
- package/dist/components/pagination.js +1 -1
- package/dist/components/place-about-tab.d.ts +17 -1
- package/dist/components/place-about-tab.js +4 -4
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +7 -7
- package/dist/components/place-schedule-week.js +2 -2
- package/dist/components/popover.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/sheet.d.ts +23 -0
- package/dist/components/sheet.js +51 -14
- package/dist/components/sliding-panels.js +5 -1
- package/dist/components/sonner.js +2 -2
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/stepper.js +4 -1
- package/dist/components/table.js +1 -1
- package/dist/components/tabs.js +1 -1
- package/dist/components/tag-group.js +1 -1
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- package/dist/components/text.d.ts +21 -3
- package/dist/components/text.js +1 -1
- package/dist/components/toggle-group.js +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/lib/card-hover.d.ts +75 -0
- package/dist/lib/card-hover.js +18 -0
- package/package.json +1 -1
- package/src/components/accordion.tsx +1 -1
- package/src/components/avatar.stories.tsx +59 -26
- package/src/components/avatar.test.tsx +129 -0
- package/src/components/avatar.tsx +93 -37
- package/src/components/badge.tsx +15 -3
- package/src/components/breadcrumb.tsx +1 -1
- package/src/components/button.tsx +16 -1
- package/src/components/calendar.tsx +2 -2
- package/src/components/circular-progress.tsx +1 -1
- package/src/components/command.tsx +2 -2
- package/src/components/currency-input.tsx +1 -1
- package/src/components/description-list.tsx +1 -1
- package/src/components/dropdown-menu.tsx +1 -1
- package/src/components/field.tsx +1 -1
- package/src/components/input-group.tsx +1 -1
- package/src/components/input.tsx +1 -1
- package/src/components/item.tsx +1 -1
- package/src/components/kbd.tsx +1 -1
- package/src/components/label.tsx +1 -1
- package/src/components/place-about-tab.tsx +131 -18
- package/src/components/place-card.tsx +1 -1
- package/src/components/place-details-mobile.tsx +2 -2
- package/src/components/popover.tsx +9 -1
- package/src/components/select.tsx +2 -2
- package/src/components/selection-bar.tsx +1 -1
- package/src/components/sheet.tsx +72 -12
- package/src/components/sliding-panels.stories.tsx +70 -0
- package/src/components/sliding-panels.test.tsx +116 -0
- package/src/components/sliding-panels.tsx +14 -0
- package/src/components/sonner.tsx +2 -2
- package/src/components/stepper.tsx +4 -1
- package/src/components/table.tsx +1 -1
- package/src/components/tabs.tsx +1 -1
- package/src/components/tag-group.tsx +1 -1
- package/src/components/tag.tsx +1 -1
- package/src/components/text.test.tsx +37 -3
- package/src/components/text.tsx +32 -3
- package/src/components/toggle.tsx +1 -1
- package/src/globals.css +53 -11
- package/src/lib/card-hover.ts +87 -0
- package/dist/chunk-EBAMCZDW.js +0 -112
- package/dist/chunk-VQNDZVI7.js +0 -106
- package/dist/components/toast.d.ts +0 -18
- package/dist/components/toast.js +0 -3
- package/dist/components/toaster.d.ts +0 -5
- package/dist/components/toaster.js +0 -25
- package/dist/components/use-toast.d.ts +0 -42
- package/dist/components/use-toast.js +0 -2
- package/src/components/toast.stories.tsx +0 -63
- package/src/components/toast.tsx +0 -130
- package/src/components/toaster.tsx +0 -31
- package/src/components/use-toast.ts +0 -180
|
@@ -83,7 +83,7 @@ export function PlaceDetailsMobile({
|
|
|
83
83
|
<div className="rounded-[22px] overflow-hidden relative">
|
|
84
84
|
<div className="h-44 flex items-center justify-center relative overflow-hidden">
|
|
85
85
|
{/* Status badge — bottom right */}
|
|
86
|
-
<div className="absolute bottom-2 right-2 z-20 flex items-center gap-1.5 bg-surface border border-border px-2.5 h-7 rounded-[12px] text-[11px] font-
|
|
86
|
+
<div className="absolute bottom-2 right-2 z-20 flex items-center gap-1.5 bg-surface border border-border px-2.5 h-7 rounded-[12px] text-[11px] font-semibold text-foreground">
|
|
87
87
|
<span
|
|
88
88
|
className={cn(
|
|
89
89
|
"w-1.5 h-1.5 rounded-full flex-shrink-0",
|
|
@@ -235,7 +235,7 @@ function MobileTabPlaceholder({
|
|
|
235
235
|
return (
|
|
236
236
|
<div
|
|
237
237
|
className={cn(
|
|
238
|
-
"flex flex-1 min-w-0 items-center justify-center gap-1 px-1 py-2.5 text-[12px] font-
|
|
238
|
+
"flex flex-1 min-w-0 items-center justify-center gap-1 px-1 py-2.5 text-[12px] font-semibold border-b-2 -mb-px",
|
|
239
239
|
active
|
|
240
240
|
? "border-primary text-foreground"
|
|
241
241
|
: "border-transparent text-muted-foreground"
|
|
@@ -12,15 +12,23 @@ const PopoverClose = PopoverPrimitive.Close;
|
|
|
12
12
|
const PopoverContent = React.forwardRef<
|
|
13
13
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
14
14
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
15
|
-
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
|
15
|
+
>(({ className, align = "center", sideOffset = 4, collisionPadding = 12, ...props }, ref) => (
|
|
16
16
|
<PopoverPrimitive.Portal>
|
|
17
17
|
<PopoverPrimitive.Content
|
|
18
18
|
ref={ref}
|
|
19
19
|
data-slot="popover-content"
|
|
20
20
|
align={align}
|
|
21
21
|
sideOffset={sideOffset}
|
|
22
|
+
// Radix flips the side to avoid a collision, but a popover TALLER than the
|
|
23
|
+
// space on either side still overflows — and it overflows upward, off the
|
|
24
|
+
// top of the viewport, where the content cannot be scrolled back into
|
|
25
|
+
// view. `collisionPadding` keeps it off the edges; capping to Radix's
|
|
26
|
+
// measured available height and scrolling makes tall content fit rather
|
|
27
|
+
// than escape.
|
|
28
|
+
collisionPadding={collisionPadding}
|
|
22
29
|
className={cn(
|
|
23
30
|
"z-50 w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-xl outline-none",
|
|
31
|
+
"max-h-[var(--radix-popover-content-available-height)] overflow-y-auto overscroll-contain",
|
|
24
32
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
25
33
|
className
|
|
26
34
|
)}
|
|
@@ -11,7 +11,7 @@ const SelectGroup = SelectPrimitive.Group;
|
|
|
11
11
|
const SelectValue = SelectPrimitive.Value;
|
|
12
12
|
|
|
13
13
|
const selectTriggerVariants = cva(
|
|
14
|
-
"flex w-full items-center justify-between rounded-xl bg-background border border-border text-sm font-
|
|
14
|
+
"flex w-full items-center justify-between rounded-xl bg-background border border-border text-sm font-semibold text-foreground transition-colors focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1 hover:border-foreground/30 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive/40",
|
|
15
15
|
{
|
|
16
16
|
variants: {
|
|
17
17
|
size: {
|
|
@@ -126,7 +126,7 @@ 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-accent-foreground data-[state=checked]:font-
|
|
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-accent-foreground data-[state=checked]:font-semibold",
|
|
130
130
|
className
|
|
131
131
|
)}
|
|
132
132
|
{...props}
|
|
@@ -40,7 +40,7 @@ const SelectionBar = React.forwardRef<HTMLDivElement, SelectionBarProps>(
|
|
|
40
40
|
)}
|
|
41
41
|
{...props}
|
|
42
42
|
>
|
|
43
|
-
<div className="flex items-center gap-2 text-sm font-
|
|
43
|
+
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
44
44
|
{onClear && (
|
|
45
45
|
<button
|
|
46
46
|
type="button"
|
package/src/components/sheet.tsx
CHANGED
|
@@ -29,21 +29,66 @@ const SheetOverlay = React.forwardRef<
|
|
|
29
29
|
));
|
|
30
30
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* `attachment` controls whether the panel is welded to the viewport edge or
|
|
34
|
+
* floats clear of it.
|
|
35
|
+
*
|
|
36
|
+
* - `edge` (default) — the classic sheet: full bleed against its side, only the
|
|
37
|
+
* two corners facing inward rounded. Every existing consumer gets this, so
|
|
38
|
+
* adding `inset` changes nothing already shipped.
|
|
39
|
+
* - `inset` — a floating panel: held off all four edges, rounded on every
|
|
40
|
+
* corner, page visible around it. Reads as a surface laid ON the page rather
|
|
41
|
+
* than one grafted to it, which is what detail surfaces want — the content
|
|
42
|
+
* behind stays legible as context instead of being cropped away.
|
|
43
|
+
*
|
|
44
|
+
* `inset` deliberately drops the border. At an edge the border is the seam
|
|
45
|
+
* between panel and viewport and has work to do; floating, the shadow already
|
|
46
|
+
* separates the two and a border only draws a hard line around the radius.
|
|
47
|
+
*/
|
|
32
48
|
const sheetVariants = cva(
|
|
33
|
-
"fixed z-[1000] gap-4 bg-background border-border
|
|
49
|
+
"fixed z-[1000] gap-4 bg-background border-border shadow-2xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-[var(--duration-deliberate)] data-[state=closed]:duration-[var(--duration-exit-standard)] data-[state=open]:ease-[var(--ease-enter)] data-[state=closed]:ease-[var(--ease-exit)]",
|
|
34
50
|
{
|
|
35
51
|
variants: {
|
|
36
52
|
side: {
|
|
37
|
-
top: "inset-x-0 top-0
|
|
53
|
+
top: "inset-x-0 top-0 data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
38
54
|
bottom:
|
|
39
|
-
"inset-x-0 bottom-0
|
|
40
|
-
left: "inset-y-0 left-0 h-full w-3/4
|
|
55
|
+
"inset-x-0 bottom-0 data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
56
|
+
left: "inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
|
41
57
|
right:
|
|
42
|
-
"inset-y-0 right-0 h-full w-3/4
|
|
58
|
+
"inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
|
59
|
+
},
|
|
60
|
+
attachment: {
|
|
61
|
+
edge: "p-6",
|
|
62
|
+
// Padding is the caller's business here: a detail surface runs a gallery
|
|
63
|
+
// and a sticky action bar to the panel edge, and a fixed p-6 would inset
|
|
64
|
+
// both. `edge` keeps its p-6 so nothing already shipped shifts.
|
|
65
|
+
inset: "rounded-2xl overflow-hidden",
|
|
43
66
|
},
|
|
44
67
|
},
|
|
68
|
+
compoundVariants: [
|
|
69
|
+
// `edge` restates exactly what each side carried before `attachment`
|
|
70
|
+
// existed — the seam border and the two inward-facing corners. Kept as
|
|
71
|
+
// compounds rather than left on `side` so `inset` is not fighting them
|
|
72
|
+
// through class-merge order.
|
|
73
|
+
// `sm:max-w-sm` lives here, not on `side`: an inset panel is sized by its
|
|
74
|
+
// caller, and a responsive cap left on `side` cannot be overridden from
|
|
75
|
+
// className — tailwind-merge scopes `sm:max-w-*` and `max-w-*` to
|
|
76
|
+
// different groups, so `max-w-none` loses to it at every width ≥ sm.
|
|
77
|
+
{ side: "top", attachment: "edge", class: "border-b" },
|
|
78
|
+
{ side: "bottom", attachment: "edge", class: "border-t rounded-t-2xl" },
|
|
79
|
+
{ side: "left", attachment: "edge", class: "border-r rounded-r-2xl sm:max-w-sm" },
|
|
80
|
+
{ side: "right", attachment: "edge", class: "border-l rounded-l-2xl sm:max-w-sm" },
|
|
81
|
+
// The inset offsets live here rather than on `attachment` because each
|
|
82
|
+
// side has to give up its own edge: a right-side panel clears top, right
|
|
83
|
+
// and bottom but is still anchored to the right.
|
|
84
|
+
{ side: "right", attachment: "inset", class: "inset-y-3 right-3 h-auto" },
|
|
85
|
+
{ side: "left", attachment: "inset", class: "inset-y-3 left-3 h-auto" },
|
|
86
|
+
{ side: "top", attachment: "inset", class: "inset-x-3 top-3" },
|
|
87
|
+
{ side: "bottom", attachment: "inset", class: "inset-x-3 bottom-3" },
|
|
88
|
+
],
|
|
45
89
|
defaultVariants: {
|
|
46
90
|
side: "right",
|
|
91
|
+
attachment: "edge",
|
|
47
92
|
},
|
|
48
93
|
}
|
|
49
94
|
);
|
|
@@ -51,20 +96,35 @@ const sheetVariants = cva(
|
|
|
51
96
|
interface SheetContentProps
|
|
52
97
|
extends
|
|
53
98
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
|
54
|
-
VariantProps<typeof sheetVariants> {
|
|
99
|
+
VariantProps<typeof sheetVariants> {
|
|
100
|
+
/**
|
|
101
|
+
* Suppress the built-in floating close button. Use when the panel renders its
|
|
102
|
+
* own header row with a close control, which would otherwise stack two close
|
|
103
|
+
* buttons in the same corner.
|
|
104
|
+
*/
|
|
105
|
+
hideClose?: boolean;
|
|
106
|
+
}
|
|
55
107
|
|
|
56
108
|
const SheetContent = React.forwardRef<
|
|
57
109
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
|
58
110
|
SheetContentProps
|
|
59
|
-
>(({ side = "right", className, children, ...props }, ref) => (
|
|
111
|
+
>(({ side = "right", attachment = "edge", className, children, hideClose, ...props }, ref) => (
|
|
60
112
|
<SheetPortal>
|
|
61
113
|
<SheetOverlay />
|
|
62
|
-
<SheetPrimitive.Content
|
|
114
|
+
<SheetPrimitive.Content
|
|
115
|
+
ref={ref}
|
|
116
|
+
className={cn(sheetVariants({ side, attachment }), className)}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
63
119
|
{children}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
120
|
+
{/* Surfaces that run their own header own the close control there, and
|
|
121
|
+
would otherwise stack two of them in the same corner. */}
|
|
122
|
+
{!hideClose && (
|
|
123
|
+
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-lg opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary glass-hover p-2">
|
|
124
|
+
<X className="h-4 w-4" />
|
|
125
|
+
<span className="sr-only">Close</span>
|
|
126
|
+
</SheetPrimitive.Close>
|
|
127
|
+
)}
|
|
68
128
|
</SheetPrimitive.Content>
|
|
69
129
|
</SheetPortal>
|
|
70
130
|
));
|
|
@@ -66,3 +66,73 @@ function SlidingPanelsDemo() {
|
|
|
66
66
|
export const Default: Story = {
|
|
67
67
|
render: () => <SlidingPanelsDemo />,
|
|
68
68
|
};
|
|
69
|
+
|
|
70
|
+
function KeyboardSafetyDemo() {
|
|
71
|
+
const [currentPanel, setCurrentPanel] = useState("list");
|
|
72
|
+
|
|
73
|
+
const panels = [
|
|
74
|
+
{
|
|
75
|
+
id: "list",
|
|
76
|
+
content: (
|
|
77
|
+
<div className="space-y-2 p-4">
|
|
78
|
+
<h3 className="text-lg font-semibold">Items</h3>
|
|
79
|
+
{["Espresso", "Cortado", "Flat white", "Cold brew"].map((item) => (
|
|
80
|
+
<Button
|
|
81
|
+
key={item}
|
|
82
|
+
variant="outline"
|
|
83
|
+
className="w-full justify-start"
|
|
84
|
+
onClick={() => setCurrentPanel("detail")}
|
|
85
|
+
>
|
|
86
|
+
{item}
|
|
87
|
+
</Button>
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "detail",
|
|
94
|
+
parentId: "list",
|
|
95
|
+
showBackButton: true,
|
|
96
|
+
backButtonLabel: "Assign",
|
|
97
|
+
content: (
|
|
98
|
+
<div className="space-y-3 p-4">
|
|
99
|
+
<p className="text-muted-foreground text-sm">
|
|
100
|
+
Tab through this panel — focus never reaches the list behind it, because inactive
|
|
101
|
+
panels are <code>inert</code>.
|
|
102
|
+
</p>
|
|
103
|
+
<div className="flex gap-2">
|
|
104
|
+
<Button variant="outline">Split</Button>
|
|
105
|
+
<Button variant="outline">Assign</Button>
|
|
106
|
+
<Button onClick={() => setCurrentPanel("list")}>Done</Button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
),
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="w-[400px] space-y-4">
|
|
115
|
+
<p className="text-muted-foreground text-sm">
|
|
116
|
+
Open the detail panel, then press Tab repeatedly: only the visible panel is reachable.
|
|
117
|
+
</p>
|
|
118
|
+
<div className="rounded-lg border overflow-hidden">
|
|
119
|
+
<SlidingPanels
|
|
120
|
+
panels={panels}
|
|
121
|
+
currentPanel={currentPanel}
|
|
122
|
+
onPanelChange={setCurrentPanel}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
<Button variant="ghost">Control after the panels</Button>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Hidden panels stay mounted so the slide can animate, but they carry `inert`
|
|
132
|
+
* — their controls are out of the tab order, untargetable by pointer, and
|
|
133
|
+
* skipped by find-in-page. Tab from the top of this story: focus goes through
|
|
134
|
+
* the active panel and straight on to "Control after the panels".
|
|
135
|
+
*/
|
|
136
|
+
export const HiddenPanelsAreNotTabbable: Story = {
|
|
137
|
+
render: () => <KeyboardSafetyDemo />,
|
|
138
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { SlidingPanels, type Panel } from "./sliding-panels";
|
|
6
|
+
|
|
7
|
+
const basePanels: Panel[] = [
|
|
8
|
+
{ id: "one", content: <button type="button">Action one</button> },
|
|
9
|
+
{
|
|
10
|
+
id: "two",
|
|
11
|
+
showBackButton: true,
|
|
12
|
+
backButtonLabel: "Two",
|
|
13
|
+
content: <button type="button">Action two</button>,
|
|
14
|
+
},
|
|
15
|
+
{ id: "three", content: <button type="button">Action three</button> },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const panelWrappers = () =>
|
|
19
|
+
Array.from(document.querySelectorAll<HTMLElement>('[data-slot="sliding-panel"]'));
|
|
20
|
+
|
|
21
|
+
// The back button is icon-only, so it is the one button in the active panel
|
|
22
|
+
// with no text content.
|
|
23
|
+
const backButton = () =>
|
|
24
|
+
screen.getAllByRole("button").filter((button) => button.textContent === "")[0];
|
|
25
|
+
|
|
26
|
+
function Controlled({ initial = "one" }: { initial?: string }) {
|
|
27
|
+
const [current, setCurrent] = useState(initial);
|
|
28
|
+
return (
|
|
29
|
+
<SlidingPanels panels={basePanels} currentPanel={current} onPanelChange={setCurrent} />
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("SlidingPanels", () => {
|
|
34
|
+
it("renders every panel so transitions can animate", () => {
|
|
35
|
+
render(<Controlled />);
|
|
36
|
+
expect(panelWrappers()).toHaveLength(3);
|
|
37
|
+
expect(screen.getByText("Action three")).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("marks inactive panels inert and leaves the active one interactive", () => {
|
|
41
|
+
render(<Controlled initial="two" />);
|
|
42
|
+
const [one, two, three] = panelWrappers();
|
|
43
|
+
expect(one).toHaveAttribute("inert");
|
|
44
|
+
expect(two).not.toHaveAttribute("inert");
|
|
45
|
+
expect(three).toHaveAttribute("inert");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps aria-hidden in sync with inert for engines that ignore inert", () => {
|
|
49
|
+
render(<Controlled initial="two" />);
|
|
50
|
+
const [one, two] = panelWrappers();
|
|
51
|
+
expect(one).toHaveAttribute("aria-hidden", "true");
|
|
52
|
+
expect(two).not.toHaveAttribute("aria-hidden", "true");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("exposes active state via data-state", () => {
|
|
56
|
+
render(<Controlled initial="three" />);
|
|
57
|
+
expect(panelWrappers().map((el) => el.dataset.state)).toEqual([
|
|
58
|
+
"inactive",
|
|
59
|
+
"inactive",
|
|
60
|
+
"active",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("moves inert onto the previously active panel after navigation", async () => {
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
render(<Controlled initial="two" />);
|
|
67
|
+
await user.click(backButton());
|
|
68
|
+
|
|
69
|
+
const [one, two] = panelWrappers();
|
|
70
|
+
expect(one).not.toHaveAttribute("inert");
|
|
71
|
+
expect(two).toHaveAttribute("inert");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps the transform transition classes on inert panels", () => {
|
|
75
|
+
render(<Controlled initial="two" />);
|
|
76
|
+
const [one, two, three] = panelWrappers();
|
|
77
|
+
expect(one.className).toContain("transition-transform");
|
|
78
|
+
expect(one.className).toContain("-translate-x-[calc(100%+8px)]");
|
|
79
|
+
expect(two.className).toContain("translate-x-0");
|
|
80
|
+
expect(three.className).toContain("translate-x-[calc(100%+8px)]");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("never leaves the active panel inert in tree mode", () => {
|
|
84
|
+
const treePanels: Panel[] = [
|
|
85
|
+
{ id: "root", content: <button type="button">Root</button> },
|
|
86
|
+
{ id: "child", parentId: "root", content: <button type="button">Child</button> },
|
|
87
|
+
{ id: "sibling", parentId: "root", content: <button type="button">Sibling</button> },
|
|
88
|
+
];
|
|
89
|
+
render(
|
|
90
|
+
<SlidingPanels panels={treePanels} currentPanel="child" onPanelChange={vi.fn()} />
|
|
91
|
+
);
|
|
92
|
+
const [root, child, sibling] = panelWrappers();
|
|
93
|
+
expect(root).toHaveAttribute("inert");
|
|
94
|
+
expect(child).not.toHaveAttribute("inert");
|
|
95
|
+
expect(sibling).toHaveAttribute("inert");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("navigates back to the parent panel from the back button", async () => {
|
|
99
|
+
const user = userEvent.setup();
|
|
100
|
+
const onPanelChange = vi.fn();
|
|
101
|
+
const treePanels: Panel[] = [
|
|
102
|
+
{ id: "root", content: <button type="button">Root</button> },
|
|
103
|
+
{
|
|
104
|
+
id: "child",
|
|
105
|
+
parentId: "root",
|
|
106
|
+
showBackButton: true,
|
|
107
|
+
content: <button type="button">Child</button>,
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
render(
|
|
111
|
+
<SlidingPanels panels={treePanels} currentPanel="child" onPanelChange={onPanelChange} />
|
|
112
|
+
);
|
|
113
|
+
await user.click(backButton());
|
|
114
|
+
expect(onPanelChange).toHaveBeenCalledWith("root");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -82,6 +82,7 @@ export const SlidingPanels = ({
|
|
|
82
82
|
|
|
83
83
|
return (
|
|
84
84
|
<div
|
|
85
|
+
data-slot="sliding-panels"
|
|
85
86
|
className={cn(
|
|
86
87
|
"relative overflow-clip transition-[height] duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
|
|
87
88
|
className
|
|
@@ -101,12 +102,25 @@ export const SlidingPanels = ({
|
|
|
101
102
|
ref={(el) => {
|
|
102
103
|
panelRefs.current[index] = el;
|
|
103
104
|
}}
|
|
105
|
+
data-slot="sliding-panel"
|
|
106
|
+
data-state={isActive ? "active" : "inactive"}
|
|
104
107
|
className={cn(
|
|
105
108
|
"absolute top-0 left-0 w-full transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]",
|
|
106
109
|
isActive && "translate-x-0",
|
|
107
110
|
isLeft && "-translate-x-[calc(100%+8px)]",
|
|
108
111
|
isRight && "translate-x-[calc(100%+8px)]"
|
|
109
112
|
)}
|
|
113
|
+
// Off-screen panels stay mounted (so the slide can animate), which
|
|
114
|
+
// used to leave their controls in the tab order behind the active
|
|
115
|
+
// panel. `inert` takes them out of tab order, pointer targeting and
|
|
116
|
+
// find-in-page without touching layout or the transform transition.
|
|
117
|
+
// Written as `true | undefined` rather than a raw boolean: HTML
|
|
118
|
+
// treats *any* value as inert, so a literal `inert={false}` would
|
|
119
|
+
// disable the active panel on renderers that stringify booleans.
|
|
120
|
+
// `aria-hidden` is kept alongside it: modern engines derive that
|
|
121
|
+
// semantic from `inert` itself (no double announcement), but older
|
|
122
|
+
// ones that ignore `inert` still need it to hide the subtree.
|
|
123
|
+
inert={!isActive || undefined}
|
|
110
124
|
aria-hidden={!isActive}
|
|
111
125
|
>
|
|
112
126
|
{panel.showBackButton && (
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
CircleAlertIcon,
|
|
4
5
|
CircleCheckIcon,
|
|
5
6
|
InfoIcon,
|
|
6
7
|
Loader2Icon,
|
|
7
|
-
OctagonXIcon,
|
|
8
8
|
TriangleAlertIcon,
|
|
9
9
|
} from "lucide-react";
|
|
10
10
|
import { useTheme } from "next-themes";
|
|
@@ -21,7 +21,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|
|
21
21
|
success: <CircleCheckIcon className="size-4" />,
|
|
22
22
|
info: <InfoIcon className="size-4" />,
|
|
23
23
|
warning: <TriangleAlertIcon className="size-4" />,
|
|
24
|
-
error: <
|
|
24
|
+
error: <CircleAlertIcon className="size-4" />,
|
|
25
25
|
loading: <Loader2Icon className="size-4 animate-spin" />,
|
|
26
26
|
}}
|
|
27
27
|
style={
|
|
@@ -76,8 +76,11 @@ const Stepper = React.forwardRef<HTMLElement, StepperProps>(
|
|
|
76
76
|
<span
|
|
77
77
|
className={cn(
|
|
78
78
|
"whitespace-nowrap transition-colors",
|
|
79
|
+
// current and complete share a weight; COLOUR carries the
|
|
80
|
+
// state (full → 80% → 40%). `complete` was 500, the one
|
|
81
|
+
// step that is not in the 400/600/700 system.
|
|
79
82
|
status === "current" && "font-semibold text-foreground",
|
|
80
|
-
status === "complete" && "font-
|
|
83
|
+
status === "complete" && "font-semibold text-foreground/80",
|
|
81
84
|
status === "upcoming" && "text-foreground/40",
|
|
82
85
|
)}
|
|
83
86
|
>
|
package/src/components/table.tsx
CHANGED
|
@@ -56,7 +56,7 @@ const TableFooter = React.forwardRef<
|
|
|
56
56
|
<tfoot
|
|
57
57
|
ref={ref}
|
|
58
58
|
data-slot="table-footer"
|
|
59
|
-
className={cn("border-t border-border bg-muted/50 font-
|
|
59
|
+
className={cn("border-t border-border bg-muted/50 font-semibold [&>tr]:last:border-b-0", className)}
|
|
60
60
|
{...props}
|
|
61
61
|
/>
|
|
62
62
|
));
|
package/src/components/tabs.tsx
CHANGED
|
@@ -115,7 +115,7 @@ const TabsTrigger = React.forwardRef<
|
|
|
115
115
|
ref={composedRef}
|
|
116
116
|
data-slot="tabs-trigger"
|
|
117
117
|
className={cn(
|
|
118
|
-
"relative inline-flex items-center justify-center whitespace-nowrap text-sm font-
|
|
118
|
+
"relative inline-flex items-center justify-center whitespace-nowrap text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
119
119
|
variant === "pill" &&
|
|
120
120
|
"rounded-xl px-4 py-1.5 text-muted-foreground hover:text-foreground data-[state=active]:text-foreground",
|
|
121
121
|
variant === "underline" &&
|
|
@@ -18,7 +18,7 @@ const TagGroup = React.forwardRef<
|
|
|
18
18
|
{...props}
|
|
19
19
|
>
|
|
20
20
|
{label && !srLabel && (
|
|
21
|
-
<span className="mr-1 text-xs font-
|
|
21
|
+
<span className="mr-1 text-xs font-semibold text-muted-foreground">{label}</span>
|
|
22
22
|
)}
|
|
23
23
|
{children}
|
|
24
24
|
</div>
|
package/src/components/tag.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
7
|
|
|
8
8
|
const tagVariants = cva(
|
|
9
|
-
"inline-flex items-center gap-1 rounded-full border text-xs font-
|
|
9
|
+
"inline-flex items-center gap-1 rounded-full border text-xs font-semibold transition-colors",
|
|
10
10
|
{
|
|
11
11
|
variants: {
|
|
12
12
|
variant: {
|
|
@@ -2,6 +2,24 @@ import { describe, it, expect } from "vitest";
|
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import { Text, textVariants } from "./text";
|
|
4
4
|
|
|
5
|
+
// Every variant in the scale. Kept explicit (not derived from the cva config)
|
|
6
|
+
// so adding a variant forces a deliberate edit here — the point of a CLOSED
|
|
7
|
+
// set is that growing it is a decision, not a side effect.
|
|
8
|
+
const VARIANTS = [
|
|
9
|
+
"display",
|
|
10
|
+
"h1",
|
|
11
|
+
"h2",
|
|
12
|
+
"h3",
|
|
13
|
+
"h4",
|
|
14
|
+
"body-lg",
|
|
15
|
+
"body",
|
|
16
|
+
"body-sm",
|
|
17
|
+
"small",
|
|
18
|
+
"caption",
|
|
19
|
+
"label",
|
|
20
|
+
"overline",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
5
23
|
// ─── textVariants utility ────────────────────────────────────────────────────
|
|
6
24
|
|
|
7
25
|
describe("textVariants", () => {
|
|
@@ -29,17 +47,33 @@ describe("textVariants", () => {
|
|
|
29
47
|
expect(textVariants({ variant: "body" })).toContain("text-base");
|
|
30
48
|
expect(textVariants({ variant: "body-sm" })).toContain("text-sm");
|
|
31
49
|
expect(textVariants({ variant: "caption" })).toContain("text-xs");
|
|
32
|
-
expect(textVariants({ variant: "label" })).toContain("font-
|
|
50
|
+
expect(textVariants({ variant: "label" })).toContain("font-semibold");
|
|
33
51
|
expect(textVariants({ variant: "overline" })).toContain("uppercase");
|
|
34
52
|
});
|
|
35
53
|
|
|
36
|
-
it("maps weight props", () => {
|
|
54
|
+
it("maps weight props onto the 3-weight scale", () => {
|
|
37
55
|
expect(textVariants({ weight: "normal" })).toContain("font-normal");
|
|
38
|
-
expect(textVariants({ weight: "medium" })).toContain("font-medium");
|
|
39
56
|
expect(textVariants({ weight: "semibold" })).toContain("font-semibold");
|
|
40
57
|
expect(textVariants({ weight: "bold" })).toContain("font-bold");
|
|
41
58
|
});
|
|
42
59
|
|
|
60
|
+
// The scale is 400/600/700. `medium` survives as a deprecated alias so the
|
|
61
|
+
// existing call sites keep compiling, but it must never emit 500 again.
|
|
62
|
+
it("never emits weight 500", () => {
|
|
63
|
+
expect(textVariants({ weight: "medium" })).toContain("font-semibold");
|
|
64
|
+
for (const variant of VARIANTS) {
|
|
65
|
+
expect(textVariants({ variant })).not.toContain("font-medium");
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// `body-lg` and `small` are deprecated duplicates. They must stay
|
|
70
|
+
// byte-compatible with the role that replaced them, or the two apps drift
|
|
71
|
+
// back onto two names for one size.
|
|
72
|
+
it("keeps deprecated aliases identical to their replacements", () => {
|
|
73
|
+
expect(textVariants({ variant: "body-lg" })).toBe(textVariants({ variant: "body" }));
|
|
74
|
+
expect(textVariants({ variant: "small" })).toContain("text-sm");
|
|
75
|
+
});
|
|
76
|
+
|
|
43
77
|
it("maps leading props", () => {
|
|
44
78
|
expect(textVariants({ leading: "none" })).toContain("leading-none");
|
|
45
79
|
expect(textVariants({ leading: "tight" })).toContain("leading-tight");
|
package/src/components/text.tsx
CHANGED
|
@@ -2,6 +2,24 @@ import * as React from "react";
|
|
|
2
2
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The type scale is a CLOSED set of roles. Seven carry every surface:
|
|
7
|
+
*
|
|
8
|
+
* h1 page title 30/700
|
|
9
|
+
* h3 section heading 20/600
|
|
10
|
+
* body running prose 16/400 relaxed
|
|
11
|
+
* label row / item title 14/600
|
|
12
|
+
* body-sm secondary fact 14/400
|
|
13
|
+
* overline eyebrow 12/600 uppercase
|
|
14
|
+
* caption fine print 12/400
|
|
15
|
+
*
|
|
16
|
+
* Emphasis, colour and tabular figures are ORTHOGONAL modifiers (`weight`,
|
|
17
|
+
* `color`, className) — they must never be expressed by changing SIZE. A
|
|
18
|
+
* paragraph that steps 16 → 14 mid-sentence to mark its lead reads as two
|
|
19
|
+
* paragraphs jammed together; that is what the modifiers exist for.
|
|
20
|
+
*
|
|
21
|
+
* Weights are 400 / 600 / 700. 500 is not in the system.
|
|
22
|
+
*/
|
|
5
23
|
const textVariants = cva("", {
|
|
6
24
|
variants: {
|
|
7
25
|
variant: {
|
|
@@ -10,12 +28,19 @@ const textVariants = cva("", {
|
|
|
10
28
|
h2: "text-2xl font-semibold tracking-tight",
|
|
11
29
|
h3: "text-xl font-semibold",
|
|
12
30
|
h4: "text-lg font-semibold leading-none tracking-tight",
|
|
31
|
+
/** @deprecated Now identical to `body`. Use `body`. */
|
|
13
32
|
"body-lg": "text-base leading-relaxed",
|
|
14
|
-
body:
|
|
33
|
+
// `leading-relaxed`, absorbing what `body-lg` was: 16px is the prose size
|
|
34
|
+
// and prose wants relaxed leading. Two variants differing only in
|
|
35
|
+
// line-height forced every author to choose between two names for one
|
|
36
|
+
// role, and the two apps duly diverged onto different ones.
|
|
37
|
+
body: "text-base leading-relaxed",
|
|
15
38
|
"body-sm": "text-sm leading-relaxed",
|
|
39
|
+
/** @deprecated Use `body-sm` + `color="muted"` — same size, second name. */
|
|
16
40
|
small: "text-sm text-muted-foreground",
|
|
17
41
|
caption: "text-xs leading-normal text-muted-foreground",
|
|
18
|
-
|
|
42
|
+
// 600, not 500 — see the weight scale below.
|
|
43
|
+
label: "text-sm font-semibold",
|
|
19
44
|
overline: "text-xs font-semibold uppercase tracking-wider",
|
|
20
45
|
},
|
|
21
46
|
color: {
|
|
@@ -26,9 +51,13 @@ const textVariants = cva("", {
|
|
|
26
51
|
"primary-foreground": "text-primary-foreground",
|
|
27
52
|
inherit: "",
|
|
28
53
|
},
|
|
54
|
+
// Three weights: 400 / 600 / 700. A fourth step is not a hierarchy level,
|
|
55
|
+
// it is noise — 500 against 600 is indistinguishable at body sizes while
|
|
56
|
+
// still counting as a separate treatment when you audit a screen.
|
|
29
57
|
weight: {
|
|
30
58
|
normal: "font-normal",
|
|
31
|
-
|
|
59
|
+
/** @deprecated Alias for `semibold`. 500 is not in the 3-weight system. */
|
|
60
|
+
medium: "font-semibold",
|
|
32
61
|
semibold: "font-semibold",
|
|
33
62
|
bold: "font-bold",
|
|
34
63
|
},
|
|
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
8
|
|
|
9
9
|
const toggleVariants = cva(
|
|
10
|
-
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-
|
|
10
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-semibold hover:bg-accent/50 hover:text-accent-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
11
|
{
|
|
12
12
|
variants: {
|
|
13
13
|
variant: {
|