Custom components
Every UniAria component’s behavior is a definition: a plain object, made
with defineBehavior, that names the component’s parts, declares its props and events, and turns state into
attributes. The adapters are generic over definitions, so a definition you
write works in React, in Vue and as a custom element, renders on the server,
and never re-renders a framework component for its internal state, exactly
like the built-in components.
This guide builds a disclosure (a button that shows and hides a panel, the WAI-ARIA Disclosure pattern), uses it from every adapter and tests it. The reference for everything used here is in Runtime and DOM layer.
Start from the pattern and decide four things:
- Parts: the elements the consumer renders. Here a
trigger(a<button>) and apanel. - Props: what the consumer passes. Whether the panel is expanded is
state the user changes, so it is a bindable,
isExpanded: controlled when the consumer passes it, uncontrolled otherwise.isDisabledis a plain prop. - Events: what the component reports. A bindable reports its own
changes (
expandedChange), so this one needs no other. - Public state: what consumers can read and style by:
isExpanded,isDisabledandisPressed.
// disclosure.ts
import { bindable, defineBehavior, prop } from '@uniaria/core';
import { createPress } from '@uniaria/core/dom';
import type { DomPartProps } from '@uniaria/core/dom';
const flag = (isOn: boolean) => (isOn ? '' : undefined);
export const disclosure = defineBehavior({
name: 'disclosure',
parts: ['trigger', 'panel'],
props: {
isExpanded: bindable(false),
isDisabled: prop(false),
},
setup(ctx) {
const { isExpanded, isDisabled } = ctx.props;
const press = createPress(ctx, {
part: 'trigger',
isDisabled,
onPress: () => isExpanded.set(!isExpanded.get()),
});
return { isPressed: press.isPressed, handlers: press.handlers };
},
expose: ({ props, api }) => ({
isExpanded: props.isExpanded.get(),
isDisabled: props.isDisabled(),
isPressed: api.isPressed(),
}),
connect({ props, ids, api }) {
const isExpanded = props.isExpanded.get();
const isDisabled = props.isDisabled();
const state = isExpanded ? 'open' : 'closed';
const trigger: DomPartProps = {
attrs: {
// Safari focuses a clicked button only when it has a tabindex.
tabindex: '0',
'aria-expanded': String(isExpanded),
'aria-controls': ids.panel,
'aria-disabled': isDisabled ? 'true' : undefined,
'data-state': state,
'data-pressed': flag(api.isPressed()),
'data-disabled': flag(isDisabled),
},
on: api.handlers,
};
const panel: DomPartProps = {
attrs: { id: ids.panel, hidden: flag(!isExpanded), 'data-state': state },
};
return { trigger, panel };
},
});What each piece does:
nameis kebab case. It becomes the element name,<uni-disclosure>by default. Each part gets its name capitalized asdata-element(Trigger,Panel); a part namedroot, which this component doesn’t have, getsdata-uni="disclosure"instead. A part listed inoutsideParts, rendered outside the root as an overlay’s trigger is, getsdata-uni-part="disclosure-<part>". Without a root, as here, nothing ties the parts to the component in CSS: give it a root to style it through UniAria’s markers.propsis the one schema both adapters read. FromisExpanded: bindable(false)the consumer getsisExpanded,defaultExpandedandonExpandedChangein React, and thedefault-expandedattribute, theisExpandedproperty and theexpandedchangeevent on the element.isDisabledbecomes thedisabledattribute. See naming conventions.setup(ctx)runs once per instance, on the server too, so it must not touch the DOM. It reads props throughctx.props(a bindable hasget()andset()), and wirescreatePress, which gives onepressfor mouse, touch, keyboard (Enter and Space) and screen-reader activation. It returns whatexpose()andconnect()need. That return value is internal to the definition: consumers call only the members it lists as actions, and this one lists none.isExpanded.set()commits the change and callsonExpandedChangewhile uncontrolled. While controlled it only callsonExpandedChange, and the panel changes when the consumer passes the new value.expose()is the public state. Keep it small: it is API.connect()returns attributes and handlers for every part, from current state. It runs again after every change and UniAria writes only the attributes that differ, so keep it cheap and pure. ARIA values are strings ('true','false'), a boolean attribute that is on is'', andundefinedremoves an attribute.idsgives each part’s id: the one its element carries, else a stable generated one, so read it inconnect();DomPartPropstypes the handlers with DOM events.
A few rules keep a definition working in both adapters:
- Never read a prop once and keep it: call the reader where you need the value.
- Anything that needs a part’s element goes through
partEffector an interaction from@uniaria/core/dom, since a custom element’s parts can arrive after it connects. - Combine several interactions on one part with
mergeHandlers; never callstopPropagation. - Write only the attributes the component owns. Content, classes,
typeand everything else stay the consumer’s.
import { useBehavior, useBehaviorState } from '@uniaria/react';
import { disclosure } from './disclosure';
export function Shipping() {
const ui = useBehavior(disclosure, { defaultExpanded: true });
const isExpanded = useBehaviorState(ui, (state) => state.isExpanded);
return (
<section>
<button {...ui.parts.trigger} type="button" className="disclosure">
{isExpanded ? 'Hide shipping' : 'Show shipping'}
</button>
<div {...ui.parts.panel}>Ships in two business days.</div>
</section>
);
}Pressing the button changes the panel’s hidden, aria-expanded and
data-state without rendering Shipping again. It renders again here only
because the label reads isExpanded through useBehaviorState; drop that
line and the component renders once. See React.
<script setup lang="ts">
import { useBehavior, useBehaviorState } from '@uniaria/vue';
import { disclosure } from './disclosure';
const ui = useBehavior(disclosure, { defaultExpanded: true });
const isExpanded = useBehaviorState(ui, (state) => state.isExpanded);
</script>
<template>
<section>
<button v-bind="ui.parts.trigger" type="button" class="disclosure">
{{ isExpanded ? 'Hide shipping' : 'Show shipping' }}
</button>
<div v-bind="ui.parts.panel">Ships in two business days.</div>
</section>
</template>The same definition, with nothing Vue-specific in it. The template renders
again only for the label, which reads isExpanded. See Vue.
// elements.ts
import { defineElement } from '@uniaria/elements';
import type { UniElement } from '@uniaria/elements';
import { disclosure } from './disclosure';
export const AcmeDisclosure = defineElement(disclosure, { prefix: 'acme' });
declare global {
interface HTMLElementTagNameMap {
'acme-disclosure': UniElement<typeof disclosure>;
}
}Import elements.ts once on the page. defineElement names the element
<prefix>-<name>, returns the existing class if the name is taken, and
returns undefined on a server, so the module is safe to import anywhere.
<acme-disclosure>
<h3>
<button data-element="Trigger" type="button">Shipping</button>
</h3>
<div data-element="Panel" hidden>Ships in two business days.</div>
</acme-disclosure>
<script type="module">
import './elements.js';
const shipping = document.querySelector('acme-disclosure');
shipping.addEventListener('expandedchange', (event) => {
console.log('expanded:', event.detail);
});
</script>The element finds its parts by data-element, a root part by data-uni
and a part outside the root by data-uni-part, at any depth, and writes the
same attributes React and Vue do.
The hidden in the markup keeps the panel shut before the script runs;
UniAria removes it when the panel opens. Add default-expanded to start open,
or set the isExpanded property to control it. See
Custom elements.
The definition runs in Node without a framework or a DOM. Create an instance, pass props as React would, and check the part props and the public state:
// disclosure.test.ts
import { createInstance } from '@uniaria/core';
import { describe, expect, it } from 'vitest';
import { disclosure } from './disclosure';
describe('disclosure', () => {
it('starts collapsed, with the trigger pointing at the panel', () => {
const instance = createInstance(disclosure, { id: 'shipping' });
const { trigger, panel } = instance.connect();
expect(trigger.attrs['aria-expanded']).toBe('false');
expect(trigger.attrs['aria-controls']).toBe('shipping-panel');
expect(panel.attrs.id).toBe('shipping-panel');
expect(panel.attrs.hidden).toBe('');
});
it('starts expanded from defaultExpanded', () => {
const instance = createInstance(disclosure, {
props: { defaultExpanded: true },
});
expect(instance.getState().isExpanded).toBe(true);
expect(instance.connect().panel.attrs.hidden).toBeUndefined();
});
it('follows a controlled value', () => {
const instance = createInstance(disclosure, {
props: { isExpanded: false },
});
instance.setProps({ isExpanded: true });
instance.sync();
expect(instance.connect().trigger.attrs['aria-expanded']).toBe('true');
});
});createPress needs real elements and real input, so test presses in a
browser. With Vitest’s browser mode and the commands from
@uniaria/testing/commands registered (see Testing),
pointer and keyboard send real input through Playwright:
// disclosure.browser.test.ts
import { keyboard, pointer, releaseInput } from '@uniaria/testing/browser';
import { afterEach, expect, it } from 'vitest';
import './elements';
afterEach(releaseInput);
it('toggles with the mouse and the keyboard', async () => {
document.body.innerHTML = `
<acme-disclosure>
<button data-element="Trigger" type="button">Shipping</button>
<div data-element="Panel">Ships in two business days.</div>
</acme-disclosure>`;
const host = document.querySelector('acme-disclosure')!;
const trigger = host.querySelector('button')!;
const panel = host.querySelector<HTMLElement>('[data-element="Panel"]')!;
const changes: boolean[] = [];
host.addEventListener('expandedchange', (event) => {
changes.push((event as CustomEvent<boolean>).detail);
});
await pointer.click(trigger);
expect(panel.hidden).toBe(false);
await keyboard.press('Enter');
expect(panel.hidden).toBe(true);
expect(changes).toEqual([true, false]);
});To check that every adapter behaves the same, write a conformance suite with
defineConformance and run it against each; see Testing.
UniAria’s fields are built from smaller behaviors, and your definition can compose them the same way:
- TextInput (
@uniaria/core/text-input) is a native<input>or<textarea>with TextField’s value handling: uncontrolled, or controlled with the typed text kept on screen until you pass it back, never written in the middle of an IME composition, and back to its default on a form reset. Hover and focus come with it. It has no label and no element of its own: the behavior around it labels it. TextField is Field and TextInput. - Autocomplete (
@uniaria/core/autocomplete) is a text input that filters a list and moves a highlight through it while focus stays in the input. It has its own label, description and error parts, and an element. Combobox is Autocomplete and Popover. See Autocomplete. - Field (
@uniaria/core/field) gives a control its label, description and error, and binds its value to a Form byname. Every UniAria field has it inside. See A field of your own.
TextInput has no page of its own, so here is all of it:
| TextInput | |
|---|---|
| Part | root, the <input> or <textarea>: native disabled and readonly, the value property, and data-hovered, data-focused, data-focus-visible, data-readonly, data-disabled |
| Props | value (bindable, reported as valueChange on each edit), isDisabled, isReadOnly |
| Events | focusChange, when the input gains or loses focus |
| State | value, isFocused, isFocusVisible, isHovered, isDisabled, isReadOnly |
React and Vue can render it alone with useBehavior(textInput, props). On
custom elements, a TextField that renders only its input part is the input
on its own.
Here TextInput handles the value, and the definition refuses typed characters that aren’t hex:
// hex-input.ts
import { bindable, defineBehavior } from '@uniaria/core';
import { partEffect } from '@uniaria/core/dom';
import { textInput } from '@uniaria/core/text-input';
export const hexInput = defineBehavior({
name: 'hex-input',
parts: ['root', 'input', 'swatch'],
props: {
// The element adopts the text typed before it upgraded.
value: bindable('', { adopt: { part: 'input', property: 'value' } }),
},
setup(ctx) {
const { value } = ctx.props;
const text = ctx.use(
textInput,
() => ({
value: value.get(),
onValueChange: (next: string) => value.set(next),
}),
{ parts: { root: 'input' } },
);
partEffect(ctx, 'input', (input) => {
const veto = (event: InputEvent) => {
if (event.data && !/^[\da-f]+$/i.test(event.data)) {
event.preventDefault();
}
};
input.addEventListener('beforeinput', veto);
return () => input.removeEventListener('beforeinput', veto);
});
return { text };
},
expose: ({ api }) => api.text.state(),
connect: ({ props, api }) => ({
root: { attrs: {} },
input: api.text.connect().root,
swatch: { attrs: {}, style: { '--hex-color': `#${props.value.get()}` } },
}),
});{ parts: { root: 'input' } }renders TextInput’srooton this component’sinputpart.- Typing, IME, a controlled value that a form library echoes back late,
hover and focus all come from TextInput. Label the input as any other,
with a
<label for>or anaria-label, or wrap it in a TextField. - The value is
hexInput’s, passed down. Inside a form, reset it yourself as TextField does (see Resetting a bindable): a TextInput given its value leaves the reset to you. - The veto can’t refuse IME text in Chromium, which doesn’t let a composition be cancelled.
Autocomplete composes the same way. This command list runs the command the user chooses and clears the text, and on Enter with nothing highlighted it searches for the text instead:
// command-list.ts
import { bindable, defineBehavior, event, prop } from '@uniaria/core';
import { autocomplete } from '@uniaria/core/autocomplete';
import { isComposing, mergeHandlers } from '@uniaria/core/dom';
import type { DomPartProps } from '@uniaria/core/dom';
interface Command {
readonly key: string;
readonly textValue: string;
}
export const commandList = defineBehavior({
name: 'command-list',
parts: ['root', 'label', 'input', 'list', 'empty'],
itemParts: ['option'],
props: {
// The element reads the commands from its options.
commands: prop<readonly Command[]>([], {
attribute: false,
adopt: { items: { item: 'option' } },
}),
query: bindable(''),
},
events: {
run: event<string>(),
search: event<string>(),
},
setup(ctx) {
const { commands, query } = ctx.props;
const search = ctx.use(
autocomplete,
() => ({
items: commands(),
inputValue: query.get(),
onInputChange: (next: string) => query.set(next),
onAction: (key: string) => {
ctx.emit('run', key);
query.set('');
},
}),
// The parts this component doesn't have.
{
parts: {
button: null,
popup: null,
scroller: null,
description: null,
error: null,
},
},
);
// Runs after Autocomplete's, which prevents an Enter that chooses.
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || event.defaultPrevented) return;
if (isComposing(event) || event.altKey || event.ctrlKey) return;
if (event.metaKey) return;
const text = search.state().inputValue.trim();
if (text === '') return;
event.preventDefault();
ctx.emit('search', text);
query.set('');
};
return { search, onKeyDown };
},
expose: ({ api }) => api.search.state(),
connect: ({ api }) => {
const { root, label, input, list, empty } = api.search.connect();
const own = input as DomPartProps;
const on = mergeHandlers(own.on ?? {}, { keydown: api.onKeyDown });
return { root, label, input: { ...own, on }, list, empty };
},
connectItem: ({ api }, part, key) => api.search.connectItem(part, key),
});The rules for putting behaviors together:
- Parts. A composed part renders on your part of the same name, or
where
partsmaps it. Map the ones you don’t render tonull. One with no part of its name and no mapping is left out as well, but UniAria warns about it in development, so a part you meant to render doesn’t go missing without a word. A part whose name you also have binds to yours, so map it tonullwhen it isn’t meant to be there. - Handlers. Add your handlers to a composed part with
mergeHandlers; they run in the order given. Autocomplete ignores akeydownthat is already prevented, so a handler you put first takes a key over. A handler for a key Autocomplete leaves alone, such as Enter with nothing highlighted, goes after it and skips what Autocomplete skips: akeydownalready prevented, one that belongs to an IME composition (isComposing()), one with Ctrl or Meta, and every key while disabled. - Item parts.
partsmaps parts, not item parts. Declare the item parts you render under Autocomplete’s names (option,optionLabel,group, …) in your ownitemParts, and return itsconnectItem()for them. - Adoption. A custom element reads unset props from its markup only
for the definition it registers, not for the ones that definition
composes. Declare
adopton your own props, asvalueandcommandsdo above, for the element to take the text typed before it upgraded or the options in its markup. - What to rely on. The props you pass and the events they report back,
state(),connect()andconnectItem()as the component documents them,actions(search.actions.showAll()), and the names of its parts and links.apiis internal and can change in any version. See Composition.
Simpler needs don’t call for a definition. TextField’s wrapper holds
content before and after the input, and Autocomplete’s own action and
inputChange are enough for a search box that runs a command and clears;
commandList is a definition for the Enter it adds.
Calling useBehavior(textInput) and useBehavior(listbox) in one React or
Vue component instead, and spreading both on the input, doesn’t work:
- The props collide. The later spread replaces the earlier one’s handlers and attributes, so every adapter would have to merge them by hand.
- Every arrow key renders again. What joins the two (which option is highlighted, whether the list shows) would live in React or Vue state.
- A definition does more. It works in React, in Vue and as an element, renders on the server, and runs in Node tests.
A control UniAria doesn’t have gets its label, description, error and form
binding by composing Field. This star rating is a spin button: the arrows
change the rating, Home and End go to the ends, and in a form it is required
and submitted under its name:
// rating.ts
import { defineBehavior, prop } from '@uniaria/core';
import { mergeHandlers } from '@uniaria/core/dom';
import type { DomPartProps } from '@uniaria/core/dom';
import { field } from '@uniaria/core/field';
import { formLink } from '@uniaria/core/form';
const MAX = 5;
export const rating = defineBehavior({
name: 'rating',
parts: ['root', 'label', 'stars', 'description', 'error', 'errorText'],
links: [formLink],
props: {
name: prop<string | undefined>(undefined, { type: 'string' }),
isRequired: prop(false),
},
setup(ctx) {
const { props } = ctx;
const labelling = ctx.use(
field,
() => ({
name: props.name(),
isRequired: props.isRequired(),
labelElementType: 'span',
}),
{ parts: { control: 'stars' }, links: [formLink] },
);
const stars = () => {
const value = labelling.state().value;
return typeof value === 'number' ? value : 0;
};
const onKeyDown = (event: KeyboardEvent) => {
const next = {
ArrowUp: Math.min(stars() + 1, MAX),
ArrowDown: Math.max(stars() - 1, 1),
Home: 1,
End: MAX,
}[event.key];
if (next === undefined) return;
event.preventDefault();
labelling.actions.setValue(next);
};
return { labelling, stars, onKeyDown };
},
expose: ({ api }) => api.labelling.state(),
connect: ({ api }) => {
const { control, ...parts } = api.labelling.connect();
const own = control as DomPartProps;
const count = api.stars();
return {
...parts,
stars: {
...own,
attrs: {
...own.attrs,
role: 'spinbutton',
tabindex: '0',
'aria-valuemin': '1',
'aria-valuemax': String(MAX),
'aria-valuenow': count ? String(count) : undefined,
'aria-valuetext': count ? `${count} of ${MAX} stars` : 'No rating',
},
style: { '--stars': String(count) },
on: mergeHandlers(own.on ?? {}, { keydown: api.onKeyDown }),
},
};
},
});In React it renders like any field, and inside a form’s OwnerProvider,
<Rating name="score" isRequired /> submits { score: 3 }:
function Rating(props: { name?: string; isRequired?: boolean }) {
const ui = useBehavior(rating, props);
return (
<div {...ui.parts.root}>
<span {...ui.parts.label}>Your rating</span>
<div {...ui.parts.stars} />
<p {...ui.parts.error}>
<span {...ui.parts.errorText} />
</p>
</div>
);
}- Parts.
{ parts: { control: 'stars' } }puts Field’s control on the spin button. Field’s other parts render on the rating’s parts of the same name; a part you don’t render, map tonull. - The form link. A composed definition reads only the links its composer
passes on, so the rating declares
links: [formLink]and passes it to Field. Without it, the rating never finds its form. - The value is Field’s. Render it from
labelling.state().value, the form’s while bound, and report each change withlabelling.actions.setValue(). Outside a form, Field holds it. - Field’s control props. Merge your attributes and handlers with them,
as
connect()does: Field’s handlers follow focus, so a form checks the rating when focus leaves it. - The label. A
<label>names only a native control, so the label is a<span>(labelElementType: 'span'), linked througharia-labelledby. - More of Field. Declare the other props you need, such as
isInvalid,validateorvalidationMessages, and hand them on the same way. - A native control. Around an
<input>or a<select>, Field follows the value itself: mapcontrolonto it and leave outsetValue.
- Events. Declare one with
event<Detail>()undereventsand report it fromsetup()withctx.emit(name, detail). React and Vue receive it ason<Name>, the element dispatches the lowercase DOM event. See event. - Actions. To let consumers expand the panel from elsewhere, return an
expandfunction fromsetup()and list it:actions: ['expand']. React and Vue callui.actions.expand(), and the element gets anexpand()method. See Actions. - Hover and focus.
createHoverandcreateFocusaddisHovered,isFocusedandisFocusVisible; merge their handlers with the press’s. See Interactions. - Reusing a component.
ctx.use(button, props)runs Button inside your definition, on its parts, as ToggleButton does. See Build on the controls and Composition. - Working inside another instance. An
owner(a checkbox in its group) or a link (a tooltip) connects separate instances. See Owners and links. - Overlays and lists. Focus scopes, the layer stack, presence and positioning are in the DOM layer; collections, typeahead and virtualization in Collections.
- Development warnings. Report misuse with
warn()from@uniaria/core/dom, which logs only in development.