@bearmenu/ui 0.7.1 → 0.8.2
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.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.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.js +1 -1
- package/dist/components/currency-input.js +2 -2
- package/dist/components/date-input.js +1 -1
- package/dist/components/description-list.js +1 -1
- package/dist/components/drawer.js +5 -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.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/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.js +1 -1
- package/dist/components/text.d.ts +18 -0
- 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/drawer.tsx +5 -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
|
@@ -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: {
|
package/src/globals.css
CHANGED
|
@@ -89,6 +89,26 @@ h6 {
|
|
|
89
89
|
--shadow-alpha-xl: 0.18;
|
|
90
90
|
--shadow-alpha-2xl: 0.25;
|
|
91
91
|
|
|
92
|
+
/* ─── Card cover shadows (companion to lib/card-hover.ts) ─────────────
|
|
93
|
+
The warm lift under a card's cover region, and its deepened hover state.
|
|
94
|
+
Deliberately separate from the --shadow-alpha-* ladder above: that is a
|
|
95
|
+
neutral black ramp for surfaces, this is the warm brown cast that only
|
|
96
|
+
reads correctly under photography and generated cover art.
|
|
97
|
+
|
|
98
|
+
--shadow-menu-cover-inset is the pressed-in "well" edge carried by covers
|
|
99
|
+
that render a generated illustration instead of a photo. It has to live in
|
|
100
|
+
the SAME box-shadow value as the outer lift — box-shadow is one property,
|
|
101
|
+
so two `shadow-*` utilities can't compose — which is why it is a token
|
|
102
|
+
rather than a second class. Identical in both themes.
|
|
103
|
+
|
|
104
|
+
Consumer apps must not redefine these: their stylesheet is imported after
|
|
105
|
+
this one and would silently win, which is the drift these tokens exist to
|
|
106
|
+
end. */
|
|
107
|
+
--shadow-card-image: 0 4px 14px rgba(120, 80, 50, 0.22);
|
|
108
|
+
--shadow-card-image-hover: 0 12px 28px rgba(120, 80, 50, 0.3);
|
|
109
|
+
--shadow-menu-cover-inset:
|
|
110
|
+
inset 0 1px 2px oklch(1 0 0 / 0.15), inset 0 -1px 4px oklch(0 0 0 / 0.18);
|
|
111
|
+
|
|
92
112
|
/* ─── Motion System v1.0 ─────────────────────────────────────────────
|
|
93
113
|
Three layers of tokens. Feature code reads only `--duration-*` and
|
|
94
114
|
`--ease-*` semantic names; raw values live here.
|
|
@@ -181,6 +201,11 @@ h6 {
|
|
|
181
201
|
--shadow-alpha-lg: 0.5;
|
|
182
202
|
--shadow-alpha-xl: 0.55;
|
|
183
203
|
--shadow-alpha-2xl: 0.65;
|
|
204
|
+
|
|
205
|
+
/* Neutral black on a dark canvas — a warm-brown lift is invisible there.
|
|
206
|
+
The inset well is unchanged between themes, so it has no override. */
|
|
207
|
+
--shadow-card-image: 0 4px 14px rgba(0, 0, 0, 0.45);
|
|
208
|
+
--shadow-card-image-hover: 0 12px 28px rgba(0, 0, 0, 0.55);
|
|
184
209
|
}
|
|
185
210
|
|
|
186
211
|
@theme inline {
|
|
@@ -321,26 +346,38 @@ h6 {
|
|
|
321
346
|
box-shadow: 0 0 12px color-mix(in oklch, var(--accent) 60%, transparent);
|
|
322
347
|
}
|
|
323
348
|
|
|
324
|
-
/* Scroll-to highlight
|
|
349
|
+
/* Scroll-to highlight — marks the element a deep link just scrolled to.
|
|
350
|
+
*
|
|
351
|
+
* A RIPPLE: a ring born at the element's edge that expands outward and fades to
|
|
352
|
+
* nothing, three times. The movement is the signal, so it reads on any
|
|
353
|
+
* background — which matters because the targets include saturated colour
|
|
354
|
+
* fields in the same warm hue family as `--primary`, where a static ring is
|
|
355
|
+
* brown-on-brown and disappears into the thing it is marking.
|
|
356
|
+
*
|
|
357
|
+
* This replaced two attempts that both failed for the same underlying reason:
|
|
358
|
+
* an outline whose offset breathed 3px (no colour travel — read as a static
|
|
359
|
+
* ring), then a ring that faded in place (better, but still competing with the
|
|
360
|
+
* field on colour rather than on motion).
|
|
361
|
+
*
|
|
362
|
+
* Spread-only box-shadow, so nothing reflows: the ripple paints outside the
|
|
363
|
+
* border box and never affects layout. It IS clippable by an ancestor's
|
|
364
|
+
* `overflow-hidden`, unlike an outline — target an element whose ancestors
|
|
365
|
+
* don't clip. */
|
|
325
366
|
@keyframes highlight-pulse {
|
|
326
367
|
0% {
|
|
327
|
-
|
|
328
|
-
outline-offset: 3px;
|
|
368
|
+
box-shadow: 0 0 0 0 color-mix(in oklch, var(--primary) 55%, transparent);
|
|
329
369
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
outline-offset: 6px;
|
|
370
|
+
70% {
|
|
371
|
+
box-shadow: 0 0 0 18px transparent;
|
|
333
372
|
}
|
|
334
373
|
100% {
|
|
335
|
-
|
|
336
|
-
outline-offset: 3px;
|
|
374
|
+
box-shadow: 0 0 0 0 transparent;
|
|
337
375
|
}
|
|
338
376
|
}
|
|
339
377
|
|
|
340
378
|
.scroll-highlight {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
animation: highlight-pulse 1s ease-in-out 3;
|
|
379
|
+
border-radius: var(--radius);
|
|
380
|
+
animation: highlight-pulse 1.4s ease-out 3;
|
|
344
381
|
}
|
|
345
382
|
|
|
346
383
|
/* Scrollbar styling — token-driven, single rule covers both themes */
|
|
@@ -477,6 +514,11 @@ h6 {
|
|
|
477
514
|
.motion-live-pulse {
|
|
478
515
|
animation: none;
|
|
479
516
|
}
|
|
517
|
+
/* Keep the ring — it is the only thing identifying the target — but stop it
|
|
518
|
+
from pulsing. */
|
|
519
|
+
.scroll-highlight {
|
|
520
|
+
animation: none;
|
|
521
|
+
}
|
|
480
522
|
}
|
|
481
523
|
|
|
482
524
|
/* Swiper pagination styling for experience cards */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card hover motion tokens — the shared recipe for any card with a cover image.
|
|
3
|
+
*
|
|
4
|
+
* Every card type animates the same set of properties (transform on the card
|
|
5
|
+
* root, transform on the image, box-shadow on the image wrapper) so the timings
|
|
6
|
+
* must stay in lockstep. Defining the tokens in one place is the only way to
|
|
7
|
+
* guarantee a hover that feels identical across a mixed grid.
|
|
8
|
+
*
|
|
9
|
+
* Prefer the COMPOSED tokens (`HOVER_IMAGE_WRAPPER`, `HOVER_IMAGE_MOTION`) over
|
|
10
|
+
* re-assembling a recipe from `HOVER_DURATION` + `HOVER_EASE` + a shadow at the
|
|
11
|
+
* call site. Consumers that did the latter silently shipped without a hover
|
|
12
|
+
* shadow — the transition and the resting shadow were wired up, the hover state
|
|
13
|
+
* simply never was, and nothing failed loudly. One class per element is what
|
|
14
|
+
* stops that.
|
|
15
|
+
*
|
|
16
|
+
* Companion CSS custom properties live in `./globals.css`:
|
|
17
|
+
* --shadow-card-image / --shadow-card-image-hover / --shadow-menu-cover-inset
|
|
18
|
+
* They carry the dark-mode override, so none of these tokens needs a `dark:`
|
|
19
|
+
* twin of every state. A consumer app must NOT redefine them locally — app
|
|
20
|
+
* stylesheets are imported after this one and would silently win.
|
|
21
|
+
*
|
|
22
|
+
* Why we don't animate filter / mix-blend-mode opacity:
|
|
23
|
+
* - filter animations (`saturate`, `brightness`) on <img> are CPU-heavy and
|
|
24
|
+
* can hitch on lower-end devices.
|
|
25
|
+
* - mix-blend-multiply opacity transitions composite at unpredictable frame
|
|
26
|
+
* rates so they never sync with transform/opacity transitions.
|
|
27
|
+
* Restricting hover to transform + box-shadow keeps every element on the same
|
|
28
|
+
* GPU-composited frame, which is what makes the in/out feel perfectly synced.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const HOVER_DURATION = "duration-[320ms]";
|
|
32
|
+
export const HOVER_EASE = "ease-[cubic-bezier(0.4,0,0.2,1)]";
|
|
33
|
+
|
|
34
|
+
/** Apply to the card root — composes the base transform transition. */
|
|
35
|
+
export const HOVER_MOTION = `transition-transform ${HOVER_DURATION} ${HOVER_EASE} motion-reduce:transition-none will-change-transform`;
|
|
36
|
+
|
|
37
|
+
/** Apply to elements that animate box-shadow on hover (image wrappers). */
|
|
38
|
+
export const HOVER_SHADOW_MOTION = `transition-shadow ${HOVER_DURATION} ${HOVER_EASE}`;
|
|
39
|
+
|
|
40
|
+
/** Grid/standard/hero card variants — full lift. */
|
|
41
|
+
export const HOVER_LIFT_GRID = "[@media(hover:hover)]:hover:-translate-y-1";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Same lift as `HOVER_LIFT_GRID`, but driven by a NAMED `group/card` ancestor
|
|
45
|
+
* instead of the element's own `:hover`.
|
|
46
|
+
*
|
|
47
|
+
* For overlays that must render as SIBLINGS of the interactive card rather than
|
|
48
|
+
* children — e.g. a pill that is itself a button or link, which would be
|
|
49
|
+
* invalid ARIA nested inside the card's own `role="button"` / `<a>`. A sibling
|
|
50
|
+
* can't inherit the card's transform, so it stays pinned while the card lifts
|
|
51
|
+
* out from under it. Putting `group/card` on the shared wrapper and this token
|
|
52
|
+
* on BOTH the card and the overlay moves them as one, and means hovering the
|
|
53
|
+
* overlay lifts the card too (its own `:hover` never fires for a sibling).
|
|
54
|
+
*/
|
|
55
|
+
export const HOVER_LIFT_GRID_GROUP = "[@media(hover:hover)]:group-hover/card:-translate-y-1";
|
|
56
|
+
|
|
57
|
+
/** List/compact row variants — half lift since rows are denser. */
|
|
58
|
+
export const HOVER_LIFT_LIST = "[@media(hover:hover)]:hover:-translate-y-0.5";
|
|
59
|
+
|
|
60
|
+
/** Image inside a card — scales subtly on group hover. */
|
|
61
|
+
export const HOVER_IMAGE_SCALE = "group-hover:scale-[1.05]";
|
|
62
|
+
|
|
63
|
+
/** Drop-in className for the `<img>` element inside card image wrappers. */
|
|
64
|
+
export const HOVER_IMAGE_MOTION = `${HOVER_MOTION} ${HOVER_IMAGE_SCALE}`;
|
|
65
|
+
|
|
66
|
+
/** Warm image-wrapper shadow that deepens on group hover. */
|
|
67
|
+
export const HOVER_IMAGE_SHADOW =
|
|
68
|
+
"shadow-[var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-card-image-hover)]";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Same lift, for covers that ALSO carry an inset "well" edge on a generated
|
|
72
|
+
* illustration rather than a photo. `box-shadow` is a single property, so the
|
|
73
|
+
* inset can't be a second `shadow-*` class stacked on `HOVER_IMAGE_SHADOW`; it
|
|
74
|
+
* has to be part of the same value, which is why both states are spelled out.
|
|
75
|
+
*/
|
|
76
|
+
export const HOVER_IMAGE_SHADOW_INSET =
|
|
77
|
+
"shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image)] [@media(hover:hover)]:group-hover:shadow-[var(--shadow-menu-cover-inset),var(--shadow-card-image-hover)]";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The whole cover-wrapper recipe: shadow transition + the warm lift. Pair with
|
|
81
|
+
* `HOVER_IMAGE_MOTION` on the image inside (covers that render a generated
|
|
82
|
+
* illustration have no image to scale and use this alone).
|
|
83
|
+
*/
|
|
84
|
+
export const HOVER_IMAGE_WRAPPER = `${HOVER_SHADOW_MOTION} ${HOVER_IMAGE_SHADOW}`;
|
|
85
|
+
|
|
86
|
+
/** `HOVER_IMAGE_WRAPPER` for the inset-well covers. */
|
|
87
|
+
export const HOVER_IMAGE_WRAPPER_INSET = `${HOVER_SHADOW_MOTION} ${HOVER_IMAGE_SHADOW_INSET}`;
|