Component workflow
A component is one task, built in small commits, in this order:
- Spec. What the component does, approved by the owner before anything else.
- Conformance scenarios. The tests every adapter must pass, written from the spec.
- Definition. The behavior, iterated until the scenarios pass in both adapters.
- Adapters and docs. The element entry, React or Vue sugar if it is needed, and the component’s page with a live demo.
The binding version of this workflow is in the
root AGENTS.md
(Component workflow, Conformance, Stop and ask the owner, Dependencies).
Copy
docs/_internal/component-spec-template.md
to packages/core/src/components/<name>/spec.md and fill it in:
| Section | What it settles |
|---|---|
| Summary | What the component is for, the native element underneath, the WAI-ARIA pattern, what it builds on (ctx.use(), dom/), its owner, and whether it gets a custom element. |
| Anatomy | The parts, their elements and the attributes UniAria owns on each. Element-owned parts, whose content UniAria writes, are marked. |
| Props | Type, default, whether it is bindable and its change event. |
| Public state | What expose() returns. Selectors read it, so it is public API: adding a field is a feature, changing one is a breaking change. |
| Keyboard | Each key, where it applies, and what it does. |
| ARIA and state attributes per state | The attributes on each part in each state. |
| Focus | Where focus goes on open, close, selection and dismissal, and what restores it. |
| Pointer and touch | Press behavior, press and hold, outside interaction, hover. |
| Styling tokens | What the consumer’s CSS can bind to: state attributes, CSS variables and public state. Tokens, never a look; styles UniAria writes only where a behavior needs them. |
| Expected spoken output | What the virtual screen reader should announce in the main flows. These become conformance checks. |
| Deviations from the WAI-ARIA pattern | None, or each one with its reason. |
| Open questions | Questions for the owner. |
Then:
- Commit the spec on its own and ask the owner to review it. Do not go
further until the owner sets its status line to
Status: approved by @tenphi on <date>. A spec with open questions is not approved; the owner’s answers go under a Resolved questions section, which the code cites by number. - Complex components start from Cube UI Kit, not React Aria: the
overlays and collections (Dialog, Popover, Tooltip, Listbox, Select,
Combobox). Cube UI Kit is UniAria’s first consumer, and it replaced React
Aria patterns that hurt its users: its ComboBox is its own implementation,
and one click outside a popover acts. Read the component’s source, tests
and docs in
references/cube-ui-kit, and list in the spec every place it differs from React Aria or the APG, with the reason. The owner decides each one. React Aria stays the reference for browser and assistive-technology edge cases. - Interactions have a catalogue: before building or changing one in
dom/, read its section ofdocs/_internal/interaction-layer.md(the behaviors, the browser bugs, where UniAria differs) and update the status there.
Scenarios are written from the approved spec in conformance/src/<name>/, in
commits of their own:
types.tsdeclares the suite’sPropsandStatetypes from the spec, not from a definition, so the suite exists before the implementation does.<name>.conformance.tsdefines the suite withdefineConformance<Props, State>('<name>', …), mirroring the spec’s keyboard map and ARIA table, and including spoken-output checks with the virtual screen reader.conformance/src/index.tsexports the suite and adds it tosuites, which every adapter’s conformance run reads.
Every change under conformance/ needs the owner’s approval, and no commit
or pull request changes both conformance/ and implementation under
packages/*/src/. Scenarios never import a definition or an adapter. See
Testing for the mount contract, phases and the
checks CI runs.
The definition lives in packages/core/src/components/<name>/: the
definition module, an index.ts that exports it with its <Name>Props and
<Name>State types (PropsOf and StateOf), and its unit tests. Each
module starts with a JSDoc block stating its responsibility and the design
decision it implements.
- Iterate until the scenarios pass in every adapter. Never edit a scenario to make the implementation pass. If a scenario looks wrong, stop and describe the problem to the owner.
- Compose, don’t duplicate. A composite component composes other
definitions with
ctx.use(); separate instances link through owners. See Architecture. - Register the subpath in three places: an entry in
packages/core/tsdown.config.ts, anexportsentry inpackages/core/package.jsonwith theuniaria-sourcecondition first, and a line inpackages/core/src/ssr-safety.test.ts. - Make it known to every conformance run: add the definition to the
definitionsmaps inpackages/react/src/conformance-adapter.tsxandpackages/vue/src/conformance-adapter.ts, and import its element entry inpackages/elements/src/conformance-adapter.ts. Each adapter’sconformance.browser.test.tsruns suites up to the roadmap phase it declares; raise it as the roadmap advances.
- Element entry.
packages/elements/src/<name>.tscallsdefineElement(definition)with the defaultuni-prefix, exports the class and its element type, and declares the tag inHTMLElementTagNameMap. Like a core subpath it needs an entry intsdown.config.ts, anexportsentry and a line insrc/ssr-safety.test.ts, plus a place insideEffectsinpackages/elements/package.json, because importing it registers the element. - React or Vue sugar only if it is needed.
@uniaria/reactand@uniaria/vuehold no component-specific behavior; sugar may only wrapuseBehavioranduseBehaviorState. - Docs. A page in
docs/components/<name>.mdxwith a live demo, listed in the site’s navigation. See Documentation.
| What | Rule | Example |
|---|---|---|
| Folder and subpath | kebab-case | checkbox-group, @uniaria/core/checkbox-group |
| Definition export | camelCase | checkboxGroup |
Definition name |
kebab-case | name: 'checkbox-group' |
| Element | uni- plus the name |
<uni-checkbox-group> |
| Root part’s marker | data-uni with the name |
data-uni="checkbox-group" |
| A part outside the root | data-uni-part with the name and the part |
data-uni-part="popover-trigger" |
| Part names | camelCase; data-element capitalizes them |
hiddenInput, data-element="HiddenInput" |
| A name that is a reserved word | A descriptive export instead | toggleSwitch for name: 'switch' |
| Booleans | Predicates | isDisabled, isOpen, shouldPreventFocusOnPress, hasArrow |
| Events | camelCase; DOM lowercase; props on + name |
pressStart, pressstart, onPressStart |
| Bindable change events | <name>Change, without the is prefix |
isOpen → openChange, seeded by defaultOpen |
- Booleans read as predicates everywhere, in public API and inside the
code:
is…for states;should…,has…,allows…orcan…for behavior. That covers props, public state, options, variables, parameters and functions that return booleans. Names the DOM defines stay as they are (shiftKey, thedisabledattribute). - The brand stays out of APIs. Exports get descriptive names.
- No near-duplicate components. Button covers links (link mode with
href). A Field plus a text control is<Name>Field(TextField, NumberField), and TextField covers<textarea>too.
Before any of these, stop and ask the owner in the conversation:
- Starting a component without an approved spec.
- Any change to
conformance/. - Changing the runtime contract (
defineBehavior, bindables, the store, part props) or the custom-element contract. - Adding a dependency that isn’t on the approved list.
- Changing the boundary rules in
eslint.config.js,tsconfig.core.json,types/host.d.ts,pnpm-workspace.yaml, or anything inscripts/or.github/. - Deviating from the WAI-ARIA Authoring Practices pattern for a component.
- Anything that contradicts
AGENTS.md.
Small utilities are built in-house; only large, domain-heavy packages are reused. The approved runtime dependencies:
| Package | Used for |
|---|---|
@internationalized/number |
Locale-aware number parsing and formatting (NumberField) |
@internationalized/date |
Calendars and date math, for later date components |
@tanstack/virtual-core |
Virtualization, bundled into @uniaria/core/virtual |
@tanstack/virtual-core is a development dependency, pinned to one exact
version and bundled into @uniaria/core/virtual with its development checks
compiled out. Upgrade it by hand once a newer release passes the 14-day
release-age policy and the virtualization scenarios.
The approved development dependencies for testing are
@guidepup/virtual-screen-reader, @guidepup/guidepup,
@guidepup/playwright, and react-aria in @uniaria/react, used only for
the comparison with React Aria. Any other dependency needs the owner’s
approval, including small utility packages, Lit, state libraries
(nanostores, signals) and Zag.js packages.
The web platform has a policy too. Use only features that are Baseline
widely available. A newer feature is allowed only behind feature detection
with a fallback that keeps the behavior working, or behind a page-wide
setting the consumer opts into (configure(), such as CSS mode for
positioning). Check a feature with the web-features development
dependency:
node -e "import('web-features').then(({features: f}) => console.log(f['popover'].status))"baseline: 'high' is widely available; 'low' and false need the
fallback. A feature’s status.by_compat_key covers its single APIs, which
can lag the feature.