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

Open Beginner friendly
#8,840 0 comments 0 reactions 0 assignees View on GitHub

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 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
  1. Render an NcSelect with :options="['production', 'RBAC Automations App']" and no option / selected-option slot.
  2. Open the dropdown and inspect the accessibility tree (Chrome DevTools → Accessibility, or await page.accessibility.snapshot()).
  3. 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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.