Vitral 0.2
Reference

Accessibility

A condition of a component existing, not a milestone on its way there.

A component is not done until its accessibility works: the role, an accessible name, every state and value that applies, the relations that tie it to its label, description and popup, and the keyboard behaviour of the matching WAI-ARIA APG pattern.

What every spec does

  • Runs axe over the representative states, and for an overlay both closed and open.
  • Drives the keyboard through the pattern's keys and asserts what changed.
  • Asserts the ARIA attributes, not only the classes: a test that only sees classes cannot tell a styled div from a control.
Select.spec.ts
it('is a combobox that answers the keyboard', async () => {
    const wrapper = mount(Select, { props: { options, optionLabel: 'name' } });

    await expectNoA11yViolations(wrapper);     // axe, closed
    await press(wrapper, 'ArrowDown');         // opens and marks the first option
    await expectNoA11yViolations(wrapper);     // axe, open

    expect(trigger.attributes('aria-expanded')).toBe('true');
    expect(trigger.attributes('aria-activedescendant')).toBe(optionId(0));
});

What that means in practice

  • Fields are real controls: <label for>, name and aria-describedby reach them without extra props, because non-class attributes fall through to the control rather than the wrapper.
  • Overlays trap focus when modal, return it when they close, and close on Escape or a press outside. Only the topmost one, so a dialog over a dialog behaves.
  • Lists take type-ahead, Home/End, and aria-activedescendant rather than moving focus inside a popup.
  • Toolbars, tab lists and radio groups are one tab stop with arrow-key movement, as the APG asks.
  • Anything that animates stops under prefers-reduced-motion: reduce.
  • Toasts are live regions (polite, or an alert for a danger), and a life pauses while the card is hovered or focused.

Contrast, and the one switch it needs

Text, focus rings and disabled states meet 1.4.3 and 1.4.11 in every preset and both schemes, and a test measures it rather than trusting the eye: the theme is compiled, each var() chain is followed to a real colour, translucent surfaces are composited over what is behind them, and the ratio is computed. A preset that drifts under the bar fails the build.

One thing is deliberately not on by default. 1.4.11 also asks the border of a field to reach 3:1 against the field, and these presets are drawn with quieter edges than that — a line that suggests where a control is rather than outlining it. Both sets ship in the stylesheet; borders: 'strong' switches to the ones that meet the criterion, and because it is a mark on <html> and not a recompile it can be flipped at runtime.

Example
app.use(Vitral, {
    theme: { preset: Ink, borders: 'strong' }
});

// or at runtime, from anywhere:
const { setBorders } = useTheme();
setBorders('strong');

The theme menu at the top of this page has the same switch, so every example on the site can be read either way.

Unstyled changes none of it

Unstyled mode drops the classes and keeps every line above. That is the division the library is built on: behaviour and semantics are the component, the look is a theme.