Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
name: lint/test

on:
push:
branches: [ main ]
# Runs on PRs (to gate merges) and when the Release workflow calls it (to gate
# publishing). No `push` trigger, so it doesn't re-run standalone on merges to main.
pull_request:
workflow_call:

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.2/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
272 changes: 166 additions & 106 deletions bun.lock

Large diffs are not rendered by default.

38 changes: 19 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,36 @@
"test:watch": "vitest"
},
"devDependencies": {
"@biomejs/biome": "^2.5.2",
"@biomejs/biome": "^2.5.4",
"@chromatic-com/storybook": "^5.2.1",
"@inertiajs/react": "^3.5.0",
"@inertiajs/react": "^3.6.1",
"@react-stately/color": "^3.10.1",
"@storybook/addon-a11y": "^10.4.6",
"@storybook/addon-docs": "^10.4.6",
"@storybook/addon-themes": "^10.4.6",
"@storybook/addon-vitest": "^10.4.6",
"@storybook/react-vite": "^10.4.6",
"@tailwindcss/vite": "^4.3.2",
"@storybook/addon-a11y": "^10.5.2",
"@storybook/addon-docs": "^10.5.2",
"@storybook/addon-themes": "^10.5.2",
"@storybook/addon-vitest": "^10.5.2",
"@storybook/react-vite": "^10.5.2",
"@tailwindcss/vite": "^4.3.3",
"@types/culori": "^4.0.1",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.17",
"@vitest/browser-playwright": "^4.1.9",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/browser-playwright": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^1.23.0",
"lucide-react": "^1.25.0",
"playwright": "^1.61.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"recharts": "^3.9.1",
"shiki": "^4.3.0",
"recharts": "^3.9.2",
"shiki": "^4.3.1",
"sonner": "^2.0.7",
"storybook": "^10.4.6",
"tailwindcss": "^4.3.2",
"storybook": "^10.5.2",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.1.2",
"vite": "^8.1.5",
"vite-plugin-dts": "^5.0.3",
"vitest": "^4.1.9"
"vitest": "^4.1.10"
},
"peerDependencies": {
"@inertiajs/react": "^3",
Expand All @@ -68,7 +68,7 @@
"clsx": "^2.1.1",
"culori": "^4.0.2",
"domhandler": "^6.0.1",
"html-react-parser": "^6.1.3",
"html-react-parser": "^6.1.5",
"input-otp": "^1.4.2",
"motion": "^12.42.2",
"qrcode": "^1.5.4",
Expand Down
2 changes: 2 additions & 0 deletions src/components/shared/button-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const buttonStyles = tv({
"[--btn-bg:var(--color-primary)] [--btn-fg:var(--color-primary-fg)] [--btn-icon-active:var(--primary-fg)]/80 [--btn-icon:var(--primary-fg)]/60 [--btn-overlay:color-mix(in_oklab,var(--color-primary-fg)_10%,var(--color-primary)_90%)]",
secondary:
"[--btn-bg:var(--color-secondary)] [--btn-fg:var(--color-secondary-fg)] [--btn-icon:var(--color-muted-fg)] [--btn-outline:var(--color-secondary-fg)] [--btn-overlay:color-mix(in_oklab,var(--color-secondary-fg)_10%,var(--color-secondary)_90%)] [--btn-ring:var(--color-muted-fg)]/20",
accent:
"[--btn-bg:var(--color-accent-solid)] [--btn-fg:var(--color-accent-solid-fg)] [--btn-icon-active:var(--color-accent-solid-fg)]/80 [--btn-icon:var(--color-accent-solid-fg)]/60 [--btn-overlay:color-mix(in_oklab,var(--color-accent-solid-fg)_10%,var(--color-accent-solid)_90%)]",
warning:
"[--btn-bg:var(--color-warning)] [--btn-fg:var(--color-warning-fg)] [--btn-icon:var(--color-warning-fg)]/60 [--btn-overlay:color-mix(in_oklab,var(--color-white)_10%,var(--color-warning)_90%)]",
danger:
Expand Down
10 changes: 5 additions & 5 deletions src/components/ui/heading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ const Heading = ({ className, level = 1, ...props }: HeadingProps) => {
return (
<Element
className={twMerge(
"text-balance font-display font-semibold text-fg tracking-tight",
level === 1 && "text-xl/8 sm:text-2xl/8",
level === 2 && "text-lg/6 sm:text-xl/8",
level === 3 && "text-base/6 sm:text-lg/6",
level === 4 && "text-base/6",
"text-balance font-display font-medium text-fg tracking-tight",
level === 1 && "text-3xl sm:text-4xl",
level === 2 && "text-2xl sm:text-3xl",
level === 3 && "text-xl sm:text-2xl",
level === 4 && "text-xl",
className,
)}
{...props}
Expand Down
5 changes: 4 additions & 1 deletion src/stories/button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const meta = {
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "warning", "danger", "success", "outline", "plain"],
options: ["primary", "secondary", "accent", "warning", "danger", "success", "outline", "plain"],
},
size: {
control: "select",
Expand Down Expand Up @@ -53,6 +53,9 @@ export const Variants: Story = {
<Button {...args} variant="plain">
Plain
</Button>
<Button {...args} variant="accent">
Accent
</Button>
</div>
),
};
Expand Down
67 changes: 67 additions & 0 deletions src/stories/menu-items-render.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,46 @@ const items: NavItem[] = [
{ id: "settings", type: "link", label: "Settings", href: "#", isActive: false },
];

// A deep tree: an inline section of links, a collapsible group (submenu), and a
// nested collapsible group (nested submenu) — MenuItemsRender recurses through all.
const menuTree: NavItem[] = [
{
id: "main",
type: "section",
label: "Main",
isActive: false,
collapsible: false,
collapsed: false,
children: [
{ id: "m-dashboard", type: "link", label: "Dashboard", href: "#", isActive: false },
{ id: "m-projects", type: "link", label: "Projects", href: "#", isActive: false },
],
},
{
id: "reports",
type: "group",
label: "Reports",
isActive: false,
collapsible: true,
collapsed: false,
children: [
{ id: "monthly", type: "link", label: "Monthly", href: "#", isActive: false },
{
id: "archive",
type: "group",
label: "Archive",
isActive: false,
collapsible: true,
collapsed: false,
children: [
{ id: "y2024", type: "link", label: "2024", href: "#", isActive: false },
{ id: "y2023", type: "link", label: "2023", href: "#", isActive: false },
],
},
],
},
];

const meta = {
title: "Composite/MenuItemsRender",
component: MenuItemsRender,
Expand Down Expand Up @@ -42,3 +82,30 @@ export const Default: Story = {
await expect(await menu.findByText("Dashboard")).toBeInTheDocument();
},
};

/** Renders a nested menu recursively — section links, a submenu, and a nested submenu. */
export const MultiLevel: Story = {
render: () => (
<Menu>
<MenuTrigger>Open menu</MenuTrigger>
<MenuContent>
{menuTree.map((item) => (
<MenuItemsRender key={item.id} item={item} />
))}
</MenuContent>
</Menu>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
await userEvent.click(canvas.getByRole("button", { name: "Open menu" }));
// Inline section link renders immediately.
await expect(await body.findByRole("menuitem", { name: "Projects" })).toBeInTheDocument();
// Open the "Reports" submenu → its children.
await userEvent.click(await body.findByRole("menuitem", { name: "Reports" }));
await expect(await body.findByRole("menuitem", { name: "Monthly" })).toBeInTheDocument();
// Open the nested "Archive" submenu → deepest link.
await userEvent.click(await body.findByRole("menuitem", { name: "Archive" }));
await expect(await body.findByRole("menuitem", { name: "2024" })).toBeInTheDocument();
},
};
67 changes: 67 additions & 0 deletions src/stories/navbar-items-render.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { NavbarItemsRender } from "@/components/composite/render/navbar-items-render";
import { Navbar, NavbarProvider } from "@/components/ui/navbar";
import type { NavItem } from "@/types";
Expand Down Expand Up @@ -44,3 +45,69 @@ export const SingleLink: Story = {
item: { id: "profile", type: "link", label: "Profile", href: "/profile", isActive: false },
},
};

// A deep tree: a section of links + a `group` (navbar dropdown) whose children
// include a nested collapsible group (submenu). Renders through every level.
const navbarTree: NavItem[] = [
{
id: "primary",
type: "section",
label: "Primary",
isActive: false,
collapsible: false,
collapsed: false,
children: [
{ id: "nav-home", type: "link", label: "Home", href: "/", isActive: true },
{ id: "nav-about", type: "link", label: "About", href: "/about", isActive: false },
],
},
{
id: "products",
type: "group",
label: "Products",
isActive: false,
collapsible: false,
collapsed: false,
children: [
{ id: "prod-overview", type: "link", label: "Overview", href: "/overview", isActive: false },
{ id: "prod-sep", type: "separator" },
{
id: "resources",
type: "group",
label: "Resources",
isActive: false,
collapsible: true,
collapsed: false,
children: [
{ id: "res-docs", type: "link", label: "Docs", href: "/docs", isActive: false },
{ id: "res-api", type: "link", label: "API", href: "/api", isActive: false },
],
},
],
},
];

/** Section links inline + a `group` dropdown containing a nested submenu. */
export const MultiLevel: Story = {
render: () => (
<NavbarProvider>
<Navbar aria-label="Main navigation">
{navbarTree.map((item) => (
<NavbarItemsRender key={item.id} item={item} />
))}
</Navbar>
</NavbarProvider>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(document.body);
// Section link renders inline in the navbar.
await expect(await canvas.findByRole("link", { name: "Home" })).toBeInTheDocument();
// Open the "Products" dropdown → its children (rendered via MenuItemsRender).
await userEvent.click(canvas.getByRole("button", { name: /Products/ }));
await expect(await body.findByRole("menuitem", { name: "Overview" })).toBeInTheDocument();
// Open the nested "Resources" submenu → deepest link.
await userEvent.click(await body.findByRole("menuitem", { name: "Resources" }));
await expect(await body.findByRole("menuitem", { name: "Docs" })).toBeInTheDocument();
},
};
94 changes: 87 additions & 7 deletions src/stories/sidebar-items-render.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { SidebarItemsRender } from "@/components/composite/render/sidebar-items-render";
import { Sidebar, SidebarContent, SidebarProvider } from "@/components/ui/sidebar";
import type { NavItem } from "@/types";

const svg = (path: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;

const icons = {
dashboard: svg(
'<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
),
projects: svg(
'<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>',
),
settings: svg(
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/>',
),
};

const mainSection: NavItem = {
id: "main",
type: "section",
Expand All @@ -11,16 +27,59 @@ const mainSection: NavItem = {
collapsible: false,
collapsed: false,
children: [
{ id: "dashboard", type: "link", label: "Dashboard", href: "#", isActive: true },
{ id: "projects", type: "link", label: "Projects", href: "#", isActive: false },
{ id: "dashboard", type: "link", label: "Dashboard", href: "#", isActive: true, icon: icons.dashboard },
{ id: "projects", type: "link", label: "Projects", href: "#", isActive: false, icon: icons.projects },
],
};

const items: NavItem[] = [
mainSection,
{ id: "sep", type: "separator" },
{ id: "settings", type: "link", label: "Settings", href: "#", isActive: false },
];
const systemSection: NavItem = {
id: "system",
type: "section",
label: "System",
isActive: false,
collapsible: false,
collapsed: false,
children: [{ id: "settings", type: "link", label: "Settings", href: "#", isActive: false, icon: icons.settings }],
};

const items: NavItem[] = [mainSection, { id: "sep", type: "separator" }, systemSection];

// A deep tree: section → link + collapsible group → links + a nested collapsible group → links.
// `isActive` on a group makes its disclosure default-expanded, so every level renders at once.
const analyticsSection: NavItem = {
id: "analytics",
type: "section",
label: "Analytics",
isActive: false,
collapsible: false,
collapsed: false,
children: [
{ id: "overview", type: "link", label: "Overview", href: "#", isActive: false, icon: icons.dashboard },
{
id: "reports",
type: "group",
label: "Reports",
isActive: true,
collapsible: true,
collapsed: false,
children: [
{ id: "monthly", type: "link", label: "Monthly", href: "#", isActive: false, icon: icons.projects },
{
id: "archive",
type: "group",
label: "Archive",
isActive: true,
collapsible: true,
collapsed: false,
children: [
{ id: "y2024", type: "link", label: "2024", href: "#", isActive: false, icon: icons.projects },
{ id: "y2023", type: "link", label: "2023", href: "#", isActive: false, icon: icons.projects },
],
},
],
},
],
};

const meta = {
title: "Composite/SidebarItemsRender",
Expand All @@ -46,3 +105,24 @@ export const Default: Story = {
</SidebarProvider>
),
};

/** Renders a nested nav tree recursively — section → group → nested group → link. */
export const MultiLevel: Story = {
render: () => (
<SidebarProvider>
<Sidebar>
<SidebarContent>
<SidebarItemsRender item={analyticsSection} />
</SidebarContent>
</Sidebar>
</SidebarProvider>
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Every level renders: the section label, the group, the nested group, and its deepest links.
await expect(canvas.getByText("Analytics")).toBeInTheDocument();
await expect(canvas.getByText("Reports")).toBeInTheDocument();
await expect(canvas.getByText("Archive")).toBeInTheDocument();
await expect(canvas.getByText("2024")).toBeInTheDocument();
},
};
Loading