PRACTICAL GUIDE / custom widget activation key tests
When Enter works but Space fails: testing custom widgets
Learn to separate focus, key handling, semantic state, and duplicate actions in custom widgets, with role-specific Playwright tests and CI evidence.
In this guide6 sections
What you will learn
- Start with the role, not the key
- Prove which layer failed before changing the test
- Prefer the native fix, then test the business effect
- Give disclosures and tabs their own contracts
A custom “Save view” control works with a mouse, but pressing Space after tabbing to it scrolls the page. Enter works, although the analytics log records two saves for one press. Those symptoms come from separate defects, and a test that checks only the final CSS class can miss both.
Start with the role, not the key
Keyboard expectations belong to a widget pattern, not to whatever key handler a developer happened to add. A button triggers an action. A link moves to a destination. A disclosure shows or hides content. A tab is one selectable label inside a composite widget. Similar styling does not make those contracts interchangeable.
That distinction matters during triage. If a card has role button because selecting it adds a product to a comparison, the WAI-ARIA Authoring Practices button pattern assigns Enter and Space to activation. If the card navigates to a product page, it should normally be a link, and the APG link pattern assigns Enter, not Space. A test that demands Space from every clickable surface will file false defects against links. A test that checks only Enter will miss an incomplete button.
Start the test case with a short contract that a developer and tester can both challenge. Record the intended role, accessible name, starting state, entry path, keys owned by the widget, visible result, semantic result, protected business effect, and expected focus after the action. “Space should work” is not a contract. “After Tab moves focus from the search field to the Save view toggle, one Space press changes aria-pressed from false to true, records one save, keeps the label Save view, and leaves focus on the toggle” is testable.
The standard and the pattern answer different questions. WCAG 2.2 Success Criterion 2.1.1, Keyboard, is Level A and requires functionality to be operable through a keyboard interface, apart from its path-dependent exception. It does not say that every widget uses every key. The APG pattern supplies a conventional key map for the role. Product behavior supplies the specific outcome, such as saving a filter or opening shipping details.
Two other criteria expose defects that an activation-only assertion cannot see. SC 2.1.2, No Keyboard Trap, is Level A. It requires a keyboard user who can move focus into a component to be able to move it away using a keyboard, with instructions if the exit method is not standard. SC 2.4.7, Focus Visible, is Level AA and requires a mode in which the keyboard focus indicator is visible. A key may activate the right command while the user has no idea where focus is, or while the next Tab press never escapes the widget.
Semantics are a separate observable surface. SC 4.1.2, Name, Role, Value, is Level A. It requires the name and role of user interface components to be programmatically determined, along with user-settable states, properties, and values and notification of their changes. A pressed style drawn with a dark background does not communicate the toggle state to an accessibility API. Conversely, aria-pressed can change correctly while the save operation never happens. Test the semantic state and the product effect because neither proves the other.
Adding role button to a div changes the semantics exposed to accessibility APIs, but it does not give that div native button behavior. The author still owns focusability, Enter and Space handling, disabled behavior, focus styling, and the action itself. WAI-ARIA 1.2 advises authors to use the host language feature when an equivalent one is available. In this case, a real HTML button is usually the smaller and safer fix.
Native does not mean untested. A button placed inside a form can submit it unless its type is set appropriately. A design-system reset can remove its focus outline. A wrapper can become the click target while focus remains on an inner element. A render can replace the focused node after state changes. Browser behavior removes much of the custom keyboard code, but integration can still break the user path.
Treat activation as four linked observations. First, can the user reach the control from the preceding focus stop? Second, does the focused control respond to the key assigned by its pattern? Third, do its semantic state and visible result agree? Fourth, does exactly one business effect occur, with focus left in a useful place? Calling a locator’s focus method is acceptable in a narrow component check, but it bypasses the first observation. Clicking after pressing a key destroys the second observation because the test can no longer tell which input caused the result.
Event phase is normally an implementation detail. APG tells the user which key activates the pattern; it does not give every application permission to encode a test around a particular sequence of internal handlers. Browsers and assistive technology combinations can produce event details that differ from an oversimplified unit fixture. Assert one completed key action and its result unless the event phase itself caused a verified regression. If a held key is a risk, add a case for repeated keydown events and decide whether the component should ignore the repeat flag. Do not quietly make that assumption for every button.
Negative controls make the positive case meaningful. Pressing KeyA on a focused button should not save a view. Space on a link should not be required to navigate. An aria-disabled custom button must not run the protected command merely because its attribute looks correct. WAI-ARIA describes disabled state, but the specification also refers to proper scripting for disabling custom behavior. The test must observe the effect that would harm the user, such as a request, state revision, or deletion.
The first review should therefore reject a universal “keyboard widget” helper that presses Enter, Space, every arrow, Home, End, and Escape against every role. Such a helper produces noise and encourages teams to weaken assertions until the matrix passes. Share setup and evidence collection, but keep the key map beside the pattern it represents.
Prove which layer failed before changing the test
Begin the investigation with the same entry path a keyboard user takes. Put focus on a known control immediately before the widget, press Tab, and inspect the active element. If focus skips the widget, changing its key handler will not fix reachability. Look for a missing native focus target, an inappropriate negative tabindex, hidden or inert ancestry, or DOM order that differs from the visual order.
Once focus lands correctly, record what the browser receives. A useful log contains the event type, key value, repeat flag, target, defaultPrevented value, semantic state before and after, business-effect count, and final active element. It does not need every property from a KeyboardEvent. Large event dumps hide the first divergence and can collect page data that has nothing to do with the defect.
The following Playwright test is an intentionally failing reproducer. It runs as a standalone test because page.setContent supplies the fixture. The defective control activates on both keydown and keyup. Evidence is attached before the assertion, so the failed run preserves the reason the count reached two.
import { expect, test } from "@playwright/test";
test("records a duplicate Enter activation", async ({ page }, testInfo) => {
await page.setContent(
[
'<button id="before" type="button">Before widget</button>',
'<div id="save-view" role="button" tabindex="0">Save view</div>',
'<output id="save-count">0</output>',
"<script>",
"window.__keyboardEvidence = [];",
"const widget = document.querySelector('#save-view');",
"const count = document.querySelector('#save-count');",
"for (const type of ['keydown', 'keyup', 'click']) {",
" document.addEventListener(type, (event) => {",
" window.__keyboardEvidence.push({",
" type: event.type,",
" key: event.key || '',",
" repeat: event.repeat || false,",
" target: event.target.id || event.target.tagName,",
" defaultPrevented: event.defaultPrevented",
" });",
" });",
"}",
"function activate() {",
" count.textContent = String(Number(count.textContent) + 1);",
"}",
"widget.addEventListener('keydown', (event) => {",
" if (event.key === 'Enter') activate();",
"});",
"widget.addEventListener('keyup', (event) => {",
" if (event.key === 'Enter') activate();",
"});",
"</script>"
].join("")
);
await page.locator("#before").focus();
await page.keyboard.press("Tab");
const widget = page.getByRole("button", { name: "Save view" });
await expect(widget).toBeFocused();
await page.keyboard.press("Enter");
const evidence = await page.evaluate(() => ({
events: (window as Window & {
__keyboardEvidence: unknown[];
}).__keyboardEvidence,
count: document.querySelector("#save-count")?.textContent,
activeElement: document.activeElement?.id
}));
await testInfo.attach("keyboard-evidence.json", {
body: JSON.stringify(evidence, null, 2),
contentType: "application/json"
});
await expect(page.locator("#save-count")).toHaveText("1");
});The assertion reports an expected count of one and a received count of two. The attachment holds four rows rather than two, because the document listeners are installed before the Tab press: keydown Tab targeted at before, keyup Tab targeted at save-view, then keydown Enter and keyup Enter both targeted at save-view. The final active element is save-view.
Read those rows rather than counting them. Two of the four are keyups on the widget, and only one of the two carries key: "Enter". Exactly one Enter keydown and one Enter keyup reached the control, with repeat false on both, which rules out a double press from Playwright or a held key. The doubled count therefore comes from the implementation invoking its command in both event phases.
The two Tab rows are evidence, not noise, and filtering them out of the attachment would cost more than it saves. keydown Tab on before and keyup Tab on save-view show focus moving between the two halves of one key press, which is the reachability observation that has to hold before any activation claim means anything. A log that begins at the Enter press cannot tell a widget that sits in the tab sequence from one the test focused directly.
A different attachment points to a different repair. Suppose the log has those same two Tab rows followed by one Space keydown and one Space keyup on the widget, the count remains zero, and the page’s vertical scroll position changes. Focus and event delivery worked. The custom control did not implement the Space branch or did not prevent the browser’s scrolling behavior at the point its custom handling required. Replacing the div with a native button is preferable when the design permits it. If the element must remain custom, the team needs a pattern-correct handler rather than a longer test timeout.
Another near-match occurs when the event count is one and aria-pressed changes, but the save request is absent. That is not a keyboard delivery defect. The state update may be optimistic while the command path is disconnected, or a guard may reject the operation. Check the application’s observable request or domain event. Do not rewrite the test to accept the pressed attribute as proof that data was saved.
The inverse failure is equally useful. A request succeeds, but aria-pressed stays false and the visual label says “Not saved.” The business path works while the communicated state is wrong. This case belongs under the semantic and rendering layers. A tester should file the disagreement explicitly because a back-end success does not repair what an assistive technology announces.
Playwright’s Keyboard API documents that press is a shortcut for a key down followed by a key up. Use page.keyboard.press after a real Tab entry when the focused element itself is part of the oracle. Locator.press is convenient when the test intentionally targets a known element, but it focuses that locator before pressing. Mixing the two without saying why can hide a broken tab sequence.
A dispatched JavaScript KeyboardEvent is a poor end-to-end substitute. It can exercise the application listener, but it does not reproduce all default browser behavior associated with trusted keyboard input. That is especially important for the Space scrolling defect and for native controls. Keep direct event dispatch in unit tests that are explicitly about handler logic. Use browser keyboard input for the integrated contract.
The trace helps locate the action and inspect the surrounding DOM state, but it does not automatically explain every listener invocation. Keep the compact event attachment for duplicate or missing effects. In the trace, check the action immediately before the failed assertion, the focused element in the snapshot, the current ARIA attributes, and whether a render replaced the node. If the trace shows the intended key was sent to the wrong focus target, investigate focus movement before touching activation code.
Manual reproduction still earns its place. Use Tab and Shift+Tab from outside the component, watch the focus indicator, activate each required key, and leave the component in both directions. A Playwright assertion can prove which DOM node is focused. It cannot by itself judge whether the indicator is easy to see against the real background, nor can it cover every assistive technology and browser combination.
Prefer the native fix, then test the business effect
The shortest reliable repair for a div that behaves as a button is often to stop making it a div. A native button supplies focusability and standard Enter and Space activation through its click behavior. The component still has to expose the right state, avoid an accidental form submission, preserve a useful label, and run one command per activation.
This controlled React component keeps the accessible name stable and exposes the toggle state with aria-pressed. It has one application action path, onClick. There is no parallel Enter handler to drift away from the Space handler.
"use client";
type SaveViewToggleProps = {
pressed: boolean;
onPressedChange: (nextPressed: boolean) => void;
};
export function SaveViewToggle({
pressed,
onPressedChange
}: SaveViewToggleProps) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={() => onPressedChange(!pressed)}
>
Save view
</button>
);
}The explicit button type matters when the component can appear inside a form. Without it, the HTML default can introduce a submission that the old div never caused. That is a real migration cost. Converting to native markup can also change default font, padding, line height, focus ring, and disabled behavior. Budget time to reconcile design-system CSS without removing the browser’s useful focus indication.
Do not preserve a visually changing “Save view” and “Remove saved view” label while also treating aria-pressed as a stable toggle label unless that wording has been reviewed against the chosen pattern. The APG button guidance says a toggle button’s label remains unchanged as its pressed state changes. If the interface genuinely needs a changing action label, model and test that design deliberately instead of attaching aria-pressed by habit.
A production check should enter from a neighboring control and observe more than the attribute. The example below assumes a stable fixture route that renders the component with a Before widget button and a save-count output. Each key gets a fresh page, so Enter cannot leave state that makes the Space case pass.
import { expect, test } from "@playwright/test";
const activationKeys = ["Enter", "Space"] as const;
for (const key of activationKeys) {
test("Save view activates once with " + key, async ({ page }) => {
await page.goto("/test-fixtures/save-view");
await page
.getByRole("button", { name: "Before widget" })
.focus();
await page.keyboard.press("Tab");
const toggle = page.getByRole("button", { name: "Save view" });
const count = page.getByTestId("save-count");
await expect(toggle).toBeFocused();
await expect(toggle).toHaveAttribute("aria-pressed", "false");
await expect(count).toHaveText("0");
await page.keyboard.press(key);
await expect(toggle).toHaveAttribute("aria-pressed", "true");
await expect(toggle).toBeFocused();
await expect(count).toHaveText("1");
});
}
test("Save view ignores an unrelated printable key", async ({ page }) => {
await page.goto("/test-fixtures/save-view");
const toggle = page.getByRole("button", { name: "Save view" });
await toggle.focus();
await toggle.press("KeyA");
await expect(toggle).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("save-count")).toHaveText("0");
});Every assertion has a plausible product change that makes it fail. Removing the element from the tab sequence breaks the focus assertion. Dropping Space support breaks the state and count assertions for that case. Calling the save command twice breaks the count. Updating only CSS breaks aria-pressed. Updating only aria-pressed breaks the count. This is a stronger oracle than checking a hard-coded fixture value against another hard-coded value that cannot diverge.
The count in a real application should come from an observable effect owned by the fixture, not a test-only variable that the same handler sets beside the UI state. Good options include a recorded request in a local test server, an application event surfaced by the fixture, or a persisted record queried through an approved test API. Pick the closest stable boundary to the risk. For a destructive command, verify the resulting record rather than relying only on a client analytics call.
Network observation adds cost. It couples the check to a request contract and can make a fast interaction test depend on server setup. A component fixture counter is cheaper and catches duplicate handler calls, but it cannot prove the back end accepted only one command. Keep most key-map cases at the component boundary and one or two integrated cases at the business boundary.
A disabled variant needs its own oracle. For a native button, the disabled attribute has host-language behavior that aria-disabled alone does not add to a custom element. Some designs intentionally keep an unavailable custom command discoverable in focus order and use aria-disabled, but then the implementation must suppress activation. Whatever the design decision, assert that the protected effect remains unchanged. “The attribute equals true” is not enough.
Focus appearance deserves a separate review. A screenshot expectation captured with the control focused can detect an accidental visual regression in a stable component fixture. It does not turn a pixel diff into a full WCAG judgment. High contrast modes, zoom, surrounding colors, and the integrated page still require targeted review. Keep that work beside the activation checks rather than hiding it inside a helper called accessibleButton.
Give disclosures and tabs their own contracts
A disclosure may look like the Save view toggle because both use a button, but its state and outcome are different. The APG disclosure pattern defines a button that toggles the visibility of controlled content. Enter and Space activate the control. When content is visible, aria-expanded is true; when it is hidden, aria-expanded is false. aria-controls may identify the controlled content, but it is optional in the pattern.
That gives the test two independent outputs. The trigger’s expanded state must change, and the content must actually become visible or hidden. A regression can update one without the other. If the code changes aria-expanded but leaves a collapsed CSS class on the panel, a role-only assertion passes while the user sees nothing. If the panel opens but aria-expanded stays false, sighted interaction may look fine while the semantic state is stale.
Focus movement is not specified as a universal disclosure action in the pattern. For the product in this example, the reviewed contract says focus remains on the trigger after opening and closing. That lets a user toggle the same section again and continue from a predictable point. Another product may move focus for a justified workflow, but the test should name that destination rather than asserting vaguely that focus is not on the body.
import { expect, test } from "@playwright/test";
for (const key of ["Enter", "Space"] as const) {
test("Shipping details toggles with " + key, async ({ page }) => {
await page.goto("/test-fixtures/shipping-disclosure");
const trigger = page.getByRole("button", {
name: "Shipping details"
});
const panel = page.locator("#shipping-details");
await trigger.focus();
await expect(trigger).toHaveAttribute("aria-expanded", "false");
await expect(panel).toBeHidden();
await trigger.press(key);
await expect(trigger).toHaveAttribute("aria-expanded", "true");
await expect(panel).toBeVisible();
await expect(trigger).toBeFocused();
await trigger.press(key);
await expect(trigger).toHaveAttribute("aria-expanded", "false");
await expect(panel).toBeHidden();
await expect(trigger).toBeFocused();
});
}A failure where Enter opens the panel and Space scrolls the page points back to incomplete button behavior, especially when the trigger is non-native. A failure where both keys change aria-expanded but the panel stays hidden points to state-to-render wiring. A failure where the panel opens and immediately closes suggests duplicate activation. Record an expansion counter or the event sequence before blaming Playwright’s waiting.
Now compare that with manual tabs. The APG tabs pattern says Tab moves focus into the tab list onto the active tab. In a horizontal tab list, Left Arrow and Right Arrow move focus among tabs and wrap at the ends. For manual activation, Enter or Space activates the focused tab. The selected tab has aria-selected true, the others have false, and the associated tab panel is displayed.
Focus and selection are deliberately separate in manual activation. After Right Arrow moves from Email to Push, Push has focus but Email remains selected until the user presses Enter or Space. A test that finds only the focused tab and assumes it is selected will accidentally accept automatic activation. A test that checks only aria-selected can miss broken arrow navigation.
The following case runs each activation key from a clean state. The fixture implements a horizontal, manually activated tab list with tab and tabpanel relationships. Its initial selection is Email.
import { expect, test } from "@playwright/test";
for (const activationKey of ["Enter", "Space"] as const) {
test(
"manual tabs select with " + activationKey,
async ({ page }) => {
await page.goto("/test-fixtures/notification-tabs");
const emailTab = page.getByRole("tab", { name: "Email" });
const pushTab = page.getByRole("tab", { name: "Push" });
const emailPanel = page.getByRole("tabpanel", {
name: "Email"
});
const pushPanel = page.getByRole("tabpanel", {
name: "Push"
});
await emailTab.focus();
await expect(emailTab).toHaveAttribute(
"aria-selected",
"true"
);
await expect(pushTab).toHaveAttribute(
"aria-selected",
"false"
);
await emailTab.press("ArrowRight");
await expect(pushTab).toBeFocused();
await expect(emailTab).toHaveAttribute(
"aria-selected",
"true"
);
await expect(pushTab).toHaveAttribute(
"aria-selected",
"false"
);
await expect(emailPanel).toBeVisible();
await expect(pushPanel).toBeHidden();
await pushTab.press(activationKey);
await expect(pushTab).toBeFocused();
await expect(emailTab).toHaveAttribute(
"aria-selected",
"false"
);
await expect(pushTab).toHaveAttribute(
"aria-selected",
"true"
);
await expect(emailPanel).toBeHidden();
await expect(pushPanel).toBeVisible();
}
);
}That test would fail for the near-miss where an arrow updates selection immediately. It would also fail if focus moves visually through a CSS marker while DOM focus remains on Email, if aria-selected does not follow activation, or if both panels remain visible. Those failures can share a screenshot, yet the assertions identify different mechanisms.
Orientation changes the arrow contract. The APG tabs pattern maps Down Arrow and Up Arrow to next and previous behavior when aria-orientation is vertical. A horizontal tab list should not consume those keys, so normal browser scrolling can remain available. Do not parameterize all four arrows without reading the orientation. An implementation that prevents every arrow can make the horizontal test appear thorough while taking useful page behavior away.
Automatic tabs are another deliberate near-match. The APG allows the newly focused tab to activate during arrow navigation and recommends automatic activation when panels display without noticeable latency, which usually means the content is already available. That product decision produces a different oracle: after Right Arrow, focus and selection move together. Do not “repair” an automatic tab set to satisfy the manual test. Identify the intended model before filing the defect.
Loading behavior affects that decision. If moving to a tab starts a request and leaves focus navigation waiting on content, automatic activation can make exploration slow. Manual activation lets users move across labels before choosing a panel. Tests should not invent a latency threshold. Observe the product’s chosen model, and label any example timing as illustrative unless the team has measured it.
Tab exit also needs an integrated case. Pressing Tab while focus is within the tab list should move to the next element in the page sequence outside the list, which can be meaningful content in the active panel. A roving tabindex implementation normally keeps a single tab stop within the tab list. Test entry and exit on the full page because a self-contained tab fixture cannot expose an unexpected header control or sticky overlay inserted into the real sequence.
Disclosures and tabs show why a single activation helper becomes dangerous. They can share event logging and evidence attachment. They should not share an oracle that says “press Space and expect active=true.” One exposes aria-expanded and controlled visibility. The other coordinates focus, aria-selected, tab order, and panel visibility.
Roll the checks into an existing suite without hiding old defects
An established suite usually contains direct clicks against custom controls. Replacing every click in one pull request creates noisy failures and makes it hard to separate accessibility defects from fixture mistakes. Inventory the controls first. Search for non-native elements with interactive roles, tabindex changes, key handlers, click-only handlers, aria-expanded, aria-pressed, aria-selected, and custom disabled logic. Then group findings by APG pattern rather than by CSS component name.
Choose one representative control per pattern and perform a manual keyboard review before automating it. That review freezes the actual contract or exposes a design decision that has never been made. Write down whether tabs use manual or automatic activation, where focus goes after a disclosure closes, whether disabled commands remain focusable, and which business effect proves a single action. Automation should capture an agreed behavior, not settle an unresolved product debate by accident.
Add a stable focus origin to component fixtures. The origin is not test decoration. It proves the target participates in the tab sequence and reveals if a refactor inserts another stop. Also add a visible, inspectable effect boundary such as a local request recorder or domain event count. Keep it outside the activation handler so the test can detect a disconnected command.
Migrate in three passes. First, add reachability and role/name checks without changing the component. Existing failures become known defects with owners, not assertions quietly skipped forever. Second, repair the highest-risk patterns, starting with destructive actions, submissions, purchases, and controls that trap focus. Third, add role-specific key matrices and negative controls. This order gives the team useful evidence even when a complete component rewrite cannot land immediately.
Known failures need an explicit policy. A skipped test with no issue reference disappears from normal review. A retry can turn an intermittent focus loss into a green job while Playwright labels the result flaky. Prefer a small, visible list of expected failures with owners and removal conditions, or keep the job non-blocking for a short migration window while publishing its report. Do not change the assertion to match the defect.
Keep the CI project narrow at first. The configuration below looks only for keyboard specification files, retains a trace for a failed run, saves failure screenshots, and does not retry. The zero-retry choice costs convenience, but it prevents a second attempt from reclassifying a duplicate action or focus race as an acceptable pass.
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/accessibility",
testMatch: "**/*.keyboard.spec.ts",
retries: 0,
reporter: [
["line"],
[
"html",
{
open: "never",
outputFolder: "playwright-report"
}
]
],
use: {
trace: "retain-on-failure",
screenshot: "only-on-failure"
},
projects: [
{
name: "chromium-keyboard",
use: { browserName: "chromium" }
}
]
});The Playwright test options document retain-on-failure tracing. The assertion reference confirms that focus, visibility, text, and attribute checks are auto-retrying web assertions. Those retries poll the expected condition within one test run; they are different from rerunning the whole failed test. Avoid fixed sleeps because they add latency without saying which state the test needs.
Trace retention consumes storage, and event attachments add more. Keep evidence small and attach detailed logs on failure or during active diagnosis. A JSON file containing six event records, the final state, the effect count, and the active element is easier to review than a full DOM serialization. Set an artifact retention policy in the CI system based on the team’s debugging window rather than keeping every trace indefinitely.
Browser coverage has a direct runtime cost. Start with the primary supported engine on each change, then run a risk-based supported-browser matrix on a scheduled or release workflow. Expand per-change coverage when a widget relies on browser defaults, when a defect reproduced in only one engine, or when the control is central to checkout or account access. Do not multiply every state by every engine just to make the test count impressive.
Parallel execution introduces state risk. Two workers toggling the same saved view can make each other’s initial count and pressed state wrong. Give each worker an isolated account or unique record, or keep the effect inside a local fixture for the key matrix. One integrated test can verify persistence against the real service with controlled data. This split keeps the fast pattern checks deterministic without pretending the service boundary is covered everywhere.
Role-based locators impose a useful migration cost: they fail when the accessible role or name disappears. A team may be tempted to switch to a test id to get the build green. Keep the role locator for the semantic contract. Use a test id for an evidence-only output such as save-count, where no user-facing role or label is relevant.
Manual keyboard review remains on the release path for changes that affect layout, overlays, focus styling, or page order. Automation covers known routes through known states. A person can notice a barely visible indicator, an unexpected intermediate stop, or a control that is technically reachable but confusing to operate. Record browser, zoom, and assistive setup for that review so “works for me” does not become the only evidence.
Know when this test is aimed at the wrong problem
Do not require button keys from a link. Enter activates a link in the APG pattern; Space normally has another page-level function. If a control looks like a link but performs an in-page command, the underlying role and visual design need review. Changing the test to accept whichever behavior currently exists leaves users with an ambiguous control.
Do not apply the manual-tabs oracle to automatic tabs. In automatic activation, arrow navigation may change focus, selection, and the visible panel together. In manual activation, arrow navigation changes focus first and Enter or Space commits selection. The logs can look nearly identical if the test records only the final panel, so capture the state immediately after the arrow and before activation.
Do not call an arrow-navigation failure an activation-key failure. A tab can respond correctly to Enter and Space while Right Arrow leaves focus stuck. That defect belongs to the composite navigation contract. Conversely, a working arrow path does not prove the newly focused manual tab can be selected.
Do not use a keyboard test to prove the quality of the visible focus indicator. The test can place focus, assert the active element, and compare a reviewed screenshot. WCAG SC 2.4.7 is about a visible indicator, not merely the browser’s internal focus state. A manual or visual accessibility review must judge the indicator in its real context. WCAG 2.2 also includes other focus criteria, but cite and test them only when the acceptance criteria actually cover their requirements.
Do not infer screen reader support from a passing role locator. Playwright’s role locator uses accessible semantics and gives valuable early feedback, but the Playwright documentation explicitly says role selectors do not replace accessibility audits and conformance tests. Announcements, reading modes, virtual cursor behavior, and support differences require separate assistive technology checks. Keep those results attached to a named environment rather than writing “screen-reader compatible” from one automated assertion.
Do not send a synthetic event and call the integrated path covered. A unit test may dispatch keydown to verify a handler branch. An end-to-end check should focus the control and use browser keyboard input so default behavior, focus, and the rendered result remain in scope. If synthetic dispatch passes while Playwright keyboard input fails, the difference is a useful diagnostic clue, not a reason to replace the browser test.
Do not assert an internal keydown-versus-keyup choice unless it is the verified source of a user-facing bug. The pattern requires an activation result. Locking the suite to an event phase can reject a safe refactor that preserves behavior. The duplicate reproducer inspected both phases because two handlers caused two saves, not because every component must activate during one prescribed phase.
Do not blame the key when pointer activation fails too. If clicking and pressing Enter both reach the handler, but the server rejects the request, investigate authorization, validation, or service state. If neither input reaches the handler after a render, investigate a detached or covered node. The event and effect evidence should direct ownership before the issue title is written.
Do not treat a modal’s deliberate focus containment as a keyboard trap without testing its full pattern. A modal dialog is expected to keep Tab and Shift+Tab within the dialog while it is open, and it needs a keyboard route to close and return to a logical place. That is different from a broken widget that offers no exit. Use the APG dialog pattern for that review rather than copying a disclosure test.
Do not demand that aria-disabled remove every custom control from the tab sequence. Products sometimes keep an unavailable command focusable so its presence and disabled state can be discovered. Native disabled buttons behave differently. The correct assertion comes from the reviewed focus policy, while the non-negotiable business assertion is that the unavailable action does not run.
Skip custom activation code altogether when native HTML expresses the behavior without loss. Keeping a div-based button solely to preserve existing tests reverses the priority. Update the markup, adapt the visual styles, and let the role-based tests expose any semantic or focus regression introduced during migration.
Finally, stop expanding the key matrix when the remaining combinations belong to a different pattern, an assistive technology study, or a browser conformance test with no application-specific risk. Spend that time on one destructive action that can fire twice, one disclosure whose content and aria-expanded can diverge, and one composite whose focus can become stranded. Those cases fail for different reasons, and each produces evidence a developer can act on.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 02Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 03Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
- 04Official w3.org reference
w3.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why does Space scroll instead of activating my custom button?
A non-native element has no built-in button keyboard behavior. Its handler must implement the chosen pattern and suppress Space scrolling at the appropriate point, or the team can replace it with a native button.
Should a Playwright keyboard test call click after pressing Enter?
No. Pressing the key and then calling click introduces a second action and can hide broken keyboard wiring. Drive the key while focused, then assert the state and business effect produced by that key alone.
Which keys must a role=button widget support?
The APG button pattern assigns both Enter and Space to activation. That mapping applies when the control truly behaves as a button; a link uses Enter, and composite widgets have their own contracts.
How do I test tabs that use manual activation?
For a manually activated horizontal tablist, Left Arrow and Right Arrow move focus, while Enter or Space activates the focused tab. Assert focus and aria-selected separately so an automatic-activation implementation cannot pass by accident.
Does aria-disabled stop a custom control from running?
By itself, aria-disabled communicates state, but a custom control still needs code that blocks its action. Test the protected effect, not only the attribute, because a correct-looking disabled widget can still send a request.
RELATED GUIDES
Continue the learning route
GUIDE 01
Flaky Tests: Causes and How to Fix Them
Learn how to fix flaky tests with root cause analysis, stable waits, quarantine strategy, CI retries policy, and practical Playwright examples.
GUIDE 02
Custom Playwright Matchers: Extend and Merge expect Safely
Create retry-aware Playwright custom matchers, merge expect modules without collisions, and preserve useful negation, timeout, and failure output.
GUIDE 03
Parameterize Playwright Tests Without Breaking Hooks or Reporting
Parameterize Playwright tests with typed case tables, correct hook boundaries, unique titles, project options, and reports that preserve per-case diagnosis.
GUIDE 04
Create Playwright Custom Reporter Attachments for Evidence
Master Playwright custom reporter attachments with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.