Skip to content

fix: option position announcement for virtual mode#1226

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

fix: option position announcement for virtual mode#1226
Pareder wants to merge 7 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 通报逻辑,完善 aria-setsizearia-posinset,并在分组场景中跳过组标题以保持实际选项连续序号。
    • 改进虚拟渲染的相邻隐藏/辅助区域分组包裹方式,使屏幕阅读顺序更贴近原生行为。
  • Tests

    • 新增 3 条无障碍测试用例,覆盖虚拟模式下位置/规模计算及跨组计数规则。

@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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7ea611a3-b8ee-4e2d-91c3-c02e0649846e

📥 Commits

Reviewing files that changed from the base of the PR and between 2c814d0 and 53adb6b.

⛔ 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
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/OptionList.tsx
  • tests/Accessibility.test.tsx

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: zombiej

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
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: private package registry requires authentication. Disable ESLint in CodeRabbit settings or use public packages.


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
Comment on lines +278 to +282
// Skip group headers to match native <select> announcements with <optgroup>
const optionCount = memoFlattenOptions.filter((option) => !option.group).length;

const getOptionPosition = (index: number) =>
memoFlattenOptions.slice(0, index + 1).filter((option) => !option.group).length;

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.

high

Calculating optionCount and getOptionPosition on every render performs array filtering and slicing operations ($O(N)$) over the entire memoFlattenOptions list. Since getOptionPosition is called up to three times per render (for activeIndex - 1, activeIndex, and activeIndex + 1), and renders occur on every keyboard navigation step (as activeIndex changes), this can cause noticeable lag/performance degradation in large virtualized lists.

Since memoFlattenOptions is already memoized, we can precompute the option count and positions map in a single $O(N)$ pass using React.useMemo. This reduces the lookup in getOptionPosition to $O(1)$ and avoids redundant array allocations and filtering on every render.

  // Skip group headers to match native <select> announcements with <optgroup>
  const [optionCount, optionPositions] = React.useMemo(() => {
    let count = 0;
    const positions = new Array(memoFlattenOptions.length);
    for (let i = 0; i < memoFlattenOptions.length; i += 1) {
      if (!memoFlattenOptions[i].group) {
        count += 1;
      }
      positions[i] = count;
    }
    return [count, positions];
  }, [memoFlattenOptions]);

  const getOptionPosition = (index: number) => optionPositions[index] || 0;

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.

Check this one

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@afc163 Done. By the way it won't be a problem when using React Compiler.

@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.45%. Comparing base (03bf553) to head (53adb6b).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1226      +/-   ##
==========================================
+ Coverage   99.44%   99.45%   +0.01%     
==========================================
  Files          31       31              
  Lines        1271     1295      +24     
  Branches      466      449      -17     
==========================================
+ Hits         1264     1288      +24     
  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?

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

2 participants