From cef6b98782436c7c852f89d86d2be7acfc8f57cd Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 26 Aug 2026 01:13:10 +0530 Subject: [PATCH 1/5] feat: expand component library and docs --- .changeset/bright-tools-gather.md | 5 + README.md | 2 +- .../docs/src/components/component-gallery.tsx | 89 +++++++ apps/docs/src/components/icons.tsx | 2 + apps/docs/src/components/sidebar-nav.tsx | 94 ++++++-- apps/docs/src/docs.css | 85 ++++++- apps/docs/src/lib/nav.ts | 107 ++++++++- apps/docs/src/routeTree.gen.ts | 126 ++++++++++ apps/docs/src/routes/components/combobox.tsx | 59 +++++ .../src/routes/components/command-menu.tsx | 67 ++++++ apps/docs/src/routes/components/drawer.tsx | 64 +++++ apps/docs/src/routes/components/list.tsx | 63 +++++ apps/docs/src/routes/components/progress.tsx | 29 +++ apps/docs/src/routes/components/toast.tsx | 74 ++++++ apps/docs/test/browser/contrast.spec.ts | 36 +++ packages/dowel/README.md | 2 +- .../components/combobox/combobox.stylex.ts | 164 +++++++++++++ .../src/components/combobox/combobox.test.tsx | 65 ++++++ .../dowel/src/components/combobox/index.tsx | 209 +++++++++++++++++ .../command-menu/command-menu.stylex.ts | 167 +++++++++++++ .../command-menu/command-menu.test.tsx | 55 +++++ .../src/components/command-menu/index.tsx | 192 +++++++++++++++ .../src/components/drawer/drawer.stylex.ts | 152 ++++++++++++ .../src/components/drawer/drawer.test.tsx | 49 ++++ .../dowel/src/components/drawer/index.tsx | 179 ++++++++++++++ packages/dowel/src/components/list/index.tsx | 127 ++++++++++ .../dowel/src/components/list/list.stylex.ts | 83 +++++++ .../dowel/src/components/list/list.test.tsx | 36 +++ .../dowel/src/components/progress/index.tsx | 60 +++++ .../components/progress/progress.stylex.ts | 61 +++++ .../src/components/progress/progress.test.tsx | 30 +++ packages/dowel/src/components/toast/index.tsx | 178 ++++++++++++++ .../src/components/toast/toast.stylex.ts | 220 ++++++++++++++++++ .../dowel/src/components/toast/toast.test.tsx | 115 +++++++++ packages/dowel/src/index.ts | 29 +++ 35 files changed, 3047 insertions(+), 28 deletions(-) create mode 100644 .changeset/bright-tools-gather.md create mode 100644 apps/docs/src/routes/components/combobox.tsx create mode 100644 apps/docs/src/routes/components/command-menu.tsx create mode 100644 apps/docs/src/routes/components/drawer.tsx create mode 100644 apps/docs/src/routes/components/list.tsx create mode 100644 apps/docs/src/routes/components/progress.tsx create mode 100644 apps/docs/src/routes/components/toast.tsx create mode 100644 packages/dowel/src/components/combobox/combobox.stylex.ts create mode 100644 packages/dowel/src/components/combobox/combobox.test.tsx create mode 100644 packages/dowel/src/components/combobox/index.tsx create mode 100644 packages/dowel/src/components/command-menu/command-menu.stylex.ts create mode 100644 packages/dowel/src/components/command-menu/command-menu.test.tsx create mode 100644 packages/dowel/src/components/command-menu/index.tsx create mode 100644 packages/dowel/src/components/drawer/drawer.stylex.ts create mode 100644 packages/dowel/src/components/drawer/drawer.test.tsx create mode 100644 packages/dowel/src/components/drawer/index.tsx create mode 100644 packages/dowel/src/components/list/index.tsx create mode 100644 packages/dowel/src/components/list/list.stylex.ts create mode 100644 packages/dowel/src/components/list/list.test.tsx create mode 100644 packages/dowel/src/components/progress/index.tsx create mode 100644 packages/dowel/src/components/progress/progress.stylex.ts create mode 100644 packages/dowel/src/components/progress/progress.test.tsx create mode 100644 packages/dowel/src/components/toast/index.tsx create mode 100644 packages/dowel/src/components/toast/toast.stylex.ts create mode 100644 packages/dowel/src/components/toast/toast.test.tsx diff --git a/.changeset/bright-tools-gather.md b/.changeset/bright-tools-gather.md new file mode 100644 index 0000000..ceda141 --- /dev/null +++ b/.changeset/bright-tools-gather.md @@ -0,0 +1,5 @@ +--- +"@karnstack/dowel": minor +--- + +Add Combobox, CommandMenu, Drawer, List collection primitives, Progress, and Toast with a global manager. 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/component-gallery.tsx b/apps/docs/src/components/component-gallery.tsx index 89eb7f9..65e7c50 100644 --- a/apps/docs/src/components/component-gallery.tsx +++ b/apps/docs/src/components/component-gallery.tsx @@ -8,17 +8,24 @@ import { Callout, Checkbox, CheckboxGroup, + Combobox, + CommandMenu, Composer, DataTable, Dialog, + Drawer, EmptyState, Field, IconButton, Input, Kbd, + List, + ListCell, + ListRow, Menu, NativeSelect, Popover, + Progress, PropertyPicker, PropertyPill, RadioGroup, @@ -32,8 +39,10 @@ import { Switch, Tabs, Textarea, + ToastProvider, Tooltip, createDataTableColumnHelper, + toast, } from "@karnstack/dowel"; import type { ReactNode } from "react"; @@ -207,6 +216,17 @@ export function ComponentGallery() { /> + + + + Description @@ -306,6 +326,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 +437,18 @@ export function ComponentGallery() { + + + + + + + + + + @@ -446,6 +522,19 @@ export function ComponentGallery() { /> + + + + 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..1ebbff1 100644 --- a/apps/docs/src/docs.css +++ b/apps/docs/src/docs.css @@ -290,26 +290,99 @@ body { /* ------------------------------------------------------------------- nav */ .docs-nav-section { - margin-block-end: var(--dowel-space-7); + margin-block-end: var(--dowel-space-1); + transition: margin-block-end var(--dowel-dur-fast) var(--dowel-ease); +} + +.docs-nav-section[data-open] { + margin-block-end: var(--dowel-space-4); } .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); + display: flex; + align-items: center; + gap: var(--dowel-space-2); + inline-size: 100%; + min-block-size: 1.75rem; + margin: 0; + padding: 0 var(--dowel-space-3); + border: 0; + border-radius: 6px; + background: transparent; 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-nav-heading:hover { + color: var(--dowel-text-1); + background: var(--dowel-bg-2); +} + +.docs-nav-heading:focus-visible { + outline: 2px solid var(--dowel-focus); + outline-offset: -2px; +} + +.docs-nav-section[data-active] > .docs-nav-heading { + color: var(--dowel-text-1); +} + +.docs-nav-chevron { + display: inline-flex; + flex: none; + margin-inline-start: auto; + color: var(--dowel-text-3); + transition: transform var(--dowel-dur-fast) var(--dowel-ease); +} + +.docs-nav-chevron svg { + block-size: 0.75rem; + inline-size: 0.75rem; +} + +.docs-nav-section[data-open] .docs-nav-chevron { + transform: rotate(90deg); +} + +.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-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; diff --git a/apps/docs/src/lib/nav.ts b/apps/docs/src/lib/nav.ts index e45a0a2..d1a32cd 100644 --- a/apps/docs/src/lib/nav.ts +++ b/apps/docs/src/lib/nav.ts @@ -50,6 +50,16 @@ export const componentNav: NavItem[] = [ to: "/components/checkbox", summary: "Checked, unchecked, and mixed selection with native form values.", }, + { + 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", @@ -65,6 +75,11 @@ 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", @@ -85,6 +100,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", @@ -106,6 +126,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", @@ -161,6 +186,86 @@ export const componentNav: NavItem[] = [ to: "/components/tooltip", summary: "A hover and focus label on the popover elevation tier.", }, + { + title: "Toast", + to: "/components/toast", + summary: "A global notification queue with actions and promises.", + }, +]; + +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"), + }, + { + title: "Forms and selection", + items: components( + "/components/checkbox", + "/components/combobox", + "/components/input", + "/components/native-select", + "/components/property-picker", + "/components/radio-group", + "/components/search-field", + "/components/select", + "/components/switch", + ), + }, + { + title: "Navigation", + items: components( + "/components/command-menu", + "/components/sidebar", + "/components/tabs", + ), + }, + { + title: "Overlays", + items: components( + "/components/alert-dialog", + "/components/dialog", + "/components/drawer", + "/components/menu", + "/components/popover", + "/components/tooltip", + ), + }, + { + title: "Data and display", + items: components( + "/components/avatar", + "/components/badge", + "/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 +284,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..038ae0a 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -19,16 +19,21 @@ import { Route as ComponentsBadgeRouteImport } from './routes/components/badge' import { Route as ComponentsButtonRouteImport } from './routes/components/button' import { Route as ComponentsCalloutRouteImport } from './routes/components/callout' import { Route as ComponentsCheckboxRouteImport } from './routes/components/checkbox' +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 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 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 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' @@ -40,6 +45,7 @@ import { Route as ComponentsSpinnerRouteImport } from './routes/components/spinn 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 ComponentsTooltipRouteImport } from './routes/components/tooltip' const IndexRoute = IndexRouteImport.update({ @@ -92,6 +98,16 @@ const ComponentsCheckboxRoute = ComponentsCheckboxRouteImport.update({ path: '/checkbox', 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', @@ -107,6 +123,11 @@ 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', @@ -127,6 +148,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', @@ -142,6 +168,11 @@ const ComponentsPopoverRoute = ComponentsPopoverRouteImport.update({ 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', @@ -198,6 +229,11 @@ const ComponentsTabsRoute = ComponentsTabsRouteImport.update({ path: '/tabs', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsToastRoute = ComponentsToastRouteImport.update({ + id: '/toast', + path: '/toast', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsTooltipRoute = ComponentsTooltipRouteImport.update({ id: '/tooltip', path: '/tooltip', @@ -214,16 +250,21 @@ export interface FileRoutesByFullPath { '/components/button': typeof ComponentsButtonRoute '/components/callout': typeof ComponentsCalloutRoute '/components/checkbox': typeof ComponentsCheckboxRoute + '/components/combobox': typeof ComponentsComboboxRoute + '/components/command-menu': typeof ComponentsCommandMenuRoute '/components/composer': typeof ComponentsComposerRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/drawer': typeof ComponentsDrawerRoute '/components/empty-state': typeof ComponentsEmptyStateRoute '/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/popover': typeof ComponentsPopoverRoute + '/components/progress': typeof ComponentsProgressRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute @@ -235,6 +276,7 @@ export interface FileRoutesByFullPath { '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute + '/components/toast': typeof ComponentsToastRoute '/components/tooltip': typeof ComponentsTooltipRoute '/components/': typeof ComponentsIndexRoute } @@ -247,16 +289,21 @@ export interface FileRoutesByTo { '/components/button': typeof ComponentsButtonRoute '/components/callout': typeof ComponentsCalloutRoute '/components/checkbox': typeof ComponentsCheckboxRoute + '/components/combobox': typeof ComponentsComboboxRoute + '/components/command-menu': typeof ComponentsCommandMenuRoute '/components/composer': typeof ComponentsComposerRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/drawer': typeof ComponentsDrawerRoute '/components/empty-state': typeof ComponentsEmptyStateRoute '/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/popover': typeof ComponentsPopoverRoute + '/components/progress': typeof ComponentsProgressRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute @@ -268,6 +315,7 @@ export interface FileRoutesByTo { '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute + '/components/toast': typeof ComponentsToastRoute '/components/tooltip': typeof ComponentsTooltipRoute '/components': typeof ComponentsIndexRoute } @@ -282,16 +330,21 @@ export interface FileRoutesById { '/components/button': typeof ComponentsButtonRoute '/components/callout': typeof ComponentsCalloutRoute '/components/checkbox': typeof ComponentsCheckboxRoute + '/components/combobox': typeof ComponentsComboboxRoute + '/components/command-menu': typeof ComponentsCommandMenuRoute '/components/composer': typeof ComponentsComposerRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/drawer': typeof ComponentsDrawerRoute '/components/empty-state': typeof ComponentsEmptyStateRoute '/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/popover': typeof ComponentsPopoverRoute + '/components/progress': typeof ComponentsProgressRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute @@ -303,6 +356,7 @@ export interface FileRoutesById { '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute + '/components/toast': typeof ComponentsToastRoute '/components/tooltip': typeof ComponentsTooltipRoute '/components/': typeof ComponentsIndexRoute } @@ -318,16 +372,21 @@ export interface FileRouteTypes { | '/components/button' | '/components/callout' | '/components/checkbox' + | '/components/combobox' + | '/components/command-menu' | '/components/composer' | '/components/data-table' | '/components/dialog' + | '/components/drawer' | '/components/empty-state' | '/components/icon-button' | '/components/input' | '/components/kbd' + | '/components/list' | '/components/menu' | '/components/native-select' | '/components/popover' + | '/components/progress' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' @@ -339,6 +398,7 @@ export interface FileRouteTypes { | '/components/status' | '/components/switch' | '/components/tabs' + | '/components/toast' | '/components/tooltip' | '/components/' fileRoutesByTo: FileRoutesByTo @@ -351,16 +411,21 @@ export interface FileRouteTypes { | '/components/button' | '/components/callout' | '/components/checkbox' + | '/components/combobox' + | '/components/command-menu' | '/components/composer' | '/components/data-table' | '/components/dialog' + | '/components/drawer' | '/components/empty-state' | '/components/icon-button' | '/components/input' | '/components/kbd' + | '/components/list' | '/components/menu' | '/components/native-select' | '/components/popover' + | '/components/progress' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' @@ -372,6 +437,7 @@ export interface FileRouteTypes { | '/components/status' | '/components/switch' | '/components/tabs' + | '/components/toast' | '/components/tooltip' | '/components' id: @@ -385,16 +451,21 @@ export interface FileRouteTypes { | '/components/button' | '/components/callout' | '/components/checkbox' + | '/components/combobox' + | '/components/command-menu' | '/components/composer' | '/components/data-table' | '/components/dialog' + | '/components/drawer' | '/components/empty-state' | '/components/icon-button' | '/components/input' | '/components/kbd' + | '/components/list' | '/components/menu' | '/components/native-select' | '/components/popover' + | '/components/progress' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' @@ -406,6 +477,7 @@ export interface FileRouteTypes { | '/components/status' | '/components/switch' | '/components/tabs' + | '/components/toast' | '/components/tooltip' | '/components/' fileRoutesById: FileRoutesById @@ -488,6 +560,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsCheckboxRouteImport 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' @@ -509,6 +595,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' @@ -537,6 +630,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' @@ -558,6 +658,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' @@ -635,6 +742,13 @@ 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/tooltip': { id: '/components/tooltip' path: '/tooltip' @@ -652,16 +766,21 @@ interface ComponentsRouteRouteChildren { ComponentsButtonRoute: typeof ComponentsButtonRoute ComponentsCalloutRoute: typeof ComponentsCalloutRoute ComponentsCheckboxRoute: typeof ComponentsCheckboxRoute + ComponentsComboboxRoute: typeof ComponentsComboboxRoute + ComponentsCommandMenuRoute: typeof ComponentsCommandMenuRoute ComponentsComposerRoute: typeof ComponentsComposerRoute ComponentsDataTableRoute: typeof ComponentsDataTableRoute ComponentsDialogRoute: typeof ComponentsDialogRoute + ComponentsDrawerRoute: typeof ComponentsDrawerRoute ComponentsEmptyStateRoute: typeof ComponentsEmptyStateRoute ComponentsIconButtonRoute: typeof ComponentsIconButtonRoute ComponentsInputRoute: typeof ComponentsInputRoute ComponentsKbdRoute: typeof ComponentsKbdRoute + ComponentsListRoute: typeof ComponentsListRoute ComponentsMenuRoute: typeof ComponentsMenuRoute ComponentsNativeSelectRoute: typeof ComponentsNativeSelectRoute ComponentsPopoverRoute: typeof ComponentsPopoverRoute + ComponentsProgressRoute: typeof ComponentsProgressRoute ComponentsPropertyPickerRoute: typeof ComponentsPropertyPickerRoute ComponentsRadioGroupRoute: typeof ComponentsRadioGroupRoute ComponentsSearchFieldRoute: typeof ComponentsSearchFieldRoute @@ -673,6 +792,7 @@ interface ComponentsRouteRouteChildren { ComponentsStatusRoute: typeof ComponentsStatusRoute ComponentsSwitchRoute: typeof ComponentsSwitchRoute ComponentsTabsRoute: typeof ComponentsTabsRoute + ComponentsToastRoute: typeof ComponentsToastRoute ComponentsTooltipRoute: typeof ComponentsTooltipRoute ComponentsIndexRoute: typeof ComponentsIndexRoute } @@ -684,16 +804,21 @@ const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { ComponentsButtonRoute: ComponentsButtonRoute, ComponentsCalloutRoute: ComponentsCalloutRoute, ComponentsCheckboxRoute: ComponentsCheckboxRoute, + ComponentsComboboxRoute: ComponentsComboboxRoute, + ComponentsCommandMenuRoute: ComponentsCommandMenuRoute, ComponentsComposerRoute: ComponentsComposerRoute, ComponentsDataTableRoute: ComponentsDataTableRoute, ComponentsDialogRoute: ComponentsDialogRoute, + ComponentsDrawerRoute: ComponentsDrawerRoute, ComponentsEmptyStateRoute: ComponentsEmptyStateRoute, ComponentsIconButtonRoute: ComponentsIconButtonRoute, ComponentsInputRoute: ComponentsInputRoute, ComponentsKbdRoute: ComponentsKbdRoute, + ComponentsListRoute: ComponentsListRoute, ComponentsMenuRoute: ComponentsMenuRoute, ComponentsNativeSelectRoute: ComponentsNativeSelectRoute, ComponentsPopoverRoute: ComponentsPopoverRoute, + ComponentsProgressRoute: ComponentsProgressRoute, ComponentsPropertyPickerRoute: ComponentsPropertyPickerRoute, ComponentsRadioGroupRoute: ComponentsRadioGroupRoute, ComponentsSearchFieldRoute: ComponentsSearchFieldRoute, @@ -705,6 +830,7 @@ const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { ComponentsStatusRoute: ComponentsStatusRoute, ComponentsSwitchRoute: ComponentsSwitchRoute, ComponentsTabsRoute: ComponentsTabsRoute, + ComponentsToastRoute: ComponentsToastRoute, ComponentsTooltipRoute: ComponentsTooltipRoute, ComponentsIndexRoute: ComponentsIndexRoute, } 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/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/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/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/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/test/browser/contrast.spec.ts b/apps/docs/test/browser/contrast.spec.ts index a711dde..099ae9a 100644 --- a/apps/docs/test/browser/contrast.spec.ts +++ b/apps/docs/test/browser/contrast.spec.ts @@ -20,6 +20,42 @@ test("dependencies renders inside the documentation shell", async ({ await expect(page.locator(".docs-workspace-footer")).toBeVisible(); }); +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/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..65b724c --- /dev/null +++ b/packages/dowel/src/components/command-menu/command-menu.test.tsx @@ -0,0 +1,55 @@ +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("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/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 ( +