Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs-src/src/content/docs/guides/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion shepherd.js/src/components/shepherd-element.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 19 additions & 0 deletions shepherd.js/src/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
46 changes: 46 additions & 0 deletions shepherd.js/test/cypress/integration/a11y.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
237 changes: 237 additions & 0 deletions shepherd.js/test/unit/components/shepherd-element.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
13 changes: 13 additions & 0 deletions shepherd.js/test/unit/step.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading