Skip to content

NcEllipsisedOption exposes its presentational DOM split to assistive tech — every NcSelect option ≥10 chars gets a wrong accessible name #8840

Description

@rubenvdlinde

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions