From 8ac86f80228c3a83ad18554d172d0181553fee74 Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 26 Aug 2026 00:07:10 +0530 Subject: [PATCH 1/6] feat: add core display primitives --- .changeset/bright-tools-arrive.md | 5 + README.md | 2 +- .../docs/src/components/component-gallery.tsx | 80 +++++++++- apps/docs/src/lib/nav.ts | 35 +++++ apps/docs/src/routeTree.gen.ts | 147 ++++++++++++++++++ apps/docs/src/routes/components/avatar.tsx | 58 +++++++ .../src/routes/components/empty-state.tsx | 71 +++++++++ apps/docs/src/routes/components/popover.tsx | 73 +++++++++ apps/docs/src/routes/components/separator.tsx | 49 ++++++ apps/docs/src/routes/components/skeleton.tsx | 51 ++++++ apps/docs/src/routes/components/spinner.tsx | 47 ++++++ apps/docs/src/routes/components/status.tsx | 54 +++++++ packages/dowel/README.md | 2 +- .../src/components/avatar/avatar.stylex.ts | 75 +++++++++ .../src/components/avatar/avatar.test.tsx | 52 +++++++ .../dowel/src/components/avatar/index.tsx | 89 +++++++++++ .../empty-state/empty-state.stylex.ts | 81 ++++++++++ .../empty-state/empty-state.test.tsx | 54 +++++++ .../src/components/empty-state/index.tsx | 64 ++++++++ .../dowel/src/components/popover/index.tsx | 118 ++++++++++++++ .../src/components/popover/popover.stylex.ts | 82 ++++++++++ .../src/components/popover/popover.test.tsx | 93 +++++++++++ .../dowel/src/components/separator/index.tsx | 43 +++++ .../components/separator/separator.stylex.ts | 24 +++ .../components/separator/separator.test.tsx | 42 +++++ .../dowel/src/components/skeleton/index.tsx | 39 +++++ .../components/skeleton/skeleton.stylex.ts | 52 +++++++ .../src/components/skeleton/skeleton.test.tsx | 47 ++++++ .../dowel/src/components/spinner/index.tsx | 37 +++++ .../src/components/spinner/spinner.stylex.ts | 56 +++++++ .../src/components/spinner/spinner.test.tsx | 34 ++++ .../dowel/src/components/status/index.tsx | 43 +++++ .../src/components/status/status.stylex.ts | 49 ++++++ .../src/components/status/status.test.tsx | 47 ++++++ packages/dowel/src/index.ts | 22 +++ 35 files changed, 1914 insertions(+), 3 deletions(-) create mode 100644 .changeset/bright-tools-arrive.md create mode 100644 apps/docs/src/routes/components/avatar.tsx create mode 100644 apps/docs/src/routes/components/empty-state.tsx create mode 100644 apps/docs/src/routes/components/popover.tsx create mode 100644 apps/docs/src/routes/components/separator.tsx create mode 100644 apps/docs/src/routes/components/skeleton.tsx create mode 100644 apps/docs/src/routes/components/spinner.tsx create mode 100644 apps/docs/src/routes/components/status.tsx create mode 100644 packages/dowel/src/components/avatar/avatar.stylex.ts create mode 100644 packages/dowel/src/components/avatar/avatar.test.tsx create mode 100644 packages/dowel/src/components/avatar/index.tsx create mode 100644 packages/dowel/src/components/empty-state/empty-state.stylex.ts create mode 100644 packages/dowel/src/components/empty-state/empty-state.test.tsx create mode 100644 packages/dowel/src/components/empty-state/index.tsx create mode 100644 packages/dowel/src/components/popover/index.tsx create mode 100644 packages/dowel/src/components/popover/popover.stylex.ts create mode 100644 packages/dowel/src/components/popover/popover.test.tsx create mode 100644 packages/dowel/src/components/separator/index.tsx create mode 100644 packages/dowel/src/components/separator/separator.stylex.ts create mode 100644 packages/dowel/src/components/separator/separator.test.tsx create mode 100644 packages/dowel/src/components/skeleton/index.tsx create mode 100644 packages/dowel/src/components/skeleton/skeleton.stylex.ts create mode 100644 packages/dowel/src/components/skeleton/skeleton.test.tsx create mode 100644 packages/dowel/src/components/spinner/index.tsx create mode 100644 packages/dowel/src/components/spinner/spinner.stylex.ts create mode 100644 packages/dowel/src/components/spinner/spinner.test.tsx create mode 100644 packages/dowel/src/components/status/index.tsx create mode 100644 packages/dowel/src/components/status/status.stylex.ts create mode 100644 packages/dowel/src/components/status/status.test.tsx diff --git a/.changeset/bright-tools-arrive.md b/.changeset/bright-tools-arrive.md new file mode 100644 index 0000000..d3da002 --- /dev/null +++ b/.changeset/bright-tools-arrive.md @@ -0,0 +1,5 @@ +--- +"@karnstack/dowel": minor +--- + +Add Popover, Separator, Avatar, Status, Spinner, Skeleton, and EmptyState with responsive documentation, light and dark themes, reduced-motion handling, and accessibility coverage. diff --git a/README.md b/README.md index 72fd535..36803c6 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 · Badge · Button · Callout · Checkbox · Composer · Data Table · Dialog · Icon Button · Input · Kbd · Menu · Native Select · Property Picker · Radio Group · Search Field · Select · Sidebar · Switch · Tabs · Tooltip +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 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 8d34837..cca37a7 100644 --- a/apps/docs/src/components/component-gallery.tsx +++ b/apps/docs/src/components/component-gallery.tsx @@ -2,6 +2,7 @@ import { Link } from "@tanstack/react-router"; import type { LinkProps } from "@tanstack/react-router"; import { AlertDialog, + Avatar, Badge, Button, Callout, @@ -10,18 +11,24 @@ import { Composer, DataTable, Dialog, + EmptyState, Field, IconButton, Input, Kbd, Menu, NativeSelect, + Popover, PropertyPicker, PropertyPill, RadioGroup, SearchField, + Separator, Select, Sidebar, + Skeleton, + Spinner, + Status, Switch, Tabs, Textarea, @@ -30,7 +37,13 @@ import { } from "@karnstack/dowel"; import type { ReactNode } from "react"; -import { ArrowRightIcon, CheckIcon, CopyIcon, MenuIcon } from "./icons"; +import { + ArrowRightIcon, + CheckIcon, + CopyIcon, + MenuIcon, + SearchIcon, +} from "./icons"; type GalleryItemProps = { title: string; @@ -116,6 +129,12 @@ export function ComponentGallery() { + + + + + + @@ -138,6 +157,12 @@ export function ComponentGallery() { Draft + + Passed + Degraded + Failed + + Project name @@ -278,6 +303,23 @@ export function ComponentGallery() { + + + Repository access} /> + + + + + Repository access + + Visible to organization members. + + + + + + + @@ -312,6 +354,42 @@ export function ComponentGallery() { + + Checks passed + + Updated two minutes ago + + + + + + + + + + + + + + + + } + title="No matching repositories" + description="Try a different name or clear the active filters." + actions={} + /> + + diff --git a/apps/docs/src/lib/nav.ts b/apps/docs/src/lib/nav.ts index 7ca5b45..d8e93c8 100644 --- a/apps/docs/src/lib/nav.ts +++ b/apps/docs/src/lib/nav.ts @@ -25,6 +25,11 @@ export const componentNav: NavItem[] = [ to: "/components/alert-dialog", summary: "A focused confirmation modal for consequential actions.", }, + { + title: "Avatar", + to: "/components/avatar", + summary: "An image or initials fallback with shape, size, and presence.", + }, { title: "Badge", to: "/components/badge", @@ -60,6 +65,11 @@ export const componentNav: NavItem[] = [ to: "/components/dialog", summary: "A modal on the modal elevation tier, labelled by its title.", }, + { + title: "Empty State", + to: "/components/empty-state", + summary: "Structured guidance and actions for a surface without content.", + }, { title: "Icon Button", to: "/components/icon-button", @@ -85,6 +95,11 @@ export const componentNav: NavItem[] = [ to: "/components/native-select", summary: "A resilient native choice control with Dowel field styling.", }, + { + title: "Popover", + to: "/components/popover", + summary: "An anchored surface for interactive controls and rich details.", + }, { title: "Property Picker", to: "/components/property-picker", @@ -101,6 +116,11 @@ export const componentNav: NavItem[] = [ to: "/components/search-field", summary: "A search input with icon, clear action, and shortcut hint.", }, + { + title: "Separator", + to: "/components/separator", + summary: "A quiet horizontal or vertical content divider.", + }, { title: "Select", to: "/components/select", @@ -111,6 +131,21 @@ export const componentNav: NavItem[] = [ to: "/components/sidebar", summary: "A responsive application rail with pointer and keyboard resize.", }, + { + title: "Skeleton", + to: "/components/skeleton", + summary: "Reduced-motion-aware placeholders for pending content.", + }, + { + title: "Spinner", + to: "/components/spinner", + summary: "Accessible indeterminate progress in three compact sizes.", + }, + { + title: "Status", + to: "/components/status", + summary: "A lightweight state icon and label in semantic tones.", + }, { title: "Switch", to: "/components/switch", diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts index f89d1a7..a480ec1 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index' import { Route as ComponentsRouteRouteImport } from './routes/components/route' import { Route as ComponentsIndexRouteImport } from './routes/components/index' 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 ComponentsButtonRouteImport } from './routes/components/button' import { Route as ComponentsCalloutRouteImport } from './routes/components/callout' @@ -20,16 +21,22 @@ import { Route as ComponentsCheckboxRouteImport } from './routes/components/chec 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 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 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 ComponentsPropertyPickerRouteImport } from './routes/components/property-picker' import { Route as ComponentsRadioGroupRouteImport } from './routes/components/radio-group' import { Route as ComponentsSearchFieldRouteImport } from './routes/components/search-field' 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 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 ComponentsTooltipRouteImport } from './routes/components/tooltip' @@ -54,6 +61,11 @@ const ComponentsAlertDialogRoute = ComponentsAlertDialogRouteImport.update({ path: '/alert-dialog', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsAvatarRoute = ComponentsAvatarRouteImport.update({ + id: '/avatar', + path: '/avatar', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsBadgeRoute = ComponentsBadgeRouteImport.update({ id: '/badge', path: '/badge', @@ -89,6 +101,11 @@ const ComponentsDialogRoute = ComponentsDialogRouteImport.update({ path: '/dialog', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsEmptyStateRoute = ComponentsEmptyStateRouteImport.update({ + id: '/empty-state', + path: '/empty-state', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsIconButtonRoute = ComponentsIconButtonRouteImport.update({ id: '/icon-button', path: '/icon-button', @@ -114,6 +131,11 @@ const ComponentsNativeSelectRoute = ComponentsNativeSelectRouteImport.update({ path: '/native-select', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsPopoverRoute = ComponentsPopoverRouteImport.update({ + id: '/popover', + path: '/popover', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsPropertyPickerRoute = ComponentsPropertyPickerRouteImport.update({ id: '/property-picker', @@ -135,11 +157,31 @@ const ComponentsSelectRoute = ComponentsSelectRouteImport.update({ path: '/select', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsSeparatorRoute = ComponentsSeparatorRouteImport.update({ + id: '/separator', + path: '/separator', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsSidebarRoute = ComponentsSidebarRouteImport.update({ id: '/sidebar', path: '/sidebar', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsSkeletonRoute = ComponentsSkeletonRouteImport.update({ + id: '/skeleton', + path: '/skeleton', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsSpinnerRoute = ComponentsSpinnerRouteImport.update({ + id: '/spinner', + path: '/spinner', + getParentRoute: () => ComponentsRouteRoute, +} as any) +const ComponentsStatusRoute = ComponentsStatusRouteImport.update({ + id: '/status', + path: '/status', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsSwitchRoute = ComponentsSwitchRouteImport.update({ id: '/switch', path: '/switch', @@ -160,6 +202,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/components': typeof ComponentsRouteRouteWithChildren '/components/alert-dialog': typeof ComponentsAlertDialogRoute + '/components/avatar': typeof ComponentsAvatarRoute '/components/badge': typeof ComponentsBadgeRoute '/components/button': typeof ComponentsButtonRoute '/components/callout': typeof ComponentsCalloutRoute @@ -167,16 +210,22 @@ export interface FileRoutesByFullPath { '/components/composer': typeof ComponentsComposerRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/empty-state': typeof ComponentsEmptyStateRoute '/components/icon-button': typeof ComponentsIconButtonRoute '/components/input': typeof ComponentsInputRoute '/components/kbd': typeof ComponentsKbdRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/popover': typeof ComponentsPopoverRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute '/components/select': typeof ComponentsSelectRoute + '/components/separator': typeof ComponentsSeparatorRoute '/components/sidebar': typeof ComponentsSidebarRoute + '/components/skeleton': typeof ComponentsSkeletonRoute + '/components/spinner': typeof ComponentsSpinnerRoute + '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute '/components/tooltip': typeof ComponentsTooltipRoute @@ -185,6 +234,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/components/alert-dialog': typeof ComponentsAlertDialogRoute + '/components/avatar': typeof ComponentsAvatarRoute '/components/badge': typeof ComponentsBadgeRoute '/components/button': typeof ComponentsButtonRoute '/components/callout': typeof ComponentsCalloutRoute @@ -192,16 +242,22 @@ export interface FileRoutesByTo { '/components/composer': typeof ComponentsComposerRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/empty-state': typeof ComponentsEmptyStateRoute '/components/icon-button': typeof ComponentsIconButtonRoute '/components/input': typeof ComponentsInputRoute '/components/kbd': typeof ComponentsKbdRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/popover': typeof ComponentsPopoverRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute '/components/select': typeof ComponentsSelectRoute + '/components/separator': typeof ComponentsSeparatorRoute '/components/sidebar': typeof ComponentsSidebarRoute + '/components/skeleton': typeof ComponentsSkeletonRoute + '/components/spinner': typeof ComponentsSpinnerRoute + '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute '/components/tooltip': typeof ComponentsTooltipRoute @@ -212,6 +268,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/components': typeof ComponentsRouteRouteWithChildren '/components/alert-dialog': typeof ComponentsAlertDialogRoute + '/components/avatar': typeof ComponentsAvatarRoute '/components/badge': typeof ComponentsBadgeRoute '/components/button': typeof ComponentsButtonRoute '/components/callout': typeof ComponentsCalloutRoute @@ -219,16 +276,22 @@ export interface FileRoutesById { '/components/composer': typeof ComponentsComposerRoute '/components/data-table': typeof ComponentsDataTableRoute '/components/dialog': typeof ComponentsDialogRoute + '/components/empty-state': typeof ComponentsEmptyStateRoute '/components/icon-button': typeof ComponentsIconButtonRoute '/components/input': typeof ComponentsInputRoute '/components/kbd': typeof ComponentsKbdRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/popover': typeof ComponentsPopoverRoute '/components/property-picker': typeof ComponentsPropertyPickerRoute '/components/radio-group': typeof ComponentsRadioGroupRoute '/components/search-field': typeof ComponentsSearchFieldRoute '/components/select': typeof ComponentsSelectRoute + '/components/separator': typeof ComponentsSeparatorRoute '/components/sidebar': typeof ComponentsSidebarRoute + '/components/skeleton': typeof ComponentsSkeletonRoute + '/components/spinner': typeof ComponentsSpinnerRoute + '/components/status': typeof ComponentsStatusRoute '/components/switch': typeof ComponentsSwitchRoute '/components/tabs': typeof ComponentsTabsRoute '/components/tooltip': typeof ComponentsTooltipRoute @@ -240,6 +303,7 @@ export interface FileRouteTypes { | '/' | '/components' | '/components/alert-dialog' + | '/components/avatar' | '/components/badge' | '/components/button' | '/components/callout' @@ -247,16 +311,22 @@ export interface FileRouteTypes { | '/components/composer' | '/components/data-table' | '/components/dialog' + | '/components/empty-state' | '/components/icon-button' | '/components/input' | '/components/kbd' | '/components/menu' | '/components/native-select' + | '/components/popover' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' | '/components/select' + | '/components/separator' | '/components/sidebar' + | '/components/skeleton' + | '/components/spinner' + | '/components/status' | '/components/switch' | '/components/tabs' | '/components/tooltip' @@ -265,6 +335,7 @@ export interface FileRouteTypes { to: | '/' | '/components/alert-dialog' + | '/components/avatar' | '/components/badge' | '/components/button' | '/components/callout' @@ -272,16 +343,22 @@ export interface FileRouteTypes { | '/components/composer' | '/components/data-table' | '/components/dialog' + | '/components/empty-state' | '/components/icon-button' | '/components/input' | '/components/kbd' | '/components/menu' | '/components/native-select' + | '/components/popover' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' | '/components/select' + | '/components/separator' | '/components/sidebar' + | '/components/skeleton' + | '/components/spinner' + | '/components/status' | '/components/switch' | '/components/tabs' | '/components/tooltip' @@ -291,6 +368,7 @@ export interface FileRouteTypes { | '/' | '/components' | '/components/alert-dialog' + | '/components/avatar' | '/components/badge' | '/components/button' | '/components/callout' @@ -298,16 +376,22 @@ export interface FileRouteTypes { | '/components/composer' | '/components/data-table' | '/components/dialog' + | '/components/empty-state' | '/components/icon-button' | '/components/input' | '/components/kbd' | '/components/menu' | '/components/native-select' + | '/components/popover' | '/components/property-picker' | '/components/radio-group' | '/components/search-field' | '/components/select' + | '/components/separator' | '/components/sidebar' + | '/components/skeleton' + | '/components/spinner' + | '/components/status' | '/components/switch' | '/components/tabs' | '/components/tooltip' @@ -349,6 +433,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsAlertDialogRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/avatar': { + id: '/components/avatar' + path: '/avatar' + fullPath: '/components/avatar' + preLoaderRoute: typeof ComponentsAvatarRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/badge': { id: '/components/badge' path: '/badge' @@ -398,6 +489,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsDialogRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/empty-state': { + id: '/components/empty-state' + path: '/empty-state' + fullPath: '/components/empty-state' + preLoaderRoute: typeof ComponentsEmptyStateRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/icon-button': { id: '/components/icon-button' path: '/icon-button' @@ -433,6 +531,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsNativeSelectRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/popover': { + id: '/components/popover' + path: '/popover' + fullPath: '/components/popover' + preLoaderRoute: typeof ComponentsPopoverRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/property-picker': { id: '/components/property-picker' path: '/property-picker' @@ -461,6 +566,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsSelectRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/separator': { + id: '/components/separator' + path: '/separator' + fullPath: '/components/separator' + preLoaderRoute: typeof ComponentsSeparatorRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/sidebar': { id: '/components/sidebar' path: '/sidebar' @@ -468,6 +580,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsSidebarRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/skeleton': { + id: '/components/skeleton' + path: '/skeleton' + fullPath: '/components/skeleton' + preLoaderRoute: typeof ComponentsSkeletonRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/spinner': { + id: '/components/spinner' + path: '/spinner' + fullPath: '/components/spinner' + preLoaderRoute: typeof ComponentsSpinnerRouteImport + parentRoute: typeof ComponentsRouteRoute + } + '/components/status': { + id: '/components/status' + path: '/status' + fullPath: '/components/status' + preLoaderRoute: typeof ComponentsStatusRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/switch': { id: '/components/switch' path: '/switch' @@ -494,6 +627,7 @@ declare module '@tanstack/react-router' { interface ComponentsRouteRouteChildren { ComponentsAlertDialogRoute: typeof ComponentsAlertDialogRoute + ComponentsAvatarRoute: typeof ComponentsAvatarRoute ComponentsBadgeRoute: typeof ComponentsBadgeRoute ComponentsButtonRoute: typeof ComponentsButtonRoute ComponentsCalloutRoute: typeof ComponentsCalloutRoute @@ -501,16 +635,22 @@ interface ComponentsRouteRouteChildren { ComponentsComposerRoute: typeof ComponentsComposerRoute ComponentsDataTableRoute: typeof ComponentsDataTableRoute ComponentsDialogRoute: typeof ComponentsDialogRoute + ComponentsEmptyStateRoute: typeof ComponentsEmptyStateRoute ComponentsIconButtonRoute: typeof ComponentsIconButtonRoute ComponentsInputRoute: typeof ComponentsInputRoute ComponentsKbdRoute: typeof ComponentsKbdRoute ComponentsMenuRoute: typeof ComponentsMenuRoute ComponentsNativeSelectRoute: typeof ComponentsNativeSelectRoute + ComponentsPopoverRoute: typeof ComponentsPopoverRoute ComponentsPropertyPickerRoute: typeof ComponentsPropertyPickerRoute ComponentsRadioGroupRoute: typeof ComponentsRadioGroupRoute ComponentsSearchFieldRoute: typeof ComponentsSearchFieldRoute ComponentsSelectRoute: typeof ComponentsSelectRoute + ComponentsSeparatorRoute: typeof ComponentsSeparatorRoute ComponentsSidebarRoute: typeof ComponentsSidebarRoute + ComponentsSkeletonRoute: typeof ComponentsSkeletonRoute + ComponentsSpinnerRoute: typeof ComponentsSpinnerRoute + ComponentsStatusRoute: typeof ComponentsStatusRoute ComponentsSwitchRoute: typeof ComponentsSwitchRoute ComponentsTabsRoute: typeof ComponentsTabsRoute ComponentsTooltipRoute: typeof ComponentsTooltipRoute @@ -519,6 +659,7 @@ interface ComponentsRouteRouteChildren { const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { ComponentsAlertDialogRoute: ComponentsAlertDialogRoute, + ComponentsAvatarRoute: ComponentsAvatarRoute, ComponentsBadgeRoute: ComponentsBadgeRoute, ComponentsButtonRoute: ComponentsButtonRoute, ComponentsCalloutRoute: ComponentsCalloutRoute, @@ -526,16 +667,22 @@ const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { ComponentsComposerRoute: ComponentsComposerRoute, ComponentsDataTableRoute: ComponentsDataTableRoute, ComponentsDialogRoute: ComponentsDialogRoute, + ComponentsEmptyStateRoute: ComponentsEmptyStateRoute, ComponentsIconButtonRoute: ComponentsIconButtonRoute, ComponentsInputRoute: ComponentsInputRoute, ComponentsKbdRoute: ComponentsKbdRoute, ComponentsMenuRoute: ComponentsMenuRoute, ComponentsNativeSelectRoute: ComponentsNativeSelectRoute, + ComponentsPopoverRoute: ComponentsPopoverRoute, ComponentsPropertyPickerRoute: ComponentsPropertyPickerRoute, ComponentsRadioGroupRoute: ComponentsRadioGroupRoute, ComponentsSearchFieldRoute: ComponentsSearchFieldRoute, ComponentsSelectRoute: ComponentsSelectRoute, + ComponentsSeparatorRoute: ComponentsSeparatorRoute, ComponentsSidebarRoute: ComponentsSidebarRoute, + ComponentsSkeletonRoute: ComponentsSkeletonRoute, + ComponentsSpinnerRoute: ComponentsSpinnerRoute, + ComponentsStatusRoute: ComponentsStatusRoute, ComponentsSwitchRoute: ComponentsSwitchRoute, ComponentsTabsRoute: ComponentsTabsRoute, ComponentsTooltipRoute: ComponentsTooltipRoute, diff --git a/apps/docs/src/routes/components/avatar.tsx b/apps/docs/src/routes/components/avatar.tsx new file mode 100644 index 0000000..def18db --- /dev/null +++ b/apps/docs/src/routes/components/avatar.tsx @@ -0,0 +1,58 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Avatar } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/avatar")({ + component: AvatarDocs, +}); + +const toc = [ + { id: "fallbacks", title: "Fallbacks" }, + { id: "sizes", title: "Sizes and presence" }, +]; + +function AvatarDocs() { + return ( + +
+

+ Pass a name for the accessible label and generated initials. A missing + or failed image returns to the same fallback automatically. +

+ + +`} + > + + + + +
+ +
+

+ Four sizes cover dense rows through prominent profile surfaces. + Presence becomes part of the avatar's accessible name. +

+ + + +`} + > + + + + + +
+
+ ); +} diff --git a/apps/docs/src/routes/components/empty-state.tsx b/apps/docs/src/routes/components/empty-state.tsx new file mode 100644 index 0000000..cdd8e85 --- /dev/null +++ b/apps/docs/src/routes/components/empty-state.tsx @@ -0,0 +1,71 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, EmptyState } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; +import { SearchIcon } from "../../components/icons"; + +export const Route = createFileRoute("/components/empty-state")({ + component: EmptyStateDocs, +}); + +const toc = [ + { id: "first-use", title: "First-use state" }, + { id: "no-results", title: "No-results state" }, +]; + +function EmptyStateDocs() { + return ( + +
+

+ Explain what belongs here, then offer one clear next action. Match the + heading level to the surrounding page structure. +

+ Connect repository} +/>`} + > + Connect repository} + /> + +
+ +
+

+ A compact empty state fits inside filtered lists and search results. + Keep the recovery action close to the explanation. +

+ } + title="No matching repositories" + description="Try a different name or clear the active filters." + actions={} +/>`} + > + } + title="No matching repositories" + description="Try a different name or clear the active filters." + actions={} + /> + +
+
+ ); +} diff --git a/apps/docs/src/routes/components/popover.tsx b/apps/docs/src/routes/components/popover.tsx new file mode 100644 index 0000000..26fb24d --- /dev/null +++ b/apps/docs/src/routes/components/popover.tsx @@ -0,0 +1,73 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, Popover, Status } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/popover")({ + component: PopoverDocs, +}); + +const toc = [ + { id: "anatomy", title: "Anatomy" }, + { id: "content", title: "Content and semantics" }, +]; + +function PopoverDocs() { + return ( + +
+

+ Compose the trigger, portal, positioner, and popup. Add a title and + description when the content needs an announced name and summary. +

+ + Repository access} /> + + + + + Repository access + + Visible to organization members. + + Connected + + + +`} + > + + Repository access} /> + + + + + Repository access + + Visible to organization members. + + Connected + + + + + +
+ +
+

+ Use Popover for information or controls that need focus, keyboard, + touch, and screen-reader access. Use Tooltip only for a short visual + label whose trigger is already independently named. +

+
+
+ ); +} diff --git a/apps/docs/src/routes/components/separator.tsx b/apps/docs/src/routes/components/separator.tsx new file mode 100644 index 0000000..cbef0c4 --- /dev/null +++ b/apps/docs/src/routes/components/separator.tsx @@ -0,0 +1,49 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Separator, Status } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/separator")({ + component: SeparatorDocs, +}); + +const toc = [ + { id: "orientation", title: "Orientation" }, + { id: "semantics", title: "Semantics" }, +]; + +function SeparatorDocs() { + return ( + +
+

+ Horizontal separators fill their container. Vertical separators + stretch to the height of their flex row. +

+ Checks passed + +Updated two minutes ago`} + > + Checks passed + + Updated two minutes ago + +
+ +
+

+ Separator is decorative by default. Set decorative=false + when the divider represents a meaningful boundary that belongs in the + accessibility tree. +

+
+
+ ); +} diff --git a/apps/docs/src/routes/components/skeleton.tsx b/apps/docs/src/routes/components/skeleton.tsx new file mode 100644 index 0000000..ecb4807 --- /dev/null +++ b/apps/docs/src/routes/components/skeleton.tsx @@ -0,0 +1,51 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Skeleton } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/skeleton")({ + component: SkeletonDocs, +}); + +const toc = [ + { id: "variants", title: "Variants" }, + { id: "accessibility", title: "Accessibility" }, +]; + +function SkeletonDocs() { + return ( + +
+

+ Text fills its line, block preserves a larger region, and circle + stands in for compact imagery such as an avatar. +

+ + + +`} + > + + + + + +
+ +
+

+ Skeletons are decorative and hidden from assistive technology. Name + the containing loading region, and replace the placeholders when real + content arrives. The pulse stops under reduced motion. +

+
+
+ ); +} diff --git a/apps/docs/src/routes/components/spinner.tsx b/apps/docs/src/routes/components/spinner.tsx new file mode 100644 index 0000000..5bed41c --- /dev/null +++ b/apps/docs/src/routes/components/spinner.tsx @@ -0,0 +1,47 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Spinner } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/spinner")({ + component: SpinnerDocs, +}); + +const toc = [ + { id: "sizes", title: "Sizes" }, + { id: "labels", title: "Loading labels" }, +]; + +function SpinnerDocs() { + return ( + +
+

+ Choose the size that matches the nearby control or content density. +

+ + +`} + > + + + + +
+ +
+

+ The default accessible label is “Loading”. Pass a more specific label + when the surrounding context does not already explain the pending + work. +

+
+
+ ); +} diff --git a/apps/docs/src/routes/components/status.tsx b/apps/docs/src/routes/components/status.tsx new file mode 100644 index 0000000..436278b --- /dev/null +++ b/apps/docs/src/routes/components/status.tsx @@ -0,0 +1,54 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Status } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; +import { CheckIcon } from "../../components/icons"; + +export const Route = createFileRoute("/components/status")({ + component: StatusDocs, +}); + +const toc = [ + { id: "tones", title: "Tones" }, + { id: "semantics", title: "Semantics" }, +]; + +function StatusDocs() { + return ( + +
+

+ A semantic tone colors only the leading visual. Pass an icon when a + dot is not specific enough for the state. +

+ Queued +Running +}>Passed +Degraded +Failed`} + > + Queued + Running + }> + Passed + + Degraded + Failed + +
+ +
+

+ Status is not a live region by default. Add role=status + only when an update should be politely announced as it changes. +

+
+
+ ); +} diff --git a/packages/dowel/README.md b/packages/dowel/README.md index c1cbba9..fe543d7 100644 --- a/packages/dowel/README.md +++ b/packages/dowel/README.md @@ -25,7 +25,7 @@ import { Button, ThemeProvider } from "@karnstack/dowel"; ## Components -Alert Dialog · Badge · Button · Callout · Checkbox · Composer · Data Table · Dialog · Icon Button · Input · Kbd · Menu · Native Select · Property Picker · Radio Group · Search Field · Select · Sidebar · Switch · Tabs · Tooltip +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 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/avatar/avatar.stylex.ts b/packages/dowel/src/components/avatar/avatar.stylex.ts new file mode 100644 index 0000000..0910c4d --- /dev/null +++ b/packages/dowel/src/components/avatar/avatar.stylex.ts @@ -0,0 +1,75 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +export const root = stylex.create({ + base: { + alignItems: "center", + backgroundColor: tokens["--dowel-bg-surface-3"], + borderColor: tokens["--dowel-border-default"], + borderStyle: "solid", + borderWidth: tokens["--dowel-hairline"], + boxSizing: "border-box", + color: tokens["--dowel-text-primary"], + display: "inline-flex", + flexShrink: 0, + fontFamily: tokens["--dowel-font-sans"], + fontWeight: 550, + justifyContent: "center", + overflow: "visible", + position: "relative", + userSelect: "none", + }, +}); + +export const size = stylex.create({ + xs: { fontSize: "0.5625rem", height: "1.25rem", width: "1.25rem" }, + sm: { fontSize: "0.625rem", height: "1.5rem", width: "1.5rem" }, + md: { fontSize: "0.6875rem", height: "1.75rem", width: "1.75rem" }, + lg: { fontSize: "0.8125rem", height: "2.25rem", width: "2.25rem" }, +}); + +export const shape = stylex.create({ + circle: { borderRadius: tokens["--dowel-radius-pill"] }, + square: { borderRadius: tokens["--dowel-radius-md"] }, +}); + +export const part = stylex.create({ + image: { + borderRadius: "inherit", + display: "block", + height: "100%", + objectFit: "cover", + overflow: "hidden", + width: "100%", + }, + fallback: { + alignItems: "center", + borderRadius: "inherit", + display: "flex", + height: "100%", + justifyContent: "center", + lineHeight: 1, + overflow: "hidden", + width: "100%", + }, + status: { + borderColor: tokens["--dowel-bg-surface-1"], + borderRadius: tokens["--dowel-radius-pill"], + borderStyle: "solid", + borderWidth: "2px", + bottom: "-1px", + boxSizing: "border-box", + height: "0.5rem", + position: "absolute", + right: "-1px", + width: "0.5rem", + }, +}); + +export const statusTone = stylex.create({ + online: { backgroundColor: tokens["--dowel-success"] }, + away: { backgroundColor: tokens["--dowel-warning"] }, + busy: { backgroundColor: tokens["--dowel-danger"] }, + offline: { backgroundColor: tokens["--dowel-text-disabled"] }, +}); diff --git a/packages/dowel/src/components/avatar/avatar.test.tsx b/packages/dowel/src/components/avatar/avatar.test.tsx new file mode 100644 index 0000000..43ba8b1 --- /dev/null +++ b/packages/dowel/src/components/avatar/avatar.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { renderBoth } from "../../../test/render"; +import { Avatar } from "./index"; + +describe("Avatar", () => { + it("derives a two-letter fallback from the name", () => { + render(); + expect(screen.getByText("AL")).toBeDefined(); + expect(screen.getByRole("img", { name: "Ada Lovelace" })).toBeDefined(); + }); + + it("falls back when the image fails", () => { + const { container } = render( + , + ); + fireEvent.error(container.querySelector("img")!); + expect(screen.getByText("GH")).toBeDefined(); + }); + + it("includes presence in its accessible name", () => { + render(); + expect(screen.getByRole("img", { name: "Lin Chen, online" })).toBeDefined(); + }); + + it("ignores appearance props smuggled through a spread", () => { + const smuggled = { className: "evil", style: { color: "red" } }; + render(); + const avatar = screen.getByRole("img"); + expect(avatar.className).not.toContain("evil"); + expect(avatar.getAttribute("style")).toBeNull(); + }); + + it("renders in both themes", () => { + const { light, dark } = renderBoth(); + expect( + light.querySelector('[data-dowel-component="avatar"]'), + ).not.toBeNull(); + expect( + dark.querySelector('[data-dowel-component="avatar"]'), + ).not.toBeNull(); + }); + + it("has no accessibility violations", async () => { + const { container } = render( + , + ); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/components/avatar/index.tsx b/packages/dowel/src/components/avatar/index.tsx new file mode 100644 index 0000000..5afc390 --- /dev/null +++ b/packages/dowel/src/components/avatar/index.tsx @@ -0,0 +1,89 @@ +import * as stylex from "@stylexjs/stylex"; +import { forwardRef, useState } from "react"; +import type { ComponentPropsWithoutRef } from "react"; + +import { withoutAppearanceProps } from "../_shared/props"; +import * as styles from "./avatar.stylex"; + +export type AvatarSize = "xs" | "sm" | "md" | "lg"; +export type AvatarShape = "circle" | "square"; +export type AvatarStatus = "online" | "away" | "busy" | "offline"; + +export interface AvatarProps + extends Omit< + ComponentPropsWithoutRef<"span">, + "children" | "className" | "style" | "role" + > { + name: string; + src?: string; + alt?: string; + initials?: string; + size?: AvatarSize; + shape?: AvatarShape; + status?: AvatarStatus; +} + +function initialsFor(name: string) { + const words = name.trim().split(/\s+/).filter(Boolean); + return words + .slice(0, 2) + .map((word) => word[0]) + .join("") + .toLocaleUpperCase(); +} + +export const Avatar = forwardRef(function Avatar( + { + name, + src, + alt = name, + initials = initialsFor(name), + size = "md", + shape = "circle", + status, + ...props + }, + ref, +) { + const [failedSrc, setFailedSrc] = useState(); + const safeProps = withoutAppearanceProps(props); + const showImage = Boolean(src) && failedSrc !== src; + const label = status ? `${alt}, ${status}` : alt; + + return ( + + {showImage ? ( + setFailedSrc(src)} + /> + ) : ( + + )} + {status ? ( + + ); +}); diff --git a/packages/dowel/src/components/empty-state/empty-state.stylex.ts b/packages/dowel/src/components/empty-state/empty-state.stylex.ts new file mode 100644 index 0000000..436d75d --- /dev/null +++ b/packages/dowel/src/components/empty-state/empty-state.stylex.ts @@ -0,0 +1,81 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const MOBILE = "@media (max-width: 639px)"; + +export const root = stylex.create({ + base: { + alignItems: "center", + boxSizing: "border-box", + color: tokens["--dowel-text-secondary"], + display: "flex", + flexDirection: "column", + fontFamily: tokens["--dowel-font-sans"], + justifyContent: "center", + textAlign: "center", + width: "100%", + }, +}); + +export const size = stylex.create({ + compact: { + gap: "0.625rem", + paddingBlock: "1.25rem", + paddingInline: "1rem", + }, + default: { + gap: "0.875rem", + minHeight: "12rem", + paddingBlock: { + default: "2.5rem", + [MOBILE]: "2rem", + }, + paddingInline: { + default: "2rem", + [MOBILE]: "1rem", + }, + }, +}); + +export const part = stylex.create({ + icon: { + alignItems: "center", + color: tokens["--dowel-text-tertiary"], + display: "inline-flex", + flexShrink: 0, + height: "1.5rem", + justifyContent: "center", + width: "1.5rem", + }, + copy: { + alignItems: "center", + display: "flex", + flexDirection: "column", + gap: "0.375rem", + maxWidth: "24rem", + minWidth: 0, + }, + title: { + color: tokens["--dowel-text-primary"], + fontSize: "0.9375rem", + fontWeight: 550, + letterSpacing: "-0.0125rem", + lineHeight: 1.4, + margin: 0, + }, + description: { + color: tokens["--dowel-text-tertiary"], + fontSize: "0.8125rem", + letterSpacing: "-0.0125rem", + lineHeight: 1.5, + margin: 0, + }, + actions: { + alignItems: "center", + display: "flex", + flexWrap: "wrap", + gap: "0.5rem", + justifyContent: "center", + }, +}); diff --git a/packages/dowel/src/components/empty-state/empty-state.test.tsx b/packages/dowel/src/components/empty-state/empty-state.test.tsx new file mode 100644 index 0000000..7963479 --- /dev/null +++ b/packages/dowel/src/components/empty-state/empty-state.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { Button } from "../button"; +import { EmptyState } from "./index"; + +describe("EmptyState", () => { + it("renders structured copy and actions", () => { + render( + Connect repository} + />, + ); + expect( + screen.getByRole("heading", { name: "No repositories", level: 3 }), + ).toBeDefined(); + expect( + screen.getByText("Connect a repository to start mirroring code."), + ).toBeDefined(); + expect( + screen.getByRole("button", { name: "Connect repository" }), + ).toBeDefined(); + }); + + it("supports the surrounding page heading level", () => { + render(); + expect( + screen.getByRole("heading", { name: "No results", level: 2 }), + ).toBeDefined(); + }); + + it("ignores appearance props smuggled through a spread", () => { + const smuggled = { className: "evil", style: { color: "red" } }; + render(); + const state = screen + .getByRole("heading") + .closest("[data-dowel-component]")!; + expect(state.className).not.toContain("evil"); + expect(state.getAttribute("style")).toBeNull(); + }); + + it("has no accessibility violations", async () => { + const { container } = render( + , + ); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/components/empty-state/index.tsx b/packages/dowel/src/components/empty-state/index.tsx new file mode 100644 index 0000000..5702302 --- /dev/null +++ b/packages/dowel/src/components/empty-state/index.tsx @@ -0,0 +1,64 @@ +import * as stylex from "@stylexjs/stylex"; +import { createElement, forwardRef } from "react"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; + +import { withoutAppearanceProps } from "../_shared/props"; +import * as styles from "./empty-state.stylex"; + +export interface EmptyStateProps + extends Omit< + ComponentPropsWithoutRef<"div">, + "children" | "className" | "style" | "title" + > { + title: ReactNode; + description?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; + headingLevel?: 2 | 3 | 4; + size?: "compact" | "default"; +} + +export const EmptyState = forwardRef( + function EmptyState( + { + title, + description, + icon, + actions, + headingLevel = 3, + size = "default", + ...props + }, + ref, + ) { + const safeProps = withoutAppearanceProps(props); + return ( +
+ {icon ? ( + + ) : null} +
+ {createElement( + `h${headingLevel}`, + stylex.props(styles.part.title), + title, + )} + {description ? ( +

{description}

+ ) : null} +
+ {actions ? ( +
{actions}
+ ) : null} +
+ ); + }, +); diff --git a/packages/dowel/src/components/popover/index.tsx b/packages/dowel/src/components/popover/index.tsx new file mode 100644 index 0000000..ae260e2 --- /dev/null +++ b/packages/dowel/src/components/popover/index.tsx @@ -0,0 +1,118 @@ +import { Popover as BasePopover } from "@base-ui/react/popover"; +import * as stylex from "@stylexjs/stylex"; +import { useContext } from "react"; + +import { DowelThemeContext, themeStyles } from "../../theme/theme-provider"; +import * as styles from "./popover.stylex"; + +type Props unknown> = T extends ( + props: infer P, +) => unknown + ? Omit + : never; + +function partProps(style: stylex.StyleXStyles) { + const resolved = stylex.props(style); + return { className: resolved.className, style: resolved.style }; +} + +/** Anchored, nonmodal content for controls and compact supporting details. */ +export const Popover = { + Root: BasePopover.Root, + + Trigger: function PopoverTrigger(props: Props) { + return ( + + ); + }, + + Portal: function PopoverPortal(props: Props) { + const theme = useContext(DowelThemeContext); + const resolved = stylex.props(themeStyles[theme]); + + return ( + + ); + }, + + Positioner: function PopoverPositioner( + props: Props, + ) { + return ( + + ); + }, + + Popup: function PopoverPopup(props: Props) { + return ( + + ); + }, + + Viewport: function PopoverViewport( + props: Props, + ) { + return ( + + ); + }, + + Arrow: function PopoverArrow(props: Props) { + return ( + + + + ); + }, + + Title: function PopoverTitle(props: Props) { + return ( + + ); + }, + + Description: function PopoverDescription( + props: Props, + ) { + return ( + + ); + }, + + Close: function PopoverClose(props: Props) { + return ( + + ); + }, +}; diff --git a/packages/dowel/src/components/popover/popover.stylex.ts b/packages/dowel/src/components/popover/popover.stylex.ts new file mode 100644 index 0000000..a91ed00 --- /dev/null +++ b/packages/dowel/src/components/popover/popover.stylex.ts @@ -0,0 +1,82 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const REDUCED_MOTION = "@media (prefers-reduced-motion: reduce)"; + +export const popup = stylex.create({ + root: { + 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", + color: tokens["--dowel-text-secondary"], + fontFamily: tokens["--dowel-font-sans"], + maxWidth: "min(22rem, calc(100vw - 1rem))", + minWidth: "12rem", + opacity: 1, + outline: "none", + padding: "0.75rem", + transformOrigin: "var(--transform-origin)", + transitionDuration: { + default: tokens["--dowel-duration-fast"], + [REDUCED_MOTION]: "0.01ms", + }, + transitionProperty: "opacity", + transitionTimingFunction: tokens["--dowel-ease-out"], + zIndex: 70, + "[data-starting-style]": { + opacity: 0, + }, + "[data-ending-style]": { + opacity: 0, + }, + }, +}); + +export const part = stylex.create({ + viewport: { + maxHeight: "min(24rem, var(--available-height))", + overflowY: "auto", + }, + title: { + color: tokens["--dowel-text-primary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + fontWeight: 550, + letterSpacing: "-0.0125rem", + lineHeight: 1.4, + margin: 0, + }, + description: { + color: tokens["--dowel-text-tertiary"], + fontFamily: tokens["--dowel-font-sans"], + fontSize: "0.8125rem", + letterSpacing: "-0.0125rem", + lineHeight: 1.5, + marginBlock: "0.25rem 0", + }, + arrow: { + alignItems: "center", + display: "flex", + height: "0.5rem", + justifyContent: "center", + width: "0.75rem", + }, + arrowShape: { + backgroundColor: tokens["--dowel-bg-elevated"], + borderBottomColor: tokens["--dowel-border-default"], + borderBottomStyle: "solid", + borderBottomWidth: tokens["--dowel-hairline"], + borderRightColor: tokens["--dowel-border-default"], + borderRightStyle: "solid", + borderRightWidth: tokens["--dowel-hairline"], + display: "block", + height: "0.5rem", + rotate: "45deg", + width: "0.5rem", + }, +}); diff --git a/packages/dowel/src/components/popover/popover.test.tsx b/packages/dowel/src/components/popover/popover.test.tsx new file mode 100644 index 0000000..4d484f5 --- /dev/null +++ b/packages/dowel/src/components/popover/popover.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { ThemeProvider } from "../../theme/theme-provider"; +import { Button } from "../button"; +import { Popover } from "./index"; + +function Example() { + return ( + + Repository access} /> + + + + + Repository access + + Visible to organization members. + + Done} /> + + + + + ); +} + +describe("Popover", () => { + it("opens from its trigger and closes from its close control", async () => { + render(); + expect(screen.queryByText("Visible to organization members.")).toBeNull(); + + await userEvent.click( + screen.getByRole("button", { name: "Repository access" }), + ); + expect( + await screen.findByText("Visible to organization members."), + ).toBeDefined(); + + await userEvent.click(screen.getByRole("button", { name: "Done" })); + await waitFor(() => + expect(screen.queryByText("Visible to organization members.")).toBeNull(), + ); + }); + + it("carries the active theme into its portal", async () => { + render( + + + + + Details + + + + , + ); + await screen.findByText("Details"); + expect(screen.getByTestId("portal").dataset.dowelTheme).toBe("dark"); + }); + + it("ignores appearance props smuggled onto rendered parts", async () => { + const smuggled = { + id: "smuggled-popup", + className: "evil", + style: { color: "red" }, + }; + render( + + + + Details + + + , + ); + await screen.findByText("Details"); + const popup = document.getElementById("smuggled-popup")!; + expect(popup.className).not.toContain("evil"); + expect(popup.style.color).toBe(""); + }); + + it("has no accessibility violations", async () => { + const { container } = render(); + await userEvent.click( + screen.getByRole("button", { name: "Repository access" }), + ); + await screen.findByText("Visible to organization members."); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/components/separator/index.tsx b/packages/dowel/src/components/separator/index.tsx new file mode 100644 index 0000000..b310e14 --- /dev/null +++ b/packages/dowel/src/components/separator/index.tsx @@ -0,0 +1,43 @@ +import * as stylex from "@stylexjs/stylex"; +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef } from "react"; + +import { withoutAppearanceProps } from "../_shared/props"; +import * as styles from "./separator.stylex"; + +export interface SeparatorProps + extends Omit< + ComponentPropsWithoutRef<"div">, + "children" | "className" | "style" | "role" | "aria-orientation" + > { + orientation?: "horizontal" | "vertical"; + /** Decorative separators are hidden from the accessibility tree. */ + decorative?: boolean; +} + +export const Separator = forwardRef( + function Separator( + { orientation = "horizontal", decorative = true, ...props }, + ref, + ) { + const safeProps = withoutAppearanceProps(props); + const resolved = stylex.props( + styles.root.base, + styles.orientation[orientation], + ); + + return ( +
+ ); + }, +); diff --git a/packages/dowel/src/components/separator/separator.stylex.ts b/packages/dowel/src/components/separator/separator.stylex.ts new file mode 100644 index 0000000..44f4aaa --- /dev/null +++ b/packages/dowel/src/components/separator/separator.stylex.ts @@ -0,0 +1,24 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +export const root = stylex.create({ + base: { + backgroundColor: tokens["--dowel-border-subtle"], + border: 0, + boxSizing: "border-box", + flexShrink: 0, + }, +}); + +export const orientation = stylex.create({ + horizontal: { + height: tokens["--dowel-hairline"], + width: "100%", + }, + vertical: { + alignSelf: "stretch", + minHeight: "1rem", + width: tokens["--dowel-hairline"], + }, +}); diff --git a/packages/dowel/src/components/separator/separator.test.tsx b/packages/dowel/src/components/separator/separator.test.tsx new file mode 100644 index 0000000..6aff921 --- /dev/null +++ b/packages/dowel/src/components/separator/separator.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { Separator } from "./index"; + +describe("Separator", () => { + it("is decorative by default", () => { + const { container } = render(); + const separator = container.querySelector( + '[data-dowel-component="separator"]', + )!; + expect(separator.getAttribute("role")).toBe("none"); + expect(separator.getAttribute("aria-hidden")).toBe("true"); + }); + + it("supports a semantic vertical separator", () => { + render(); + const separator = screen.getByRole("separator"); + expect(separator.getAttribute("aria-orientation")).toBe("vertical"); + expect(separator.dataset.orientation).toBe("vertical"); + }); + + it("ignores appearance props smuggled through a spread", () => { + const smuggled = { + id: "separator", + className: "evil", + style: { color: "red" }, + }; + const { container } = render(); + const separator = container.querySelector( + '[data-dowel-component="separator"]', + )!; + expect(separator.className).not.toContain("evil"); + expect(separator.getAttribute("style")).toBeNull(); + }); + + it("has no accessibility violations", async () => { + const { container } = render(); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/components/skeleton/index.tsx b/packages/dowel/src/components/skeleton/index.tsx new file mode 100644 index 0000000..45b97a8 --- /dev/null +++ b/packages/dowel/src/components/skeleton/index.tsx @@ -0,0 +1,39 @@ +import * as stylex from "@stylexjs/stylex"; +import { forwardRef } from "react"; +import type { ComponentPropsWithoutRef } from "react"; + +import { withoutAppearanceProps } from "../_shared/props"; +import * as styles from "./skeleton.stylex"; + +export type SkeletonVariant = "text" | "block" | "circle"; +export type SkeletonSize = "sm" | "md" | "lg"; + +export interface SkeletonProps + extends Omit< + ComponentPropsWithoutRef<"span">, + "children" | "className" | "style" + > { + variant?: SkeletonVariant; + size?: SkeletonSize; +} + +export const Skeleton = forwardRef( + function Skeleton({ variant = "text", size = "md", ...props }, ref) { + const safeProps = withoutAppearanceProps(props); + return ( +