Skip to content

Commit 24313d6

Browse files
authored
fix(stepper): make step title keyboard and screen-reader accessible (#1397)
1 parent 567febe commit 24313d6

4 files changed

Lines changed: 96 additions & 3 deletions

File tree

src/components/Stepper/Step/Step.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
text-decoration: underline;
3030
}
3131

32+
.step-enabled {
33+
@include vf-focus-themed;
34+
}
35+
3236
.step-disabled {
3337
color: $colors--theme--text-muted;
3438
pointer-events: none;

src/components/Stepper/Step/Step.test.tsx

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { render, screen } from "@testing-library/react";
2+
import { createEvent, fireEvent, render, screen } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import Step from "./Step";
55
import type { Props } from "./Step";
@@ -14,6 +14,10 @@ describe("Step component", () => {
1414
handleClick: jest.fn(),
1515
};
1616

17+
beforeEach(() => {
18+
jest.clearAllMocks();
19+
});
20+
1721
it("renders the step with the required props", () => {
1822
render(<Step {...props} />);
1923
expect(screen.getByText("Title")).toBeInTheDocument();
@@ -47,6 +51,55 @@ describe("Step component", () => {
4751
expect(props.handleClick).toHaveBeenCalled();
4852
});
4953

54+
it("exposes the title as a button role", () => {
55+
render(<Step {...props} />);
56+
expect(screen.getByRole("button", { name: "Title" })).toBeInTheDocument();
57+
});
58+
59+
it("is keyboard focusable and not aria-disabled when enabled", () => {
60+
render(<Step {...props} />);
61+
const title = screen.getByText("Title");
62+
expect(title).toHaveAttribute("tabindex", "0");
63+
expect(title).toHaveAttribute("aria-disabled", "false");
64+
});
65+
66+
it("calls handleClick when Enter is pressed and enabled", async () => {
67+
render(<Step {...props} />);
68+
screen.getByText("Title").focus();
69+
await userEvent.keyboard("{Enter}");
70+
expect(props.handleClick).toHaveBeenCalled();
71+
});
72+
73+
it("calls handleClick and prevents default when Space is pressed and enabled", () => {
74+
render(<Step {...props} />);
75+
const title = screen.getByText("Title");
76+
const event = createEvent.keyDown(title, { key: " " });
77+
fireEvent(title, event);
78+
expect(props.handleClick).toHaveBeenCalled();
79+
expect(event.defaultPrevented).toBe(true);
80+
});
81+
82+
it("is not focusable and is aria-disabled when disabled", () => {
83+
render(<Step {...props} enabled={false} />);
84+
const title = screen.getByText("Title");
85+
expect(title).toHaveAttribute("tabindex", "-1");
86+
expect(title).toHaveAttribute("aria-disabled", "true");
87+
});
88+
89+
it("does not call handleClick on keyboard activation when disabled", () => {
90+
render(<Step {...props} enabled={false} />);
91+
const title = screen.getByText("Title");
92+
fireEvent.keyDown(title, { key: "Enter" });
93+
fireEvent.keyDown(title, { key: " " });
94+
expect(props.handleClick).not.toHaveBeenCalled();
95+
});
96+
97+
it("does not call handleClick when clicked while disabled", async () => {
98+
render(<Step {...props} enabled={false} />);
99+
await userEvent.click(screen.getByText("Title"));
100+
expect(props.handleClick).not.toHaveBeenCalled();
101+
});
102+
50103
it("can display optional label", () => {
51104
render(<Step {...props} label="Optional label" />);
52105

src/components/Stepper/Step/Step.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ const Step = ({
6363
}: Props): React.JSX.Element => {
6464
const stepStatusClass = enabled ? "step-enabled" : "step-disabled";
6565

66+
const handleKeyDown = (event: React.KeyboardEvent<HTMLSpanElement>) => {
67+
if (!enabled) {
68+
return;
69+
}
70+
const isEnter = event.key === "Enter";
71+
const isSpace = event.key === " " || event.key === "Spacebar";
72+
if (isEnter || isSpace) {
73+
if (event.repeat) {
74+
return;
75+
}
76+
if (isSpace) {
77+
event.preventDefault();
78+
}
79+
handleClick();
80+
}
81+
};
82+
6683
return (
6784
<div
6885
className={classNames("step", {
@@ -86,7 +103,14 @@ const Step = ({
86103
/>
87104
)}
88105
<div className="step-content">
89-
<span className={classNames(stepStatusClass)} onClick={handleClick}>
106+
<span
107+
className={classNames(stepStatusClass)}
108+
onClick={enabled ? handleClick : undefined}
109+
onKeyDown={handleKeyDown}
110+
role="button"
111+
tabIndex={enabled ? 0 : -1}
112+
aria-disabled={!enabled}
113+
>
90114
{title}
91115
</span>
92116
{label && (

src/components/Stepper/__snapshots__/Stepper.test.tsx.snap

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

33
exports[`Stepper component renders the stepper 1`] = `
44
<ul
@@ -17,7 +17,10 @@ exports[`Stepper component renders the stepper 1`] = `
1717
class="step-content"
1818
>
1919
<span
20+
aria-disabled="false"
2021
class="step-enabled"
22+
role="button"
23+
tabindex="0"
2124
>
2225
Step 1
2326
</span>
@@ -42,7 +45,10 @@ exports[`Stepper component renders the stepper 1`] = `
4245
class="step-content"
4346
>
4447
<span
48+
aria-disabled="false"
4549
class="step-enabled"
50+
role="button"
51+
tabindex="0"
4652
>
4753
Step 2
4854
</span>
@@ -69,7 +75,10 @@ exports[`Stepper component renders the stepper 1`] = `
6975
class="step-content"
7076
>
7177
<span
78+
aria-disabled="false"
7279
class="step-enabled"
80+
role="button"
81+
tabindex="0"
7382
>
7483
Step 3
7584
</span>
@@ -102,7 +111,10 @@ exports[`Stepper component renders the stepper 1`] = `
102111
class="step-content"
103112
>
104113
<span
114+
aria-disabled="true"
105115
class="step-disabled"
116+
role="button"
117+
tabindex="-1"
106118
>
107119
Step 4
108120
</span>

0 commit comments

Comments
 (0)