Skip to content
Merged
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
3 changes: 2 additions & 1 deletion code/addons/a11y/src/components/A11yContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
import type { AxeResults } from 'axe-core';
import * as api from 'storybook/manager-api';

import { EVENTS } from '../constants';
import { EVENTS, UI_STATE_ID } from '../constants';
import { RuleType } from '../types';
import { A11yContextProvider, useA11yContext } from './A11yContext';

vi.mock('storybook/manager-api');
Expand Down
173 changes: 105 additions & 68 deletions code/addons/a11y/src/components/A11yContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { convert, themes } from 'storybook/theming';
import { getFriendlySummaryForAxeResult, getTitleForAxeResult } from '../axeRuleMappingHelper';
import { ADDON_ID, EVENTS, STATUS_TYPE_ID_A11Y, STATUS_TYPE_ID_COMPONENT_TEST } from '../constants';
import type { A11yParameters } from '../params';
import type { A11YReport, EnhancedResult, EnhancedResults } from '../types';
import type { A11YReport, EnhancedResult, EnhancedResults, Status } from '../types';
import { RuleType } from '../types';
import type { TestDiscrepancy } from './TestDiscrepancyMessage';

Expand Down Expand Up @@ -86,8 +86,6 @@ export const A11yContext = createContext<A11yContextStore>({
handleSelectionChange: () => {},
});

type Status = 'initial' | 'manual' | 'running' | 'error' | 'component-test-error' | 'ran' | 'ready';

export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
const parameters = useParameter<A11yParameters>('a11y', {});

Expand All @@ -106,16 +104,22 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
return value;
}, [api]);

const [results, setResults] = useAddonState<EnhancedResults | undefined>(ADDON_ID);
const [tab, setTab] = useState(() => {
const [type] = a11ySelection?.split('.') ?? [];
return type && Object.values(RuleType).includes(type as RuleType)
? (type as RuleType)
: RuleType.VIOLATION;
const [state, setState] = useAddonState<{
ui: { highlighted: boolean; tab: RuleType };
results: EnhancedResults | undefined;
error: unknown;
status: Status;
}>(ADDON_ID, {
ui: {
highlighted: false,
tab: RuleType.VIOLATION,
},
results: undefined,
error: undefined,
status: getInitialStatus(manual),
});
const [error, setError] = useState<unknown>(undefined);
const [status, setStatus] = useState<Status>(getInitialStatus(manual));
const [highlighted, setHighlighted] = useState(!!a11ySelection);

const { ui, results, error, status } = state;

const { storyId } = useStorybookState();
const currentStoryA11yStatusValue = experimental_useStatusStore(
Expand All @@ -128,17 +132,16 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
const current = statuses[storyId]?.[STATUS_TYPE_ID_COMPONENT_TEST];
const previous = previousStatuses[storyId]?.[STATUS_TYPE_ID_COMPONENT_TEST];
if (current?.value === 'status-value:error' && previous?.value !== 'status-value:error') {
setStatus('component-test-error');
setState((prev) => ({ ...prev, status: 'component-test-error' }));
}
}
);
return unsubscribe;
}, [storyId]);
}, [setState, storyId]);

const handleToggleHighlight = useCallback(
() => setHighlighted((prevHighlighted) => !prevHighlighted),
[]
);
const handleToggleHighlight = useCallback(() => {
setState((prev) => ({ ...prev, ui: { ...prev.ui, highlighted: !prev.ui.highlighted } }));
}, [setState]);

const [selectedItems, setSelectedItems] = useState<Map<string, string>>(() => {
const initialValue = new Map();
Expand All @@ -153,9 +156,9 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {

// All items are expanded if something is selected from each result for the current tab
const allExpanded = useMemo(() => {
const currentResults = results?.[tab];
return currentResults?.every((result) => selectedItems.has(`${tab}.${result.id}`)) ?? false;
}, [results, selectedItems, tab]);
const currentResults = results?.[ui.tab];
return currentResults?.every((result) => selectedItems.has(`${ui.tab}.${result.id}`)) ?? false;
}, [results, selectedItems, ui.tab]);

const toggleOpen = useCallback(
(event: React.SyntheticEvent<Element>, type: RuleType, item: EnhancedResult) => {
Expand All @@ -174,42 +177,49 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
setSelectedItems(
(prev) =>
new Map(
results?.[tab]?.map((result) => {
const key = `${tab}.${result.id}`;
results?.[ui.tab]?.map((result) => {
const key = `${ui.tab}.${result.id}`;
return [key, prev.get(key) ?? `${key}.1`];
}) ?? []
)
);
}, [results, tab]);
}, [results, ui.tab]);

const handleSelectionChange = useCallback((key: string) => {
const [type, id] = key.split('.');
setSelectedItems((prev) => new Map(prev.set(`${type}.${id}`, key)));
}, []);

const handleError = useCallback((err: unknown) => {
setStatus('error');
setError(err);
}, []);
const handleError = useCallback(
(err: unknown) => {
setState((prev) => ({ ...prev, status: 'error', error: err }));
},
[setState]
);

const handleResult = useCallback(
(axeResults: EnhancedResults, id: string) => {
if (storyId === id) {
setStatus('ran');
setResults(axeResults);
setState((prev) => ({ ...prev, status: 'ran', results: axeResults }));

setTimeout(() => {
if (status === 'ran') {
setStatus('ready');
}
if (selectedItems.size === 1) {
const [key] = selectedItems.values();
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setState((prev) => {
if (prev.status === 'ran') {
return { ...prev, status: 'ready' };
}
return prev;
});
setSelectedItems((prev) => {
if (prev.size === 1) {
const [key] = prev.values();
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return prev;
});
}, 900);
}
},
[setResults, status, storyId, selectedItems]
[storyId, setState, setSelectedItems]
);

const handleSelect = useCallback(
Expand Down Expand Up @@ -250,14 +260,16 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
const handleReset = useCallback(
({ newPhase }: { newPhase: string }) => {
if (newPhase === 'loading') {
setResults(undefined);
setStatus(manual ? 'manual' : 'initial');
}
if (newPhase === 'afterEach' && !manual) {
setStatus('running');
setState((prev) => ({
...prev,
results: undefined,
status: manual ? 'manual' : 'initial',
}));
} else if (newPhase === 'afterEach' && !manual) {
setState((prev) => ({ ...prev, status: 'running' }));
}
},
[manual, setResults]
[manual, setState]
);

const emit = useChannel(
Expand All @@ -269,17 +281,17 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
[STORY_RENDER_PHASE_CHANGED]: handleReset,
[STORY_FINISHED]: handleReport,
[STORY_HOT_UPDATED]: () => {
setStatus('running');
setState((prev) => ({ ...prev, status: 'running' }));
emit(EVENTS.MANUAL, storyId, parameters);
},
},
[handleReset, handleReport, handleSelect, handleError, handleResult, parameters, storyId]
);

const handleManual = useCallback(() => {
setStatus('running');
setState((prev) => ({ ...prev, status: 'running' }));
emit(EVENTS.MANUAL, storyId, parameters);
}, [emit, parameters, storyId]);
}, [emit, parameters, setState, storyId]);

const handleCopyLink = useCallback(async (linkPath: string) => {
const { createCopyToClipboardFunction } = await import('storybook/internal/components');
Expand All @@ -292,22 +304,41 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
);

useEffect(() => {
setStatus(getInitialStatus(manual));
}, [getInitialStatus, manual]);
setState((prev) => ({ ...prev, status: getInitialStatus(manual) }));
}, [getInitialStatus, manual, setState]);

const isInitial = status === 'initial';

// If a deep link is provided, prefer it once on mount and persist UI state accordingly
useEffect(() => {
if (!a11ySelection) {
return;
}
setState((prev) => {
const update = { ...prev.ui, highlighted: true };

const [type] = a11ySelection.split('.') ?? [];
if (type && Object.values(RuleType).includes(type as RuleType)) {
update.tab = type as RuleType;
}
return { ...prev, ui: update };
});

// We intentionally do not include setState in deps to avoid loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [a11ySelection]);

useEffect(() => {
emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/selected`);
emit(REMOVE_HIGHLIGHT, `${ADDON_ID}/others`);

if (!highlighted || isInitial) {
if (!ui.highlighted || isInitial) {
return;
}

const selected = Array.from(selectedItems.values()).flatMap((key) => {
const [type, id, number] = key.split('.');
if (type !== tab) {
if (type !== ui.tab) {
return [];
}
const result = results?.[type as RuleType]?.find((r) => r.id === id);
Expand All @@ -320,7 +351,7 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
priority: 1,
selectors: selected,
styles: {
outline: `1px solid color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`,
outline: `1px solid color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 30%)`,
backgroundColor: 'transparent',
},
hoverStyles: {
Expand All @@ -329,20 +360,20 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
focusStyles: {
backgroundColor: 'transparent',
},
menu: results?.[tab as RuleType].map<HighlightMenuItem[]>((result) => {
menu: results?.[ui.tab as RuleType].map<HighlightMenuItem[]>((result) => {
const selectors = result.nodes
.flatMap((n) => n.target)
.map(String)
.filter((e) => selected.includes(e));
return [
{
id: `${tab}.${result.id}:info`,
id: `${ui.tab}.${result.id}:info`,
title: getTitleForAxeResult(result),
description: getFriendlySummaryForAxeResult(result),
selectors,
},
{
id: `${tab}.${result.id}`,
id: `${ui.tab}.${result.id}`,
iconLeft: 'info',
iconRight: 'shareAlt',
title: 'Learn how to resolve this violation',
Expand All @@ -354,37 +385,37 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
});
}

const others = results?.[tab as RuleType]
const others = results?.[ui.tab as RuleType]
.flatMap((r) => r.nodes.flatMap((n) => n.target).map(String))
.filter((e) => ![...unhighlightedSelectors, ...selected].includes(e));
if (others?.length) {
emit(HIGHLIGHT, {
id: `${ADDON_ID}/others`,
selectors: others,
styles: {
outline: `1px solid color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`,
backgroundColor: `color-mix(in srgb, ${colorsByType[tab]}, transparent 60%)`,
outline: `1px solid color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 30%)`,
backgroundColor: `color-mix(in srgb, ${colorsByType[ui.tab]}, transparent 60%)`,
},
hoverStyles: {
outlineWidth: '2px',
},
focusStyles: {
backgroundColor: 'transparent',
},
menu: results?.[tab as RuleType].map<HighlightMenuItem[]>((result) => {
menu: results?.[ui.tab as RuleType].map<HighlightMenuItem[]>((result) => {
const selectors = result.nodes
.flatMap((n) => n.target)
.map(String)
.filter((e) => !selected.includes(e));
return [
{
id: `${tab}.${result.id}:info`,
id: `${ui.tab}.${result.id}:info`,
title: getTitleForAxeResult(result),
description: getFriendlySummaryForAxeResult(result),
selectors,
},
{
id: `${tab}.${result.id}`,
id: `${ui.tab}.${result.id}`,
iconLeft: 'info',
iconRight: 'shareAlt',
title: 'Learn how to resolve this violation',
Expand All @@ -395,7 +426,7 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
}),
});
}
}, [isInitial, emit, highlighted, results, tab, selectedItems]);
}, [isInitial, emit, ui.highlighted, results, ui.tab, selectedItems]);

const discrepancy: TestDiscrepancy = useMemo(() => {
if (!currentStoryA11yStatusValue) {
Expand All @@ -422,14 +453,20 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
value={{
parameters,
results,
highlighted,
highlighted: ui.highlighted,
toggleHighlight: handleToggleHighlight,
tab,
setTab,
tab: ui.tab,
setTab: useCallback(
(type: RuleType) => setState((prev) => ({ ...prev, ui: { ...prev.ui, tab: type } })),
[setState]
),
handleCopyLink,
status,
setStatus,
error,
status: status,
setStatus: useCallback(
(status: Status) => setState((prev) => ({ ...prev, status })),
[setState]
),
error: error,
handleManual,
discrepancy,
selectedItems,
Expand Down
1 change: 1 addition & 0 deletions code/addons/a11y/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const ADDON_ID = 'storybook/a11y';
export const PANEL_ID = `${ADDON_ID}/panel`;
export const PARAM_KEY = `a11y`;
export const UI_STATE_ID = `${ADDON_ID}/ui`;
const RESULT = `${ADDON_ID}/result`;
const REQUEST = `${ADDON_ID}/request`;
const RUNNING = `${ADDON_ID}/running`;
Expand Down
8 changes: 5 additions & 3 deletions code/addons/a11y/src/manager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('A11yManager', () => {

it('should compute title with no issues', () => {
// given
mockedApi.useAddonState.mockImplementation(() => [undefined]);
mockedApi.useAddonState.mockImplementation(() => [{ results: undefined }]);
registrationImpl(api as unknown as api.API);
const title = mockedAddons.add.mock.calls.map(([_, def]) => def).find(isPanel)
?.title as () => void;
Expand All @@ -65,8 +65,10 @@ describe('A11yManager', () => {
// given
mockedApi.useAddonState.mockImplementation(() => [
{
violations: [{}],
incomplete: [{}, {}],
results: {
violations: [{}],
incomplete: [{}, {}],
},
},
]);
registrationImpl(mockedApi);
Expand Down
Loading