nextcloud-libraries / nextcloud-libraries/nextcloud-vue
NcEllipsisedOption exposes its presentational DOM split to assistive tech — every NcSelect option ≥10 chars gets a wrong accessible name
Nobody has claimed this yet.
- Dominant language
- Vue
- Stars
- 246
- Forks
- 99
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 103
Description
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
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
- Render an
NcSelectwith:options="['production', 'RBAC Automations App']"and nooption/selected-optionslot. - Open the dropdown and inspect the accessibility tree (Chrome DevTools → Accessibility, or
await page.accessibility.snapshot()). - The option's accessible name is
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
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by locating the NcEllipsisedOption component and the NcSelect option and selected-option fallback described in the issue; inspect the split spans and their accessibility attributes. Add a regression test using getByRole('option', { name: 'production' }) and verify that the accessible name matches the label while the visual split remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- css, javascript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 75/100