Skip to content

fix: option position announcement for virtual mode#1226

Open
Pareder wants to merge 1 commit into
react-component:masterfrom
Pareder:fix/option-position
Open

fix: option position announcement for virtual mode#1226
Pareder wants to merge 1 commit 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属性以提高无障碍性,使其更符合原生选择组件的行为标准。

@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

功能概览

该 PR 改进了 Select 组件虚拟列表模式下的屏幕阅读器无障碍通报。通过新增选项位置计数、更新 ARIA 属性(aria-setsize、aria-posinset)、重构隐藏项分组渲染,使虚拟化选项的位置序号与总数能正确通报给屏幕阅读器,同时支持分组选项的嵌套结构。

改动详情

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

Layer / File(s) 摘要
选项位置计数与 ARIA 属性
src/OptionList.tsx
新增 optionCount 统计非分组选项总数,getOptionPosition 计算选项在跳过分组后的位置,更新选项容器的 aria-labelaria-setsizearia-posinset 属性以提供准确的列表大小和序号。
隐藏项分组渲染实现
src/OptionList.tsx
新增 getGroupItem 向前回溯查找分组元素,新增 renderHiddenItems 围绕当前活动索引构造相邻选项的隐藏内容,按分组包装为 role="group" 容器,非分组选项直接渲染。
虚拟模式的隐藏项集成
src/OptionList.tsx
在虚拟列表分支中,将固定渲染前后三个可见项的手动方式替换为调用 renderHiddenItems(),生成更接近原生分组结构的隐藏辅助内容。
无障碍属性测试覆盖
tests/Accessibility.test.tsx
新增两项测试:验证虚拟模式下隐藏容器中选项的 aria-posinsetaria-setsize 行为;验证分组选项时位置计数跳过组标题、跨组连续、父元素正确归属。

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

相关 PR

  • react-component/select#1077 — 同样修改 src/OptionList.tsx 的每项选项无障碍渲染,前一 PR 调整 combobox 模式的 aria-selected 计算。

建议审查人

  • afc163

兔子之诗 🐰

虚拟之列舞动妙,
分组藏匿无忧扰,
位置序号细声报,
屏幕小伙伴笑颜绽,
无碍天地显芬芳。 🎵✨

🚥 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 PR完整实现了linked issue #58346的所有编码要求:增加了aria-posinset和aria-setsize属性、跳过分组计数、添加回归测试。
Out of Scope Changes check ✅ Passed 所有改动都围绕虚拟列表模式下的可访问性问题,包括OptionList组件修改和新增测试用例,与linked issue范围一致。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 timed out. The project may have too many dependencies for the sandbox.


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 and usage tips.

@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
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;

@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 99.30%. Comparing base (7876c6c) to head (aa2d435).

Files with missing lines Patch % Lines
src/OptionList.tsx 96.42% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1226      +/-   ##
==========================================
- Coverage   99.44%   99.30%   -0.15%     
==========================================
  Files          31       31              
  Lines        1266     1292      +26     
  Branches      442      449       +7     
==========================================
+ Hits         1259     1283      +24     
- Misses          7        9       +2     

☔ 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
Comment on lines +304 to +306
aria-label={typeof mergedLabel === 'string' ? mergedLabel : null}
aria-setsize={optionCount}
aria-posinset={getOptionPosition(index)}

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

建议将数字标签也映射到 aria-label,避免可读名称丢失。

当前仅在 label 为字符串时设置 aria-label。当标签是数字时(合法场景),隐藏无障碍节点会丢失该标签语义,读屏更可能退化为读 value。建议复用现有 isTitleType 逻辑统一转字符串。

💡 建议修改
-        aria-label={typeof mergedLabel === 'string' ? mergedLabel : null}
+        aria-label={isTitleType(mergedLabel) ? String(mergedLabel) : null}
...
-          aria-label={typeof groupLabel === 'string' ? groupLabel : null}
+          aria-label={isTitleType(groupLabel) ? String(groupLabel) : null}

Also applies to: 361-362

🤖 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 `@src/OptionList.tsx` around lines 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).

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

1 participant