Skip to content

fix: option position announcement for virtual mode - #1226

Open
Pareder wants to merge 10 commits into
react-component:masterfrom
Pareder:fix/option-position
Open

fix: option position announcement for virtual mode#1226
Pareder wants to merge 10 commits into
react-component:masterfrom
Pareder:fix/option-position

Conversation

@Pareder

@Pareder Pareder commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Problem

With virtual mode enabled, the hidden accessibility container only renders 3 options around the active one (activeIndex ± 1). Screen readers computed option positions from the DOM, so users always heard "1 of 3" / "2 of 3" / "3 of 3" regardless of the actual option position and list size.

Fix

  • Added explicit aria-posinset / aria-setsize to the hidden options so screen readers announce the real position and total (e.g. "4 of 6").
  • Positions count only real options, skipping group headers — matching native <select> + <optgroup> announcements.
  • Replaced flat role="presentation" group headers in the hidden container with role="group" + aria-label wrappers nesting their options, so screen readers also announce the group name when the active option enters a new group (MDN example).

Tests

  • New regression tests covering position announcements on render and during keyboard navigation, including grouped options (continuous numbering across groups, labelled group wrappers, no position attributes on headers).
  • Updated snapshots reflect the new attributes on role="option" elements only.

Related issues

Fixes ant-design/ant-design#58346

Summary by CodeRabbit

发布说明

  • Bug Fixes

    • 优化虚拟列表的无障碍体验,完善选项 aria-setsizearia-posinset 信息。
    • 分组场景下跳过组标题计数,确保选项序号连续,并改进隐藏辅助内容的分组呈现与阅读顺序。
    • 支持使用分组标题作为辅助区域标签。
  • Tests

    • 新增 4 条无障碍测试,覆盖滚动、跨组计数、分段渲染及分组标签。

@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

@Pareder is attempting to deploy a commit to the React Component Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

该 PR 调整 Select 虚拟列表的无障碍序号通报。代码新增 ARIA 位置属性,按分组结构渲染隐藏选项,并补充虚拟列表与分组场景测试。

Changes

虚拟列表无障碍属性与分组渲染

Layer / File(s) Summary
选项位置计数与 ARIA 属性
src/OptionList.tsx
新增非分组选项的位置映射,并输出 aria-setsizearia-posinset。标题项使用 aria-label
隐藏项分组渲染实现
src/OptionList.tsx
围绕活动项收集相邻选项,按所属分组输出 role="group" 容器。未分组选项直接渲染。
无障碍属性测试覆盖
tests/Accessibility.test.tsx
覆盖虚拟模式下的 ARIA 序号、跨分组连续计数、分组边界渲染和分组标题标签。

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: afc163, qdyanbing

Poem

小兔检查虚拟栏,
序号连续报平安。
分组包裹排整齐,
隐藏选项不走散。
无障碍通报更清晰。

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了虚拟模式下修复选项位置播报的主要变更。
Linked Issues check ✅ Passed 变更通过 aria-posinset、aria-setsize 和分组处理,满足完整列表位置播报及屏幕阅读器支持目标。[#58346]
Out of Scope Changes check ✅ Passed 代码和测试变更均围绕虚拟模式下的无障碍位置播报,没有发现无关或超出范围的变更。
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request improves accessibility in virtualized select lists by adding aria-setsize and aria-posinset attributes to options (skipping group headers) and nesting options inside role="group" wrappers to match native <select> announcements. Corresponding unit and snapshot tests have been added or updated. Feedback is provided regarding a performance issue in src/OptionList.tsx, where calculating option counts and positions on every render performs O(N) operations; it is recommended to precompute these values using React.useMemo to achieve O(1) lookups and prevent lag in large lists.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/OptionList.tsx Outdated
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.46%. Comparing base (dcd39c2) to head (8dc092c).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1226      +/-   ##
==========================================
+ Coverage   99.44%   99.46%   +0.01%     
==========================================
  Files          31       31              
  Lines        1271     1301      +30     
  Branches      466      472       +6     
==========================================
+ Hits         1264     1294      +30     
  Misses          7        7              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/Accessibility.test.tsx (1)

266-303: ⚡ Quick win

建议补一条“group wrapper 不应带 aria-posinset/aria-setsize”的显式断言。

当前测试已验证 option 上有位置属性,但还没直接锁定 role="group" 容器不应携带这些属性。补上后可更稳地覆盖“仅 option 有位置语义”的回归面。

✅ 可追加断言示例
       groupWrappers = getGroupWrappers();
       expect(groupWrappers).toHaveLength(1);
       expect(groupWrappers[0]).toHaveAttribute('aria-label', 'Second group');
+      groupWrappers.forEach((group) => {
+        expect(group).not.toHaveAttribute('aria-posinset');
+        expect(group).not.toHaveAttribute('aria-setsize');
+      });
🤖 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 `@tests/Accessibility.test.tsx` around lines 266 - 303, Add explicit assertions
to ensure the group wrapper elements returned by getGroupWrappers() do not carry
positional attributes; after fetching groupWrappers (e.g., the variable
groupWrappers used in the test) assert that groupWrappers[0] (and any group
wrapper in the array) does not have aria-posinset and does not have
aria-setsize, similar to how hiddenOptions (from getHiddenOptions()) are
asserted for those attributes so the test guarantees only option elements expose
position semantics.
🤖 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 `@src/OptionList.tsx`:
- Around line 304-306: The aria-label currently only sets when mergedLabel is a
string, which drops semantic labels for numeric titles; update the condition to
reuse the existing isTitleType check and coerce the title to string (e.g.,
aria-label={isTitleType(mergedLabel) ? String(mergedLabel) : null}) so numeric
labels are exposed to assistive tech; apply the same change to the other
occurrence that sets aria-label (the second block using
mergedLabel/getOptionPosition).

---

Nitpick comments:
In `@tests/Accessibility.test.tsx`:
- Around line 266-303: Add explicit assertions to ensure the group wrapper
elements returned by getGroupWrappers() do not carry positional attributes;
after fetching groupWrappers (e.g., the variable groupWrappers used in the test)
assert that groupWrappers[0] (and any group wrapper in the array) does not have
aria-posinset and does not have aria-setsize, similar to how hiddenOptions (from
getHiddenOptions()) are asserted for those attributes so the test guarantees
only option elements expose position semantics.
🪄 Autofix (Beta)

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: a0427256-7a22-48f6-b5ce-5248b088f57f

📥 Commits

Reviewing files that changed from the base of the PR and between 7876c6c and aa2d435.

⛔ Files ignored due to path filters (3)
  • tests/__snapshots__/OptionList.test.tsx.snap is excluded by !**/*.snap
  • tests/__snapshots__/Select.test.tsx.snap is excluded by !**/*.snap
  • tests/__snapshots__/Tags.test.tsx.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • src/OptionList.tsx
  • tests/Accessibility.test.tsx

Comment thread src/OptionList.tsx Outdated
@Pareder

Pareder commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

@afc163 @zombieJ @meet-student @QDyanbing Could you please review?

@Pareder

Pareder commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@afc163 Could you please take a look?

@afc163

afc163 commented Jun 26, 2026

Copy link
Copy Markdown
Member

Thanks for the update. The current direction looks good to me: adding aria-posinset / aria-setsize to the virtual a11y options is the right fix for the screen reader position announcement issue, and the latest memoized optionPositions version avoids the repeated slice/filter work during keyboard navigation.

One small edge case before merging: aria-label is now only emitted when the option/group label is a string. Numeric labels are valid and would lose their accessible name in the hidden list. Could you reuse the existing title-like check and stringify it for both option and group labels, e.g. isTitleType(label) ? String(label) : null?

Please also check the Codecov failures, or leave a note if they are expected/unrelated. The Vercel failure looks like the usual authorization issue and is not a code signal.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/Accessibility.test.tsx (1)

307-318: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

显式开启 virtual,避免用例依赖默认值。

这里验证的是虚拟模式下隐藏 a11y 容器的分段渲染,但 virtual 没有显式传入;如果默认值以后调整,这个用例就不再稳定地覆盖这条路径了。Line 307-318 建议把场景条件写死。

♻️ 建议修改
       const { container } = render(
         <Select
           id="virtual-select"
           open
+          virtual
           options={[
🤖 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 `@tests/Accessibility.test.tsx` around lines 307 - 318, The Select
accessibility test currently relies on the component’s default virtual behavior,
which makes the scenario brittle if the default changes. Update the Select usage
in the virtual a11y case to explicitly pass virtual so the test always exercises
the hidden accessibility container path. Keep the change local to the test case
around the Select and its grouped options setup.
🤖 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.

Nitpick comments:
In `@tests/Accessibility.test.tsx`:
- Around line 307-318: The Select accessibility test currently relies on the
component’s default virtual behavior, which makes the scenario brittle if the
default changes. Update the Select usage in the virtual a11y case to explicitly
pass virtual so the test always exercises the hidden accessibility container
path. Keep the change local to the test case around the Select and its grouped
options setup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 47d4a3a6-f7ca-445e-b0fb-bbcc10862375

📥 Commits

Reviewing files that changed from the base of the PR and between 3cbfc05 and 2c814d0.

📒 Files selected for processing (2)
  • src/OptionList.tsx
  • tests/Accessibility.test.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/OptionList.tsx

@Pareder

Pareder commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

@afc163 Done. Fixed value for labels and adjusted code coverage.

@Pareder

Pareder commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

@afc163 @zombieJ Could you please review?

@Pareder

Pareder commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

@afc163 @zombieJ Could you please review?

@Pareder
Pareder requested a review from afc163 July 24, 2026 09:46
@Pareder

Pareder commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@afc163 @zombieJ Could you please review?

@yoyo837 yoyo837 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the accessibility improvement. I found two follow-up suggestions around large grouped lists and non-text group labels. I am leaving these as comments rather than an approval/request-changes review.

Comment thread src/OptionList.tsx Outdated
return;
}

const groupItem = item.groupOption ? getGroupItem(index) : null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we precompute the owning group for each flattened item together with optionPositions? getGroupItem walks backward from the active index for each of up to three hidden items, so keyboard navigation near the end of a large single group can become O(N) per render and partially undermine virtualization. A memoized parallel array/map would make these lookups O(1).

Comment thread src/OptionList.tsx Outdated
<div
key={group.key}
role="group"
aria-label={isTitleType(groupLabel) ? String(groupLabel) : null}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we preserve an accessible name when the group label is a React node? Option/group labels may be non-string React nodes; in that case this new role="group" wrapper is unnamed, so assistive technology cannot announce the group name. Consider using aria-labelledby with a hidden element that renders the label, or falling back to a string data.title when available, and add a regression test with a React-node group label.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks. I rechecked the latest head (8dc092c) and the React-node case is still unnamed unless the caller also supplies title. For example, with label={<span>React node group</span>} and no title, Testing Library reports the role="group" accessible name as empty, so getByRole('group', { name: 'React node group' }) fails. Since DefaultOptionType.label supports ReactNode, the data.title fallback does not cover the normal React-node-label case by itself. Could we keep this thread open and either expose a stable accessible-name source (or honor an explicit aria-label/aria-labelledby), or avoid emitting an unnamed group? A regression test for a React-node group label would catch this.

Comment thread tests/Accessibility.test.tsx Fixed
@Pareder
Pareder force-pushed the fix/option-position branch from 0b2f977 to 8dc092c Compare August 3, 2026 07:57
@Pareder

Pareder commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@yoyo837 Fixed both suggestions. As to React-node group label I've added data.title handling, the same way it works for non-virtual list https://github.com/react-component/select/blob/master/src/OptionList.tsx#L348.

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.

Select: options are announced incorrectly

4 participants