feat(ui): add navigation and picker primitives

This commit is contained in:
2026-03-22 23:38:31 +08:00
parent a8c1d3f256
commit 4d67f4ad76
22 changed files with 2805 additions and 0 deletions
+5
View File
@@ -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 />
};
+1
View File
@@ -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"));
});
});
+353
View File
@@ -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");
});
});
+116
View File
@@ -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();
});
});
});
+343
View File
@@ -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);
});
});
+586
View File
@@ -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"
);
+91
View File
@@ -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,
+30
View File
@@ -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
+136
View File
@@ -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",
+1
View File
@@ -356,6 +356,7 @@ Current shipped patterns:
- `Data Table`
- `Command`
- `Combobox`
- `Date Picker`
- `Sheet`
- `Empty State`