Skip to content

a11y: add main landmarks, drop bogus listbox roles, size footer tap targets - #478

Draft
alukach wants to merge 1 commit into
mainfrom
a11y/landmarks-and-list-semantics
Draft

a11y: add main landmarks, drop bogus listbox roles, size footer tap targets#478
alukach wants to merge 1 commit into
mainfrom
a11y/landmarks-and-list-semantics

Conversation

@alukach

@alukach alukach commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Part of #471.

Three accessibility failures from the Lighthouse audit. Independent of the perf PRs in the epic.

Review tip: use ?w=1. Most of the page.tsx diff is re-indentation from wrapping sections in <main>; the real change is two lines.

1. landmark-one-main — no <main> anywhere

grep found zero <main> or role="main" in src/, so this failed on every route, not just the landing page.

  • (app) layout — added via Radix asChild, so <main> replaces the existing Box element rather than adding a DOM node. Covers every app route.
  • Marketing pagemain starts after <Navigation /> rather than wrapping the page, because Landing.module.css pins the nav background with .landingInner > nav. Hoisting the nav out would silently drop that background.

Consequence worth flagging: the case-study and tout sections below the fold sit outside the landmark. Fixing that properly means restructuring the page's three sibling wrapper boxes, which is a real layout risk for a cosmetic gain — deliberately not bundled here.

2. aria-input-field-name — a listbox that isn't one

ProductsList declared role="listbox" with role="option" children. Reading useProductListKeyboardShortcuts, the j/k navigation is a document-level keydown handler — no tabindex, no focus management, no aria-activedescendant. So the markup promised a widget that was never implemented, in three separate ways:

  1. The listbox had no accessible name → the reported failure.
  2. It was never focusable and never pointed at an active option, so a screen reader user got no selection feedback at all.
  3. role="option" may not contain interactive descendants, and each <li> wraps a ProductListItem containing a link — screen readers may hide it.

Fix is a deletion: drop both roles. The existing <nav aria-label="Product list"> already names the region, and aria-current carries the keyboard highlight without claiming widget semantics. Visual behavior is unchanged — isSelected still drives styling.

3. target-size — 15px footer links

The four footer links render at 0.75rem, giving a ~15px tall hit box against the 24px WCAG 2.2 minimum. display: inline-block is the load-bearing part: vertical padding on an inline element paints without growing the hit box. Widths (29–58px) already passed, so only height needed fixing.

Verification

  • npx jest src/components/features/products src/components/layout "src/app/(app)/products"21 passed, 1 failed. The failure is DropdownSection.integration.test.tsx, which I confirmed fails identically on clean main (pre-existing, untouched by this PR).
  • npm run type-check — 14 errors, identical to the baseline on main.
  • Not re-run through Lighthouse: these need a deployed preview. Worth confirming the three audits flip on the preview URL before merging.

🤖 Generated with Claude Code

Three failures from a Lighthouse accessibility audit of the landing page.

landmark-one-main: there was no <main> anywhere in src/, so this failed on
every route, not just the landing page. Added to the (app) layout via
asChild (no extra DOM node) and to the marketing page. On the marketing
page main starts after <Navigation /> because Landing.module.css pins the
nav background with `.landingInner > nav`, so the nav has to stay a direct
child of that box — the sections below the fold stay outside the landmark
as a result.

aria-input-field-name: the product list declared role="listbox" with
role="option" children, but the j/k shortcuts are a document-level keydown
handler with no focus management and no aria-activedescendant. The roles
promised a widget that was never implemented, the listbox had no
accessible name, and role="option" may not contain the link each item
renders. Dropped both roles — the <nav aria-label="Product list"> wrapper
already carries the naming — and moved the highlight to aria-current.

target-size: the four footer links are 0.75rem, giving a ~15px tall hit
box against the 24px WCAG 2.2 minimum. inline-block plus vertical padding
is what actually grows the box; padding alone on an inline element paints
without changing hit testing.

Refs #471
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
source-cooperative Ready Ready Preview Aug 3, 2026 4:35pm

Request Review

@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 2m 0s —— View job


❌ Changes requested — see findings below.

Verified the three fixes do what the PR description claims: the .landingInner > nav CSS selector still matches (Navigation stays a direct-child sibling of <main>), the asChild/main pattern is consistent with existing usage in the codebase (Button asChild, Link asChild), the keyboard-shortcut hook is a pure document-level listener with no role/aria-selected querying to break, and no test/CSS in the repo references the removed role="listbox"/role="option".

One real issue survived that check:

  • src/components/features/products/ProductsList.tsx:60 — the new aria-current on <li> duplicates an existing aria-current="page" already set one level down, on the Box rendered by ProductListItem (src/components/features/products/ProductListItem.tsx:31). For the selected item, a screen reader now encounters two ancestor/descendant elements both marked current, with different token values ("true" vs "page") for the same piece of state. This PR is exactly the right place to fix it since it's already touching this markup to clean up ARIA semantics — right now it adds a second, conflicting aria-current rather than replacing the incorrect one.

    // ProductsList.tsx — drop the current marking here, since ProductListItem's
    // Box already carries aria-current="page" for isSelected
    <li key={`${product.account_id}/${product.product_id}`}>
      <ProductListItem
        product={product}
        isSelected={index === selectedIndex}
      />
    </li>

    (Separately, aria-current="page" in ProductListItem.tsx is itself a questionable token for a keyboard-highlighted list item rather than a page-navigation link — but that line predates this PR, so flagging only the newly-introduced duplication as blocking here.)

Everything else — the main landmark placement in both layouts, the Footer.tsx tap-target sizing (correctly scoped to just the four "ABOUT US" links via the pathname === "/" branch), and the CSS selector dependency — checks out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant