diff --git a/.changeset/tiny-workspaces-rest.md b/.changeset/tiny-workspaces-rest.md new file mode 100644 index 0000000..e388982 --- /dev/null +++ b/.changeset/tiny-workspaces-rest.md @@ -0,0 +1,6 @@ +--- +"@karnstack/dowel": minor +--- + +Add compact PageHeader, SettingsSection, and SettingsRow composition +primitives for application workspaces. diff --git a/apps/docs/public/components/index.md b/apps/docs/public/components/index.md index d68bbaa..c6c895e 100644 --- a/apps/docs/public/components/index.md +++ b/apps/docs/public/components/index.md @@ -29,14 +29,16 @@ - [List](https://dowel.sh/components/list.md): Semantic grouped rows with cells, density, and selection. - [Menu](https://dowel.sh/components/menu.md): A dropdown with keyboard navigation and typeahead. - [Native Select](https://dowel.sh/components/native-select.md): A resilient native choice control with Dowel field styling. +- [Page Header](https://dowel.sh/components/page-header.md): A compact workspace heading with context, actions, and tabs. - [Pagination](https://dowel.sh/components/pagination.md): Controlled page navigation with compact number elision. - [Popover](https://dowel.sh/components/popover.md): An anchored surface for interactive controls and rich details. - [Property Picker](https://dowel.sh/components/property-picker.md): Searchable grouped metadata selection with a compact pill trigger. - [Progress](https://dowel.sh/components/progress.md): Determinate and indeterminate progress for long tasks. - [Radio Group](https://dowel.sh/components/radio-group.md): A single-choice group with native forms and arrow-key selection. - [Search Field](https://dowel.sh/components/search-field.md): A search input with icon, clear action, and shortcut hint. -- [Separator](https://dowel.sh/components/separator.md): A quiet horizontal or vertical content divider. - [Select](https://dowel.sh/components/select.md): A grouped single-choice popup with native form submission. +- [Separator](https://dowel.sh/components/separator.md): A quiet horizontal or vertical content divider. +- [Settings](https://dowel.sh/components/settings.md): Divider-led sections and rows for compact preference screens. - [Sidebar](https://dowel.sh/components/sidebar.md): A responsive application rail with pointer and keyboard resize. - [Slider](https://dowel.sh/components/slider.md): A pointer and keyboard control for a numeric range. - [Skeleton](https://dowel.sh/components/skeleton.md): Reduced-motion-aware placeholders for pending content. diff --git a/apps/docs/public/components/page-header.md b/apps/docs/public/components/page-header.md new file mode 100644 index 0000000..43ae96c --- /dev/null +++ b/apps/docs/public/components/page-header.md @@ -0,0 +1,9 @@ +# Page Header + +> A compact workspace heading with context, actions, and tabs. + +- Package: `@karnstack/dowel` +- [HTML documentation](https://dowel.sh/components/page-header) +- [Markdown documentation](https://dowel.sh/components/page-header.md) + +Import public components and types from the package root. Import `@karnstack/dowel/dowel.css` once at the application root. diff --git a/apps/docs/public/components/settings.md b/apps/docs/public/components/settings.md new file mode 100644 index 0000000..b6f22ac --- /dev/null +++ b/apps/docs/public/components/settings.md @@ -0,0 +1,9 @@ +# Settings + +> Divider-led sections and rows for compact preference screens. + +- Package: `@karnstack/dowel` +- [HTML documentation](https://dowel.sh/components/settings) +- [Markdown documentation](https://dowel.sh/components/settings.md) + +Import public components and types from the package root. Import `@karnstack/dowel/dowel.css` once at the application root. diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt index 0181d31..5157a3f 100644 --- a/apps/docs/public/llms-full.txt +++ b/apps/docs/public/llms-full.txt @@ -343,6 +343,18 @@ Import public components and types from the package root. Import `@karnstack/dow --- +## Page Header + +> A compact workspace heading with context, actions, and tabs. + +- Package: `@karnstack/dowel` +- [HTML documentation](https://dowel.sh/components/page-header) +- [Markdown documentation](https://dowel.sh/components/page-header.md) + +Import public components and types from the package root. Import `@karnstack/dowel/dowel.css` once at the application root. + +--- + ## Pagination > Controlled page navigation with compact number elision. @@ -415,6 +427,18 @@ Import public components and types from the package root. Import `@karnstack/dow --- +## Select + +> A grouped single-choice popup with native form submission. + +- Package: `@karnstack/dowel` +- [HTML documentation](https://dowel.sh/components/select) +- [Markdown documentation](https://dowel.sh/components/select.md) + +Import public components and types from the package root. Import `@karnstack/dowel/dowel.css` once at the application root. + +--- + ## Separator > A quiet horizontal or vertical content divider. @@ -427,13 +451,13 @@ Import public components and types from the package root. Import `@karnstack/dow --- -## Select +## Settings -> A grouped single-choice popup with native form submission. +> Divider-led sections and rows for compact preference screens. - Package: `@karnstack/dowel` -- [HTML documentation](https://dowel.sh/components/select) -- [Markdown documentation](https://dowel.sh/components/select.md) +- [HTML documentation](https://dowel.sh/components/settings) +- [Markdown documentation](https://dowel.sh/components/settings.md) Import public components and types from the package root. Import `@karnstack/dowel/dowel.css` once at the application root. diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index 4a76e4c..0cc141b 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -33,14 +33,16 @@ Install `@karnstack/dowel`, import `@karnstack/dowel/dowel.css` once, and import - [List](https://dowel.sh/components/list.md): Semantic grouped rows with cells, density, and selection. - [Menu](https://dowel.sh/components/menu.md): A dropdown with keyboard navigation and typeahead. - [Native Select](https://dowel.sh/components/native-select.md): A resilient native choice control with Dowel field styling. +- [Page Header](https://dowel.sh/components/page-header.md): A compact workspace heading with context, actions, and tabs. - [Pagination](https://dowel.sh/components/pagination.md): Controlled page navigation with compact number elision. - [Popover](https://dowel.sh/components/popover.md): An anchored surface for interactive controls and rich details. - [Property Picker](https://dowel.sh/components/property-picker.md): Searchable grouped metadata selection with a compact pill trigger. - [Progress](https://dowel.sh/components/progress.md): Determinate and indeterminate progress for long tasks. - [Radio Group](https://dowel.sh/components/radio-group.md): A single-choice group with native forms and arrow-key selection. - [Search Field](https://dowel.sh/components/search-field.md): A search input with icon, clear action, and shortcut hint. -- [Separator](https://dowel.sh/components/separator.md): A quiet horizontal or vertical content divider. - [Select](https://dowel.sh/components/select.md): A grouped single-choice popup with native form submission. +- [Separator](https://dowel.sh/components/separator.md): A quiet horizontal or vertical content divider. +- [Settings](https://dowel.sh/components/settings.md): Divider-led sections and rows for compact preference screens. - [Sidebar](https://dowel.sh/components/sidebar.md): A responsive application rail with pointer and keyboard resize. - [Slider](https://dowel.sh/components/slider.md): A pointer and keyboard control for a numeric range. - [Skeleton](https://dowel.sh/components/skeleton.md): Reduced-motion-aware placeholders for pending content. diff --git a/apps/docs/src/components/component-gallery.tsx b/apps/docs/src/components/component-gallery.tsx index 1176d8b..aa4eb6e 100644 --- a/apps/docs/src/components/component-gallery.tsx +++ b/apps/docs/src/components/component-gallery.tsx @@ -31,6 +31,7 @@ import { Menu, NativeSelect, Pagination, + PageHeader, Popover, Progress, PropertyPicker, @@ -39,6 +40,8 @@ import { SearchField, Separator, Select, + SettingsRow, + SettingsSection, Sidebar, Slider, Skeleton, @@ -534,6 +537,30 @@ export function ComponentGallery() { + + Save} + /> + + + + + + + + + + + + + Volume diff --git a/apps/docs/src/lib/nav.ts b/apps/docs/src/lib/nav.ts index 9ad698a..db85417 100644 --- a/apps/docs/src/lib/nav.ts +++ b/apps/docs/src/lib/nav.ts @@ -155,6 +155,11 @@ export const componentNav: NavItem[] = [ to: "/components/native-select", summary: "A resilient native choice control with Dowel field styling.", }, + { + title: "Page Header", + to: "/components/page-header", + summary: "A compact workspace heading with context, actions, and tabs.", + }, { title: "Pagination", to: "/components/pagination", @@ -186,15 +191,20 @@ export const componentNav: NavItem[] = [ to: "/components/search-field", summary: "A search input with icon, clear action, and shortcut hint.", }, + { + title: "Select", + to: "/components/select", + summary: "A grouped single-choice popup with native form submission.", + }, { title: "Separator", to: "/components/separator", summary: "A quiet horizontal or vertical content divider.", }, { - title: "Select", - to: "/components/select", - summary: "A grouped single-choice popup with native form submission.", + title: "Settings", + to: "/components/settings", + summary: "Divider-led sections and rows for compact preference screens.", }, { title: "Sidebar", @@ -293,6 +303,7 @@ export const componentSections: NavSection[] = [ items: components( "/components/breadcrumbs", "/components/command-menu", + "/components/page-header", "/components/pagination", "/components/sidebar", "/components/tabs", @@ -341,6 +352,7 @@ export const componentSections: NavSection[] = [ items: components( "/components/collection-controls", "/components/composer", + "/components/settings", ), }, ]; diff --git a/apps/docs/src/routeTree.gen.ts b/apps/docs/src/routeTree.gen.ts index a357c82..d3e5f7a 100644 --- a/apps/docs/src/routeTree.gen.ts +++ b/apps/docs/src/routeTree.gen.ts @@ -40,6 +40,7 @@ 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 ComponentsPageHeaderRouteImport } from './routes/components/page-header' import { Route as ComponentsPaginationRouteImport } from './routes/components/pagination' import { Route as ComponentsPopoverRouteImport } from './routes/components/popover' import { Route as ComponentsProgressRouteImport } from './routes/components/progress' @@ -48,6 +49,7 @@ import { Route as ComponentsRadioGroupRouteImport } from './routes/components/ra 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 ComponentsSettingsRouteImport } from './routes/components/settings' import { Route as ComponentsSidebarRouteImport } from './routes/components/sidebar' import { Route as ComponentsSkeletonRouteImport } from './routes/components/skeleton' import { Route as ComponentsSliderRouteImport } from './routes/components/slider' @@ -216,6 +218,11 @@ const ComponentsNativeSelectRoute = ComponentsNativeSelectRouteImport.update({ path: '/native-select', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsPageHeaderRoute = ComponentsPageHeaderRouteImport.update({ + id: '/page-header', + path: '/page-header', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsPaginationRoute = ComponentsPaginationRouteImport.update({ id: '/pagination', path: '/pagination', @@ -257,6 +264,11 @@ const ComponentsSeparatorRoute = ComponentsSeparatorRouteImport.update({ path: '/separator', getParentRoute: () => ComponentsRouteRoute, } as any) +const ComponentsSettingsRoute = ComponentsSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ComponentsRouteRoute, +} as any) const ComponentsSidebarRoute = ComponentsSidebarRouteImport.update({ id: '/sidebar', path: '/sidebar', @@ -344,6 +356,7 @@ export interface FileRoutesByFullPath { '/components/list': typeof ComponentsListRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/page-header': typeof ComponentsPageHeaderRoute '/components/pagination': typeof ComponentsPaginationRoute '/components/popover': typeof ComponentsPopoverRoute '/components/progress': typeof ComponentsProgressRoute @@ -352,6 +365,7 @@ export interface FileRoutesByFullPath { '/components/search-field': typeof ComponentsSearchFieldRoute '/components/select': typeof ComponentsSelectRoute '/components/separator': typeof ComponentsSeparatorRoute + '/components/settings': typeof ComponentsSettingsRoute '/components/sidebar': typeof ComponentsSidebarRoute '/components/skeleton': typeof ComponentsSkeletonRoute '/components/slider': typeof ComponentsSliderRoute @@ -395,6 +409,7 @@ export interface FileRoutesByTo { '/components/list': typeof ComponentsListRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/page-header': typeof ComponentsPageHeaderRoute '/components/pagination': typeof ComponentsPaginationRoute '/components/popover': typeof ComponentsPopoverRoute '/components/progress': typeof ComponentsProgressRoute @@ -403,6 +418,7 @@ export interface FileRoutesByTo { '/components/search-field': typeof ComponentsSearchFieldRoute '/components/select': typeof ComponentsSelectRoute '/components/separator': typeof ComponentsSeparatorRoute + '/components/settings': typeof ComponentsSettingsRoute '/components/sidebar': typeof ComponentsSidebarRoute '/components/skeleton': typeof ComponentsSkeletonRoute '/components/slider': typeof ComponentsSliderRoute @@ -448,6 +464,7 @@ export interface FileRoutesById { '/components/list': typeof ComponentsListRoute '/components/menu': typeof ComponentsMenuRoute '/components/native-select': typeof ComponentsNativeSelectRoute + '/components/page-header': typeof ComponentsPageHeaderRoute '/components/pagination': typeof ComponentsPaginationRoute '/components/popover': typeof ComponentsPopoverRoute '/components/progress': typeof ComponentsProgressRoute @@ -456,6 +473,7 @@ export interface FileRoutesById { '/components/search-field': typeof ComponentsSearchFieldRoute '/components/select': typeof ComponentsSelectRoute '/components/separator': typeof ComponentsSeparatorRoute + '/components/settings': typeof ComponentsSettingsRoute '/components/sidebar': typeof ComponentsSidebarRoute '/components/skeleton': typeof ComponentsSkeletonRoute '/components/slider': typeof ComponentsSliderRoute @@ -502,6 +520,7 @@ export interface FileRouteTypes { | '/components/list' | '/components/menu' | '/components/native-select' + | '/components/page-header' | '/components/pagination' | '/components/popover' | '/components/progress' @@ -510,6 +529,7 @@ export interface FileRouteTypes { | '/components/search-field' | '/components/select' | '/components/separator' + | '/components/settings' | '/components/sidebar' | '/components/skeleton' | '/components/slider' @@ -553,6 +573,7 @@ export interface FileRouteTypes { | '/components/list' | '/components/menu' | '/components/native-select' + | '/components/page-header' | '/components/pagination' | '/components/popover' | '/components/progress' @@ -561,6 +582,7 @@ export interface FileRouteTypes { | '/components/search-field' | '/components/select' | '/components/separator' + | '/components/settings' | '/components/sidebar' | '/components/skeleton' | '/components/slider' @@ -605,6 +627,7 @@ export interface FileRouteTypes { | '/components/list' | '/components/menu' | '/components/native-select' + | '/components/page-header' | '/components/pagination' | '/components/popover' | '/components/progress' @@ -613,6 +636,7 @@ export interface FileRouteTypes { | '/components/search-field' | '/components/select' | '/components/separator' + | '/components/settings' | '/components/sidebar' | '/components/skeleton' | '/components/slider' @@ -852,6 +876,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsNativeSelectRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/page-header': { + id: '/components/page-header' + path: '/page-header' + fullPath: '/components/page-header' + preLoaderRoute: typeof ComponentsPageHeaderRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/pagination': { id: '/components/pagination' path: '/pagination' @@ -908,6 +939,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ComponentsSeparatorRouteImport parentRoute: typeof ComponentsRouteRoute } + '/components/settings': { + id: '/components/settings' + path: '/settings' + fullPath: '/components/settings' + preLoaderRoute: typeof ComponentsSettingsRouteImport + parentRoute: typeof ComponentsRouteRoute + } '/components/sidebar': { id: '/components/sidebar' path: '/sidebar' @@ -1016,6 +1054,7 @@ interface ComponentsRouteRouteChildren { ComponentsListRoute: typeof ComponentsListRoute ComponentsMenuRoute: typeof ComponentsMenuRoute ComponentsNativeSelectRoute: typeof ComponentsNativeSelectRoute + ComponentsPageHeaderRoute: typeof ComponentsPageHeaderRoute ComponentsPaginationRoute: typeof ComponentsPaginationRoute ComponentsPopoverRoute: typeof ComponentsPopoverRoute ComponentsProgressRoute: typeof ComponentsProgressRoute @@ -1024,6 +1063,7 @@ interface ComponentsRouteRouteChildren { ComponentsSearchFieldRoute: typeof ComponentsSearchFieldRoute ComponentsSelectRoute: typeof ComponentsSelectRoute ComponentsSeparatorRoute: typeof ComponentsSeparatorRoute + ComponentsSettingsRoute: typeof ComponentsSettingsRoute ComponentsSidebarRoute: typeof ComponentsSidebarRoute ComponentsSkeletonRoute: typeof ComponentsSkeletonRoute ComponentsSliderRoute: typeof ComponentsSliderRoute @@ -1066,6 +1106,7 @@ const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { ComponentsListRoute: ComponentsListRoute, ComponentsMenuRoute: ComponentsMenuRoute, ComponentsNativeSelectRoute: ComponentsNativeSelectRoute, + ComponentsPageHeaderRoute: ComponentsPageHeaderRoute, ComponentsPaginationRoute: ComponentsPaginationRoute, ComponentsPopoverRoute: ComponentsPopoverRoute, ComponentsProgressRoute: ComponentsProgressRoute, @@ -1074,6 +1115,7 @@ const ComponentsRouteRouteChildren: ComponentsRouteRouteChildren = { ComponentsSearchFieldRoute: ComponentsSearchFieldRoute, ComponentsSelectRoute: ComponentsSelectRoute, ComponentsSeparatorRoute: ComponentsSeparatorRoute, + ComponentsSettingsRoute: ComponentsSettingsRoute, ComponentsSidebarRoute: ComponentsSidebarRoute, ComponentsSkeletonRoute: ComponentsSkeletonRoute, ComponentsSliderRoute: ComponentsSliderRoute, diff --git a/apps/docs/src/routes/components/page-header.tsx b/apps/docs/src/routes/components/page-header.tsx new file mode 100644 index 0000000..88bc98e --- /dev/null +++ b/apps/docs/src/routes/components/page-header.tsx @@ -0,0 +1,81 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Breadcrumbs, Button, PageHeader, Tabs } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/page-header")({ + component: PageHeaderDocs, +}); + +const toc = [ + { id: "composition", title: "Composition" }, + { id: "hierarchy", title: "Hierarchy" }, +]; + +function PageHeaderExample() { + return ( + + } + actions={} + > + + + General + Members + Integrations + + + + ); +} + +function PageHeaderDocs() { + return ( + +
+

+ Keep the title and primary page action together. Breadcrumbs add + context only when the surrounding workspace does not already provide + it. Children are reserved for tabs or filters below the title row. +

+ } + actions={} +> + ... +`} + > + + +
+ +
+

+ The title is an h1 by default. Set headingLevel only when the header + is nested inside a larger page or documentation example. Actions wrap + while the title copy keeps the available width. +

+
+
+ ); +} diff --git a/apps/docs/src/routes/components/settings.tsx b/apps/docs/src/routes/components/settings.tsx new file mode 100644 index 0000000..c3ea458 --- /dev/null +++ b/apps/docs/src/routes/components/settings.tsx @@ -0,0 +1,82 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Button, SettingsRow, SettingsSection, Switch } from "@karnstack/dowel"; + +import { Demo } from "../../components/demo"; +import { DocsPage, Section } from "../../components/docs-page"; + +export const Route = createFileRoute("/components/settings")({ + component: SettingsDocs, +}); + +const toc = [ + { id: "section", title: "Settings section" }, + { id: "controls", title: "Control labels" }, +]; + +function SettingsExample() { + return ( + + + + + + + + + + + + ); +} + +function SettingsDocs() { + return ( + +
+

+ Sections introduce one related set of preferences. Rows use dividers + instead of cards, keep explanatory copy flexible, and reserve the end + of each row for one labelled control or action. +

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

+ Row copy is visible context, but interactive children still need their + own accessible name. Use aria-label when a Switch or icon-only action + does not include visible text. +

+
+
+ ); +} diff --git a/apps/docs/test/browser/responsive-contracts.spec.ts b/apps/docs/test/browser/responsive-contracts.spec.ts index 0510254..8d61928 100644 --- a/apps/docs/test/browser/responsive-contracts.spec.ts +++ b/apps/docs/test/browser/responsive-contracts.spec.ts @@ -182,3 +182,35 @@ for (const viewport of viewports.filter(({ compact }) => compact)) { await context.close(); }); } + +test("workspace composition stays compact at phone width", async ({ + browser, +}) => { + const { context, page } = await createPage(browser, { + width: 390, + height: 844, + }); + + await page.goto("/components/page-header"); + await page.waitForLoadState("networkidle"); + const header = page.locator('[data-dowel-component="page-header"]').first(); + await expect(header).toBeVisible(); + const headerBox = await header.boundingBox(); + expect(Math.round(headerBox?.height ?? 0)).toBe(165); + await expect( + header.getByRole("button", { name: "Save changes" }), + ).toBeVisible(); + + await page.goto("/components/settings"); + await page.waitForLoadState("networkidle"); + const rows = page.locator('[data-dowel-component="settings-row"]'); + await expect(rows).toHaveCount(3); + const rowHeights = await rows.evaluateAll((elements) => + elements.map((element) => + Math.round(element.getBoundingClientRect().height), + ), + ); + expect(rowHeights).toEqual([59, 59, 52]); + + await context.close(); +}); diff --git a/packages/dowel/src/components/page-header/index.tsx b/packages/dowel/src/components/page-header/index.tsx new file mode 100644 index 0000000..36a4109 --- /dev/null +++ b/packages/dowel/src/components/page-header/index.tsx @@ -0,0 +1,86 @@ +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 "./page-header.stylex"; + +export interface PageHeaderProps + extends Omit< + ComponentPropsWithoutRef<"header">, + "children" | "className" | "style" | "title" + > { + title: ReactNode; + description?: ReactNode; + breadcrumbs?: ReactNode; + actions?: ReactNode; + /** Optional navigation or filters placed below the title row. */ + children?: ReactNode; + headingLevel?: 1 | 2 | 3; +} + +export const PageHeader = forwardRef( + function PageHeader( + { + title, + description, + breadcrumbs, + actions, + children, + headingLevel = 1, + ...props + }, + ref, + ) { + const safeProps = withoutAppearanceProps(props); + + return ( +
+ {breadcrumbs ? ( +
+ {breadcrumbs} +
+ ) : null} +
+
+ {createElement( + `h${headingLevel}`, + stylex.props(styles.part.title), + title, + )} + {description ? ( +

{description}

+ ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ {children ? ( +
+ {children} +
+ ) : null} +
+ ); + }, +); diff --git a/packages/dowel/src/components/page-header/page-header.stylex.ts b/packages/dowel/src/components/page-header/page-header.stylex.ts new file mode 100644 index 0000000..b1a1eb6 --- /dev/null +++ b/packages/dowel/src/components/page-header/page-header.stylex.ts @@ -0,0 +1,69 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const MOBILE = "@media (max-width: 639px)"; + +export const part = stylex.create({ + root: { + borderBottomColor: tokens["--dowel-border-subtle"], + borderBottomStyle: "solid", + borderBottomWidth: tokens["--dowel-hairline"], + boxSizing: "border-box", + color: tokens["--dowel-text-primary"], + display: "flex", + flexDirection: "column", + fontFamily: tokens["--dowel-font-sans"], + gap: "0.75rem", + minWidth: 0, + paddingBlock: "0.875rem", + width: "100%", + }, + breadcrumbs: { + minWidth: 0, + }, + main: { + alignItems: "flex-start", + display: "flex", + gap: "1rem", + minWidth: 0, + width: "100%", + }, + copy: { + display: "flex", + flex: 1, + flexDirection: "column", + gap: "0.25rem", + minWidth: 0, + }, + title: { + color: tokens["--dowel-text-primary"], + fontSize: "1.125rem", + fontWeight: 600, + letterSpacing: "-0.025rem", + lineHeight: 1.3, + margin: 0, + }, + description: { + color: tokens["--dowel-text-tertiary"], + fontSize: { + default: "0.8125rem", + [MOBILE]: "0.875rem", + }, + letterSpacing: "-0.00625rem", + lineHeight: 1.45, + margin: 0, + maxWidth: "48rem", + }, + actions: { + alignItems: "center", + display: "flex", + flexShrink: 0, + flexWrap: "wrap", + gap: "0.5rem", + justifyContent: "flex-end", + }, + secondary: { + minWidth: 0, + }, +}); diff --git a/packages/dowel/src/components/page-header/page-header.test.tsx b/packages/dowel/src/components/page-header/page-header.test.tsx new file mode 100644 index 0000000..10d5110 --- /dev/null +++ b/packages/dowel/src/components/page-header/page-header.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { Button } from "../button"; +import { PageHeader } from "./index"; + +describe("PageHeader", () => { + it("composes navigation, title copy, actions, and secondary content", () => { + render( + Workspace} + actions={} + > + + , + ); + + expect( + screen.getByRole("heading", { level: 1, name: "Workspace settings" }), + ).toBeDefined(); + expect( + screen.getByRole("navigation", { name: "Breadcrumb" }), + ).toBeDefined(); + expect(screen.getByRole("button", { name: "Invite member" })).toBeDefined(); + expect( + screen.getByRole("navigation", { name: "Settings views" }), + ).toBeDefined(); + }); + + it("supports a surrounding page heading level", () => { + render(); + expect( + screen.getByRole("heading", { level: 2, name: "Project settings" }), + ).toBeDefined(); + }); + + it("does not expose an appearance override", () => { + const smuggled = { className: "override", style: { color: "red" } }; + render(); + const header = document.querySelector( + '[data-dowel-component="page-header"]', + )!; + expect(header.className).not.toContain("override"); + expect(header.getAttribute("style")).toBeNull(); + }); + + it("has no accessibility violations", async () => { + const { container } = render( + Invite member} + />, + ); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/components/settings/index.tsx b/packages/dowel/src/components/settings/index.tsx new file mode 100644 index 0000000..2d4a464 --- /dev/null +++ b/packages/dowel/src/components/settings/index.tsx @@ -0,0 +1,93 @@ +import * as stylex from "@stylexjs/stylex"; +import { createElement, forwardRef, useId } from "react"; +import type { ComponentPropsWithoutRef, ReactNode } from "react"; + +import { withoutAppearanceProps } from "../_shared/props"; +import * as styles from "./settings.stylex"; + +export interface SettingsSectionProps + extends Omit< + ComponentPropsWithoutRef<"section">, + "children" | "className" | "style" | "title" + > { + title: ReactNode; + description?: ReactNode; + children: ReactNode; + headingLevel?: 2 | 3; +} + +export const SettingsSection = forwardRef( + function SettingsSection( + { title, description, children, headingLevel = 2, ...props }, + ref, + ) { + const titleId = useId(); + const safeProps = withoutAppearanceProps(props); + + return ( +
+
+ {createElement( + `h${headingLevel}`, + { ...stylex.props(styles.section.title), id: titleId }, + title, + )} + {description ? ( +

{description}

+ ) : null} +
+
+ {children} +
+
+ ); + }, +); + +export interface SettingsRowProps + extends Omit< + ComponentPropsWithoutRef<"div">, + "children" | "className" | "style" | "title" + > { + title: ReactNode; + description?: ReactNode; + /** A labelled control or action placed at the end of the row. */ + children: ReactNode; +} + +export const SettingsRow = forwardRef( + function SettingsRow({ title, description, children, ...props }, ref) { + const safeProps = withoutAppearanceProps(props); + + return ( +
+
+
{title}
+ {description ? ( +
{description}
+ ) : null} +
+
+ {children} +
+
+ ); + }, +); diff --git a/packages/dowel/src/components/settings/settings.stylex.ts b/packages/dowel/src/components/settings/settings.stylex.ts new file mode 100644 index 0000000..bc92947 --- /dev/null +++ b/packages/dowel/src/components/settings/settings.stylex.ts @@ -0,0 +1,90 @@ +import * as stylex from "@stylexjs/stylex"; + +import { tokens } from "../../theme/tokens.stylex"; + +const MOBILE = "@media (max-width: 639px)"; + +export const section = stylex.create({ + root: { + color: tokens["--dowel-text-primary"], + fontFamily: tokens["--dowel-font-sans"], + minWidth: 0, + width: "100%", + }, + header: { + display: "flex", + flexDirection: "column", + gap: "0.25rem", + paddingBlockEnd: "0.75rem", + }, + 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.00625rem", + lineHeight: 1.45, + margin: 0, + maxWidth: "48rem", + }, + rows: { + borderTopColor: tokens["--dowel-border-subtle"], + borderTopStyle: "solid", + borderTopWidth: tokens["--dowel-hairline"], + }, +}); + +export const row = stylex.create({ + root: { + alignItems: "center", + borderBottomColor: tokens["--dowel-border-subtle"], + borderBottomStyle: "solid", + borderBottomWidth: tokens["--dowel-hairline"], + boxSizing: "border-box", + display: "flex", + gap: { + default: "1.5rem", + [MOBILE]: "0.75rem", + }, + minHeight: "3.25rem", + minWidth: 0, + paddingBlock: "0.625rem", + }, + copy: { + display: "flex", + flex: 1, + flexDirection: "column", + gap: "0.125rem", + minWidth: 0, + }, + title: { + color: tokens["--dowel-text-primary"], + fontSize: { + default: "0.8125rem", + [MOBILE]: "0.875rem", + }, + fontWeight: 500, + letterSpacing: "-0.00625rem", + lineHeight: 1.4, + }, + description: { + color: tokens["--dowel-text-tertiary"], + fontSize: "0.75rem", + letterSpacing: "-0.00625rem", + lineHeight: 1.4, + }, + control: { + alignItems: "center", + display: "flex", + flexShrink: 0, + gap: "0.5rem", + justifyContent: "flex-end", + marginInlineStart: "auto", + }, +}); diff --git a/packages/dowel/src/components/settings/settings.test.tsx b/packages/dowel/src/components/settings/settings.test.tsx new file mode 100644 index 0000000..9388253 --- /dev/null +++ b/packages/dowel/src/components/settings/settings.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { expectNoA11yViolations } from "../../../test/setup"; +import { Switch } from "../switch"; +import { SettingsRow, SettingsSection } from "./index"; + +function Example() { + return ( + + + + + + + + + ); +} + +describe("SettingsSection and SettingsRow", () => { + it("labels the section and composes controls into divided rows", () => { + render(); + const section = screen.getByRole("region", { name: "Notifications" }); + expect(section).toBeDefined(); + expect( + screen.getByRole("switch", { name: "Issue activity" }), + ).toBeDefined(); + expect( + section.querySelectorAll('[data-dowel-component="settings-row"]'), + ).toHaveLength(2); + }); + + it("supports a nested heading level", () => { + render( + + + + + , + ); + expect( + screen.getByRole("heading", { level: 3, name: "Members" }), + ).toBeDefined(); + }); + + it("does not expose appearance overrides", () => { + const smuggled = { className: "override", style: { color: "red" } }; + render( + + + + + , + ); + for (const element of document.querySelectorAll("[data-dowel-component]")) { + expect(element.className).not.toContain("override"); + } + }); + + it("has no accessibility violations", async () => { + const { container } = render(); + await expectNoA11yViolations(container); + }); +}); diff --git a/packages/dowel/src/index.ts b/packages/dowel/src/index.ts index 64ac677..c8aef02 100644 --- a/packages/dowel/src/index.ts +++ b/packages/dowel/src/index.ts @@ -44,6 +44,13 @@ export type { SidebarRootProps, SidebarVariant, } from "./components/sidebar"; +export { PageHeader } from "./components/page-header"; +export type { PageHeaderProps } from "./components/page-header"; +export { SettingsRow, SettingsSection } from "./components/settings"; +export type { + SettingsRowProps, + SettingsSectionProps, +} from "./components/settings"; export { DataTable, createDataTableColumnHelper,