From de1caa6f6712b1b336f14927243b63dbda8f3c6d Mon Sep 17 00:00:00 2001
From: adamXbot <111877622+adamXbot@users.noreply.github.com>
Date: Thu, 23 Jul 2026 22:51:36 +1000
Subject: [PATCH] fix(a11y): make import candidate selection a native
radiogroup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The iTunes match candidates in SearchResultBlock were bare clickable
s — no role, no tabIndex, no keyboard path, nothing exposed to
assistive tech. They are now rows inside a
labelled role="radiogroup" using the wizard's existing roving
pattern (lib/use-roving-radiogroup.ts): Tab enters the group on the
chosen row (first row when nothing is chosen, via rovingTabIndex),
arrows move with selection-follows-focus, and clicking/Space on the
chosen row still clears it — the hook's checked guard keeps arrow
passes from clearing rows on the way through.
Details:
- group aria-label carries the query (new i18n key
onboard.search_block.candidates_group_aria, en + zh)
- the decorative checkmark span is aria-hidden and the candidate icon
alt is empty — the row's accessible name is the candidate name/dev
text, not repeated image alt
- .candidate-row CSS neutralises UA button chrome; layout unchanged
- the fixme-gated keyboard spec in onboarding-keyboard.spec.ts is now
live and guards the arrow-selection contract (Shift+Tab into the
group, ArrowDown selects the alternate candidate)
Verified: full Playwright suite 34 passed / 0 skipped (axe scan of
the match step, keyboard specs, and all click-based candidate flows);
438 unit tests; typecheck, lint, and locale parity clean.
Co-Authored-By: Claude Fable 5
---
app/components/OnboardWizard.tsx | 204 ++++++++++++++------------
app/globals.css | 10 ++
locales/en.json | 1 +
locales/zh.json | 1 +
tests/e2e/a11y.spec.ts | 2 +-
tests/e2e/onboarding-keyboard.spec.ts | 102 +++++++------
6 files changed, 182 insertions(+), 138 deletions(-)
diff --git a/app/components/OnboardWizard.tsx b/app/components/OnboardWizard.tsx
index 91518d8..c1fa15a 100644
--- a/app/components/OnboardWizard.tsx
+++ b/app/components/OnboardWizard.tsx
@@ -8990,6 +8990,7 @@ function SearchResultBlock({
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(result.query);
const [draftDeveloper, setDraftDeveloper] = useState(developerHint);
+ const candidateRadioKeyDown = useRovingRadioGroup();
// When collapsed and the user has chosen a candidate, show THAT one — not
// the iTunes #1 pick — so "Show less" after selecting a non-top match
@@ -9313,75 +9314,93 @@ function SearchResultBlock({
) : (
<>
- {candidates.map((candidate) => {
- // Bundle-ID fallback catches the legacy-import duplicate
- // case where the same physical app exists under a
- // different App Store track ID — see TrackedApp comment.
- const candidateTracked =
- trackedByAppleId.get(candidate.appleId) ??
- (candidate.bundleId
- ? trackedByBundleId?.get(candidate.bundleId)
- : undefined);
- const bundleMismatch = Boolean(
- result.sourceBundleId &&
- candidate.bundleId &&
- result.sourceBundleId.toLowerCase() !==
- candidate.bundleId.toLowerCase()
- );
- return (
-
- onChoose(
- chosen?.appleId === candidate.appleId ? null : candidate
- )
- }
- >
-
+ {candidates.map((candidate, candidateIndex) => {
+ // Bundle-ID fallback catches the legacy-import duplicate
+ // case where the same physical app exists under a
+ // different App Store track ID — see TrackedApp comment.
+ const candidateTracked =
+ trackedByAppleId.get(candidate.appleId) ??
+ (candidate.bundleId
+ ? trackedByBundleId?.get(candidate.bundleId)
+ : undefined);
+ const bundleMismatch = Boolean(
+ result.sourceBundleId &&
+ candidate.bundleId &&
+ result.sourceBundleId.toLowerCase() !==
+ candidate.bundleId.toLowerCase()
+ );
+ const isChosen = chosen?.appleId === candidate.appleId;
+ return (
+ onChoose(isChosen ? null : candidate)}
+ role="radio"
+ tabIndex={rovingTabIndex(
+ isChosen,
+ candidateIndex,
+ chosenIsVisibleWhenCollapsed
+ )}
+ type="button"
>
- {chosen?.appleId === candidate.appleId ? "✓" : ""}
-
+
+ {isChosen ? "✓" : ""}
+
- {candidate.iconUrl && (
-
- )}
-
-
- {candidate.name}
- {/* Inline "already tracking" chip. Renders for every
+ {candidate.iconUrl && (
+
+ )}
+
+
+ {candidate.name}
+ {/* Inline "already tracking" chip. Renders for every
tracked candidate (not just the chosen one) so
users browsing alternate matches can still tell
which rows would re-sync rather than add a
@@ -9391,33 +9410,34 @@ function SearchResultBlock({
deliberate: the chip is visible alongside the
name even on long lists where the block header
has scrolled off. */}
- {candidateTracked && (
-
- {t("candidate_tracking_chip")}
-
- )}
- {bundleMismatch && (
-
- Bundle differs
-
+ {candidateTracked && (
+
+ {t("candidate_tracking_chip")}
+
+ )}
+ {bundleMismatch && (
+
+ Bundle differs
+
+ )}
+
+
{candidate.developer}
+ {result.sourceBundleId && (
+
+ Imported {result.sourceBundleId}
+ {candidate.bundleId
+ ? ` · App Store ${candidate.bundleId}`
+ : ""}
+
)}
-
{candidate.developer}
- {result.sourceBundleId && (
-
- Imported {result.sourceBundleId}
- {candidate.bundleId
- ? ` · App Store ${candidate.bundleId}`
- : ""}
-
- )}
-
-
- );
- })}
+
+ );
+ })}
+
{result.candidates.length > 1 && (
(native semantics for the
+ candidate picker) — neutralise the UA button chrome so it keeps
+ rendering as the flex row it always was. */
+ appearance: none;
cursor: pointer;
+ background: transparent;
+ border: 0;
border-radius: var(--r-md);
transition: background 0.12s;
}
diff --git a/locales/en.json b/locales/en.json
index 025e019..507c344 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -835,6 +835,7 @@
"task_steps_label": "{done} / {total} steps"
},
"search_block": {
+ "candidates_group_aria": "App Store matches for {query}",
"edit_app_name": "App name",
"edit_seller": "Seller",
"edit_seller_csv": "(from CSV — tweak to re-rank)",
diff --git a/locales/zh.json b/locales/zh.json
index b63ccd9..6719932 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -835,6 +835,7 @@
"task_steps_label": "{done} / {total} 步"
},
"search_block": {
+ "candidates_group_aria": "“{query}”的 App Store 匹配结果",
"edit_app_name": "应用名称",
"edit_seller": "开发者",
"edit_seller_csv": "(来自 CSV — 调整以重新排序)",
diff --git a/tests/e2e/a11y.spec.ts b/tests/e2e/a11y.spec.ts
index 80c0f04..60ff042 100644
--- a/tests/e2e/a11y.spec.ts
+++ b/tests/e2e/a11y.spec.ts
@@ -253,7 +253,7 @@ browserFlow(
});
// Step 3 — matched block with the candidate list expanded, so the
- // candidate rows (the surface awaiting native controls) are in
+ // candidate rows (a roving radiogroup of role=radio buttons) are in
// the scanned DOM.
await page.getByTestId("onboard-app-names").fill("Notes");
await page.getByTestId("imported-apps-add").click();
diff --git a/tests/e2e/onboarding-keyboard.spec.ts b/tests/e2e/onboarding-keyboard.spec.ts
index 2dafcb9..d88be86 100644
--- a/tests/e2e/onboarding-keyboard.spec.ts
+++ b/tests/e2e/onboarding-keyboard.spec.ts
@@ -14,10 +14,9 @@ import { tabTo } from "./helpers/keyboard";
* "Keyboard navigation" system setting makes WebKit skip buttons and
* links entirely, which is an OS behaviour, not a bug (see AGENTS.md).
*
- * The candidate-selection spec at the bottom is `fixme`-gated: it is
- * the acceptance test for the tracked candidate-selection fix
- * (candidate rows are bare clickable s today). Remove the `fixme`
- * in the PR that converts them to native controls.
+ * Covers the full manual-entry path including candidate selection —
+ * the candidate rows are a roving radiogroup, so the last spec guards
+ * the arrow-key selection contract.
*/
const sameOriginHeaders = {
@@ -186,49 +185,62 @@ browserFlow(
);
// ---------------------------------------------------------------------------
-// Acceptance test for the tracked candidate-selection fix
+// Candidate selection — regression guard for the radiogroup conversion
// ---------------------------------------------------------------------------
//
-// The candidate rows in SearchResultBlock are bare clickable
s
-// today (no role, no tabIndex, no keyboard handler), so this spec
-// cannot pass. It documents the required end state: expand the
-// alternate candidates and choose one using only the keyboard. When
-// the fix converts the rows to native controls (radiogroup pattern, like
-// the wizard's method cards), remove this `fixme` and the spec becomes
-// the regression guard for that fix.
-
-// `test.fixme` (not `browserFlow.fixme`) — the CODEX_SANDBOX alias is
-// `test.skip`, which has no `.fixme` property; fixme already never runs.
-test.fixme("keyboard-only: pick a non-default candidate in the match step (pending candidate-row fix)", async ({
- page,
-}) => {
- await mockSearchFromFixtures(page);
- await keyboardToTextEntry(page);
-
- const textarea = page.getByTestId("onboard-app-names");
- await tabTo(page, textarea);
- await page.keyboard.type("Notes");
- const addBtn = page.getByTestId("imported-apps-add");
- await tabTo(page, addBtn);
- await page.keyboard.press("Enter");
- const searchBtn = page.getByTestId("onboard-search");
- await tabTo(page, searchBtn);
- await page.keyboard.press("Enter");
+// The candidate rows are `` inside a roving
+// radiogroup (lib/use-roving-radiogroup.ts): Tab enters the group on
+// the chosen row, arrows move with selection-follows-focus. This spec
+// picks the non-default Apple candidate using only the keyboard.
- const block = page
- .locator(".search-result-item")
- .filter({ hasText: "Notes" });
- await expect(block).toHaveCount(1);
+browserFlow(
+ "keyboard-only: pick a non-default candidate in the match step",
+ async ({ page }) => {
+ await mockSearchFromFixtures(page);
+ await keyboardToTextEntry(page);
- // Expand the alternate candidates from the keyboard.
- const showMore = block.locator(".show-more-btn");
- await tabTo(page, showMore);
- await page.keyboard.press("Enter");
+ const textarea = page.getByTestId("onboard-app-names");
+ await tabTo(page, textarea);
+ await page.keyboard.type("Notes");
+ const addBtn = page.getByTestId("imported-apps-add");
+ await tabTo(page, addBtn);
+ await page.keyboard.press("Enter");
+ const searchBtn = page.getByTestId("onboard-search");
+ await tabTo(page, searchBtn);
+ await page.keyboard.press("Enter");
- // Reach the Apple candidate row and select it — this is the part
- // that requires the candidate rows to become native controls.
- const appleRow = block.locator(".candidate-row").filter({ hasText: "Apple" });
- await tabTo(page, appleRow);
- await page.keyboard.press("Space");
- await expect(appleRow).toHaveClass(/chosen/);
-});
+ const block = page
+ .locator(".search-result-item")
+ .filter({ hasText: "Notes" });
+ await expect(block).toHaveCount(1);
+
+ // The auto-chosen top candidate is the group's tab stop.
+ const defaultRow = block
+ .locator(".candidate-row")
+ .filter({ hasText: "Random Notes Co" });
+
+ // Expand the alternate candidates from the keyboard.
+ const showMore = block.locator(".show-more-btn");
+ await tabTo(page, showMore);
+ await page.keyboard.press("Enter");
+
+ // Radios sit before the show-more button in DOM order, so step
+ // BACK into the group — focus lands on the chosen radio (the
+ // roving tab stop), then ArrowDown moves to the Apple row and
+ // selects it (selection follows focus).
+ await page.keyboard.press("Shift+Tab");
+ await expect(defaultRow).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+
+ const appleRow = block
+ .locator(".candidate-row")
+ .filter({ hasText: "Apple" });
+ await expect(appleRow).toBeFocused();
+ await expect(appleRow).toHaveClass(/chosen/);
+ await expect(appleRow).toHaveAttribute("aria-checked", "true");
+ await expect(defaultRow).toHaveAttribute("aria-checked", "false");
+
+ // The import CTA reflects the keyboard-made choice.
+ await expect(page.getByTestId("onboard-confirm-import")).toBeEnabled();
+ }
+);