diff --git a/.changeset/bright-tools-gather.md b/.changeset/bright-tools-gather.md new file mode 100644 index 0000000..b59044e --- /dev/null +++ b/.changeset/bright-tools-gather.md @@ -0,0 +1,7 @@ +--- +"@karnstack/dowel": minor +--- + +Add Accordion, Breadcrumbs, Calendar and DatePicker, Collapsible, Combobox, CommandMenu, ContextMenu, Drawer, FileUpload and Dropzone, List collection primitives, Pagination, Progress, Slider, Toast with a global manager, ToggleGroup, and TreeView. + +Calendar and DatePicker use React Aria behavior and timezone-safe `CalendarDate` values from `@internationalized/date`. diff --git a/README.md b/README.md index 36803c6..7c4b996 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The system theme is the default. Pass `light` or `dark` to pin a theme. ## Components -Alert Dialog · Avatar · Badge · Button · Callout · Checkbox · Composer · Data Table · Dialog · Empty State · Icon Button · Input · Kbd · Menu · Native Select · Popover · Property Picker · Radio Group · Search Field · Select · Separator · Sidebar · Skeleton · Spinner · Status · Switch · Tabs · Tooltip +Alert Dialog · Avatar · Badge · Button · Callout · Checkbox · Combobox · Command Menu · Composer · Data Table · Dialog · Drawer · Empty State · Icon Button · Input · Kbd · List · Menu · Native Select · Popover · Progress · Property Picker · Radio Group · Search Field · Select · Separator · Sidebar · Skeleton · Spinner · Status · Switch · Tabs · Toast · Tooltip Every component is keyboard tested and axe checked. The rendered catalog is contrast tested against WCAG 2.2 AA in light and dark. Visual controls own their appearance. Layout primitives accept application shell hooks. diff --git a/apps/docs/src/components/command-search.tsx b/apps/docs/src/components/command-search.tsx index 5b74315..611f412 100644 --- a/apps/docs/src/components/command-search.tsx +++ b/apps/docs/src/components/command-search.tsx @@ -1,135 +1,39 @@ -import { Dialog, Input, Kbd } from "@karnstack/dowel"; +import { CommandMenu } from "@karnstack/dowel"; import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; -import { componentNav } from "../lib/nav"; -import { SearchIcon } from "./icons"; - -const allComponents = { - title: "All components", - to: "/components" as const, - summary: "Browse every component in the library.", -}; +import { nav } from "../lib/nav"; export function CommandSearch({ - open, onOpenChange, }: { - open: boolean; onOpenChange: (open: boolean) => void; }) { const navigate = useNavigate(); - const [query, setQuery] = useState(""); - const [activeIndex, setActiveIndex] = useState(0); - const results = useMemo(() => { - const normalized = query.trim().toLowerCase(); - const items = [allComponents, ...componentNav]; - if (!normalized) return items; - return items.filter((item) => - `${item.title} ${item.summary}`.toLowerCase().includes(normalized), - ); - }, [query]); - - useEffect(() => setActiveIndex(0), [query]); - useEffect(() => { - if (!open) setQuery(""); - }, [open]); - - function openResult(index: number) { - const result = results[index]; - if (!result) return; - onOpenChange(false); - void navigate({ to: result.to }); - } + const items = useMemo( + () => + nav.flatMap((section) => + section.items.map((item) => ({ + id: `${section.title}:${item.to}`, + label: item.title, + description: item.summary, + group: section.title, + keywords: [section.title], + onSelect: () => void navigate({ to: item.to }), + })), + ), + [navigate], + ); return ( - - - - -
-
- Search components -
-
- - setQuery(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - if (!results.length) return; - setActiveIndex((index) => - Math.min(index + 1, results.length - 1), - ); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - if (!results.length) return; - setActiveIndex((index) => Math.max(index - 1, 0)); - } else if (event.key === "Enter") { - event.preventDefault(); - openResult(activeIndex); - } - }} - /> - -
- -
- {results.length ? ( - results.map((result, index) => ( - - )) - ) : ( -

No components found.

- )} -
- - -
-
-
-
+ ); } diff --git a/apps/docs/src/components/component-gallery.tsx b/apps/docs/src/components/component-gallery.tsx index 89eb7f9..1176d8b 100644 --- a/apps/docs/src/components/component-gallery.tsx +++ b/apps/docs/src/components/component-gallery.tsx @@ -1,24 +1,38 @@ import { Link } from "@tanstack/react-router"; import type { LinkProps } from "@tanstack/react-router"; import { + Accordion, AlertDialog, Avatar, Badge, + Breadcrumbs, Button, Callout, + Collapsible, Checkbox, CheckboxGroup, + Combobox, + CommandMenu, Composer, + ContextMenu, DataTable, + DatePicker, Dialog, + Drawer, EmptyState, Field, + FileUpload, IconButton, Input, Kbd, + List, + ListCell, + ListRow, Menu, NativeSelect, + Pagination, Popover, + Progress, PropertyPicker, PropertyPill, RadioGroup, @@ -26,15 +40,22 @@ import { Separator, Select, Sidebar, + Slider, Skeleton, Spinner, Status, Switch, Tabs, Textarea, + ToastProvider, + ToggleGroup, Tooltip, + TreeView, createDataTableColumnHelper, + parseDate, + toast, } from "@karnstack/dowel"; +import { useState } from "react"; import type { ReactNode } from "react"; import { @@ -103,10 +124,34 @@ function GalleryItem({ ); } +function GalleryPagination() { + const [page, setPage] = useState(3); + return ; +} + export function ComponentGallery() { return (
    + + + + + Project details + + + + Visible to workspace members. + + + + + + More + + + + + + + + Two approvals are required before merging. @@ -207,6 +270,32 @@ export function ComponentGallery() { /> + + + + + + + Advanced settings + + + Branch protection enabled. + + + + + Description @@ -277,6 +366,26 @@ export function ComponentGallery() { + + + + Right-click for project actions + + + + + Rename + Duplicate + + + + + + New project} /> @@ -306,6 +415,50 @@ export function ComponentGallery() { + + + Open details} /> + + + + + + + Repository details + + Metadata for dowel. + + + + Close} /> + + + + + + + + + + Open commands} + items={[ + { + id: "repo", + label: "Open repository", + group: "Navigation", + onSelect: () => {}, + }, + { + id: "settings", + label: "Open settings", + group: "Navigation", + onSelect: () => {}, + }, + ]} + /> + + Repository access} /> @@ -373,6 +526,35 @@ export function ComponentGallery() { + + + + + + + + + + + Volume + + + + + + + + + + + + + + + + @@ -406,6 +588,33 @@ export function ComponentGallery() { + + + List + Board + + + + + + + + + + + dowel + Synced + + + sourcetown + Updated yesterday + + + + + + + + void; +}) { + const active = section.items.some((item) => item.to === pathname); + const [open, setOpen] = useState(true); + const itemsId = useId(); + + useEffect(() => { + if (active) setOpen(true); + }, [active]); + + return ( +
    + +
    +
      + {section.items.map((item) => ( +
    • + + {item.title} + +
    • + ))} +
    +
    +
    + ); +} /** * One nav, rendered twice: once in the sticky desktop sidebar and once in the @@ -8,27 +72,19 @@ import { nav } from "../lib/nav"; * listing different components, which is the usual way a mobile menu rots. */ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); + return ( ); diff --git a/apps/docs/src/docs.css b/apps/docs/src/docs.css index 9f63896..8040ac1 100644 --- a/apps/docs/src/docs.css +++ b/apps/docs/src/docs.css @@ -181,135 +181,102 @@ body { gap: var(--dowel-space-3); } -/* --------------------------------------------------------- command search */ - -.docs-command { - display: flex; - flex-direction: column; - max-block-size: min(34rem, calc(100dvh - 4rem)); - overflow: hidden; - border: 1px solid var(--dowel-border-2); - border-radius: var(--dowel-radius-lg); - background: var(--dowel-bg-elevated); - box-shadow: var(--docs-panel-shadow); -} +/* ------------------------------------------------------------------- nav */ -.docs-command-input { - display: flex; - flex: none; - align-items: center; - gap: var(--dowel-space-4); - min-block-size: 3.25rem; - padding-inline: var(--dowel-space-7); - border-block-end: 1px solid var(--dowel-border-1); - color: var(--dowel-text-3); +.docs-nav-section { + margin-block-end: var(--dowel-space-1); + transition: margin-block-end var(--dowel-dur-fast) var(--dowel-ease); } -.docs-command-input [data-dowel-component="input"] { - flex: 1; - min-inline-size: 0; - block-size: 3rem; - padding-inline: 0; - font-size: 1rem; +.docs-nav-section[data-open] { + margin-block-end: var(--dowel-space-4); } -.docs-command-results { +.docs-nav-heading { display: flex; - flex-direction: column; - gap: 1px; - min-block-size: 0; - overflow-y: auto; - padding: var(--dowel-space-3); -} - -.docs-command-result { - display: grid; - gap: 2px; + align-items: center; + gap: var(--dowel-space-2); inline-size: 100%; - min-block-size: 3rem; - padding: var(--dowel-space-4) var(--dowel-space-6); + min-block-size: 1.75rem; + margin: 0; + padding: 0 var(--dowel-space-3); border: 0; border-radius: 6px; background: transparent; - color: var(--dowel-text-1); + color: var(--dowel-text-2); font: inherit; + font-size: 0.6875rem; + font-weight: var(--dowel-fw-medium); + letter-spacing: 0.04em; text-align: start; + text-transform: uppercase; cursor: pointer; } -.docs-command-result[aria-selected="true"] { +.docs-nav-heading:hover { + color: var(--dowel-text-1); background: var(--dowel-bg-2); } -.docs-command-result:focus-visible { +.docs-nav-heading:focus-visible { outline: 2px solid var(--dowel-focus); outline-offset: -2px; } -.docs-command-result span { - font-size: var(--dowel-fs-small); - font-weight: var(--dowel-fw-medium); -} - -.docs-command-result small { - overflow: hidden; - color: var(--dowel-text-3); - font-size: var(--dowel-fs-mini); - font-weight: var(--dowel-fw-normal); - text-overflow: ellipsis; - white-space: nowrap; +.docs-nav-section[data-active] > .docs-nav-heading { + color: var(--dowel-text-1); } -.docs-command-empty { - margin: 0; - padding: 2rem var(--dowel-space-6); +.docs-nav-chevron { + display: inline-flex; + flex: none; + margin-inline-start: auto; color: var(--dowel-text-3); - font-size: var(--dowel-fs-small); - text-align: center; + transition: transform var(--dowel-dur-fast) var(--dowel-ease); } -.docs-command-footer { - display: flex; - flex: none; - align-items: center; - justify-content: flex-end; - gap: var(--dowel-space-7); - min-block-size: 2.25rem; - padding-inline: var(--dowel-space-6); - border-block-start: 1px solid var(--dowel-border-1); - color: var(--dowel-text-4); - font-size: var(--dowel-fs-mini); +.docs-nav-chevron svg { + block-size: 0.75rem; + inline-size: 0.75rem; } -.docs-command-footer span { - display: inline-flex; - align-items: center; - gap: var(--dowel-space-2); +.docs-nav-section[data-open] .docs-nav-chevron { + transform: rotate(90deg); } -/* ------------------------------------------------------------------- nav */ - -.docs-nav-section { - margin-block-end: var(--dowel-space-7); +.docs-nav-items { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 180ms cubic-bezier(0.22, 1, 0.36, 1), + opacity var(--dowel-dur-fast) var(--dowel-ease); } -.docs-nav-heading { - margin: 0 0 var(--dowel-space-2); - padding-inline: var(--dowel-space-4); - font-size: var(--dowel-fs-mini); - font-weight: var(--dowel-fw-medium); - color: var(--dowel-text-2); +.docs-nav-section[data-open] .docs-nav-items { + grid-template-rows: 1fr; + opacity: 1; } .docs-nav ul { display: flex; flex-direction: column; gap: 1px; + min-block-size: 0; margin: 0; - padding: 0; + overflow: hidden; + padding: var(--dowel-space-1) 0 0 var(--dowel-space-5); list-style: none; } +@media (prefers-reduced-motion: reduce) { + .docs-nav-section, + .docs-nav-chevron, + .docs-nav-items { + transition-duration: 0.01ms; + } +} + .docs-nav a { display: flex; align-items: center; @@ -807,6 +774,24 @@ body { justify-content: flex-start; } +.docs-calendar-example { + display: grid; + justify-items: center; + gap: var(--dowel-space-4); +} + +.docs-calendar-value { + margin: 0; + color: var(--dowel-text-3); + font-size: var(--dowel-fs-mini); +} + +.docs-calendar-value code { + color: var(--dowel-text-2); + font: inherit; + font-variant-numeric: tabular-nums; +} + /* The code block is welded to the preview: no radius of its own, one shared border. */ .docs-demo .docs-code { diff --git a/apps/docs/src/lib/nav.ts b/apps/docs/src/lib/nav.ts index e45a0a2..374b325 100644 --- a/apps/docs/src/lib/nav.ts +++ b/apps/docs/src/lib/nav.ts @@ -20,6 +20,11 @@ export type NavSection = { }; export const componentNav: NavItem[] = [ + { + title: "Accordion", + to: "/components/accordion", + summary: "A keyboard-accessible group of animated disclosure panels.", + }, { title: "Alert Dialog", to: "/components/alert-dialog", @@ -35,11 +40,21 @@ export const componentNav: NavItem[] = [ to: "/components/badge", summary: "A compact status or metadata pill in five tones.", }, + { + title: "Breadcrumbs", + to: "/components/breadcrumbs", + summary: "A compact location trail with automatic middle-item elision.", + }, { title: "Button", to: "/components/button", summary: "The default action control. Five hierarchy levels, two sizes.", }, + { + title: "Calendar & Date Picker", + to: "/components/calendar", + summary: "An internationalized month grid and segmented date field.", + }, { title: "Callout", to: "/components/callout", @@ -50,11 +65,31 @@ export const componentNav: NavItem[] = [ to: "/components/checkbox", summary: "Checked, unchecked, and mixed selection with native form values.", }, + { + title: "Collapsible", + to: "/components/collapsible", + summary: "An animated disclosure for one optional content region.", + }, + { + title: "Combobox", + to: "/components/combobox", + summary: "A filterable single-choice field for larger collections.", + }, + { + title: "Command Menu", + to: "/components/command-menu", + summary: "A modal search surface for grouped application commands.", + }, { title: "Composer", to: "/components/composer", summary: "A borderless creation shell with metadata and action regions.", }, + { + title: "Context Menu", + to: "/components/context-menu", + summary: "A pointer-positioned menu for actions on a target surface.", + }, { title: "Data Table", to: "/components/data-table", @@ -65,11 +100,21 @@ export const componentNav: NavItem[] = [ to: "/components/dialog", summary: "A modal on the modal elevation tier, labelled by its title.", }, + { + title: "Drawer", + to: "/components/drawer", + summary: "A swipe-dismissable edge panel for navigation and detail.", + }, { title: "Empty State", to: "/components/empty-state", summary: "Structured guidance and actions for a surface without content.", }, + { + title: "File Upload", + to: "/components/file-upload", + summary: "A native file picker and dropzone with built-in validation.", + }, { title: "Icon Button", to: "/components/icon-button", @@ -85,6 +130,11 @@ export const componentNav: NavItem[] = [ to: "/components/kbd", summary: "A keyboard shortcut rendered one key per cap.", }, + { + title: "List", + to: "/components/list", + summary: "Semantic grouped rows with cells, density, and selection.", + }, { title: "Menu", to: "/components/menu", @@ -95,6 +145,11 @@ export const componentNav: NavItem[] = [ to: "/components/native-select", summary: "A resilient native choice control with Dowel field styling.", }, + { + title: "Pagination", + to: "/components/pagination", + summary: "Controlled page navigation with compact number elision.", + }, { title: "Popover", to: "/components/popover", @@ -106,6 +161,11 @@ export const componentNav: NavItem[] = [ summary: "Searchable grouped metadata selection with a compact pill trigger.", }, + { + title: "Progress", + to: "/components/progress", + summary: "Determinate and indeterminate progress for long tasks.", + }, { title: "Radio Group", to: "/components/radio-group", @@ -131,6 +191,11 @@ export const componentNav: NavItem[] = [ to: "/components/sidebar", summary: "A responsive application rail with pointer and keyboard resize.", }, + { + title: "Slider", + to: "/components/slider", + summary: "A pointer and keyboard control for a numeric range.", + }, { title: "Skeleton", to: "/components/skeleton", @@ -156,11 +221,114 @@ export const componentNav: NavItem[] = [ to: "/components/tabs", summary: "Compact pill and line view switching with arrow-key activation.", }, + { + title: "Toast", + to: "/components/toast", + summary: "A global notification queue with actions and promises.", + }, + { + title: "Toggle Group", + to: "/components/toggle-group", + summary: "A connected set of independently pressed view controls.", + }, { title: "Tooltip", to: "/components/tooltip", summary: "A hover and focus label on the popover elevation tier.", }, + { + title: "Tree View", + to: "/components/tree-view", + summary: "A nested hierarchy with selection and keyboard navigation.", + }, +]; + +function components(...paths: NavItem["to"][]): NavItem[] { + return paths.map((path) => { + const item = componentNav.find((candidate) => candidate.to === path); + if (!item) throw new Error(`Missing component navigation item: ${path}`); + return item; + }); +} + +export const componentSections: NavSection[] = [ + { + title: "Actions", + items: components( + "/components/button", + "/components/icon-button", + "/components/toggle-group", + ), + }, + { + title: "Forms and selection", + items: components( + "/components/checkbox", + "/components/calendar", + "/components/combobox", + "/components/file-upload", + "/components/input", + "/components/native-select", + "/components/property-picker", + "/components/radio-group", + "/components/search-field", + "/components/select", + "/components/slider", + "/components/switch", + ), + }, + { + title: "Navigation", + items: components( + "/components/breadcrumbs", + "/components/command-menu", + "/components/pagination", + "/components/sidebar", + "/components/tabs", + "/components/tree-view", + ), + }, + { + title: "Overlays", + items: components( + "/components/alert-dialog", + "/components/context-menu", + "/components/dialog", + "/components/drawer", + "/components/menu", + "/components/popover", + "/components/tooltip", + ), + }, + { + title: "Data and display", + items: components( + "/components/accordion", + "/components/avatar", + "/components/badge", + "/components/collapsible", + "/components/data-table", + "/components/kbd", + "/components/list", + "/components/separator", + "/components/status", + ), + }, + { + title: "Feedback", + items: components( + "/components/callout", + "/components/empty-state", + "/components/progress", + "/components/skeleton", + "/components/spinner", + "/components/toast", + ), + }, + { + title: "Composites", + items: components("/components/composer"), + }, ]; export const nav: NavSection[] = [ @@ -179,5 +347,5 @@ export const nav: NavSection[] = [ }, ], }, - { title: "Components", items: componentNav }, + ...componentSections, ]; diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts index 38a7824..a68130f 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -13,22 +13,34 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as ComponentsRouteRouteImport } from './routes/components/route' import { Route as DependenciesRouteImport } from './routes/dependencies' import { Route as ComponentsIndexRouteImport } from './routes/components/index' +import { Route as ComponentsAccordionRouteImport } from './routes/components/accordion' import { Route as ComponentsAlertDialogRouteImport } from './routes/components/alert-dialog' import { Route as ComponentsAvatarRouteImport } from './routes/components/avatar' import { Route as ComponentsBadgeRouteImport } from './routes/components/badge' +import { Route as ComponentsBreadcrumbsRouteImport } from './routes/components/breadcrumbs' import { Route as ComponentsButtonRouteImport } from './routes/components/button' +import { Route as ComponentsCalendarRouteImport } from './routes/components/calendar' import { Route as ComponentsCalloutRouteImport } from './routes/components/callout' import { Route as ComponentsCheckboxRouteImport } from './routes/components/checkbox' +import { Route as ComponentsCollapsibleRouteImport } from './routes/components/collapsible' +import { Route as ComponentsComboboxRouteImport } from './routes/components/combobox' +import { Route as ComponentsCommandMenuRouteImport } from './routes/components/command-menu' import { Route as ComponentsComposerRouteImport } from './routes/components/composer' +import { Route as ComponentsContextMenuRouteImport } from './routes/components/context-menu' import { Route as ComponentsDataTableRouteImport } from './routes/components/data-table' import { Route as ComponentsDialogRouteImport } from './routes/components/dialog' +import { Route as ComponentsDrawerRouteImport } from './routes/components/drawer' import { Route as ComponentsEmptyStateRouteImport } from './routes/components/empty-state' +import { Route as ComponentsFileUploadRouteImport } from './routes/components/file-upload' import { Route as ComponentsIconButtonRouteImport } from './routes/components/icon-button' import { Route as ComponentsInputRouteImport } from './routes/components/input' import { Route as ComponentsKbdRouteImport } from './routes/components/kbd' +import { Route as ComponentsListRouteImport } from './routes/components/list' import { Route as ComponentsMenuRouteImport } from './routes/components/menu' import { Route as ComponentsNativeSelectRouteImport } from './routes/components/native-select' +import { Route as ComponentsPaginationRouteImport } from './routes/components/pagination' import { Route as ComponentsPopoverRouteImport } from './routes/components/popover' +import { Route as ComponentsProgressRouteImport } from './routes/components/progress' import { Route as ComponentsPropertyPickerRouteImport } from './routes/components/property-picker' import { Route as ComponentsRadioGroupRouteImport } from './routes/components/radio-group' import { Route as ComponentsSearchFieldRouteImport } from './routes/components/search-field' @@ -36,11 +48,15 @@ import { Route as ComponentsSelectRouteImport } from './routes/components/select import { Route as ComponentsSeparatorRouteImport } from './routes/components/separator' import { Route as ComponentsSidebarRouteImport } from './routes/components/sidebar' import { Route as ComponentsSkeletonRouteImport } from './routes/components/skeleton' +import { Route as ComponentsSliderRouteImport } from './routes/components/slider' import { Route as ComponentsSpinnerRouteImport } from './routes/components/spinner' import { Route as ComponentsStatusRouteImport } from './routes/components/status' import { Route as ComponentsSwitchRouteImport } from './routes/components/switch' import { Route as ComponentsTabsRouteImport } from './routes/components/tabs' +import { Route as ComponentsToastRouteImport } from './routes/components/toast' +import { Route as ComponentsToggleGroupRouteImport } from './routes/components/toggle-group' import { Route as ComponentsTooltipRouteImport } from './routes/components/tooltip' +import { Route as ComponentsTreeViewRouteImport } from './routes/components/tree-view' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -62,6 +78,11 @@ const ComponentsIndexRoute = ComponentsIndexRouteImport.update({ path: '/', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsAccordionRoute = ComponentsAccordionRouteImport.update({ + id: '/accordion', + path: '/accordion', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsAlertDialogRoute = ComponentsAlertDialogRouteImport.update({ id: '/alert-dialog', path: '/alert-dialog', @@ -77,11 +98,21 @@ const ComponentsBadgeRoute = ComponentsBadgeRouteImport.update({ path: '/badge', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsBreadcrumbsRoute = ComponentsBreadcrumbsRouteImport.update({ + id: '/breadcrumbs', + path: '/breadcrumbs', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsButtonRoute = ComponentsButtonRouteImport.update({ id: '/button', path: '/button', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsCalendarRoute = ComponentsCalendarRouteImport.update({ + id: '/calendar', + path: '/calendar', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsCalloutRoute = ComponentsCalloutRouteImport.update({ id: '/callout', path: '/callout', @@ -92,11 +123,31 @@ const ComponentsCheckboxRoute = ComponentsCheckboxRouteImport.update({ path: '/checkbox', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsCollapsibleRoute = ComponentsCollapsibleRouteImport.update({ + id: '/collapsible', + path: '/collapsible', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsComboboxRoute = ComponentsComboboxRouteImport.update({ + id: '/combobox', + path: '/combobox', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsCommandMenuRoute = ComponentsCommandMenuRouteImport.update({ + id: '/command-menu', + path: '/command-menu', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsComposerRoute = ComponentsComposerRouteImport.update({ id: '/composer', path: '/composer', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsContextMenuRoute = ComponentsContextMenuRouteImport.update({ + id: '/context-menu', + path: '/context-menu', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsDataTableRoute = ComponentsDataTableRouteImport.update({ id: '/data-table', path: '/data-table', @@ -107,11 +158,21 @@ const ComponentsDialogRoute = ComponentsDialogRouteImport.update({ path: '/dialog', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsDrawerRoute = ComponentsDrawerRouteImport.update({ + id: '/drawer', + path: '/drawer', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsEmptyStateRoute = ComponentsEmptyStateRouteImport.update({ id: '/empty-state', path: '/empty-state', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsFileUploadRoute = ComponentsFileUploadRouteImport.update({ + id: '/file-upload', + path: '/file-upload', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsIconButtonRoute = ComponentsIconButtonRouteImport.update({ id: '/icon-button', path: '/icon-button', @@ -127,6 +188,11 @@ const ComponentsKbdRoute = ComponentsKbdRouteImport.update({ path: '/kbd', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsListRoute = ComponentsListRouteImport.update({ + id: '/list', + path: '/list', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsMenuRoute = ComponentsMenuRouteImport.update({ id: '/menu', path: '/menu', @@ -137,11 +203,21 @@ const ComponentsNativeSelectRoute = ComponentsNativeSelectRouteImport.update({ path: '/native-select', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsPaginationRoute = ComponentsPaginationRouteImport.update({ + id: '/pagination', + path: '/pagination', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsPopoverRoute = ComponentsPopoverRouteImport.update({ id: '/popover', path: '/popover', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsProgressRoute = ComponentsProgressRouteImport.update({ + id: '/progress', + path: '/progress', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsPropertyPickerRoute = ComponentsPropertyPickerRouteImport.update({ id: '/property-picker', @@ -178,6 +254,11 @@ const ComponentsSkeletonRoute = ComponentsSkeletonRouteImport.update({ path: '/skeleton', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsSliderRoute = ComponentsSliderRouteImport.update({ + id: '/slider', + path: '/slider', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsSpinnerRoute = ComponentsSpinnerRouteImport.update({ id: '/spinner', path: '/spinner', @@ -198,32 +279,59 @@ const ComponentsTabsRoute = ComponentsTabsRouteImport.update({ path: '/tabs', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsToastRoute = ComponentsToastRouteImport.update({ + id: '/toast', + path: '/toast', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsToggleGroupRoute = ComponentsToggleGroupRouteImport.update({ + id: '/toggle-group', + path: '/toggle-group', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsTooltipRoute = ComponentsTooltipRouteImport.update({ id: '/tooltip', path: '/tooltip', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsTreeViewRoute = ComponentsTreeViewRouteImport.update({ + id: '/tree-view', + path: '/tree-view', + getParentRoute: () => ComponentsRouteRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/components': typeof ComponentsRouteRouteWithChildren '/dependencies': typeof DependenciesRoute + '/components/accordion': typeof ComponentsAccordionRoute '/components/alert-dialog': typeof ComponentsAlertDialogRoute '/components/avatar': typeof ComponentsAvatarRoute '/components/badge': typeof ComponentsBadgeRoute + '/components/breadcrumbs': typeof ComponentsBreadcrumbsRoute '/components/button': typeof ComponentsButtonRoute + '/components/calendar': typeof ComponentsCalendarRoute '/components/callout': typeof ComponentsCalloutRoute '/components/checkbox': typeof ComponentsCheckboxRoute + '/components/collapsible': typeof ComponentsCollapsibleRoute + '/components/combobox': typeof ComponentsComboboxRoute + '/components/command-menu': typeof ComponentsCommandMenuRoute '/components/composer': typeof ComponentsComposerRoute + '/components/context-menu': typeof ComponentsContextMenuRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/drawer': typeof ComponentsDrawerRoute '/components/empty-state': typeof ComponentsEmptyStateRoute + '/components/file-upload': typeof ComponentsFileUploadRoute '/components/icon-button': typeof ComponentsIconButtonRoute '/components/input': typeof ComponentsInputRoute '/components/kbd': typeof ComponentsKbdRoute + '/components/list': typeof ComponentsListRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/pagination': typeof ComponentsPaginationRoute '/components/popover': typeof ComponentsPopoverRoute + '/components/progress': typeof ComponentsProgressRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute @@ -231,32 +339,48 @@ export interface FileRoutesByFullPath { '/components/separator': typeof ComponentsSeparatorRoute '/components/sidebar': typeof ComponentsSidebarRoute '/components/skeleton': typeof ComponentsSkeletonRoute + '/components/slider': typeof ComponentsSliderRoute '/components/spinner': typeof ComponentsSpinnerRoute '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute + '/components/toast': typeof ComponentsToastRoute + '/components/toggle-group': typeof ComponentsToggleGroupRoute '/components/tooltip': typeof ComponentsTooltipRoute + '/components/tree-view': typeof ComponentsTreeViewRoute '/components/': typeof ComponentsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/dependencies': typeof DependenciesRoute + '/components/accordion': typeof ComponentsAccordionRoute '/components/alert-dialog': typeof ComponentsAlertDialogRoute '/components/avatar': typeof ComponentsAvatarRoute '/components/badge': typeof ComponentsBadgeRoute + '/components/breadcrumbs': typeof ComponentsBreadcrumbsRoute '/components/button': typeof ComponentsButtonRoute + '/components/calendar': typeof ComponentsCalendarRoute '/components/callout': typeof ComponentsCalloutRoute '/components/checkbox': typeof ComponentsCheckboxRoute + '/components/collapsible': typeof ComponentsCollapsibleRoute + '/components/combobox': typeof ComponentsComboboxRoute + '/components/command-menu': typeof ComponentsCommandMenuRoute '/components/composer': typeof ComponentsComposerRoute + '/components/context-menu': typeof ComponentsContextMenuRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/drawer': typeof ComponentsDrawerRoute '/components/empty-state': typeof ComponentsEmptyStateRoute + '/components/file-upload': typeof ComponentsFileUploadRoute '/components/icon-button': typeof ComponentsIconButtonRoute '/components/input': typeof ComponentsInputRoute '/components/kbd': typeof ComponentsKbdRoute + '/components/list': typeof ComponentsListRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/pagination': typeof ComponentsPaginationRoute '/components/popover': typeof ComponentsPopoverRoute + '/components/progress': typeof ComponentsProgressRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute @@ -264,11 +388,15 @@ export interface FileRoutesByTo { '/components/separator': typeof ComponentsSeparatorRoute '/components/sidebar': typeof ComponentsSidebarRoute '/components/skeleton': typeof ComponentsSkeletonRoute + '/components/slider': typeof ComponentsSliderRoute '/components/spinner': typeof ComponentsSpinnerRoute '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute + '/components/toast': typeof ComponentsToastRoute + '/components/toggle-group': typeof ComponentsToggleGroupRoute '/components/tooltip': typeof ComponentsTooltipRoute + '/components/tree-view': typeof ComponentsTreeViewRoute '/components': typeof ComponentsIndexRoute } export interface FileRoutesById { @@ -276,22 +404,34 @@ export interface FileRoutesById { '/': typeof IndexRoute '/components': typeof ComponentsRouteRouteWithChildren '/dependencies': typeof DependenciesRoute + '/components/accordion': typeof ComponentsAccordionRoute '/components/alert-dialog': typeof ComponentsAlertDialogRoute '/components/avatar': typeof ComponentsAvatarRoute '/components/badge': typeof ComponentsBadgeRoute + '/components/breadcrumbs': typeof ComponentsBreadcrumbsRoute '/components/button': typeof ComponentsButtonRoute + '/components/calendar': typeof ComponentsCalendarRoute '/components/callout': typeof ComponentsCalloutRoute '/components/checkbox': typeof ComponentsCheckboxRoute + '/components/collapsible': typeof ComponentsCollapsibleRoute + '/components/combobox': typeof ComponentsComboboxRoute + '/components/command-menu': typeof ComponentsCommandMenuRoute '/components/composer': typeof ComponentsComposerRoute + '/components/context-menu': typeof ComponentsContextMenuRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/drawer': typeof ComponentsDrawerRoute '/components/empty-state': typeof ComponentsEmptyStateRoute + '/components/file-upload': typeof ComponentsFileUploadRoute '/components/icon-button': typeof ComponentsIconButtonRoute '/components/input': typeof ComponentsInputRoute '/components/kbd': typeof ComponentsKbdRoute + '/components/list': typeof ComponentsListRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/pagination': typeof ComponentsPaginationRoute '/components/popover': typeof ComponentsPopoverRoute + '/components/progress': typeof ComponentsProgressRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute @@ -299,11 +439,15 @@ export interface FileRoutesById { '/components/separator': typeof ComponentsSeparatorRoute '/components/sidebar': typeof ComponentsSidebarRoute '/components/skeleton': typeof ComponentsSkeletonRoute + '/components/slider': typeof ComponentsSliderRoute '/components/spinner': typeof ComponentsSpinnerRoute '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute + '/components/toast': typeof ComponentsToastRoute + '/components/toggle-group': typeof ComponentsToggleGroupRoute '/components/tooltip': typeof ComponentsTooltipRoute + '/components/tree-view': typeof ComponentsTreeViewRoute '/components/': typeof ComponentsIndexRoute } export interface FileRouteTypes { @@ -312,22 +456,34 @@ export interface FileRouteTypes { | '/' | '/components' | '/dependencies' + | '/components/accordion' | '/components/alert-dialog' | '/components/avatar' | '/components/badge' + | '/components/breadcrumbs' | '/components/button' + | '/components/calendar' | '/components/callout' | '/components/checkbox' + | '/components/collapsible' + | '/components/combobox' + | '/components/command-menu' | '/components/composer' + | '/components/context-menu' | '/components/data-table' | '/components/dialog' + | '/components/drawer' | '/components/empty-state' + | '/components/file-upload' | '/components/icon-button' | '/components/input' | '/components/kbd' + | '/components/list' | '/components/menu' | '/components/native-select' + | '/components/pagination' | '/components/popover' + | '/components/progress' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' @@ -335,32 +491,48 @@ export interface FileRouteTypes { | '/components/separator' | '/components/sidebar' | '/components/skeleton' + | '/components/slider' | '/components/spinner' | '/components/status' | '/components/switch' | '/components/tabs' + | '/components/toast' + | '/components/toggle-group' | '/components/tooltip' + | '/components/tree-view' | '/components/' fileRoutesByTo: FileRoutesByTo to: | '/' | '/dependencies' + | '/components/accordion' | '/components/alert-dialog' | '/components/avatar' | '/components/badge' + | '/components/breadcrumbs' | '/components/button' + | '/components/calendar' | '/components/callout' | '/components/checkbox' + | '/components/collapsible' + | '/components/combobox' + | '/components/command-menu' | '/components/composer' + | '/components/context-menu' | '/components/data-table' | '/components/dialog' + | '/components/drawer' | '/components/empty-state' + | '/components/file-upload' | '/components/icon-button' | '/components/input' | '/components/kbd' + | '/components/list' | '/components/menu' | '/components/native-select' + | '/components/pagination' | '/components/popover' + | '/components/progress' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' @@ -368,33 +540,49 @@ export interface FileRouteTypes { | '/components/separator' | '/components/sidebar' | '/components/skeleton' + | '/components/slider' | '/components/spinner' | '/components/status' | '/components/switch' | '/components/tabs' + | '/components/toast' + | '/components/toggle-group' | '/components/tooltip' + | '/components/tree-view' | '/components' id: | '__root__' | '/' | '/components' | '/dependencies' + | '/components/accordion' | '/components/alert-dialog' | '/components/avatar' | '/components/badge' + | '/components/breadcrumbs' | '/components/button' + | '/components/calendar' | '/components/callout' | '/components/checkbox' + | '/components/collapsible' + | '/components/combobox' + | '/components/command-menu' | '/components/composer' + | '/components/context-menu' | '/components/data-table' | '/components/dialog' + | '/components/drawer' | '/components/empty-state' + | '/components/file-upload' | '/components/icon-button' | '/components/input' | '/components/kbd' + | '/components/list' | '/components/menu' | '/components/native-select' + | '/components/pagination' | '/components/popover' + | '/components/progress' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' @@ -402,11 +590,15 @@ export interface FileRouteTypes { | '/components/separator' | '/components/sidebar' | '/components/skeleton' + | '/components/slider' | '/components/spinner' | '/components/status' | '/components/switch' | '/components/tabs' + | '/components/toast' + | '/components/toggle-group' | '/components/tooltip' + | '/components/tree-view' | '/components/' fileRoutesById: FileRoutesById } @@ -446,6 +638,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsIndexRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/accordion': { + id: '/components/accordion' + path: '/accordion' + fullPath: '/components/accordion' + preLoaderRoute: typeof ComponentsAccordionRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/alert-dialog': { id: '/components/alert-dialog' path: '/alert-dialog' @@ -467,6 +666,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsBadgeRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/breadcrumbs': { + id: '/components/breadcrumbs' + path: '/breadcrumbs' + fullPath: '/components/breadcrumbs' + preLoaderRoute: typeof ComponentsBreadcrumbsRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/button': { id: '/components/button' path: '/button' @@ -474,6 +680,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsButtonRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/calendar': { + id: '/components/calendar' + path: '/calendar' + fullPath: '/components/calendar' + preLoaderRoute: typeof ComponentsCalendarRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/callout': { id: '/components/callout' path: '/callout' @@ -488,6 +701,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsCheckboxRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/collapsible': { + id: '/components/collapsible' + path: '/collapsible' + fullPath: '/components/collapsible' + preLoaderRoute: typeof ComponentsCollapsibleRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/combobox': { + id: '/components/combobox' + path: '/combobox' + fullPath: '/components/combobox' + preLoaderRoute: typeof ComponentsComboboxRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/command-menu': { + id: '/components/command-menu' + path: '/command-menu' + fullPath: '/components/command-menu' + preLoaderRoute: typeof ComponentsCommandMenuRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/composer': { id: '/components/composer' path: '/composer' @@ -495,6 +729,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsComposerRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/context-menu': { + id: '/components/context-menu' + path: '/context-menu' + fullPath: '/components/context-menu' + preLoaderRoute: typeof ComponentsContextMenuRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/data-table': { id: '/components/data-table' path: '/data-table' @@ -509,6 +750,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsDialogRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/drawer': { + id: '/components/drawer' + path: '/drawer' + fullPath: '/components/drawer' + preLoaderRoute: typeof ComponentsDrawerRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/empty-state': { id: '/components/empty-state' path: '/empty-state' @@ -516,6 +764,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsEmptyStateRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/file-upload': { + id: '/components/file-upload' + path: '/file-upload' + fullPath: '/components/file-upload' + preLoaderRoute: typeof ComponentsFileUploadRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/icon-button': { id: '/components/icon-button' path: '/icon-button' @@ -537,6 +792,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsKbdRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/list': { + id: '/components/list' + path: '/list' + fullPath: '/components/list' + preLoaderRoute: typeof ComponentsListRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/menu': { id: '/components/menu' path: '/menu' @@ -551,6 +813,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsNativeSelectRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/pagination': { + id: '/components/pagination' + path: '/pagination' + fullPath: '/components/pagination' + preLoaderRoute: typeof ComponentsPaginationRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/popover': { id: '/components/popover' path: '/popover' @@ -558,6 +827,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsPopoverRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/progress': { + id: '/components/progress' + path: '/progress' + fullPath: '/components/progress' + preLoaderRoute: typeof ComponentsProgressRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/property-picker': { id: '/components/property-picker' path: '/property-picker' @@ -607,6 +883,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsSkeletonRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/slider': { + id: '/components/slider' + path: '/slider' + fullPath: '/components/slider' + preLoaderRoute: typeof ComponentsSliderRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/spinner': { id: '/components/spinner' path: '/spinner' @@ -635,6 +918,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsTabsRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/toast': { + id: '/components/toast' + path: '/toast' + fullPath: '/components/toast' + preLoaderRoute: typeof ComponentsToastRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/toggle-group': { + id: '/components/toggle-group' + path: '/toggle-group' + fullPath: '/components/toggle-group' + preLoaderRoute: typeof ComponentsToggleGroupRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/tooltip': { id: '/components/tooltip' path: '/tooltip' @@ -642,26 +939,45 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsTooltipRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/tree-view': { + id: '/components/tree-view' + path: '/tree-view' + fullPath: '/components/tree-view' + preLoaderRoute: typeof ComponentsTreeViewRouteImport + parentRoute: typeof ComponentsRouteRoute + } } } interface ComponentsRouteRouteChildren { + ComponentsAccordionRoute: typeof ComponentsAccordionRoute ComponentsAlertDialogRoute: typeof ComponentsAlertDialogRoute ComponentsAvatarRoute: typeof ComponentsAvatarRoute ComponentsBadgeRoute: typeof ComponentsBadgeRoute + ComponentsBreadcrumbsRoute: typeof ComponentsBreadcrumbsRoute ComponentsButtonRoute: typeof ComponentsButtonRoute + ComponentsCalendarRoute: typeof ComponentsCalendarRoute ComponentsCalloutRoute: typeof ComponentsCalloutRoute ComponentsCheckboxRoute: typeof ComponentsCheckboxRoute + ComponentsCollapsibleRoute: typeof ComponentsCollapsibleRoute + ComponentsComboboxRoute: typeof ComponentsComboboxRoute + ComponentsCommandMenuRoute: typeof ComponentsCommandMenuRoute ComponentsComposerRoute: typeof ComponentsComposerRoute + ComponentsContextMenuRoute: typeof ComponentsContextMenuRoute ComponentsDataTableRoute: typeof ComponentsDataTableRoute ComponentsDialogRoute: typeof ComponentsDialogRoute + ComponentsDrawerRoute: typeof ComponentsDrawerRoute ComponentsEmptyStateRoute: typeof ComponentsEmptyStateRoute + ComponentsFileUploadRoute: typeof ComponentsFileUploadRoute ComponentsIconButtonRoute: typeof ComponentsIconButtonRoute ComponentsInputRoute: typeof ComponentsInputRoute ComponentsKbdRoute: typeof ComponentsKbdRoute + ComponentsListRoute: typeof ComponentsListRoute ComponentsMenuRoute: typeof ComponentsMenuRoute ComponentsNativeSelectRoute: typeof ComponentsNativeSelectRoute + ComponentsPaginationRoute: typeof ComponentsPaginationRoute ComponentsPopoverRoute: typeof ComponentsPopoverRoute + ComponentsProgressRoute: typeof ComponentsProgressRoute ComponentsPropertyPickerRoute: typeof ComponentsPropertyPickerRoute ComponentsRadioGroupRoute: typeof ComponentsRadioGroupRoute ComponentsSearchFieldRoute: typeof ComponentsSearchFieldRoute @@ -669,31 +985,47 @@ interface ComponentsRouteRouteChildren { ComponentsSeparatorRoute: typeof ComponentsSeparatorRoute ComponentsSidebarRoute: typeof ComponentsSidebarRoute ComponentsSkeletonRoute: typeof ComponentsSkeletonRoute + ComponentsSliderRoute: typeof ComponentsSliderRoute ComponentsSpinnerRoute: typeof ComponentsSpinnerRoute ComponentsStatusRoute: typeof ComponentsStatusRoute ComponentsSwitchRoute: typeof ComponentsSwitchRoute ComponentsTabsRoute: typeof ComponentsTabsRoute + ComponentsToastRoute: typeof ComponentsToastRoute + ComponentsToggleGroupRoute: typeof ComponentsToggleGroupRoute ComponentsTooltipRoute: typeof ComponentsTooltipRoute + ComponentsTreeViewRoute: typeof ComponentsTreeViewRoute ComponentsIndexRoute: typeof ComponentsIndexRoute } const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { + ComponentsAccordionRoute: ComponentsAccordionRoute, ComponentsAlertDialogRoute: ComponentsAlertDialogRoute, ComponentsAvatarRoute: ComponentsAvatarRoute, ComponentsBadgeRoute: ComponentsBadgeRoute, + ComponentsBreadcrumbsRoute: ComponentsBreadcrumbsRoute, ComponentsButtonRoute: ComponentsButtonRoute, + ComponentsCalendarRoute: ComponentsCalendarRoute, ComponentsCalloutRoute: ComponentsCalloutRoute, ComponentsCheckboxRoute: ComponentsCheckboxRoute, + ComponentsCollapsibleRoute: ComponentsCollapsibleRoute, + ComponentsComboboxRoute: ComponentsComboboxRoute, + ComponentsCommandMenuRoute: ComponentsCommandMenuRoute, ComponentsComposerRoute: ComponentsComposerRoute, + ComponentsContextMenuRoute: ComponentsContextMenuRoute, ComponentsDataTableRoute: ComponentsDataTableRoute, ComponentsDialogRoute: ComponentsDialogRoute, + ComponentsDrawerRoute: ComponentsDrawerRoute, ComponentsEmptyStateRoute: ComponentsEmptyStateRoute, + ComponentsFileUploadRoute: ComponentsFileUploadRoute, ComponentsIconButtonRoute: ComponentsIconButtonRoute, ComponentsInputRoute: ComponentsInputRoute, ComponentsKbdRoute: ComponentsKbdRoute, + ComponentsListRoute: ComponentsListRoute, ComponentsMenuRoute: ComponentsMenuRoute, ComponentsNativeSelectRoute: ComponentsNativeSelectRoute, + ComponentsPaginationRoute: ComponentsPaginationRoute, ComponentsPopoverRoute: ComponentsPopoverRoute, + ComponentsProgressRoute: ComponentsProgressRoute, ComponentsPropertyPickerRoute: ComponentsPropertyPickerRoute, ComponentsRadioGroupRoute: ComponentsRadioGroupRoute, ComponentsSearchFieldRoute: ComponentsSearchFieldRoute, @@ -701,11 +1033,15 @@ const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { ComponentsSeparatorRoute: ComponentsSeparatorRoute, ComponentsSidebarRoute: ComponentsSidebarRoute, ComponentsSkeletonRoute: ComponentsSkeletonRoute, + ComponentsSliderRoute: ComponentsSliderRoute, ComponentsSpinnerRoute: ComponentsSpinnerRoute, ComponentsStatusRoute: ComponentsStatusRoute, ComponentsSwitchRoute: ComponentsSwitchRoute, ComponentsTabsRoute: ComponentsTabsRoute, + ComponentsToastRoute: ComponentsToastRoute, + ComponentsToggleGroupRoute: ComponentsToggleGroupRoute, ComponentsTooltipRoute: ComponentsTooltipRoute, + ComponentsTreeViewRoute: ComponentsTreeViewRoute, ComponentsIndexRoute: ComponentsIndexRoute, } diff --git a/apps/docs/src/routes/__root.tsx b/apps/docs/src/routes/__root.tsx index 9948803..f7b664e 100644 --- a/apps/docs/src/routes/__root.tsx +++ b/apps/docs/src/routes/__root.tsx @@ -102,7 +102,9 @@ function RootDocument() { - + {searchOpen ? ( + + ) : null} diff --git a/apps/docs/src/routes/components/accordion.tsx b/apps/docs/src/routes/components/accordion.tsx new file mode 100644 index 0000000..7690e42 --- /dev/null +++ b/apps/docs/src/routes/components/accordion.tsx @@ -0,0 +1,49 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Accordion } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/accordion")({ + component: AccordionDocs, +}); + +function AccordionDocs() { + return ( + +
    + \n \n \n Project details\n \n \n Visible to workspace members.\n \n \n`} + > + + + + Project details + + + + Visible to workspace members and invited guests. + + + + + + Data retention + + + + Deleted records are retained for 30 days. + + + + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/breadcrumbs.tsx b/apps/docs/src/routes/components/breadcrumbs.tsx new file mode 100644 index 0000000..a92aa98 --- /dev/null +++ b/apps/docs/src/routes/components/breadcrumbs.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Breadcrumbs } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/breadcrumbs")({ + component: BreadcrumbsDocs, +}); + +const items = [ + { label: "Workspace", href: "#workspace" }, + { label: "Projects", href: "#projects" }, + { label: "Dowel", current: true }, +]; + +function BreadcrumbsDocs() { + return ( + +
    + `} layout="stack"> + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/calendar.tsx b/apps/docs/src/routes/components/calendar.tsx new file mode 100644 index 0000000..97c488e --- /dev/null +++ b/apps/docs/src/routes/components/calendar.tsx @@ -0,0 +1,58 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Calendar, DatePicker, parseDate } from "@karnstack/dowel"; +import { useState } from "react"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/calendar")({ + component: CalendarDocs, +}); + +function CalendarExample() { + const [date, setDate] = useState(parseDate("2026-08-12")); + return ( +
    + + + Selected: {date.toString()} + +
    + ); +} + +function CalendarDocs() { + return ( + +
    + \nSelected: {date.toString()}`} + > + + +
    +
    +

    + DatePicker adds locale-aware editable segments and composes the same + keyboard-navigable calendar into its popover. +

    + `} + > + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/collapsible.tsx b/apps/docs/src/routes/components/collapsible.tsx new file mode 100644 index 0000000..644b1ff --- /dev/null +++ b/apps/docs/src/routes/components/collapsible.tsx @@ -0,0 +1,35 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Collapsible } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/collapsible")({ + component: CollapsibleDocs, +}); + +function CollapsibleDocs() { + return ( + +
    + \n Advanced settings\n \n Optional configuration.\n \n`} + > + + Advanced settings + + + Configure branch protection and merge automation. + + + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/combobox.tsx b/apps/docs/src/routes/components/combobox.tsx new file mode 100644 index 0000000..f84399e --- /dev/null +++ b/apps/docs/src/routes/components/combobox.tsx @@ -0,0 +1,59 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Combobox, Field } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/combobox")({ + component: ComboboxDocs, +}); + +const organizations = [ + { value: "acme", label: "Acme", group: "Organizations" }, + { + value: "northstar", + label: "Northstar", + description: "12 repositories", + group: "Organizations", + }, + { value: "personal", label: "Personal workspace", group: "Workspaces" }, +]; + +function ComboboxDocs() { + return ( + +
    +

    + Use Combobox when Select would make someone scan too many options. +

    + \n Organization\n \n`} + > + + Organization + + + +
    +
    +

    + Typing filters labels, descriptions, and groups. Arrow keys move + through results, Enter selects, Escape closes, and the clear button + resets the value. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/command-menu.tsx b/apps/docs/src/routes/components/command-menu.tsx new file mode 100644 index 0000000..74a6a39 --- /dev/null +++ b/apps/docs/src/routes/components/command-menu.tsx @@ -0,0 +1,67 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, CommandMenu } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/command-menu")({ + component: CommandMenuDocs, +}); + +const commands = [ + { + id: "repo", + label: "Open repository", + description: "Jump to a repository", + group: "Navigation", + shortcut: "G R", + keywords: ["project"], + onSelect: () => {}, + }, + { + id: "settings", + label: "Open settings", + group: "Navigation", + shortcut: "G S", + onSelect: () => {}, + }, + { + id: "create", + label: "Create merge request", + group: "Actions", + shortcut: "C M", + onSelect: () => {}, + }, +]; + +function CommandMenuDocs() { + return ( + +
    + Open commands}\n items={commands}\n footer="↑↓ Navigate · ↵ Run · Esc Close"\n/>`} + > + Open commands} + items={commands} + footer="↑↓ Navigate · ↵ Run · Esc Close" + /> + +
    +
    +

    + Control open state from the application hotkey registry. Dowel owns + filtering, focus, keyboard movement, and dismissal; each command keeps + its own domain action. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/context-menu.tsx b/apps/docs/src/routes/components/context-menu.tsx new file mode 100644 index 0000000..dbc2d0f --- /dev/null +++ b/apps/docs/src/routes/components/context-menu.tsx @@ -0,0 +1,42 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ContextMenu } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/context-menu")({ + component: ContextMenuDocs, +}); + +function ContextMenuDocs() { + return ( + +
    + \n Right-click this surface\n \n \n \n Rename\n Duplicate\n \n \n \n`} + > + + + Right-click this surface for project actions + + + + + Rename + Duplicate + + Archive + + + + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/drawer.tsx b/apps/docs/src/routes/components/drawer.tsx new file mode 100644 index 0000000..8c9748a --- /dev/null +++ b/apps/docs/src/routes/components/drawer.tsx @@ -0,0 +1,64 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, Drawer } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/drawer")({ + component: DrawerDocs, +}); + +function DrawerExample() { + return ( + + Open repository details} /> + + + + + + + Repository details + + Metadata and access for dowel. + + + Default branch: main + + Close} /> + + + + + + + ); +} + +function DrawerDocs() { + return ( + +
    + \n Open details} />\n \n \n \n \n \n \n`} + > + + +
    +
    +

    + Top, right, bottom, and left placement each map to the matching + swipe-dismiss gesture. Use Dialog when gesture dismissal and edge + attachment are unnecessary. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/file-upload.tsx b/apps/docs/src/routes/components/file-upload.tsx new file mode 100644 index 0000000..a3b5d05 --- /dev/null +++ b/apps/docs/src/routes/components/file-upload.tsx @@ -0,0 +1,34 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { FileUpload } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/file-upload")({ + component: FileUploadDocs, +}); + +function FileUploadDocs() { + return ( + +
    + `} + > + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/list.tsx b/apps/docs/src/routes/components/list.tsx new file mode 100644 index 0000000..430f490 --- /dev/null +++ b/apps/docs/src/routes/components/list.tsx @@ -0,0 +1,63 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + Avatar, + GroupHeader, + List, + ListCell, + ListRow, + Status, +} from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/list")({ + component: ListDocs, +}); + +function ListDocs() { + return ( + +
    + \n Favorites\n \n \n dowel\n Updated today\n \n`} + > + + Favorites + + + + dowel + + + Synced + + + + + + sourcetown + + Updated yesterday + + + +
    +
    +

    + Rows preserve list semantics and own layout only. Put native links, + buttons, checkboxes, or menus inside cells when a row needs + interaction. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/pagination.tsx b/apps/docs/src/routes/components/pagination.tsx new file mode 100644 index 0000000..ee22f49 --- /dev/null +++ b/apps/docs/src/routes/components/pagination.tsx @@ -0,0 +1,33 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Pagination } from "@karnstack/dowel"; +import { useState } from "react"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/pagination")({ + component: PaginationDocs, +}); + +function PaginationExample() { + const [page, setPage] = useState(4); + return ; +} + +function PaginationDocs() { + return ( + +
    + `} + > + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/progress.tsx b/apps/docs/src/routes/components/progress.tsx new file mode 100644 index 0000000..429d336 --- /dev/null +++ b/apps/docs/src/routes/components/progress.tsx @@ -0,0 +1,29 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Progress } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/progress")({ + component: ProgressDocs, +}); + +function ProgressDocs() { + return ( + +
    + \n`} + > + + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/slider.tsx b/apps/docs/src/routes/components/slider.tsx new file mode 100644 index 0000000..ced9a00 --- /dev/null +++ b/apps/docs/src/routes/components/slider.tsx @@ -0,0 +1,37 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Slider } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/slider")({ + component: SliderDocs, +}); + +function SliderDocs() { + return ( + +
    + \n Volume\n \n \n \n \n \n`} + > + + Volume + + + + + + + + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/toast.tsx b/apps/docs/src/routes/components/toast.tsx new file mode 100644 index 0000000..4188830 --- /dev/null +++ b/apps/docs/src/routes/components/toast.tsx @@ -0,0 +1,74 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, ToastProvider, toast } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/toast")({ + component: ToastDocs, +}); + +function ToastDocs() { + return ( + +
    +

    + Mount one provider near the application root, then call toast from + components or ordinary modules. +

    + \n \n\n\ntoast.success("Repository created", {\n description: "dowel is ready",\n});`} + > + +
    + + +
    +
    +
    +
    +
    +

    + New notifications settle onto a compact stack. Hover or focus the + viewport to expand it, and drag a toast down or right to dismiss it. + Motion is reduced automatically when the operating system requests it. +

    +
    +
    +

    + Reserve toasts for transient, non-blocking results. Keep validation + and pending state next to the action that produced them. F6 moves + focus into the notification viewport. +

    +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/toggle-group.tsx b/apps/docs/src/routes/components/toggle-group.tsx new file mode 100644 index 0000000..35f3981 --- /dev/null +++ b/apps/docs/src/routes/components/toggle-group.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ToggleGroup } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/toggle-group")({ + component: ToggleGroupDocs, +}); + +function ToggleGroupDocs() { + return ( + +
    + \n List\n Board\n`} + > + + List + Board + Timeline + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/components/tree-view.tsx b/apps/docs/src/routes/components/tree-view.tsx new file mode 100644 index 0000000..b3ba964 --- /dev/null +++ b/apps/docs/src/routes/components/tree-view.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { TreeView } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/tree-view")({ + component: TreeViewDocs, +}); + +const items = [ + { + id: "product", + label: "Product", + children: [ + { id: "roadmap", label: "Roadmap" }, + { id: "feedback", label: "Feedback" }, + ], + }, + { + id: "engineering", + label: "Engineering", + children: [ + { id: "platform", label: "Platform" }, + { id: "web", label: "Web" }, + ], + }, +]; + +function TreeViewDocs() { + return ( + +
    + `} + > + + +
    +
    + ); +} diff --git a/apps/docs/src/routes/dependencies.tsx b/apps/docs/src/routes/dependencies.tsx index 3a34e04..df7c4f8 100644 --- a/apps/docs/src/routes/dependencies.tsx +++ b/apps/docs/src/routes/dependencies.tsx @@ -35,6 +35,16 @@ const runtimeDependencies = [ version: packageMetadata.dependencies["@tanstack/react-table"], role: "Its headless, application-controlled state model handles advanced table behavior without coupling Dowel to a table layout or data-fetching opinion.", }, + { + name: "@internationalized/date", + version: packageMetadata.dependencies["@internationalized/date"], + role: "Its timezone-safe date types and locale-aware calendar arithmetic keep date-only values stable across regions, time zones, and calendar systems.", + }, + { + name: "react-aria-components", + version: packageMetadata.dependencies["react-aria-components"], + role: "It supplies the Calendar and DatePicker accessibility model, including roving focus, keyboard navigation, localized date segments, and validation behavior.", + }, ] as const; const peerDependencies = [ diff --git a/apps/docs/test/browser/contrast.spec.ts b/apps/docs/test/browser/contrast.spec.ts index a711dde..10325ef 100644 --- a/apps/docs/test/browser/contrast.spec.ts +++ b/apps/docs/test/browser/contrast.spec.ts @@ -20,6 +20,107 @@ test("dependencies renders inside the documentation shell", async ({ await expect(page.locator(".docs-workspace-footer")).toBeVisible(); }); +test("documentation search uses the library command menu and includes dependencies", async ({ + page, +}) => { + await page.goto("/components/command-menu"); + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: /^search\b/i }).click(); + + const commandMenu = page.locator('[data-dowel-component="command-menu"]'); + await expect(commandMenu).toBeVisible(); + await commandMenu.getByRole("combobox").fill("dependencies"); + await commandMenu.getByText("Dependencies", { exact: true }).click(); + + await expect(page).toHaveURL(/\/dependencies$/); + await expect( + page.getByRole("heading", { level: 1, name: "Dependencies" }), + ).toBeVisible(); +}); + +test("calendar exposes its live selected date and uses a non-text cursor", async ({ + page, +}) => { + await page.goto("/components/calendar"); + await page.waitForLoadState("networkidle"); + + const twelfth = page + .getByRole("button", { name: /August 12, 2026/i }) + .first(); + await expect(twelfth).toHaveAttribute("data-selected", "true"); + await expect(page.locator(".docs-calendar-value")).toHaveText( + "Selected: 2026-08-12", + ); + await expect(twelfth).toHaveCSS("cursor", "default"); + await expect(twelfth).toHaveCSS("align-items", "center"); + await expect(twelfth).toHaveCSS("justify-content", "center"); + + await page + .getByRole("button", { name: /August 20, 2026/i }) + .first() + .click(); + await expect(page.locator(".docs-calendar-value")).toHaveText( + "Selected: 2026-08-20", + ); +}); + +test("semantic badges share one neutral shell", async ({ page }) => { + await page.goto("/components/badge"); + + const accent = page.locator( + '[data-dowel-component="badge"][data-tone="accent"]', + ); + const danger = page.locator( + '[data-dowel-component="badge"][data-tone="danger"]', + ); + const [accentBackground, dangerBackground, accentColor, dangerColor] = + await Promise.all([ + accent.evaluate((element) => getComputedStyle(element).backgroundColor), + danger.evaluate((element) => getComputedStyle(element).backgroundColor), + accent.evaluate((element) => getComputedStyle(element).color), + danger.evaluate((element) => getComputedStyle(element).color), + ]); + + expect(dangerBackground).toBe(accentBackground); + expect(dangerColor).not.toBe(accentColor); +}); + +test("component navigation opens the active category and toggles sections", async ({ + page, +}) => { + await page.goto("/components/toast"); + await expect( + page.locator('[data-dowel-component="toast-viewport"]'), + ).toBeAttached(); + + const feedback = page.getByRole("button", { name: "Feedback" }).first(); + const actions = page.getByRole("button", { name: "Actions" }).first(); + await expect(feedback).toHaveAttribute("aria-expanded", "true"); + await expect( + page.getByRole("link", { name: "Toast", exact: true }).first(), + ).toBeVisible(); + await expect(actions).toHaveAttribute("aria-expanded", "true"); + + await actions.click(); + await expect(actions).toHaveAttribute("aria-expanded", "false"); + await expect( + actions.locator("xpath=following-sibling::*[1]"), + ).toHaveAttribute("aria-hidden", "true"); +}); + +test("toast notifications stack and expand on hover", async ({ page }) => { + await page.goto("/components/toast"); + const viewport = page.locator('[data-dowel-component="toast-viewport"]'); + await expect(viewport).toBeAttached(); + await page.getByRole("button", { name: "Create stack" }).click(); + + const toasts = page.locator('[data-dowel-component="toast"]'); + await expect(toasts).toHaveCount(3); + await toasts.first().hover(); + await expect(viewport).toHaveAttribute("data-expanded", ""); + await expect(toasts.first()).toHaveAttribute("data-expanded", ""); +}); + for (const theme of themes) { test(`component catalog meets WCAG 2.2 AA contrast in ${theme} mode`, async ({ page, diff --git a/packages/dowel/README.md b/packages/dowel/README.md index fe543d7..3d8cff8 100644 --- a/packages/dowel/README.md +++ b/packages/dowel/README.md @@ -25,7 +25,7 @@ import { Button, ThemeProvider } from "@karnstack/dowel"; ## Components -Alert Dialog · Avatar · Badge · Button · Callout · Checkbox · Composer · Data Table · Dialog · Empty State · Icon Button · Input · Kbd · Menu · Native Select · Popover · Property Picker · Radio Group · Search Field · Select · Separator · Sidebar · Skeleton · Spinner · Status · Switch · Tabs · Tooltip +Alert Dialog · Avatar · Badge · Button · Callout · Checkbox · Combobox · Command Menu · Composer · Data Table · Dialog · Drawer · Empty State · Icon Button · Input · Kbd · List · Menu · Native Select · Popover · Progress · Property Picker · Radio Group · Search Field · Select · Separator · Sidebar · Skeleton · Spinner · Status · Switch · Tabs · Toast · Tooltip Visual controls own their appearance. Layout primitives accept application hooks. Every theme, focus state, and interaction ships with the package. diff --git a/packages/dowel/package.json b/packages/dowel/package.json index 1b21e0f..15900ee 100644 --- a/packages/dowel/package.json +++ b/packages/dowel/package.json @@ -53,8 +53,10 @@ "dependencies": { "@base-ui/react": "^1.7.0", "@heroicons/react": "2.2.0", + "@internationalized/date": "^3.12.3", "@stylexjs/stylex": "0.19.0", - "@tanstack/react-table": "^9.1.2" + "@tanstack/react-table": "^9.1.2", + "react-aria-components": "^1.20.0" }, "devDependencies": { "@stylexjs/eslint-plugin": "0.19.0", diff --git a/packages/dowel/src/components/accordion/accordion.stylex.ts b/packages/dowel/src/components/accordion/accordion.stylex.ts new file mode 100644 index 0000000..4677897 --- /dev/null +++ b/packages/dowel/src/components/accordion/accordion.stylex.ts @@ -0,0 +1,73 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)"; + +export const accordion = stylex.create({ + root: { + borderColor: tokens["--dowel-border-default"], + borderRadius: tokens["--dowel-radius-md"], + borderStyle: "solid", + borderWidth: tokens["--dowel-hairline"], + overflow: "hidden", + width: "100%", + }, + item: { + borderTopColor: tokens["--dowel-border-default"], + borderTopStyle: "solid", + borderTopWidth: tokens["--dowel-hairline"], + ":first-child": { borderTopWidth: 0 }, + }, + header: { margin: 0 }, + trigger: { + alignItems: "center", + backgroundColor: "transparent", + borderWidth: 0, + color: tokens["--dowel-text-primary"], + display: "flex", + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + fontWeight: 500, + gap: "0.75rem", + justifyContent: "space-between", + minHeight: "2.5rem", + outline: "none", + paddingInline: "0.75rem", + textAlign: "left", + width: "100%", + ":hover": { backgroundColor: tokens["--dowel-bg-hover"] }, + ":focus-visible": { + boxShadow: `inset 0 0 0 1px ${tokens["--dowel-focus-ring"]}`, + }, + "[data-disabled]": { color: tokens["--dowel-text-disabled"] }, + }, + triggerText: { minWidth: 0 }, + icon: { + color: tokens["--dowel-text-tertiary"], + flexShrink: 0, + transitionDuration: { + default: tokens["--dowel-duration-fast"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "transform", + transitionTimingFunction: tokens["--dowel-ease-out"], + }, + panel: { + color: tokens["--dowel-text-secondary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + height: "var(--accordion-panel-height)", + lineHeight: 1.5, + overflow: "hidden", + transitionDuration: { + default: tokens["--dowel-duration-normal"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "height", + transitionTimingFunction: tokens["--dowel-ease-out"], + "[data-starting-style]": { height: 0 }, + "[data-ending-style]": { height: 0 }, + }, + content: { padding: "0 0.75rem 0.75rem" }, +}); diff --git a/packages/dowel/src/components/accordion/accordion.test.tsx b/packages/dowel/src/components/accordion/accordion.test.tsx new file mode 100644 index 0000000..b877123 --- /dev/null +++ b/packages/dowel/src/components/accordion/accordion.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { Accordion } from "./index"; + +describe("Accordion", () => { + it("opens panels from their heading", async () => { + render( + + + + Details + + + Panel content + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Details" })); + expect(screen.getByText("Panel content")).toBeDefined(); + }); +}); diff --git a/packages/dowel/src/components/accordion/index.tsx b/packages/dowel/src/components/accordion/index.tsx new file mode 100644 index 0000000..6298f48 --- /dev/null +++ b/packages/dowel/src/components/accordion/index.tsx @@ -0,0 +1,98 @@ +import { Accordion as BaseAccordion } from "@base-ui/react/accordion"; +import { ChevronRightIcon } from "@heroicons/react/16/solid"; +import * as stylex from "@stylexjs/stylex"; +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react"; + +import * as styles from "./accordion.stylex"; + +type PartProps = Omit< + ComponentPropsWithoutRef, + "className" | "style" +>; + +function sx(...values: stylex.StyleXStyles[]) { + const resolved = stylex.props(...values); + return { className: resolved.className, style: resolved.style }; +} + +const Root = forwardRef>( + function AccordionRoot(props, ref) { + return ( + + ); + }, +); + +const Item = forwardRef>( + function AccordionItem(props, ref) { + return ( + + ); + }, +); + +const Header = forwardRef< + HTMLHeadingElement, + PartProps +>(function AccordionHeader(props, ref) { + return ( + + ); +}); + +const Trigger = forwardRef< + HTMLElement, + PartProps +>(function AccordionTrigger({ children, ...props }, ref) { + return ( + + {children} + + ); +}); + +const Panel = forwardRef>( + function AccordionPanel(props, ref) { + return ( + + ); + }, +); + +const Content = forwardRef( + function AccordionContent(props, ref) { + return
    ; + }, +); + +export const Accordion = { Root, Item, Header, Trigger, Panel, Content }; diff --git a/packages/dowel/src/components/badge/badge.stylex.ts b/packages/dowel/src/components/badge/badge.stylex.ts index 584790c..7b9d733 100644 --- a/packages/dowel/src/components/badge/badge.stylex.ts +++ b/packages/dowel/src/components/badge/badge.stylex.ts @@ -55,8 +55,5 @@ export const tone = stylex.create({ accent: { color: tokens["--dowel-accent"] }, success: { color: tokens["--dowel-success"] }, warning: { color: tokens["--dowel-warning"] }, - danger: { - backgroundColor: tokens["--dowel-danger-surface"], - color: tokens["--dowel-danger"], - }, + danger: { color: tokens["--dowel-danger"] }, }); diff --git a/packages/dowel/src/components/breadcrumbs/breadcrumbs.stylex.ts b/packages/dowel/src/components/breadcrumbs/breadcrumbs.stylex.ts new file mode 100644 index 0000000..929df1e --- /dev/null +++ b/packages/dowel/src/components/breadcrumbs/breadcrumbs.stylex.ts @@ -0,0 +1,55 @@ +import * as stylex from "@stylexjs/stylex"; +import { tokens } from "../../theme/tokens.stylex"; +export const breadcrumbs = stylex.create({ + root: { minWidth: 0 }, + list: { + alignItems: "center", + display: "flex", + flexWrap: "wrap", + gap: "0.25rem", + listStyle: "none", + margin: 0, + padding: 0, + }, + item: { + alignItems: "center", + display: "inline-flex", + gap: "0.25rem", + minWidth: 0, + }, + separator: { color: tokens["--dowel-text-disabled"], flexShrink: 0 }, + link: { + borderRadius: tokens["--dowel-radius-sm"], + color: tokens["--dowel-text-secondary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.75rem", + outline: "none", + padding: "0.125rem 0.25rem", + textDecoration: "none", + ":hover": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + ":focus-visible": { + outlineColor: tokens["--dowel-focus-ring"], + outlineStyle: "solid", + outlineWidth: "1px", + }, + }, + current: { + color: tokens["--dowel-text-primary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.75rem", + fontWeight: 500, + overflow: "hidden", + padding: "0.125rem 0.25rem", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + ellipsis: { + alignItems: "center", + color: tokens["--dowel-text-tertiary"], + display: "inline-flex", + padding: "0.125rem", + }, +}); diff --git a/packages/dowel/src/components/breadcrumbs/breadcrumbs.test.tsx b/packages/dowel/src/components/breadcrumbs/breadcrumbs.test.tsx new file mode 100644 index 0000000..1cea75f --- /dev/null +++ b/packages/dowel/src/components/breadcrumbs/breadcrumbs.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Breadcrumbs } from "./index"; +describe("Breadcrumbs", () => { + it("marks the current page", () => { + render( + , + ); + expect(screen.getByText("Settings").getAttribute("aria-current")).toBe( + "page", + ); + }); +}); diff --git a/packages/dowel/src/components/breadcrumbs/index.tsx b/packages/dowel/src/components/breadcrumbs/index.tsx new file mode 100644 index 0000000..00dc119 --- /dev/null +++ b/packages/dowel/src/components/breadcrumbs/index.tsx @@ -0,0 +1,78 @@ +import { + ChevronRightIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/16/solid"; +import * as stylex from "@stylexjs/stylex"; +import type { ReactNode } from "react"; +import * as styles from "./breadcrumbs.stylex"; + +export interface BreadcrumbItem { + label: ReactNode; + href?: string; + current?: boolean; +} +export interface BreadcrumbsProps { + items: BreadcrumbItem[]; + ariaLabel?: string; + maxItems?: number; +} +function sx(...v: stylex.StyleXStyles[]) { + const r = stylex.props(...v); + return { className: r.className, style: r.style }; +} +export function Breadcrumbs({ + items, + ariaLabel = "Breadcrumbs", + maxItems = 5, +}: BreadcrumbsProps) { + const visible: Array = + items.length > maxItems + ? [items[0]!, "ellipsis", ...items.slice(-(maxItems - 2))] + : items; + return ( + + ); +} diff --git a/packages/dowel/src/components/calendar/calendar.stylex.ts b/packages/dowel/src/components/calendar/calendar.stylex.ts new file mode 100644 index 0000000..75436d6 --- /dev/null +++ b/packages/dowel/src/components/calendar/calendar.stylex.ts @@ -0,0 +1,240 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const COARSE = "@media (pointer: coarse)"; +const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)"; + +export const calendar = stylex.create({ + root: { + boxSizing: "border-box", + color: tokens["--dowel-text-primary"], + fontFamily: tokens["--dowel-font-sans"], + padding: "0.25rem", + width: "17.5rem", + }, + header: { + alignItems: "center", + display: "grid", + gap: "0.25rem", + gridTemplateColumns: "2rem 1fr 2rem", + marginBottom: "0.375rem", + }, + month: { + fontSize: "0.8125rem", + fontWeight: 600, + margin: 0, + textAlign: "center", + }, + nav: { + alignItems: "center", + backgroundColor: "transparent", + borderRadius: tokens["--dowel-radius-sm"], + borderWidth: 0, + color: tokens["--dowel-text-secondary"], + display: "inline-flex", + height: "2rem", + justifyContent: "center", + outline: "none", + padding: 0, + width: "2rem", + ":hover": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + ":focus-visible": { + outlineColor: tokens["--dowel-focus-ring"], + outlineOffset: "-1px", + outlineStyle: "solid", + outlineWidth: "1px", + }, + }, + grid: { + borderCollapse: "separate", + borderSpacing: "0.125rem", + tableLayout: "fixed", + width: "100%", + }, + weekday: { + color: tokens["--dowel-text-tertiary"], + fontSize: "0.6875rem", + fontWeight: 600, + height: "1.5rem", + padding: 0, + textAlign: "center", + textTransform: "uppercase", + }, + day: { + alignItems: "center", + backgroundColor: "transparent", + borderRadius: tokens["--dowel-radius-sm"], + boxSizing: "border-box", + color: tokens["--dowel-text-secondary"], + cursor: "default", + display: "flex", + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.75rem", + height: { default: "2rem", [COARSE]: "2.5rem" }, + justifyContent: "center", + outline: "none", + padding: 0, + position: "relative", + textAlign: "center", + ":hover": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + }, + dayFocused: { + outlineColor: tokens["--dowel-focus-ring"], + outlineOffset: "-1px", + outlineStyle: "solid", + outlineWidth: "1px", + }, + outside: { color: tokens["--dowel-text-disabled"] }, + selected: { + backgroundColor: tokens["--dowel-accent"], + color: tokens["--dowel-on-accent"], + ":hover": { + backgroundColor: tokens["--dowel-accent-hover"], + color: tokens["--dowel-on-accent"], + }, + }, + today: { + fontWeight: 700, + "::after": { + backgroundColor: tokens["--dowel-accent"], + borderRadius: "50%", + bottom: "0.1875rem", + content: '""', + height: "2px", + left: "calc(50% - 1px)", + position: "absolute", + width: "2px", + }, + }, + todaySelected: { + "::after": { backgroundColor: tokens["--dowel-on-accent"] }, + }, + disabled: { + color: tokens["--dowel-text-disabled"], + opacity: 0.45, + }, + unavailable: { + color: tokens["--dowel-text-tertiary"], + textDecoration: "line-through", + }, +}); + +export const picker = stylex.create({ + root: { + display: "inline-flex", + flexDirection: "column", + fontFamily: tokens["--dowel-font-sans"], + gap: "0.375rem", + minWidth: 0, + }, + label: { + color: tokens["--dowel-text-primary"], + fontSize: "0.8125rem", + fontWeight: 500, + lineHeight: 1.4, + }, + group: { + alignItems: "center", + backgroundColor: tokens["--dowel-bg-surface-1"], + borderColor: tokens["--dowel-border-default"], + borderRadius: tokens["--dowel-radius-md"], + borderStyle: "solid", + borderWidth: tokens["--dowel-hairline"], + boxShadow: tokens["--dowel-shadow-control"], + boxSizing: "border-box", + display: "inline-flex", + height: tokens["--dowel-control-md"], + minWidth: "10rem", + transitionDuration: tokens["--dowel-duration-normal"], + transitionProperty: "border-color, background-color, opacity", + transitionTimingFunction: tokens["--dowel-ease-out"], + ":hover": { borderColor: tokens["--dowel-border-strong"] }, + }, + groupFocused: { + borderColor: tokens["--dowel-focus-ring"], + boxShadow: "none", + }, + groupInvalid: { borderColor: tokens["--dowel-danger"] }, + disabled: { cursor: "not-allowed", opacity: 0.55 }, + input: { + alignItems: "center", + color: tokens["--dowel-text-primary"], + display: "flex", + flex: 1, + fontSize: "0.8125rem", + fontWeight: 450, + letterSpacing: "-0.0125rem", + minWidth: 0, + paddingLeft: "0.625rem", + }, + segment: { + borderRadius: "3px", + color: tokens["--dowel-text-primary"], + outline: "none", + paddingBlock: "0.125rem", + "[data-placeholder]": { color: tokens["--dowel-text-tertiary"] }, + "[data-focused]": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + "[data-type='literal']": { color: tokens["--dowel-text-tertiary"] }, + }, + trigger: { + alignItems: "center", + alignSelf: "stretch", + backgroundColor: "transparent", + borderColor: tokens["--dowel-border-subtle"], + borderRadius: 0, + borderStyle: "solid", + borderWidth: "0 0 0 1px", + color: tokens["--dowel-text-secondary"], + display: "inline-flex", + justifyContent: "center", + outline: "none", + padding: 0, + width: "2rem", + ":hover": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + ":focus-visible": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + }, + description: { + color: tokens["--dowel-text-tertiary"], + fontSize: "0.75rem", + lineHeight: 1.5, + }, + popover: { + backgroundColor: tokens["--dowel-bg-elevated"], + borderColor: tokens["--dowel-border-default"], + borderRadius: "10px", + borderStyle: "solid", + borderWidth: tokens["--dowel-hairline"], + boxShadow: tokens["--dowel-shadow-popover"], + boxSizing: "border-box", + maxWidth: "calc(100vw - 1rem)", + opacity: 1, + outline: "none", + padding: "0.5rem", + transitionDuration: { + default: tokens["--dowel-duration-fast"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "opacity, transform", + transitionTimingFunction: tokens["--dowel-ease-out"], + zIndex: 70, + "[data-entering]": { opacity: 0, transform: "translateY(-2px)" }, + "[data-exiting]": { opacity: 0, transform: "translateY(-2px)" }, + }, + dialog: { outline: "none" }, +}); diff --git a/packages/dowel/src/components/calendar/calendar.test.tsx b/packages/dowel/src/components/calendar/calendar.test.tsx new file mode 100644 index 0000000..1a9224e --- /dev/null +++ b/packages/dowel/src/components/calendar/calendar.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { parseDate } from "@internationalized/date"; +import { describe, expect, it, vi } from "vitest"; +import { Calendar, DatePicker } from "./index"; +describe("Calendar", () => { + it("marks the controlled date as selected", () => { + render(); + expect( + screen + .getByRole("button", { name: /August 12, 2026/i }) + .getAttribute("data-selected"), + ).not.toBeNull(); + }); + + it("selects a date", async () => { + const change = vi.fn(); + render( + , + ); + await userEvent.click( + screen.getByRole("button", { name: /August 12, 2026/i }), + ); + expect(change.mock.calls[0]?.[0]?.toString()).toBe("2026-08-12"); + }); + it("opens from a date picker", async () => { + render( + , + ); + await userEvent.click( + screen.getByRole("button", { name: /Open calendar.*Due date/i }), + ); + expect( + await screen.findByRole("grid", { name: /August 2026/i }), + ).toBeDefined(); + }); + it("moves focus with calendar arrow keys", async () => { + render(); + const selected = screen.getByRole("button", { + name: /August 12, 2026/i, + }); + selected.focus(); + await userEvent.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: /August 13, 2026/i }), + ); + }); +}); diff --git a/packages/dowel/src/components/calendar/index.tsx b/packages/dowel/src/components/calendar/index.tsx new file mode 100644 index 0000000..f36ef58 --- /dev/null +++ b/packages/dowel/src/components/calendar/index.tsx @@ -0,0 +1,223 @@ +import { + CalendarDaysIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "@heroicons/react/16/solid"; +import { CalendarDate } from "@internationalized/date"; +import * as stylex from "@stylexjs/stylex"; +import { + Button as AriaButton, + Calendar as AriaCalendar, + CalendarCell as AriaCalendarCell, + CalendarGrid as AriaCalendarGrid, + CalendarGridBody as AriaCalendarGridBody, + CalendarGridHeader as AriaCalendarGridHeader, + CalendarHeaderCell as AriaCalendarHeaderCell, + CalendarHeading as AriaCalendarHeading, + DateInput as AriaDateInput, + DatePicker as AriaDatePicker, + DateSegment as AriaDateSegment, + Dialog as AriaDialog, + Group as AriaGroup, + I18nProvider, + Label as AriaLabel, + Popover as AriaPopover, +} from "react-aria-components"; +import type { + CalendarCellRenderProps, + CalendarProps as AriaCalendarProps, + DatePickerProps as AriaDatePickerProps, + GroupRenderProps, +} from "react-aria-components"; +import { useState } from "react"; +import type { ReactNode } from "react"; + +import * as styles from "./calendar.stylex"; + +function sx(...values: stylex.StyleXStyles[]) { + const resolved = stylex.props(...values); + return { className: resolved.className, style: resolved.style }; +} + +function calendarCellClassName({ + isDisabled, + isFocusVisible, + isOutsideMonth, + isSelected, + isToday, + isUnavailable, +}: CalendarCellRenderProps) { + return ( + sx( + styles.calendar.day, + isOutsideMonth && styles.calendar.outside, + isSelected && styles.calendar.selected, + isToday && styles.calendar.today, + isToday && isSelected && styles.calendar.todaySelected, + isFocusVisible && styles.calendar.dayFocused, + isDisabled && styles.calendar.disabled, + isUnavailable && styles.calendar.unavailable, + ).className ?? "" + ); +} + +function CalendarContents() { + return ( + <> +
    + + + + + +
    + + + {(day) => ( + + {day} + + )} + + + {(date) => ( + + )} + + + + ); +} + +export interface CalendarProps + extends Omit< + AriaCalendarProps, + "aria-label" | "children" | "className" | "onChange" | "style" + > { + ariaLabel?: string; + locale?: string; + onValueChange?: (date: CalendarDate) => void; +} + +function CalendarRoot({ + ariaLabel = "Choose date", + onValueChange, + ...props +}: Omit) { + return ( + + + + ); +} + +export function Calendar({ locale, ...props }: CalendarProps) { + const calendar = ; + return locale ? ( + {calendar} + ) : ( + calendar + ); +} + +export interface DatePickerProps + extends Omit< + AriaDatePickerProps, + "children" | "className" | "onChange" | "style" + > { + label: string; + description?: ReactNode; + locale?: string; + onValueChange?: (date: CalendarDate | null) => void; +} + +function groupClassName({ + isDisabled, + isFocusWithin, + isInvalid, +}: GroupRenderProps) { + return ( + sx( + styles.picker.group, + isFocusWithin && styles.picker.groupFocused, + isInvalid && styles.picker.groupInvalid, + isDisabled && styles.picker.disabled, + ).className ?? "" + ); +} + +function DatePickerRoot({ + label, + description, + onValueChange, + ...props +}: Omit) { + const [portalContainer, setPortalContainer] = useState( + null, + ); + return ( + + {label} + + + {(segment) => ( + + )} + + + + + {description ? ( + + {description} + + ) : null} + + + + + + + + + ); +} + +export function DatePicker({ locale, ...props }: DatePickerProps) { + const picker = ; + return locale ? ( + {picker} + ) : ( + picker + ); +} + +export { CalendarDate }; diff --git a/packages/dowel/src/components/collapsible/collapsible.stylex.ts b/packages/dowel/src/components/collapsible/collapsible.stylex.ts new file mode 100644 index 0000000..a65d605 --- /dev/null +++ b/packages/dowel/src/components/collapsible/collapsible.stylex.ts @@ -0,0 +1,62 @@ +import * as stylex from "@stylexjs/stylex"; +import { tokens } from "../../theme/tokens.stylex"; +const REDUCED = "@media (prefers-reduced-motion: reduce)"; +export const collapsible = stylex.create({ + root: { color: tokens["--dowel-text-primary"], width: "100%" }, + trigger: { + alignItems: "center", + backgroundColor: "transparent", + borderRadius: tokens["--dowel-radius-sm"], + borderWidth: 0, + color: tokens["--dowel-text-secondary"], + display: "flex", + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + fontWeight: 500, + gap: "0.5rem", + justifyContent: "space-between", + minHeight: "2rem", + outline: "none", + paddingInline: "0.5rem", + width: "100%", + ":hover": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + ":focus-visible": { + outlineColor: tokens["--dowel-focus-ring"], + outlineOffset: "-1px", + outlineStyle: "solid", + outlineWidth: "1px", + }, + }, + icon: { + color: tokens["--dowel-text-tertiary"], + flexShrink: 0, + transitionDuration: { + default: tokens["--dowel-duration-fast"], + [REDUCED]: "0.01ms", + }, + transitionProperty: "transform", + transitionTimingFunction: tokens["--dowel-ease-out"], + }, + panel: { + height: "var(--collapsible-panel-height)", + overflow: "hidden", + transitionDuration: { + default: tokens["--dowel-duration-normal"], + [REDUCED]: "0.01ms", + }, + transitionProperty: "height", + transitionTimingFunction: tokens["--dowel-ease-out"], + "[data-starting-style]": { height: 0 }, + "[data-ending-style]": { height: 0 }, + }, + content: { + color: tokens["--dowel-text-secondary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + lineHeight: 1.5, + padding: "0.375rem 0.5rem 0.5rem", + }, +}); diff --git a/packages/dowel/src/components/collapsible/collapsible.test.tsx b/packages/dowel/src/components/collapsible/collapsible.test.tsx new file mode 100644 index 0000000..5326b71 --- /dev/null +++ b/packages/dowel/src/components/collapsible/collapsible.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { Collapsible } from "./index"; +describe("Collapsible", () => { + it("toggles its panel", async () => { + render( + + More + + Hidden details + + , + ); + await userEvent.click(screen.getByRole("button", { name: "More" })); + expect(screen.getByText("Hidden details")).toBeDefined(); + }); +}); diff --git a/packages/dowel/src/components/collapsible/index.tsx b/packages/dowel/src/components/collapsible/index.tsx new file mode 100644 index 0000000..c58728d --- /dev/null +++ b/packages/dowel/src/components/collapsible/index.tsx @@ -0,0 +1,68 @@ +import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible"; +import { ChevronRightIcon } from "@heroicons/react/16/solid"; +import * as stylex from "@stylexjs/stylex"; +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react"; + +import * as styles from "./collapsible.stylex"; + +type PartProps = Omit< + ComponentPropsWithoutRef, + "className" | "style" +>; +function sx(...values: stylex.StyleXStyles[]) { + const r = stylex.props(...values); + return { className: r.className, style: r.style }; +} + +const Root = forwardRef>( + function Root(props, ref) { + return ( + + ); + }, +); +const Trigger = forwardRef< + HTMLButtonElement, + PartProps +>(function Trigger({ children, ...props }, ref) { + return ( + + {children} + + ); +}); +const Panel = forwardRef< + HTMLDivElement, + PartProps +>(function Panel(props, ref) { + return ( + + ); +}); +const Content = forwardRef( + function Content(props, ref) { + return
    ; + }, +); +export const Collapsible = { Root, Trigger, Panel, Content }; diff --git a/packages/dowel/src/components/combobox/combobox.stylex.ts b/packages/dowel/src/components/combobox/combobox.stylex.ts new file mode 100644 index 0000000..45c9a23 --- /dev/null +++ b/packages/dowel/src/components/combobox/combobox.stylex.ts @@ -0,0 +1,164 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)"; + +export const control = stylex.create({ + root: { + alignItems: "center", + backgroundColor: tokens["--dowel-bg-surface-1"], + borderColor: tokens["--dowel-border-default"], + borderRadius: tokens["--dowel-radius-md"], + borderStyle: "solid", + borderWidth: tokens["--dowel-hairline"], + boxShadow: tokens["--dowel-shadow-control"], + boxSizing: "border-box", + display: "flex", + height: tokens["--dowel-control-lg"], + minWidth: "12rem", + overflow: "hidden", + ":focus-within": { + borderColor: tokens["--dowel-focus-ring"], + outlineColor: tokens["--dowel-focus-ring"], + outlineOffset: "-1px", + outlineStyle: "solid", + outlineWidth: "1px", + }, + ":has(input:disabled)": { + backgroundColor: tokens["--dowel-bg-surface-2"], + color: tokens["--dowel-text-disabled"], + }, + }, + input: { + backgroundColor: "transparent", + borderWidth: 0, + boxSizing: "border-box", + color: tokens["--dowel-text-primary"], + flex: 1, + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.875rem", + height: "100%", + lineHeight: 1.4, + minWidth: 0, + outline: "none", + padding: "0 0.625rem", + "::placeholder": { color: tokens["--dowel-text-tertiary"] }, + ":disabled": { cursor: "not-allowed" }, + }, + actions: { + alignItems: "center", + display: "flex", + paddingInlineEnd: "0.25rem", + }, + action: { + alignItems: "center", + backgroundColor: "transparent", + borderWidth: 0, + borderRadius: tokens["--dowel-radius-sm"], + color: tokens["--dowel-text-tertiary"], + display: "inline-flex", + height: "1.75rem", + justifyContent: "center", + padding: 0, + width: "1.75rem", + ":hover": { + backgroundColor: tokens["--dowel-bg-hover"], + color: tokens["--dowel-text-primary"], + }, + ":focus-visible": { + outlineColor: tokens["--dowel-focus-ring"], + outlineOffset: "-2px", + outlineStyle: "solid", + outlineWidth: "2px", + }, + "[data-disabled]": { cursor: "not-allowed", opacity: 0.5 }, + }, +}); + +export const popup = stylex.create({ + root: { + backgroundColor: tokens["--dowel-bg-elevated"], + borderColor: tokens["--dowel-border-default"], + borderRadius: tokens["--dowel-radius-md"], + borderStyle: "solid", + borderWidth: tokens["--dowel-hairline"], + boxShadow: tokens["--dowel-shadow-popover"], + boxSizing: "border-box", + color: tokens["--dowel-text-primary"], + maxHeight: "min(20rem, var(--available-height))", + minWidth: "var(--anchor-width)", + opacity: 1, + outline: "none", + overflow: "hidden", + transformOrigin: "var(--transform-origin)", + transitionDuration: { + default: tokens["--dowel-duration-fast"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "opacity, scale", + transitionTimingFunction: tokens["--dowel-ease-out"], + "[data-starting-style]": { opacity: 0, scale: 0.98 }, + "[data-ending-style]": { opacity: 0, scale: 0.98 }, + }, + list: { maxHeight: "19rem", overflowY: "auto", padding: "0.25rem" }, + groupLabel: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.6875rem", + fontWeight: 550, + lineHeight: 1.4, + padding: "0.5rem 0.5rem 0.25rem", + }, + item: { + alignItems: "center", + borderRadius: tokens["--dowel-radius-sm"], + boxSizing: "border-box", + cursor: "default", + display: "flex", + gap: "0.5rem", + minHeight: tokens["--dowel-control-md"], + outline: "none", + padding: "0.375rem 0.5rem", + "[data-highlighted]": { backgroundColor: tokens["--dowel-bg-hover"] }, + "[data-selected]": { color: tokens["--dowel-accent"] }, + "[data-disabled]": { color: tokens["--dowel-text-disabled"] }, + }, + icon: { + alignItems: "center", + color: tokens["--dowel-text-tertiary"], + display: "inline-flex", + flexShrink: 0, + }, + copy: { display: "flex", flex: 1, flexDirection: "column", minWidth: 0 }, + label: { + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + lineHeight: 1.4, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + description: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.75rem", + lineHeight: 1.4, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + indicator: { + alignItems: "center", + color: tokens["--dowel-accent"], + display: "inline-flex", + flexShrink: 0, + }, + empty: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + padding: "1rem", + textAlign: "center", + }, +}); diff --git a/packages/dowel/src/components/combobox/combobox.test.tsx b/packages/dowel/src/components/combobox/combobox.test.tsx new file mode 100644 index 0000000..19466b0 --- /dev/null +++ b/packages/dowel/src/components/combobox/combobox.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { Combobox } from "./index"; + +const options = [ + { value: "acme", label: "Acme", group: "Organizations" }, + { value: "northstar", label: "Northstar", group: "Organizations" }, + { + value: "archive", + label: "Archive", + description: "Read only", + disabled: true, + }, +] as const; + +describe("Combobox", () => { + it("filters and selects an option", async () => { + const onValueChange = vi.fn(); + render( + , + ); + const input = screen.getByRole("combobox", { name: "Organization" }); + await userEvent.type(input, "north"); + expect(screen.queryByRole("option", { name: "Acme" })).toBeNull(); + await userEvent.keyboard("{ArrowDown}{Enter}"); + expect(onValueChange).toHaveBeenCalledWith("northstar"); + expect((input as HTMLInputElement).value).toBe("Northstar"); + }); + + it("submits the selected value", () => { + const { container } = render( +
    + + , + ); + expect( + new FormData(container.querySelector("form")!).get("organization"), + ).toBe("acme"); + }); + + it("has no accessibility violations when open", async () => { + const { container } = render( +
    + +
    , + ); + await userEvent.click( + screen.getByRole("button", { name: "Open organization options" }), + ); + await screen.findByRole("listbox"); + await expectNoA11yViolations(container.ownerDocument.body); + }); +}); diff --git a/packages/dowel/src/components/combobox/index.tsx b/packages/dowel/src/components/combobox/index.tsx new file mode 100644 index 0000000..c43bf8e --- /dev/null +++ b/packages/dowel/src/components/combobox/index.tsx @@ -0,0 +1,209 @@ +import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; +import { + CheckIcon, + ChevronDownIcon, + XMarkIcon, +} from "@heroicons/react/16/solid"; +import * as stylex from "@stylexjs/stylex"; +import { useContext, useMemo, useState } from "react"; +import type { ReactNode } from "react"; + +import { DowelThemeContext, themeStyles } from "../../theme/theme-provider"; +import * as styles from "./combobox.stylex"; + +export interface ComboboxOption { + value: string; + label: string; + description?: string; + group?: string; + icon?: ReactNode; + disabled?: boolean; +} + +export interface ComboboxProps { + label: string; + options: readonly ComboboxOption[]; + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; + onInputValueChange?: (inputValue: string) => void; + placeholder?: string; + emptyText?: string; + clearable?: boolean; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + name?: string; + form?: string; + id?: string; + autoComplete?: string; + onOpenChange?: (open: boolean) => void; +} + +type OptionGroup = { + key: string; + label?: string; + items: readonly ComboboxOption[]; +}; + +function groupOptions(options: readonly ComboboxOption[]): OptionGroup[] { + const groups = new Map(); + for (const option of options) { + const key = option.group ?? ""; + const group = groups.get(key); + if (group) group.push(option); + else groups.set(key, [option]); + } + return Array.from(groups, ([key, items]) => ({ + key: key || "__ungrouped", + label: key || undefined, + items, + })); +} + +function sx(style: stylex.StyleXStyles) { + const resolved = stylex.props(style); + return { className: resolved.className, style: resolved.style }; +} + +export function Combobox({ + label, + options, + value, + defaultValue = null, + onValueChange, + onInputValueChange, + placeholder = "Select an option", + emptyText = "No matching options", + clearable = true, + disabled = false, + readOnly = false, + required = false, + name, + form, + id, + autoComplete, + onOpenChange, +}: ComboboxProps) { + const theme = useContext(DowelThemeContext); + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); + const currentValue = value === undefined ? uncontrolledValue : value; + const selectedOption = + options.find((option) => option.value === currentValue) ?? null; + const groups = useMemo(() => groupOptions(options), [options]); + const portalTheme = stylex.props(themeStyles[theme]); + + function changeValue(next: ComboboxOption | null) { + const nextValue = next?.value ?? null; + if (value === undefined) setUncontrolledValue(nextValue); + onValueChange?.(nextValue); + } + + return ( + + id={id} + name={name} + form={form} + items={groups} + value={selectedOption} + onValueChange={changeValue} + onInputValueChange={(inputValue) => onInputValueChange?.(inputValue)} + onOpenChange={(open) => onOpenChange?.(open)} + itemToStringLabel={(option) => option.label} + itemToStringValue={(option) => option.value} + isItemEqualToValue={(option, selected) => option.value === selected.value} + autoHighlight + autoComplete={autoComplete} + disabled={disabled} + readOnly={readOnly} + required={required} + > + + + + {clearable ? ( + + + + ) : null} + + + + + + + + + +
    {emptyText}
    +
    + + {(group: OptionGroup) => ( + + {group.label ? ( + + {group.label} + + ) : null} + + {(option: ComboboxOption) => ( + + {option.icon ? ( + + ) : null} + + + {option.label} + + {option.description ? ( + + {option.description} + + ) : null} + + + + + + )} + + + )} + +
    +
    +
    + + ); +} diff --git a/packages/dowel/src/components/command-menu/command-menu.stylex.ts b/packages/dowel/src/components/command-menu/command-menu.stylex.ts new file mode 100644 index 0000000..2a40c65 --- /dev/null +++ b/packages/dowel/src/components/command-menu/command-menu.stylex.ts @@ -0,0 +1,167 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const MOBILE = "@media (max-width: 639px)"; +const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)"; + +export const command = stylex.create({ + title: { + clip: "rect(0 0 0 0)", + clipPath: "inset(50%)", + height: "1px", + overflow: "hidden", + position: "absolute", + whiteSpace: "nowrap", + width: "1px", + }, + backdrop: { + backgroundColor: tokens["--dowel-scrim"], + inset: 0, + opacity: 1, + position: "fixed", + transitionDuration: { + default: tokens["--dowel-duration-normal"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "opacity", + zIndex: 60, + "[data-starting-style]": { opacity: 0 }, + "[data-ending-style]": { opacity: 0 }, + }, + popup: { + backgroundColor: tokens["--dowel-bg-elevated"], + borderColor: tokens["--dowel-border-default"], + borderRadius: { + default: tokens["--dowel-radius-lg"], + [MOBILE]: tokens["--dowel-radius-md"], + }, + borderStyle: "solid", + borderWidth: tokens["--dowel-hairline"], + boxShadow: tokens["--dowel-shadow-modal"], + boxSizing: "border-box", + color: tokens["--dowel-text-primary"], + insetBlockStart: { default: "max(3rem, 15vh)", [MOBILE]: "0.5rem" }, + insetInlineStart: "50%", + maxHeight: { + default: "min(32rem, calc(100dvh - 6rem))", + [MOBILE]: "calc(100dvh - 1rem)", + }, + opacity: 1, + outline: "none", + overflow: "hidden", + position: "fixed", + transform: "translateX(-50%)", + transitionDuration: { + default: tokens["--dowel-duration-normal"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "opacity, translate", + transitionTimingFunction: tokens["--dowel-ease-out"], + translate: "0 0", + width: { + default: "min(38rem, calc(100vw - 2rem))", + [MOBILE]: "calc(100vw - 1rem)", + }, + zIndex: 61, + "[data-starting-style]": { opacity: 0, translate: "0 -0.25rem" }, + "[data-ending-style]": { opacity: 0, translate: "0 -0.25rem" }, + }, + search: { + alignItems: "center", + borderBottomColor: tokens["--dowel-border-subtle"], + borderBottomStyle: "solid", + borderBottomWidth: tokens["--dowel-hairline"], + display: "flex", + gap: "0.625rem", + minHeight: "3rem", + paddingInline: "0.875rem", + }, + searchIcon: { color: tokens["--dowel-text-tertiary"], flexShrink: 0 }, + input: { + backgroundColor: "transparent", + borderWidth: 0, + color: tokens["--dowel-text-primary"], + flex: 1, + fontFamily: tokens["--dowel-font-sans"], + fontSize: { default: "0.9375rem", [MOBILE]: "1rem" }, + height: "3rem", + minWidth: 0, + outline: "none", + padding: 0, + "::placeholder": { color: tokens["--dowel-text-tertiary"] }, + }, + list: { maxHeight: "26rem", overflowY: "auto", padding: "0.375rem" }, + groupLabel: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.6875rem", + fontWeight: 550, + lineHeight: 1.4, + padding: "0.5rem 0.5rem 0.25rem", + }, + item: { + alignItems: "center", + borderRadius: tokens["--dowel-radius-sm"], + cursor: "default", + display: "flex", + gap: "0.625rem", + minHeight: "2.5rem", + outline: "none", + padding: "0.375rem 0.5rem", + "[data-highlighted]": { backgroundColor: tokens["--dowel-bg-hover"] }, + "[data-disabled]": { color: tokens["--dowel-text-disabled"] }, + }, + itemIcon: { + alignItems: "center", + color: tokens["--dowel-text-tertiary"], + display: "inline-flex", + flexShrink: 0, + }, + itemCopy: { display: "flex", flex: 1, flexDirection: "column", minWidth: 0 }, + itemLabel: { + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + lineHeight: 1.4, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + itemDescription: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.75rem", + lineHeight: 1.4, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + shortcut: { + color: tokens["--dowel-text-tertiary"], + flexShrink: 0, + fontFamily: tokens["--dowel-font-mono"], + fontSize: "0.6875rem", + lineHeight: 1.4, + }, + empty: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + padding: "2rem 1rem", + textAlign: "center", + }, + footer: { + alignItems: "center", + borderTopColor: tokens["--dowel-border-subtle"], + borderTopStyle: "solid", + borderTopWidth: tokens["--dowel-hairline"], + color: tokens["--dowel-text-tertiary"], + display: "flex", + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.6875rem", + gap: "0.75rem", + justifyContent: "flex-end", + minHeight: "2rem", + padding: "0.375rem 0.75rem", + }, +}); diff --git a/packages/dowel/src/components/command-menu/command-menu.test.tsx b/packages/dowel/src/components/command-menu/command-menu.test.tsx new file mode 100644 index 0000000..5af6b97 --- /dev/null +++ b/packages/dowel/src/components/command-menu/command-menu.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { Button } from "../button"; +import { CommandMenu } from "./index"; + +function items(onSelect = vi.fn()) { + return [ + { + id: "repo", + label: "Open repository", + group: "Navigation", + shortcut: "G R", + onSelect, + }, + { + id: "settings", + label: "Open settings", + group: "Navigation", + keywords: ["preferences"], + onSelect: vi.fn(), + }, + ]; +} + +describe("CommandMenu", () => { + it("supports controlled open state without a trigger", () => { + render(); + expect( + screen.getByRole("combobox", { name: "Command menu" }), + ).toBeDefined(); + }); + + it("filters, runs a command, and closes", async () => { + const onSelect = vi.fn(); + render( + Commands} + items={items(onSelect)} + />, + ); + await userEvent.click(screen.getByRole("button", { name: "Commands" })); + const input = screen.getByRole("combobox", { name: "Command menu" }); + await userEvent.type(input, "repo"); + await userEvent.keyboard("{ArrowDown}{Enter}"); + expect(onSelect).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("matches keywords", async () => { + render(); + await userEvent.type(screen.getByRole("combobox"), "preferences"); + expect(screen.getByText("Open settings")).toBeDefined(); + }); + + it("has no accessibility violations when open", async () => { + render(); + await expectNoA11yViolations(screen.getByRole("dialog")); + }); +}); diff --git a/packages/dowel/src/components/command-menu/index.tsx b/packages/dowel/src/components/command-menu/index.tsx new file mode 100644 index 0000000..5b87e94 --- /dev/null +++ b/packages/dowel/src/components/command-menu/index.tsx @@ -0,0 +1,192 @@ +import { Autocomplete as BaseAutocomplete } from "@base-ui/react/autocomplete"; +import { Dialog as BaseDialog } from "@base-ui/react/dialog"; +import { MagnifyingGlassIcon } from "@heroicons/react/16/solid"; +import * as stylex from "@stylexjs/stylex"; +import { useContext, useMemo, useState } from "react"; +import type { ReactElement, ReactNode } from "react"; + +import { DowelThemeContext, themeStyles } from "../../theme/theme-provider"; +import * as styles from "./command-menu.stylex"; + +export interface CommandMenuItem { + id: string; + label: string; + description?: string; + group?: string; + icon?: ReactNode; + shortcut?: string; + disabled?: boolean; + keywords?: readonly string[]; + onSelect: () => void; +} + +export interface CommandMenuProps { + label?: string; + items: readonly CommandMenuItem[]; + trigger?: ReactElement; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placeholder?: string; + emptyText?: string; + footer?: ReactNode; +} + +type CommandGroup = { + key: string; + label?: string; + items: readonly CommandMenuItem[]; +}; + +function groupItems(items: readonly CommandMenuItem[]): CommandGroup[] { + const groups = new Map(); + for (const item of items) { + const key = item.group ?? ""; + const group = groups.get(key); + if (group) group.push(item); + else groups.set(key, [item]); + } + return Array.from(groups, ([key, groupedItems]) => ({ + key: key || "__ungrouped", + label: key || undefined, + items: groupedItems, + })); +} + +function sx(...values: stylex.StyleXStyles[]) { + const resolved = stylex.props(...values); + return { className: resolved.className, style: resolved.style }; +} + +export function CommandMenu({ + label = "Command menu", + items, + trigger, + open, + defaultOpen = false, + onOpenChange, + placeholder = "Type a command or search", + emptyText = "No matching commands", + footer, +}: CommandMenuProps) { + const theme = useContext(DowelThemeContext); + const portalTheme = stylex.props(themeStyles[theme]); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const [query, setQuery] = useState(""); + const currentOpen = open === undefined ? uncontrolledOpen : open; + const groups = useMemo(() => groupItems(items), [items]); + + function changeOpen(nextOpen: boolean) { + if (open === undefined) setUncontrolledOpen(nextOpen); + if (!nextOpen) setQuery(""); + onOpenChange?.(nextOpen); + } + + function select(item: CommandMenuItem) { + item.onSelect(); + changeOpen(false); + } + + return ( + + {trigger ? : null} + + + + } {...sx(styles.command.title)}> + {label} + + setQuery(nextQuery)} + itemToStringValue={(item) => + [item.label, item.description, ...(item.keywords ?? [])] + .filter(Boolean) + .join(" ") + } + autoHighlight="always" + inline + open + > + + + + + +
    {emptyText}
    +
    + + {(group: CommandGroup) => ( + + {group.label ? ( + + {group.label} + + ) : null} + + {(item: CommandMenuItem) => ( + select(item)} + {...sx(styles.command.item)} + > + {item.icon ? ( + + ) : null} + + + {item.label} + + {item.description ? ( + + {item.description} + + ) : null} + + {item.shortcut ? ( + + {item.shortcut} + + ) : null} + + )} + + + )} + +
    + {footer ?
    {footer}
    : null} +
    +
    +
    + ); +} diff --git a/packages/dowel/src/components/context-menu/context-menu.stylex.ts b/packages/dowel/src/components/context-menu/context-menu.stylex.ts new file mode 100644 index 0000000..9bd4a76 --- /dev/null +++ b/packages/dowel/src/components/context-menu/context-menu.stylex.ts @@ -0,0 +1,23 @@ +import * as stylex from "@stylexjs/stylex"; +import { tokens } from "../../theme/tokens.stylex"; +export const context = stylex.create({ + target: { + alignItems: "center", + backgroundColor: tokens["--dowel-bg-surface-2"], + borderColor: tokens["--dowel-border-default"], + borderRadius: tokens["--dowel-radius-md"], + borderStyle: "dashed", + borderWidth: "1px", + color: tokens["--dowel-text-secondary"], + display: "flex", + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + justifyContent: "center", + minHeight: "8rem", + outline: "none", + padding: "1rem", + userSelect: "none", + width: "100%", + ":focus-visible": { borderColor: tokens["--dowel-focus-ring"] }, + }, +}); diff --git a/packages/dowel/src/components/context-menu/context-menu.test.tsx b/packages/dowel/src/components/context-menu/context-menu.test.tsx new file mode 100644 index 0000000..7e7d825 --- /dev/null +++ b/packages/dowel/src/components/context-menu/context-menu.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ContextMenu } from "./index"; +describe("ContextMenu", () => { + it("opens at the pointer", async () => { + render( + + Right click + + + + Rename + + + + , + ); + fireEvent.contextMenu(screen.getByText("Right click")); + expect(await screen.findByText("Rename")).toBeDefined(); + }); +}); diff --git a/packages/dowel/src/components/context-menu/index.tsx b/packages/dowel/src/components/context-menu/index.tsx new file mode 100644 index 0000000..a735c66 --- /dev/null +++ b/packages/dowel/src/components/context-menu/index.tsx @@ -0,0 +1,52 @@ +import { ContextMenu as BaseContextMenu } from "@base-ui/react/context-menu"; +import * as stylex from "@stylexjs/stylex"; +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef } from "react"; + +import { Menu } from "../menu"; +import * as styles from "./context-menu.stylex"; + +type TriggerProps = Omit< + ComponentPropsWithoutRef, + "className" | "style" +>; +function sx(...v: stylex.StyleXStyles[]) { + const r = stylex.props(...v); + return { className: r.className, style: r.style }; +} +const Trigger = forwardRef( + function ContextMenuTrigger(props, ref) { + return ( + + ); + }, +); + +export const ContextMenu = { + Root: BaseContextMenu.Root, + Trigger, + Portal: Menu.Portal, + Positioner: Menu.Positioner, + Popup: Menu.Popup, + Item: Menu.Item, + LinkItem: Menu.LinkItem, + CheckboxItem: Menu.CheckboxItem, + CheckboxItemIndicator: Menu.CheckboxItemIndicator, + RadioGroup: Menu.RadioGroup, + RadioItem: Menu.RadioItem, + RadioItemIndicator: Menu.RadioItemIndicator, + Group: Menu.Group, + GroupLabel: Menu.GroupLabel, + Separator: Menu.Separator, + SubmenuRoot: Menu.SubmenuRoot, + SubmenuTrigger: Menu.SubmenuTrigger, + Icon: Menu.Icon, + Shortcut: Menu.Shortcut, +}; + +export type { TriggerProps as ContextMenuTriggerProps }; diff --git a/packages/dowel/src/components/drawer/drawer.stylex.ts b/packages/dowel/src/components/drawer/drawer.stylex.ts new file mode 100644 index 0000000..8dfc595 --- /dev/null +++ b/packages/dowel/src/components/drawer/drawer.stylex.ts @@ -0,0 +1,152 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)"; +const MOBILE = "@media (max-width: 639px)"; + +export const backdrop = stylex.create({ + root: { + backgroundColor: tokens["--dowel-scrim"], + inset: 0, + opacity: "calc(1 - var(--drawer-swipe-progress, 0))", + position: "fixed", + transitionDuration: { + default: tokens["--dowel-duration-normal"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "opacity", + zIndex: 50, + "[data-starting-style]": { opacity: 0 }, + "[data-ending-style]": { opacity: 0 }, + "[data-swiping]": { transitionDuration: "0ms" }, + }, +}); + +export const viewport = stylex.create({ + root: { inset: 0, pointerEvents: "none", position: "fixed", zIndex: 51 }, +}); + +export const popup = stylex.create({ + root: { + backgroundColor: tokens["--dowel-bg-elevated"], + boxShadow: tokens["--dowel-shadow-modal"], + boxSizing: "border-box", + color: tokens["--dowel-text-primary"], + display: "flex", + flexDirection: "column", + outline: "none", + overflow: "hidden", + pointerEvents: "auto", + position: "absolute", + transitionDuration: { + default: tokens["--dowel-duration-normal"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "transform", + transitionTimingFunction: tokens["--dowel-ease-out"], + "[data-swiping]": { transitionDuration: "0ms" }, + }, + right: { + borderInlineStartColor: tokens["--dowel-border-default"], + borderInlineStartStyle: "solid", + borderInlineStartWidth: tokens["--dowel-hairline"], + height: "100dvh", + insetBlock: 0, + insetInlineEnd: 0, + maxWidth: "calc(100vw - 1rem)", + transform: "translateX(var(--drawer-swipe-movement-x, 0px))", + width: "min(24rem, 90vw)", + "[data-starting-style]": { transform: "translateX(100%)" }, + "[data-ending-style]": { transform: "translateX(100%)" }, + }, + left: { + borderInlineEndColor: tokens["--dowel-border-default"], + borderInlineEndStyle: "solid", + borderInlineEndWidth: tokens["--dowel-hairline"], + height: "100dvh", + insetBlock: 0, + insetInlineStart: 0, + maxWidth: "calc(100vw - 1rem)", + transform: "translateX(var(--drawer-swipe-movement-x, 0px))", + width: "min(24rem, 90vw)", + "[data-starting-style]": { transform: "translateX(-100%)" }, + "[data-ending-style]": { transform: "translateX(-100%)" }, + }, + bottom: { + borderStartEndRadius: tokens["--dowel-radius-lg"], + borderStartStartRadius: tokens["--dowel-radius-lg"], + insetBlockEnd: 0, + insetInline: 0, + marginInline: "auto", + maxHeight: "calc(100dvh - 1rem)", + transform: + "translateY(calc(var(--drawer-snap-point-offset, 0px) + var(--drawer-swipe-movement-y, 0px)))", + width: { default: "min(36rem, calc(100vw - 2rem))", [MOBILE]: "100vw" }, + "[data-starting-style]": { transform: "translateY(100%)" }, + "[data-ending-style]": { transform: "translateY(100%)" }, + }, + top: { + borderEndEndRadius: tokens["--dowel-radius-lg"], + borderEndStartRadius: tokens["--dowel-radius-lg"], + insetBlockStart: 0, + insetInline: 0, + marginInline: "auto", + maxHeight: "calc(100dvh - 1rem)", + transform: "translateY(var(--drawer-swipe-movement-y, 0px))", + width: { default: "min(36rem, calc(100vw - 2rem))", [MOBILE]: "100vw" }, + "[data-starting-style]": { transform: "translateY(-100%)" }, + "[data-ending-style]": { transform: "translateY(-100%)" }, + }, +}); + +export const part = stylex.create({ + content: { display: "flex", flex: 1, flexDirection: "column", minHeight: 0 }, + handle: { + alignSelf: "center", + backgroundColor: tokens["--dowel-border-strong"], + borderRadius: tokens["--dowel-radius-pill"], + height: "0.25rem", + marginBlockStart: "0.5rem", + width: "2.25rem", + }, + header: { + display: "flex", + flexDirection: "column", + gap: "0.375rem", + padding: "1rem", + }, + body: { + color: tokens["--dowel-text-secondary"], + flex: 1, + minHeight: 0, + overflowY: "auto", + padding: "0 1rem 1rem", + }, + footer: { + alignItems: "center", + borderTopColor: tokens["--dowel-border-subtle"], + borderTopStyle: "solid", + borderTopWidth: tokens["--dowel-hairline"], + display: "flex", + flexWrap: "wrap", + gap: "0.5rem", + justifyContent: "flex-end", + padding: "0.75rem 1rem 1rem", + }, + title: { + color: tokens["--dowel-text-primary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.9375rem", + fontWeight: 550, + lineHeight: 1.4, + margin: 0, + }, + description: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + lineHeight: 1.5, + margin: 0, + }, +}); diff --git a/packages/dowel/src/components/drawer/drawer.test.tsx b/packages/dowel/src/components/drawer/drawer.test.tsx new file mode 100644 index 0000000..c4aa9e1 --- /dev/null +++ b/packages/dowel/src/components/drawer/drawer.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { Button } from "../button"; +import { Drawer } from "./index"; + +function Example() { + return ( + + Browse} /> + + + + + + + Repositories + Choose a repository. + + Repository list + + Close} /> + + + + + + + ); +} + +describe("Drawer", () => { + it("opens, exposes its side, and closes", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Browse" })); + const dialog = screen.getByRole("dialog", { name: "Repositories" }); + expect(dialog.dataset.side).toBe("left"); + await userEvent.click(screen.getByRole("button", { name: "Close" })); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("has no accessibility violations when open", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Browse" })); + await expectNoA11yViolations(screen.getByRole("dialog")); + }); +}); diff --git a/packages/dowel/src/components/drawer/index.tsx b/packages/dowel/src/components/drawer/index.tsx new file mode 100644 index 0000000..6838775 --- /dev/null +++ b/packages/dowel/src/components/drawer/index.tsx @@ -0,0 +1,179 @@ +import { Drawer as BaseDrawer } from "@base-ui/react/drawer"; +import * as stylex from "@stylexjs/stylex"; +import { createContext, forwardRef, useContext } from "react"; +import type { ComponentPropsWithoutRef } from "react"; +import type { DrawerRootProps as BaseDrawerRootProps } from "@base-ui/react/drawer"; + +import { DowelThemeContext, themeStyles } from "../../theme/theme-provider"; +import * as styles from "./drawer.stylex"; + +type Props unknown> = T extends ( + props: infer P, +) => unknown + ? Omit + : never; +type DivProps = Omit, "className" | "style">; +export type DrawerSide = "top" | "right" | "bottom" | "left"; +export interface DrawerRootProps + extends Omit { + side?: DrawerSide; +} +export interface DrawerPopupProps extends Props { + side?: DrawerSide; +} + +function partProps(...values: stylex.StyleXStyles[]) { + const resolved = stylex.props(...values); + return { className: resolved.className, style: resolved.style }; +} + +const swipeDirection = { + top: "up", + right: "right", + bottom: "down", + left: "left", +} as const; + +const DrawerSideContext = createContext("right"); + +export const Drawer = { + Provider: BaseDrawer.Provider, + Root: function DrawerRoot({ side = "right", ...props }: DrawerRootProps) { + return ( + + + + ); + }, + Trigger: function DrawerTrigger(props: Props) { + return ( + + ); + }, + Portal: function DrawerPortal(props: Props) { + const theme = useContext(DowelThemeContext); + const resolved = stylex.props(themeStyles[theme]); + return ( + + ); + }, + Backdrop: function DrawerBackdrop(props: Props) { + return ( + + ); + }, + Viewport: function DrawerViewport(props: Props) { + return ( + + ); + }, + Popup: function DrawerPopup({ + side: sideOverride, + ...props + }: DrawerPopupProps) { + const inheritedSide = useContext(DrawerSideContext); + const side = sideOverride ?? inheritedSide; + return ( + + ); + }, + Content: function DrawerContent(props: Props) { + return ( + + ); + }, + Handle: forwardRef( + function DrawerHandle(props, ref) { + return ( +