# HG changeset patch # User MrJuneJune # Date 1785881529 25200 # Node ID 2b6e732087ff626b3179f23c402cdd25c91bc93c # Parent fdf3816959cbe8afc1706bc79cb587f9e16c0479 [ui] Add complete native component catalog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> diff -r fdf3816959cb -r 2b6e732087ff .claude/skills/zenbu-design-system/SKILL.md --- a/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 12:21:19 2026 -0700 +++ b/.claude/skills/zenbu-design-system/SKILL.md Tue Aug 04 15:12:09 2026 -0700 @@ -30,12 +30,35 @@ - Prefix custom elements and tokens with `zen-` / `--zen-`. - Keep selectors low-specificity so applications can override tokens. - Support light, dark, reduced-motion, desktop, and mobile behavior. +- Disclosure, overlay, and collapsing surfaces require smooth tokenized motion + plus a reduced-motion fallback. Native disclosure markers must be replaced by + first-party `zen-icon` geometry. +- Reset browser chrome for design-system form controls and restyle every state + with Zenbu tokens. Calendar and date-picker UI must use the first-party + keyboard calendar rather than exposing the browser's default date picker. +- Browser acceptance must exercise every catalog route at desktop and mobile + widths and reject horizontal page overflow. +- Preserve the warm, restrained MUJI-like visual character: paper neutrals, + natural material colors, strong typography, and deliberate color accents. +- Use semantic `--zen-color-*` aliases in component CSS. Use primitive ramps + or the ten data tokens only when a semantic role does not fit; never encode + meaning through color alone. +- Raw color literals are forbidden outside `src/styles/tokens.css`. Run + `//design_system/test:design_system_policy_test` whenever styles, components, + stories, or catalog markup changes. +- Render every interface icon with the first-party `zen-icon` component. Emoji, + text-glyph icons, ad hoc SVG, icon fonts, and external icon packages are + forbidden. +- Every exported JavaScript class, function, or constant requires Google-style + JSDoc with its public type tags. The policy test must fail when documentation + is missing. ## Bazel targets ```bash bazel run //design_system:dev bazel test //design_system/test:storybook_test +bazel test //design_system/test:design_system_policy_test bazel build //design_system:design_system_server_bundle ``` diff -r fdf3816959cb -r 2b6e732087ff design_system/BUILD --- a/design_system/BUILD Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/BUILD Tue Aug 04 15:12:09 2026 -0700 @@ -28,6 +28,12 @@ srcs = glob(["src/**"]), ) +filegroup( + name = "policy_sources", + srcs = glob(["src/**"]), + visibility = ["//design_system/test:__pkg__"], +) + cc_binary( name = "design_system_server", srcs = ["main.c"], diff -r fdf3816959cb -r 2b6e732087ff design_system/COLORS.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/COLORS.md Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,86 @@ +# Zenbu UI color + +The Zenbu palette is warm, quiet, and material-oriented. Paper neutrals carry +most surfaces; restrained natural colors provide emphasis without making the +interface feel synthetic or overly saturated. + +## Primitive ramps + +Each family has `50`, `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, +`900`, and `950` steps: + +- `--zen-color-neutral-*`: warm paper, linen, charcoal, and ink; +- `--zen-color-red-*`: brick and iron oxide; +- `--zen-color-orange-*`: persimmon; +- `--zen-color-amber-*`: straw and ochre; +- `--zen-color-green-*`: matcha and moss; +- `--zen-color-teal-*`: aged copper and patina; +- `--zen-color-blue-*`: faded workwear and indigo; +- `--zen-color-violet-*`: muted plum; +- `--zen-color-rose-*`: clay and dusty rose; +- `--zen-color-brown-*`: kraft paper and wood. + +Use a primitive when an application needs a precise ramp step. Primitive values +do not change between light and dark themes. + +## Material aliases + +Material aliases make one-off composition readable: + +```css +var(--zen-color-paper) +var(--zen-color-washi) +var(--zen-color-linen) +var(--zen-color-sumi) +var(--zen-color-brick) +var(--zen-color-persimmon) +var(--zen-color-ochre) +var(--zen-color-moss) +var(--zen-color-patina) +var(--zen-color-indigo) +var(--zen-color-plum) +var(--zen-color-clay) +var(--zen-color-wood) +``` + +## Semantic aliases + +Components should prefer semantic tokens because these adapt to the current +theme: + +- surfaces: `canvas`, `surface`, `surface-raised`, `surface-muted`, + `surface-sunken`, and `surface-overlay`; +- text: `text`, `text-muted`, `text-subtle`, and `text-inverse`; +- borders: `border`, `border-muted`, and `border-strong`; +- action: `accent`, `accent-hover`, `accent-muted`, `focus`, `disabled`, and + `selection`; +- status: `info`, `success`, `warning`, `danger`, their `on-*` foregrounds, + and their `*-muted` surfaces. + +All names use the `--zen-color-` prefix. + +Component and catalog styles may not contain raw color literals. Add or reuse a +token in `tokens.css`, then reference it with `var()`. The Bazel target +`//design_system/test:design_system_policy_test` rejects hex, functional, or +named colors anywhere else in the design-system source. + +## Data colors + +`--zen-color-data-1` through `--zen-color-data-10` form a categorical palette. +They deliberately vary in both hue and lightness. Keep adjacent series in +numerical order and provide labels or patterns; color alone must never carry +meaning. + +## Usage + +```css +.repository-summary { + border: 1px solid var(--zen-color-border-muted); + background: var(--zen-color-surface-muted); + color: var(--zen-color-text); +} + +.repository-summary strong { + color: var(--zen-color-indigo); +} +``` diff -r fdf3816959cb -r 2b6e732087ff design_system/COMPONENTS.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/COMPONENTS.md Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,125 @@ +# Zenbu UI component contracts + +Zenbu UI covers the 65 concepts in the shadcn/ui component catalog without +copying its React, Radix, or third-party runtime. Every Zenbu component uses +light DOM and keeps the corresponding native HTML control visible. + +## Inventory + +| shadcn/ui concept | Zenbu UI element | +| --- | --- | +| Accordion | `zen-accordion` | +| Alert | `zen-alert` | +| Alert Dialog | `zen-alert-dialog` | +| Aspect Ratio | `zen-aspect-ratio` | +| Attachment | `zen-attachment` | +| Avatar | `zen-avatar` | +| Badge | `zen-badge` | +| Breadcrumb | `zen-breadcrumb` | +| Bubble | `zen-bubble` | +| Button | `zen-button` | +| Button Group | `zen-button-group` | +| Calendar | `zen-calendar` | +| Card | `zen-card` | +| Carousel | `zen-carousel` | +| Chart | `zen-chart` | +| Checkbox | `zen-checkbox` | +| Collapsible | `zen-collapsible` | +| Combobox | `zen-combobox` | +| Command | `zen-command` | +| Context Menu | `zen-context-menu` | +| Data Table | `zen-data-table` | +| Date Picker | `zen-date-picker` | +| Dialog | `zen-dialog` | +| Direction | `zen-direction` | +| Drawer | `zen-drawer` | +| Dropdown Menu | `zen-dropdown-menu` | +| Empty | `zen-empty` | +| Field | `zen-field` | +| Form | `zen-form` | +| Hover Card | `zen-hover-card` | +| Input | `zen-input` | +| Input Group | `zen-input-group` | +| Input OTP | `zen-input-otp` | +| Item | `zen-item` | +| Kbd | `zen-kbd` | +| Label | `zen-label` | +| Marker | `zen-marker` | +| Menubar | `zen-menubar` | +| Message | `zen-message` | +| Message Scroller | `zen-message-scroller` | +| Native Select | `zen-native-select` | +| Navigation Menu | `zen-navigation-menu` | +| Pagination | `zen-pagination` | +| Popover | `zen-popover` | +| Progress | `zen-progress` | +| Radio Group | `zen-radio-group` | +| Resizable | `zen-resizable` | +| Scroll Area | `zen-scroll-area` | +| Select | `zen-select` | +| Separator | `zen-separator` | +| Sheet | `zen-sheet` | +| Sidebar | `zen-sidebar` | +| Skeleton | `zen-skeleton` | +| Slider | `zen-slider` | +| Sonner | `zen-notifications` | +| Spinner | `zen-spinner` | +| Switch | `zen-switch` | +| Table | `zen-table` | +| Tabs | `zen-tabs` | +| Textarea | `zen-textarea` | +| Toast (deprecated) | `zen-notifications` | +| Toggle | `zen-toggle` | +| Toggle Group | `zen-toggle-group` | +| Tooltip | `zen-tooltip` | +| Typography | `zen-typography` | + +`zen-stack` remains a Zenbu-specific layout primitive in addition to this +catalog. + +## Native HTML rules + +- Dialog, alert dialog, sheet, and drawer contain a real `dialog`. +- Accordion and collapsible contain `details` and `summary`. +- Input, textarea, select, checkbox, radio, switch, and slider contain their + corresponding native form controls. +- Calendar and date picker keep a native date value for form submission while + rendering the first-party keyboard month grid. The browser date-picker chrome + is not exposed. +- Table and data table contain a real `table`; pagination and breadcrumbs use + native links in a `nav`. +- Button-like components contain native `button` or `a` elements. +- Components do not own application data, network requests, routing, or + persistence. + +## Interactive hooks + +| Elements | Required light-DOM hooks | Emitted event | +| --- | --- | --- | +| `zen-dialog`, `zen-alert-dialog`, `zen-sheet`, `zen-drawer` | `[data-zen-trigger]`, `dialog`, optional `[data-zen-close]` | native `close` | +| `zen-popover`, `zen-hover-card`, `zen-tooltip` | `[data-zen-trigger]`, `[data-zen-content]` | native toggle/focus events | +| Menu elements | `[data-zen-trigger]`, `[role="menu"]`, `[role="menuitem"]` | native click | +| `zen-tabs` | `[role="tablist"]`, `[role="tab"]`, `[role="tabpanel"]` | `zen-change` with `{ value }` | +| `zen-toggle` | direct `button` | `zen-change` with `{ pressed }` | +| `zen-toggle-group` | direct buttons with `value` | `zen-change` with `{ values }` | +| `zen-sidebar` | `[data-zen-sidebar-trigger]`, `[data-zen-sidebar-panel]` | `zen-change` with `{ open }` | +| `zen-combobox` | `input`, `[role="listbox"]`, `[role="option"]` | native `input`/`change` and `zen-change` | +| `zen-command` | `input`, `[role="menu"]`, `[role="menuitem"]` | `zen-command` with `{ value }` | +| `zen-input-otp` | one native input, optional `[data-zen-otp-slots]` | `zen-complete` with `{ value }` | +| `zen-calendar`, `zen-date-picker` | native date input plus generated month grid | native `input`/`change` and `zen-change` | +| `zen-carousel` | `[data-zen-viewport]`, `[data-zen-prev]`, `[data-zen-next]` | `zen-change` with `{ index }` | +| `zen-resizable` | two `[data-zen-panel]` elements and `[data-zen-handle]` | `zen-resize` with `{ percent }` | +| `zen-data-table` | `button[data-zen-sort]` and matching cell `data-key` values | `zen-sort` | + +All custom events bubble but remain inside their current DOM tree unless a +component-specific document says otherwise. Application code should listen at +the nearest owning feature boundary. + +## Catalog + +Every inventory row has a route at `/components/`, a rendered example, +and copyable HTML source in the Seobeo-backed catalog: + +```bash +bazel run //design_system:dev +``` diff -r fdf3816959cb -r 2b6e732087ff design_system/ICONS.md --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/ICONS.md Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,40 @@ +# Zenbu UI icons + +`zen-icon` is the only icon renderer used by Zenbu UI. It renders first-party +SVG geometry in light DOM, inherits the current text color, and has no font, +emoji, package, or network dependency. + +```html + + +``` + +Icons without `label` are decorative and receive `aria-hidden="true"`. An icon +with a label receives `role="img"` and the matching accessible name. Buttons +that already have an accessible name should use an unlabeled icon. + +## Names + +The initial set contains: + +```text +add alert archive arrow-down arrow-left arrow-right arrow-up +branch calendar check chevron-down chevron-left chevron-right chevron-up +close code copy download error external file folder history info +menu moon more pause play repository retry search settings sun +trash upload user +``` + +Unknown names render the `alert` fallback rather than inserting authored HTML. +The catalog at `/icons` renders the authoritative runtime list. + +## Hard requirements + +- Do not use emoji or text glyphs as interface icons. +- Do not add inline SVG outside `components/icon.js`. +- Do not import an external icon package or icon font. +- Use `currentColor` through `zen-icon`; the owning component must obtain that + color from a `--zen-color-*` token. +- Add new geometry to the first-party icon registry and include a catalog test. + +`//design_system/test:design_system_policy_test` enforces these requirements. diff -r fdf3816959cb -r 2b6e732087ff design_system/README.md --- a/design_system/README.md Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/README.md Tue Aug 04 15:12:09 2026 -0700 @@ -4,6 +4,11 @@ It stays close to ordinary HTML: components wrap native controls and sectioning elements instead of replacing their semantics. +The catalog includes native Zenbu implementations of all 65 concepts currently +listed by shadcn/ui. These are original Web Components using Zenbu tokens, not +ports of shadcn's React, Radix, or third-party runtime. See +[`COMPONENTS.md`](COMPONENTS.md) for the complete mapping and HTML contracts. + ## Catalog Run the Seobeo-backed component catalog: @@ -50,11 +55,22 @@ ``` Every visual token is a `--zen-*` custom property and can be overridden by the -application. +application. See [`COLORS.md`](COLORS.md) for the complete warm color ramps, +material aliases, semantic roles, dark-theme behavior, and data palette. + +Use [`zen-icon`](ICONS.md) for all interface iconography. Design-system source +does not use emoji, ad hoc SVG, icon fonts, or third-party icon packages. + +All exported JavaScript APIs use Google-style JSDoc. The policy target checks +the documentation contract along with token-only colors and first-party icons. + +The catalog navigation is searchable with `/`, and every route is exercised at +desktop and mobile widths. Form controls reset browser chrome; date selection +uses Zenbu's first-party keyboard calendar. ## Native HTML contract -| Component | Native content | Attributes | Events | +| Component family | Native content | Attributes | Events | | --- | --- | --- | --- | | `zen-button` | direct `button` or `a` | `variant`, `loading`, `disabled` | native click/form events | | `zen-card` | direct `article` or `section` | `interactive` | native descendant events | @@ -62,6 +78,10 @@ | `zen-field` | direct `label`, form control, help text | native control attributes | native input/change/invalid | | `zen-notifications` | descendant event producers | `aria-label`, `dismiss-label` | `zen-notification-action`, `zen-notification-removed` | | `zen-stack` | any HTML | `direction`, `gap` | native descendant events | +| Disclosure and tabs | `details`, `summary`, buttons, sections | `multiple`, `activation` | `zen-change` | +| Dialogs and menus | native `dialog`, buttons, links | `placement`, native attributes | native click/close | +| Form controls | native input, select, textarea, fieldset | native control attributes | native form events | +| Collections | table, article, section, scroll containers | component-specific data hooks | `zen-change`, `zen-sort`, `zen-resize` | Do not put business state into the design-system components. They provide presentation, small accessibility wiring, and interaction affordances while diff -r fdf3816959cb -r 2b6e732087ff design_system/main.c --- a/design_system/main.c Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/main.c Tue Aug 04 15:12:09 2026 -0700 @@ -3,6 +3,76 @@ #include #include +static const char *const COMPONENT_PATHS[] = { + "/components/accordion", + "/components/alert", + "/components/alert-dialog", + "/components/aspect-ratio", + "/components/attachment", + "/components/avatar", + "/components/badge", + "/components/breadcrumb", + "/components/bubble", + "/components/button", + "/components/button-group", + "/components/calendar", + "/components/card", + "/components/carousel", + "/components/chart", + "/components/checkbox", + "/components/collapsible", + "/components/combobox", + "/components/command", + "/components/context-menu", + "/components/data-table", + "/components/date-picker", + "/components/dialog", + "/components/direction", + "/components/drawer", + "/components/dropdown-menu", + "/components/empty", + "/components/field", + "/components/form", + "/components/hover-card", + "/components/input", + "/components/input-group", + "/components/input-otp", + "/components/item", + "/components/kbd", + "/components/label", + "/components/marker", + "/components/menubar", + "/components/message", + "/components/message-scroller", + "/components/native-select", + "/components/navigation-menu", + "/components/notifications", + "/components/pagination", + "/components/popover", + "/components/progress", + "/components/radio-group", + "/components/resizable", + "/components/scroll-area", + "/components/select", + "/components/separator", + "/components/sheet", + "/components/sidebar", + "/components/skeleton", + "/components/slider", + "/components/sonner", + "/components/spinner", + "/components/stack", + "/components/switch", + "/components/table", + "/components/tabs", + "/components/textarea", + "/components/toast", + "/components/toggle", + "/components/toggle-group", + "/components/tooltip", + "/components/typography", +}; + static Seobeo_Request_Entry *Get_Component_Catalog( Seobeo_Request_Entry *request, Dowa_Arena *arena) @@ -71,13 +141,17 @@ Seobeo_Router_Init(); Seobeo_Router_Register("GET", "/", Get_Component_Catalog); Seobeo_Router_Register("GET", "/tokens", Get_Component_Catalog); + Seobeo_Router_Register("GET", "/icons", Get_Component_Catalog); Seobeo_Router_Register("GET", "/components", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/button", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/card", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/alert", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/field", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/notifications", Get_Component_Catalog); - Seobeo_Router_Register("GET", "/components/stack", Get_Component_Catalog); + for (size_t i = 0; + i < sizeof(COMPONENT_PATHS) / sizeof(COMPONENT_PATHS[0]); + i++) + { + Seobeo_Router_Register( + "GET", + COMPONENT_PATHS[i], + Get_Component_Catalog); + } const char *port = getenv("DESIGN_SYSTEM_PORT"); if (!port || port[0] == '\0') diff -r fdf3816959cb -r 2b6e732087ff design_system/src/catalog.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/catalog.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,732 @@ +/** @const {!ReadonlyArray} shadcn concepts shown by the catalog. */ +export const componentCatalog = [ + { + name: "Accordion", + slug: "accordion", + element: "zen-accordion", + group: "Disclosure", + description: "A group of native details elements with optional single-open behavior.", + markup: ` +
Is it accessible?

It keeps native details and summary semantics.

+
Can several open?

Add the multiple attribute to allow it.

+
`, + }, + { + name: "Alert", + slug: "alert", + element: "zen-alert", + group: "Feedback", + description: "Status messaging with semantic tones and optional dismissal.", + existing: true, + }, + { + name: "Alert Dialog", + slug: "alert-dialog", + element: "zen-alert-dialog", + group: "Overlays", + description: "A native modal dialog for decisions that require confirmation.", + markup: ` + + +

Delete deployment?

+

This action cannot be undone.

+ + +
+
`, + }, + { + name: "Aspect Ratio", + slug: "aspect-ratio", + element: "zen-aspect-ratio", + group: "Layout", + description: "A media frame driven by a readable ratio attribute.", + markup: ` +
16 / 9
+
`, + }, + { + name: "Attachment", + slug: "attachment", + element: "zen-attachment", + group: "Chat", + description: "File and upload state presentation using ordinary links and buttons.", + markup: ` + +
release-notes.pdf248 KB · Ready
+ +
`, + }, + { + name: "Avatar", + slug: "avatar", + element: "zen-avatar", + group: "Display", + description: "A compact identity image with a visible text fallback.", + markup: `JP`, + }, + { + name: "Badge", + slug: "badge", + element: "zen-badge", + group: "Display", + description: "A small label for metadata, state, or counts.", + markup: ` + Default + Healthy + Failed +`, + }, + { + name: "Breadcrumb", + slug: "breadcrumb", + element: "zen-breadcrumb", + group: "Navigation", + description: "A native navigation landmark for hierarchical location.", + markup: ` + +`, + }, + { + name: "Bubble", + slug: "bubble", + element: "zen-bubble", + group: "Chat", + description: "A message bubble with incoming and outgoing alignment.", + markup: ` +

The deployment is ready.

+

Ship it.

+
`, + }, + { + name: "Button", + slug: "button", + element: "zen-button", + group: "Actions", + description: "Native buttons and links with shared variants and states.", + existing: true, + }, + { + name: "Button Group", + slug: "button-group", + element: "zen-button-group", + group: "Actions", + description: "Related native buttons joined into one visual control.", + markup: ` + + + +`, + }, + { + name: "Calendar", + slug: "calendar", + element: "zen-calendar", + group: "Forms", + description: "A first-party date month grid backed by a native form value.", + markup: ` + + +`, + }, + { + name: "Card", + slug: "card", + element: "zen-card", + group: "Display", + description: "A visual container around native article or section content.", + existing: true, + }, + { + name: "Carousel", + slug: "carousel", + element: "zen-carousel", + group: "Collections", + description: "A scroll-snap collection with native buttons and keyboard controls.", + markup: ` +
+
Mercurial forge
+
HLS player
+
LaTeX editor
+
+ + +
`, + }, + { + name: "Chart", + slug: "chart", + element: "zen-chart", + group: "Data", + description: "A dependency-free chart frame for semantic labels and custom marks.", + markup: ` +
+ + + + +
+
`, + }, + { + name: "Checkbox", + slug: "checkbox", + element: "zen-checkbox", + group: "Forms", + description: "A styled native checkbox that remains form-associated.", + markup: ` + +`, + }, + { + name: "Collapsible", + slug: "collapsible", + element: "zen-collapsible", + group: "Disclosure", + description: "A single native details disclosure.", + markup: ` +
Three changed files

server.c, styles.css, README.md

+
`, + }, + { + name: "Combobox", + slug: "combobox", + element: "zen-combobox", + group: "Forms", + description: "A filterable text input backed by an accessible option list.", + markup: ` + + +
+ + + +
+
`, + }, + { + name: "Command", + slug: "command", + element: "zen-command", + group: "Navigation", + description: "A keyboard-first filterable command list.", + markup: ` + +
+ + + +
+
`, + }, + { + name: "Context Menu", + slug: "context-menu", + element: "zen-context-menu", + group: "Menus", + description: "A right-click menu with keyboard navigation and native buttons.", + markup: ` +
Right-click this repository
+ +
`, + }, + { + name: "Data Table", + slug: "data-table", + element: "zen-data-table", + group: "Data", + description: "A native table enhanced with optional client-side sorting.", + markup: ` + + + + + + + + + + +
seobeo87
hg-web142
design-system12
+
`, + }, + { + name: "Date Picker", + slug: "date-picker", + element: "zen-date-picker", + group: "Forms", + description: "A tokenized date trigger and first-party keyboard calendar.", + markup: ` + + +`, + }, + { + name: "Dialog", + slug: "dialog", + element: "zen-dialog", + group: "Overlays", + description: "A native modal dialog with trigger and close wiring.", + markup: ` + + +

Edit profile

+ + +
+
`, + }, + { + name: "Direction", + slug: "direction", + element: "zen-direction", + group: "Utilities", + description: "A light-DOM boundary for left-to-right or right-to-left content.", + markup: `

مرحبا بالعالم

`, + }, + { + name: "Drawer", + slug: "drawer", + element: "zen-drawer", + group: "Overlays", + description: "A native dialog presented as an edge drawer.", + markup: ` + +

Build queue

Two jobs are running.

+
`, + }, + { + name: "Dropdown Menu", + slug: "dropdown-menu", + element: "zen-dropdown-menu", + group: "Menus", + description: "A button-owned action menu with arrow-key navigation.", + markup: ` + + +`, + }, + { + name: "Empty", + slug: "empty", + element: "zen-empty", + group: "Feedback", + description: "A structured empty state with an optional native action.", + markup: ` + No deployments yet +

Create the first release from the current default branch.

+ +
`, + }, + { + name: "Field", + slug: "field", + element: "zen-field", + group: "Forms", + description: "Label, help text, validity, and a native form control.", + existing: true, + }, + { + name: "Form", + slug: "form", + element: "zen-form", + group: "Forms", + description: "A native form boundary with visible browser validity state.", + markup: ` +
+ Required + +
+
`, + }, + { + name: "Hover Card", + slug: "hover-card", + element: "zen-hover-card", + group: "Overlays", + description: "Supplemental content shown on hover or keyboard focus.", + markup: ` + @mrjunejune + +`, + }, + { + name: "Input", + slug: "input", + element: "zen-input", + group: "Forms", + description: "A styled native input with no replacement semantics.", + markup: ``, + }, + { + name: "Input Group", + slug: "input-group", + element: "zen-input-group", + group: "Forms", + description: "A native input with prefix, suffix, or inline actions.", + markup: ` + + + +`, + }, + { + name: "Input OTP", + slug: "input-otp", + element: "zen-input-otp", + group: "Forms", + description: "A single native one-time-code input with visual slots.", + markup: ` + + + +`, + }, + { + name: "Item", + slug: "item", + element: "zen-item", + group: "Display", + description: "A flexible list row for an icon, content, and actions.", + markup: ` + +
hg-webUpdated two minutes ago
+ +
`, + }, + { + name: "Kbd", + slug: "kbd", + element: "zen-kbd", + group: "Display", + description: "Keyboard shortcut presentation using native kbd elements.", + markup: `

Open commands with Ctrl+K.

`, + }, + { + name: "Label", + slug: "label", + element: "zen-label", + group: "Forms", + description: "A styled native label that keeps browser association.", + markup: ` +`, + }, + { + name: "Marker", + slug: "marker", + element: "zen-marker", + group: "Chat", + description: "An inline system marker for chat and activity streams.", + markup: `Today`, + }, + { + name: "Menubar", + slug: "menubar", + element: "zen-menubar", + group: "Menus", + description: "A horizontal application menu using native buttons and menu roles.", + markup: ` + + + + +`, + }, + { + name: "Message", + slug: "message", + element: "zen-message", + group: "Chat", + description: "An avatar, author, and content row for conversations.", + markup: ` + JP +
June

Can we deploy this release?

+
`, + }, + { + name: "Message Scroller", + slug: "message-scroller", + element: "zen-message-scroller", + group: "Chat", + description: "A chat viewport that follows new content until the reader scrolls away.", + markup: ` +
+

Worker connected.

Build started.

Bundle created.

Deployment complete.

+
+ +
`, + }, + { + name: "Native Select", + slug: "native-select", + element: "zen-native-select", + group: "Forms", + description: "A styled native select with full platform behavior.", + markup: ``, + }, + { + name: "Navigation Menu", + slug: "navigation-menu", + element: "zen-navigation-menu", + group: "Navigation", + description: "Top-level navigation with optional keyboard-accessible flyouts.", + markup: ` + +`, + }, + { + name: "Pagination", + slug: "pagination", + element: "zen-pagination", + group: "Navigation", + description: "Page navigation built from native links.", + markup: ` + +`, + }, + { + name: "Popover", + slug: "popover", + element: "zen-popover", + group: "Overlays", + description: "A lightweight panel using the native Popover API when available.", + markup: ` + +
Dimensions
+
`, + }, + { + name: "Progress", + slug: "progress", + element: "zen-progress", + group: "Feedback", + description: "A styled native progress element.", + markup: `68%`, + }, + { + name: "Radio Group", + slug: "radio-group", + element: "zen-radio-group", + group: "Forms", + description: "A fieldset of native radio inputs.", + markup: ` +
Visibility + + +
+
`, + }, + { + name: "Resizable", + slug: "resizable", + element: "zen-resizable", + group: "Layout", + description: "Two native panels separated by a pointer and keyboard handle.", + markup: ` +
Repository tree
+
+
File preview
+
`, + }, + { + name: "Scroll Area", + slug: "scroll-area", + element: "zen-scroll-area", + group: "Layout", + description: "A bounded native scroll container with theme-aware scrollbars.", + markup: ` +

default

feature/design-system

release/2026-08

experiment/trading

+
`, + }, + { + name: "Select", + slug: "select", + element: "zen-select", + group: "Forms", + description: "A native select styled as the default accessible implementation.", + markup: ``, + }, + { + name: "Separator", + slug: "separator", + element: "zen-separator", + group: "Layout", + description: "A native horizontal rule or ARIA separator.", + markup: `
Repository

Changesets
`, + }, + { + name: "Sheet", + slug: "sheet", + element: "zen-sheet", + group: "Overlays", + description: "A native dialog presented from a selected screen edge.", + markup: ` + +

Filters

+
`, + }, + { + name: "Sidebar", + slug: "sidebar", + element: "zen-sidebar", + group: "Navigation", + description: "A collapsible navigation region with an explicit native trigger.", + markup: ` + + +`, + }, + { + name: "Skeleton", + slug: "skeleton", + element: "zen-skeleton", + group: "Feedback", + description: "A reduced-motion-safe placeholder for pending content.", + markup: ` + + + +`, + }, + { + name: "Slider", + slug: "slider", + element: "zen-slider", + group: "Forms", + description: "A native range input with browser keyboard and pointer behavior.", + markup: ``, + }, + { + name: "Sonner", + slug: "sonner", + element: "zen-notifications", + group: "Feedback", + description: "Zenbu's bounded, scoped, Sonner-like notification stack.", + markup: ` + +`, + }, + { + name: "Spinner", + slug: "spinner", + element: "zen-spinner", + group: "Feedback", + description: "A small CSS loading indicator with status semantics.", + markup: ``, + }, + { + name: "Switch", + slug: "switch", + element: "zen-switch", + group: "Forms", + description: "A native checkbox presented with switch semantics.", + markup: ``, + }, + { + name: "Table", + slug: "table", + element: "zen-table", + group: "Data", + description: "Responsive styling around a native HTML table.", + markup: ` + + + +
Recent changesets
RevisionAuthor
253:fdf3816959cbJune
252:7a7581f040e8June
`, + }, + { + name: "Tabs", + slug: "tabs", + element: "zen-tabs", + group: "Disclosure", + description: "ARIA tabs with automatic or manual keyboard activation.", + markup: ` +
+ + + +
+
Browse repository files.
+
Inspect changesets.
+
View branch topology.
+
`, + }, + { + name: "Textarea", + slug: "textarea", + element: "zen-textarea", + group: "Forms", + description: "A styled native multiline text control.", + markup: ``, + }, + { + name: "Toast", + slug: "toast", + element: "zen-notifications", + group: "Feedback", + description: "The deprecated toast concept maps to zen-notifications.", + markup: `

Toast is deprecated. Use zen-notifications for scoped transient messages.

`, + }, + { + name: "Toggle", + slug: "toggle", + element: "zen-toggle", + group: "Actions", + description: "A native button with managed pressed state.", + markup: ``, + }, + { + name: "Toggle Group", + slug: "toggle-group", + element: "zen-toggle-group", + group: "Actions", + description: "Single or multiple pressed-state buttons with roving keyboard focus.", + markup: ` + + + +`, + }, + { + name: "Tooltip", + slug: "tooltip", + element: "zen-tooltip", + group: "Overlays", + description: "A delayed, non-interactive description for hover and focus.", + markup: ` + + +`, + }, + { + name: "Typography", + slug: "typography", + element: "zen-typography", + group: "Foundations", + description: "Readable defaults for native headings, paragraphs, lists, and code.", + markup: ` +

The Mercurial forge

+

Typography stays semantic and inherits Zenbu tokens.

+
Own the stack, understand the stack.
+
`, + }, +]; + +/** @const {number} Number of mapped shadcn component concepts. */ +export const shadcnComponentCount = componentCatalog.length; diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/alert.js --- a/design_system/src/components/alert.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/alert.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Presents a semantic status message with optional dismissal. + * + * @extends {HTMLElement} + */ export class ZenAlert extends HTMLElement { static get observedAttributes() { return ["dismissible", "tone"]; @@ -50,7 +55,9 @@ dismiss.dataset.zenDismiss = ""; dismiss.dataset.zenGenerated = "true"; dismiss.setAttribute("aria-label", "Dismiss message"); - dismiss.textContent = "×"; + const icon = document.createElement("zen-icon"); + icon.setAttribute("name", "close"); + dismiss.append(icon); this.append(dismiss); } if (dismiss === this._dismissControl) return; diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/button.js --- a/design_system/src/components/button.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/button.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Enhances a native button or link with disabled and loading states. + * + * @extends {HTMLElement} + */ export class ZenButton extends HTMLElement { static get observedAttributes() { return ["disabled", "loading"]; diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/card.js --- a/design_system/src/components/card.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/card.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Provides presentation for a native article or section. + * + * @extends {HTMLElement} + */ export class ZenCard extends HTMLElement {} if (!customElements.get("zen-card")) { diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/collections.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/collections.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,579 @@ +const HTMLElementBase = globalThis.HTMLElement || class {}; + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function originalAttribute(state, element, name) { + const attributes = state.get(element); + return attributes?.has(name) + ? attributes.get(name) + : element.getAttribute(name); +} + +function restoreElement(state, element) { + const attributes = state.get(element); + if (!attributes) return; + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + state.delete(element); +} + +function restoreAttributes(state) { + for (const element of [...state.keys()]) restoreElement(state, element); +} + +function pruneAttributes(state, retained) { + for (const element of [...state.keys()]) { + if (!retained.has(element)) restoreElement(state, element); + } +} + +function setDefaultAttribute(state, element, name, value) { + if (!element.hasAttribute(name) || state.get(element)?.has(name)) { + setManagedAttribute(state, element, name, value); + } +} + +function eventElement(event) { + const target = event.target; + if (target?.nodeType === 1) return target; + return target?.parentElement || null; +} + +function ownedElement(host, selector) { + return [...host.querySelectorAll(selector)].find( + element => element.closest(host.localName) === host, + ) || null; +} + +function emit(element, type, detail) { + element.dispatchEvent(new CustomEvent(type, { + bubbles: true, + detail, + })); +} + +function isDisabled(control) { + return Boolean(control?.disabled) || + control?.getAttribute("aria-disabled") === "true"; +} + +/** + * Enhances a native scroll-snap viewport with navigation controls. + * + * @extends {HTMLElement} + */ +export class ZenCarousel extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const control = eventElement(event)?.closest( + "[data-zen-prev], [data-zen-next]", + ); + if (control === this._prev && !isDisabled(control)) this.move(-1); + else if (control === this._next && !isDisabled(control)) this.move(1); + }; + this._onKeydown ||= event => { + if (event.altKey || event.ctrlKey || event.metaKey || + !["ArrowLeft", "ArrowRight"].includes(event.key)) { + return; + } + const target = eventElement(event); + if (target?.closest("input, textarea, select, [contenteditable]")) return; + event.preventDefault(); + this.move(event.key === "ArrowLeft" ? -1 : 1); + }; + this._onScroll ||= () => { + globalThis.clearTimeout(this._scrollTimer); + this._scrollTimer = globalThis.setTimeout(() => { + this._scrollTimer = null; + this.setIndex(this.currentIndex(), true); + }, 100); + }; + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + this._viewport?.removeEventListener("scroll", this._onScroll); + globalThis.clearTimeout(this._scrollTimer); + restoreAttributes(this._managed); + this._viewport = null; + this._slides = []; + this._prev = null; + this._next = null; + this._index = null; + this._scrollTimer = null; + } + + sync() { + const viewport = ownedElement(this, "[data-zen-viewport]"); + if (viewport !== this._viewport) { + this._viewport?.removeEventListener("scroll", this._onScroll); + this._viewport = viewport; + viewport?.addEventListener("scroll", this._onScroll, { passive: true }); + } + this._slides = viewport ? [...viewport.children] : []; + this._prev = ownedElement(this, "[data-zen-prev]"); + this._next = ownedElement(this, "[data-zen-next]"); + + const retained = new Set([ + this, + viewport, + this._prev, + this._next, + ...this._slides, + ].filter(Boolean)); + pruneAttributes(this._managed, retained); + setDefaultAttribute(this._managed, this, "role", "region"); + setDefaultAttribute( + this._managed, + this, + "aria-roledescription", + "carousel", + ); + setDefaultAttribute(this._managed, this, "aria-label", "Carousel"); + if (viewport) { + setDefaultAttribute(this._managed, viewport, "tabindex", "0"); + } + for (const [index, slide] of this._slides.entries()) { + setDefaultAttribute(this._managed, slide, "role", "group"); + setDefaultAttribute( + this._managed, + slide, + "aria-roledescription", + "slide", + ); + setDefaultAttribute( + this._managed, + slide, + "aria-label", + `Slide ${index + 1} of ${this._slides.length}`, + ); + } + + const index = this.currentIndex(); + this._index = index; + this.render(); + } + + currentIndex() { + if (!this._viewport || !this._slides.length) return 0; + const viewportLeft = this._viewport.getBoundingClientRect().left; + let closest = 0; + let distance = Infinity; + for (const [index, slide] of this._slides.entries()) { + const current = Math.abs(slide.getBoundingClientRect().left - viewportLeft); + if (current < distance) { + closest = index; + distance = current; + } + } + return closest; + } + + move(offset) { + if (!this._slides.length) return; + const current = Number.isInteger(this._index) + ? this._index + : this.currentIndex(); + this.scrollTo(Math.max(0, Math.min(this._slides.length - 1, current + offset))); + } + + scrollTo(index) { + const slide = this._slides[index]; + if (!this._viewport || !slide || index === this._index) return; + const viewportRect = this._viewport.getBoundingClientRect(); + const slideRect = slide.getBoundingClientRect(); + const left = this._viewport.scrollLeft + slideRect.left - viewportRect.left; + if (typeof this._viewport.scrollTo === "function") { + this._viewport.scrollTo({ left, behavior: "smooth" }); + } else if (typeof slide.scrollIntoView === "function") { + slide.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + }); + } + this.setIndex(index, true); + } + + setIndex(index, notify) { + const next = Math.max( + 0, + Math.min(this._slides.length ? this._slides.length - 1 : 0, index), + ); + if (next === this._index) { + this.render(); + return; + } + this._index = next; + this.render(); + if (notify) emit(this, "zen-change", { index: next }); + } + + render() { + const last = Math.max(0, this._slides.length - 1); + this.syncDisabled(this._prev, !this._slides.length || this._index <= 0); + this.syncDisabled( + this._next, + !this._slides.length || this._index >= last, + ); + } + + syncDisabled(control, disabled) { + if (!control) return; + const authored = originalAttribute(this._managed, control, "disabled"); + setManagedAttribute( + this._managed, + control, + "disabled", + disabled || authored !== null ? "" : null, + ); + } +} + +/** + * Keeps a message viewport pinned while the reader remains near its end. + * + * @extends {HTMLElement} + */ +export class ZenMessageScroller extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onScroll ||= () => { + this._nearEnd = this.isNearEnd(); + this.render(); + }; + this._onClick ||= event => { + const button = eventElement(event)?.closest("[data-zen-scroll-end]"); + if (button === this._button && !isDisabled(button)) this.scrollEnd(true); + }; + this._viewport = ownedElement(this, "[data-zen-viewport]") || this; + this._button = ownedElement(this, "[data-zen-scroll-end]"); + this._nearEnd = this.isNearEnd(); + this.addEventListener("click", this._onClick); + this._viewport.addEventListener("scroll", this._onScroll, { passive: true }); + this._observer ||= new MutationObserver(records => { + if (!records.some(record => record.addedNodes.length)) return; + const wasNearEnd = this._nearEnd; + if (wasNearEnd) this.scrollEnd(false); + else this.render(); + }); + this._observer.observe(this._viewport, { childList: true, subtree: true }); + this.render(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this._viewport?.removeEventListener("scroll", this._onScroll); + restoreAttributes(this._managed); + this._viewport = null; + this._button = null; + } + + isNearEnd() { + if (!this._viewport) return true; + return this._viewport.scrollHeight - this._viewport.scrollTop - + this._viewport.clientHeight <= 24; + } + + scrollEnd(smooth) { + if (!this._viewport) return; + if (typeof this._viewport.scrollTo === "function") { + this._viewport.scrollTo({ + top: this._viewport.scrollHeight, + behavior: smooth ? "smooth" : "auto", + }); + } else { + this._viewport.scrollTop = this._viewport.scrollHeight; + } + this._nearEnd = true; + this.render(); + } + + render() { + if (!this._button) return; + setManagedAttribute( + this._managed, + this._button, + "hidden", + this._nearEnd ? "" : null, + ); + } +} + +/** + * Adds pointer and keyboard resizing to two native content panels. + * + * @extends {HTMLElement} + */ +export class ZenResizable extends HTMLElementBase { + static get observedAttributes() { + return ["min", "max"]; + } + + connectedCallback() { + this._managed ||= new Map(); + this._onPointerDown ||= event => { + const handle = eventElement(event)?.closest("[data-zen-handle]"); + if (handle !== this._handle || (event.button !== 0 && event.button !== -1)) { + return; + } + event.preventDefault(); + this._pointerId = event.pointerId; + this._handle.setPointerCapture?.(event.pointerId); + }; + this._onPointerMove ||= event => { + if (event.pointerId !== this._pointerId) return; + const rect = this.getBoundingClientRect(); + if (!rect.width) return; + this.setPercent((event.clientX - rect.left) / rect.width * 100, true); + }; + this._onPointerEnd ||= event => { + if (event.pointerId !== this._pointerId) return; + if (this._handle?.hasPointerCapture?.(event.pointerId)) { + this._handle.releasePointerCapture(event.pointerId); + } + this._pointerId = null; + }; + this._onKeydown ||= event => { + const handle = eventElement(event)?.closest("[data-zen-handle]"); + if (handle !== this._handle || + !["ArrowLeft", "ArrowRight"].includes(event.key)) { + return; + } + event.preventDefault(); + const step = Number(this.getAttribute("step")); + const amount = Number.isFinite(step) && step > 0 ? step : 1; + this.setPercent( + this._percent + (event.key === "ArrowLeft" ? -amount : amount), + true, + ); + }; + this.addEventListener("pointerdown", this._onPointerDown); + this.addEventListener("pointermove", this._onPointerMove); + this.addEventListener("pointerup", this._onPointerEnd); + this.addEventListener("pointercancel", this._onPointerEnd); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("pointerdown", this._onPointerDown); + this.removeEventListener("pointermove", this._onPointerMove); + this.removeEventListener("pointerup", this._onPointerEnd); + this.removeEventListener("pointercancel", this._onPointerEnd); + this.removeEventListener("keydown", this._onKeydown); + restoreAttributes(this._managed); + this._first = null; + this._second = null; + this._handle = null; + this._pointerId = null; + this._percent = null; + } + + attributeChangedCallback() { + if (this.isConnected && Number.isFinite(this._percent)) { + this.setPercent(this._percent, false); + } + } + + limits() { + const minimum = this.getAttribute("min"); + const maximum = this.getAttribute("max"); + const min = minimum !== null && Number.isFinite(Number(minimum)) + ? Math.max(0, Math.min(100, Number(minimum))) + : 0; + const max = maximum !== null && Number.isFinite(Number(maximum)) + ? Math.max(min, Math.min(100, Number(maximum))) + : 100; + return { min, max }; + } + + sync() { + const children = [...this.children]; + const handle = children.find(child => child.hasAttribute("data-zen-handle")); + const handleIndex = children.indexOf(handle); + const first = children.slice(0, handleIndex).reverse().find( + child => child.hasAttribute("data-zen-panel"), + ); + const second = children.slice(handleIndex + 1).find( + child => child.hasAttribute("data-zen-panel"), + ); + + if (!first || !second || !handle) { + if (this._first) restoreElement(this._managed, this._first); + if (this._handle) restoreElement(this._managed, this._handle); + this._first = null; + this._second = null; + this._handle = null; + this._percent = null; + return; + } + if (this._first && this._first !== first) restoreElement(this._managed, this._first); + if (this._handle && this._handle !== handle) { + restoreElement(this._managed, this._handle); + } + this._first = first; + this._second = second; + this._handle = handle; + + setManagedAttribute(this._managed, this._handle, "role", "separator"); + setManagedAttribute( + this._managed, + this._handle, + "aria-orientation", + "vertical", + ); + setDefaultAttribute(this._managed, this._handle, "tabindex", "0"); + + if (!Number.isFinite(this._percent)) { + const containerWidth = this.getBoundingClientRect().width; + const rendered = containerWidth + ? this._first.getBoundingClientRect().width / containerWidth * 100 + : Number.parseFloat(this._first.style.flexBasis); + this._percent = Number.isFinite(rendered) ? rendered : 50; + } + this.setPercent(this._percent, false); + } + + setPercent(percent, notify) { + if (!this._first || !this._handle) return; + const { min, max } = this.limits(); + const next = Math.round(Math.max(min, Math.min(max, percent)) * 100) / 100; + const changed = next !== this._percent; + this._percent = next; + rememberAttribute(this._managed, this._first, "style"); + this._first.style.flexBasis = `${next}%`; + setManagedAttribute(this._managed, this._handle, "aria-valuemin", String(min)); + setManagedAttribute(this._managed, this._handle, "aria-valuemax", String(max)); + setManagedAttribute(this._managed, this._handle, "aria-valuenow", String(next)); + if (notify && changed) emit(this, "zen-resize", { percent: next }); + } +} + +/** + * Adds stable client-side sorting to a native HTML table. + * + * @extends {HTMLElement} + */ +export class ZenDataTable extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const button = eventElement(event)?.closest("button[data-zen-sort]"); + if (!this._buttons.includes(button) || isDisabled(button)) return; + this.sort(button); + }; + this.addEventListener("click", this._onClick); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + restoreAttributes(this._managed); + this._table = null; + this._buttons = []; + } + + sync() { + this._table = + this.querySelector(":scope > table") || this.querySelector("table"); + this._buttons = this._table + ? [...this._table.querySelectorAll("button[data-zen-sort]")].filter( + button => button.closest("table") === this._table && + button.closest("th"), + ) + : []; + pruneAttributes( + this._managed, + new Set(this._buttons.map(button => button.closest("th"))), + ); + } + + sort(button) { + const key = button.getAttribute("data-zen-sort"); + const header = button.closest("th"); + if (!this._table || !key || !header) return; + const direction = + header.getAttribute("aria-sort") === "ascending" + ? "descending" + : "ascending"; + + for (const candidate of this._buttons) { + const candidateHeader = candidate.closest("th"); + setManagedAttribute( + this._managed, + candidateHeader, + "aria-sort", + candidateHeader === header ? direction : null, + ); + } + + for (const body of this._table.tBodies) { + const entries = [...body.rows].map((row, index) => { + const cell = [...row.cells].find( + candidate => candidate.getAttribute("data-key") === key, + ); + return { index, row, value: cell?.textContent?.trim() ?? "" }; + }); + const numeric = entries.every( + entry => Number.isFinite(Number(entry.value)), + ); + const factor = direction === "ascending" ? 1 : -1; + entries.sort((left, right) => { + const compared = numeric + ? Number(left.value) - Number(right.value) + : left.value.localeCompare(right.value); + return compared ? compared * factor : left.index - right.index; + }); + body.append(...entries.map(entry => entry.row)); + } + emit(this, "zen-sort", { column: key, direction }); + } +} + +const definitions = { + "zen-carousel": ZenCarousel, + "zen-data-table": ZenDataTable, + "zen-message-scroller": ZenMessageScroller, + "zen-resizable": ZenResizable, +}; + +if (globalThis.customElements) { + for (const [name, definition] of Object.entries(definitions)) { + if (!globalThis.customElements.get(name)) { + globalThis.customElements.define(name, definition); + } + } +} diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/disclosure.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/disclosure.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,800 @@ +let nextDisclosureId = 1; + +const HTMLElementBase = globalThis.HTMLElement || class {}; + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) { + if (element.hasAttribute(name)) element.removeAttribute(name); + } else if (element.getAttribute(name) !== value) { + element.setAttribute(name, value); + } +} + +function restoreAttribute(state, element, name) { + const attributes = state.get(element); + if (!attributes?.has(name)) return; + const value = attributes.get(name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + attributes.delete(name); + if (!attributes.size) state.delete(element); +} + +function restoreElement(state, element) { + const attributes = state.get(element); + if (!attributes) return; + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + state.delete(element); +} + +function restoreAttributes(state) { + for (const element of [...state.keys()]) restoreElement(state, element); +} + +function pruneAttributes(state, retained) { + for (const element of [...state.keys()]) { + if (!retained.has(element)) restoreElement(state, element); + } +} + +function uniqueId(element, prefix) { + const document = element.ownerDocument; + let id; + do id = `${prefix}-${nextDisclosureId++}`; + while ([...document.querySelectorAll("[id]")].some(item => item.id === id)); + return id; +} + +function ensureId(state, element, prefix) { + const document = element.ownerDocument; + const duplicate = element.id && + [...document.querySelectorAll("[id]")].some( + item => item !== element && item.id === element.id, + ); + if (!element.id || duplicate) { + setManagedAttribute(state, element, "id", uniqueId(element, prefix)); + } + return element.id; +} + +function eventElement(event) { + const target = event.target; + if (target?.nodeType === 1) return target; + return target?.parentElement || null; +} + +function isDisabled(control) { + return Boolean(control.disabled) || + control.getAttribute("aria-disabled") === "true"; +} + +function emitChange(element, detail) { + element.dispatchEvent(new CustomEvent("zen-change", { + bubbles: true, + detail, + })); +} + +function ownedElements(host, selector) { + return [...host.querySelectorAll(selector)].filter( + element => element.closest(host.localName) === host, + ); +} + +function syncDisclosureIcon(details) { + const summary = details.querySelector(":scope > summary"); + if (!summary) return; + let icon = summary.querySelector( + ":scope > zen-icon[data-zen-disclosure-icon]", + ); + if (!icon) { + icon = document.createElement("zen-icon"); + icon.dataset.zenDisclosureIcon = ""; + icon.dataset.zenGenerated = "true"; + summary.append(icon); + } + if (!icon.hasAttribute("name")) icon.setAttribute("name", "chevron-down"); +} + +function removeGeneratedDisclosureIcons(host) { + for (const icon of host.querySelectorAll( + 'zen-icon[data-zen-disclosure-icon][data-zen-generated="true"]', + )) { + icon.remove(); + } +} + +/** + * Coordinates native details elements and summary keyboard navigation. + * + * @extends {HTMLElement} + */ +export class ZenAccordion extends HTMLElementBase { + static get observedAttributes() { + return ["multiple"]; + } + + connectedCallback() { + this._details ||= new Set(); + this._onToggle ||= event => { + const opened = event.currentTarget; + if (!opened.open || this.hasAttribute("multiple")) return; + for (const details of this._details) { + if (details !== opened) details.open = false; + } + }; + this._onKeydown ||= event => { + if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) { + return; + } + const summary = eventElement(event)?.closest("summary"); + const summaries = this.summaries(); + const index = summaries.indexOf(summary); + if (index < 0) return; + event.preventDefault(); + let next = event.key === "Home" ? 0 : summaries.length - 1; + if (event.key === "ArrowUp") { + next = (index - 1 + summaries.length) % summaries.length; + } else if (event.key === "ArrowDown") { + next = (index + 1) % summaries.length; + } + summaries[next]?.focus(); + }; + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("keydown", this._onKeydown); + for (const details of this._details || []) { + details.removeEventListener("toggle", this._onToggle); + } + removeGeneratedDisclosureIcons(this); + this._details?.clear(); + } + + attributeChangedCallback() { + if (this.isConnected && this._details) this.sync(); + } + + summaries() { + return [...this._details] + .map(details => details.querySelector(":scope > summary")) + .filter(Boolean); + } + + sync() { + const current = new Set( + ownedElements(this, "details").filter(details => { + const parent = details.parentElement?.closest("details"); + return !parent || parent.closest("zen-accordion") !== this; + }), + ); + for (const details of this._details) { + if (!current.has(details)) { + details.removeEventListener("toggle", this._onToggle); + } + } + for (const details of current) { + if (!this._details.has(details)) { + details.addEventListener("toggle", this._onToggle); + } + syncDisclosureIcon(details); + } + this._details = current; + + if (!this.hasAttribute("multiple")) { + const opened = [...current].filter(details => details.open); + for (const details of opened.slice(1)) details.open = false; + } + } +} + +/** + * Styles a single native details disclosure without replacing its behavior. + * + * @extends {HTMLElement} + */ +export class ZenCollapsible extends HTMLElementBase { + connectedCallback() { + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + removeGeneratedDisclosureIcons(this); + } + + sync() { + for (const details of ownedElements(this, "details")) { + syncDisclosureIcon(details); + } + } +} + +/** + * Connects an ARIA tablist with its native content panels. + * + * @extends {HTMLElement} + */ +export class ZenTabs extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const tab = eventElement(event)?.closest('[role="tab"]'); + if (!this.isTab(tab) || isDisabled(tab)) return; + this.activate(tab, true); + }; + this._onKeydown ||= event => { + const tab = eventElement(event)?.closest('[role="tab"]'); + if (!this.isTab(tab)) return; + const tabs = this._tabs.filter(candidate => !isDisabled(candidate)); + const index = tabs.indexOf(tab); + if (index < 0) return; + + let next; + if (event.key === "Home") next = tabs[0]; + else if (event.key === "End") next = tabs[tabs.length - 1]; + else if (event.key === "ArrowLeft") { + next = tabs[(index - 1 + tabs.length) % tabs.length]; + } else if (event.key === "ArrowRight") { + next = tabs[(index + 1) % tabs.length]; + } else { + return; + } + + event.preventDefault(); + this._focusTab = next; + this.render(); + next.focus(); + if (this.getAttribute("activation") !== "manual") { + this.activate(next, true); + } + }; + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { + attributeFilter: ["aria-controls", "aria-disabled", "disabled", "id"], + attributes: true, + childList: true, + subtree: true, + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + restoreAttributes(this._managed); + this._tablist = null; + this._tabs = []; + this._panels = []; + this._pairs = new Map(); + this._active = null; + this._focusTab = null; + } + + isTab(tab) { + return Boolean( + tab && this._tabs?.includes(tab) && this._tablist?.contains(tab), + ); + } + + sync() { + this._tablist = ownedElements(this, '[role="tablist"]')[0] || null; + this._tabs = this._tablist + ? [...this._tablist.querySelectorAll('[role="tab"]')].filter( + tab => tab.closest("zen-tabs") === this, + ) + : []; + this._panels = ownedElements(this, '[role="tabpanel"]'); + pruneAttributes( + this._managed, + new Set([...this._tabs, ...this._panels]), + ); + + for (const tab of this._tabs) { + ensureId(this._managed, tab, "zen-tab"); + } + for (const panel of this._panels) { + ensureId(this._managed, panel, "zen-tabpanel"); + } + + const available = new Set(this._panels); + this._pairs = new Map(); + for (const [index, tab] of this._tabs.entries()) { + const controlled = tab.getAttribute("aria-controls"); + let panel = this._panels.find( + candidate => available.has(candidate) && candidate.id === controlled, + ); + if (!panel && available.has(this._panels[index])) { + panel = this._panels[index]; + } + panel ||= [...available][0] || null; + if (panel) { + available.delete(panel); + this._pairs.set(tab, panel); + setManagedAttribute( + this._managed, + tab, + "aria-controls", + panel.id, + ); + setManagedAttribute( + this._managed, + panel, + "aria-labelledby", + tab.id, + ); + } else { + restoreAttribute(this._managed, tab, "aria-controls"); + } + } + for (const panel of available) { + restoreAttribute(this._managed, panel, "aria-labelledby"); + } + + if (!this._tabs.includes(this._active) || isDisabled(this._active)) { + this._active = this._tabs.find( + tab => !isDisabled(tab) && tab.getAttribute("aria-selected") === "true", + ) || this._tabs.find(tab => !isDisabled(tab)) || this._tabs[0] || null; + } + if (!this._tabs.includes(this._focusTab) || isDisabled(this._focusTab)) { + this._focusTab = this._active; + } + this.render(); + } + + render() { + for (const tab of this._tabs) { + const active = tab === this._active; + setManagedAttribute( + this._managed, + tab, + "aria-selected", + String(active), + ); + setManagedAttribute( + this._managed, + tab, + "tabindex", + tab === this._focusTab && !isDisabled(tab) ? "0" : "-1", + ); + } + const activePanel = this._pairs.get(this._active); + for (const panel of this._panels) { + setManagedAttribute( + this._managed, + panel, + "hidden", + panel === activePanel ? null : "", + ); + } + } + + activate(tab, notify) { + if (!this.isTab(tab) || isDisabled(tab)) return; + this._focusTab = tab; + if (tab === this._active) { + this.render(); + return; + } + this._active = tab; + this.render(); + if (notify) { + emitChange(this, { + value: tab.dataset.value ?? tab.getAttribute("value") ?? tab.id, + }); + } + } +} + +/** + * Manages the pressed state of one direct native button. + * + * @extends {HTMLElement} + */ +export class ZenToggle extends HTMLElementBase { + static get observedAttributes() { + return ["pressed"]; + } + + get pressed() { + return this.hasAttribute("pressed"); + } + + set pressed(value) { + this.toggleAttribute("pressed", Boolean(value)); + } + + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const button = eventElement(event)?.closest("button"); + if (button !== this._button || isDisabled(button)) return; + this.pressed = !this.pressed; + emitChange(this, { pressed: this.pressed }); + }; + this.addEventListener("click", this._onClick); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + restoreAttributes(this._managed); + this._button = null; + } + + attributeChangedCallback() { + if (this.isConnected && this._managed) this.sync(); + } + + sync() { + const button = this.querySelector(":scope > button"); + if (button !== this._button) { + if (this._button) restoreElement(this._managed, this._button); + this._button = button; + } + if (button) { + setManagedAttribute( + this._managed, + button, + "aria-pressed", + String(this.pressed), + ); + } + } +} + +function buttonValue(button) { + return button.getAttribute("value") ?? button.dataset.value ?? ""; +} + +function parseMultipleValue(value) { + if (!value) return []; + if (value.trim().startsWith("[")) { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { + // Fall through to token parsing. + } + } + return value.trim().split(/[\s,]+/).filter(Boolean); +} + +/** + * Manages single or multiple pressed buttons with roving focus. + * + * @extends {HTMLElement} + */ +export class ZenToggleGroup extends HTMLElementBase { + static get observedAttributes() { + return ["type", "value"]; + } + + get values() { + return [...(this._values || [])]; + } + + set values(values) { + this.setValues(Array.isArray(values) ? values.map(String) : [], false); + } + + connectedCallback() { + this._managed ||= new Map(); + this._buttons ||= []; + if (!this._initialized) { + this._values = this.initialValues(); + this._initialized = true; + } + this._hasConnected = true; + this._onClick ||= event => { + const button = eventElement(event)?.closest("button"); + if (!this._buttons.includes(button) || isDisabled(button)) return; + const value = buttonValue(button); + this._focusButton = button; + let values; + if (this.multiple) { + values = this._values.includes(value) + ? this._values.filter(item => item !== value) + : [...this._values, value]; + } else { + values = this._values.includes(value) ? [] : [value]; + } + this.setValues(values, true); + }; + this._onKeydown ||= event => { + const button = eventElement(event)?.closest("button"); + const buttons = this._buttons.filter(candidate => !isDisabled(candidate)); + const index = buttons.indexOf(button); + if (index < 0) return; + + let next; + if (event.key === "Home") next = buttons[0]; + else if (event.key === "End") next = buttons[buttons.length - 1]; + else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + next = buttons[(index - 1 + buttons.length) % buttons.length]; + } else if (event.key === "ArrowRight" || event.key === "ArrowDown") { + next = buttons[(index + 1) % buttons.length]; + } else { + return; + } + event.preventDefault(); + this._focusButton = next; + this.render(); + next.focus(); + }; + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { + attributeFilter: ["aria-disabled", "data-value", "disabled", "value"], + attributes: true, + childList: true, + subtree: true, + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + restoreAttributes(this._managed); + this._buttons = []; + this._focusButton = null; + } + + attributeChangedCallback(name) { + if (name === "value" && !this._settingValue && + this.hasAttribute("value")) { + this._values = this.readValue(); + this._initialized = true; + } else if (name === "value" && !this._settingValue) { + this._values = []; + this._initialized = true; + } else if (name === "type" && !this._hasConnected && + this.hasAttribute("value")) { + this._values = this.readValue(); + this._initialized = true; + } + if (this.isConnected && this._managed) this.sync(); + } + + get multiple() { + return this.getAttribute("type") === "multiple"; + } + + readValue() { + const value = this.getAttribute("value"); + if (value === null) return []; + return this.multiple ? parseMultipleValue(value) : [value]; + } + + initialValues() { + if (this.hasAttribute("value")) return this.readValue(); + const pressed = [...this.querySelectorAll(":scope > button")] + .filter(button => button.getAttribute("aria-pressed") === "true"); + return pressed.map(buttonValue); + } + + sync() { + const buttons = [...this.querySelectorAll(":scope > button")]; + pruneAttributes(this._managed, new Set([this, ...buttons])); + this._buttons = buttons; + if (!this.hasAttribute("role")) { + setManagedAttribute(this._managed, this, "role", "group"); + } + if (!this.multiple && this._values.length > 1) { + this._values = this._values.slice(0, 1); + this.writeValue(); + } + if (!buttons.includes(this._focusButton) || + isDisabled(this._focusButton)) { + this._focusButton = buttons.find( + button => + !isDisabled(button) && + button.getAttribute("tabindex") === "0", + ) || buttons.find( + button => + !isDisabled(button) && this._values.includes(buttonValue(button)), + ) || buttons.find(button => !isDisabled(button)) || null; + } + this.render(); + } + + render() { + for (const button of this._buttons) { + setManagedAttribute( + this._managed, + button, + "aria-pressed", + String(this._values.includes(buttonValue(button))), + ); + setManagedAttribute( + this._managed, + button, + "tabindex", + button === this._focusButton ? "0" : "-1", + ); + } + } + + writeValue() { + this._settingValue = true; + const value = this.multiple + ? (this._values.some(item => /[\s,]/.test(item)) + ? JSON.stringify(this._values) + : this._values.join(" ")) + : this._values[0]; + if (value === undefined || value === "") this.removeAttribute("value"); + else this.setAttribute("value", value); + this._settingValue = false; + } + + setValues(values, notify) { + const unique = [...new Set(values.map(String))]; + this._values = this.multiple ? unique : unique.slice(0, 1); + this.writeValue(); + if (this.isConnected) this.render(); + if (notify) emitChange(this, { values: this.values }); + } +} + +/** + * Controls a responsive native sidebar region and its trigger. + * + * @extends {HTMLElement} + */ +export class ZenSidebar extends HTMLElementBase { + static get observedAttributes() { + return ["open"]; + } + + get open() { + return this.hasAttribute("open"); + } + + set open(value) { + this.toggleAttribute("open", Boolean(value)); + } + + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const trigger = eventElement(event)?.closest( + "[data-zen-sidebar-trigger]", + ); + if (trigger !== this._trigger || isDisabled(trigger)) return; + this.setOpen(!this.open, true); + }; + this._onKeydown ||= event => { + if (event.key !== "Escape" || !this.open) return; + event.preventDefault(); + this.setOpen(false, true); + this._trigger?.focus(); + }; + this._onMediaChange ||= () => this.syncState(); + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this._media = globalThis.matchMedia?.("(max-width: 640px)") || null; + if (this._media?.addEventListener) { + this._media.addEventListener("change", this._onMediaChange); + } else { + this._media?.addListener?.(this._onMediaChange); + } + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + if (this._media?.removeEventListener) { + this._media.removeEventListener("change", this._onMediaChange); + } else { + this._media?.removeListener?.(this._onMediaChange); + } + restoreAttributes(this._managed); + this._trigger = null; + this._panel = null; + this._media = null; + } + + attributeChangedCallback() { + if (this.isConnected) this.syncState(); + } + + sync() { + const trigger = ownedElements( + this, + "[data-zen-sidebar-trigger]", + )[0] || null; + const panel = ownedElements( + this, + "[data-zen-sidebar-panel]", + )[0] || null; + if (trigger !== this._trigger || panel !== this._panel) { + if (this._trigger) restoreElement(this._managed, this._trigger); + if (this._panel) restoreElement(this._managed, this._panel); + this._trigger = trigger; + this._panel = panel; + } + if (!trigger || !panel) return; + const panelId = ensureId(this._managed, panel, "zen-sidebar-panel"); + setManagedAttribute( + this._managed, + trigger, + "aria-controls", + panelId, + ); + this.syncState(); + } + + syncState() { + if (!this._trigger || !this._panel) return; + setManagedAttribute( + this._managed, + this._panel, + "hidden", + this.open ? null : "", + ); + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(this.open), + ); + } + + setOpen(open, notify) { + if (this.open === open) return; + this.open = open; + this.syncState(); + if (notify) emitChange(this, { open }); + } +} + +const definitions = { + "zen-accordion": ZenAccordion, + "zen-collapsible": ZenCollapsible, + "zen-sidebar": ZenSidebar, + "zen-tabs": ZenTabs, + "zen-toggle": ZenToggle, + "zen-toggle-group": ZenToggleGroup, +}; + +const registry = globalThis.customElements; +if (registry) { + for (const [name, definition] of Object.entries(definitions)) { + if (!registry.get(name)) registry.define(name, definition); + } +} diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/field.js --- a/design_system/src/components/field.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/field.js Tue Aug 04 15:12:09 2026 -0700 @@ -9,6 +9,11 @@ return candidate; } +/** + * Associates a label, native form control, and descriptive help text. + * + * @extends {HTMLElement} + */ export class ZenField extends HTMLElement { connectedCallback() { if (!this._observer) { diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/forms.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/forms.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,1240 @@ +let nextFormId = 1; + +function uniqueId(prefix) { + let id; + do id = `${prefix}-${nextFormId++}`; + while (document.getElementById(id)); + return id; +} + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function restoreElement(state, element) { + const attributes = state.get(element); + if (!attributes) return; + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + state.delete(element); +} + +function restoreAttribute(state, element, name) { + const attributes = state.get(element); + if (!attributes?.has(name)) return; + const value = attributes.get(name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + attributes.delete(name); + if (!attributes.size) state.delete(element); +} + +function restoreAttributes(state) { + for (const element of [...state.keys()]) restoreElement(state, element); +} + +function originalAttribute(state, element, name) { + const attributes = state.get(element); + return attributes?.has(name) + ? attributes.get(name) + : element.getAttribute(name); +} + +function ensureId(state, element, prefix) { + const owner = element.id && document.getElementById(element.id); + if (!element.id || (owner && owner !== element)) { + setManagedAttribute(state, element, "id", uniqueId(prefix)); + } + return element.id; +} + +function emit(element, type, detail) { + element.dispatchEvent(new CustomEvent(type, { + bubbles: true, + detail, + })); +} + +class NativeControl extends HTMLElement { + connectedCallback() { + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this._control = null; + } + + sync() { + this._control = this.querySelector(this.controlSelector); + } +} + +/** + * Provides presentation around a native checkbox input. + * + * @extends {NativeControl} + */ +export class ZenCheckbox extends NativeControl { + get controlSelector() { + return 'input[type="checkbox"]'; + } +} + +/** + * Provides presentation around a native select control. + * + * @extends {NativeControl} + */ +export class ZenSelect extends NativeControl { + get controlSelector() { + return "select"; + } + + sync() { + super.sync(); + if (!this._control) return; + let icon = this.querySelector( + ":scope > zen-icon[data-zen-select-icon]", + ); + if (!icon) { + icon = document.createElement("zen-icon"); + icon.dataset.zenSelectIcon = ""; + icon.dataset.zenGenerated = "true"; + icon.setAttribute("name", "chevron-down"); + this.append(icon); + } + } + + disconnectedCallback() { + this.querySelector( + ':scope > zen-icon[data-zen-select-icon][data-zen-generated="true"]', + )?.remove(); + super.disconnectedCallback(); + } +} + +/** + * Provides presentation around a native range input. + * + * @extends {NativeControl} + */ +export class ZenSlider extends NativeControl { + get controlSelector() { + return 'input[type="range"]'; + } +} + +function createDate(year, month, day) { + const date = new Date(0); + date.setHours(0, 0, 0, 0); + date.setFullYear(year, month, day); + return date; +} + +function parseDate(value) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || ""); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const date = createDate(year, month, day); + if (date.getFullYear() !== year || + date.getMonth() !== month || + date.getDate() !== day) return null; + return date; +} + +function dateKey(date) { + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function addDays(date, amount) { + return createDate( + date.getFullYear(), + date.getMonth(), + date.getDate() + amount, + ); +} + +function addMonths(date, amount) { + const first = createDate(date.getFullYear(), date.getMonth() + amount, 1); + const last = createDate( + first.getFullYear(), + first.getMonth() + 1, + 0, + ).getDate(); + return createDate( + first.getFullYear(), + first.getMonth(), + Math.min(date.getDate(), last), + ); +} + +/** + * Renders a tokenized calendar backed by a native date input. + * + * @extends {HTMLElement} + */ +export class ZenCalendar extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => this.onClick(event); + this._onKeydown ||= event => this.onKeydown(event); + this._onInput ||= event => { + if (event.target !== this._input) return; + this.readInput(); + if (this._input.validity.valid) this.setInvalid(false); + this.render(); + }; + this._onInvalid ||= event => { + if (event.target !== this._input) return; + event.preventDefault(); + this.setInvalid(true); + queueMicrotask(() => { + if (!this.isConnected) return; + const target = this._trigger || + this._grid?.querySelector('[tabindex="0"]:not(:disabled)'); + target?.focus(); + }); + }; + this._onDocumentPointerDown ||= event => { + if (this.isPicker && this._open && !this.contains(event.target)) { + this.setOpen(false); + } + }; + this._onReset ||= () => queueMicrotask(() => { + if (!this.isConnected) return; + this.readInput(); + this.render(); + }); + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this.addEventListener("input", this._onInput); + this.addEventListener("change", this._onInput); + this.addEventListener("invalid", this._onInvalid, true); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this._inputObserver ||= new MutationObserver(() => { + this.readInput(); + this.render(); + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this._inputObserver?.disconnect(); + this._form?.removeEventListener("reset", this._onReset); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + this.removeEventListener("input", this._onInput); + this.removeEventListener("change", this._onInput); + this.removeEventListener("invalid", this._onInvalid, true); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + this.release(); + } + + get isPicker() { + return this.localName === "zen-date-picker"; + } + + sync() { + const input = [...this.children].find( + child => child.matches?.('input[type="date"]'), + ) || null; + if (input === this._input) return; + this.release(); + this._input = input; + if (!input) return; + + setManagedAttribute( + this._managed, + input, + "data-zen-calendar-input", + "", + ); + setManagedAttribute(this._managed, input, "tabindex", "-1"); + setManagedAttribute(this._managed, input, "aria-hidden", "true"); + this._inputObserver.observe(input, { + attributes: true, + attributeFilter: ["disabled", "max", "min", "readonly", "value"], + }); + this._form = input.form; + this._form?.addEventListener("reset", this._onReset); + this.readInput(); + this.createView(); + this.render(); + } + + release() { + this._inputObserver?.disconnect(); + this._form?.removeEventListener("reset", this._onReset); + this._generated?.remove(); + this._generated = null; + this._view = null; + this._grid = null; + this._heading = null; + this._trigger = null; + this._panel = null; + this._labelText = ""; + this._labelIds = []; + this._form = null; + this._open = false; + restoreAttributes(this._managed || new Map()); + this._input = null; + } + + readInput() { + const selected = parseDate(this._input?.value); + const today = new Date(); + this._selected = selected; + this._focusDate = selected || this._focusDate || today; + this._month = createDate( + (selected || this._focusDate).getFullYear(), + (selected || this._focusDate).getMonth(), + 1, + ); + } + + limits() { + return { + maximum: parseDate(this._input?.max), + minimum: parseDate(this._input?.min), + }; + } + + isEditable() { + return Boolean(this._input) && + !this._input.disabled && + !this._input.readOnly; + } + + isAllowed(date) { + if (!date) return false; + const { maximum, minimum } = this.limits(); + return (!minimum || date >= minimum) && (!maximum || date <= maximum); + } + + clampDate(date) { + const { maximum, minimum } = this.limits(); + if (minimum && date < minimum) return minimum; + if (maximum && date > maximum) return maximum; + return date; + } + + setInvalid(invalid) { + setManagedAttribute( + this._managed, + this, + "data-invalid", + invalid ? "" : null, + ); + if (this._trigger) { + this._trigger.setAttribute("aria-invalid", String(invalid)); + } + if (this._grid) { + this._grid.setAttribute("aria-invalid", String(invalid)); + } + } + + createIcon(name) { + const icon = document.createElement("zen-icon"); + icon.setAttribute("name", name); + return icon; + } + + createControl(name, label) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset[name] = ""; + button.setAttribute("aria-label", label); + return button; + } + + createView() { + const generated = document.createElement("div"); + generated.dataset.zenGenerated = "calendar"; + this._generated = generated; + const labels = [...this._input.labels]; + const authoredLabelIds = (this._input.getAttribute("aria-labelledby") || "") + .split(/\s+/) + .filter(id => id && document.getElementById(id)); + const associatedLabelIds = labels.map(label => + ensureId(this._managed, label, "zen-date-label") + ); + this._labelIds = authoredLabelIds.length + ? authoredLabelIds + : associatedLabelIds; + this._labelText = this._labelIds.map(id => + document.getElementById(id)?.textContent.trim() + ) + .filter(Boolean) + .join(", ") || + labels.map(label => label.textContent.trim()) + .filter(Boolean) + .join(", ") || + this._input.getAttribute("aria-label") || + "Date"; + + if (this.isPicker) { + const trigger = this.createControl( + "zenDateTrigger", + "Choose date", + ); + trigger.id = uniqueId("zen-date-picker-trigger"); + trigger.append(this.createIcon("calendar")); + const triggerText = document.createElement("span"); + triggerText.dataset.zenDateValue = ""; + triggerText.id = uniqueId("zen-date-picker-value"); + trigger.append(triggerText); + this._trigger = trigger; + + const panel = document.createElement("div"); + panel.dataset.zenCalendarPanel = ""; + panel.id = uniqueId("zen-date-picker-panel"); + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", `${this._labelText} calendar`); + panel.hidden = true; + trigger.setAttribute("aria-haspopup", "dialog"); + trigger.setAttribute("aria-controls", panel.id); + if (this._input.hasAttribute("aria-describedby")) { + trigger.setAttribute( + "aria-describedby", + this._input.getAttribute("aria-describedby"), + ); + } + if (this._labelIds.length) { + trigger.setAttribute( + "aria-labelledby", + [...this._labelIds, triggerText.id].join(" "), + ); + } + for (const label of labels) { + if (label.htmlFor === this._input.id) { + setManagedAttribute(this._managed, label, "for", trigger.id); + } + } + this._panel = panel; + generated.append(trigger, panel); + } + + const view = document.createElement("div"); + view.dataset.zenCalendarView = ""; + const header = document.createElement("header"); + const previous = this.createControl( + "zenCalendarPrevious", + "Previous month", + ); + previous.append(this.createIcon("chevron-left")); + const heading = document.createElement("strong"); + heading.setAttribute("aria-live", "polite"); + const next = this.createControl("zenCalendarNext", "Next month"); + next.append(this.createIcon("chevron-right")); + header.append(previous, heading, next); + + const weekdays = document.createElement("div"); + weekdays.dataset.zenCalendarWeekdays = ""; + weekdays.setAttribute("aria-hidden", "true"); + const formatter = new Intl.DateTimeFormat(undefined, { + weekday: "narrow", + }); + for (let day = 4; day < 11; day++) { + const label = document.createElement("span"); + label.textContent = formatter.format(new Date(2026, 0, day)); + weekdays.append(label); + } + + const grid = document.createElement("div"); + grid.dataset.zenCalendarGrid = ""; + grid.setAttribute("role", "grid"); + grid.setAttribute( + "aria-label", + this._labelText ? `${this._labelText} calendar` : "Calendar", + ); + view.append(header, weekdays, grid); + this._view = view; + this._heading = heading; + this._grid = grid; + (this._panel || generated).append(view); + this.append(generated); + if (this.isPicker) this.setOpen(false); + } + + render() { + if (!this._grid || !this._month) return; + this._focusDate = this.clampDate(this._focusDate); + this._heading.textContent = new Intl.DateTimeFormat(undefined, { + month: "long", + year: "numeric", + }).format(this._month); + + if (this._trigger) { + const text = this._trigger.querySelector("[data-zen-date-value]"); + text.textContent = this._selected + ? new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + }).format(this._selected) + : "Choose date"; + if (this._labelIds.length) { + this._trigger.removeAttribute("aria-label"); + } else { + this._trigger.setAttribute( + "aria-label", + `${this._labelText}, ${text.textContent}`, + ); + } + this._trigger.disabled = !this.isEditable(); + } + for (const control of this._view.querySelectorAll( + "[data-zen-calendar-previous], [data-zen-calendar-next]", + )) { + control.disabled = !this.isEditable(); + } + + const first = createDate( + this._month.getFullYear(), + this._month.getMonth(), + 1, + ); + const start = addDays(first, -first.getDay()); + const { maximum, minimum } = this.limits(); + const today = dateKey(new Date()); + const selected = this._selected ? dateKey(this._selected) : ""; + const focused = dateKey(this._focusDate); + const fragment = document.createDocumentFragment(); + const formatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "full", + }); + + for (let week = 0; week < 6; week++) { + const row = document.createElement("div"); + row.setAttribute("role", "row"); + for (let day = 0; day < 7; day++) { + const date = addDays(start, week * 7 + day); + const key = dateKey(date); + const button = document.createElement("button"); + button.type = "button"; + button.dataset.zenCalendarDay = key; + button.setAttribute("role", "gridcell"); + button.setAttribute("aria-label", formatter.format(date)); + button.setAttribute("aria-selected", String(key === selected)); + const disabled = !this.isEditable() || + Boolean((minimum && date < minimum) || (maximum && date > maximum)); + button.tabIndex = key === focused && !disabled ? 0 : -1; + button.textContent = String(date.getDate()); + button.toggleAttribute( + "data-outside", + date.getMonth() !== this._month.getMonth(), + ); + button.toggleAttribute("data-today", key === today); + button.disabled = disabled; + row.append(button); + } + fragment.append(row); + } + this._grid.replaceChildren(fragment); + } + + onClick(event) { + const target = event.target instanceof Element ? event.target : null; + if (!target) return; + if (target.closest("[data-zen-date-trigger]") === this._trigger) { + if (!this.isEditable()) return; + this.setOpen(!this._open); + return; + } + if (target.closest("[data-zen-calendar-previous]")) { + if (!this.isEditable()) return; + this.changeMonth(-1); + return; + } + if (target.closest("[data-zen-calendar-next]")) { + if (!this.isEditable()) return; + this.changeMonth(1); + return; + } + const day = target.closest("[data-zen-calendar-day]"); + if (day && !day.disabled) this.selectDate(parseDate(day.dataset.zenCalendarDay)); + } + + onKeydown(event) { + if (event.key === "Escape" && this.isPicker && this._open) { + event.preventDefault(); + this.setOpen(false); + this._trigger.focus(); + return; + } + const day = event.target instanceof Element + ? event.target.closest("[data-zen-calendar-day]") + : null; + if (!day) return; + const current = parseDate(day.dataset.zenCalendarDay); + let next = null; + if (event.key === "ArrowLeft") next = addDays(current, -1); + else if (event.key === "ArrowRight") next = addDays(current, 1); + else if (event.key === "ArrowUp") next = addDays(current, -7); + else if (event.key === "ArrowDown") next = addDays(current, 7); + else if (event.key === "Home") next = addDays(current, -current.getDay()); + else if (event.key === "End") next = addDays(current, 6 - current.getDay()); + else if (event.key === "PageUp") next = addMonths(current, -1); + else if (event.key === "PageDown") next = addMonths(current, 1); + if (!next) return; + next = this.clampDate(next); + event.preventDefault(); + this._focusDate = next; + this._month = createDate(next.getFullYear(), next.getMonth(), 1); + this.render(); + this._grid.querySelector( + `[data-zen-calendar-day="${dateKey(next)}"]`, + )?.focus(); + } + + changeMonth(amount) { + if (!this.isEditable()) return; + this._month = addMonths(this._month, amount); + this._focusDate = this.clampDate(createDate( + this._month.getFullYear(), + this._month.getMonth(), + 1, + )); + this._month = createDate( + this._focusDate.getFullYear(), + this._focusDate.getMonth(), + 1, + ); + this.render(); + this._grid.querySelector('[tabindex="0"]')?.focus(); + } + + selectDate(date) { + if (!this.isEditable() || !this.isAllowed(date)) return; + this._selected = date; + this._focusDate = date; + this._month = createDate(date.getFullYear(), date.getMonth(), 1); + this._input.value = dateKey(date); + this._input.dispatchEvent(new Event("input", { + bubbles: true, + composed: true, + })); + this._input.dispatchEvent(new Event("change", { bubbles: true })); + emit(this, "zen-change", { value: this._input.value }); + this.render(); + if (this.isPicker) { + this.setOpen(false); + this._trigger.focus(); + } + } + + setOpen(open) { + if (!this.isPicker || !this._panel || !this._trigger) return; + this._open = Boolean(open) && this.isEditable(); + this._panel.hidden = !this._open; + this._trigger.setAttribute("aria-expanded", String(this._open)); + if (this._open) { + this._grid.querySelector('[tabindex="0"]')?.focus(); + } + } +} + +/** + * Presents the first-party calendar behind a tokenized disclosure trigger. + * + * @extends {ZenCalendar} + */ +export class ZenDatePicker extends ZenCalendar {} + +/** + * Adds radiogroup semantics when no native fieldset owns the radios. + * + * @extends {HTMLElement} + */ +export class ZenRadioGroup extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + restoreAttributes(this._managed); + } + + sync() { + const radios = this.querySelectorAll('input[type="radio"]'); + const needsRole = radios.length > 0 && !this.querySelector("fieldset"); + if (needsRole && !this.hasAttribute("role")) { + setManagedAttribute(this._managed, this, "role", "radiogroup"); + } else if (!needsRole) { + restoreElement(this._managed, this); + } + } +} + +/** + * Synchronizes switch semantics onto a native checkbox. + * + * @extends {HTMLElement} + */ +export class ZenSwitch extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._onStateChange ||= event => { + if (event.target === this._control) this.updateState(); + }; + this._onReset ||= () => { + queueMicrotask(() => { + if (this.isConnected) this.updateState(); + }); + }; + this.addEventListener("input", this._onStateChange); + this.addEventListener("change", this._onStateChange); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("input", this._onStateChange); + this.removeEventListener("change", this._onStateChange); + this._form?.removeEventListener("reset", this._onReset); + restoreAttributes(this._managed); + this._control = null; + this._form = null; + } + + sync() { + const control = this.querySelector('input[type="checkbox"]'); + const form = control?.form || null; + if (control !== this._control) { + if (this._control) restoreElement(this._managed, this._control); + this._control = control; + } + if (form !== this._form) { + this._form?.removeEventListener("reset", this._onReset); + this._form = form; + form?.addEventListener("reset", this._onReset); + } + this.updateState(); + } + + updateState() { + if (!this._control) return; + setManagedAttribute(this._managed, this._control, "role", "switch"); + setManagedAttribute( + this._managed, + this._control, + "aria-checked", + String(this._control.checked), + ); + } +} + +/** + * Reflects native form validity on the component boundary. + * + * @extends {HTMLElement} + */ +export class ZenForm extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._syncValidity ||= () => { + if (!this._form) return; + setManagedAttribute( + this._managed, + this, + "data-invalid", + this._form.checkValidity() ? null : "", + ); + }; + this._markInvalid ||= () => { + setManagedAttribute(this._managed, this, "data-invalid", ""); + }; + this._onReset ||= () => queueMicrotask(this._syncValidity); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.releaseForm(); + restoreAttributes(this._managed); + } + + releaseForm() { + this._form?.removeEventListener("submit", this._syncValidity); + this._form?.removeEventListener("input", this._syncValidity); + this._form?.removeEventListener("invalid", this._markInvalid, true); + this._form?.removeEventListener("reset", this._onReset); + this._form = null; + } + + sync() { + const form = this.querySelector("form"); + if (form === this._form) return; + this.releaseForm(); + restoreElement(this._managed, this); + this._form = form; + form?.addEventListener("submit", this._syncValidity); + form?.addEventListener("input", this._syncValidity); + form?.addEventListener("invalid", this._markInvalid, true); + form?.addEventListener("reset", this._onReset); + } +} + +class FilterableList extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._active = null; + this._onInput ||= event => { + if (event.target !== this._input) return; + if (this.isCombobox) this.setOpen(true); + this.filter(); + }; + this._onKeydown ||= event => this.onKeydown(event); + this._onClick ||= event => this.onClick(event); + this._onFocus ||= event => { + if (this.isCombobox && event.target === this._input) { + this.setOpen(true); + this.filter(); + } + }; + this.addEventListener("input", this._onInput); + this.addEventListener("keydown", this._onKeydown); + this.addEventListener("click", this._onClick); + this.addEventListener("focusin", this._onFocus); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("input", this._onInput); + this.removeEventListener("keydown", this._onKeydown); + this.removeEventListener("click", this._onClick); + this.removeEventListener("focusin", this._onFocus); + restoreAttributes(this._managed); + this._input = null; + this._list = null; + this._items = []; + this._active = null; + } + + get isCombobox() { + return false; + } + + get listRole() { + return "menu"; + } + + get itemRole() { + return "menuitem"; + } + + sync() { + const input = this.querySelector("input"); + const list = this.querySelector(`[role="${this.listRole}"]`); + if (input !== this._input || list !== this._list) { + restoreAttributes(this._managed); + this._input = input; + this._list = list; + this._items = []; + this._active = null; + this._open = false; + } + if (!input || !list) return; + + const items = [...list.querySelectorAll(`[role="${this.itemRole}"]`)]; + for (const item of this._items) { + if (!items.includes(item)) restoreElement(this._managed, item); + } + this._items = items; + + const listId = ensureId( + this._managed, + list, + this.isCombobox ? "zen-listbox" : "zen-command-menu", + ); + if (!input.hasAttribute("aria-controls")) { + setManagedAttribute(this._managed, input, "aria-controls", listId); + } + if (this.isCombobox) { + if (!input.hasAttribute("role")) { + setManagedAttribute(this._managed, input, "role", "combobox"); + } + if (!input.hasAttribute("aria-autocomplete")) { + setManagedAttribute( + this._managed, + input, + "aria-autocomplete", + "list", + ); + } + this.setOpen(this._open); + } + this.filter(); + } + + filter() { + if (!this._input || !this._list) return; + const query = this._input.value.trim().toLowerCase(); + for (const item of this._items) { + const originallyHidden = + originalAttribute(this._managed, item, "hidden") !== null; + const matches = item.textContent.toLowerCase().includes(query); + setManagedAttribute( + this._managed, + item, + "hidden", + originallyHidden || !matches ? "" : null, + ); + } + if (this._active?.hidden) this.setActive(null); + } + + visibleItems() { + return this._items.filter(item => { + if (item.hidden || item.closest("[hidden]")) return false; + if (item.getAttribute("aria-disabled") === "true") return false; + return !("disabled" in item && item.disabled); + }); + } + + setActive(item) { + this._active = item; + for (const candidate of this._items) { + if (this.isCombobox) { + setManagedAttribute( + this._managed, + candidate, + "aria-selected", + String(candidate === item), + ); + } else { + restoreAttribute(this._managed, candidate, "aria-selected"); + } + } + if (!this._input) return; + if (item) { + setManagedAttribute( + this._managed, + this._input, + "aria-activedescendant", + ensureId(this._managed, item, "zen-option"), + ); + item.scrollIntoView?.({ block: "nearest" }); + } else { + setManagedAttribute( + this._managed, + this._input, + "aria-activedescendant", + null, + ); + } + } + + moveActive(offset) { + const items = this.visibleItems(); + if (!items.length) { + this.setActive(null); + return; + } + const current = items.indexOf(this._active); + const index = current < 0 + ? (offset > 0 ? 0 : items.length - 1) + : (current + offset + items.length) % items.length; + this.setActive(items[index]); + } + + onKeydown(event) { + if (event.target !== this._input) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (this.isCombobox) this.setOpen(true); + this.moveActive(event.key === "ArrowDown" ? 1 : -1); + return; + } + if (event.key === "Enter") { + const item = this._active || this.visibleItems()[0]; + if (!item) return; + event.preventDefault(); + this.choose(item); + } + } + + onClick(event) { + const target = event.target instanceof Element + ? event.target.closest(`[role="${this.itemRole}"]`) + : null; + if (!target || !this._list?.contains(target) || target.hidden) return; + if (target.getAttribute("aria-disabled") === "true" || + ("disabled" in target && target.disabled)) return; + this.choose(target); + } + + valueFor(item) { + return item.dataset.value ?? item.getAttribute("value") ?? + item.textContent.trim(); + } +} + +/** + * Filters and selects native option elements from a text input. + * + * @extends {FilterableList} + */ +export class ZenCombobox extends FilterableList { + get isCombobox() { + return true; + } + + get listRole() { + return "listbox"; + } + + get itemRole() { + return "option"; + } + + setOpen(open) { + this._open = Boolean(open); + if (!this._input || !this._list) return; + setManagedAttribute( + this._managed, + this._input, + "aria-expanded", + String(this._open), + ); + setManagedAttribute( + this._managed, + this._list, + "hidden", + this._open ? null : "", + ); + if (!this._open) this.setActive(null); + } + + onKeydown(event) { + if (event.target === this._input && event.key === "Escape") { + if (this._open) event.preventDefault(); + this.setOpen(false); + return; + } + super.onKeydown(event); + } + + choose(item) { + const value = this.valueFor(item); + this._input.value = value; + this._input.dispatchEvent(new Event("input", { + bubbles: true, + composed: true, + })); + this._input.dispatchEvent(new Event("change", { bubbles: true })); + emit(this, "zen-change", { value }); + this.setOpen(false); + } +} + +/** + * Filters a keyboard-oriented command menu and emits selected commands. + * + * @extends {FilterableList} + */ +export class ZenCommand extends FilterableList { + choose(item) { + emit(this, "zen-command", { value: this.valueFor(item) }); + } +} + +/** + * Enhances one native one-time-code input with visual slots. + * + * @extends {HTMLElement} + */ +export class ZenInputOtp extends HTMLElement { + connectedCallback() { + this._managed ||= new Map(); + this._complete ??= false; + this._onInput ||= event => { + if (event.target === this._input) this.update(true); + }; + this.addEventListener("input", this._onInput); + if (!this._observer) { + this._observer = new MutationObserver(() => this.sync()); + } + this._observer.observe(this, { + attributes: true, + attributeFilter: ["allow", "maxlength", "pattern"], + childList: true, + subtree: true, + }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("input", this._onInput); + const input = this._input; + const complete = this._complete; + this.release(); + this._previousInput = input; + this._complete = complete; + } + + release() { + restoreAttributes(this._managed); + if (this._slots && this._originalSlots) { + this._slots.replaceChildren(...this._originalSlots); + } + this._input = null; + this._slots = null; + this._originalSlots = null; + this._complete = false; + } + + sync() { + const inputs = [...this.querySelectorAll("input")]; + const input = inputs.length === 1 ? inputs[0] : null; + const slots = this.querySelector("[data-zen-otp-slots]"); + if (input !== this._input || slots !== this._slots) { + const remainedComplete = + (input === this._input || input === this._previousInput) && + this._complete; + this.release(); + this._input = input; + this._slots = slots; + this._originalSlots = slots ? [...slots.childNodes] : null; + this._complete = remainedComplete; + this._previousInput = null; + } + if (!this._input) return; + + if (!this._input.hasAttribute("maxlength")) { + setManagedAttribute(this._managed, this._input, "maxlength", "6"); + } + if (this.numericOnly() && !this._input.hasAttribute("inputmode")) { + setManagedAttribute(this._managed, this._input, "inputmode", "numeric"); + } else if (!this.numericOnly()) { + restoreAttribute(this._managed, this._input, "inputmode"); + } + this.update(true); + } + + numericOnly() { + return !this.hasAttribute("allow") && + !this.hasAttribute("pattern") && + !this._input.hasAttribute("allow") && + !this._input.hasAttribute("pattern"); + } + + length() { + const value = Number.parseInt(this._input.getAttribute("maxlength"), 10); + return Number.isFinite(value) && value > 0 ? value : 6; + } + + update(notify) { + const length = this.length(); + const before = this._input.value; + let value = this.numericOnly() ? before.replace(/\D/g, "") : before; + value = [...value].slice(0, length).join(""); + if (value !== before) { + const selection = this._input.selectionStart; + this._input.value = value; + if (selection !== null) { + const preceding = before.slice(0, selection); + const position = this.numericOnly() + ? preceding.replace(/\D/g, "").length + : [...preceding].slice(0, length).join("").length; + try { + this._input.setSelectionRange(position, position); + } catch { + // Some native input types do not expose text selection. + } + } + } + this.renderSlots(value, length); + + const complete = [...value].length === length; + if (notify && complete && !this._complete) { + emit(this, "zen-complete", { value }); + } + this._complete = complete; + } + + renderSlots(value, length) { + if (!this._slots) return; + const characters = [...value]; + const current = [...this._slots.children]; + const unchanged = current.length === length && + current.every((slot, index) => + slot.localName === "span" && + slot.textContent === (characters[index] || "")); + if (unchanged) return; + + const fragment = document.createDocumentFragment(); + for (let index = 0; index < length; index++) { + const slot = document.createElement("span"); + slot.textContent = characters[index] || ""; + fragment.append(slot); + } + this._slots.replaceChildren(fragment); + } +} + +const definitions = { + "zen-calendar": ZenCalendar, + "zen-checkbox": ZenCheckbox, + "zen-combobox": ZenCombobox, + "zen-command": ZenCommand, + "zen-date-picker": ZenDatePicker, + "zen-form": ZenForm, + "zen-input-otp": ZenInputOtp, + "zen-radio-group": ZenRadioGroup, + "zen-select": ZenSelect, + "zen-slider": ZenSlider, + "zen-switch": ZenSwitch, +}; + +if (globalThis.customElements) { + for (const [name, definition] of Object.entries(definitions)) { + if (!customElements.get(name)) customElements.define(name, definition); + } +} diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/icon.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/icon.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,109 @@ +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + +const paths = { + add: ["M12 5v14", "M5 12h14"], + alert: ["M12 3 2.8 20h18.4L12 3Z", "M12 9v5", "M12 17h.01"], + archive: ["M4 7h16v13H4Z", "M3 3h18v4H3Z", "M9 11h6"], + "arrow-down": ["M12 4v16", "m6 14-6 6-6-6"], + "arrow-left": ["M20 12H4", "m10 6-6-6 6-6"], + "arrow-right": ["M4 12h16", "m-6-6 6 6-6 6"], + "arrow-up": ["M12 20V4", "m-6 6 6-6 6 6"], + check: ["m5 12 4 4L19 6"], + "chevron-down": ["m5 9 7 7 7-7"], + "chevron-left": ["m15 5-7 7 7 7"], + "chevron-right": ["m9 5 7 7-7 7"], + "chevron-up": ["m5 15 7-7 7 7"], + close: ["M6 6l12 12", "M18 6 6 18"], + code: ["m9 6-6 6 6 6", "m6-12 6 6-6 6"], + copy: ["M8 8h12v12H8Z", "M4 16V4h12"], + download: ["M12 3v12", "m7-7 5 5 5-5", "M5 21h14"], + error: ["M5 5l14 14", "M19 5 5 19"], + external: ["M14 5h5v5", "m19 5-8 8", "M19 13v6H5V5h6"], + file: ["M6 3h8l4 4v14H6Z", "M14 3v5h4"], + folder: ["M3 6h7l2 2h9v12H3Z"], + branch: ["M7 5v10a4 4 0 0 0 4 4h6", "M7 9h7a3 3 0 0 0 3-3V5"], + calendar: ["M5 4h14v17H5Z", "M8 2v4", "M16 2v4", "M5 9h14"], + history: ["M4 5v5h5", "M5 9a8 8 0 1 0 2-4", "M12 7v5l3 2"], + info: ["M12 11v6", "M12 7h.01", "M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"], + menu: ["M4 6h16", "M4 12h16", "M4 18h16"], + moon: ["M20 15a8 8 0 0 1-11-11 9 9 0 1 0 11 11Z"], + more: ["M5 12h.01", "M12 12h.01", "M19 12h.01"], + pause: ["M8 5v14", "M16 5v14"], + play: ["m8 5 11 7-11 7Z"], + repository: ["M4 4h13a3 3 0 0 1 3 3v13H7a3 3 0 0 1-3-3Z", "M8 4v16"], + retry: ["M4 4v6h6", "M5 9a8 8 0 1 1 2 8"], + search: ["M4 11a7 7 0 1 0 14 0 7 7 0 1 0-14 0", "m16 16 4 4"], + settings: [ + "M9.5 4.2 10 2h4l.5 2.2 2 .8 2-1.2 2.8 2.8-1.2 2 .8 2L22 10v4l-2.2.5-.8 2 1.2 2-2.8 2.8-2-1.2-2 .8-.5 2.2h-4L9.5 20l-2-.8-2 1.2-2.8-2.8 1.2-2-.8-2L2 14v-4l2.2-.5.8-2-1.2-2 2.8-2.8 2 1.2Z", + "M9 12a3 3 0 1 0 6 0 3 3 0 1 0-6 0", + ], + sun: ["M12 2v3", "M12 19v3", "M2 12h3", "M19 12h3", "m5 5 2 2", "m17 17 2 2", "m19 5-2 2", "M7 17l-2 2", "M8 12a4 4 0 1 0 8 0 4 4 0 1 0-8 0"], + trash: ["M4 7h16", "M9 3h6l1 4H8Z", "m6 7 1 14h10l1-14", "M10 11v6", "M14 11v6"], + upload: ["M12 21V9", "m5 14 5-5 5 5", "M5 3h14"], + user: ["M8 8a4 4 0 1 0 8 0 4 4 0 1 0-8 0", "M4 21a8 8 0 0 1 16 0"], +}; + +/** @const {!ReadonlyArray} Names in the first-party icon registry. */ +export const ICON_NAMES = Object.freeze(Object.keys(paths).sort()); + +function safeSize(value) { + if (/^\d+(?:\.\d+)?(?:px|rem|em)?$/.test(value || "")) return value; + return "1em"; +} + +/** + * Renders validated first-party SVG geometry with inherited color. + * + * @extends {HTMLElement} + */ +export class ZenIcon extends HTMLElement { + static get observedAttributes() { + return ["label", "name", "size"]; + } + + connectedCallback() { + this.render(); + } + + attributeChangedCallback() { + if (this.isConnected) this.render(); + } + + render() { + const requested = this.getAttribute("name") || "info"; + const known = Object.hasOwn(paths, requested); + const definition = known ? paths[requested] : paths.alert; + const svg = document.createElementNS(SVG_NAMESPACE, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("focusable", "false"); + + for (const data of definition) { + const path = document.createElementNS(SVG_NAMESPACE, "path"); + path.setAttribute("d", data); + svg.append(path); + } + + const label = this.getAttribute("label"); + if (label) { + this.setAttribute("role", "img"); + this.setAttribute("aria-label", label); + this.removeAttribute("aria-hidden"); + } else { + this.removeAttribute("role"); + this.removeAttribute("aria-label"); + this.setAttribute("aria-hidden", "true"); + } + this.dataset.zenIcon = known ? requested : "alert"; + this.style.setProperty("--zen-icon-size", safeSize(this.getAttribute("size"))); + this.replaceChildren(svg); + } +} + +if (!customElements.get("zen-icon")) { + customElements.define("zen-icon", ZenIcon); +} diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/index.js --- a/design_system/src/components/index.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/index.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,11 +1,56 @@ export { ZenAlert } from "./alert.js"; export { ZenButton } from "./button.js"; export { ZenCard } from "./card.js"; +export { + ZenCarousel, + ZenDataTable, + ZenMessageScroller, + ZenResizable, +} from "./collections.js"; +export { + ZenAccordion, + ZenCollapsible, + ZenSidebar, + ZenTabs, + ZenToggle, + ZenToggleGroup, +} from "./disclosure.js"; export { ZenField } from "./field.js"; export { + ZenCalendar, + ZenCheckbox, + ZenCombobox, + ZenCommand, + ZenDatePicker, + ZenForm, + ZenInputOtp, + ZenRadioGroup, + ZenSelect, + ZenSlider, + ZenSwitch, +} from "./forms.js"; +export { ICON_NAMES, ZenIcon } from "./icon.js"; +export { ZenNotifications, validateNotification, validateNotificationDismiss, } from "./notifications.js"; +export { + ZenAlertDialog, + ZenContextMenu, + ZenDialog, + ZenDrawer, + ZenDropdownMenu, + ZenHoverCard, + ZenMenubar, + ZenNavigationMenu, + ZenPopover, + ZenSheet, + ZenTooltip, +} from "./overlays.js"; +export { + ZEN_PRIMITIVE_ELEMENTS, + ZenPrimitive, +} from "./primitives.js"; export { ZenStack } from "./stack.js"; export { ZenStory } from "./story.js"; diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/notifications.js --- a/design_system/src/components/notifications.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/notifications.js Tue Aug 04 15:12:09 2026 -0700 @@ -13,6 +13,12 @@ (allowEmpty || value.trim().length > 0); } +/** + * Validates and normalizes a notification event record. + * + * @param {*} value Candidate event detail. + * @return {?Object} Frozen normalized record, or null when invalid. + */ export function validateNotification(value) { if (!value || value.version !== 1 || @@ -53,6 +59,12 @@ }); } +/** + * Validates a programmatic notification dismissal record. + * + * @param {*} value Candidate event detail. + * @return {?string} Valid notification ID, or null when invalid. + */ export function validateNotificationDismiss(value) { if (!value || value.version !== 1 || @@ -72,6 +84,11 @@ }; } +/** + * Renders a bounded, scoped, Sonner-like notification queue. + * + * @extends {HTMLElement} + */ export class ZenNotifications extends HTMLElement { static version = 1; @@ -363,6 +380,8 @@ const tone = document.createElement("span"); tone.className = "zen-notification-tone"; tone.setAttribute("aria-hidden", "true"); + const toneIcon = document.createElement("zen-icon"); + tone.append(toneIcon); const content = document.createElement("div"); content.className = "zen-notification-content"; @@ -373,7 +392,9 @@ const dismiss = document.createElement("button"); dismiss.type = "button"; dismiss.className = "zen-notification-dismiss"; - dismiss.textContent = "×"; + const dismissIcon = document.createElement("zen-icon"); + dismissIcon.setAttribute("name", "close"); + dismiss.append(dismissIcon); dismiss.addEventListener("click", () => { this.dismiss(record.id, "dismissed"); }); @@ -402,18 +423,19 @@ dismiss, message, tone, + toneIcon, }; this.#resizeObserver?.observe(article); } const view = record.view; view.article.dataset.tone = record.tone; - view.tone.textContent = { - info: "i", - success: "✓", - warning: "!", - error: "×", - }[record.tone]; + view.toneIcon.setAttribute("name", { + info: "info", + success: "check", + warning: "alert", + error: "error", + }[record.tone]); view.message.textContent = record.message; view.dismiss.setAttribute( "aria-label", diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/overlays.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/overlays.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,865 @@ +let nextOverlayId = 1; + +function uniqueId(prefix) { + let id; + do id = `${prefix}-${nextOverlayId++}`; + while (document.getElementById(id)); + return id; +} + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function restoreAttributes(state) { + for (const [element, attributes] of state) { + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + } + state.clear(); +} + +function resetManagedAttribute(state, element, name) { + const attributes = state.get(element); + if (!attributes?.has(name)) return; + const value = attributes.get(name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function ensureId(state, element, prefix) { + const existing = element.id && document.getElementById(element.id); + if (!element.id || (existing && existing !== element)) { + setManagedAttribute(state, element, "id", uniqueId(prefix)); + } + return element.id; +} + +function eventElement(event) { + return event.target instanceof Element ? event.target : null; +} + +const OVERLAY_COMPONENTS = [ + "zen-dialog", + "zen-alert-dialog", + "zen-sheet", + "zen-drawer", + "zen-popover", + "zen-hover-card", + "zen-tooltip", +].join(","); + +function ownedElement(host, selector) { + return [...host.querySelectorAll(selector)].find( + element => element.closest(OVERLAY_COMPONENTS) === host, + ) || null; +} + +/** + * Wires a native modal dialog to explicit trigger and close controls. + * + * @extends {HTMLElement} + */ +export class ZenDialog extends HTMLElement { + static dialogRole = null; + + constructor() { + super(); + this._managed = new Map(); + this._open = false; + this._openedByComponent = false; + this._onTriggerClick = event => { + event.preventDefault(); + this.open(); + }; + this._onDialogClick = event => { + const control = eventElement(event)?.closest("[data-zen-close]"); + if (!control || !this._dialog?.contains(control)) return; + const value = control.hasAttribute("value") + ? control.getAttribute("value") + : undefined; + this.close(value); + }; + this._onNativeClose = () => this.syncOpen(false); + } + + connectedCallback() { + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "button[data-zen-trigger]"); + const dialog = ownedElement(this, "dialog"); + if (trigger === this._trigger && dialog === this._dialog) return; + if (this._openedByComponent) this.close(); + this.unbind(); + this._trigger = trigger; + this._dialog = dialog; + if (!this._trigger || !this._dialog) return; + + const id = ensureId(this._managed, this._dialog, this.localName); + setManagedAttribute( + this._managed, + this._trigger, + "aria-haspopup", + "dialog", + ); + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + if (this.constructor.dialogRole) { + setManagedAttribute( + this._managed, + this._dialog, + "role", + this.constructor.dialogRole, + ); + } + this._trigger.addEventListener("click", this._onTriggerClick); + this._dialog.addEventListener("click", this._onDialogClick); + this._dialog.addEventListener("close", this._onNativeClose); + this.syncOpen(this._dialog.open); + } + + unbind() { + this._trigger?.removeEventListener("click", this._onTriggerClick); + this._dialog?.removeEventListener("click", this._onDialogClick); + this._dialog?.removeEventListener("close", this._onNativeClose); + restoreAttributes(this._managed); + this._trigger = null; + this._dialog = null; + this._open = false; + this._openedByComponent = false; + } + + disconnectedCallback() { + this._observer?.disconnect(); + if (this._openedByComponent) this.close(); + this.unbind(); + } + + open() { + if (!this._dialog || this._dialog.open || this._open) return; + try { + if (typeof this._dialog.showModal === "function") { + this._dialog.showModal(); + } else { + setManagedAttribute(this._managed, this._dialog, "open", ""); + } + this._openedByComponent = true; + this.syncOpen(true); + } catch { + this.syncOpen(Boolean(this._dialog.open)); + } + } + + close(returnValue) { + if (!this._dialog || (!this._dialog.open && !this._open)) return; + if (typeof this._dialog.close === "function") { + if (returnValue === undefined) this._dialog.close(); + else this._dialog.close(returnValue); + } else { + setManagedAttribute(this._managed, this._dialog, "open", null); + } + this.syncOpen(false); + } + + syncOpen(open) { + this._open = open; + if (!open) this._openedByComponent = false; + if (this._trigger) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + } +} + +/** + * Presents a native modal with alert-dialog semantics. + * + * @extends {ZenDialog} + */ +export class ZenAlertDialog extends ZenDialog { + static dialogRole = "alertdialog"; +} + +/** + * Presents a native dialog as an edge-aligned sheet. + * + * @extends {ZenDialog} + */ +export class ZenSheet extends ZenDialog {} + +/** + * Presents a native dialog as a drawer. + * + * @extends {ZenDialog} + */ +export class ZenDrawer extends ZenDialog {} + +function isPopoverOpen(element) { + try { + return element.matches(":popover-open"); + } catch { + return false; + } +} + +/** + * Connects a trigger to a native popover with an accessible fallback. + * + * @extends {HTMLElement} + */ +export class ZenPopover extends HTMLElement { + constructor() { + super(); + this._managed = new Map(); + this._open = false; + this._onClick = event => { + const target = eventElement(event); + if (!target) return; + if (this._trigger?.contains(target)) { + event.preventDefault(); + this.toggle(); + } else if (this._content?.contains(target) && + target.closest("[data-zen-close]")) { + this.hide(); + } + }; + this._onToggle = event => { + this.syncOpen(event.newState + ? event.newState === "open" + : isPopoverOpen(this._content)); + }; + this._onDocumentPointerDown = event => { + if (!this._native && this._open && !this.contains(event.target)) { + this.hide(); + } + }; + this._onDocumentKeyDown = event => { + if (!this._native && this._open && event.key === "Escape") { + this.hide(); + this._trigger?.focus(); + } + }; + } + + connectedCallback() { + this.addEventListener("click", this._onClick); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + document.addEventListener("keydown", this._onDocumentKeyDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "[data-zen-trigger]"); + const content = ownedElement( + this, + "[data-zen-content], [popover]", + ); + if (trigger === this._trigger && content === this._content) return; + if (this._open) this.hide(); + this.unbindElements(); + this._trigger = trigger; + this._content = content; + if (!this._trigger || !this._content) return; + + const id = ensureId(this._managed, this._content, "zen-popover"); + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + this._native = typeof this._content.showPopover === "function" && + typeof this._content.hidePopover === "function"; + if (this._native && !this._content.hasAttribute("popover")) { + setManagedAttribute(this._managed, this._content, "popover", "auto"); + } + if (this._native && "popoverTargetElement" in this._trigger) { + this._originalPopoverTarget = this._trigger.popoverTargetElement; + this._trigger.popoverTargetElement = this._content; + } + if (this._native) { + setManagedAttribute(this._managed, this._content, "hidden", null); + } else { + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + + this._content.addEventListener("toggle", this._onToggle); + this.syncOpen(this._native && isPopoverOpen(this._content)); + } + + unbindElements() { + this._content?.removeEventListener("toggle", this._onToggle); + if (this._trigger && "popoverTargetElement" in this._trigger) { + this._trigger.popoverTargetElement = this._originalPopoverTarget || null; + } + restoreAttributes(this._managed); + this._trigger = null; + this._content = null; + this._native = false; + this._open = false; + this._originalPopoverTarget = null; + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + document.removeEventListener("keydown", this._onDocumentKeyDown); + if (this._open) this.hide(); + this.unbindElements(); + } + + toggle() { + if (this._open || isPopoverOpen(this._content)) this.hide(); + else this.show(); + } + + show() { + if (!this._content || this._open) return; + if (this._native) { + try { + this._content.showPopover(); + } catch { + this._native = false; + setManagedAttribute(this._managed, this._content, "hidden", null); + } + } else { + setManagedAttribute(this._managed, this._content, "hidden", null); + } + this.syncOpen(true); + } + + hide() { + if (!this._content || !this._open) return; + if (this._native) { + try { + this._content.hidePopover(); + } catch { + this._native = false; + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + } else { + setManagedAttribute(this._managed, this._content, "hidden", ""); + } + this.syncOpen(false); + } + + syncOpen(open) { + this._open = open; + if (this._trigger) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + if (!this._native && this._content) { + setManagedAttribute( + this._managed, + this._content, + "hidden", + open ? null : "", + ); + } + } +} + +class ZenTimedOverlay extends HTMLElement { + static delay = 150; + static tooltip = false; + + constructor() { + super(); + this._managed = new Map(); + this._timer = null; + this._open = false; + this._pointerWithin = false; + this._focusWithin = false; + this._onPointerEnter = () => { + this._pointerWithin = true; + this.schedule(true); + }; + this._onPointerLeave = event => { + if (event.relatedTarget instanceof Node && + this.contains(event.relatedTarget)) return; + this._pointerWithin = false; + this.schedule(this._focusWithin); + }; + this._onFocusIn = () => { + this._focusWithin = true; + this.schedule(true); + }; + this._onFocusOut = event => { + if (event.relatedTarget instanceof Node && + this.contains(event.relatedTarget)) return; + this._focusWithin = false; + this.schedule(this._pointerWithin); + }; + this._onKeyDown = event => { + if (event.key !== "Escape" || !this._open) return; + clearTimeout(this._timer); + this._timer = null; + this.setOpen(false); + }; + } + + connectedCallback() { + this.addEventListener("keydown", this._onKeyDown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + sync() { + const trigger = ownedElement(this, "[data-zen-trigger]"); + const content = ownedElement(this, "[data-zen-content]"); + if (trigger === this._trigger && content === this._content) return; + this.unbindElements(); + this._trigger = trigger; + this._content = content; + if (!this._trigger || !this._content) return; + + const id = ensureId(this._managed, this._content, this.localName); + setManagedAttribute(this._managed, this._content, "hidden", ""); + if (this.constructor.tooltip) { + setManagedAttribute(this._managed, this._content, "role", "tooltip"); + const ids = new Set( + (this._trigger.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(Boolean), + ); + ids.add(id); + setManagedAttribute( + this._managed, + this._trigger, + "aria-describedby", + [...ids].join(" "), + ); + } else { + setManagedAttribute(this._managed, this._trigger, "aria-controls", id); + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + "false", + ); + } + + for (const element of [this._trigger, this._content]) { + element.addEventListener("pointerenter", this._onPointerEnter); + element.addEventListener("pointerleave", this._onPointerLeave); + element.addEventListener("focusin", this._onFocusIn); + element.addEventListener("focusout", this._onFocusOut); + } + } + + unbindElements() { + clearTimeout(this._timer); + this._timer = null; + for (const element of [this._trigger, this._content]) { + element?.removeEventListener("pointerenter", this._onPointerEnter); + element?.removeEventListener("pointerleave", this._onPointerLeave); + element?.removeEventListener("focusin", this._onFocusIn); + element?.removeEventListener("focusout", this._onFocusOut); + } + restoreAttributes(this._managed); + this._trigger = null; + this._content = null; + this._open = false; + this._pointerWithin = false; + this._focusWithin = false; + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("keydown", this._onKeyDown); + this.unbindElements(); + } + + schedule(open) { + clearTimeout(this._timer); + this._timer = setTimeout( + () => this.setOpen(open), + this.constructor.delay, + ); + } + + setOpen(open) { + if (!this._content || open === this._open) return; + this._open = open; + setManagedAttribute( + this._managed, + this._content, + "hidden", + open ? null : "", + ); + if (!this.constructor.tooltip) { + setManagedAttribute( + this._managed, + this._trigger, + "aria-expanded", + String(open), + ); + } + } +} + +/** + * Shows supplemental interactive content on hover or focus. + * + * @extends {ZenTimedOverlay} + */ +export class ZenHoverCard extends ZenTimedOverlay { + static delay = 150; +} + +/** + * Shows a delayed non-interactive description on hover or focus. + * + * @extends {ZenTimedOverlay} + */ +export class ZenTooltip extends ZenTimedOverlay { + static delay = 350; + static tooltip = true; +} + +const MENU_COMPONENTS = [ + "zen-dropdown-menu", + "zen-context-menu", + "zen-menubar", + "zen-navigation-menu", +].join(","); +const MENU_SELECTOR = "[data-zen-menu], [role='menu'], [data-zen-content]"; +const ITEM_SELECTOR = [ + "[role='menuitem']", + "[role='menuitemcheckbox']", + "[role='menuitemradio']", + "[data-zen-menu-item]", + "a[href]", + "button", + "input[type='button']", + "input[type='submit']", +].join(","); + +class ZenMenu extends HTMLElement { + static contextMenu = false; + static menubar = false; + static multiple = false; + + constructor() { + super(); + this._managed = new Map(); + this._pairs = []; + this._openPair = null; + this._onClick = event => { + const target = eventElement(event); + if (!target) return; + const pair = this._pairs.find(({ trigger }) => + trigger.contains(target)); + if (pair) { + event.preventDefault(); + if (this._openPair === pair) this.close(false); + else this.open(pair, 0, this.constructor.contextMenu ? event : null); + return; + } + const item = target.closest(ITEM_SELECTOR); + if (this._openPair?.menu.contains(target) && + item && + !item.hasAttribute("disabled") && + item.getAttribute("aria-disabled") !== "true") { + this.close(true); + } + }; + this._onContextMenu = event => { + if (!this.constructor.contextMenu || + this._pairs.some(({ menu }) => menu.contains(event.target))) return; + event.preventDefault(); + this.open(this._pairs[0], 0, event); + }; + this._onKeyDown = event => this.handleKeyDown(event); + this._onDocumentPointerDown = event => { + if (!this._openPair) return; + const { trigger, menu } = this._openPair; + if (!trigger.contains(event.target) && !menu.contains(event.target)) { + this.close(false); + } + }; + } + + connectedCallback() { + this.setupPairs(); + this.addEventListener("click", this._onClick); + this.addEventListener("contextmenu", this._onContextMenu); + this.addEventListener("keydown", this._onKeyDown); + document.addEventListener("pointerdown", this._onDocumentPointerDown); + this._observer ||= new MutationObserver(() => this.refreshPairs()); + this._observer.observe(this, { childList: true, subtree: true }); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("contextmenu", this._onContextMenu); + this.removeEventListener("keydown", this._onKeyDown); + document.removeEventListener("pointerdown", this._onDocumentPointerDown); + this._openPair = null; + restoreAttributes(this._managed); + this._pairs = []; + } + + refreshPairs() { + if (this._openPair) this.close(false); + restoreAttributes(this._managed); + this._pairs = []; + this.setupPairs(); + } + + ownedElements(selector) { + return [...this.querySelectorAll(selector)].filter(element => + element.closest(MENU_COMPONENTS) === this); + } + + setupPairs() { + let triggers = this.ownedElements("[data-zen-trigger]"); + const menus = this.ownedElements(MENU_SELECTOR); + triggers = triggers.filter(trigger => + !menus.some(menu => menu.contains(trigger))); + if (!this.constructor.multiple) triggers = triggers.slice(0, 1); + + const unused = new Set(menus); + for (const trigger of triggers) { + const controlled = trigger.getAttribute("aria-controls"); + let menu = controlled + ? menus.find(candidate => candidate.id === controlled) + : null; + if (!menu) menu = [...unused][0]; + if (!menu) continue; + unused.delete(menu); + + const triggerId = ensureId(this._managed, trigger, `${this.localName}-trigger`); + const menuId = ensureId(this._managed, menu, `${this.localName}-menu`); + setManagedAttribute(this._managed, trigger, "aria-haspopup", "menu"); + setManagedAttribute(this._managed, trigger, "aria-controls", menuId); + setManagedAttribute(this._managed, trigger, "aria-expanded", "false"); + setManagedAttribute(this._managed, menu, "role", "menu"); + setManagedAttribute(this._managed, menu, "aria-labelledby", triggerId); + setManagedAttribute(this._managed, menu, "hidden", ""); + if (this.constructor.menubar) { + setManagedAttribute(this._managed, trigger, "role", "menuitem"); + } + const pair = { trigger, menu }; + this._pairs.push(pair); + this.prepareItems(pair); + } + if (this.constructor.menubar) { + setManagedAttribute(this._managed, this, "role", "menubar"); + this.setMenubarTabStop(this._pairs[0]); + } + } + + prepareItems(pair) { + const items = [...pair.menu.querySelectorAll(ITEM_SELECTOR)].filter(item => + item.closest(MENU_SELECTOR) === pair.menu && + !item.hidden && + !item.hasAttribute("disabled") && + item.getAttribute("aria-disabled") !== "true"); + for (const item of items) { + if (!item.hasAttribute("role")) { + setManagedAttribute(this._managed, item, "role", "menuitem"); + } + setManagedAttribute(this._managed, item, "tabindex", "-1"); + } + return items; + } + + open(pair, itemIndex = 0, pointerEvent = null) { + if (!pair) return; + if (this._openPair && this._openPair !== pair) this.close(false); + this._openPair = pair; + if (this.constructor.menubar) this.setMenubarTabStop(pair); + if (pointerEvent) { + rememberAttribute(this._managed, pair.menu, "style"); + pair.menu.style.position = "fixed"; + pair.menu.style.left = `${Number(pointerEvent.clientX) || 0}px`; + pair.menu.style.top = `${Number(pointerEvent.clientY) || 0}px`; + } else { + resetManagedAttribute(this._managed, pair.menu, "style"); + } + setManagedAttribute(this._managed, pair.menu, "hidden", null); + setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "true"); + const items = this.prepareItems(pair); + if (items.length) { + const index = itemIndex < 0 ? items.length - 1 : itemIndex; + setManagedAttribute(this._managed, items[index], "tabindex", "0"); + items[index].focus(); + } else { + setManagedAttribute(this._managed, pair.menu, "tabindex", "-1"); + pair.menu.focus(); + } + } + + close(restoreFocus) { + if (!this._openPair) return; + const pair = this._openPair; + this._openPair = null; + setManagedAttribute(this._managed, pair.menu, "hidden", ""); + setManagedAttribute(this._managed, pair.trigger, "aria-expanded", "false"); + resetManagedAttribute(this._managed, pair.menu, "style"); + if (restoreFocus) pair.trigger.focus(); + } + + moveItem(pair, offset, edge = null) { + const active = document.activeElement; + const items = this.prepareItems(pair); + if (!items.length) return; + let index = items.indexOf(active); + if (edge === "first") index = 0; + else if (edge === "last") index = items.length - 1; + else index = (Math.max(index, 0) + offset + items.length) % items.length; + setManagedAttribute(this._managed, items[index], "tabindex", "0"); + items[index].focus(); + } + + moveMenubarTrigger(pair, offset, openMenu) { + const index = this._pairs.indexOf(pair); + const next = this._pairs[ + (index + offset + this._pairs.length) % this._pairs.length + ]; + this.setMenubarTabStop(next); + if (openMenu) this.open(next); + else next.trigger.focus(); + } + + setMenubarTabStop(activePair) { + for (const pair of this._pairs) { + setManagedAttribute( + this._managed, + pair.trigger, + "tabindex", + pair === activePair ? "0" : "-1", + ); + } + } + + handleKeyDown(event) { + const target = eventElement(event); + if (!target) return; + const triggerPair = this._pairs.find(({ trigger }) => + trigger.contains(target)); + if (triggerPair) { + if (this.constructor.menubar && + (event.key === "ArrowRight" || event.key === "ArrowLeft")) { + event.preventDefault(); + this.moveMenubarTrigger( + triggerPair, + event.key === "ArrowRight" ? 1 : -1, + false, + ); + } else if (["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) { + event.preventDefault(); + this.open(triggerPair, event.key === "ArrowUp" ? -1 : 0); + } else if (event.key === "Escape" && this._openPair) { + event.preventDefault(); + this.close(true); + } + return; + } + + const pair = this._pairs.find(({ menu }) => menu.contains(target)); + if (!pair || this._openPair !== pair) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + this.moveItem(pair, 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + this.moveItem(pair, -1); + } else if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + this.moveItem(pair, 0, event.key === "Home" ? "first" : "last"); + } else if (event.key === "Escape") { + event.preventDefault(); + this.close(true); + } else if (event.key === "Tab") { + this.close(false); + } else if (this.constructor.menubar && + (event.key === "ArrowRight" || event.key === "ArrowLeft")) { + event.preventDefault(); + this.moveMenubarTrigger( + pair, + event.key === "ArrowRight" ? 1 : -1, + true, + ); + } + } +} + +/** + * Provides an accessible action menu opened from a native trigger. + * + * @extends {ZenMenu} + */ +export class ZenDropdownMenu extends ZenMenu {} + +/** + * Opens an accessible action menu at the pointer location. + * + * @extends {ZenMenu} + */ +export class ZenContextMenu extends ZenMenu { + static contextMenu = true; +} + +/** + * Coordinates multiple keyboard-accessible application menus. + * + * @extends {ZenMenu} + */ +export class ZenMenubar extends ZenMenu { + static menubar = true; + static multiple = true; +} + +/** + * Provides keyboard-accessible navigation flyouts. + * + * @extends {ZenMenu} + */ +export class ZenNavigationMenu extends ZenMenu {} + +const definitions = { + "zen-dialog": ZenDialog, + "zen-alert-dialog": ZenAlertDialog, + "zen-sheet": ZenSheet, + "zen-drawer": ZenDrawer, + "zen-popover": ZenPopover, + "zen-hover-card": ZenHoverCard, + "zen-tooltip": ZenTooltip, + "zen-dropdown-menu": ZenDropdownMenu, + "zen-context-menu": ZenContextMenu, + "zen-menubar": ZenMenubar, + "zen-navigation-menu": ZenNavigationMenu, +}; + +for (const [name, constructor] of Object.entries(definitions)) { + if (!customElements.get(name)) customElements.define(name, constructor); +} diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/primitives.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/primitives.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,143 @@ +/** @const {!ReadonlyArray} Registered presentation-only elements. */ +export const ZEN_PRIMITIVE_ELEMENTS = [ + "zen-aspect-ratio", + "zen-attachment", + "zen-avatar", + "zen-badge", + "zen-breadcrumb", + "zen-bubble", + "zen-button-group", + "zen-chart", + "zen-direction", + "zen-empty", + "zen-input", + "zen-input-group", + "zen-item", + "zen-kbd", + "zen-label", + "zen-marker", + "zen-message", + "zen-native-select", + "zen-pagination", + "zen-progress", + "zen-scroll-area", + "zen-separator", + "zen-skeleton", + "zen-spinner", + "zen-table", + "zen-textarea", + "zen-typography", +]; + +function parseRatio(value) { + const [width, height] = String(value || "16/9") + .split("/") + .map(Number); + if (!Number.isFinite(width) || !Number.isFinite(height) || + width <= 0 || height <= 0) return "16 / 9"; + return `${width} / ${height}`; +} + +/** + * Applies small semantic enhancements shared by presentation primitives. + * + * @extends {HTMLElement} + */ +export class ZenPrimitive extends HTMLElement { + connectedCallback() { + this.sync(); + } + + static get observedAttributes() { + return ["dir", "ratio", "value", "width"]; + } + + attributeChangedCallback() { + if (this.isConnected) this.sync(); + } + + sync() { + switch (this.localName) { + case "zen-aspect-ratio": + this.style.setProperty( + "--zen-aspect-ratio", + parseRatio(this.getAttribute("ratio")), + ); + break; + case "zen-breadcrumb": { + const navigation = this.querySelector(":scope > nav"); + if (navigation && !navigation.hasAttribute("aria-label")) { + navigation.setAttribute("aria-label", "Breadcrumb"); + } + break; + } + case "zen-chart": + if (!this.hasAttribute("role")) this.setAttribute("role", "img"); + break; + case "zen-direction": + if (!this.hasAttribute("dir") && this.hasAttribute("value")) { + this.dir = this.getAttribute("value"); + } + break; + case "zen-pagination": { + const navigation = this.querySelector(":scope > nav"); + if (navigation && !navigation.hasAttribute("aria-label")) { + navigation.setAttribute("aria-label", "Pagination"); + } + break; + } + case "zen-native-select": { + if (!this.querySelector("select")) break; + let icon = this.querySelector( + ":scope > zen-icon[data-zen-select-icon]", + ); + if (!icon) { + icon = document.createElement("zen-icon"); + icon.dataset.zenSelectIcon = ""; + icon.dataset.zenGenerated = "true"; + icon.setAttribute("name", "chevron-down"); + this.append(icon); + } + break; + } + case "zen-separator": + if (!this.querySelector(":scope > hr")) { + if (!this.hasAttribute("role")) { + this.setAttribute("role", "separator"); + } + if (!this.hasAttribute("aria-orientation")) { + this.setAttribute( + "aria-orientation", + this.getAttribute("orientation") === "vertical" + ? "vertical" + : "horizontal", + ); + } + } + break; + case "zen-skeleton": + if (this.hasAttribute("width")) { + this.style.setProperty( + "--zen-skeleton-width", + this.getAttribute("width"), + ); + } else { + this.style.removeProperty("--zen-skeleton-width"); + } + if (!this.hasAttribute("role")) this.setAttribute("aria-hidden", "true"); + break; + case "zen-spinner": + if (!this.hasAttribute("role")) this.setAttribute("role", "status"); + if (!this.hasAttribute("aria-label")) { + this.setAttribute("aria-label", "Loading"); + } + break; + } + } +} + +for (const name of ZEN_PRIMITIVE_ELEMENTS) { + if (!customElements.get(name)) { + customElements.define(name, class extends ZenPrimitive {}); + } +} diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/stack.js --- a/design_system/src/components/stack.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/stack.js Tue Aug 04 15:12:09 2026 -0700 @@ -1,3 +1,8 @@ +/** + * Provides a token-driven flex layout around arbitrary native HTML. + * + * @extends {HTMLElement} + */ export class ZenStack extends HTMLElement {} if (!customElements.get("zen-stack")) { diff -r fdf3816959cb -r 2b6e732087ff design_system/src/components/story.js --- a/design_system/src/components/story.js Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/components/story.js Tue Aug 04 15:12:09 2026 -0700 @@ -9,6 +9,11 @@ return lines.map(line => line.slice(minimum)).join("\n"); } +/** + * Renders a declarative component example and its copyable HTML source. + * + * @extends {HTMLElement} + */ export class ZenStory extends HTMLElement { connectedCallback() { if (this.dataset.ready === "true") return; diff -r fdf3816959cb -r 2b6e732087ff design_system/src/index.html --- a/design_system/src/index.html Tue Aug 04 12:21:19 2026 -0700 +++ b/design_system/src/index.html Tue Aug 04 15:12:09 2026 -0700 @@ -18,10 +18,25 @@ Zenbu UI + + + + + +

+

Foundations

Components

@@ -97,14 +112,58 @@
-

Color

+

Semantic color

-
--zen-color-accent
Primary action
-
--zen-color-info
Informational state
-
--zen-color-success
Successful state
-
--zen-color-danger
Dangerous state
+
--zen-color-accent
Primary action
+
--zen-color-info
Informational state
+
--zen-color-success
Successful state
+
--zen-color-danger
Dangerous state
+
+

Material palette

+

Warm, quiet colors inspired by paper, natural dye, clay, and wood.

+
+
paper
+
washi
+
linen
+
sumi
+
brick
+
persimmon
+
ochre
+
moss
+
patina
+
indigo
+
plum
+
clay
+
wood
+
+
+
+

Data palette

+

Ten distinct aliases for charts and categorical comparisons.

+
+ + + + + + + + + + +
+
+
+

Color ramps

+ + neutral · red · orange · amber + green · teal · blue · violet + rose · brown + Each ramp includes 50–900 plus 950. + +

Spacing

@@ -126,6 +185,15 @@
+ +