From 9f8dbf727f6b34f13038bd8f58aaddc7811fd869 Mon Sep 17 00:00:00 2001 From: Chuck Carpenter Date: Thu, 13 Aug 2026 14:17:52 +0200 Subject: [PATCH] feat: add `label` step option for the dialog's accessible name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step with no `title` gets no naming attribute at all on its ``, so the dialog has no accessible name and screen readers announce it without one. Steps whose content is text-only had no way to fix that short of adding a visible `title` and changing the UI. Add an optional `label` step option, emitted as `aria-label` on the dialog only when the step has no `title`. It accepts a string or a function returning one, matching `title` and the existing `label` options on buttons and the cancel icon. The function is invoked with the step as `this`, and only when its result will actually be used, so a `label` that throws (an i18n catalog that has not loaded yet, say) cannot break a titled step. When both `title` and `label` are given, `title` wins: `aria-labelledby` outranks `aria-label` in the accessible name computation, so emitting both would leave `label` silently dead, and it keeps the accessible name matching the visible heading (WCAG 2.5.3). The `title` path is untouched and its output is byte-identical. The resolved value is gated on its trimmed length before the attribute is set, so `label: ''`, `label: () => ''` and whitespace-only values omit `aria-label` rather than writing a name that assistive technology treats as empty. The untrimmed string is what gets written. Non-string values are ignored rather than coerced, so a stray plain-JS `label` cannot produce an `[object Object]` accessible name. Note on the original report: #2390 pastes a `
`, which is pre-v15 markup. v15 renders a native `` and sets no `role` attribute anywhere, so the specific axe rule cited in that report no longer selects this element. The underlying complaint is still real and is what this fixes — an unnamed dialog is an unnamed dialog regardless of which linter notices. Every existing step is unaffected: `applyAttrs` skips null, so the DOM for a step without `label` is byte-identical. The one behavior change is for a plain-JS consumer who was already passing an inert `label` key; on a title-less step that key now becomes an accessible name. Fixes #2390 Co-Authored-By: Claude Opus 5 --- docs-src/src/content/docs/guides/usage.md | 10 + .../src/components/shepherd-element.ts | 22 +- shepherd.js/src/step.ts | 19 ++ .../test/cypress/integration/a11y.cy.js | 46 ++++ .../unit/components/shepherd-element.spec.js | 237 ++++++++++++++++++ shepherd.js/test/unit/step.spec.js | 13 + 6 files changed, 346 insertions(+), 1 deletion(-) diff --git a/docs-src/src/content/docs/guides/usage.md b/docs-src/src/content/docs/guides/usage.md index 43c18852b..2eb8b4b65 100644 --- a/docs-src/src/content/docs/guides/usage.md +++ b/docs-src/src/content/docs/guides/usage.md @@ -198,6 +198,16 @@ Steps are instances of the Step object. They are generally created by the - `Function` to be executed when the step is built. It must return one the two options above. - `title`: The step's title. It becomes an `h3` at the top of the step. +- `label`: An `aria-label` for the step's dialog element. Use it to give a step + an accessible name when it has no visible `title` — a step with neither gets + no naming attribute at all, so its dialog has no accessible name and screen + readers announce it without one. It can also be a function that returns a + string (useful with i18n solutions). It is ignored when `title` is set, + because the title already supplies the accessible name via `aria-labelledby`; + a function-valued `label` is not invoked in that case. An empty or + whitespace-only value omits the attribute. It can be set on + `defaultStepOptions`, but prefer a distinct `label` per step so each dialog + has a meaningful, unique name. - `attachTo`: The element the step should be attached to on the page. An object with properties `element` and `on`. - `element`: An element selector string, a DOM element, or a function diff --git a/shepherd.js/src/components/shepherd-element.ts b/shepherd.js/src/components/shepherd-element.ts index e773d13ec..1ee06803a 100644 --- a/shepherd.js/src/components/shepherd-element.ts +++ b/shepherd.js/src/components/shepherd-element.ts @@ -1,6 +1,6 @@ import { h } from '../utils/dom.ts'; import { createShepherdContent } from './shepherd-content.ts'; -import { isUndefined, isString } from '../utils/type-check.ts'; +import { isUndefined, isString, isFunction } from '../utils/type-check.ts'; import type { Step } from '../step.ts'; import './shepherd-element.css'; @@ -41,6 +41,25 @@ export function createShepherdElement( const hasCancelIcon = step.options?.cancelIcon?.enabled ?? false; const hasTitle = step.options?.title ?? false; + // A step with no title has no naming attribute at all, so its dialog has no + // accessible name. `label` supplies one via `aria-label`. + // `title` wins when both are set, because `aria-labelledby` outranks + // `aria-label` in the accessible name computation, and because the + // accessible name should match the visible title (WCAG 2.5.3). The gate + // comes first so a function-valued `label` is never invoked when it would + // be discarded — a throwing or side-effecting one must not break a titled + // step. + const resolvedLabel = hasTitle + ? undefined + : isFunction(step.options.label) + ? (step.options.label.call(step) as string) + : step.options.label; + // Gate on the trimmed value so `''`, `() => ''` and whitespace-only names + // omit the attribute instead of emitting an accessible name that assistive + // technology treats as empty. The untrimmed value is what gets written. + const ariaLabel = + isString(resolvedLabel) && resolvedLabel.trim() ? resolvedLabel : null; + /** * Setup keydown events to allow closing the modal with ESC * @@ -116,6 +135,7 @@ export function createShepherdElement( // Build the dialog element const element = h('dialog', { 'aria-describedby': !isUndefined(step.options.text) ? descriptionId : null, + 'aria-label': ariaLabel, 'aria-labelledby': step.options.title ? labelId : null, class: [ 'shepherd-element', diff --git a/shepherd.js/src/step.ts b/shepherd.js/src/step.ts index c8804a265..58f918bc1 100644 --- a/shepherd.js/src/step.ts +++ b/shepherd.js/src/step.ts @@ -129,6 +129,25 @@ export interface StepOptions { */ id?: string; + /** + * The `aria-label` for the step's dialog, used to give the step an + * accessible name when it has no visible `title`. A step with neither gets + * no naming attribute at all, so its dialog has no accessible name and + * screen readers announce it without one. + * ``` + * - string + * - `Function` to be executed when the step is built. It must return a string. + * ``` + * Ignored when `title` is set: the title already supplies the accessible + * name via `aria-labelledby`, which outranks `aria-label` in the accessible + * name computation. In that case a function-valued `label` is not invoked + * at all. + * + * An empty or whitespace-only value omits the attribute rather than + * emitting an accessible name that assistive technology treats as empty. + */ + label?: StringOrStringFunction; + /** * An amount of padding to add around the modal overlay opening */ diff --git a/shepherd.js/test/cypress/integration/a11y.cy.js b/shepherd.js/test/cypress/integration/a11y.cy.js index 386aeb820..3799a950e 100644 --- a/shepherd.js/test/cypress/integration/a11y.cy.js +++ b/shepherd.js/test/cypress/integration/a11y.cy.js @@ -370,4 +370,50 @@ describe('a11y', () => { }); }); }); + + describe('accessible name', () => { + let tour; + + beforeEach(() => { + tour = new Shepherd.Tour({ + defaultStepOptions: { + classes: 'shepherd-theme-arrows', + scrollTo: true + } + }); + }); + + afterEach(() => { + tour?.complete(); + }); + + it('a text-only step is named by its `label`', () => { + tour.addStep({ + text: 'Testing the accessible name of a title-less step', + label: 'Welcome tour' + }); + + tour.start(); + + cy.get('.shepherd-element').should( + 'have.attr', + 'aria-label', + 'Welcome tour' + ); + cy.get('.shepherd-element').should('not.have.attr', 'aria-labelledby'); + }); + + it('a step with a title is named by the title, not the `label`', () => { + tour.addStep({ + title: 'Test Step', + text: 'Testing that the title wins', + label: 'Ignored' + }); + + tour.start(); + + cy.get('.shepherd-element').should('have.attr', 'aria-labelledby'); + cy.get('.shepherd-element').should('not.have.attr', 'aria-label'); + }); + }); }); diff --git a/shepherd.js/test/unit/components/shepherd-element.spec.js b/shepherd.js/test/unit/components/shepherd-element.spec.js index 5a00f6698..09a20d6d4 100644 --- a/shepherd.js/test/unit/components/shepherd-element.spec.js +++ b/shepherd.js/test/unit/components/shepherd-element.spec.js @@ -404,4 +404,241 @@ describe('components/ShepherdElement', () => { cleanup(); }); }); + + describe('accessible name', () => { + function build(stepOptions, tourOptions) { + const tour = new Tour(tourOptions); + const step = new Step(tour, stepOptions); + + const { element, cleanup } = createShepherdElement({ + descriptionId: 'test-desc', + labelId: 'test-label', + step + }); + container.appendChild(element); + + return { element, cleanup, step }; + } + + it('no title and no label leaves the dialog with no naming attribute', () => { + const { element, cleanup } = build({ text: 'Lorem Ipsum' }); + + expect(element).not.toHaveAttribute('aria-label'); + expect(element).not.toHaveAttribute('aria-labelledby'); + + cleanup(); + }); + + it('label sets aria-label when there is no title', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + label: 'Welcome tour' + }); + + expect(element.getAttribute('aria-label')).toBe('Welcome tour'); + expect(element).not.toHaveAttribute('aria-labelledby'); + + cleanup(); + }); + + it('label can be a function returning a string', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + label: () => 'Welcome tour' + }); + + expect(element.getAttribute('aria-label')).toBe('Welcome tour'); + + cleanup(); + }); + + it('label function is called with the step itself as `this`', () => { + let seenThis; + const { cleanup, step } = build({ + id: 'test-step', + text: 'Lorem Ipsum', + label: function () { + seenThis = this; + return 'Welcome tour'; + } + }); + + // Identity, not a shape match: `step.options` and any `{ id }` stand-in + // must not satisfy this. + expect(seenThis).toBe(step); + + cleanup(); + }); + + it('label sets aria-label on a step attached to an element', () => { + const testElement = document.createElement('div'); + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + attachTo: { element: testElement, on: 'top' }, + label: 'Welcome tour' + }); + + expect(element.getAttribute('aria-label')).toBe('Welcome tour'); + + cleanup(); + }); + + it('label sets aria-label on a step with no text', () => { + const { element, cleanup } = build({ label: 'Welcome tour' }); + + expect(element.getAttribute('aria-label')).toBe('Welcome tour'); + expect(element).not.toHaveAttribute('aria-describedby'); + + cleanup(); + }); + + it('label is inherited from defaultStepOptions', () => { + const { element, cleanup } = build( + { text: 'Lorem Ipsum' }, + { defaultStepOptions: { label: 'Welcome tour' } } + ); + + expect(element.getAttribute('aria-label')).toBe('Welcome tour'); + + cleanup(); + }); + + it('a function label is inherited from defaultStepOptions', () => { + const { element, cleanup } = build( + { text: 'Lorem Ipsum' }, + { defaultStepOptions: { label: () => 'Welcome tour' } } + ); + + expect(element.getAttribute('aria-label')).toBe('Welcome tour'); + + cleanup(); + }); + + it('title wins over label when both are set', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + title: 'Test', + label: 'Ignored' + }); + + expect(element.getAttribute('aria-labelledby')).toBe('test-label'); + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('a function label is never invoked when title wins', () => { + const label = vi.fn(() => 'Ignored'); + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + title: 'Test', + label + }); + + // Not merely discarded — never called. A `label` that throws (an i18n + // catalog that is not loaded yet, say) must not break a titled step. + expect(label).not.toHaveBeenCalled(); + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('a throwing label does not break a titled step', () => { + expect(() => + build({ + text: 'Lorem Ipsum', + title: 'Test', + label: () => { + throw new Error('i18n not ready'); + } + }) + ).not.toThrow(); + }); + + it('title alone still sets aria-labelledby and no aria-label', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + title: 'Test' + }); + + expect(element.getAttribute('aria-labelledby')).toBe('test-label'); + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('empty string label does not emit an empty aria-label', () => { + const { element, cleanup } = build({ text: 'Lorem Ipsum', label: '' }); + + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('function returning an empty string does not emit an empty aria-label', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + label: () => '' + }); + + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('whitespace-only label does not emit an aria-label', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + label: ' ' + }); + + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('function returning a whitespace-only string does not emit an aria-label', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + label: () => '\n\t ' + }); + + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('a non-string label is ignored rather than stringified', () => { + // `label` is typed `string | (() => string)`, but plain-JS consumers can + // pass anything. Coercing would write names like `[object Object]`. + const { element, cleanup } = build({ text: 'Lorem Ipsum', label: {} }); + + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('a label function returning a non-string is ignored', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + label: () => 42 + }); + + expect(element).not.toHaveAttribute('aria-label'); + + cleanup(); + }); + + it('a padded label is emitted with its surrounding whitespace intact', () => { + const { element, cleanup } = build({ + text: 'Lorem Ipsum', + label: ' Welcome tour ' + }); + + // Only the truthiness gate trims; the author's string is written as-is. + expect(element.getAttribute('aria-label')).toBe(' Welcome tour '); + + cleanup(); + }); + }); }); diff --git a/shepherd.js/test/unit/step.spec.js b/shepherd.js/test/unit/step.spec.js index 094236656..32d919a71 100644 --- a/shepherd.js/test/unit/step.spec.js +++ b/shepherd.js/test/unit/step.spec.js @@ -598,6 +598,19 @@ describe('Tour | Step', () => { 'test-step-description' ); }); + + it('`label` sets aria-label on a step with no title', () => { + const step = new Step(tour, { + id: 'test-step', + text: 'Lorem Ipsum', + label: 'Welcome' + }); + + const element = step._createTooltipContent(); + + expect(element.getAttribute('aria-label')).toBe('Welcome'); + expect(element.getAttribute('aria-labelledby')).toBeNull(); + }); }); describe('correct operation of classes on body element when step not attached to an element', () => {