feat(ui): add navigation and picker primitives
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@ai-ui/ui": minor
|
||||
---
|
||||
|
||||
Add Accordion, Breadcrumb, ContextMenu, and a single-date DatePicker to round out workflow and navigation primitives.
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Badge,
|
||||
Button
|
||||
} from "@ai-ui/ui";
|
||||
|
||||
function AccordionPlayground() {
|
||||
return (
|
||||
<div className="w-full max-w-3xl">
|
||||
<Accordion collapsible>
|
||||
<AccordionItem value="editorial">
|
||||
<AccordionTrigger>Editorial review</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
The editorial lane is ready for the final release note pass and legal cross-check.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="engineering">
|
||||
<AccordionTrigger>Engineering canary</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Canary thresholds are green and the 10% wave can begin after routing sign-off.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="support">
|
||||
<AccordionTrigger>Support queue</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Customer macros are staged and only need one more quiet-hour review.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionFaq() {
|
||||
return (
|
||||
<div className="w-full max-w-3xl">
|
||||
<Accordion type="multiple">
|
||||
<AccordionItem value="why">
|
||||
<AccordionTrigger>Why use Accordion instead of Tabs?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Use Accordion when people need to compare or progressively reveal multiple sections in
|
||||
the same reading flow. Tabs hide sibling content; Accordion keeps the page narrative in
|
||||
one vertical surface.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="where">
|
||||
<AccordionTrigger>Where does it fit best?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Settings groups, FAQ sections, filter drawers, release notes, audit explanations, and
|
||||
inspector panels all benefit from lightweight disclosure.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="motion">
|
||||
<AccordionTrigger>How does motion behave?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
The content region uses the shared motion contract for expansion and still respects the
|
||||
static motion mode when the user needs a quieter interface.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionControlPanel() {
|
||||
return (
|
||||
<div className="w-full max-w-4xl">
|
||||
<Accordion type="multiple">
|
||||
<AccordionItem value="release-state">
|
||||
<AccordionTrigger>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Badge size="sm" variant="outline">
|
||||
Release
|
||||
</Badge>
|
||||
<span>Wave rollout controls</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
Stage a 10% wave, keep rollback thresholds visible, and hold broader rollout until
|
||||
the support digest is approved.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm">Start 10% wave</Button>
|
||||
<Button size="sm" variant="secondary">
|
||||
Hold rollout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="audit-context">
|
||||
<AccordionTrigger>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Badge size="sm" tone="warning" variant="outline">
|
||||
Audit
|
||||
</Badge>
|
||||
<span>Open outstanding review notes</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid gap-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
<p>Legal footnote still needs one sentence tightened before public launch.</p>
|
||||
<p>Customer support messaging is approved but waiting on the rollout window.</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Accordion",
|
||||
component: AccordionPlayground,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A lightweight disclosure surface for FAQ sections, filter groups, settings, and inspector panels. Use it when content should remain in one vertical reading flow instead of moving into tabs or overlays."
|
||||
}
|
||||
},
|
||||
layout: "centered"
|
||||
},
|
||||
tags: ["autodocs"]
|
||||
} satisfies Meta<typeof AccordionPlayground>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const Faq: Story = {
|
||||
render: () => <AccordionFaq />
|
||||
};
|
||||
|
||||
export const ControlPanel: Story = {
|
||||
render: () => <AccordionControlPanel />
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
BreadcrumbCurrent,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@ai-ui/ui";
|
||||
|
||||
function WorkspaceBreadcrumbShowcase() {
|
||||
return (
|
||||
<div className="grid gap-6 p-6">
|
||||
<Card className="max-w-4xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow navigation</CardTitle>
|
||||
<CardDescription>
|
||||
Breadcrumb should stabilize layered navigation across runs, threads, queues,
|
||||
and operator workspaces without competing with page headings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-8">
|
||||
<section className="grid gap-3">
|
||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
Run detail path
|
||||
</p>
|
||||
<Breadcrumb aria-label="Run detail path">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#runs">Runs</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#run-42">run-42</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbCurrent>Thread timeline</BreadcrumbCurrent>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
Environment drill-down
|
||||
</p>
|
||||
<Breadcrumb aria-label="Environment drill-down">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#org">Cadence Labs</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#project">Agent platform</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#env">Production</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbCurrent>Deploy guardrails</BreadcrumbCurrent>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||
Mixed actions
|
||||
</p>
|
||||
<Breadcrumb aria-label="Escalation trail">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<Badge size="sm" variant="outline">
|
||||
Holding
|
||||
</Badge>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>•</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Button size="sm" variant="ghost">
|
||||
Escalations
|
||||
</Button>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbCurrent>Review queue</BreadcrumbCurrent>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Breadcrumb",
|
||||
component: WorkspaceBreadcrumbShowcase,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A lightweight breadcrumb family for layered operator and admin navigation. Use it to establish context across list → detail → nested object flows without overloading the page chrome."
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: ["autodocs"]
|
||||
} satisfies Meta<typeof WorkspaceBreadcrumbShowcase>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from "@ai-ui/ui";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
function FileIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M4 2.75h5.2l2.8 2.8v7.7H4z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<path d="M9.2 2.75v2.8H12" stroke="currentColor" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
|
||||
<path
|
||||
d="M1.75 8s2.2-3.25 6.25-3.25S14.25 8 14.25 8 12.05 11.25 8 11.25 1.75 8 1.75 8Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
<circle cx="8" cy="8" r="1.85" stroke="currentColor" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRowContextMenu({ label = "Open file menu" }: { label?: string }) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="grid gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex size-10 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<FileIcon />
|
||||
</span>
|
||||
<div className="grid gap-1">
|
||||
<p className="text-sm font-medium text-[var(--color-foreground)]">release-plan.md</p>
|
||||
<p className="text-xs text-[var(--color-muted-foreground)]">
|
||||
Right click to open contextual actions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge size="sm" variant="outline">
|
||||
docs
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent size="xl">
|
||||
<ContextMenuLabel inset>release-plan.md</ContextMenuLabel>
|
||||
<ContextMenuItem
|
||||
description="Open the document in a side-by-side preview."
|
||||
leading={<EyeIcon />}
|
||||
shortcut="P"
|
||||
>
|
||||
Preview file
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
description="Reveal the document inside the release workspace."
|
||||
leading={<FileIcon />}
|
||||
shortcut="R"
|
||||
>
|
||||
Reveal in workspace
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuCheckboxItem
|
||||
checked
|
||||
description="Keep this file pinned in the review queue."
|
||||
shortcut="K"
|
||||
>
|
||||
Keep pinned
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuRadioGroup value="write">
|
||||
<ContextMenuRadioItem
|
||||
description="Allow direct edits before the next checkpoint."
|
||||
shortcut="W"
|
||||
value="write"
|
||||
>
|
||||
Write access
|
||||
</ContextMenuRadioItem>
|
||||
<ContextMenuRadioItem
|
||||
description="Review the file without mutating it."
|
||||
shortcut="R"
|
||||
value="read"
|
||||
>
|
||||
Read-only access
|
||||
</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger description="Secondary actions with lower urgency.">
|
||||
More actions
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem description="Duplicate this file into the next release branch.">
|
||||
Duplicate file
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
description="Move the file out of the active release without deleting it."
|
||||
variant="destructive"
|
||||
>
|
||||
Archive file
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/ContextMenu",
|
||||
component: ContextMenu,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"ContextMenu extends the menu contract to right-click and long-press surfaces. It matches the DropdownMenu visual language while supporting richer item rows, nested submenus, toggles, and destructive actions for row-level workflows."
|
||||
}
|
||||
},
|
||||
layout: "centered"
|
||||
},
|
||||
tags: ["autodocs"]
|
||||
} satisfies Meta<typeof ContextMenu>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <FileRowContextMenu />
|
||||
};
|
||||
|
||||
export const FileRowWorkflow: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[720px] gap-4">
|
||||
<FileRowContextMenu label="Open release file context menu" />
|
||||
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3 text-sm text-[var(--color-muted-foreground)]">
|
||||
This example is designed for real workflow rows. Right click the file card to reveal
|
||||
preview, workspace, pinning, permission, and archive actions.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
export const DensePanels: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-[960px] gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
|
||||
File row context menu
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
This menu mirrors the richer rows from DropdownMenu, but the trigger is a context
|
||||
surface instead of a button.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<FileRowContextMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
|
||||
Data table row actions
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
Use a context menu when table rows need denser actions than an inline action column can
|
||||
comfortably show.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="grid gap-1 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium text-[var(--color-foreground)]">
|
||||
Run 184 · release wave
|
||||
</span>
|
||||
<Badge size="sm" tone="warning" variant="outline">
|
||||
blocked
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||
Right click this row to route the issue, open the thread, or escalate the blocker.
|
||||
</p>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem description="Open the current run details in a side panel.">
|
||||
Open run detail
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem description="Jump directly to the latest blocked thread.">
|
||||
Open blocked thread
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
description="Escalate this row into the operator blocker queue."
|
||||
variant="destructive"
|
||||
>
|
||||
Escalate blocker
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ai-ui/ui";
|
||||
import { DatePicker } from "../../../../packages/ui/src/components/date-picker";
|
||||
|
||||
function DatePickerPlayground() {
|
||||
const [value, setValue] = useState<Date | undefined>(new Date(2026, 3, 18));
|
||||
|
||||
return (
|
||||
<div className="grid w-full max-w-3xl gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<CardTitle>Launch scheduling</CardTitle>
|
||||
<CardDescription>
|
||||
Pick a single launch date from a lightweight calendar surface.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge size="sm" variant="outline">
|
||||
single date
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DatePicker
|
||||
aria-label="Launch date"
|
||||
onValueChange={setValue}
|
||||
value={value}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatePickerScenarios() {
|
||||
return (
|
||||
<div className="grid w-full max-w-4xl gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Empty state</CardTitle>
|
||||
<CardDescription>Use the field as a clean trigger for a future date choice.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DatePicker aria-label="Review date" placeholder="Select review date" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Guardrailed window</CardTitle>
|
||||
<CardDescription>
|
||||
Limit choices to a narrow release window without turning the API into a range picker.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DatePicker
|
||||
aria-label="Launch window"
|
||||
defaultMonth={new Date(2026, 4, 1)}
|
||||
defaultValue={new Date(2026, 4, 14)}
|
||||
maxDate={new Date(2026, 4, 20)}
|
||||
minDate={new Date(2026, 4, 10)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/DatePicker",
|
||||
component: DatePickerPlayground,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A single-date picker for launch windows, review deadlines, and operator scheduling surfaces. This first slice stays intentionally narrow: one date, one popover calendar, no range or timezone API."
|
||||
}
|
||||
},
|
||||
layout: "centered"
|
||||
},
|
||||
tags: ["autodocs"]
|
||||
} satisfies Meta<typeof DatePickerPlayground>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {};
|
||||
|
||||
export const Scenarios: Story = {
|
||||
render: () => <DatePickerScenarios />
|
||||
};
|
||||
@@ -38,6 +38,7 @@
|
||||
"@ai-ui/tokens": "workspace:*",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from "./accordion";
|
||||
|
||||
function ExampleAccordion(props: any = {}) {
|
||||
return (
|
||||
<Accordion {...props}>
|
||||
<AccordionItem value="editorial">
|
||||
<AccordionTrigger>Editorial review</AccordionTrigger>
|
||||
<AccordionContent>Copy is locked for launch review.</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="legal">
|
||||
<AccordionTrigger>Legal review</AccordionTrigger>
|
||||
<AccordionContent>Policy language still needs sign-off.</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Accordion", () => {
|
||||
it("opens one item at a time in single mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ExampleAccordion />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Editorial review" }));
|
||||
|
||||
expect(screen.getByText("Copy is locked for launch review.")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Editorial review" }).closest('[data-slot="trigger"]')
|
||||
).toHaveAttribute("data-state", "open");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Legal review" }));
|
||||
|
||||
expect(screen.getByText("Policy language still needs sign-off.")).toBeVisible();
|
||||
expect(screen.getByText("Copy is locked for launch review.")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("supports multiple open items in multiple mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ExampleAccordion type="multiple" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Editorial review" }));
|
||||
await user.click(screen.getByRole("button", { name: "Legal review" }));
|
||||
|
||||
expect(screen.getByText("Copy is locked for launch review.")).toBeVisible();
|
||||
expect(screen.getByText("Policy language still needs sign-off.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("supports controlled single mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onValueChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ExampleAccordion
|
||||
onValueChange={onValueChange}
|
||||
type="single"
|
||||
value="editorial"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Copy is locked for launch review.")).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Legal review" }));
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith("legal");
|
||||
});
|
||||
|
||||
it("wires aria controls and slot metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ExampleAccordion />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "Editorial review" });
|
||||
expect(trigger.closest('[data-slot="trigger"]')).toHaveAttribute("data-state", "closed");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(trigger);
|
||||
|
||||
const content = screen.getByText("Copy is locked for launch review.").closest('[data-slot="content"]');
|
||||
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(content).toHaveAttribute("data-state", "open");
|
||||
expect(content).toHaveAttribute("role", "region");
|
||||
expect(content).toHaveAttribute("id", trigger.getAttribute("aria-controls"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
createContext,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactElement,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
accordionContentInnerVariants,
|
||||
accordionContentVariants,
|
||||
accordionIconVariants,
|
||||
accordionItemVariants,
|
||||
accordionRootVariants,
|
||||
accordionTitleVariants,
|
||||
accordionTriggerVariants
|
||||
} from "./accordion.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import { ChevronDownIcon } from "../lib/icons";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
|
||||
type AccordionType = "single" | "multiple";
|
||||
|
||||
type AccordionSingleValue = string | undefined;
|
||||
type AccordionMultipleValue = string[];
|
||||
|
||||
type AccordionBaseProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export type AccordionSingleProps = AccordionBaseProps & {
|
||||
collapsible?: boolean;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: AccordionSingleValue) => void;
|
||||
type?: "single";
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type AccordionMultipleProps = AccordionBaseProps & {
|
||||
defaultValue?: string[];
|
||||
onValueChange?: (value: AccordionMultipleValue) => void;
|
||||
type: "multiple";
|
||||
value?: string[];
|
||||
};
|
||||
|
||||
export type AccordionProps = AccordionSingleProps | AccordionMultipleProps;
|
||||
|
||||
type AccordionContextValue = {
|
||||
collapsible: boolean;
|
||||
openValues: string[];
|
||||
setValue: (value: string) => void;
|
||||
type: AccordionType;
|
||||
};
|
||||
|
||||
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
||||
|
||||
function useAccordionContext() {
|
||||
const context = useContext(AccordionContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Accordion compound components must be used inside Accordion.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
type AccordionItemContextValue = {
|
||||
contentId: string;
|
||||
disabled: boolean;
|
||||
open: boolean;
|
||||
triggerId: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
|
||||
|
||||
function useAccordionItemContext() {
|
||||
const context = useContext(AccordionItemContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("AccordionItem compound components must be used inside AccordionItem.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function normalizeSingleValue(value: AccordionSingleValue) {
|
||||
return value ? [value] : [];
|
||||
}
|
||||
|
||||
function useAccordionState(props: AccordionProps) {
|
||||
const isMultiple = props.type === "multiple";
|
||||
const isControlled = isMultiple
|
||||
? props.value !== undefined
|
||||
: (props as AccordionSingleProps).value !== undefined;
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState<string[]>(
|
||||
isMultiple
|
||||
? props.defaultValue ?? []
|
||||
: normalizeSingleValue((props as AccordionSingleProps).defaultValue)
|
||||
);
|
||||
|
||||
const controlledValue = isMultiple
|
||||
? props.value
|
||||
: isControlled
|
||||
? normalizeSingleValue((props as AccordionSingleProps).value)
|
||||
: undefined;
|
||||
const value = controlledValue ?? uncontrolledValue;
|
||||
|
||||
const setValue = (nextItemValue: string) => {
|
||||
if (isMultiple) {
|
||||
const nextValue = value.includes(nextItemValue)
|
||||
? value.filter((item) => item !== nextItemValue)
|
||||
: [...value, nextItemValue];
|
||||
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue(nextValue);
|
||||
}
|
||||
|
||||
props.onValueChange?.(nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const collapsible = (props as AccordionSingleProps).collapsible ?? false;
|
||||
const nextValue =
|
||||
value[0] === nextItemValue
|
||||
? collapsible
|
||||
? undefined
|
||||
: value[0]
|
||||
: nextItemValue;
|
||||
|
||||
if (!isControlled) {
|
||||
setUncontrolledValue(normalizeSingleValue(nextValue));
|
||||
}
|
||||
|
||||
props.onValueChange?.(nextValue);
|
||||
};
|
||||
|
||||
return {
|
||||
openValues: value,
|
||||
setValue,
|
||||
type: isMultiple ? "multiple" : "single"
|
||||
} as const;
|
||||
}
|
||||
|
||||
function injectAccordionIndex(children: ReactNode) {
|
||||
return Children.map(children, (child, index) => {
|
||||
if (!isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return cloneElement(
|
||||
child as ReactElement<{ __accordionIndex?: number }>,
|
||||
{
|
||||
__accordionIndex: index
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const Accordion = forwardRef<HTMLDivElement, AccordionProps>(function Accordion(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { openValues, setValue, type } = useAccordionState(props);
|
||||
const isCollapsible =
|
||||
props.type === "multiple"
|
||||
? true
|
||||
: "collapsible" in props
|
||||
? props.collapsible ?? false
|
||||
: false;
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
collapsible: isCollapsible,
|
||||
openValues,
|
||||
setValue,
|
||||
type
|
||||
}),
|
||||
[isCollapsible, openValues, setValue, type]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={contextValue}>
|
||||
<div
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({ type })}
|
||||
className={cn(accordionRootVariants(), className)}
|
||||
ref={ref}
|
||||
>
|
||||
{injectAccordionIndex(children)}
|
||||
</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
export type AccordionItemProps = ComponentPropsWithoutRef<"div"> & {
|
||||
disabled?: boolean;
|
||||
value: string;
|
||||
__accordionIndex?: number;
|
||||
};
|
||||
|
||||
export const AccordionItem = forwardRef<HTMLDivElement, AccordionItemProps>(function AccordionItem(
|
||||
{
|
||||
__accordionIndex,
|
||||
children,
|
||||
className,
|
||||
disabled = false,
|
||||
value,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const accordion = useAccordionContext();
|
||||
const reactId = useId();
|
||||
const contentId = `accordion-content-${reactId.replace(/:/g, "")}`;
|
||||
const triggerId = `accordion-trigger-${reactId.replace(/:/g, "")}`;
|
||||
const open = accordion.openValues.includes(value);
|
||||
const itemContext = useMemo(
|
||||
() => ({
|
||||
contentId,
|
||||
disabled,
|
||||
open,
|
||||
triggerId,
|
||||
value
|
||||
}),
|
||||
[contentId, disabled, open, triggerId, value]
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItemContext.Provider value={itemContext}>
|
||||
<div
|
||||
{...props}
|
||||
{...createSlot("item")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
index: __accordionIndex,
|
||||
state: open ? "open" : "closed"
|
||||
})}
|
||||
className={cn(accordionItemVariants(), className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionItemContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
export type AccordionTriggerProps = ComponentPropsWithoutRef<"button"> & {
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export type AccordionTitleProps = ComponentPropsWithoutRef<"span">;
|
||||
|
||||
export const AccordionTitle = forwardRef<HTMLSpanElement, AccordionTitleProps>(
|
||||
function AccordionTitle({ className, ...props }, ref) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
{...createSlot("label")}
|
||||
className={cn(accordionTitleVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const AccordionTrigger = forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||
function AccordionTrigger({ children, className, icon, onClick, ...props }, ref) {
|
||||
const accordion = useAccordionContext();
|
||||
const item = useAccordionItemContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
{...createSlot("trigger")}
|
||||
{...createDataAttributes({
|
||||
disabled: item.disabled,
|
||||
state: item.open ? "open" : "closed"
|
||||
})}
|
||||
aria-controls={item.contentId}
|
||||
aria-disabled={item.disabled || undefined}
|
||||
aria-expanded={item.open}
|
||||
className={cn(accordionTriggerVariants(), className)}
|
||||
disabled={item.disabled}
|
||||
id={item.triggerId}
|
||||
onClick={(event) => {
|
||||
accordion.setValue(item.value);
|
||||
onClick?.(event);
|
||||
}}
|
||||
ref={ref}
|
||||
type="button"
|
||||
>
|
||||
{isValidElement(children) ? (
|
||||
children
|
||||
) : (
|
||||
<AccordionTitle>{children}</AccordionTitle>
|
||||
)}
|
||||
<span
|
||||
{...createSlot("icon")}
|
||||
{...createDataAttributes({
|
||||
state: item.open ? "open" : "closed"
|
||||
})}
|
||||
className={accordionIconVariants()}
|
||||
>
|
||||
{icon ?? <ChevronDownIcon className="size-4" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type AccordionContentProps = ComponentPropsWithoutRef<"div">;
|
||||
|
||||
export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps>(
|
||||
function AccordionContent({ children, className, style, ...props }, ref) {
|
||||
const item = useAccordionItemContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({
|
||||
state: item.open ? "open" : "closed"
|
||||
})}
|
||||
aria-hidden={!item.open || undefined}
|
||||
className={accordionContentVariants()}
|
||||
id={item.contentId}
|
||||
role="region"
|
||||
>
|
||||
<div
|
||||
{...props}
|
||||
className={cn(accordionContentInnerVariants(), className)}
|
||||
ref={ref}
|
||||
style={{
|
||||
...style,
|
||||
visibility: item.open ? "visible" : "hidden"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const accordionRootVariants = cva("grid gap-3");
|
||||
|
||||
export const accordionItemVariants = cva(
|
||||
[
|
||||
"overflow-hidden rounded-[var(--ui-card-radius)] border text-[var(--color-card-foreground)]",
|
||||
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] shadow-[var(--ui-card-default-shadow)]",
|
||||
"[border-width:var(--ui-card-border-width)]",
|
||||
"data-[disabled]:opacity-55"
|
||||
]
|
||||
);
|
||||
|
||||
export const accordionTriggerVariants = cva(
|
||||
[
|
||||
"flex w-full items-center justify-between gap-4 px-5 py-4 text-left outline-none",
|
||||
"text-[var(--color-foreground)] transition-[color,background-color,transform,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-inset",
|
||||
"data-[state=open]:bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_72%,var(--ui-card-default-bg))]",
|
||||
"data-[disabled]:cursor-not-allowed",
|
||||
getMotionRecipeClassNames("ring")
|
||||
]
|
||||
);
|
||||
|
||||
export const accordionTitleVariants = cva(
|
||||
"text-base font-semibold leading-6 tracking-[var(--tracking-tight)]"
|
||||
);
|
||||
|
||||
export const accordionIconVariants = cva(
|
||||
[
|
||||
"inline-flex size-8 shrink-0 items-center justify-center rounded-[var(--ui-control-radius)]",
|
||||
"bg-[var(--ui-control-bg)] text-[var(--color-muted-foreground)] shadow-[var(--ui-control-shadow)]",
|
||||
"transition-[transform,color,background-color] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
|
||||
"data-[state=open]:rotate-180 data-[state=open]:text-[var(--color-foreground)]"
|
||||
]
|
||||
);
|
||||
|
||||
export const accordionContentVariants = cva(
|
||||
[
|
||||
"grid overflow-hidden border-t border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]",
|
||||
"transition-[grid-template-rows,opacity] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
|
||||
"data-[state=closed]:grid-rows-[0fr] data-[state=closed]:opacity-70",
|
||||
"data-[state=open]:grid-rows-[1fr] data-[state=open]:opacity-100",
|
||||
getMotionRecipeClassNames("transition")
|
||||
]
|
||||
);
|
||||
|
||||
export const accordionContentInnerVariants = cva("min-h-0 px-5 pb-5 pt-1");
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbCurrent,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator
|
||||
} from "./breadcrumb";
|
||||
|
||||
describe("Breadcrumb", () => {
|
||||
it("renders semantic navigation, list, items, and current page state", () => {
|
||||
render(
|
||||
<Breadcrumb aria-label="Release path">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/releases">Releases</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbCurrent>Q2 Launch</BreadcrumbCurrent>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("navigation", { name: "Release path" })).toHaveAttribute(
|
||||
"data-slot",
|
||||
"root"
|
||||
);
|
||||
expect(screen.getByRole("list")).toHaveAttribute("data-slot", "list");
|
||||
expect(screen.getByRole("link", { name: "Releases" })).toHaveAttribute("data-slot", "link");
|
||||
expect(screen.getByText("Q2 Launch")).toHaveAttribute("aria-current", "page");
|
||||
expect(screen.getByText("Q2 Launch")).toHaveAttribute("data-current", "");
|
||||
});
|
||||
|
||||
it("supports custom separators", () => {
|
||||
render(
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="/runs">Runs</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbCurrent>run-42</BreadcrumbCurrent>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
expect(screen.getByText("/")).toHaveAttribute("data-slot", "separator");
|
||||
expect(screen.getByText("/")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
|
||||
it("supports asChild composition for custom links", () => {
|
||||
render(
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<button type="button">Open run</button>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Open run" });
|
||||
expect(button).toHaveAttribute("data-slot", "link");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
breadcrumbCurrentVariants,
|
||||
breadcrumbItemVariants,
|
||||
breadcrumbLinkVariants,
|
||||
breadcrumbListVariants,
|
||||
breadcrumbSeparatorVariants,
|
||||
breadcrumbVariants
|
||||
} from "./breadcrumb.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot, type AsChildProp } from "../lib/contracts";
|
||||
import { ChevronRightIcon } from "../lib/icons";
|
||||
|
||||
export type BreadcrumbProps = ComponentPropsWithoutRef<"nav">;
|
||||
|
||||
export const Breadcrumb = forwardRef<HTMLElement, BreadcrumbProps>(function Breadcrumb(
|
||||
{ className, "aria-label": ariaLabel = "Breadcrumb", ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<nav
|
||||
{...props}
|
||||
{...createSlot("root")}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(breadcrumbVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type BreadcrumbListProps = ComponentPropsWithoutRef<"ol">;
|
||||
|
||||
export const BreadcrumbList = forwardRef<HTMLOListElement, BreadcrumbListProps>(
|
||||
function BreadcrumbList({ className, ...props }, ref) {
|
||||
return (
|
||||
<ol
|
||||
{...props}
|
||||
{...createSlot("list")}
|
||||
className={cn(breadcrumbListVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type BreadcrumbItemProps = ComponentPropsWithoutRef<"li">;
|
||||
|
||||
export const BreadcrumbItem = forwardRef<HTMLLIElement, BreadcrumbItemProps>(
|
||||
function BreadcrumbItem({ className, ...props }, ref) {
|
||||
return (
|
||||
<li
|
||||
{...props}
|
||||
{...createSlot("item")}
|
||||
className={cn(breadcrumbItemVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type BreadcrumbLinkProps = ComponentPropsWithoutRef<"a"> & AsChildProp;
|
||||
|
||||
export const BreadcrumbLink = forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(
|
||||
function BreadcrumbLink({ asChild = false, className, ...props }, ref) {
|
||||
const Component = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
{...createSlot("link")}
|
||||
className={cn(breadcrumbLinkVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type BreadcrumbCurrentProps = ComponentPropsWithoutRef<"span">;
|
||||
|
||||
export const BreadcrumbCurrent = forwardRef<HTMLSpanElement, BreadcrumbCurrentProps>(
|
||||
function BreadcrumbCurrent({ className, ...props }, ref) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
{...createSlot("current")}
|
||||
{...createDataAttributes({ current: true })}
|
||||
aria-current="page"
|
||||
className={cn(breadcrumbCurrentVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type BreadcrumbSeparatorProps = ComponentPropsWithoutRef<"li"> & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const BreadcrumbSeparator = forwardRef<HTMLLIElement, BreadcrumbSeparatorProps>(
|
||||
function BreadcrumbSeparator({ children, className, ...props }, ref) {
|
||||
return (
|
||||
<li
|
||||
{...props}
|
||||
{...createSlot("separator")}
|
||||
aria-hidden="true"
|
||||
className={cn(breadcrumbSeparatorVariants(), className)}
|
||||
ref={ref}
|
||||
role="presentation"
|
||||
>
|
||||
{children ?? <ChevronRightIcon className="size-3.5" />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const breadcrumbVariants = cva(
|
||||
"w-full text-[var(--color-muted-foreground)]"
|
||||
);
|
||||
|
||||
export const breadcrumbListVariants = cva(
|
||||
"flex flex-wrap items-center gap-x-2 gap-y-1.5"
|
||||
);
|
||||
|
||||
export const breadcrumbItemVariants = cva(
|
||||
"inline-flex min-w-0 items-center gap-2"
|
||||
);
|
||||
|
||||
export const breadcrumbLinkVariants = cva(
|
||||
[
|
||||
"inline-flex min-w-0 items-center rounded-[var(--radius-sm)] text-sm font-medium",
|
||||
"text-[var(--color-muted-foreground)] outline-none",
|
||||
"transition-[color,background-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
|
||||
"hover:text-[var(--color-foreground)] focus-visible:text-[var(--color-foreground)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2",
|
||||
"focus-visible:ring-offset-[var(--color-background)]",
|
||||
getMotionRecipeClassNames("ring")
|
||||
]
|
||||
);
|
||||
|
||||
export const breadcrumbCurrentVariants = cva(
|
||||
"inline-flex min-w-0 items-center text-sm font-semibold text-[var(--color-foreground)]"
|
||||
);
|
||||
|
||||
export const breadcrumbSeparatorVariants = cva(
|
||||
"inline-flex size-4 shrink-0 items-center justify-center text-[var(--color-muted-foreground)]"
|
||||
);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger
|
||||
} from "./context-menu";
|
||||
|
||||
describe("ContextMenu", () => {
|
||||
it("opens on context menu interaction and renders label, items, and shortcuts", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>Open surface</ContextMenuTrigger>
|
||||
<ContextMenuContent size="lg">
|
||||
<ContextMenuLabel inset>File actions</ContextMenuLabel>
|
||||
<ContextMenuItem inset onSelect={onSelect}>
|
||||
Open
|
||||
<ContextMenuShortcut>O</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuCheckboxItem checked>Pin file</ContextMenuCheckboxItem>
|
||||
<ContextMenuRadioGroup value="write">
|
||||
<ContextMenuRadioItem value="write">Write</ContextMenuRadioItem>
|
||||
</ContextMenuRadioGroup>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Open surface"));
|
||||
|
||||
const menu = await screen.findByRole("menu");
|
||||
expect(menu).toHaveAttribute("data-slot", "content");
|
||||
expect(menu).toHaveAttribute("data-size", "lg");
|
||||
expect(screen.getByText("File actions")).toHaveAttribute("data-slot", "label");
|
||||
expect(screen.getByText("Open").closest('[data-slot="item"]')).toHaveAttribute(
|
||||
"data-inset",
|
||||
""
|
||||
);
|
||||
expect(screen.getByText("O")).toHaveAttribute("data-slot", "shortcut");
|
||||
expect(screen.getByText("Pin file").closest('[data-slot="item"]')).toHaveAttribute(
|
||||
"data-checked",
|
||||
""
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Open"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders richer row content and nested submenu descriptions", async () => {
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>Open surface</ContextMenuTrigger>
|
||||
<ContextMenuContent size="xl">
|
||||
<ContextMenuItem
|
||||
description="Open the selected file in a side-by-side preview."
|
||||
leading={<span data-testid="leading-icon">•</span>}
|
||||
shortcut="P"
|
||||
>
|
||||
Preview file
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger description="Open more file operations.">
|
||||
More actions
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem description="Archive the file without deleting it.">
|
||||
Archive file
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Open surface"));
|
||||
|
||||
const menu = await screen.findByRole("menu");
|
||||
expect(menu).toHaveAttribute("data-size", "xl");
|
||||
expect(screen.getByTestId("leading-icon").closest('[data-slot="leading"]')).toBeInTheDocument();
|
||||
expect(screen.getByText("Open the selected file in a side-by-side preview.")).toHaveAttribute(
|
||||
"data-slot",
|
||||
"description"
|
||||
);
|
||||
expect(screen.getByText("P")).toHaveAttribute("data-slot", "shortcut");
|
||||
});
|
||||
|
||||
it("closes on Escape after opening from a context interaction", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>Controlled surface</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>Open</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Controlled surface"));
|
||||
expect(await screen.findByRole("menu")).toBeInTheDocument();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import {
|
||||
forwardRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ElementRef,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
contextMenuContentVariants,
|
||||
contextMenuItemBodyVariants,
|
||||
contextMenuItemDescriptionVariants,
|
||||
contextMenuItemLabelVariants,
|
||||
contextMenuItemLeadingVariants,
|
||||
contextMenuItemVariants,
|
||||
contextMenuLabelVariants,
|
||||
contextMenuSeparatorVariants
|
||||
} from "./context-menu.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import type { VariantProps } from "../lib/cva";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
import { CheckIcon, ChevronRightIcon, DotIcon } from "../lib/icons";
|
||||
|
||||
export const ContextMenu = ContextMenuPrimitive.Root;
|
||||
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
export const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
export const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
export const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
type ContextMenuRichItemProps = {
|
||||
description?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
shortcut?: ReactNode;
|
||||
};
|
||||
|
||||
function ContextMenuItemContent({
|
||||
children,
|
||||
description,
|
||||
leading,
|
||||
shortcut
|
||||
}: PropsWithChildren<ContextMenuRichItemProps>) {
|
||||
return (
|
||||
<>
|
||||
{leading ? (
|
||||
<span
|
||||
{...createSlot("leading")}
|
||||
className={cn(contextMenuItemLeadingVariants())}
|
||||
>
|
||||
{leading}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
{...createSlot("body")}
|
||||
className={cn(contextMenuItemBodyVariants())}
|
||||
>
|
||||
<span
|
||||
{...createSlot("label")}
|
||||
className={cn(contextMenuItemLabelVariants())}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{description ? (
|
||||
<span
|
||||
{...createSlot("description")}
|
||||
className={cn(contextMenuItemDescriptionVariants())}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{shortcut ? <ContextMenuShortcut>{shortcut}</ContextMenuShortcut> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type ContextMenuContentProps =
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> &
|
||||
VariantProps<typeof contextMenuContentVariants>;
|
||||
|
||||
export const ContextMenuContent = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
ContextMenuContentProps
|
||||
>(function ContextMenuContent(
|
||||
{ className, size, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuPrimitive.Content
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({ size })}
|
||||
className={cn(contextMenuContentVariants({ size }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
</ContextMenuPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export type ContextMenuSubContentProps =
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> &
|
||||
VariantProps<typeof contextMenuContentVariants>;
|
||||
|
||||
export const ContextMenuSubContent = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
ContextMenuSubContentProps
|
||||
>(function ContextMenuSubContent(
|
||||
{ className, size, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuPrimitive.SubContent
|
||||
{...props}
|
||||
{...createSlot("content")}
|
||||
{...createDataAttributes({ size })}
|
||||
className={cn(contextMenuContentVariants({ size }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
</ContextMenuPortal>
|
||||
);
|
||||
});
|
||||
|
||||
export type ContextMenuItemProps =
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> &
|
||||
VariantProps<typeof contextMenuItemVariants> &
|
||||
ContextMenuRichItemProps;
|
||||
|
||||
export const ContextMenuItem = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
ContextMenuItemProps
|
||||
>(function ContextMenuItem(
|
||||
{ children, className, description, inset, leading, shortcut, variant, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
{...props}
|
||||
{...createSlot("item")}
|
||||
{...createDataAttributes({ inset, variant })}
|
||||
className={cn(contextMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
<ContextMenuItemContent
|
||||
description={description}
|
||||
leading={leading}
|
||||
shortcut={shortcut}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuItemContent>
|
||||
</ContextMenuPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
export type ContextMenuCheckboxItemProps =
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> &
|
||||
VariantProps<typeof contextMenuItemVariants> &
|
||||
Omit<ContextMenuRichItemProps, "leading">;
|
||||
|
||||
export const ContextMenuCheckboxItem = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
ContextMenuCheckboxItemProps
|
||||
>(function ContextMenuCheckboxItem(
|
||||
{
|
||||
checked,
|
||||
children,
|
||||
className,
|
||||
description,
|
||||
inset = true,
|
||||
shortcut,
|
||||
variant,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
{...props}
|
||||
checked={checked}
|
||||
{...createSlot("item")}
|
||||
{...createDataAttributes({ checked: checked === true, inset, variant })}
|
||||
className={cn(contextMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
<span
|
||||
{...createSlot("icon")}
|
||||
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-[var(--color-primary)]"
|
||||
>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-3" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<ContextMenuItemContent
|
||||
description={description}
|
||||
shortcut={shortcut}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuItemContent>
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
});
|
||||
|
||||
export type ContextMenuRadioItemProps =
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> &
|
||||
VariantProps<typeof contextMenuItemVariants> &
|
||||
Omit<ContextMenuRichItemProps, "leading">;
|
||||
|
||||
export const ContextMenuRadioItem = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
ContextMenuRadioItemProps
|
||||
>(function ContextMenuRadioItem(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
description,
|
||||
inset = true,
|
||||
shortcut,
|
||||
variant,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
{...props}
|
||||
{...createSlot("item")}
|
||||
{...createDataAttributes({ inset, variant })}
|
||||
className={cn(contextMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
<span
|
||||
{...createSlot("icon")}
|
||||
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-[var(--color-primary)]"
|
||||
>
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<DotIcon className="size-2.5" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<ContextMenuItemContent
|
||||
description={description}
|
||||
shortcut={shortcut}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuItemContent>
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
});
|
||||
|
||||
export type ContextMenuLabelProps =
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> &
|
||||
VariantProps<typeof contextMenuLabelVariants>;
|
||||
|
||||
export const ContextMenuLabel = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
ContextMenuLabelProps
|
||||
>(function ContextMenuLabel({ className, inset, ...props }, ref) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
{...props}
|
||||
{...createSlot("label")}
|
||||
{...createDataAttributes({ inset })}
|
||||
className={cn(contextMenuLabelVariants({ inset }), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ContextMenuSeparator = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(function ContextMenuSeparator({ className, ...props }, ref) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
{...props}
|
||||
{...createSlot("separator")}
|
||||
className={cn(contextMenuSeparatorVariants(), className)}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export type ContextMenuSubTriggerProps =
|
||||
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> &
|
||||
VariantProps<typeof contextMenuItemVariants> &
|
||||
Pick<ContextMenuRichItemProps, "description">;
|
||||
|
||||
export const ContextMenuSubTrigger = forwardRef<
|
||||
ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
ContextMenuSubTriggerProps
|
||||
>(function ContextMenuSubTrigger(
|
||||
{ children, className, description, inset, variant, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
{...props}
|
||||
{...createSlot("trigger")}
|
||||
{...createDataAttributes({ inset, variant })}
|
||||
className={cn(contextMenuItemVariants({ inset, variant }), className)}
|
||||
ref={ref}
|
||||
>
|
||||
<span
|
||||
{...createSlot("body")}
|
||||
className={cn(contextMenuItemBodyVariants())}
|
||||
>
|
||||
<span
|
||||
{...createSlot("label")}
|
||||
className={cn(contextMenuItemLabelVariants())}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{description ? (
|
||||
<span
|
||||
{...createSlot("description")}
|
||||
className={cn(contextMenuItemDescriptionVariants())}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronRightIcon className="ml-auto size-3 text-[var(--color-muted-foreground)]" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
export function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
{...createSlot("shortcut")}
|
||||
className={cn(
|
||||
"ml-auto text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
dropdownMenuContentVariants as contextMenuContentVariants,
|
||||
dropdownMenuItemBodyVariants as contextMenuItemBodyVariants,
|
||||
dropdownMenuItemDescriptionVariants as contextMenuItemDescriptionVariants,
|
||||
dropdownMenuItemLabelVariants as contextMenuItemLabelVariants,
|
||||
dropdownMenuItemLeadingVariants as contextMenuItemLeadingVariants,
|
||||
dropdownMenuItemVariants as contextMenuItemVariants,
|
||||
dropdownMenuLabelVariants as contextMenuLabelVariants,
|
||||
dropdownMenuSeparatorVariants as contextMenuSeparatorVariants
|
||||
} from "./dropdown-menu.variants";
|
||||
@@ -0,0 +1,106 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DatePicker } from "./date-picker";
|
||||
|
||||
describe("DatePicker", () => {
|
||||
it("renders a placeholder and selects a date in uncontrolled mode", async () => {
|
||||
render(
|
||||
<DatePicker
|
||||
aria-label="Launch date"
|
||||
defaultOpen
|
||||
placeholder="Pick launch date"
|
||||
/>
|
||||
);
|
||||
|
||||
const field = screen.getByRole("combobox", { name: "Launch date" });
|
||||
expect(field.closest('[data-slot="root"]')).toHaveAttribute("data-placeholder", "");
|
||||
|
||||
const calendar = screen.getByRole("grid");
|
||||
const dayButton = within(calendar).getAllByRole("gridcell")[10];
|
||||
|
||||
fireEvent.click(dayButton);
|
||||
|
||||
expect(field.closest('[data-slot="root"]')).not.toHaveAttribute("data-placeholder");
|
||||
expect(field).not.toHaveValue("");
|
||||
});
|
||||
|
||||
it("supports controlled values and emits changes", async () => {
|
||||
const onValueChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DatePicker
|
||||
aria-label="Controlled launch date"
|
||||
defaultOpen
|
||||
onValueChange={onValueChange}
|
||||
value={new Date(2026, 3, 18)}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("gridcell", {
|
||||
name: /Apr 20, 2026|20 Apr 2026|Apr 20 2026/i
|
||||
})
|
||||
);
|
||||
|
||||
expect(onValueChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports clearing the current value and choosing today", async () => {
|
||||
render(
|
||||
<DatePicker
|
||||
aria-label="Review date"
|
||||
defaultOpen
|
||||
defaultValue={new Date(2026, 4, 9)}
|
||||
/>
|
||||
);
|
||||
|
||||
const field = screen.getByRole("combobox", { name: "Review date" });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear date" }));
|
||||
|
||||
expect(field).toHaveValue("");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Today" }));
|
||||
|
||||
expect(field).not.toHaveValue("");
|
||||
});
|
||||
|
||||
it("supports month switching via controls and year selection", async () => {
|
||||
render(
|
||||
<DatePicker
|
||||
aria-label="Window date"
|
||||
defaultMonth={new Date(2026, 2, 1)}
|
||||
defaultOpen
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("March 2026")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Next month" }));
|
||||
expect(screen.getByText("April 2026")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("combobox", { name: "Year" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "2028" }));
|
||||
|
||||
expect(screen.getByText("April 2028")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("respects min and max dates", async () => {
|
||||
render(
|
||||
<DatePicker
|
||||
aria-label="Guardrailed date"
|
||||
defaultMonth={new Date(2026, 2, 1)}
|
||||
defaultOpen
|
||||
maxDate={new Date(2026, 2, 20)}
|
||||
minDate={new Date(2026, 2, 10)}
|
||||
/>
|
||||
);
|
||||
|
||||
const disabledDays = screen
|
||||
.getAllByRole("gridcell")
|
||||
.filter((cell) => cell.hasAttribute("data-disabled"));
|
||||
|
||||
expect(disabledDays.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,586 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type KeyboardEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
|
||||
import { Button } from "./button";
|
||||
import { Input } from "./input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent
|
||||
} from "./popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./select";
|
||||
import {
|
||||
datePickerCaptionVariants,
|
||||
datePickerContentVariants,
|
||||
datePickerDayVariants,
|
||||
datePickerFieldVariants,
|
||||
datePickerFooterVariants,
|
||||
datePickerGridVariants,
|
||||
datePickerHeaderVariants,
|
||||
datePickerMonthLabelVariants,
|
||||
datePickerNavigationVariants,
|
||||
datePickerRootVariants,
|
||||
datePickerSelectorsVariants,
|
||||
datePickerTriggerVariants,
|
||||
datePickerWeekdayVariants
|
||||
} from "./date-picker.variants";
|
||||
import { cn } from "../lib/cn";
|
||||
import { createDataAttributes, createSlot } from "../lib/contracts";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "../lib/icons";
|
||||
|
||||
type DatePickerValue = Date | undefined;
|
||||
|
||||
function startOfMonth(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), 1);
|
||||
}
|
||||
|
||||
function normalizeDate(value?: Date) {
|
||||
return value
|
||||
? new Date(value.getFullYear(), value.getMonth(), value.getDate())
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getDateKey(value?: Date) {
|
||||
return value ? `${value.getFullYear()}-${value.getMonth()}-${value.getDate()}` : "";
|
||||
}
|
||||
|
||||
function sameDay(left?: Date, right?: Date) {
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
left.getFullYear() === right.getFullYear() &&
|
||||
left.getMonth() === right.getMonth() &&
|
||||
left.getDate() === right.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value?: Date, locale?: string) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatHiddenValue(value?: Date) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = String(value.getFullYear());
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function formatMonthLabel(value: Date, locale?: string) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: "long",
|
||||
year: "numeric"
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function buildMonthGrid(month: Date) {
|
||||
const firstDay = startOfMonth(month);
|
||||
const startOffset = firstDay.getDay();
|
||||
const gridStart = new Date(firstDay);
|
||||
gridStart.setDate(firstDay.getDate() - startOffset);
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const day = new Date(gridStart);
|
||||
day.setDate(gridStart.getDate() + index);
|
||||
return day;
|
||||
});
|
||||
}
|
||||
|
||||
function isDateDisabled(date: Date, minDate?: Date, maxDate?: Date) {
|
||||
const value = normalizeDate(date)?.getTime();
|
||||
const min = normalizeDate(minDate)?.getTime();
|
||||
const max = normalizeDate(maxDate)?.getTime();
|
||||
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (min !== undefined && value < min) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function useControllableState<T>({
|
||||
controlledValue,
|
||||
defaultValue,
|
||||
onChange
|
||||
}: {
|
||||
controlledValue: T | undefined;
|
||||
defaultValue: T;
|
||||
onChange?: (value: T) => void;
|
||||
}) {
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
||||
const value = controlledValue ?? uncontrolledValue;
|
||||
|
||||
const setValue = (nextValue: T) => {
|
||||
if (controlledValue === undefined) {
|
||||
setUncontrolledValue(nextValue);
|
||||
}
|
||||
|
||||
onChange?.(nextValue);
|
||||
};
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
|
||||
function getYearOptions(displayMonth: Date, selectedDate?: Date) {
|
||||
const anchorYear = selectedDate?.getFullYear() ?? displayMonth.getFullYear();
|
||||
return Array.from({ length: 11 }, (_, index) => anchorYear - 5 + index);
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
|
||||
<rect
|
||||
height="10.5"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
width="11"
|
||||
x="2.5"
|
||||
y="3"
|
||||
/>
|
||||
<path d="M5 2v3M11 2v3M2.5 6.25h11" stroke="currentColor" strokeWidth="1.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type DatePickerProps = Omit<
|
||||
ComponentPropsWithoutRef<"input">,
|
||||
"defaultValue" | "onChange" | "size" | "value"
|
||||
> & {
|
||||
clearLabel?: ReactNode;
|
||||
defaultMonth?: Date;
|
||||
defaultOpen?: boolean;
|
||||
defaultValue?: Date;
|
||||
locale?: string;
|
||||
maxDate?: Date;
|
||||
minDate?: Date;
|
||||
onMonthChange?: (month: Date) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onValueChange?: (value: DatePickerValue) => void;
|
||||
open?: boolean;
|
||||
placeholder?: string;
|
||||
todayLabel?: ReactNode;
|
||||
value?: Date;
|
||||
};
|
||||
|
||||
export const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(function DatePicker(
|
||||
{
|
||||
className,
|
||||
clearLabel = "Clear date",
|
||||
defaultMonth,
|
||||
defaultOpen = false,
|
||||
defaultValue,
|
||||
disabled,
|
||||
id,
|
||||
locale,
|
||||
maxDate,
|
||||
minDate,
|
||||
name,
|
||||
onMonthChange,
|
||||
onOpenChange,
|
||||
onValueChange,
|
||||
open,
|
||||
placeholder = "Select date",
|
||||
todayLabel = "Today",
|
||||
value,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const reactId = useId();
|
||||
const today = useMemo(() => normalizeDate(new Date()), []);
|
||||
const normalizedControlledValue = useMemo(
|
||||
() => normalizeDate(value),
|
||||
[value ? getDateKey(value) : ""]
|
||||
);
|
||||
const normalizedDefaultValue = useMemo(
|
||||
() => normalizeDate(defaultValue),
|
||||
[defaultValue ? getDateKey(defaultValue) : ""]
|
||||
);
|
||||
const normalizedDefaultMonth = useMemo(
|
||||
() => normalizeDate(defaultMonth),
|
||||
[defaultMonth ? getDateKey(defaultMonth) : ""]
|
||||
);
|
||||
const [selectedDate, setSelectedDate] = useControllableState<DatePickerValue>({
|
||||
controlledValue: normalizedControlledValue,
|
||||
defaultValue: normalizedDefaultValue,
|
||||
onChange: onValueChange
|
||||
});
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const resolvedOpen = open ?? uncontrolledOpen;
|
||||
const [visibleMonth, setVisibleMonth] = useState(
|
||||
startOfMonth(normalizedDefaultMonth ?? normalizedDefaultValue ?? today ?? new Date())
|
||||
);
|
||||
const dayRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const controlId = id ?? `date-picker-${reactId.replace(/:/g, "")}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
setVisibleMonth(startOfMonth(selectedDate));
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const monthLabel = formatMonthLabel(visibleMonth, locale);
|
||||
const weekdays = useMemo(() => {
|
||||
const formatter = new Intl.DateTimeFormat(locale, { weekday: "short" });
|
||||
const base = new Date(2025, 0, 5);
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(base);
|
||||
day.setDate(base.getDate() + index);
|
||||
return formatter.format(day);
|
||||
});
|
||||
}, [locale]);
|
||||
const days = useMemo(() => buildMonthGrid(visibleMonth), [visibleMonth]);
|
||||
const yearOptions = useMemo(
|
||||
() => getYearOptions(visibleMonth, selectedDate),
|
||||
[selectedDate, visibleMonth]
|
||||
);
|
||||
const selectedIndex = days.findIndex((day) => sameDay(day, selectedDate));
|
||||
|
||||
useEffect(() => {
|
||||
dayRefs.current = [];
|
||||
}, [visibleMonth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusIndex =
|
||||
selectedIndex >= 0
|
||||
? selectedIndex
|
||||
: days.findIndex(
|
||||
(day) =>
|
||||
day.getMonth() === visibleMonth.getMonth() &&
|
||||
sameDay(day, today)
|
||||
);
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
dayRefs.current[focusIndex >= 0 ? focusIndex : 0]?.focus();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [days, resolvedOpen, selectedIndex, today, visibleMonth]);
|
||||
|
||||
const setOpenState = (nextOpen: boolean) => {
|
||||
if (open === undefined) {
|
||||
setUncontrolledOpen(nextOpen);
|
||||
}
|
||||
|
||||
onOpenChange?.(nextOpen);
|
||||
};
|
||||
|
||||
const goToMonth = (offset: number) => {
|
||||
const next = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + offset, 1);
|
||||
setVisibleMonth(next);
|
||||
onMonthChange?.(next);
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setOpenState(true);
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
setOpenState(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDayKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
const movementMap: Record<string, number> = {
|
||||
ArrowDown: 7,
|
||||
ArrowLeft: -1,
|
||||
ArrowRight: 1,
|
||||
ArrowUp: -7
|
||||
};
|
||||
|
||||
const movement = movementMap[event.key];
|
||||
|
||||
if (movement !== undefined) {
|
||||
event.preventDefault();
|
||||
const nextIndex = Math.min(Math.max(index + movement, 0), days.length - 1);
|
||||
const nextDate = days[nextIndex];
|
||||
|
||||
if (!nextDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
nextDate.getMonth() !== visibleMonth.getMonth() ||
|
||||
nextDate.getFullYear() !== visibleMonth.getFullYear()
|
||||
) {
|
||||
setVisibleMonth(startOfMonth(nextDate));
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
dayRefs.current[nextIndex]?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
const firstIndex = days.findIndex((day) => day.getMonth() === visibleMonth.getMonth());
|
||||
dayRefs.current[firstIndex >= 0 ? firstIndex : 0]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
const reverseIndex = [...days]
|
||||
.reverse()
|
||||
.findIndex((day) => day.getMonth() === visibleMonth.getMonth());
|
||||
const resolvedIndex =
|
||||
reverseIndex >= 0 ? days.length - 1 - reverseIndex : days.length - 1;
|
||||
dayRefs.current[resolvedIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpenState(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...createSlot("root")}
|
||||
{...createDataAttributes({
|
||||
disabled,
|
||||
invalid: props["aria-invalid"] || undefined,
|
||||
open: resolvedOpen,
|
||||
placeholder: selectedDate ? undefined : true
|
||||
})}
|
||||
className={datePickerRootVariants()}
|
||||
>
|
||||
<Popover onOpenChange={setOpenState} open={resolvedOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div {...createSlot("field")} className={datePickerFieldVariants()}>
|
||||
<Input
|
||||
{...props}
|
||||
aria-expanded={resolvedOpen}
|
||||
aria-haspopup="dialog"
|
||||
className={cn("cursor-pointer pr-20", className)}
|
||||
disabled={disabled}
|
||||
id={controlId}
|
||||
onClick={() => {
|
||||
setOpenState(true);
|
||||
}}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
placeholder={placeholder}
|
||||
readOnly
|
||||
ref={ref}
|
||||
role="combobox"
|
||||
value={selectedDate ? formatValue(selectedDate, locale) : ""}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-3 flex items-center gap-2 text-[var(--color-muted-foreground)]">
|
||||
<CalendarIcon />
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
|
||||
<PopoverContent className={datePickerContentVariants()} padding="sm" size="xl">
|
||||
<div {...createSlot("header")} className={datePickerHeaderVariants()}>
|
||||
<div className={datePickerNavigationVariants()}>
|
||||
<Button
|
||||
aria-label="Previous month"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
goToMonth(-1);
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className="size-3.5 rotate-180" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={datePickerSelectorsVariants()}>
|
||||
<Select
|
||||
value={String(visibleMonth.getMonth())}
|
||||
onValueChange={(nextValue) => {
|
||||
const nextMonth = new Date(visibleMonth.getFullYear(), Number(nextValue), 1);
|
||||
setVisibleMonth(nextMonth);
|
||||
onMonthChange?.(nextMonth);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="Month" className="w-full">
|
||||
<SelectValue placeholder={monthLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 12 }, (_, monthIndex) => {
|
||||
const monthName = new Intl.DateTimeFormat(locale, {
|
||||
month: "long"
|
||||
}).format(new Date(visibleMonth.getFullYear(), monthIndex, 1));
|
||||
|
||||
return (
|
||||
<SelectItem key={monthIndex} value={String(monthIndex)}>
|
||||
{monthName}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={String(visibleMonth.getFullYear())}
|
||||
onValueChange={(nextValue) => {
|
||||
const nextMonth = new Date(Number(nextValue), visibleMonth.getMonth(), 1);
|
||||
setVisibleMonth(nextMonth);
|
||||
onMonthChange?.(nextMonth);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="Year" className="w-full">
|
||||
<SelectValue placeholder={String(visibleMonth.getFullYear())} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((year) => (
|
||||
<SelectItem key={year} value={String(year)}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className={datePickerNavigationVariants()}>
|
||||
<Button
|
||||
aria-label="Next month"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
goToMonth(1);
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={datePickerCaptionVariants()}>{monthLabel}</p>
|
||||
|
||||
<div className={datePickerWeekdayVariants()}>
|
||||
{weekdays.map((weekday) => (
|
||||
<span key={weekday}>{weekday}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={datePickerGridVariants()} role="grid">
|
||||
{days.map((day, index) => {
|
||||
const outside = day.getMonth() !== visibleMonth.getMonth();
|
||||
const selected = sameDay(day, selectedDate);
|
||||
const isToday = sameDay(day, today);
|
||||
const dayDisabled = isDateDisabled(day, minDate, maxDate);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
{...createSlot("day")}
|
||||
{...createDataAttributes({
|
||||
disabled: dayDisabled,
|
||||
outside,
|
||||
selected,
|
||||
today: isToday
|
||||
})}
|
||||
aria-label={formatValue(day, locale)}
|
||||
aria-pressed={selected}
|
||||
className={datePickerDayVariants()}
|
||||
disabled={dayDisabled}
|
||||
onClick={() => {
|
||||
setSelectedDate(normalizeDate(day));
|
||||
setOpenState(false);
|
||||
}}
|
||||
onKeyDown={(event) => handleDayKeyDown(event, index)}
|
||||
ref={(node) => {
|
||||
dayRefs.current[index] = node;
|
||||
}}
|
||||
role="gridcell"
|
||||
type="button"
|
||||
>
|
||||
{day.getDate()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={datePickerFooterVariants()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
setSelectedDate(today);
|
||||
if (today) {
|
||||
setVisibleMonth(startOfMonth(today));
|
||||
}
|
||||
setOpenState(false);
|
||||
}}
|
||||
>
|
||||
{todayLabel}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSelectedDate(undefined);
|
||||
}}
|
||||
>
|
||||
{clearLabel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpenState(false);
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{name ? <input name={name} type="hidden" value={formatHiddenValue(selectedDate)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cva } from "../lib/cva";
|
||||
import { getMotionRecipeClassNames } from "../lib/motion";
|
||||
|
||||
export const datePickerRootVariants = cva("grid gap-2");
|
||||
|
||||
export const datePickerFieldVariants = cva("relative");
|
||||
|
||||
export const datePickerTriggerVariants = cva("w-full");
|
||||
|
||||
export const datePickerContentVariants = cva([
|
||||
"relative z-50 w-[21rem] overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)] bg-[var(--ui-panel-bg)] p-0 text-[var(--color-card-foreground)] shadow-[var(--ui-panel-shadow)] outline-none",
|
||||
"[border-width:var(--ui-panel-border-width)] backdrop-blur-[var(--ui-panel-backdrop-blur)]",
|
||||
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
|
||||
]);
|
||||
|
||||
export const datePickerHeaderVariants = cva(
|
||||
"grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
|
||||
);
|
||||
|
||||
export const datePickerNavigationVariants = cva("flex items-center gap-2");
|
||||
|
||||
export const datePickerSelectorsVariants = cva("grid gap-2 sm:grid-cols-2");
|
||||
|
||||
export const datePickerMonthLabelVariants = cva(
|
||||
"text-sm font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
|
||||
);
|
||||
|
||||
export const datePickerCaptionVariants = cva(
|
||||
"px-1 text-sm leading-6 text-[var(--color-muted-foreground)]"
|
||||
);
|
||||
|
||||
export const datePickerWeekdayVariants = cva(
|
||||
"grid grid-cols-7 gap-1 text-center text-[0.7rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
|
||||
);
|
||||
|
||||
export const datePickerGridVariants = cva("grid grid-cols-7 gap-1");
|
||||
|
||||
export const datePickerDayVariants = cva(
|
||||
[
|
||||
"inline-flex h-9 items-center justify-center rounded-[var(--ui-control-radius)] text-sm font-medium outline-none",
|
||||
"transition-[background-color,color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-panel-bg)]",
|
||||
"data-[outside=true]:text-[color-mix(in_oklch,var(--color-muted-foreground)_78%,transparent)]",
|
||||
"data-[today=true]:shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--color-primary)_26%,transparent)]",
|
||||
"data-[disabled=true]:pointer-events-none opacity-35",
|
||||
"data-[selected=true]:bg-[var(--color-primary)] data-[selected=true]:text-[var(--color-primary-foreground)] data-[selected=true]:shadow-[var(--ui-control-shadow)]",
|
||||
"hover:bg-[var(--ui-control-bg)] hover:text-[var(--color-foreground)]",
|
||||
getMotionRecipeClassNames("ring")
|
||||
]
|
||||
);
|
||||
|
||||
export const datePickerFooterVariants = cva(
|
||||
"flex flex-wrap items-center justify-between gap-3 border-t border-[var(--ui-panel-border)] pt-3"
|
||||
);
|
||||
@@ -25,8 +25,53 @@ export {
|
||||
avatarImageVariants,
|
||||
avatarVariants
|
||||
} from "./components/avatar.variants";
|
||||
export {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTitle,
|
||||
AccordionTrigger,
|
||||
type AccordionContentProps,
|
||||
type AccordionItemProps,
|
||||
type AccordionMultipleProps,
|
||||
type AccordionProps,
|
||||
type AccordionSingleProps,
|
||||
type AccordionTitleProps,
|
||||
type AccordionTriggerProps
|
||||
} from "./components/accordion";
|
||||
export {
|
||||
accordionContentInnerVariants,
|
||||
accordionContentVariants,
|
||||
accordionIconVariants,
|
||||
accordionItemVariants,
|
||||
accordionRootVariants,
|
||||
accordionTitleVariants,
|
||||
accordionTriggerVariants
|
||||
} from "./components/accordion.variants";
|
||||
export { Badge, type BadgeProps } from "./components/badge";
|
||||
export { badgeVariants } from "./components/badge.variants";
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbCurrent,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
type BreadcrumbCurrentProps,
|
||||
type BreadcrumbItemProps,
|
||||
type BreadcrumbLinkProps,
|
||||
type BreadcrumbListProps,
|
||||
type BreadcrumbProps,
|
||||
type BreadcrumbSeparatorProps
|
||||
} from "./components/breadcrumb";
|
||||
export {
|
||||
breadcrumbCurrentVariants,
|
||||
breadcrumbItemVariants,
|
||||
breadcrumbLinkVariants,
|
||||
breadcrumbListVariants,
|
||||
breadcrumbSeparatorVariants,
|
||||
breadcrumbVariants
|
||||
} from "./components/breadcrumb.variants";
|
||||
export { Button, type ButtonProps } from "./components/button";
|
||||
export { buttonVariants } from "./components/button.variants";
|
||||
export {
|
||||
@@ -103,6 +148,52 @@ export {
|
||||
dataTableTableVariants,
|
||||
dataTableToolbarVariants
|
||||
} from "./components/data-table.variants";
|
||||
export { DatePicker, type DatePickerProps } from "./components/date-picker";
|
||||
export {
|
||||
datePickerContentVariants,
|
||||
datePickerDayVariants,
|
||||
datePickerFooterVariants,
|
||||
datePickerGridVariants,
|
||||
datePickerHeaderVariants,
|
||||
datePickerMonthLabelVariants,
|
||||
datePickerRootVariants,
|
||||
datePickerTriggerVariants,
|
||||
datePickerWeekdayVariants
|
||||
} from "./components/date-picker.variants";
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRadioGroup,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
type ContextMenuCheckboxItemProps,
|
||||
type ContextMenuContentProps,
|
||||
type ContextMenuItemProps,
|
||||
type ContextMenuLabelProps,
|
||||
type ContextMenuRadioItemProps,
|
||||
type ContextMenuSubContentProps,
|
||||
type ContextMenuSubTriggerProps
|
||||
} from "./components/context-menu";
|
||||
export {
|
||||
contextMenuContentVariants,
|
||||
contextMenuItemBodyVariants,
|
||||
contextMenuItemDescriptionVariants,
|
||||
contextMenuItemLabelVariants,
|
||||
contextMenuItemLeadingVariants,
|
||||
contextMenuItemVariants,
|
||||
contextMenuLabelVariants,
|
||||
contextMenuSeparatorVariants
|
||||
} from "./components/context-menu.variants";
|
||||
export {
|
||||
Combobox,
|
||||
type ComboboxItem,
|
||||
|
||||
Generated
+30
@@ -121,6 +121,9 @@ importers:
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.3
|
||||
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-context-menu':
|
||||
specifier: ^2.2.15
|
||||
version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -911,6 +914,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context-menu@2.2.16':
|
||||
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.2':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
@@ -4341,6 +4357,20 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
|
||||
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.28
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.28)
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
@@ -27,6 +27,36 @@
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui/tokens"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Accordion component.",
|
||||
"displayName": "Accordion",
|
||||
"entrypoints": [
|
||||
"packages/ui/src/components/accordion.tsx"
|
||||
],
|
||||
"files": [
|
||||
"packages/ui/src/components/accordion.tsx",
|
||||
"packages/ui/src/components/accordion.variants.ts",
|
||||
"packages/ui/src/lib/cn.ts",
|
||||
"packages/ui/src/lib/contracts.ts",
|
||||
"packages/ui/src/lib/cva.ts",
|
||||
"packages/ui/src/lib/icons.tsx",
|
||||
"packages/ui/src/lib/motion.ts"
|
||||
],
|
||||
"kind": "component",
|
||||
"name": "accordion",
|
||||
"packageDependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"requires": [
|
||||
"tokens"
|
||||
],
|
||||
"sourcePackage": "@ai-ui/ui",
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Alert component.",
|
||||
"displayName": "Alert",
|
||||
@@ -116,6 +146,37 @@
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Breadcrumb component.",
|
||||
"displayName": "Breadcrumb",
|
||||
"entrypoints": [
|
||||
"packages/ui/src/components/breadcrumb.tsx"
|
||||
],
|
||||
"files": [
|
||||
"packages/ui/src/components/breadcrumb.tsx",
|
||||
"packages/ui/src/components/breadcrumb.variants.ts",
|
||||
"packages/ui/src/lib/cn.ts",
|
||||
"packages/ui/src/lib/contracts.ts",
|
||||
"packages/ui/src/lib/cva.ts",
|
||||
"packages/ui/src/lib/icons.tsx",
|
||||
"packages/ui/src/lib/motion.ts"
|
||||
],
|
||||
"kind": "component",
|
||||
"name": "breadcrumb",
|
||||
"packageDependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"requires": [
|
||||
"tokens"
|
||||
],
|
||||
"sourcePackage": "@ai-ui/ui",
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Button component.",
|
||||
"displayName": "Button",
|
||||
@@ -274,6 +335,37 @@
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Context Menu component.",
|
||||
"displayName": "Context Menu",
|
||||
"entrypoints": [
|
||||
"packages/ui/src/components/context-menu.tsx"
|
||||
],
|
||||
"files": [
|
||||
"packages/ui/src/components/context-menu.tsx",
|
||||
"packages/ui/src/components/context-menu.variants.ts",
|
||||
"packages/ui/src/components/dropdown-menu.variants.ts",
|
||||
"packages/ui/src/lib/cn.ts",
|
||||
"packages/ui/src/lib/contracts.ts",
|
||||
"packages/ui/src/lib/cva.ts",
|
||||
"packages/ui/src/lib/icons.tsx"
|
||||
],
|
||||
"kind": "component",
|
||||
"name": "context-menu",
|
||||
"packageDependencies": {
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"requires": [
|
||||
"tokens"
|
||||
],
|
||||
"sourcePackage": "@ai-ui/ui",
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Data Table component.",
|
||||
"displayName": "Data Table",
|
||||
@@ -330,6 +422,50 @@
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Date Picker component.",
|
||||
"displayName": "Date Picker",
|
||||
"entrypoints": [
|
||||
"packages/ui/src/components/date-picker.tsx"
|
||||
],
|
||||
"files": [
|
||||
"packages/ui/src/components/button.tsx",
|
||||
"packages/ui/src/components/button.variants.ts",
|
||||
"packages/ui/src/components/date-picker.tsx",
|
||||
"packages/ui/src/components/date-picker.variants.ts",
|
||||
"packages/ui/src/components/field.tsx",
|
||||
"packages/ui/src/components/input.tsx",
|
||||
"packages/ui/src/components/input.variants.ts",
|
||||
"packages/ui/src/components/label.tsx",
|
||||
"packages/ui/src/components/popover.tsx",
|
||||
"packages/ui/src/components/popover.variants.ts",
|
||||
"packages/ui/src/components/select.tsx",
|
||||
"packages/ui/src/components/select.variants.ts",
|
||||
"packages/ui/src/lib/cn.ts",
|
||||
"packages/ui/src/lib/contracts.ts",
|
||||
"packages/ui/src/lib/cva.ts",
|
||||
"packages/ui/src/lib/icons.tsx",
|
||||
"packages/ui/src/lib/motion.ts"
|
||||
],
|
||||
"kind": "component",
|
||||
"name": "date-picker",
|
||||
"packageDependencies": {
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^18.3.1 || ^19.0.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"requires": [
|
||||
"tokens"
|
||||
],
|
||||
"sourcePackage": "@ai-ui/ui",
|
||||
"sourceVersion": "0.0.0",
|
||||
"targetDirectory": "src/cadence-ui"
|
||||
},
|
||||
{
|
||||
"description": "Source-owned Dialog component.",
|
||||
"displayName": "Dialog",
|
||||
|
||||
@@ -356,6 +356,7 @@ Current shipped patterns:
|
||||
- `Data Table`
|
||||
- `Command`
|
||||
- `Combobox`
|
||||
- `Date Picker`
|
||||
- `Sheet`
|
||||
- `Empty State`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user