Skip to content

Commit 1eb4d1c

Browse files
committed
test(v2): add axe accessibility gate + keyboard nav tests to Storybook
Wire axe-core into the vitest Storybook runner so every v2 lib story is scanned for accessibility violations, gated per story by the @storybook/addon-a11y `a11y.test` parameter (error / todo / off) with a global default of "error". Fix the unlabeled-control violations this surfaces (icon-only buttons, decorative images) and mark the remaining component-level gaps as `todo` so CI stays green while the backlog stays visible. Add keyboard-operability play() tests to RSwitch, RTabNav, and RRating. Refs #1848 Generated-By: PostHog Code Task-Id: b44aba2c-90c9-407d-8c80-5bbe77ecd6c1
1 parent 95b9ca8 commit 1eb4d1c

21 files changed

Lines changed: 252 additions & 23 deletions

File tree

frontend/.storybook/preview.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ const preview: Preview = {
5555
parameters: {
5656
layout: "centered",
5757
backgrounds: { disable: true },
58+
// Accessibility gate: axe fails on any violation; known gaps opt down to `test: "todo"`. See #1848.
59+
a11y: {
60+
test: "error",
61+
},
5862
controls: {
5963
matchers: {
6064
color: /(background|color)$/i,

frontend/package-lock.json

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"@vitest/ui": "^4.1.5",
7676
"@vue/test-utils": "^2.4.9",
7777
"@vue/tsconfig": "^0.7.0",
78+
"axe-core": "^4.12.1",
7879
"eslint": "^10.0.3",
7980
"eslint-plugin-vue": "^10.8.0",
8081
"eslint-plugin-vuejs-accessibility": "^2.5.0",

frontend/src/v2/lib/data/RTable/RTable.stories.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ const meta: Meta = {
1111
// RTable is generic, so its component-typed shape doesn't fit
1212
// Storybook's `component` slot. The cast is narrow + intentional.
1313
component: RTable as never,
14+
// a11y todo (#1848): grid roles need their required parent structure so
15+
// rows/cells are announced correctly (incl. the mobile card-stack mode).
16+
parameters: { a11y: { test: "todo" } },
1417
argTypes: {
1518
loading: { control: "boolean" },
1619
sortKey: { control: "text" },

frontend/src/v2/lib/forms/RCheckbox/RCheckbox.stories.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,13 @@ export const Indeterminate: Story = {
7979
args: { label: "Some items selected", indeterminate: true },
8080
};
8181

82-
export const NoLabel: Story = { args: {} };
82+
export const NoLabel: Story = {
83+
render: () => ({
84+
components: { RCheckbox },
85+
setup: () => ({ value: ref(false) }),
86+
template: `<RCheckbox v-model="value" aria-label="Accept" />`,
87+
}),
88+
};
8389

8490
// ── Multi-state ─────────────────────────────────────────────────────
8591

frontend/src/v2/lib/forms/RDateField/RDateField.stories.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import RDateField from "./RDateField.vue";
55
const meta: Meta<typeof RDateField> = {
66
title: "Forms/RDateField",
77
component: RDateField,
8+
// a11y todo (#1848): calendar grid cells use an unsupported role and the
9+
// field input lacks a programmatic label. Needs a component-level fix.
10+
parameters: { a11y: { test: "todo" } },
811
argTypes: {
912
firstDayOfWeek: {
1013
control: "inline-radio",

frontend/src/v2/lib/forms/RDropzone/RDropzone.stories.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import RDropzone from "./RDropzone.vue";
66
const meta: Meta<typeof RDropzone> = {
77
title: "Forms/RDropzone",
88
component: RDropzone,
9+
// a11y todo (#1848): the hidden file input needs a programmatic label
10+
// tied to the visible drop-zone copy.
11+
parameters: { a11y: { test: "todo" } },
912
argTypes: {
1013
title: { control: "text" },
1114
hint: { control: "text" },

frontend/src/v2/lib/forms/RRating/RRating.stories.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Meta, StoryObj } from "@storybook/vue3-vite";
2+
import { expect, userEvent, within } from "storybook/test";
23
import { ref } from "vue";
34
import RRating from "./RRating.vue";
45

@@ -39,6 +40,39 @@ export const Default: Story = { args: { halfIncrements: true, hover: true } };
3940
export const Readonly: Story = { args: { readonly: true } };
4041
export const Large: Story = { args: { size: "large" } };
4142

43+
// Keyboard operability — each star is a native `<button role="radio">`, so
44+
// Tab walks focus across them and Enter / Space commits the focused rating.
45+
export const KeyboardNav: Story = {
46+
name: "Keyboard navigation (play)",
47+
args: { ariaLabel: "Rating" },
48+
render: (args) => ({
49+
components: { RRating },
50+
setup: () => {
51+
const value = ref(0);
52+
return { args, value };
53+
},
54+
template: `<RRating v-bind="args" v-model="value" />`,
55+
}),
56+
play: async ({ canvasElement, step }) => {
57+
const canvas = within(canvasElement);
58+
const stars = canvas.getAllByRole("radio");
59+
60+
await step("Tab focuses the stars in order", async () => {
61+
await userEvent.tab();
62+
expect(stars[0]).toHaveFocus();
63+
await userEvent.tab();
64+
await userEvent.tab();
65+
expect(stars[2]).toHaveFocus();
66+
});
67+
68+
await step("Enter commits the focused star as the rating", async () => {
69+
await userEvent.keyboard("{Enter}");
70+
expect(stars[2]).toHaveAttribute("aria-checked", "true");
71+
expect(stars[0]).toHaveAttribute("aria-checked", "false");
72+
});
73+
},
74+
};
75+
4276
// Difficulty preset — same primitive driven by props. Exercises the
4377
// new emptyIcon/fullIcon/activeColor pass-through used by the
4478
// score-picker on GameDetails.

frontend/src/v2/lib/forms/RSelect/RSelect.stories.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ export const Disabled: Story = {
177177
};
178178

179179
export const Loading: Story = {
180+
// a11y todo (#1848): loading spinner (role="progressbar") needs a name.
181+
parameters: { a11y: { test: "todo" } },
180182
args: { placeholder: "Loading platforms…", loading: true },
181183
};
182184

@@ -211,6 +213,9 @@ export const Validation: Story = {
211213
// ── Clearable ──────────────────────────────────────────────────────
212214

213215
export const Clearable: Story = {
216+
// a11y todo (#1848): the clear affordance nests an interactive control
217+
// inside the combobox; needs restructuring so roles don't nest.
218+
parameters: { a11y: { test: "todo" } },
214219
render: () => ({
215220
components: { RSelect },
216221
setup: () => ({ value: ref("psx"), items: PLATFORMS }),

frontend/src/v2/lib/forms/RSlider/RSlider.stories.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import RSlider from "./RSlider.vue";
55
const meta: Meta<typeof RSlider> = {
66
title: "Forms/RSlider",
77
component: RSlider,
8+
// a11y todo (#1848): the slider thumb needs an accessible name (label or
9+
// aria-label). Track a labelling API / required-label lint on RSlider.
10+
parameters: { a11y: { test: "todo" } },
811
argTypes: {
912
min: { control: "number" },
1013
max: { control: "number" },

0 commit comments

Comments
 (0)