diff --git a/packages/@luke-ui/react/src/box/box.visual.test.tsx b/packages/@luke-ui/react/src/box/box.visual.test.tsx new file mode 100644 index 00000000..2a454245 --- /dev/null +++ b/packages/@luke-ui/react/src/box/box.visual.test.tsx @@ -0,0 +1,40 @@ +import { expect, test } from 'vite-plus/test'; +import { page } from 'vite-plus/test/context'; +import { + captureVisualAppearance, + renderVisual, + visualAppearances, +} from '../test-utils/render-visual.js'; +import { vars } from '../theme/index.js'; +import { Box } from './index.js'; + +test.each(visualAppearances)('layout: $theme $mode', async (appearance) => { + const scene = renderVisual( + + Account + + Profile + Security + + , + appearance, + ); + const layout = page.getByText('Account').element().parentElement; + if (layout == null) throw new Error('Expected the Box layout container.'); + + await expect + .element(page.elementLocator(layout)) + .toHaveStyle({ display: 'flex', flexDirection: 'column' }); + await captureVisualAppearance(scene, 'box/layout', appearance); +}); diff --git a/packages/@luke-ui/react/src/button/button.visual.test.tsx b/packages/@luke-ui/react/src/button/button.visual.test.tsx index 3a70c8be..216b8f9c 100644 --- a/packages/@luke-ui/react/src/button/button.visual.test.tsx +++ b/packages/@luke-ui/react/src/button/button.visual.test.tsx @@ -4,6 +4,7 @@ import { Icon } from '../icon/index.js'; import { captureVisual, captureVisualAppearance, + emulateForcedColors, focusViaKeyboard, Grid, renderVisual, @@ -96,3 +97,35 @@ test.each(visualAppearances)('interactive states: $theme $mode', async (appearan await captureVisualAppearance(scene, 'button/pressed', appearance); await userEvent.keyboard('{/Space}'); }); + +test('forced-colors states', async () => { + await emulateForcedColors('active'); + + try { + const scene = renderVisual( + + + + + , + ); + const action = page.getByRole('button', { name: 'Action' }); + const disabled = page.getByRole('button', { name: 'Disabled' }); + const pending = page.getByRole('button', { name: 'Pending' }); + + await expect.element(disabled).toHaveStyle({ opacity: '1' }); + await expect.element(pending).toHaveStyle({ opacity: '1' }); + await captureVisual(scene, 'button/forced-colors-resting'); + await userEvent.hover(action); + await captureVisual(scene, 'button/forced-colors-hover'); + await userEvent.unhover(action); + await focusViaKeyboard(action); + await captureVisual(scene, 'button/forced-colors-focus-visible'); + await userEvent.keyboard('{Space>}'); + await expect.element(action).toHaveAttribute('data-pressed', 'true'); + await captureVisual(scene, 'button/forced-colors-pressed'); + await userEvent.keyboard('{/Space}'); + } finally { + await emulateForcedColors('none'); + } +}); diff --git a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx index 48fd3d52..439ce911 100644 --- a/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx +++ b/packages/@luke-ui/react/src/combobox-field/combobox-field.visual.test.tsx @@ -1,8 +1,11 @@ import { expect, test } from 'vite-plus/test'; +import type { Locator } from 'vite-plus/test/context'; import { page, userEvent } from 'vite-plus/test/context'; import { LoadingSpinner } from '../loading-spinner/index.js'; import { + captureVisual, captureVisualAppearance, + emulateForcedColors, focusViaKeyboard, renderVisual, Stack, @@ -118,13 +121,117 @@ test.each(visualAppearances)('interactive states: $theme $mode', async (appearan await captureVisualAppearance(scene, 'combobox-field/trigger-hover', appearance); await userEvent.unhover(trigger); await focusViaKeyboard(input); - await captureVisualAppearance(scene, 'combobox-field/focus-visible', appearance); + await captureVisualAppearance( + refreshVisualScene(scene), + 'combobox-field/focus-visible', + appearance, + ); await userEvent.tab(); await expect.element(clear).toHaveFocus(); - await captureVisualAppearance(scene, 'combobox-field/clear-focus-visible', appearance); - trigger.element().dataset.pressed = 'true'; - await captureVisualAppearance(scene, 'combobox-field/trigger-pressed', appearance); - delete trigger.element().dataset.pressed; + await captureVisualAppearance( + refreshVisualScene(scene), + 'combobox-field/clear-focus-visible', + appearance, + ); + await userEvent.keyboard('{Space>}'); + await expect.element(clear).toHaveAttribute('data-pressed', 'true'); + await captureVisualAppearance( + refreshVisualScene(scene), + 'combobox-field/clear-pressed', + appearance, + ); + await userEvent.keyboard('{/Space}'); +}); + +test('forced-colors states', async () => { + await emulateForcedColors('active'); + + try { + const scene = renderVisual( + + + {renderCountryItem} + + + {renderCountryItem} + + + {renderCountryItem} + + + {renderCountryItem} + + , + ); + const input = page.getByRole('combobox', { name: 'Interactive' }); + const control = input.element().closest('[role="group"]'); + if (control == null) throw new Error('Expected the interactive combobox control group.'); + const controlLocator = page.elementLocator(control); + const clear = controlLocator.getByRole('button', { name: 'Clear selection' }); + const trigger = controlLocator.getByRole('button', { name: 'Toggle options' }); + + await expect.element(page.getByRole('combobox', { name: 'Disabled' })).toBeDisabled(); + await expect + .element(page.getByRole('combobox', { name: 'Read-only' })) + .toHaveAttribute('readonly'); + await expect + .element(page.getByRole('combobox', { name: 'Invalid' })) + .toHaveAttribute('aria-invalid', 'true'); + await captureVisual(scene, 'combobox-field/forced-colors-resting-states'); + await userEvent.hover(trigger); + await expect.element(trigger).toHaveAttribute('data-hovered', 'true'); + await captureVisual(refreshVisualScene(scene), 'combobox-field/forced-colors-trigger-hover'); + await userEvent.unhover(trigger); + await focusViaKeyboard(input); + await captureVisual(refreshVisualScene(scene), 'combobox-field/forced-colors-focus-visible'); + await userEvent.keyboard('{ArrowDown}{Home}'); + const focusedOption = page.getByRole('option', { name: 'Australia' }); + await expect.element(focusedOption).toHaveAttribute('data-focused', 'true'); + await expect + .element(page.getByRole('option', { name: 'Canada' })) + .toHaveAttribute('aria-selected', 'true'); + await expect + .element(page.getByRole('option', { name: 'Sweden' })) + .toHaveAttribute('aria-disabled', 'true'); + await captureVisual( + page.elementLocator(document.body), + 'combobox-field/forced-colors-open-options', + ); + await userEvent.keyboard('{Escape}'); + await expect.element(input).toHaveAttribute('aria-expanded', 'false'); + await userEvent.tab(); + await expect.element(clear).toHaveFocus(); + await userEvent.keyboard('{Space>}'); + await expect.element(clear).toHaveAttribute('data-pressed', 'true'); + await captureVisual(refreshVisualScene(scene), 'combobox-field/forced-colors-clear-pressed'); + await userEvent.keyboard('{/Space}'); + } finally { + await emulateForcedColors('none'); + } }); test.each(visualAppearances)('open option interactions: $theme $mode', async (appearance) => { @@ -348,3 +455,7 @@ test.each(visualAppearances)('sizes: $theme $mode', async (appearance) => { await captureVisualAppearance(locator, 'combobox-field/sizes', appearance); }); + +function refreshVisualScene(scene: Locator) { + return page.elementLocator(scene.element()); +} diff --git a/packages/@luke-ui/react/src/icon-button/icon-button.visual.test.tsx b/packages/@luke-ui/react/src/icon-button/icon-button.visual.test.tsx index 2ffb93e5..fdb4ff89 100644 --- a/packages/@luke-ui/react/src/icon-button/icon-button.visual.test.tsx +++ b/packages/@luke-ui/react/src/icon-button/icon-button.visual.test.tsx @@ -3,6 +3,7 @@ import { page, userEvent } from 'vite-plus/test/context'; import { captureVisual, captureVisualAppearance, + emulateForcedColors, focusViaKeyboard, Grid, renderVisual, @@ -67,3 +68,35 @@ test('keyboard focus ring', async () => { await focusViaKeyboard(page.getByRole('button', { name: 'Focus add' })); await captureVisual(scene, 'icon-button/focus-visible'); }); + +test('forced-colors states', async () => { + await emulateForcedColors('active'); + + try { + const scene = renderVisual( + + + + + , + ); + const action = page.getByRole('button', { name: 'Action' }); + const disabled = page.getByRole('button', { name: 'Disabled' }); + const pending = page.getByRole('button', { name: 'Pending' }); + + await expect.element(disabled).toHaveStyle({ opacity: '1' }); + await expect.element(pending).toHaveStyle({ opacity: '1' }); + await captureVisual(scene, 'icon-button/forced-colors-resting'); + await userEvent.hover(action); + await captureVisual(scene, 'icon-button/forced-colors-hover'); + await userEvent.unhover(action); + await focusViaKeyboard(action); + await captureVisual(scene, 'icon-button/forced-colors-focus-visible'); + await userEvent.keyboard('{Space>}'); + await expect.element(action).toHaveAttribute('data-pressed', 'true'); + await captureVisual(scene, 'icon-button/forced-colors-pressed'); + await userEvent.keyboard('{/Space}'); + } finally { + await emulateForcedColors('none'); + } +}); diff --git a/packages/@luke-ui/react/src/link/link.visual.test.tsx b/packages/@luke-ui/react/src/link/link.visual.test.tsx index df138e7a..48e401d0 100644 --- a/packages/@luke-ui/react/src/link/link.visual.test.tsx +++ b/packages/@luke-ui/react/src/link/link.visual.test.tsx @@ -1,8 +1,9 @@ import { expect, test } from 'vite-plus/test'; -import { cdp, page, userEvent } from 'vite-plus/test/context'; +import { page, userEvent } from 'vite-plus/test/context'; import { captureVisual, captureVisualAppearance, + emulateForcedColors, focusViaKeyboard, Grid, renderVisual, @@ -132,9 +133,3 @@ test('forced-colors navigation states', async () => { await emulateForcedColors('none'); } }); - -async function emulateForcedColors(value: 'active' | 'none') { - await cdp().send('Emulation.setEmulatedMedia', { - features: [{ name: 'forced-colors', value }], - }); -} diff --git a/packages/@luke-ui/react/src/recipes/button.browser.test.ts b/packages/@luke-ui/react/src/recipes/button.browser.test.ts index 18c6f7d1..775e2d30 100644 --- a/packages/@luke-ui/react/src/recipes/button.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/button.browser.test.ts @@ -1,12 +1,14 @@ import '@luke-ui/react/themes/machined-edge.css'; import { afterEach, expect, test } from 'vite-plus/test'; +import { cdp } from 'vite-plus/test/context'; import { themeRootClassName } from '../theme/index.js'; import { machinedEdgeThemeClassName } from '../themes/index.js'; import { button } from './button.css.js'; let mounted: Array = []; -afterEach(() => { +afterEach(async () => { + await setEmulatedMedia(); for (const element of mounted) element.remove(); mounted = []; }); @@ -97,6 +99,18 @@ test('focus uses the independent semantic ring', () => { expect(style.boxShadow).not.toBe('none'); }); +test('reduced motion removes hover and press travel', async () => { + await setEmulatedMedia('prefers-reduced-motion', 'reduce'); + const control = mountButton(); + + control.dataset.hovered = 'true'; + expect(getComputedStyle(control).transform).toBe('none'); + + delete control.dataset.hovered; + control.dataset.pressed = 'true'; + expect(getComputedStyle(control).transform).toBe('none'); +}); + function mountButton(options: Parameters[0] = {}) { const root = document.body.appendChild(document.createElement('div')); root.className = `${themeRootClassName} ${machinedEdgeThemeClassName}`; @@ -107,3 +121,9 @@ function mountButton(options: Parameters[0] = {}) { mounted.push(root); return control; } + +async function setEmulatedMedia(name?: string, value?: string) { + await cdp().send('Emulation.setEmulatedMedia', { + features: name === undefined || value === undefined ? [] : [{ name, value }], + }); +} diff --git a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts index 5ce0d10e..f68a7bd3 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.browser.test.ts @@ -183,6 +183,17 @@ test('forced colors remove authored depth and preserve system state colors', asy const action = root.appendChild(document.createElement('button')); action.className = comboboxTrigger({ size: 'medium' }); expect(getComputedStyle(action).boxShadow).toBe('none'); + action.dataset.hovered = 'true'; + const hoveredActionStyle = getComputedStyle(action); + expect(hoveredActionStyle.boxShadow).toBe('none'); + expect(hoveredActionStyle.outlineColor).toBe(resolveSystemColor(root, 'Highlight')); + expect(hoveredActionStyle.transform).toBe('none'); + delete action.dataset.hovered; + action.dataset.pressed = 'true'; + const pressedActionStyle = getComputedStyle(action); + expect(pressedActionStyle.boxShadow).toBe('none'); + expect(pressedActionStyle.outlineColor).toBe(resolveSystemColor(root, 'Highlight')); + expect(pressedActionStyle.transform).toBe('none'); const popover = root.appendChild(document.createElement('div')); popover.className = comboboxPopover(); diff --git a/packages/@luke-ui/react/src/recipes/combobox.css.ts b/packages/@luke-ui/react/src/recipes/combobox.css.ts index 181c71e8..342b3710 100644 --- a/packages/@luke-ui/react/src/recipes/combobox.css.ts +++ b/packages/@luke-ui/react/src/recipes/combobox.css.ts @@ -160,6 +160,20 @@ const comboboxActionStyles = { selectors: { '&[data-disabled="true"]': { color: 'GrayText', opacity: 1 }, '&[data-focus-visible="true"]': { outlineColor: 'Highlight' }, + '&[data-hovered="true"]:not([data-disabled="true"]):not([aria-disabled="true"])': { + backgroundColor: 'Highlight', + boxShadow: 'none', + color: 'HighlightText', + outlineColor: 'Highlight', + transform: 'none', + }, + '&[data-pressed="true"]:not([data-disabled="true"]):not([aria-disabled="true"])': { + backgroundColor: 'Highlight', + boxShadow: 'none', + color: 'HighlightText', + outlineColor: 'Highlight', + transform: 'none', + }, }, }, '(prefers-reduced-motion: reduce)': { transform: 'none', transition: 'none' }, diff --git a/packages/@luke-ui/react/src/test-utils/render-visual.tsx b/packages/@luke-ui/react/src/test-utils/render-visual.tsx index 4ba68300..6f60fcdd 100644 --- a/packages/@luke-ui/react/src/test-utils/render-visual.tsx +++ b/packages/@luke-ui/react/src/test-utils/render-visual.tsx @@ -10,7 +10,7 @@ import type { Root } from 'react-dom/client'; import { createRoot } from 'react-dom/client'; import { expect } from 'vite-plus/test'; import type { Locator } from 'vite-plus/test/context'; -import { page, userEvent } from 'vite-plus/test/context'; +import { cdp, page, userEvent } from 'vite-plus/test/context'; // The generated spritesheet is emitted to `dist/` by the `generate` task, which // both `build` and `test` depend on, so it is always present when tests run. import spritesheetHref from '../../dist/spritesheet.svg?url'; @@ -79,6 +79,13 @@ export async function captureVisualAppearance( await captureVisual(locator, `${id}-${appearance.theme}-${appearance.mode}`); } +/** Emulates Chromium forced colours for a visual scene. */ +export async function emulateForcedColors(value: 'active' | 'none') { + await cdp().send('Emulation.setEmulatedMedia', { + features: [{ name: 'forced-colors', value }], + }); +} + /** Unmounts everything rendered by `renderVisual`. Registered globally in `visual-setup.ts`. */ export function cleanupVisual() { for (const { container, root } of mounted) { diff --git a/packages/@luke-ui/react/src/text-field/text-field.visual.test.tsx b/packages/@luke-ui/react/src/text-field/text-field.visual.test.tsx index 5ea98d20..d3505ea6 100644 --- a/packages/@luke-ui/react/src/text-field/text-field.visual.test.tsx +++ b/packages/@luke-ui/react/src/text-field/text-field.visual.test.tsx @@ -3,6 +3,7 @@ import { page, userEvent } from 'vite-plus/test/context'; import { captureVisual, captureVisualAppearance, + emulateForcedColors, focusViaKeyboard, renderVisual, Stack, @@ -129,3 +130,41 @@ test.each(visualAppearances)('invalid interactive states: $theme $mode', async ( await focusViaKeyboard(input); await captureVisualAppearance(scene, 'text-field/invalid-focus', appearance); }); + +test('forced-colors states', async () => { + await emulateForcedColors('active'); + + try { + const scene = renderVisual( + + + + + + , + ); + const input = page.getByRole('textbox', { name: 'Interactive' }); + + await expect.element(page.getByRole('textbox', { name: 'Disabled' })).toBeDisabled(); + await expect + .element(page.getByRole('textbox', { name: 'Read-only' })) + .toHaveAttribute('readonly'); + await expect + .element(page.getByRole('textbox', { name: 'Invalid' })) + .toHaveAttribute('aria-invalid', 'true'); + await captureVisual(scene, 'text-field/forced-colors-resting-states'); + await userEvent.hover(input); + await captureVisual(scene, 'text-field/forced-colors-hover'); + await userEvent.unhover(input); + await focusViaKeyboard(input); + await captureVisual(scene, 'text-field/forced-colors-focus-visible'); + } finally { + await emulateForcedColors('none'); + } +});