Skip to content

fix: option position announcement for virtual mode#1226

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

fix: option position announcement for virtual mode#1226
Pareder wants to merge 4 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

Walkthrough

该 PR 调整了 Select 虚拟列表模式下的无障碍通报方式,新增选项位置序号计算与 aria-setsizearia-posinset 输出,并重构隐藏项渲染以保留分组结构,同时补充了对应测试。

Changes

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

Layer / File(s) Summary
选项位置计数与 ARIA 属性
src/OptionList.tsx
新增非分组选项的位置数组,并在选项容器上输出 aria-setsizearia-posinset;标题项继续使用 aria-label
隐藏项分组渲染实现
src/OptionList.tsx
新增分组回溯与隐藏项渲染逻辑,围绕活动项收集相邻选项,并按分组关系输出 role="group" 包裹或直接输出 option。
无障碍属性测试覆盖
tests/Accessibility.test.tsx
新增三组测试,覆盖虚拟模式下的 aria-posinsetaria-setsize、分组场景下的连续计数,以及分组与顶层选项并存时的分段渲染。

🎯 3 (Moderate) | ⏱️ ~25 分钟

相关 PR

建议审查人

  • afc163
  • zombieJ

兔子之诗 🐰

小兔跳过虚拟栏,
序号清清报平安。
分组层层排整齐,
屏幕阅读更自然。
轻轻一蹦春风里,
选项朗朗不走偏。

🚥 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 改动集中在 OptionList 无障碍渲染及相关测试,未见明显与目标无关的额外变更。
✨ Finishing Touches
🧪 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
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 (54e068f) to head (2c814d0).

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      470       +4     
==========================================
+ 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.

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