diff --git a/addons/a11y/src/a11yRunner.ts b/addons/a11y/src/a11yRunner.ts index 5d60e10a67c8..c3f0b744f50d 100644 --- a/addons/a11y/src/a11yRunner.ts +++ b/addons/a11y/src/a11yRunner.ts @@ -2,21 +2,36 @@ import { document, window } from 'global'; import axe from 'axe-core'; import addons from '@storybook/addons'; import { EVENTS } from './constants'; -import { Setup } from './params'; +import { A11yParameters } from './params'; if (module && module.hot && module.hot.decline) { module.hot.decline(); } const channel = addons.getChannel(); +// Holds axe core running state let active = false; +// Holds latest story we requested a run +let activeStoryId: string | undefined; const getElement = () => { const storyRoot = document.getElementById('story-root'); return storyRoot ? storyRoot.children : document.getElementById('root'); }; +/** + * Handle A11yContext events. + * Because the event are sent without manual check, we split calls + */ +const handleRequest = (storyId: string) => { + const { manual } = getParams(storyId); + if (!manual) { + run(storyId); + } +}; + const run = async (storyId: string) => { + activeStoryId = storyId; try { const input = getParams(storyId); @@ -24,14 +39,29 @@ const run = async (storyId: string) => { active = true; channel.emit(EVENTS.RUNNING); - const { element = getElement(), config, options } = input; + const { + element = getElement(), + config, + options = { + restoreScroll: true, + }, + } = input; axe.reset(); if (config) { axe.configure(config); } const result = await axe.run(element, options); - channel.emit(EVENTS.RESULT, 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, result); + } else { + active = false; + run(activeStoryId); + } } } catch (error) { channel.emit(EVENTS.ERROR, error); @@ -41,7 +71,7 @@ const run = async (storyId: string) => { }; /** Returns story parameters or default ones. */ -const getParams = (storyId: string): Setup => { +const getParams = (storyId: string): A11yParameters => { const { parameters } = window.__STORYBOOK_STORY_STORE__.fromId(storyId) || {}; return ( parameters.a11y || { @@ -53,5 +83,5 @@ const getParams = (storyId: string): Setup => { ); }; -channel.on(EVENTS.REQUEST, run); +channel.on(EVENTS.REQUEST, handleRequest); channel.on(EVENTS.MANUAL, run); diff --git a/addons/a11y/src/components/A11YPanel.tsx b/addons/a11y/src/components/A11YPanel.tsx index fc568cfc4e30..a00d6178440a 100644 --- a/addons/a11y/src/components/A11YPanel.tsx +++ b/addons/a11y/src/components/A11YPanel.tsx @@ -1,16 +1,16 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { styled } from '@storybook/theming'; import { ActionBar, Icons, ScrollArea } from '@storybook/components'; import { AxeResults } from 'axe-core'; -import { useChannel, useParameter, useStorybookState, useAddonState } from '@storybook/api'; +import { useChannel, useParameter, useStorybookState } from '@storybook/api'; import { Report } from './Report'; import { Tabs } from './Tabs'; import { useA11yContext } from './A11yContext'; -import { EVENTS, ADDON_ID } from '../constants'; +import { EVENTS } from '../constants'; import { A11yParameters } from '../params'; export enum RuleType { @@ -51,13 +51,13 @@ const Centered = styled.span<{}>({ type Status = 'initial' | 'manual' | 'running' | 'error' | 'ran' | 'ready'; export const A11YPanel: React.FC = () => { - const [status, setStatus] = useAddonState(ADDON_ID, 'initial'); - const [error, setError] = React.useState(undefined); - const { setResults, results } = useA11yContext(); - const { storyId } = useStorybookState(); const { manual } = useParameter>('a11y', { manual: false, }); + const [status, setStatus] = useState(manual ? 'manual' : 'initial'); + const [error, setError] = React.useState(undefined); + const { setResults, results } = useA11yContext(); + const { storyId } = useStorybookState(); React.useEffect(() => { setStatus(manual ? 'manual' : 'initial'); diff --git a/addons/a11y/src/components/A11yContext.tsx b/addons/a11y/src/components/A11yContext.tsx index 458fa14f11b5..5061f5623d47 100644 --- a/addons/a11y/src/components/A11yContext.tsx +++ b/addons/a11y/src/components/A11yContext.tsx @@ -64,9 +64,9 @@ export const A11yContextProvider: React.FC = ({ active : prevHighlighted.filter((t) => !target.includes(t)) ); }, []); - const handleRun = React.useCallback(() => { - emit(EVENTS.REQUEST, storyId); - }, [storyId]); + const handleRun = (renderedStoryId: string) => { + emit(EVENTS.REQUEST, renderedStoryId); + }; const handleClearHighlights = React.useCallback(() => setHighlighted([]), []); const handleSetTab = React.useCallback((index: number) => { handleClearHighlights(); @@ -90,7 +90,7 @@ export const A11yContextProvider: React.FC = ({ active React.useEffect(() => { if (active) { - handleRun(); + handleRun(storyId); } else { handleClearHighlights(); } diff --git a/examples/official-storybook/stories/addon-a11y/typography.stories.js b/examples/official-storybook/stories/addon-a11y/typography.stories.js index 8a8b12c4c575..1acf3b81bf23 100644 --- a/examples/official-storybook/stories/addon-a11y/typography.stories.js +++ b/examples/official-storybook/stories/addon-a11y/typography.stories.js @@ -34,3 +34,15 @@ EmptyLink.storyName = 'Empty Link'; export const LinkWithoutHref = () => {`${text}...`}; LinkWithoutHref.storyName = 'Link without href'; + +export const Manual = () =>

I'm a manual run

; +Manual.parameters = { + a11y: { + manual: true, + config: { + disableOtherRules: true, + rules: [], + }, + options: {}, + }, +};