Describe the bug
NcEllipsisedOption splits any label of 10 characters or more into two sibling <span>s in the DOM. Because the wrapper is display: flex, each part is a flex item, so accessible-name computation joins them with a space. Every NcSelect option and selected-option therefore gets an accessible name that is not the label:
| label |
announced |
production |
produ ction |
Automations |
Automa tions |
openregister |
openre gister |
RBAC Automations App |
RBAC Autom ations App |
The split is presentational only — it exists so that CSS text-overflow: ellipsis can clip the middle while the tail stays visible. Nothing about it should reach the accessibility tree.
Consequences:
- Screen-reader users hear a mangled word for every option in every
NcSelect whose label is ≥10 characters (WCAG 2.2 4.1.2 Name, Role, Value; and 2.5.3 Label in Name where the option carries a visible label).
getByRole('option', { name: 'production' }) — Testing Library, Playwright, Cypress, axe — cannot match, because the accessible name is produ ction. The failure presents as a missing element rather than as an a11y defect, so it is routinely "fixed" by loosening the name matcher, which hides the real problem.
This is not the same as #2363 (grapheme-cluster breakage in Arabic/Persian, closed) or #402 (visual spacing). Those are about how the split looks; this is about the split being exposed to assistive technology at all. Fixing the accessible name would also make the #2363 class of visual complaints less harmful, but they are separable.
Steps to reproduce
- Render an
NcSelect with :options="['production', 'RBAC Automations App']" and no option / selected-option slot.
- Open the dropdown and inspect the accessibility tree (Chrome DevTools → Accessibility, or
await page.accessibility.snapshot()).
- The option's accessible name is
produ ction, not production.
Equivalently, in a test: getByRole('option', { name: 'production' }) times out; locator('[title="production"]') matches.
Expected behaviour
The accessible name of an option equals its label.
Actual behaviour
The accessible name is the label with a space inserted at the split index.
Where it comes from
NcSelect renders NcEllipsisedOption as the default content of both the option and the selected-option slots, so this is the behaviour of a plain NcSelect, not an opt-in:
option: withCtx((option) => [
renderSlot(_ctx.$slots, 'option', /* … */, () => [
createVNode(_component_NcEllipsisedOption, { name: String(option[$options.localLabel]), search: $data.search }),
]),
]),
'selected-option': withCtx((selectedOption) => [ /* same fallback */ ]),
The arithmetic in NcEllipsisedOption:
needsTruncate() { return this.name && this.name.length >= 10 }
split() { return this.name.length - Math.min(Math.floor(this.name.length / 2), 10) }
part1() { return this.name.slice(0, this.split) } // <span class="name-parts__first">
part2() { return this.name.slice(this.split) } // <span class="name-parts__last">
and the CSS that shows the split is purely for clipping:
.name-parts { display: flex; max-width: 100%; }
.name-parts__first { overflow: hidden; text-overflow: ellipsis; }
.name-parts__first,
.name-parts__last { white-space: pre; }
Math.min(…, 10) reproduces every string in the table above exactly, so the behaviour is deterministic and version-independent, not environmental.
The wrapper already carries title="{{ name }}", but title is only a fallback in accessible-name computation — it is used when there is no other name source. Here the descendant text nodes supply the name and win, so the title does not repair the announcement. (It does make [title="…"] a usable test locator, which is the correct interim workaround for test suites, but it is a workaround, not a fix.)
Suggested fix
Keep the two parts for layout, but expose the name once:
<span
dir="auto"
class="name-parts"
+ :aria-label="name"
:title="name">
- <NcHighlight class="name-parts__first" :text="part1" :search="search" :highlight="highlight1" />
- <NcHighlight v-if="part2" class="name-parts__last" :text="part2" :search="search" :highlight="highlight2" />
+ <NcHighlight class="name-parts__first" aria-hidden="true" :text="part1" :search="search" :highlight="highlight1" />
+ <NcHighlight v-if="part2" class="name-parts__last" aria-hidden="true" :text="part2" :search="search" :highlight="highlight2" />
</span>
This changes nothing visually, restores the correct announcement, and makes role/name locators work again. (A visually-hidden full-name element plus aria-hidden on the parts is an equivalent alternative if aria-label on a non-interactive span is undesirable — aria-label on a generic element is ignored by some AT, so a visually-hidden text node may be the safer of the two.)
An alternative worth considering is dropping the DOM split entirely in favour of a CSS-only middle-ellipsis, but that is a larger change; the aria-hidden fix is behaviour-preserving.
A regression test asserting getByRole('option', { name: 'production' }) resolves would pin it.
Versions
Confirmed by reading the shipped bundle in @nextcloud/vue 9.9.0 (dist/chunks/NcEllipsisedOption-*.mjs, dist/chunks/NcSelect-*.mjs, dist/assets/NcEllipsisedOption-*.css) and present identically in 9.8.0 and 8.39.0, so it spans both the Vue 2 and Vue 3 lines.
Originally surfaced across several Nextcloud apps as NcSelect options that no role/name locator could reach; the accessible-name mangling turned out to be the cause.
Describe the bug
NcEllipsisedOptionsplits any label of 10 characters or more into two sibling<span>s in the DOM. Because the wrapper isdisplay: flex, each part is a flex item, so accessible-name computation joins them with a space. EveryNcSelectoption and selected-option therefore gets an accessible name that is not the label:productionprodu ctionAutomationsAutoma tionsopenregisteropenre gisterRBAC Automations AppRBAC Autom ations AppThe split is presentational only — it exists so that CSS
text-overflow: ellipsiscan clip the middle while the tail stays visible. Nothing about it should reach the accessibility tree.Consequences:
NcSelectwhose label is ≥10 characters (WCAG 2.2 4.1.2 Name, Role, Value; and 2.5.3 Label in Name where the option carries a visible label).getByRole('option', { name: 'production' })— Testing Library, Playwright, Cypress, axe — cannot match, because the accessible name isprodu ction. The failure presents as a missing element rather than as an a11y defect, so it is routinely "fixed" by loosening the name matcher, which hides the real problem.This is not the same as #2363 (grapheme-cluster breakage in Arabic/Persian, closed) or #402 (visual spacing). Those are about how the split looks; this is about the split being exposed to assistive technology at all. Fixing the accessible name would also make the #2363 class of visual complaints less harmful, but they are separable.
Steps to reproduce
NcSelectwith:options="['production', 'RBAC Automations App']"and nooption/selected-optionslot.await page.accessibility.snapshot()).produ ction, notproduction.Equivalently, in a test:
getByRole('option', { name: 'production' })times out;locator('[title="production"]')matches.Expected behaviour
The accessible name of an option equals its label.
Actual behaviour
The accessible name is the label with a space inserted at the split index.
Where it comes from
NcSelectrendersNcEllipsisedOptionas the default content of both theoptionand theselected-optionslots, so this is the behaviour of a plainNcSelect, not an opt-in:The arithmetic in
NcEllipsisedOption:and the CSS that shows the split is purely for clipping:
Math.min(…, 10)reproduces every string in the table above exactly, so the behaviour is deterministic and version-independent, not environmental.The wrapper already carries
title="{{ name }}", buttitleis only a fallback in accessible-name computation — it is used when there is no other name source. Here the descendant text nodes supply the name and win, so thetitledoes not repair the announcement. (It does make[title="…"]a usable test locator, which is the correct interim workaround for test suites, but it is a workaround, not a fix.)Suggested fix
Keep the two parts for layout, but expose the name once:
This changes nothing visually, restores the correct announcement, and makes role/name locators work again. (A visually-hidden full-name element plus
aria-hiddenon the parts is an equivalent alternative ifaria-labelon a non-interactivespanis undesirable —aria-labelon a generic element is ignored by some AT, so a visually-hidden text node may be the safer of the two.)An alternative worth considering is dropping the DOM split entirely in favour of a CSS-only middle-ellipsis, but that is a larger change; the
aria-hiddenfix is behaviour-preserving.A regression test asserting
getByRole('option', { name: 'production' })resolves would pin it.Versions
Confirmed by reading the shipped bundle in
@nextcloud/vue9.9.0 (dist/chunks/NcEllipsisedOption-*.mjs,dist/chunks/NcSelect-*.mjs,dist/assets/NcEllipsisedOption-*.css) and present identically in 9.8.0 and 8.39.0, so it spans both the Vue 2 and Vue 3 lines.Originally surfaced across several Nextcloud apps as
NcSelectoptions that no role/name locator could reach; the accessible-name mangling turned out to be the cause.