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');
+ }
+});