UCDL Accessibility Use Case Definition Language
Contents

locate, focus, activate: The Three Questions Every Interactive Element Must Answer

Part 3 of "Testing the Way Users Actually Experience the Web" — a tutorial series on @afixt/usecase-runner.

Look at any use case written with usecase-runner and you'll see the same three lines over and over:

- locate: button "Submit"
- focus: button "Submit"
- activate: button "Submit"

New users ask the obvious question: why three steps? Playwright's click() will find the element and click it in one call. Aren't locate and focus redundant?

No — because each verb asks a different question, and each question is one a real user has to get a "yes" to before they can do anything.

Question 1: Can the user find it? (locate)

- locate: button "Submit"

generates

await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();

This asks: is there an element in the accessibility tree with role button and accessible name "Submit", and is it visible? A screen-reader user pulling up a list of buttons, or a voice-control user saying "click Submit", needs exactly this to be true. It fails on <div onclick>, on an icon button with no label, on a button whose visible text is inside an aria-hidden span, and on a button that's rendered but display: none.

Question 2: Can the user reach it? (focus)

- focus: button "Submit"

generates

const el = page.getByRole('button', { name: 'Submit' });
await el.focus();
await expect(el).toBeFocused();

The second line is the one that matters. Calling .focus() is a request. toBeFocused() checks whether the request was honoured. An element covered by an overlay, an element inside an inert subtree, a custom control that steals focus back to a hidden input — all of those accept the .focus() call and then aren't focused.

A keyboard user can't click. If they can't get focus onto the control, the control does not exist for them. locate passing and focus failing is one of the most common findings this tool produces, and it's one that visual QA and CSS-selector tests are structurally blind to.

What focus does not tell you. It establishes programmatic focus, which is not the same as being reachable by keyboard. tabindex="-1" is the clearest case: it makes an element focusable by script and removes it from the tab order, so .focus() succeeds, toBeFocused() passes, and a workflow written as a sequence of focus steps goes green against a page nobody can tab through.

Reachability is a separate assertion — drive the keyboard and observe where focus lands:

- focus: field "Email"
- keyboard: Tab
- verify: focus field "Password"

The manual rubric's gain focus on verb is the programmatic step. When you mean tab to, write the keyboard / verify: focus pair.

Question 3: Can the user operate it? (activate)

- activate: button "Submit"

generates a click(). But you can — and for critical controls, should — ask the keyboard version of the question:

- activate: button "Submit" via keyboard

which presses Enter on the element instead. A <div role="button" tabindex="0"> with only a click handler will pass locate, pass focus, and fail here. The role says "button"; the behaviour says "not really".

via keyboard sends Enter and only Enter. Space is the other half of the button contract and the half custom controls miss more often — a <div role="button"> wired to keydown for Enter but not Space is a real and common defect. Nothing in activate covers it; assert it explicitly:

- focus: button "Submit"
- keyboard: ' '
- verify: url "/thanks"

Tutorial: a nav menu, and the case that misses it

Here's a site navigation that looks fine and works with a mouse:

<nav aria-label="Main">
  <ul>
    <li><a href="/products" tabindex="-1" class="nav-link">Products</a></li>
    <li><a href="/pricing" tabindex="-1" class="nav-link">Pricing</a></li>
    <li><a href="/docs" tabindex="-1" class="nav-link">Docs</a></li>
  </ul>
</nav>

(Someone added tabindex="-1" to "fix" a focus-ring complaint from design. This happens more than you'd think.)

The obvious case looks like this:

id: nav-pricing
title: 'Reach the Pricing page from main navigation'
type: positive
start_location: 'https://app.example.com'
expected_result: 'The Pricing page loads'
preconditions: []

steps:
  - locate: navigation "Main"
  - locate: link "Pricing" within navigation "Main"
  - focus: link "Pricing" within navigation "Main"
  - activate: link "Pricing" within navigation "Main" via keyboard
  - verify: url "/pricing"
  - verify: heading "Pricing" level 1

It passes. All six steps, against markup no keyboard user can traverse.

That is worth sitting with, because it is the failure this whole series is about. focus asks the page to focus the link and the page obliges — tabindex="-1" removes an element from the tab order and leaves it script-focusable. activate ... via keyboard then presses Enter on a link that is genuinely focused, and the navigation happens. Every assertion in the case is true. The case is still wrong, because none of them is the assertion the title implies: reach the Pricing page.

Write the reaching instead:

steps:
  - locate: navigation "Main"
  - locate: link "Pricing" within navigation "Main"
  - focus: link "Products" within navigation "Main"
  - keyboard: Tab
  - verify: focus link "Pricing" within navigation "Main"
  - activate: link "Pricing" within navigation "Main" via keyboard
  - verify: url "/pricing"
  - verify: heading "Pricing" level 1
1  Locate navigation "Main"                            pass
2  Locate link "Pricing" within navigation "Main"      pass
3  Focus link "Products" within navigation "Main"      pass
4  Keyboard Tab                                        pass
5  Verify focus link "Pricing" within navigation "Main"  FAIL
6  Activate link "Pricing" ... via keyboard            pass
7  Verify URL "/pricing"                               pass
8  Verify heading "Pricing" level 1                    pass

Step 5 is the finding, and it is the only step that could have been. Tab from "Products" and focus leaves the nav entirely, because none of these links is in the tab order. Remove the tabindex="-1" and step 5 passes with the rest.

The lesson generalises past this one attribute: focus is the "can it be focused" question, not the "can it be reached" question. Reaching is keyboard plus verify: focus, and a case whose title says reach needs the pair. A sequence of focus steps describes a user who can teleport.

Two smaller things worth noticing. within navigation "Main" scopes the lookup to a landmark — useful when "Pricing" appears in both the header and the footer, and also a free assertion that the landmark exists. And verify: heading "Pricing" level 1 at the end checks the destination page has a proper heading, so the success condition is also an accessibility condition.

Use all three on anything the user interacts with: links, buttons, fields (where enter, select, or toggle plays the "operate" role), tabs, menu items. You don't need them for things the user only reads — a verify: heading or locate: text is complete on its own.

It's more lines. It's also the difference between a test that says "the page contains a Submit button" and a test that says "a person using only a keyboard can submit this form." Only the second one is worth running.

Next: Verify Like a Screen Reader, Not Like a Screenshot