diff --git a/.changeset/wise-crabs-heal.md b/.changeset/wise-crabs-heal.md new file mode 100644 index 0000000..f63db99 --- /dev/null +++ b/.changeset/wise-crabs-heal.md @@ -0,0 +1,5 @@ +--- +"@ai-ui/ui": minor +--- + +Add Accordion, Breadcrumb, ContextMenu, and a single-date DatePicker to round out workflow and navigation primitives. diff --git a/apps/docs/src/components/accordion.stories.tsx b/apps/docs/src/components/accordion.stories.tsx new file mode 100644 index 0000000..0e8e0f6 --- /dev/null +++ b/apps/docs/src/components/accordion.stories.tsx @@ -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 ( +
+ + + Editorial review + + The editorial lane is ready for the final release note pass and legal cross-check. + + + + Engineering canary + + Canary thresholds are green and the 10% wave can begin after routing sign-off. + + + + Support queue + + Customer macros are staged and only need one more quiet-hour review. + + + +
+ ); +} + +function AccordionFaq() { + return ( +
+ + + Why use Accordion instead of Tabs? + + 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. + + + + Where does it fit best? + + Settings groups, FAQ sections, filter drawers, release notes, audit explanations, and + inspector panels all benefit from lightweight disclosure. + + + + How does motion behave? + + The content region uses the shared motion contract for expansion and still respects the + static motion mode when the user needs a quieter interface. + + + +
+ ); +} + +function AccordionControlPanel() { + return ( +
+ + + +
+ + Release + + Wave rollout controls +
+
+ +
+

+ Stage a 10% wave, keep rollback thresholds visible, and hold broader rollout until + the support digest is approved. +

+
+ + +
+
+
+
+ + + +
+ + Audit + + Open outstanding review notes +
+
+ +
+

Legal footnote still needs one sentence tightened before public launch.

+

Customer support messaging is approved but waiting on the rollout window.

+
+
+
+
+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Faq: Story = { + render: () => +}; + +export const ControlPanel: Story = { + render: () => +}; diff --git a/apps/docs/src/components/breadcrumb.stories.tsx b/apps/docs/src/components/breadcrumb.stories.tsx new file mode 100644 index 0000000..bdeee9e --- /dev/null +++ b/apps/docs/src/components/breadcrumb.stories.tsx @@ -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 ( +
+ + + Workflow navigation + + Breadcrumb should stabilize layered navigation across runs, threads, queues, + and operator workspaces without competing with page headings. + + + +
+

+ Run detail path +

+ + + + Runs + + + + run-42 + + + + Thread timeline + + + +
+ +
+

+ Environment drill-down +

+ + + + Cadence Labs + + + + Agent platform + + + + Production + + + + Deploy guardrails + + + +
+ +
+

+ Mixed actions +

+ + + + + Holding + + + + + + + + + + + Review queue + + + +
+
+
+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/apps/docs/src/components/context-menu.stories.tsx b/apps/docs/src/components/context-menu.stories.tsx new file mode 100644 index 0000000..b9e7f2a --- /dev/null +++ b/apps/docs/src/components/context-menu.stories.tsx @@ -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 ( + + ); +} + +function EyeIcon() { + return ( + + ); +} + +function FileRowContextMenu({ label = "Open file menu" }: { label?: string }) { + return ( + + +
+
+
+ + + +
+

release-plan.md

+

+ Right click to open contextual actions. +

+
+
+ + docs + +
+ {label} +
+
+ + release-plan.md + } + shortcut="P" + > + Preview file + + } + shortcut="R" + > + Reveal in workspace + + + + Keep pinned + + + + Write access + + + Read-only access + + + + + + More actions + + + + Duplicate file + + + Archive file + + + + +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => +}; + +export const FileRowWorkflow: Story = { + render: () => ( +
+ +
+ This example is designed for real workflow rows. Right click the file card to reveal + preview, workspace, pinning, permission, and archive actions. +
+
+ ) +}; + +export const DensePanels: Story = { + render: () => ( +
+
+

+ File row context menu +

+

+ This menu mirrors the richer rows from DropdownMenu, but the trigger is a context + surface instead of a button. +

+
+ +
+
+ +
+

+ Data table row actions +

+

+ Use a context menu when table rows need denser actions than an inline action column can + comfortably show. +

+
+ + +
+
+ + Run 184 · release wave + + + blocked + +
+

+ Right click this row to route the issue, open the thread, or escalate the blocker. +

+
+
+ + + Open run detail + + + Open blocked thread + + + + Escalate blocker + + +
+
+
+
+ ) +}; diff --git a/apps/docs/src/components/date-picker.stories.tsx b/apps/docs/src/components/date-picker.stories.tsx new file mode 100644 index 0000000..69852b3 --- /dev/null +++ b/apps/docs/src/components/date-picker.stories.tsx @@ -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(new Date(2026, 3, 18)); + + return ( +
+ + +
+
+ Launch scheduling + + Pick a single launch date from a lightweight calendar surface. + +
+ + single date + +
+
+ + + +
+
+ ); +} + +function DatePickerScenarios() { + return ( +
+ + + Empty state + Use the field as a clean trigger for a future date choice. + + + + + + + + + Guardrailed window + + Limit choices to a narrow release window without turning the API into a range picker. + + + + + + +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Scenarios: Story = { + render: () => +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 0b2af7b..1c5e5cf 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/accordion.test.tsx b/packages/ui/src/components/accordion.test.tsx new file mode 100644 index 0000000..d5fc0a1 --- /dev/null +++ b/packages/ui/src/components/accordion.test.tsx @@ -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 ( + + + Editorial review + Copy is locked for launch review. + + + Legal review + Policy language still needs sign-off. + + + ); +} + +describe("Accordion", () => { + it("opens one item at a time in single mode", async () => { + const user = userEvent.setup(); + + render(); + + 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(); + + 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( + + ); + + 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(); + + 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")); + }); +}); diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx new file mode 100644 index 0000000..4a4b08b --- /dev/null +++ b/packages/ui/src/components/accordion.tsx @@ -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, "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(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(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( + 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(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 ( + +
+ {injectAccordionIndex(children)} +
+
+ ); +}); + +export type AccordionItemProps = ComponentPropsWithoutRef<"div"> & { + disabled?: boolean; + value: string; + __accordionIndex?: number; +}; + +export const AccordionItem = forwardRef(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 ( + +
+ {children} +
+
+ ); +}); + +export type AccordionTriggerProps = ComponentPropsWithoutRef<"button"> & { + icon?: ReactNode; +}; + +export type AccordionTitleProps = ComponentPropsWithoutRef<"span">; + +export const AccordionTitle = forwardRef( + function AccordionTitle({ className, ...props }, ref) { + return ( + + ); + } +); + +export const AccordionTrigger = forwardRef( + function AccordionTrigger({ children, className, icon, onClick, ...props }, ref) { + const accordion = useAccordionContext(); + const item = useAccordionItemContext(); + + return ( + + ); + } +); + +export type AccordionContentProps = ComponentPropsWithoutRef<"div">; + +export const AccordionContent = forwardRef( + function AccordionContent({ children, className, style, ...props }, ref) { + const item = useAccordionItemContext(); + + return ( +
+
+ {children} +
+
+ ); + } +); diff --git a/packages/ui/src/components/accordion.variants.ts b/packages/ui/src/components/accordion.variants.ts new file mode 100644 index 0000000..70ff380 --- /dev/null +++ b/packages/ui/src/components/accordion.variants.ts @@ -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"); diff --git a/packages/ui/src/components/breadcrumb.test.tsx b/packages/ui/src/components/breadcrumb.test.tsx new file mode 100644 index 0000000..1f8785f --- /dev/null +++ b/packages/ui/src/components/breadcrumb.test.tsx @@ -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( + + + + Releases + + + + Q2 Launch + + + + ); + + 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( + + + + Runs + + / + + run-42 + + + + ); + + expect(screen.getByText("/")).toHaveAttribute("data-slot", "separator"); + expect(screen.getByText("/")).toHaveAttribute("aria-hidden", "true"); + }); + + it("supports asChild composition for custom links", () => { + render( + + + + + + + + + + ); + + const button = screen.getByRole("button", { name: "Open run" }); + expect(button).toHaveAttribute("data-slot", "link"); + }); +}); diff --git a/packages/ui/src/components/breadcrumb.tsx b/packages/ui/src/components/breadcrumb.tsx new file mode 100644 index 0000000..78042a5 --- /dev/null +++ b/packages/ui/src/components/breadcrumb.tsx @@ -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(function Breadcrumb( + { className, "aria-label": ariaLabel = "Breadcrumb", ...props }, + ref +) { + return ( +