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 275719dc..ac03a7a7 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,5 +1,6 @@ import { expect, test } from 'vite-plus/test'; -import { page, userEvent } from 'vite-plus/test/context'; +import type { Locator } from 'vite-plus/test/context'; +import { cdp, page, userEvent } from 'vite-plus/test/context'; import { Icon } from '../icon/index.js'; import { LoadingSpinner } from '../loading-spinner/index.js'; import { ComboboxItem, ComboboxLoadMoreItem } from '../primitives/combobox/item.js'; @@ -207,6 +208,55 @@ test('option keyboard focus', async () => { await captureVisual(await openFocusedComboboxOption(), 'combobox-field/option-keyboard-focus'); }); +test('option pressed', async () => { + render( + + {renderCountryItem} + , + ); + await userEvent.click(page.getByRole('combobox', { name: 'Country' })); + const option = page.getByRole('option', { exact: true, name: 'Australia' }); + await expect.element(option).toBeVisible(); + + const release = await pressComboboxOption(option); + await captureVisual(page.elementLocator(document.body), 'combobox-field/option-pressed'); + await release(); +}); + +test('selected option pressed', async () => { + render( + + {renderCountryItem} + , + ); + // Selecting through the combobox itself (rather than a `defaultValue`) keeps DOM focus on the + // input the whole time. A fresh focus event on a combobox that already has a matching value only + // re-opens reliably right after page load; once another combobox has mounted and unmounted first, + // that reopen silently no-ops in this test environment. Staying selected-and-focused, then + // reopening with the keyboard, sidesteps that rather than depending on run order. + await withPopoverScrollStub(async () => { + const input = page.getByRole('combobox', { name: 'Country' }); + await userEvent.tab(); + await expect.element(input).toHaveFocus(); + await userEvent.keyboard('{ArrowDown}{ArrowDown}'); + const canadaFocused = page.getByRole('option', { exact: true, name: 'Canada' }); + await expect.poll(() => canadaFocused.element().getAttribute('data-focused')).toBe('true'); + await userEvent.keyboard('{Enter}'); + await expect.poll(() => input.element().getAttribute('aria-expanded')).toBe('false'); + + await userEvent.keyboard('{ArrowDown}'); + const option = page.getByRole('option', { exact: true, name: 'Canada' }); + await expect.element(option).toBeVisible(); + + const release = await pressComboboxOption(option); + await captureVisual( + page.elementLocator(document.body), + 'combobox-field/option-selected-pressed', + ); + await release(); + }); +}); + test('option with leading icon at both sizes', async () => { render( @@ -406,6 +456,43 @@ async function openFocusedComboboxOption() { }); } +/** + * Presses an option with a raw CDP mouse event instead of `userEvent.hover`/`click`. Playwright's + * locator-level hover and click actions perform their own pre-action scroll-into-view check, which + * closes this component's popover the same way keyboard navigation's `scrollIntoView` does (see + * `withPopoverScrollStub`), except this scroll happens natively and cannot be stubbed from script. + * Dispatching the mouse event directly at the option's coordinates lands `data-hovered` and + * `data-pressed` without that pre-action scroll. Returns a callback that releases the mouse button; + * call it after capturing so the option's real `onPress` selection behaviour still runs. + */ +async function pressComboboxOption(option: Locator): Promise<() => Promise> { + const rect = option.element().getBoundingClientRect(); + const frameElement = (window as unknown as { frameElement?: Element }).frameElement; + const frameRect = frameElement?.getBoundingClientRect(); + const x = (frameRect?.left ?? 0) + rect.left + rect.width / 2; + const y = (frameRect?.top ?? 0) + rect.top + rect.height / 2; + + await cdp().send('Input.dispatchMouseEvent', { button: 'none', type: 'mouseMoved', x, y }); + await cdp().send('Input.dispatchMouseEvent', { + button: 'left', + clickCount: 1, + type: 'mousePressed', + x, + y, + }); + await expect.poll(() => option.element().getAttribute('data-pressed')).toBe('true'); + + return async () => { + await cdp().send('Input.dispatchMouseEvent', { + button: 'left', + clickCount: 1, + type: 'mouseReleased', + x, + y, + }); + }; +} + /** * Waits for the tray to finish opening before a capture. The screenshot owns the resting geometry, * so nothing here measures a position. diff --git a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts index bffd915e..cce5faa8 100644 --- a/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts +++ b/packages/@luke-ui/react/src/primitives/combobox/styles.css.ts @@ -377,6 +377,9 @@ const comboboxConfig = { '&[data-hovered="true"]:not([data-disabled="true"]):not([data-selected="true"])': { backgroundColor: interactionColor('transparent', 'hover'), }, + '&[data-pressed="true"]:not([data-disabled="true"]):not([data-selected="true"])': { + backgroundColor: interactionColor('transparent', 'pressed'), + }, '&[data-focus-visible="true"]:not([data-disabled="true"])': { outlineColor: vars.color.border.focus, }, @@ -390,6 +393,9 @@ const comboboxConfig = { '&[data-hovered="true"][data-selected="true"]:not([data-disabled="true"])': { backgroundColor: interactionColor(vars.color.background.accent.subtle, 'hover'), }, + '&[data-pressed="true"][data-selected="true"]:not([data-disabled="true"])': { + backgroundColor: interactionColor(vars.color.background.accent.subtle, 'pressed'), + }, }, }, mobileInputGroup: {