diff --git a/MIGRATION.md b/MIGRATION.md index b78057b981d3..fd3ce9e10be5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,8 @@

Migration

- [From version 8.4.x to 8.5.x](#from-version-84x-to-85x) + - [Addon-a11y: Component test integration](#addon-a11y-component-test-integration) + - [Addon-a11y: Deprecated `parameters.a11y.manual`](#addon-a11y-deprecated-parametersa11ymanual) - [Indexing behavior of @storybook/experimental-addon-test is changed](#indexing-behavior-of-storybookexperimental-addon-test-is-changed) - [From version 8.2.x to 8.3.x](#from-version-82x-to-83x) - [Removed `experimental_SIDEBAR_BOTTOM` and deprecated `experimental_SIDEBAR_TOP` addon types](#removed-experimental_sidebar_bottom-and-deprecated-experimental_sidebar_top-addon-types) @@ -423,6 +425,30 @@ ## From version 8.4.x to 8.5.x +### Addon-a11y: Component test integration + +In Storybook 8.4, we introduced a new addon called [addon test](https://storybook.js.org/docs/writing-tests/test-addon). Powered by Vitest under the hood, this addon lets you watch, run, and debug your component tests directly in Storybook. + +In Storybook 8.5, we revamped the Accessibility addon (`@storybook/addon-a11y`) to integrate it with the component tests feature. This means you can now extend your component tests to include accessibility tests. If you upgrade to Storybook 8.5 via `npx storybook@latest upgrade`, the Accessibility addon will be automatically configured to work with the component tests. However, if you're upgrading manually and you have the [addon test](https://storybook.js.org/docs/writing-tests/test-addon) installed, adjust your configuration as follows: + +```diff +// .storybook/vitest.config.ts +... ++import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; + +const annotations = setProjectAnnotations([ + previewAnnotations, ++ a11yAddonAnnotations, +]); + +// Run Storybook's beforeAll hook +beforeAll(annotations.beforeAll); +``` + +### Addon-a11y: Deprecated `parameters.a11y.manual` + +We have deprecated `parameters.a11y.manual` in 8.5. Please use `globals.a11y.manual` instead. + ### Indexing behavior of @storybook/experimental-addon-test is changed The Storybook test addon used to index stories based on the `test.include` field in the Vitest config file. This caused indexing issues with Storybook, because stories could have been indexed by Storybook and not Vitest, and vice versa. Starting in Storybook 8.5.0-alpha.18, we changed the indexing behavior so that it always uses the globs defined in the `stories` field in `.storybook/main.js` for a more consistent experience. It is now discouraged to use `test.include`, please remove it. diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index cc4481a56d1c..017419318b0a 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -358,6 +358,9 @@ export const parameters = { opacity: 0.4, }, }, + a11y: { + warnings: ['minor', 'moderate', 'serious', 'critical'], + }, }; export const tags = ['test', 'vitest']; diff --git a/code/.storybook/storybook.setup.ts b/code/.storybook/storybook.setup.ts index 1be658970e2f..ce62499fa0a6 100644 --- a/code/.storybook/storybook.setup.ts +++ b/code/.storybook/storybook.setup.ts @@ -6,6 +6,8 @@ import { userEvent as storybookEvent, expect as storybookExpect } from '@storybo // eslint-disable-next-line import/namespace import * as testAnnotations from '@storybook/experimental-addon-test/preview'; +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; + import * as coreAnnotations from '../addons/toolbars/template/stories/preview'; import * as componentAnnotations from '../core/template/stories/preview'; // register global components used in many stories @@ -15,9 +17,8 @@ import * as projectAnnotations from './preview'; vi.spyOn(console, 'warn').mockImplementation((...args) => console.log(...args)); const annotations = setProjectAnnotations([ - // @ts-expect-error check type errors later + a11yAddonAnnotations, projectAnnotations, - // @ts-expect-error check type errors later componentAnnotations, coreAnnotations, testAnnotations, diff --git a/code/addons/a11y/README.md b/code/addons/a11y/README.md index 2bc3f201106d..3f3e8e86504f 100755 --- a/code/addons/a11y/README.md +++ b/code/addons/a11y/README.md @@ -1,206 +1,13 @@ -# storybook-addon-a11y +# Storybook Accessibility Addon -This Storybook addon can be helpful to make your UI components more accessible. +The @storybook/addon-a11y package provides accessibility testing for Storybook stories. It uses axe-core to run the tests. -[Framework Support](https://storybook.js.org/docs/configure/integration/frameworks-feature-support) +## Getting Started -![Screenshot](https://raw.githubusercontent.com/storybookjs/storybook/next/code/addons/a11y/docs/screenshot.png) +### Add the addon to an existing Storybook -## Getting started - -First, install the addon. - -```sh -$ yarn add @storybook/addon-a11y --dev -``` - -Add this line to your `main.js` file (create this file inside your Storybook config directory if needed). - -```js -export default { - addons: ['@storybook/addon-a11y'], -}; -``` - -And here's a sample story file to test the addon: - -```js -import React from 'react'; - -export default { - title: 'button', -}; - -export const Accessible = () => ; - -export const Inaccessible = () => ( - -); -``` - -## Handling failing rules - -When Axe reports accessibility violations in stories, there are multiple ways to handle these failures depending on your needs. - -### Story-level overrides - -At the Story level, override rules using `parameters.a11y.config.rules`. - -```js -export const InputWithoutAutofill = () => ; - -InputWithoutAutofill.parameters = { - a11y: { - // Avoid doing this, as it will fully disable all accessibility checks for this story. - disable: true, - - // Instead, override rules 👇 - // axe-core configurationOptions (https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#parameters-1) - config: { - rules: [ - { - // You can exclude some elements from failing a specific rule: - id: 'autocomplete-valid', - selector: '*:not([autocomplete="nope"])', - }, - { - // You can also signify that a violation will need to be fixed in the future - // by overriding the result of a rule to return "Needs Review" - // rather than "Violation" if the rule fails: - id: 'landmark-complementary-is-top-level', - reviewOnFail: true, - }, - ], - }, - }, -}; -``` - -Alternatively, you can disable specific rules in a Story: - -```js -export const Inaccessible = () => ( - -); -Inaccessible.parameters = { - a11y: { - config: { - rules: [{ id: 'color-contrast', enabled: false }], - }, - }, -}; +```bash +npx storybook add @storybook/addon-a11y ``` -Tip: clearly explain in a comment why a rule was overridden, it’ll help you and your team trace back why certain violations aren’t being reported or need to be addressed. For example: - -```js -MyStory.parameters = { - a11y: { - config: { - rules: [ - { - // Allow `autocomplete="nope"` on form elements, - // a workaround to disable autofill in Chrome. - // @link https://bugs.chromium.org/p/chromium/issues/detail?id=468153 - id: 'autocomplete-valid', - selector: '*:not([autocomplete="nope"])', - }, - { - // @fixme Color contrast of subdued text fails, as raised in issue #123. - id: 'color-contrast', - reviewOnFail: true, - }, - ], - }, - }, -}; -``` - -### Global overrides - -When you want to ignore an accessibility rule or change its settings across all stories, set `parameters.a11y.config.rules` in your Storybook’s `preview.ts` file. This can be particularly useful for ignoring false positives commonly reported by Axe. - -```ts -// .storybook/preview.ts - -export const parameters = { - a11y: { - config: { - rules: [ - { - // This tells Axe to run the 'autocomplete-valid' rule on selectors - // that match '*:not([autocomplete="nope"])' (all elements except [autocomplete="nope"]). - // This is the safest way of ignoring a violation across all stories, - // as Axe will only ignore very specific elements and keep reporting - // violations on other elements of this rule. - id: 'autocomplete-valid', - selector: '*:not([autocomplete="nope"])', - }, - { - // To disable a rule across all stories, set `enabled` to `false`. - // Use with caution: all violations of this rule will be ignored! - id: 'autocomplete-valid', - enabled: false, - }, - ], - }, - }, -}; -``` - -### Disabling checks - -If you wish to entirely disable `a11y` checks for a subset of stories, you can control this with story parameters: - -```js -export const MyNonCheckedStory = () => ; -MyNonCheckedStory.parameters = { - // Avoid doing this, as it fully disables all accessibility checks for this story, - // and consider the techniques described above. - a11y: { disable: true }, -}; -``` - -## Parameters - -For more customizability use parameters to configure [aXe options](https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#api-name-axeconfigure). -You can override these options [at story level too](https://storybook.js.org/docs/react/configure/features-and-behavior#per-story-options). - -```js -import React from 'react'; -import { addDecorator, addParameters, storiesOf } from '@storybook/react'; - -export default { - title: 'button', - parameters: { - a11y: { - // optional selector which element to inspect - element: '#storybook-root', - // axe-core configurationOptions (https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#parameters-1) - config: {}, - // axe-core optionsParameter (https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#options-parameter) - options: {}, - // optional flag to prevent the automatic check - manual: true, - }, - }, -}; - -export const accessible = () => ; - -export const inaccessible = () => ( - -); -``` - -## Automate accessibility tests with test runner - -The test runner does not apply any rules that you have set on your stories by default. You can configure the runner to correctly apply the rules by [following the guide on the Storybook docs](https://storybook.js.org/docs/writing-tests/accessibility-testing#automate-accessibility-tests-with-test-runner). - -## Roadmap - -- Make UI accessible -- Show in story where violations are. -- Add more example tests -- Add tests -- Make CI integration possible +[More on getting started with the accessibility addon](https://storybook.js.org/docs/writing-tests/accessibility-testing#accessibility-checks-with-a11y-addon) diff --git a/code/addons/a11y/package.json b/code/addons/a11y/package.json index 13814fc39390..e1985c0961d7 100644 --- a/code/addons/a11y/package.json +++ b/code/addons/a11y/package.json @@ -56,12 +56,16 @@ }, "dependencies": { "@storybook/addon-highlight": "workspace:*", - "axe-core": "^4.2.0" + "@storybook/test": "workspace:*", + "axe-core": "^4.2.0", + "vitest-axe": "^0.1.0" }, "devDependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^1.2.12", "@testing-library/react": "^14.0.0", + "picocolors": "^1.1.0", + "pretty-format": "^29.7.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resize-detector": "^7.1.2", diff --git a/code/addons/a11y/src/a11yRunner.test.ts b/code/addons/a11y/src/a11yRunner.test.ts index 402ff24f6cb9..ef44f367f000 100644 --- a/code/addons/a11y/src/a11yRunner.test.ts +++ b/code/addons/a11y/src/a11yRunner.test.ts @@ -22,7 +22,6 @@ describe('a11yRunner', () => { await import('./a11yRunner'); expect(mockedAddons.getChannel).toHaveBeenCalled(); - expect(mockChannel.on).toHaveBeenCalledWith(EVENTS.REQUEST, expect.any(Function)); expect(mockChannel.on).toHaveBeenCalledWith(EVENTS.MANUAL, expect.any(Function)); }); }); diff --git a/code/addons/a11y/src/a11yRunner.ts b/code/addons/a11y/src/a11yRunner.ts index 5e45a20ac7b0..be6de1be255f 100644 --- a/code/addons/a11y/src/a11yRunner.ts +++ b/code/addons/a11y/src/a11yRunner.ts @@ -2,70 +2,88 @@ import { addons } from 'storybook/internal/preview-api'; import { global } from '@storybook/global'; +import type { AxeResults } from 'axe-core'; + import { EVENTS } from './constants'; import type { A11yParameters } from './params'; const { document } = global; const channel = addons.getChannel(); -// Holds axe core running state -let active = false; -// Holds latest story we requested a run -let activeStoryId: string | undefined; const defaultParameters = { config: {}, options: {} }; -/** Handle A11yContext events. Because the event are sent without manual check, we split calls */ -const handleRequest = async (storyId: string, input: A11yParameters | null) => { - if (!input?.manual) { - await run(storyId, input ?? defaultParameters); +const disabledRules = [ + // In component testing, landmarks are not always present + // and the rule check can cause false positives + 'region', +]; + +// A simple queue to run axe-core in sequence +// This is necessary because axe-core is not designed to run in parallel +const queue: (() => Promise)[] = []; +let isRunning = false; + +const runNext = async () => { + if (queue.length === 0) { + isRunning = false; + return; + } + + isRunning = true; + const next = queue.shift(); + if (next) { + await next(); } + runNext(); }; -const run = async (storyId: string, input: A11yParameters = defaultParameters) => { - activeStoryId = storyId; - try { - if (!active) { - active = true; - channel.emit(EVENTS.RUNNING); - const { default: axe } = await import('axe-core'); +export const run = async (input: A11yParameters = defaultParameters) => { + const { default: axe } = await import('axe-core'); - const { element = '#storybook-root', config, options = {} } = input; - const htmlElement = document.querySelector(element as string); + const { element = '#storybook-root', config = {}, options = {} } = input; + const htmlElement = document.querySelector(element as string) ?? document.body; - if (!htmlElement) { - return; - } + if (!htmlElement) { + return; + } - axe.reset(); - if (config) { - axe.configure(config); - } + axe.reset(); - const result = await axe.run(htmlElement, options); - - // Axe result contains class instances, which telejson deserializes in a - // way that violates: - // Content Security Policy directive: "script-src 'self' 'unsafe-inline'". - const resultJson = JSON.parse(JSON.stringify(result)); - - // It's possible that we requested a new run on a different story. - // Unfortunately, axe doesn't support a cancel method to abort current run. - // We check if the story we run against is still the current one, - // if not, trigger a new run using the current story - if (activeStoryId === storyId) { - channel.emit(EVENTS.RESULT, resultJson); - } else { - active = false; - run(activeStoryId); + const configWithDefault = { + ...config, + rules: [...disabledRules.map((id) => ({ id, enabled: false })), ...(config?.rules ?? [])], + }; + + axe.configure(configWithDefault); + + return new Promise((resolve, reject) => { + const task = async () => { + try { + const result = await axe.run(htmlElement, options); + resolve(result); + } catch (error) { + reject(error); } + }; + + queue.push(task); + + if (!isRunning) { + runNext(); } + }); +}; + +channel.on(EVENTS.MANUAL, async (storyId: string, input: A11yParameters = defaultParameters) => { + try { + const result = await run(input); + // Axe result contains class instances, which telejson deserializes in a + // way that violates: + // Content Security Policy directive: "script-src 'self' 'unsafe-inline'". + const resultJson = JSON.parse(JSON.stringify(result)); + channel.emit(EVENTS.RESULT, resultJson, storyId); } catch (error) { channel.emit(EVENTS.ERROR, error); - } finally { - active = false; } -}; - -channel.on(EVENTS.REQUEST, handleRequest); -channel.on(EVENTS.MANUAL, run); +}); diff --git a/code/addons/a11y/src/components/A11YPanel.stories.tsx b/code/addons/a11y/src/components/A11YPanel.stories.tsx new file mode 100644 index 000000000000..701b5a509338 --- /dev/null +++ b/code/addons/a11y/src/components/A11YPanel.stories.tsx @@ -0,0 +1,222 @@ +import React from 'react'; + +import { ManagerContext } from 'storybook/internal/manager-api'; +import { ThemeProvider, convert, themes } from 'storybook/internal/theming'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import type axe from 'axe-core'; + +import { A11YPanel } from './A11YPanel'; +import { A11yContext } from './A11yContext'; +import type { A11yContextStore } from './A11yContext'; + +const managerContext: any = { + state: {}, + api: { + getDocsUrl: fn().mockName('api::getDocsUrl'), + }, +}; + +const meta: Meta = { + title: 'A11YPanel', + component: A11YPanel, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const violations: axe.Result[] = [ + { + id: 'aria-command-name', + impact: 'serious', + tags: ['cat.aria', 'wcag2a', 'wcag412', 'TTv5', 'TT6.a', 'EN-301-549', 'EN-9.4.1.2', 'ACT'], + description: 'Ensures every ARIA button, link and menuitem has an accessible name', + help: 'ARIA commands must have an accessible name', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.8/aria-command-name?application=axeAPI', + nodes: [ + { + any: [ + { + id: 'has-visible-text', + data: null, + relatedNodes: [], + impact: 'serious', + message: 'Element does not have text that is visible to screen readers', + }, + { + id: 'aria-label', + data: null, + relatedNodes: [], + impact: 'serious', + message: 'aria-label attribute does not exist or is empty', + }, + { + id: 'aria-labelledby', + data: null, + relatedNodes: [], + impact: 'serious', + message: + 'aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty', + }, + { + id: 'non-empty-title', + data: { + messageKey: 'noAttr', + }, + relatedNodes: [], + impact: 'serious', + message: 'Element has no title attribute', + }, + ], + all: [], + none: [], + impact: 'serious', + html: '
', + target: ['.css-12jpz5t'], + failureSummary: + 'Fix any of the following:\n Element does not have text that is visible to screen readers\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute', + }, + ], + }, +]; + +const Template = (args: Pick) => ( + + + +); + +export const Initializing: Story = { + render: () => { + return ( +