Skip to content

fix(a11y): correct dropdown combobox/listbox structure (D3) - #15

Merged
BrianGenisio merged 4 commits into
mainfrom
fix/a11y-d3-dropdown-listbox
Aug 12, 2026
Merged

fix(a11y): correct dropdown combobox/listbox structure (D3)#15
BrianGenisio merged 4 commits into
mainfrom
fix/a11y-d3-dropdown-listbox

Conversation

@BrianGenisio

@BrianGenisio BrianGenisio commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #8 ([a11y][D3]). The dropdown now exposes a recognizable select-only combobox → listbox pattern instead of a button popup with button-hosted options.

Changes

In components/dropdown/:

  • Toggle: role="combobox", aria-haspopup="listbox", aria-controls → listbox id (base component, not only PortalDropdown)
  • Popup: role="listbox"; .dropdown-menu-list is role="presentation" so options stay owned by the listbox
  • Options: plain div[role=option][tabindex=-1] (not <button>); decorative checkmarks aria-hidden

D4 (aria-selected) intentionally untouched — sequenced follow-up. No app cooperating change required; PortalDropdown keeps working because aria-controls still targets this.menu.

Test plan

  • Playwright against components/dropdown/test.html (light + dark): toggleRole=combobox, aria-haspopup=listbox, aria-controls resolves, menuRole=listbox, listRole=presentation, optionTag=DIV, no button options, ArrowDown still focuses an option
  • Manual: open dropdown in VoiceOver / accessibility tree and confirm options are owned by the listbox
  • Spot-check visual layout of options (still 40px rows, selected checkmark)

Made with Cursor

Use the APG select-only combobox pattern: combobox toggle with
aria-haspopup=listbox and aria-controls, presentational list wrapper so
options are owned by the listbox, and non-button option hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The dropdown toggle now uses select-only combobox semantics and links to a unique listbox. Menu items use focusable div[role="option"] elements. Focus-out handling closes the dropdown when focus leaves the widget. README, Playwright tests, accessibility checks, and CI configuration document and verify the behavior.

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: correcting the dropdown combobox/listbox accessibility structure.
Description check ✅ Passed The description directly explains the dropdown accessibility changes, acceptance scope, and verification performed.
Linked Issues check ✅ Passed The changes satisfy issue #8 by correcting roles, listbox ownership, aria-haspopup, base aria-controls, and light/dark axe coverage.
Out of Scope Changes check ✅ Passed The focus handling, tests, documentation, configuration, and CI changes support the linked accessibility objectives and are in scope.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/dropdown/dropdown.js`:
- Around line 121-125: Update the dropdown focus management around the option
elements created in the dropdown initialization so focusout closes the popup
when focus moves outside both this.toggle and this.menu, including Tab
navigation to page controls; preserve it when focus remains within the dropdown.
Register the handler consistently and remove the same listener in destroy(),
then add coverage for Tab and the intended Shift+Tab behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2e8a815-aaec-42d4-93d3-18274a3ae8e4

📥 Commits

Reviewing files that changed from the base of the PR and between b6e7127 and 30251d8.

📒 Files selected for processing (3)
  • components/dropdown/README.md
  • components/dropdown/dropdown.css
  • components/dropdown/dropdown.js

Comment thread components/dropdown/dropdown.js
Listen for focusout on the toggle and menu so Tab to page controls
dismisses the open listbox, while Shift+Tab from an option back to the
toggle keeps it open.

Co-authored-by: Cursor <cursoragent@cursor.com>
@BrianGenisio

Copy link
Copy Markdown
Contributor Author

Addressed the Tab focusout finding in 5b467d9:

  • focusout on toggle + menu closes when relatedTarget is outside both (no focus steal: close(false))
  • Shift+Tab option → toggle keeps the menu open
  • Listener removed in destroy()
  • Documented Tab / Shift+Tab in the dropdown README
  • Playwright check: Tab from option/open toggle → next control + closed; Shift+Tab from option → toggle + open; Shift+Tab from open toggle → previous control + closed

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Changed files in the reported commit ==='
git show --stat --oneline --decorate --no-renames 5b467d9

printf '%s\n' '=== Changed diff ==='
git show --format=fuller --find-renames --find-copies --no-ext-diff 5b467d9 -- \
  components/dropdown/dropdown.js \
  components/dropdown/README.md \
  components/dropdown/dropdown.css

printf '%s\n' '=== Dropdown-related files ==='
fd . components/dropdown -t f 2>/dev/null | sort

printf '%s\n' '=== Test files mentioning dropdown or focus behavior ==='
rg -n -i -C 3 '(dropdown|focusout|Shift\+Tab|ArrowDown|listbox|combobox)' \
  -g '*.{js,ts,mjs,cjs}' \
  -g '!node_modules' \
  . | head -n 320

Length of output: 28231


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Pull request metadata and changed files ==='
gh pr view 15 --repo CodeSignal/learn_bespoke-design-system \
  --json baseRefName,headRefName,commits,files \
  --jq '{baseRefName, headRefName, commits: [.commits[] | {oid: .oid, messageHeadline: .messageHeadline}], files: [.files[] | {path, additions, deletions}]}'

printf '%s\n' '=== Worktree status ==='
git status --short

printf '%s\n' '=== Dropdown source map ==='
ast-grep outline components/dropdown/dropdown.js --items all --view expanded

printf '%s\n' '=== Focus, close, portal, and destroy implementation ==='
nl -ba components/dropdown/dropdown.js | sed -n '1,125p;180,375p;620,655p'

printf '%s\n' '=== Playwright configuration and test inventory ==='
fd -HI -t f \
  -e js -e ts -e mjs -e cjs \
  -g '*playwright*' -g '*spec*' -g '*test*' \
  . | sort | head -n 300

printf '%s\n' '=== Test definitions for dropdown focus behavior ==='
rg -n -i -C 6 \
  '(test|it)\s*\(.*(dropdown|Tab|Shift\+Tab|focusout)|dropdown.*(Tab|Shift\+Tab|focusout)|page\.keyboard\.press|keyboard\.press' \
  -g '*.{js,ts,mjs,cjs}' \
  -g '!node_modules' \
  . | head -n 400

Length of output: 3038


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '=== Dropdown focus and lifecycle implementation ==='
sed -n '1,125p;180,375p;620,655p' components/dropdown/dropdown.js

printf '%s\n' '=== All candidate test and Playwright files ==='
find . \
  -path './.git' -prune -o \
  -path './node_modules' -prune -o \
  -type f \( \
    -iname '*playwright*' -o \
    -iname '*spec.js' -o -iname '*spec.ts' -o \
    -iname '*test.js' -o -iname '*test.ts' \
  \) -print | sort | head -n 300

printf '%s\n' '=== Repository references to Playwright and dropdown keyboard checks ==='
rg -n -i -C 5 \
  '(playwright|`@playwright/test`|page\.keyboard\.press|keyboard\.press|Shift\+Tab|focusout)' \
  -g '*.{js,ts,mjs,cjs,json,yml,yaml}' \
  -g '!node_modules' \
  . | head -n 400

printf '%s\n' '=== Full pull request patch summary ==='
git diff --check main...HEAD
git diff --stat main...HEAD

Length of output: 17101


@BrianGenisio The focusout implementation closes with close(false) and preserves focus on the next page control. The containment check also keeps the menu open for option-to-toggle Shift+Tab. The listener cleanup is correct.

I found one remaining issue: PR #15 contains no Playwright configuration or test file changes. The repository search also found no Playwright tests. Please add the stated Tab and Shift+Tab regression checks to a tracked test suite. This is required to verify the behavior and prevent regressions.

⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Track Tab / Shift+Tab open-state regressions in tests/dropdown-focus.spec.js
and run them on PRs via a Playwright workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
@BrianGenisio

Copy link
Copy Markdown
Contributor Author

Added a tracked Playwright suite for the Tab / Shift+Tab regression checks:

  • tests/dropdown-focus.spec.js — 4 cases (Tab from option, Shift+Tab option→toggle, Shift+Tab from open toggle, Tab from open toggle)
  • playwright.config.mjs — boots test-server.js via webServer
  • .github/workflows/pr.ymlnpm test on PRs
  • package.json / lockfile — @playwright/test

Local: npm ci && npx playwright install chromium && npm test (4 passed).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/pr.yml:
- Around line 10-11: Update the actions/checkout step to set persist-credentials
to false, preventing the checkout token from being stored in local Git
configuration while preserving the existing repository checkout behavior.

In `@tests/dropdown-focus.spec.js`:
- Around line 45-122: Add light-mode and dark-mode coverage to the dropdown
focus test suite, preserving the existing focus-transition assertions in the
tests under dropdown focus. Add axe accessibility validation for the dropdown in
each relevant theme, using the repository’s established theme setup and
accessibility-checking utilities.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cd02eb81-453f-4664-ab21-95f5a7fdfe06

📥 Commits

Reviewing files that changed from the base of the PR and between 5b467d9 and 970e384.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • .github/workflows/pr.yml
  • .gitignore
  • components/dropdown/README.md
  • package.json
  • playwright.config.mjs
  • tests/dropdown-focus.spec.js

Comment thread .github/workflows/pr.yml
Comment thread tests/dropdown-focus.spec.js Outdated
Disable persisted checkout credentials in PR CI, run Tab/Shift+Tab
cases under both color schemes, and add a scoped axe helper. Name the
combobox via aria-labelledby so axe button-name/4.1.2 passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@BrianGenisio

Copy link
Copy Markdown
Contributor Author

Addressed the remaining review findings:

  1. persist-credentials: false on actions/checkout@v6
  2. Light + dark — focus cases run under both Playwright colorScheme values (drives @media (prefers-color-scheme: dark))
  3. Axetests/helpers/a11y.js + open-dropdown scan scoped to #dropdown-basic in each theme
  4. Combobox namearia-labelledby on the toggle (needed for axe button-name / 4.1.2; DS had no prior a11y helper to reuse)

npm test: 10 passed.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Your plan includes PR reviews subject to rate limits. Reviews are available now.

@BrianGenisio
BrianGenisio merged commit 124c1b0 into main Aug 12, 2026
1 of 2 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
components/dropdown/dropdown.js (1)

127-131: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Expose the selected state on each option.

The new role="option" elements only use .selected and an aria-hidden checkmark. Set aria-selected="true" on the selected option and "false" on the others. Keep the attribute synchronized in selectItem(). Otherwise, assistive technology cannot report the current selection.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/dropdown/dropdown.js` around lines 127 - 131, Update the option
creation logic around menuItem and selectItem() so every role="option" element
initializes aria-selected to "true" for the selected item and "false" otherwise,
then synchronize that attribute whenever selectItem() changes the selection.
Keep the existing .selected and aria-hidden checkmark behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@components/dropdown/dropdown.js`:
- Around line 127-131: Update the option creation logic around menuItem and
selectItem() so every role="option" element initializes aria-selected to "true"
for the selected item and "false" otherwise, then synchronize that attribute
whenever selectItem() changes the selection. Keep the existing .selected and
aria-hidden checkmark behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1d170d4d-dc1c-4c66-a110-a756b53f3e45

📥 Commits

Reviewing files that changed from the base of the PR and between 970e384 and 14dc9a6.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (6)
  • .github/workflows/pr.yml
  • components/dropdown/README.md
  • components/dropdown/dropdown.js
  • package.json
  • tests/dropdown-focus.spec.js
  • tests/helpers/a11y.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • .github/workflows/pr.yml
  • components/dropdown/README.md
  • package.json
  • tests/dropdown-focus.spec.js

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.

[a11y][D3] Dropdown listbox/option structure is malformed

1 participant