Keyboard & accessibility
Well-behaved Stimulus controllers update ARIA state — aria-expanded, aria-selected, aria-controls — as they change the DOM. Assert on those attributes, not on class names or inline styles.
Keyboard actions
keydown.enter and keydown.esc are Stimulus shorthands for event filters. Drive them with user.keyboard:
import { render, attr.controller, attr.target, attr.action, attr.combine } from '@tito10047/stimulus-test-utils'
import KeyboardController from '../src/keyboard_controller.js'
test('Enter submits, Escape cancels', async () => {
const { element, user } = await render(KeyboardController, {
html: `
<div ${attr.combine(
attr.controller('keyboard'),
attr.action('keyboard', 'onEnter', 'keydown.enter'),
attr.action('keyboard', 'onEscape', 'keydown.esc'),
)} tabindex="0">
<span ${attr.target('keyboard', 'status')}>idle</span>
</div>
`,
})
element.focus()
await user.keyboard('{Enter}')
expect(element.querySelector('[data-keyboard-target="status"]')!.textContent).toBe('submitted')
await user.keyboard('{Escape}')
expect(element.querySelector('[data-keyboard-target="status"]')!.textContent).toBe('cancelled')
})Note the tabindex="0" and explicit element.focus() — keyboard events dispatch on document.activeElement. Without focus, the event goes to document.body.
aria-expanded on toggles
const { user, getByRole } = await render(ToggleController, { html })
const trigger = getByRole('button', { name: 'Menu' })
expect(trigger.getAttribute('aria-expanded')).toBe('false')
await user.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')Tabs with role="tablist" / role="tab"
getByRole makes tab components particularly clean:
const { user, getByRole, element } = await render(TabsController, {
html: `
<div ${attr.combine(attr.controller('tabs', { activeIndex: 0 }, { active: 'is-active' }))}>
<div role="tablist">
<button role="tab" ${attr.combine(
attr.target('tabs', 'tab'),
attr.action('tabs', 'select', 'click'),
)}>One</button>
<button role="tab" ${attr.combine(
attr.target('tabs', 'tab'),
attr.action('tabs', 'select', 'click'),
)}>Two</button>
</div>
<section ${attr.target('tabs', 'panel')}>First</section>
<section ${attr.target('tabs', 'panel')}>Second</section>
</div>
`,
})
await user.click(getByRole('tab', { name: 'Two' }))
expect(getByRole('tab', { name: 'Two' }).getAttribute('aria-selected')).toBe('true')Focus management
When a controller moves focus (focus traps, opening dialogs), assert via document.activeElement:
await user.click(getByRole('button', { name: 'Open' }))
expect(document.activeElement).toBe(getByRole('dialog'))For Tab/Shift+Tab navigation, user.tab() moves focus along the natural tab order:
await user.tab()
expect(document.activeElement).toBe(getByRole('textbox', { name: 'First name' }))
await user.tab()
expect(document.activeElement).toBe(getByRole('textbox', { name: 'Last name' }))
await user.tab({ shift: true })
expect(document.activeElement).toBe(getByRole('textbox', { name: 'First name' }))a11y-friendly queries
Prefer roles and labels over classes and test-ids. If your fixture cannot be selected by getByRole / getByLabelText, that's often a hint that the real app has an accessibility issue to fix.