Skip to content

weft_ui: accessible, styled UI components for Weft, with both demo apps on them - #17

Merged
e-cline merged 33 commits into
mainfrom
claude/weft-base-ui-components-c99930
Oct 26, 2026
Merged

e-cline merged 33 commits into
mainfrom
claude/weft-base-ui-components-c99930

Conversation

@e-cline

@e-cline e-cline commented Oct 26, 2026

Copy link
Copy Markdown
Owner

Adds @e-cline/weft_ui: accessible, styled UI components for Weft, built on weft_css and the platform's own elements (<dialog>, the Popover API, CSS anchor positioning, <details>, native inputs). Both demo apps now run on it, and a new gallery app shows every component. The design and the notes on each phase are in docs/weft_ui_plan.md.

What's in it

  • packages/weft_ui has 45 components:
    • Static, which a server component can render: layout, text, cards, alerts, badges, tables, lists, breadcrumbs, steps, spinners, skeletons, buttons, collapsibles and accordions.
    • Interactive: forms and their fields, toggles, dialogs, alert dialogs, drawers, popovers, menus, tooltips, toasts, tabs and a combobox.
    • Ready-made entrypoints for server components: TabsEntry, DialogEntry, DrawerEntry, PopoverEntry and ToastViewport.
    • Customizing:
      • Tokens are CSS variables whose defaults an app's values override.
      • Variant props.
      • className, in the cascade layer weft.components.ui, so an app's classes win.
      • State attributes.
      • Recipes: each styled component's classes as a function.
      • attrs, for any other DOM prop.
    • Testing: Testing.installJsdomShims() adds enough of <dialog> and the Popover API to jsdom for the overlays to work in tests.
    • README: every component with its kind, parts, props and keyboard behaviour.
  • Framework changes, a commit each, with changesets:
    • weft_dom: the ARIA, data and popup props, events and DOM bindings widgets need; noValidate; controlled radio buttons keeping the one their state checks.
    • weft_css: variable defaults on :where(:root) so an app's values win in any load order; logical and anchor-positioning properties; logical corner radii.
  • weft_notes and weft_shop migrated onto weft_ui, with some behaviour changes:
    • The shop's sign-in dialog and cart drawer are native modal dialogs instead of portals.
    • The account links are a menu, the search suggestions a combobox, and the questions an accordion.
    • Every field is labelled, and errors are tied to their fields.
  • examples/weft_ui_gallery, a new app showing:
    • every component in its variants, each example with its code
    • a theme switcher with each demo app's palette, kept in a cookie so the server renders the next page in it

Testing

  • jsdom tests:
    • weft_ui: 77
    • gallery: 17
    • shop: 49
    • notes: 29
  • Browser tests: axe (WCAG 2.1 A and AA) runs in all three e2e suites. It covers every page, each overlay while it's open, and each checkout step.
    • Gallery: 78 tests in Chromium, Firefox and WebKit, covering axe, keyboard and focus, and where popups are placed.
    • Shop: 26 tests.
    • Notes: 12 tests.
  • Workspace: pnpm run test and pnpm run check pass. Three lint warnings in the Vite plugin's testing.ts predate this PR.
  • Merging main: the branch has main merged in, including RPC answers bring the stylesheets of the elements in them #16. Its sale badge went onto the shop's new card.

Not in v1

Listed in the plan, dark mode first.

🤖 Generated with Claude Code

e-cline and others added 30 commits October 25, 2026 19:20
…ps on them

The design for a package of common UI components (layout, text, cards, tables,
forms, dialogs, menus, tabs, toasts, a combobox), styled with weft_css and
customizable through tokens, variants, className and state attributes, with a
few ready-made entrypoints, a gallery app, and weft_notes and weft_shop moved
onto it. Phase 0's spikes (JSX spread, a sublayer from a dependency, token
overrides, the native dialog and popover, useId across parts, an entrypoint
from a dependency) are written up in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gets need

For weft_ui's components: DomProps gains the ARIA attributes for forms, popups,
composite widgets, ranges and live regions, a fixed set of data attributes for
a component's state, inert, open_, the Popover API's attributes, more table and
form attributes, and the focusin/focusout, pointer, toggle and dialog events,
listened for on the element itself so the ones that don't bubble work. Dom
gains contains, matches, closest, document listeners, a dialog's and a
popover's methods, and more of Event. A test renders every new prop with Html
and the client and compares the DOMs, and another hears each new event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dgets need

A variable's initial value was declared on :root, in weft.base, as an app's own
value for it would be: which won depended on which sheet loaded last (phase 0
of the weft_ui plan found the library's default winning with the sheets
reversed, in every browser). On :where(:root) it has no specificity, so any
other value wins. The properties weft_ui's components need are added: logical
properties, anchor positioning, and a few for text, tables and form controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new package, @e-cline/weft_ui (namespace WeftUi): accessible, styled UI
components for Weft. Its theme is CSS variables (Tokens) named for what
they're for, with a neutral default an app overrides on its outermost element.
Every rule is in the layer weft.components.ui, so an app's classes win over
them in any order.

The static components, which call no hooks, so a server component can render
them: Stack, Grid, Container, Separator, Heading, Text, Link, VisuallyHidden,
Card, Alert, Badge, EmptyState, Table, List, DescriptionList, Breadcrumbs,
Steps, Spinner, Skeleton, Button and IconButton. Each takes a className (added
last), the ARIA props it needs and an attrs escape hatch, and the styled ones
have recipes (Button.className()) for something else to look like them.

Tests: every one rendered by weft_server's Render (which fails on a hook, as
the control shows), their ARIA and behaviour in jsdom, and every extracted rule
in weft.components.ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new example app, the gallery the weft_ui plan calls for: a page per group of
components (layout, text, surfaces, data, actions and status), each component
in its variants, every example captioned in a frame of its own. The pages are
server components rendering weft_ui's static components directly; the sidebar
is an entrypoint navigating in place in weft_ui's link recipe, which the server
hands it as a string; a busy button is an entrypoint of its own. Its harness
test opens every page: each renders, hydrates as served, and shows nothing
before its stylesheet loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The extraction test pins a fixture's sheet, which follows weft_css declaring
variables' defaults on :where(:root) (f699843).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
borderStartStartRadius, borderStartEndRadius, borderEndStartRadius and
borderEndEndRadius, for controls joined into one (a number field's buttons and
input) the right way round in either direction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clicking a radio button unchecks the others in its group, with no event of
their own, so only the clicked one's controlled checked was put back: a group
whose state ignored the click ended with nothing checked. After an event, the
other radio buttons with the same name in the same form get theirs back too,
as React does. Found by weft_ui's RadioGroup tests; a test here fails without
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Field (a control's label, description and error, told to the control through
a context: its id, aria-describedby, aria-invalid, required, disabled; the
error's region always there and polite), Fieldset, Form (its page's own
errors: noValidate, onSubmit, busy, an error at its top), Input, Textarea,
Select (the native one, with a chevron of its own), Checkbox (indeterminate
too), RadioGroup (native radios sharing a name, in a fieldset), Switch (a
checkbox with the switch role, drawn as a track), NumberField (a spinbutton:
arrows, PageUp and PageDown, Home and End, a typed draft committed on blur or
Enter, kept in bounds), Toggle (aria-pressed, its label the same either way)
and ToggleGroup (single or multiple, one Tab stop, the arrow keys moving
between items). State is controlled or uncontrolled, as Base UI's is.

Interactive: each keeps state or reads its field's, so renders inside an
entrypoint. Tests for each in jsdom (25 more).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sign-up form that checks itself on submit, each field's error shown, then is
busy, then done; inputs in each size and state; a select-all over checkboxes,
radio buttons with descriptions, switches; number fields; toggles and toggle
groups. Each is an entrypoint of the gallery's own. Its harness test fills
the form in, after submitting it empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The notes' pages and entrypoints are weft_ui's components in the notes'
colours, set as weft_ui's tokens on the page's outermost element; the theme's
element rules for buttons, inputs, links and headings are gone, and what's
left of the notes' own styles is the page's frame and the note's body.

The forms are weft_ui's Form and Fields: the comment's name and text have
visible labels, the hint describes the text's input, and a refused comment or
title says why on its own field (empty, too long: the input is invalid, its
error linked to it) or at the form's top (the note's gone, a request the
server couldn't read). The tests find those errors as assistive technology
does: through the input's aria-invalid and aria-describedby, or the form's
alert. The error box is an alert, the related notes a list of chips, the
comments a divided list.

The harness tests (29) and the browser tests (10) pass; two pinned the
title's markup and the comment form's own stylesheet, which are weft_ui's
now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The palette was on the outermost element's class, which <body> can't see, so
the body's colours were the palette's again. A sheet is sent with the classes
it has, so the theme's global sets the tokens on :root (winning over weft_ui's
:where(:root)) while its root class brings the sheet. weft_ui's README and
Tokens recommend it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
attrs was spread into each part's JSX, after which the part's own props were
assigned, absent ones too: an unset ?style wrote undefined over attrs.style
(phase 3's migration hit it twice, and the compiler can't see it). Host's
withAttrs rebuilds the part's host element with attrs under its props,
overriding only the props it set, through Weft's Element.classify and
fromNode; every part uses it (or Host.element's ~attrs) in place of a spread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dialog (the browser's <dialog>, opened with showModal from a layout effect,
its close event closing the state and putting the focus back on the trigger,
which WebKit doesn't; a click on its backdrop closes it), AlertDialog (an
alertdialog, not dismissed by a click outside, the focus on Cancel), Drawer (a
dialog pinned to a side), Popover (an auto popover its trigger opens by
popovertarget, so before hydrating too, placed by CSS anchor positioning,
flipped when there's no room; the focus in as it opens, back as it closes),
Menu (the menu button pattern: ArrowDown and ArrowUp open it on an end, the
arrows, Home, End and typeahead in it, disabled items skipped, Escape, Tab and
a choice closing it), Tooltip (a manual popover on hover after a delay or on
focus, naming an icon trigger or describing a text one) and Toast (a store,
the default one or createManager's, read with useSyncExternalStore; a polite
region, a danger toast an alert; each closing after its time, paused while
the pointer or focus is on them).

Ready-made entrypoints, with data props and their own state: DialogEntry,
DrawerEntry, PopoverEntry and ToastViewport.

Testing.installJsdomShims stands in for <dialog>'s methods and the Popover
API in jsdom (which hides every popover, and won't focus in one: the shim
shows those it opens), for weft_ui's tests and apps'. Tests for each overlay
(17); the browser checks are the gallery's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DialogEntry, DrawerEntry and PopoverEntry rendered by the page's server
component, with server-rendered content; an alert dialog, a menu, tooltips and
toast buttons as gallery entrypoints; ToastViewport in the frame. Its harness
tests add a toast and choose from the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…testing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tabs (the ARIA tabs pattern: one Tab stop, the arrow keys along the
orientation moving and, with automatic activation, selecting, past disabled
tabs, Home and End; panels kept mounted, hidden; an underline or pills look;
vertical too), and TabsEntry for a server component's tabs with
server-rendered panels.

Accordion and Collapsible, on the browser's details and summary: static, so a
server component renders them, and they open with no JavaScript; an
accordion's items sharing a name are exclusive (the name is each item's, as a
static component can't hand it down through a context).

Combobox (the ARIA editable combobox with a listbox popup): the app owns the
text and the suggestions; the combobox has the arrows highlighting them as
the input's aria-activedescendant, the focus staying in the input, Enter
choosing (or left alone with nothing highlighted), Escape, Tab and leaving
closing, the pointer highlighting and choosing; its popup a manual popover
under the input, aria-expanded true while the app renders one.

The jsdom shim gains a no-op scrollIntoView. Tests for each (11 more).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TabsEntry, an accordion and a collapsible rendered by the page's server
component; vertical, manually activated tabs and a fruit combobox as gallery
entrypoints. Its harness tests switch the server's tabs and choose from the
combobox with the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's look is weft_ui's tokens in its colours (Theme.res); its cards, badges, forms,
tables, steps, alerts and empty states are weft_ui's, and its own Tabs and Toaster are gone
(TabsEntry, Toast). The sign-in dialog and the cart drawer are native dialogs, the account
links are a menu, the search suggestions a combobox, and the questions an accordion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each page, and each overlay while it's open, is checked against WCAG 2.1 A and AA once what's
animating in has finished. The gallery gets its own browser tests: axe, and its overlays, tabs
and combobox with the keyboard, in Chromium, Firefox and WebKit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…board, and its changeset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e-cline and others added 3 commits October 25, 2026 22:02
… both

The switcher puts weft_notes' or weft_shop's palette (a class setting weft_ui's tokens) on the
page's outermost element, and keeps it in a cookie for the server to render the next page in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omponents-c99930

# Conflicts:
#	examples/weft_shop/src/server/Ui.res
#	examples/weft_shop/src/server/Ui.res.js
@e-cline
e-cline merged commit fe5e8b7 into main Oct 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant