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
@@ -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"
);