diff --git a/code/.storybook/main.ts b/code/.storybook/main.ts index 5853b8f54945..370c8b6f160d 100644 --- a/code/.storybook/main.ts +++ b/code/.storybook/main.ts @@ -40,6 +40,10 @@ const config = defineMain({ directory: '../core/src/controls/components', titlePrefix: 'controls', }, + { + directory: '../core/src/highlight', + titlePrefix: 'highlight', + }, { directory: '../lib/blocks/src', titlePrefix: 'blocks', diff --git a/code/addons/a11y/src/components/A11yContext.tsx b/code/addons/a11y/src/components/A11yContext.tsx index 9b9c2b9cd922..106aa13f11f2 100644 --- a/code/addons/a11y/src/components/A11yContext.tsx +++ b/code/addons/a11y/src/components/A11yContext.tsx @@ -2,6 +2,7 @@ import type { FC, PropsWithChildren } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { + STORY_CHANGED, STORY_FINISHED, STORY_RENDER_PHASE_CHANGED, type StoryFinishedPayload, @@ -52,10 +53,11 @@ export interface A11yContextStore { handleSelectionChange: (key: string) => void; } +const theme = convert(themes.light); const colorsByType = { - [RuleType.VIOLATION]: convert(themes.light).color.negative, - [RuleType.PASS]: convert(themes.light).color.positive, - [RuleType.INCOMPLETION]: convert(themes.light).color.warning, + [RuleType.VIOLATION]: theme.color.negative, + [RuleType.PASS]: theme.color.positive, + [RuleType.INCOMPLETION]: theme.color.warning, }; export const A11yContext = createContext({ @@ -201,6 +203,20 @@ export const A11yContextProvider: FC = (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 | undefined; @@ -234,10 +250,12 @@ export const A11yContextProvider: FC = (props) => { { [EVENTS.RESULT]: handleResult, [EVENTS.ERROR]: handleError, + [EVENTS.SELECT]: handleSelect, + [STORY_CHANGED]: () => setSelectedItems(new Map()), [STORY_RENDER_PHASE_CHANGED]: handleReset, [STORY_FINISHED]: handleReport, }, - [handleReset, handleReport, handleReset, handleError, handleResult] + [handleReset, handleReport, handleSelect, handleError, handleResult] ); const handleManual = useCallback(() => { @@ -267,31 +285,60 @@ export const A11yContextProvider: FC = (props) => { const selected = Array.from(selectedItems.values()).flatMap((key) => { const [type, id, number] = key.split('.'); + if (type !== tab) { + return []; + } 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, { + priority: 1, + selectors: selected, + styles: { + outline: `1px solid color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`, + backgroundColor: 'transparent', + }, + focusStyles: { + outline: `1px solid ${theme.color.secondary}`, + backgroundColor: 'transparent', + }, + menu: 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)); - - if (selected?.length) { - emit(HIGHLIGHT, { - elements: selected, - color: colorsByType[tab], - width: '2px', - offset: '0px', - }); - } - if (others?.length) { - emit(HIGHLIGHT, { - elements: others, - color: `${colorsByType[tab]}99`, - style: 'dashed', - width: '1px', - offset: '1px', - }); - } + emit(HIGHLIGHT, { + selectors: others, + styles: { + outline: `1px solid color-mix(in srgb, ${colorsByType[tab]}, transparent 30%)`, + backgroundColor: `color-mix(in srgb, ${colorsByType[tab]}, transparent 60%)`, + }, + focusStyles: { + outline: `1px solid ${theme.color.secondary}`, + backgroundColor: 'transparent', + }, + menu: 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]); const discrepancy: TestDiscrepancy = useMemo(() => { diff --git a/code/addons/a11y/src/constants.ts b/code/addons/a11y/src/constants.ts index bc7391abe4f9..f9293f9e260b 100755 --- a/code/addons/a11y/src/constants.ts +++ b/code/addons/a11y/src/constants.ts @@ -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'; diff --git a/code/addons/pseudo-states/src/stories/Button.stories.tsx b/code/addons/pseudo-states/src/stories/Button.stories.tsx index 5ce603196091..b09eefa68bfb 100644 --- a/code/addons/pseudo-states/src/stories/Button.stories.tsx +++ b/code/addons/pseudo-states/src/stories/Button.stories.tsx @@ -1,8 +1,5 @@ import React, { type ComponentProps } from 'react'; -import { FORCE_REMOUNT } from 'storybook/internal/core-events'; -import { useChannel, useStoryContext } from 'storybook/internal/preview-api'; - import type { Meta, StoryObj } from '@storybook/react-vite'; import { Button } from './Button'; @@ -127,23 +124,21 @@ export const DirectSelectorParentDoesNotAffectDescendants: Story = { export const DynamicStyles: Story = { render: (args, context) => { - const emit = useChannel({}); - const { id: storyId } = useStoryContext(); - - setTimeout(() => { - // @ts-expect-error We're adding this nonstandard property below - // eslint-disable-next-line no-underscore-dangle - if (window.__dynamicRuleInjected) { - return; - } - // @ts-expect-error We're adding this nonstandard property - // eslint-disable-next-line no-underscore-dangle - window.__dynamicRuleInjected = true; - const sheet = Array.from(document.styleSheets).at(-1); - sheet?.insertRule('.dynamic.button:hover { background-color: tomato }'); - emit(FORCE_REMOUNT, { storyId }); - }, 100); - return All.render!({ className: 'dynamic' }, context); }, + play: async ({ id: storyId }) => { + return new Promise((resolve) => { + setTimeout(() => { + // @ts-expect-error We're adding this nonstandard property + if (globalThis[`__dynamicRuleInjected_${storyId}`]) { + return; + } + // @ts-expect-error We're adding this nonstandard property + globalThis[`__dynamicRuleInjected_${storyId}`] = true; + const sheet = Array.from(document.styleSheets).at(-1); + sheet?.insertRule('.dynamic.button:hover { background-color: tomato }'); + resolve(); + }, 100); + }); + }, }; diff --git a/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx b/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx index 720475b97ccd..5c1fd571f8f4 100644 --- a/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx +++ b/code/addons/pseudo-states/src/stories/CSSAtRules.stories.tsx @@ -71,25 +71,23 @@ export const Active: Story = { export const DynamicStyles: Story = { render: (args, context) => { - const emit = useChannel({}); - const { id: storyId } = useStoryContext(); - - setTimeout(() => { - // @ts-expect-error We're adding this nonstandard property below - // eslint-disable-next-line no-underscore-dangle - if (window.__dynamicRuleInjected) { - return; - } - // @ts-expect-error We're adding this nonstandard property below - // eslint-disable-next-line no-underscore-dangle - window.__dynamicRuleInjected = true; - const sheet = Array.from(document.styleSheets).at(-1); - sheet?.insertRule( - '@layer foo { .dynamic.button:hover { background-color: tomato!important } }' - ); - emit(FORCE_REMOUNT, { storyId }); - }, 100); - return All.render!({ className: 'dynamic' }, context); }, + play: async ({ id: storyId }) => { + return new Promise((resolve) => { + setTimeout(() => { + // @ts-expect-error We're adding this nonstandard property below + if (globalThis[`__dynamicRuleInjected_${storyId}`]) { + return; + } + // @ts-expect-error We're adding this nonstandard property + globalThis[`__dynamicRuleInjected_${storyId}`] = true; + const sheet = Array.from(document.styleSheets).at(-1); + sheet?.insertRule( + '@layer foo { .dynamic.button:hover { background-color: tomato!important } }' + ); + resolve(); + }, 100); + }); + }, }; diff --git a/code/core/src/highlight/constants.ts b/code/core/src/highlight/constants.ts index eec22ef7e5c1..a2a262481ea4 100644 --- a/code/core/src/highlight/constants.ts +++ b/code/core/src/highlight/constants.ts @@ -1,5 +1,8 @@ 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`; + +export const MAX_Z_INDEX = 2147483647; diff --git a/code/core/src/highlight/preview.ts b/code/core/src/highlight/preview.ts index 0bf3f95a1f2b..086f0501414e 100644 --- a/code/core/src/highlight/preview.ts +++ b/code/core/src/highlight/preview.ts @@ -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(); - - 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({}); diff --git a/code/core/src/highlight/types.ts b/code/core/src/highlight/types.ts index 3613b23fb9b5..3b09f1cda062 100644 --- a/code/core/src/highlight/types.ts +++ b/code/core/src/highlight/types.ts @@ -9,3 +9,72 @@ export interface HighlightParameters { disable?: boolean; }; } + +export interface HighlightOptions { + /** Unique identifier for the highlight, required if you want to remove the highlight later */ + id?: string; + /** HTML selectors of the elements */ + selectors: string[]; + /** Priority of the highlight, higher takes precedence, defaults to 0 */ + priority?: number; + /** Whether the highlight is selectable (reveals the element's HTML) */ + selectable?: boolean; + /** CSS styles to apply to the highlight */ + styles: Record; + /** CSS styles to apply to the highlight when it is hovered */ + hoverStyles?: Record; + /** CSS styles to apply to the highlight when it is focused or selected */ + focusStyles?: Record; + /** Keyframes required for animations */ + keyframes?: string; + /** Menu items to show when the highlight is selected (implies selectable: true) */ + menu?: { + /** Unique identifier for the menu item */ + id: string; + /** Title of the menu item */ + title: string; + /** Description of the menu item */ + description?: string; + /** Name for a channel event to trigger when the menu item is clicked */ + clickEvent?: string; + /** HTML selectors for which this menu item should show (subset of `selectors`) */ + selectors?: string[]; + }[]; +} + +// Legacy format +export interface LegacyHighlightOptions { + /** @deprecated Use selectors instead */ + elements: string[]; + /** @deprecated Use styles instead */ + color: string; + /** @deprecated Use styles instead */ + style: 'dotted' | 'dashed' | 'solid' | 'double'; +} + +export type RawHighlightOptions = HighlightOptions | LegacyHighlightOptions; + +export type Highlight = { + id: string; + priority: number; + selectors: string[]; + selectable: boolean; + styles: HighlightOptions['styles']; + hoverStyles?: HighlightOptions['hoverStyles']; + focusStyles?: HighlightOptions['focusStyles']; + menu?: HighlightOptions['menu']; +}; + +export type Box = { + element: HTMLElement; + selectors: Highlight['selectors']; + selectable?: Highlight['selectable']; + styles: Highlight['styles']; + hoverStyles?: Highlight['hoverStyles']; + focusStyles?: Highlight['focusStyles']; + menu?: Highlight['menu']; + top: number; + left: number; + width: number; + height: number; +}; diff --git a/code/core/src/highlight/useHighlights.stories.tsx b/code/core/src/highlight/useHighlights.stories.tsx new file mode 100644 index 000000000000..b36db0e7de9a --- /dev/null +++ b/code/core/src/highlight/useHighlights.stories.tsx @@ -0,0 +1,364 @@ +import React, { useEffect, useState } from 'react'; + +import { mockChannel } from 'storybook/internal/preview-api'; + +import { fn, userEvent, within } from 'storybook/test'; + +import preview from '../../../.storybook/preview'; +import { HIGHLIGHT, RESET_HIGHLIGHT, SCROLL_INTO_VIEW } from './constants'; +import { useHighlights } from './useHighlights'; + +const Content = ({ dynamic, withPopover }: { dynamic: boolean; withPopover: boolean }) => { + const [extra, setExtra] = useState(false); + useEffect(() => { + if (!dynamic) { + return; + } + const interval = setInterval(() => setExtra((v) => !v), 1200); + return () => clearInterval(interval); + }, [dynamic]); + /* eslint-disable react/no-unknown-property */ + return ( +
+ {withPopover && ( + <> + {/* @ts-expect-error popover is not yet supported by React */} + + {/* @ts-expect-error popover is not yet supported by React */} +
+ Greetings, one and all! +
+ + )} + +
+
+
+
+
+
+
+
+
+ {extra && ( +
+ )} +
+ ); + /* eslint-enable react/no-unknown-property */ +}; + +const channel = mockChannel(); +channel.on('click', fn().mockName('click')); + +const meta = preview.meta({ + render: (args, { parameters }) => { + useEffect(() => useHighlights({ channel, menuId: 'menu-id', rootId: 'root-id' }), []); + return ; + }, + args: { + channel, + }, + parameters: { + layout: 'fullscreen', + highlight: { + disable: true, + }, + }, +}); + +const highlight = ( + selectors: string[], + options?: { + selectable?: boolean; + styles?: Record; + hoverStyles?: Record; + focusStyles?: Record; + keyframes?: string; + menu?: { + id: string; + title: string; + description?: string; + right?: string; + href?: string; + clickEvent?: string; + }[]; + } +) => + channel.emit(HIGHLIGHT, { + selectors, + selectable: options?.selectable ?? !!options?.menu?.length, + styles: { + background: 'rgba(0, 137, 80, 0.2)', + border: '1px solid teal', + }, + hoverStyles: { + borderWidth: '3px', + }, + focusStyles: { + background: 'transparent', + border: '2px solid teal', + }, + ...options, + }); + +export const Default = meta.story({ + play: async () => { + highlight(['div', 'input']); + }, +}); + +export const Multiple = meta.story({ + play: async () => { + highlight(['main > div', 'input']); + highlight(['div > div'], { + styles: { + border: '3px solid hotpink', + }, + }); + }, +}); + +export const Dynamic = meta.story({ + parameters: { + dynamic: true, + }, + play: async ({ canvasElement }) => { + highlight(['div', 'input']); + + const scaling = canvasElement.querySelector('#scaling') as HTMLElement; + const moving = canvasElement.querySelector('#moving') as HTMLElement; + + const interval = setInterval(() => { + scaling.style.height = `${parseInt(scaling.style.height) + 5}px`; + moving.style.left = `${parseInt(moving.style.left) + 5}px`; + }, 1000); + setTimeout(() => clearInterval(interval), 60000); + }, +}); + +export const Styles = meta.story({ + play: async () => { + highlight(['div', 'input'], { + styles: { + outline: '3px dashed hotpink', + animation: 'pulse 3s linear infinite', + transition: 'outline-offset 0.2s ease-in-out', + }, + focusStyles: { + outlineOffset: '3px', + }, + keyframes: `@keyframes pulse { + 0% { outline: 3px dashed rgba(255, 105, 180, 1); } + 50% { outline: 3px dashed rgba(255, 105, 180, 0.2); } + 100% { outline: 3px dashed rgba(255, 105, 180, 1); } + }`, + }); + }, +}); + +export const ScrollIntoView = meta.story({ + play: async () => { + channel.emit(SCROLL_INTO_VIEW, '#footer'); + }, +}); + +export const Selectable = meta.story({ + play: async () => { + highlight(['div', 'input'], { + selectable: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await userEvent.pointer({ + coords: { pageX: 470, pageY: 240 }, + keys: '[MouseLeft]', + }); + }, +}); + +export const Menu = meta.story({ + play: async () => { + highlight(['div', 'input'], { + menu: [ + { + id: '1', + title: 'Insufficient color contrast', + description: 'Elements must meet minimum color contrast ratio thresholds.', + clickEvent: 'click', + }, + { + id: '2', + title: 'Links need discernible text', + description: 'This is where a summary of the violation goes.', + clickEvent: 'click', + }, + ], + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await userEvent.pointer({ + coords: { pageX: 470, pageY: 240 }, + keys: '[MouseLeft]', + }); + }, +}); + +export const OnPopover = meta.story({ + parameters: { + withPopover: true, + }, + play: async ({ canvasElement }) => { + const button = within(canvasElement).getByText('Open Popover 1'); + await userEvent.click(button); + + highlight(['[popover]'], { + selectable: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + await userEvent.pointer({ + coords: { pageX: window.innerWidth / 2, pageY: window.innerHeight / 2 }, + keys: '[MouseLeft]', + }); + }, +}); + +const Toggler = ({ children }: { children: React.ReactNode }) => { + useEffect(() => { + let timeout = setTimeout(() => highlight(['div']), 1500); + const interval = setInterval(() => { + channel.emit(RESET_HIGHLIGHT); + timeout = setTimeout(() => highlight(['div']), 1500); + }, 3000); + return () => { + clearInterval(interval); + clearTimeout(timeout); + }; + }, []); + return children; +}; + +export const Toggling = meta.story({ + decorators: [ + (Story) => ( + + + + ), + ], +}); + +export const LayoutCentered = meta.story({ + parameters: { + layout: 'centered', + }, + play: async () => { + highlight(['div']); + }, + render: () => { + useEffect(() => useHighlights({ channel, menuId: 'menu-id', rootId: 'root-id' }), []); + return
Content
; + }, +}); diff --git a/code/core/src/highlight/useHighlights.ts b/code/core/src/highlight/useHighlights.ts new file mode 100644 index 000000000000..b7b3a3bf10b9 --- /dev/null +++ b/code/core/src/highlight/useHighlights.ts @@ -0,0 +1,618 @@ +/* eslint-env browser */ +import type { Channel } from 'storybook/internal/channels'; +import { STORY_CHANGED } from 'storybook/internal/core-events'; + +import { + HIGHLIGHT, + MAX_Z_INDEX, + REMOVE_HIGHLIGHT, + RESET_HIGHLIGHT, + SCROLL_INTO_VIEW, +} from './constants'; +import type { Box, Highlight, HighlightOptions, RawHighlightOptions } from './types'; +import { + convertLegacy, + createElement, + hidePopover, + isOverMenu, + isTargeted, + keepInViewport, + mapBoxes, + mapElements, + showPopover, + useStore, +} from './utils'; + +const chevronLeft = () => + createElement( + 'svg', + { width: '14', height: '14', viewBox: '0 0 14 14', xmlns: 'http://www.w3.org/2000/svg' }, + [ + createElement('path', { + fillRule: 'evenodd', + clipRule: 'evenodd', + d: 'M9.10355 10.1464C9.29882 10.3417 9.29882 10.6583 9.10355 10.8536C8.90829 11.0488 8.59171 11.0488 8.39645 10.8536L4.89645 7.35355C4.70118 7.15829 4.70118 6.84171 4.89645 6.64645L8.39645 3.14645C8.59171 2.95118 8.90829 2.95118 9.10355 3.14645C9.29882 3.34171 9.29882 3.65829 9.10355 3.85355L5.95711 7L9.10355 10.1464Z', + fill: 'currentColor', + }), + ] + ); + +const chevronRight = () => + createElement( + 'svg', + { width: '14', height: '14', viewBox: '0 0 14 14', xmlns: 'http://www.w3.org/2000/svg' }, + [ + createElement('path', { + fillRule: 'evenodd', + clipRule: 'evenodd', + d: 'M4.89645 10.1464C4.70118 10.3417 4.70118 10.6583 4.89645 10.8536C5.09171 11.0488 5.40829 11.0488 5.60355 10.8536L9.10355 7.35355C9.29882 7.15829 9.29882 6.84171 9.10355 6.64645L5.60355 3.14645C5.40829 2.95118 5.09171 2.95118 4.89645 3.14645C4.70118 3.34171 4.70118 3.65829 4.89645 3.85355L8.04289 7L4.89645 10.1464Z', + fill: 'currentColor', + }), + ] + ); + +export const useHighlights = ({ + channel, + menuId = `storybook-highlights-menu`, + rootId = `storybook-highlights-root`, + storybookRootId = 'storybook-root', +}: { + channel: Channel; + menuId?: string; + rootId?: string; + storybookRootId?: string; +}) => { + // Clean up any existing instance of useHighlights + // eslint-disable-next-line no-underscore-dangle + (globalThis as any).__STORYBOOK_HIGHLIGHT_TEARDOWN?.(); + + const { document } = globalThis; + + const highlights = useStore([]); + const elements = useStore>(new Map()); + const boxes = useStore([]); + + const clickCoords = useStore<{ x: number; y: number } | undefined>(); + const hoverCoords = useStore<{ x: number; y: number } | undefined>(); + const targets = useStore([]); + const hovered = useStore([]); + const focused = useStore(); + const selected = useStore(); + + let root = document.getElementById(rootId); + + // Only create the root element when first highlights are added + highlights.subscribe(() => { + if (!root) { + root = createElement('div', { id: rootId }) as HTMLElement; + document.body.appendChild(root); + } + }); + + // Update tracked elements when highlights change or the DOM tree changes + highlights.subscribe((value) => { + const storybookRoot = document.getElementById(storybookRootId)!; + if (!storybookRoot) { + return; + } + + elements.set(mapElements(value)); + + const observer = new MutationObserver(() => elements.set(mapElements(value))); + observer.observe(storybookRoot, { subtree: true, childList: true }); + + return () => { + observer.disconnect(); + }; + }); + + // Update highlight boxes when elements are resized or scrollable elements are scrolled + elements.subscribe((value) => { + const updateBoxes = () => requestAnimationFrame(() => boxes.set(mapBoxes(value))); + const observer = new ResizeObserver(updateBoxes); + observer.observe(document.body); + Array.from(value.keys()).forEach((element) => observer.observe(element)); + + const scrollers = Array.from(document.body.querySelectorAll('*')).filter((el) => { + const { overflow, overflowX, overflowY } = window.getComputedStyle(el); + return ['auto', 'scroll'].some((o) => [overflow, overflowX, overflowY].includes(o)); + }); + scrollers.forEach((element) => element.addEventListener('scroll', updateBoxes)); + + return () => { + observer.disconnect(); + scrollers.forEach((element) => element.removeEventListener('scroll', updateBoxes)); + }; + }); + + // Update highlight boxes for sticky elements when scrolling the window + elements.subscribe((value) => { + const sticky = Array.from(value.keys()).filter(({ style }) => style.position === 'sticky'); + const updateBoxes = () => + requestAnimationFrame(() => { + boxes.set((current) => + current.map((box) => { + if (sticky.includes(box.element)) { + const { top, left } = box.element.getBoundingClientRect(); + return { ...box, top: top + window.scrollY, left: left + window.scrollX }; + } + return box; + }) + ); + }); + + document.addEventListener('scroll', updateBoxes); + return () => document.removeEventListener('scroll', updateBoxes); + }); + + // Remove stale click targets (boxes) when elements are removed + elements.subscribe((value) => { + targets.set((t) => t.filter(({ element }) => value.has(element))); + }); + + // Update selected and focused elements when clickable targets change + targets.subscribe((value) => { + if (value.length) { + selected.set((s) => (value.some((t) => t.element === s?.element) ? s : undefined)); + focused.set((s) => (value.some((t) => t.element === s?.element) ? s : undefined)); + } else { + selected.set(undefined); + focused.set(undefined); + clickCoords.set(undefined); + } + }); + + // + // Rendering + // + + const styleElementByHighlight = new Map(new Map()); + + // Update highlight keyframes when highlights change + highlights.subscribe((value) => { + value.forEach(({ keyframes }) => { + if (keyframes) { + let style = styleElementByHighlight.get(keyframes); + if (!style) { + style = document.createElement('style'); + style.setAttribute('data-highlight', 'keyframes'); + styleElementByHighlight.set(keyframes, style); + document.head.appendChild(style); + } + style.innerHTML = keyframes; + } + }); + + // Clean up stale keyframes + styleElementByHighlight.forEach((style, keyframes) => { + if (!value.some((v) => v.keyframes === keyframes)) { + style.remove(); + styleElementByHighlight.delete(keyframes); + } + }); + }); + + const boxElementByTargetElement = new Map(new Map()); + + // Create an element for every highlight box + boxes.subscribe((value) => { + value.forEach((box) => { + let boxElement = boxElementByTargetElement.get(box.element); + if (root && !boxElement) { + const props = { + popover: 'manual', + 'data-highlight-dimensions': `w${box.width.toFixed(0)}h${box.height.toFixed(0)}`, + 'data-highlight-coordinates': `x${box.left.toFixed(0)}y${box.top.toFixed(0)}`, + }; + boxElement = root.appendChild(createElement('div', props) as HTMLDivElement); + boxElementByTargetElement.set(box.element, boxElement); + } + }); + + // Clean up stale highlight boxes + boxElementByTargetElement.forEach((box, element) => { + if (!value.some(({ element: e }) => e === element)) { + box.remove(); + boxElementByTargetElement.delete(element); + } + }); + }); + + // Handle click events on highlight boxes + boxes.subscribe((value) => { + const selectable = value.filter((box) => box.selectable); + if (!selectable.length) { + return; + } + + const onClick = (event: MouseEvent) => { + // The menu may get repositioned, so we wait for the next frame before checking its position + requestAnimationFrame(() => { + const menu = document.getElementById(menuId); + const coords = { x: event.pageX, y: event.pageY }; + + // Don't do anything if the click is within the menu + if (menu && !isOverMenu(menu, coords)) { + // Update menu coordinates and clicked target boxes based on the click position + const results = selectable.filter((box) => { + const boxElement = boxElementByTargetElement.get(box.element)!; + return isTargeted(box, boxElement, coords); + }); + clickCoords.set(results.length ? coords : undefined); + targets.set(results); + } + }); + }; + + document.addEventListener('click', onClick); + return () => document.removeEventListener('click', onClick); + }); + + const updateHovered = () => { + const menu = document.getElementById(menuId); + const coords = hoverCoords.get(); + if (!coords || (menu && isOverMenu(menu, coords))) { + return; + } + + hovered.set((current) => { + const update = boxes.get().filter((box) => { + const boxElement = boxElementByTargetElement.get(box.element)!; + return isTargeted(box, boxElement, coords); + }); + const existing = current.filter((box) => update.includes(box)); + const additions = update.filter((box) => !current.includes(box)); + const hasRemovals = current.length - existing.length; + // Only set a new value if there are additions or removals + return additions.length || hasRemovals ? [...existing, ...additions] : current; + }); + }; + hoverCoords.subscribe(updateHovered); + boxes.subscribe(updateHovered); + + const updateBoxStyles = () => { + const selectedElement = selected.get(); + const focusedElement = selectedElement || focused.get(); + const targetElements = selectedElement ? [selectedElement] : targets.get(); + const isMenuOpen = clickCoords.get() !== undefined; + + boxes.get().forEach((box) => { + const boxElement = boxElementByTargetElement.get(box.element); + if (boxElement) { + const isFocused = focusedElement === box; + const isHovered = isMenuOpen + ? focusedElement + ? isFocused + : targetElements.includes(box) + : hovered.get()?.includes(box); + + Object.assign(boxElement.style, { + animation: 'none', + background: 'transparent', + border: 'none', + boxSizing: 'border-box', + outline: 'none', + outlineOffset: '0px', + ...box.styles, + ...(isHovered ? box.hoverStyles : {}), + ...(isFocused ? box.focusStyles : {}), + position: getComputedStyle(box.element).position === 'fixed' ? 'fixed' : 'absolute', + zIndex: MAX_Z_INDEX - 10, + top: `${box.top}px`, + left: `${box.left}px`, + width: `${box.width}px`, + height: `${box.height}px`, + margin: 0, + padding: 0, + cursor: box.selectable ? 'pointer' : 'default', + pointerEvents: box.selectable ? 'auto' : 'none', + }); + + showPopover(boxElement); + } + }); + }; + boxes.subscribe(updateBoxStyles); + hovered.subscribe(updateBoxStyles); + focused.subscribe(updateBoxStyles); + selected.subscribe(updateBoxStyles); + + const renderMenu = () => { + if (!root) { + return; + } + + let menu = document.getElementById(menuId); + if (menu) { + menu.innerHTML = ''; + } else { + const props = { id: menuId, popover: 'manual' }; + menu = root.appendChild(createElement('div', props) as HTMLElement); + root.appendChild( + createElement('style', {}, [ + ` + #${menuId} { + position: absolute; + z-index: ${MAX_Z_INDEX}; + width: 300px; + padding: 0px; + margin: 15px 0 0 0; + transform: translateX(-50%); + font-family: "Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + background: white; + border: none; + border-radius: 6px; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.05), 0 5px 15px 0 rgba(0, 0, 0, 0.1); + color: #2E3438; + } + #${menuId} ul { + list-style: none; + padding: 4px 0; + margin: 0; + max-height: 300px; + overflow-y: auto; + } + #${menuId} li { + padding: 0 4px; + margin: 0; + } + #${menuId} li > * { + display: flex; + padding: 8px; + margin: 0; + align-items: center; + gap: 8px; + border-radius: 4px; + } + #${menuId} button { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: left; + font-family: inherit; + font-size: inherit; + } + #${menuId} button:focus { + outline-color: #029CFD; + } + #${menuId} button:hover { + background: rgba(2, 156, 253, 0.07); + color: #029CFD; + cursor: pointer; + } + #${menuId} li code { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 16px; + font-size: 11px; + } + #${menuId} li svg { + display: none; + flex-shrink: 0; + margin: 1px; + color: #73828C; + } + #${menuId} li > button:hover svg, #${menuId} li > button:focus svg { + color: #029CFD; + } + #${menuId} li.selectable svg, #${menuId} li.selected svg { + display: block; + } + #${menuId} .menu-list { + border-top: 1px solid rgba(38, 85, 115, 0.15); + } + #${menuId} .menu-list li:not(:last-child) { + padding-bottom: 4px; + margin-bottom: 4px; + border-bottom: 1px solid rgba(38, 85, 115, 0.15); + } + #${menuId} .menu-list li div { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0; + } + #${menuId} .menu-list li small { + color: #5C6870; + font-size: 11px; + } + `, + ]) + ); + } + + const selectedElement = selected.get(); + const elementList = selectedElement ? [selectedElement] : targets.get(); + + if (elementList.length) { + menu.style.position = + getComputedStyle(elementList[0].element).position === 'fixed' ? 'fixed' : 'absolute'; + + menu.appendChild( + createElement( + 'ul', + { class: 'element-list' }, + elementList.map((target) => { + const menuItems = target.menu?.filter( + (item) => !item.selectors || item.selectors.some((s) => target.selectors.includes(s)) + ); + const selectable = elementList.length > 1 && !!menuItems?.length; + const props = selectable + ? { + class: 'selectable', + onClick: () => selected.set(target), + onMouseEnter: () => focused.set(target), + onMouseLeave: () => focused.set(undefined), + } + : selectedElement + ? { class: 'selected', onClick: () => selected.set(undefined) } + : {}; + const asButton = selectable || selectedElement; + return createElement('li', props, [ + createElement(asButton ? 'button' : 'div', asButton ? { type: 'button' } : {}, [ + selectedElement ? chevronLeft() : null, + createElement('code', {}, [target.element.outerHTML]), + selectable ? chevronRight() : null, + ]), + ]); + }) + ) + ); + } + + if (selected.get() || targets.get().length === 1) { + const target = selected.get() || targets.get()[0]; + const menuItems = target.menu?.filter( + (item) => !item.selectors || item.selectors.some((s) => target.selectors.includes(s)) + ); + if (menuItems?.length) { + menu.appendChild( + createElement( + 'ul', + { class: 'menu-list' }, + menuItems.map((item) => { + const { title, description, clickEvent } = item; + const onClick = clickEvent && (() => channel.emit(clickEvent, item, target)); + return createElement('li', {}, [ + createElement( + onClick ? 'button' : 'div', + onClick ? { type: 'button', onClick } : {}, + [ + createElement('div', {}, [ + createElement('strong', {}, [title]), + description && createElement('small', {}, [description]), + ]), + ] + ), + ]); + }) + ) + ); + } + } + + const coords = clickCoords.get(); + if (coords) { + Object.assign(menu.style, { + display: 'block', + left: `${menu.style.position === 'fixed' ? coords.x - window.scrollX : coords.x}px`, + top: `${menu.style.position === 'fixed' ? coords.y - window.scrollY : coords.y}px`, + }); + + // Put the menu in #top-layer, above any other popovers and z-indexes + showPopover(menu); + + // Reposition the menu on after it renders, to avoid rendering outside the viewport + requestAnimationFrame(() => keepInViewport(menu, coords, { topOffset: 15, centered: true })); + } else { + hidePopover(menu); + Object.assign(menu.style, { display: 'none' }); + } + }; + targets.subscribe(renderMenu); + selected.subscribe(renderMenu); + + // + // Channel event handlers + // + + const addHighlight = (highlight: RawHighlightOptions) => { + const info = convertLegacy(highlight); + if (info.selectors?.length) { + highlights.set((value) => [...value, info]); + } + }; + + const removeHighlight = (id: string) => { + highlights.set((value) => value.filter((h) => h.id !== id)); + }; + + const clearHighlights = () => { + highlights.set([]); + }; + + let removeTimeout: NodeJS.Timeout; + const scrollIntoView = (target: string, options?: ScrollIntoViewOptions) => { + const id = 'scrollIntoView-highlight'; + clearTimeout(removeTimeout); + removeHighlight(id); + + const element = document.querySelector(target); + if (!element) { + console.warn(`Cannot scroll into view: ${target} not found`); + return; + } + + element.scrollIntoView({ behavior: 'smooth', block: 'center', ...options }); + const keyframeName = `kf-${Math.random().toString(36).substring(2, 15)}`; + highlights.set((value) => [ + ...value, + { + id, + priority: 1000, + selectors: [target], + selectable: false, + styles: { + outline: '2px solid #1EA7FD', + outlineOffset: '2px', + animation: `${keyframeName} 3s linear forwards`, + }, + keyframes: `@keyframes ${keyframeName} { + 0% { outline: 2px solid #1EA7FD; } + 20% { outline: 2px solid #1EA7FD00; } + 40% { outline: 2px solid #1EA7FD; } + 60% { outline: 2px solid #1EA7FD00; } + 80% { outline: 2px solid #1EA7FD; } + 100% { outline: 2px solid #1EA7FD00; } + }`, + }, + ]); + removeTimeout = setTimeout(() => removeHighlight(id), 3500); + }; + + const onMouseMove = (event: MouseEvent): void => { + requestAnimationFrame(() => hoverCoords.set({ x: event.pageX, y: event.pageY })); + }; + + document.body.addEventListener('mousemove', onMouseMove); + + channel.on(HIGHLIGHT, addHighlight); + channel.on(REMOVE_HIGHLIGHT, removeHighlight); + channel.on(RESET_HIGHLIGHT, clearHighlights); + channel.on(STORY_CHANGED, clearHighlights); + channel.on(SCROLL_INTO_VIEW, scrollIntoView); + + const teardown = () => { + clearTimeout(removeTimeout); + + document.body.removeEventListener('mousemove', onMouseMove); + + channel.off(HIGHLIGHT, addHighlight); + channel.off(RESET_HIGHLIGHT, clearHighlights); + channel.off(STORY_CHANGED, clearHighlights); + channel.off(SCROLL_INTO_VIEW, scrollIntoView); + + highlights.teardown(); + elements.teardown(); + boxes.teardown(); + targets.teardown(); + clickCoords.teardown(); + hoverCoords.teardown(); + hovered.teardown(); + focused.teardown(); + selected.teardown(); + + styleElementByHighlight.forEach((style) => style.remove()); + boxElementByTargetElement.forEach((box) => box.remove()); + document.getElementById(menuId)?.remove(); + document.getElementById(rootId)?.remove(); + }; + + // eslint-disable-next-line no-underscore-dangle + (globalThis as any).__STORYBOOK_HIGHLIGHT_TEARDOWN = teardown; + + return teardown; +}; diff --git a/code/core/src/highlight/utils.ts b/code/core/src/highlight/utils.ts new file mode 100644 index 000000000000..c1ebf36bc50e --- /dev/null +++ b/code/core/src/highlight/utils.ts @@ -0,0 +1,223 @@ +/* eslint-env browser */ +import type { Box, Highlight, HighlightOptions, RawHighlightOptions } from './types'; + +const svgElements = 'svg,path,rect,circle,line,polyline,polygon,ellipse,text'.split(','); + +export const createElement = (type: string, props: Record, children?: any[]) => { + const element = svgElements.includes(type) + ? document.createElementNS('http://www.w3.org/2000/svg', type) + : document.createElement(type); + + Object.entries(props).forEach(([key, val]) => { + if (/[A-Z]/.test(key)) { + if (key === 'onClick') { + element.addEventListener('click', val); + (element as HTMLButtonElement).addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + val(); + } + }); + } + if (key === 'onMouseEnter') { + element.addEventListener('mouseenter', val); + } + if (key === 'onMouseLeave') { + element.addEventListener('mouseleave', val); + } + } else { + element.setAttribute(key, val); + } + }); + + children?.forEach((child) => { + if (child === null || child === undefined || child === false) { + return; + } + try { + element.appendChild(child as Node); + } catch (e) { + element.appendChild(document.createTextNode(String(child))); + } + }); + + return element; +}; + +export const convertLegacy = (highlight: RawHighlightOptions): HighlightOptions => { + if ('elements' in highlight) { + const { elements, color, style } = highlight; + return { + selectors: elements, + styles: { + outline: `2px ${style} ${color}`, + outlineOffset: '2px', + boxShadow: '0 0 0 6px rgba(255,255,255,0.6)', + }, + }; + } + return highlight; +}; + +export const isFunction = (obj: unknown): obj is (...args: any[]) => any => obj instanceof Function; + +type Listener = (value: T) => void | (() => void); + +const state = new Map(); +const listeners = new Map[]>(); +const teardowns = new Map, ReturnType>>(); + +export const useStore = (initialValue?: T) => { + const key = Symbol(); + listeners.set(key, []); + state.set(key, initialValue); + + const get = () => state.get(key) as T; + const set = (update: T | ((state: T) => T)) => { + const current = state.get(key) as T; + const next = isFunction(update) ? update(current) : update; + if (next !== current) { + state.set(key, next); + listeners.get(key)?.forEach((listener) => { + teardowns.get(listener)?.(); + teardowns.set(listener, listener(next)); + }); + } + }; + const subscribe = (listener: Listener) => { + listeners.get(key)?.push(listener); + return () => { + const list = listeners.get(key); + if (list) { + listeners.set( + key, + list.filter((l) => l !== listener) + ); + } + }; + }; + const teardown = () => { + listeners.get(key)?.forEach((listener) => { + teardowns.get(listener)?.(); + teardowns.delete(listener); + }); + listeners.delete(key); + state.delete(key); + }; + + return { get, set, subscribe, teardown } as const; +}; + +export const mapElements = (highlights: HighlightOptions[]): Map => { + const root = document.getElementById('storybook-root'); + const map = new Map(); + for (const highlight of highlights) { + const { priority = 0, selectable = !!highlight.menu } = highlight; + for (const selector of highlight.selectors) { + for (const element of root?.querySelectorAll(selector) || []) { + const existing = map.get(element); + if (!existing || existing.priority < priority) { + map.set(element, { + ...highlight, + priority, + selectors: (existing?.selectors || []).concat(selector), + selectable, + }); + } + } + } + } + return map; +}; + +export const mapBoxes = (elements: Map): Box[] => + Array.from(elements.entries()) + .map(([element, { selectors, styles, hoverStyles, focusStyles, selectable, menu }]) => { + const { top, left, width, height } = element.getBoundingClientRect(); + const { position } = getComputedStyle(element); + return { + element, + selectors, + selectable, + styles, + hoverStyles, + focusStyles, + menu, + top: position === 'fixed' ? top : top + window.scrollY, + left: position === 'fixed' ? left : left + window.scrollX, + width, + height, + }; + }) + .sort((a, b) => b.width * b.height - a.width * a.height); + +export const isOverMenu = (menuElement: HTMLElement, coordinates: { x: number; y: number }) => { + const menu = menuElement.getBoundingClientRect(); + const { x, y } = coordinates; + return ( + menu?.top && + menu?.left && + x >= menu.left && + x <= menu.left + menu.width && + y >= menu.top && + y <= menu.top + menu.height + ); +}; + +export const isTargeted = ( + box: Box, + boxElement: HTMLElement, + coordinates: { x: number; y: number } +) => { + if (!coordinates) { + return false; + } + let { left, top } = box; + if (boxElement.style.position === 'fixed') { + left += window.scrollX; + top += window.scrollY; + } + const { x, y } = coordinates; + return x >= left && x <= left + box.width && y >= top && y <= top + box.height; +}; + +export const keepInViewport = ( + element: HTMLElement, + targetCoordinates: { x: number; y: number }, + options: { margin?: number; topOffset?: number; centered?: boolean } = {} +) => { + const { x, y } = targetCoordinates; + const { margin = 5, topOffset = 0, centered = false } = options; + const { scrollX, scrollY, innerHeight: windowHeight, innerWidth: windowWidth } = window; + + const top = Math.min( + element.style.position === 'fixed' ? y - scrollY : y, + windowHeight - element.clientHeight - margin - topOffset + scrollY + ); + + const leftOffset = centered ? element.clientWidth / 2 : 0; + const left = + element.style.position === 'fixed' + ? Math.max(Math.min(x - scrollX, windowWidth - leftOffset - margin), leftOffset + margin) + : Math.max( + Math.min(x, windowWidth - leftOffset - margin + scrollX), + leftOffset + margin + scrollX + ); + + Object.assign(element.style, { + ...(left !== x && { left: `${left}px` }), + ...(top !== y && { top: `${top}px` }), + }); +}; + +export const showPopover = (element: HTMLElement) => { + if (window.HTMLElement.prototype.hasOwnProperty('showPopover')) { + element.showPopover(); + } +}; + +export const hidePopover = (element: HTMLElement) => { + if (window.HTMLElement.prototype.hasOwnProperty('showPopover')) { + element.hidePopover(); + } +}; diff --git a/code/e2e-tests/addon-a11y.spec.ts b/code/e2e-tests/addon-a11y.spec.ts index cdc9818dfe81..9a0bec021188 100644 --- a/code/e2e-tests/addon-a11y.spec.ts +++ b/code/e2e-tests/addon-a11y.spec.ts @@ -19,16 +19,17 @@ test.describe('addon-a11y', () => { const panel = sbPage.panelContent(); await panel.getByRole('button', { name: 'Show highlights' }).click(); - // check that the highlight is visible - const imageElement = sbPage.previewIframe().getByRole('img'); - expect(await imageElement.evaluate((el) => getComputedStyle(el).outline)).toBe( - 'rgba(255, 68, 0, 0.6) dashed 1px' + const highlightElement = sbPage + .previewIframe() + .locator('[data-highlight-dimensions="w350h150"]'); + + await expect(highlightElement).toBeVisible(); + expect(await highlightElement.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe( + 'color(srgb 1 0.266667 0 / 0.4)' ); await page.getByRole('button', { name: 'Hide highlights' }).click(); - - // check that the highlight is not visible - expect(await imageElement.evaluate((el) => getComputedStyle(el).outline)).toMatch(/0px/); + await expect(highlightElement).toBeHidden(); }); test('should rerun a11y checks when clicking the rerun button', async ({ page }) => { diff --git a/code/yarn.lock b/code/yarn.lock index f00545ae3a62..4a2912de6282 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -2071,7 +2071,16 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.15, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": +"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.22.15, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7": + version: 7.26.9 + resolution: "@babel/runtime@npm:7.26.9" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10c0/e8517131110a6ec3a7360881438b85060e49824e007f4a64b5dfa9192cf2bb5c01e84bfc109f02d822c7edb0db926928dd6b991e3ee460b483fb0fac43152d9b + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.13.10": version: 7.27.0 resolution: "@babel/runtime@npm:7.27.0" dependencies: