Skip to content

Core: Draw highlights on top of canvas and add various new features #30894

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 51 commits into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
806a8a9
Implement highlights using overlay
ghengeveld Mar 21, 2025
861a80a
Handle dynamically created elements
ghengeveld Mar 22, 2025
082035f
Implement popover
ghengeveld Mar 24, 2025
a4be866
Handle scrolling
ghengeveld Mar 24, 2025
ae6e959
Refactor and add support for menu items
ghengeveld Mar 25, 2025
4c33573
Update highlight preview code to render new HighlightOverlay
ghengeveld Mar 25, 2025
0d3cb76
Fix stories
ghengeveld Mar 25, 2025
72626c3
Merge branch 'a11y-polish' into highlight-overlay
ghengeveld Mar 25, 2025
ecfdab7
Merge branch 'next' into highlight-overlay
ghengeveld Mar 27, 2025
010a353
Handle highlights inside scrollable containers
ghengeveld Mar 27, 2025
c419ce8
Remove unused story
ghengeveld Mar 27, 2025
3aa7687
Merge branch 'next' into highlight-overlay
ghengeveld Mar 29, 2025
841dcc1
Reimplement advanced highlighting using vanilla JS (no React)
ghengeveld Mar 31, 2025
a31c955
Merge branch 'next' into highlight-overlay
ghengeveld Mar 31, 2025
a3f571b
Clean up existing instance of useHighlights if there is one
ghengeveld Mar 31, 2025
d040e26
Highlight style tweaks
ghengeveld Mar 31, 2025
85ec1f4
Always reset styles
ghengeveld Mar 31, 2025
8ad7f10
Rename menuListItems to just menuItems
ghengeveld Mar 31, 2025
45700a9
Merge branch 'next' into highlight-overlay
ghengeveld Mar 31, 2025
ed29b4a
Rename HighlightInfo to HighlightOptions
ghengeveld Mar 31, 2025
85147e9
Add support for popovers, fixed and sticky elements
ghengeveld Apr 10, 2025
2f95396
Add support for hover styles
ghengeveld Apr 12, 2025
430ae58
Rename selectedStyles to focusStyles and apply hover style when hover…
ghengeveld Apr 12, 2025
f3b3e2e
Add basic support for hints
ghengeveld Apr 14, 2025
a7d11e3
Remove hint feature for now
ghengeveld Apr 16, 2025
a8f6aad
Improve types/docs
ghengeveld Apr 16, 2025
c5ad8b2
Update menu property to new API
ghengeveld Apr 16, 2025
4be0ed0
Ensure highlights render on top of content
ghengeveld Apr 16, 2025
056dbb5
Warn when attempting to scroll to nonexistent element
ghengeveld Apr 16, 2025
a9b3f50
Render highlights as a popover
ghengeveld Apr 16, 2025
158712b
Fix unsubscribe logic
ghengeveld Apr 16, 2025
32ed2e4
Clear interval after 60 seconds
ghengeveld Apr 16, 2025
04652c6
Merge branch 'next' into highlight-overlay
ghengeveld Apr 16, 2025
747952c
Linting
ghengeveld Apr 16, 2025
090dc59
Fix eslint env
ghengeveld Apr 16, 2025
57e8b72
Rename element IDs
ghengeveld Apr 16, 2025
77f508b
Avoid global references to browser APIs, and other test fixes
ghengeveld Apr 16, 2025
8477b54
Fix E2E tests for A11y addon and fix menu styling
ghengeveld Apr 17, 2025
1627899
Merge branch 'next' into highlight-overlay
ghengeveld Apr 17, 2025
7c80a76
Update snapshots
ghengeveld Apr 17, 2025
f5a3e13
Avoid using random ids
ghengeveld Apr 17, 2025
7a9d1a0
Only create the root element when first highlights are added
ghengeveld Apr 17, 2025
78ae3fb
Only highlight selected items from the active tab
ghengeveld Apr 17, 2025
0e58134
Ensure root exists before rendering menu, and avoid event listener on…
ghengeveld Apr 17, 2025
31ccf9f
Merge branch 'next' into highlight-overlay
ghengeveld Apr 17, 2025
1557a12
Merge branch 'next' into highlight-overlay
ghengeveld Apr 18, 2025
4cc0c39
Loosen selector
ghengeveld Apr 18, 2025
996904e
Simplify assertion
ghengeveld Apr 18, 2025
b5dbd5a
Reset selection when switching stories
ghengeveld Apr 18, 2025
493b525
Redraw boxes when the viewport resizes, not just when storybook-root …
ghengeveld Apr 18, 2025
68a2fb0
Avoid emitting FORCE_REMOUNT in story, use globalThis rather than win…
ghengeveld Apr 18, 2025
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
4 changes: 4 additions & 0 deletions code/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const config = defineMain({
directory: '../core/src/components/components',
titlePrefix: 'components',
},
{
directory: '../core/src/highlight',
titlePrefix: 'highlight',
},
{
directory: '../lib/blocks/src',
titlePrefix: 'blocks',
Expand Down
73 changes: 61 additions & 12 deletions code/addons/a11y/src/components/A11yContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,20 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
[setResults, status, storyId]
);

const handleSelect = useCallback(
(item: { id: string }, target: { selectors: string[] }) => {
const [type, id] = item.id.split('.');
const index =
results[type as RuleType]
.find((r) => r.id === id)
?.nodes.findIndex((n) => target.selectors.some((s) => s === String(n.target))) ?? -1;
if (index !== -1) {
setSelectedItems(new Map([[`${type}.${id}`, `${type}.${id}.${index + 1}`]]));
}
},
[results]
);

const handleReport = useCallback(
({ reporters }: StoryFinishedPayload) => {
const a11yReport = reporters.find((r) => r.type === 'a11y') as Report<A11YReport> | undefined;
Expand Down Expand Up @@ -257,10 +271,11 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
{
[EVENTS.RESULT]: handleResult,
[EVENTS.ERROR]: handleError,
[EVENTS.SELECT]: handleSelect,
[STORY_RENDER_PHASE_CHANGED]: handleReset,
[STORY_FINISHED]: handleReport,
},
[handleReset, handleReport, handleReset, handleError, handleResult]
[handleReset, handleReport, handleSelect, handleError, handleResult]
);

const handleManual = useCallback(() => {
Expand Down Expand Up @@ -293,24 +308,58 @@ export const A11yContextProvider: FC<PropsWithChildren> = (props) => {
const [type, id, number] = key.split('.');
const result = results[type as RuleType].find((r) => r.id === id);
const target = result?.nodes[Number(number) - 1]?.target;
return target ? [target] : [];
return target ? [String(target)] : [];
});
emit(HIGHLIGHT, {
elements: selected,
color: colorsByType[tab],
width: '2px',
offset: '0px',
priority: 1,
selectors: selected,
styles: {
outline: `2px solid color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`,
outlineOffset: '2px',
backgroundColor: 'transparent',
},
selectedStyles: {
outline: `2px solid ${colorsByType[tab]}`,
outlineOffset: '2px',
backgroundColor: 'transparent',
},
menuListItems: results[tab as RuleType].map((result) => ({
id: `${tab}.${result.id}`,
title: result.help,
description: result.description,
clickEvent: EVENTS.SELECT,
selectors: result.nodes
.flatMap((n) => n.target)
.map(String)
.filter((e) => selected.includes(e)),
})),
});

const others = results[tab as RuleType]
.flatMap((r) => r.nodes.map((n) => n.target))
.flatMap((r) => r.nodes.flatMap((n) => n.target).map(String))
.filter((e) => !selected.includes(e));
emit(HIGHLIGHT, {
elements: others,
color: `${colorsByType[tab]}99`,
style: 'dashed',
width: '1px',
offset: '1px',
selectors: others,
styles: {
outline: `1px dashed color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`,
outlineOffset: '1px',
backgroundColor: `color-mix(in srgb, ${colorsByType[tab]}, transparent 60%)`,
},
selectedStyles: {
outline: `1px solid ${colorsByType[tab]}`,
outlineOffset: '1px',
backgroundColor: 'transparent',
},
menuListItems: results[tab as RuleType].map((result) => ({
id: `${tab}.${result.id}`,
title: result.help,
description: result.description,
clickEvent: EVENTS.SELECT,
selectors: result.nodes
.flatMap((n) => n.target)
.map(String)
.filter((e) => !selected.includes(e)),
})),
});
}, [emit, highlighted, results, tab, selectedItems]);

Expand Down
3 changes: 2 additions & 1 deletion code/addons/a11y/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ const REQUEST = `${ADDON_ID}/request`;
const RUNNING = `${ADDON_ID}/running`;
const ERROR = `${ADDON_ID}/error`;
const MANUAL = `${ADDON_ID}/manual`;
const SELECT = `${ADDON_ID}/select`;

export const DOCUMENTATION_LINK = 'writing-tests/accessibility-testing';
export const DOCUMENTATION_DISCREPANCY_LINK = `${DOCUMENTATION_LINK}#why-are-my-tests-failing-in-different-environments`;

export const TEST_PROVIDER_ID = 'storybook/addon-a11y/test-provider';

export const EVENTS = { RESULT, REQUEST, RUNNING, ERROR, MANUAL };
export const EVENTS = { RESULT, REQUEST, RUNNING, ERROR, MANUAL, SELECT };

export const STATUS_TYPE_ID_COMPONENT_TEST = 'storybook/component-test';
export const STATUS_TYPE_ID_A11Y = 'storybook/a11y';
1 change: 1 addition & 0 deletions code/core/src/highlight/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export const ADDON_ID = 'storybook/highlight';

export const HIGHLIGHT = `${ADDON_ID}/add`;
export const REMOVE_HIGHLIGHT = `${ADDON_ID}/remove`;
export const RESET_HIGHLIGHT = `${ADDON_ID}/reset`;
export const SCROLL_INTO_VIEW = `${ADDON_ID}/scroll-into-view`;
112 changes: 3 additions & 109 deletions code/core/src/highlight/preview.ts
Original file line number Diff line number Diff line change
@@ -1,119 +1,13 @@
/* eslint-env browser */
import { STORY_CHANGED } from 'storybook/internal/core-events';

import { global } from '@storybook/global';

import { addons, definePreview } from 'storybook/preview-api';

import { HIGHLIGHT, RESET_HIGHLIGHT, SCROLL_INTO_VIEW } from './constants';

const { document } = global;

interface HighlightOptions {
/** HTML selectors of the elements */
elements: string[];
/** Color of the outline */
color?: string;
/** Style of the outline */
style?: 'dotted' | 'dashed' | 'solid' | 'double';
/** Width of the outline */
width?: string;
/** Offset of the outline */
offset?: string;
/**
* Duration in milliseconds of the fade out animation. Note you must use a 6-character hex color
* to use this option.
*/
fadeOut?: number;
/**
* Duration in milliseconds of the pulse out animation. Note you must use a 6-character hex color
* to use this option.
*/
pulseOut?: number;
}

const highlightStyle = (
selectors: string[],
{
color = '#FF4785',
style = 'solid',
width = '1px',
offset = '2px',
fadeOut = 0,
pulseOut = 0,
}: HighlightOptions
) => {
const animationName = Math.random().toString(36).substring(2, 15);
let keyframes = '';
if (pulseOut) {
keyframes = `@keyframes ${animationName} {
0% { outline: ${width} ${style} ${color}; }
20% { outline: ${width} ${style} ${color}00; }
40% { outline: ${width} ${style} ${color}; }
60% { outline: ${width} ${style} ${color}00; }
80% { outline: ${width} ${style} ${color}; }
100% { outline: ${width} ${style} ${color}00; }
}\n`;
} else if (fadeOut) {
keyframes = `@keyframes ${animationName} {
0% { outline: ${width} ${style} ${color}; }
100% { outline: ${width} ${style} ${color}00; }
}\n`;
}

return `${keyframes}${selectors.join(', ')} {
outline: ${width} ${style} ${color};
outline-offset: ${offset};
${pulseOut || fadeOut ? `animation: ${animationName} ${pulseOut || fadeOut}ms linear forwards;` : ''}
}`;
};
import { useHighlights } from './useHighlights';

if (addons && addons.ready) {
addons.ready().then(() => {
const channel = addons.getChannel();
const sheetIds = new Set<string>();

const highlight = (options: HighlightOptions) => {
const sheetId = Math.random().toString(36).substring(2, 15);
sheetIds.add(sheetId);

const sheet = document.createElement('style');
sheet.innerHTML = highlightStyle(Array.from(new Set(options.elements)), options);
sheet.setAttribute('id', sheetId);
document.head.appendChild(sheet);

const timeout = options.pulseOut || options.fadeOut;
if (timeout) {
setTimeout(() => removeHighlight(sheetId), timeout + 500);
}
};

const removeHighlight = (id: string) => {
const sheetElement = document.getElementById(id);
sheetElement?.parentNode?.removeChild(sheetElement);
sheetIds.delete(id);
};

const resetHighlight = () => {
sheetIds.forEach(removeHighlight);
};

const scrollIntoView = (target: string, options?: ScrollIntoViewOptions) => {
const element = document.querySelector(target);
element?.scrollIntoView({ behavior: 'smooth', block: 'center', ...options });
highlight({
elements: [target],
color: '#1EA7FD',
width: '2px',
offset: '2px',
pulseOut: 3000,
});
};

channel.on(STORY_CHANGED, resetHighlight);
channel.on(SCROLL_INTO_VIEW, scrollIntoView);
channel.on(RESET_HIGHLIGHT, resetHighlight);
channel.on(HIGHLIGHT, highlight);
useHighlights({ channel });
});
}

export default () => definePreview({});
62 changes: 62 additions & 0 deletions code/core/src/highlight/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,65 @@ export interface HighlightParameters {
disable?: boolean;
};
}

export interface HighlightInfo {
/** Unique identifier for the highlight, required if you want to remove the highlight later */
id?: string;
/** HTML selectors of the elements */
selectors: string[];
/** CSS styles to apply to the highlight */
styles: Record<string, string>;
/** Priority of the highlight, higher takes precedence, defaults to 0 */
priority?: number;
/** Whether the highlight is selectable / hoverable */
selectable?: boolean;
/** CSS styles to apply to the highlight when it is selected or hovered */
selectedStyles?: Record<string, string>;
/** Keyframes required for animations */
keyframes?: string;
/** Menu items to show when the highlight is selected, or true to show the element's HTML */
menuListItems?: {
id: string;
title: string;
description?: string;
right?: string;
href?: string;
clickEvent?: string;
selectors?: string[];
}[];
}

// Legacy format
export interface LegacyHighlightInfo {
/** @deprecated Use selectors instead */
elements: string[];
/** @deprecated Use styles instead */
color: string;
/** @deprecated Use styles instead */
style: 'dotted' | 'dashed' | 'solid' | 'double';
}

export type RawHighlightInfo = HighlightInfo | LegacyHighlightInfo;

export type Highlight = {
id: string;
priority: number;
selectors: string[];
styles: Record<string, string>;
selectable: boolean;
selectedStyles?: Record<string, string>;
menuListItems?: HighlightInfo['menuListItems'];
};

export type Box = {
element: HTMLElement;
selectors: Highlight['selectors'];
styles: Highlight['styles'];
selectable?: Highlight['selectable'];
selectedStyles?: Highlight['selectedStyles'];
menuListItems?: Highlight['menuListItems'];
top: number;
left: number;
width: number;
height: number;
};
Loading
Loading