From 5d8006961a47bd19236c14261552036047cc6db5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 27 Oct 2026 04:38:21 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=8D=20fix:=20Unclip=20and=20Recolor=20?= =?UTF-8?q?the=20Keyboard=20Highlight=20Ring=20in=20Select=20Popovers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard-highlighted option in a click-ui Select popover showed its ring on three sides only. The popover is as wide as the trigger and clips its content (overflow: hidden) inside 1px borders, but the option list is also as wide as the trigger, so options overhang the clip box by 2px on the right. click-ui draws a 2px ring at a -2px offset, in the option's outer 2px, so the right side fell entirely in the overhang. The ring also used click-ui's accent, near-black in the light theme, instead of the app's focus color. A scoped rule in styles.css, next to the other focus-ring overrides, draws the ring of a keyboard-highlighted option in --focus-ring-color, 4px inside the option. data-input-modality is only set on click-ui menu panels, so the rule reaches click-ui Select, Dropdown and ContextMenu options only, and only while the keyboard drives them. Dropdown menus (the sidebar user menu, the row action menus) already showed a whole ring; they now draw the same ring as the Selects. e2e/a11y/menus.spec.ts (live backend) measures the highlighted option's ring in two Selects and the user menu, in light and dark mode: solid, in the focus color, and inside every clipping ancestor up to the menu. Supersedes #125 by @dustinhealy, whose root-cause analysis and inset ring this reuses. --- e2e/a11y/menus.spec.ts | 109 +++++++++++++++++++++++++++++++++++++++++ src/styles.css | 10 ++++ 2 files changed, 119 insertions(+) create mode 100644 e2e/a11y/menus.spec.ts diff --git a/e2e/a11y/menus.spec.ts b/e2e/a11y/menus.spec.ts new file mode 100644 index 00000000..1e7b6403 --- /dev/null +++ b/e2e/a11y/menus.spec.ts @@ -0,0 +1,109 @@ +import { test, expect } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import { LIVE, LIVE_SKIP_REASON, openConfigTab } from '../live'; + +/** + * click-ui rings the keyboard-highlighted option of its Select and Dropdown + * menus. The ring is drawn in the app's focus color, and none of its sides is + * cut off by the menu: a Select's option list is as wide as its trigger, 2px + * wider than the popover's clip box. Needs a live backend (see ../live.ts). + */ +test.skip(!LIVE, LIVE_SKIP_REASON); + +const THEMES = ['light', 'dark'] as const; + +const SELECTS = [ + { + name: 'the audit log Target type filter', + open: async (page: Page) => { + await page.goto('/grants?tab=audit-log'); + await page.waitForLoadState('networkidle'); + return page.getByRole('button', { name: 'Target type' }); + }, + }, + { + name: 'the configuration Jump to list', + open: async (page: Page) => { + await page.setViewportSize({ width: 1024, height: 800 }); + await openConfigTab(page, 'system'); + return page.getByRole('button', { name: 'Jump to…' }); + }, + }, +] as const; + +/** The option's ring style and color, the app's focus color, and the ring sides that the option's clipping ancestors cut off. */ +function readRing(item: Element) { + const style = getComputedStyle(item); + const reach = parseFloat(style.outlineOffset) + parseFloat(style.outlineWidth); + const box = item.getBoundingClientRect(); + const ring = { + left: box.left - reach, + top: box.top - reach, + right: box.right + reach, + bottom: box.bottom + reach, + }; + const clip = { left: -Infinity, top: -Infinity, right: Infinity, bottom: Infinity }; + const menu = item.closest('[data-input-modality]'); + for (let node = item.parentElement; node; node = node.parentElement) { + const nodeStyle = getComputedStyle(node); + if (nodeStyle.overflowX !== 'visible' || nodeStyle.overflowY !== 'visible') { + const nodeBox = node.getBoundingClientRect(); + const left = nodeBox.left + node.clientLeft; + const top = nodeBox.top + node.clientTop; + clip.left = Math.max(clip.left, left); + clip.top = Math.max(clip.top, top); + clip.right = Math.min(clip.right, left + node.clientWidth); + clip.bottom = Math.min(clip.bottom, top + node.clientHeight); + } + if (node === menu) break; + } + const probe = document.createElement('span'); + probe.style.color = 'var(--focus-ring-color)'; + document.body.append(probe); + const focusColor = getComputedStyle(probe).color; + probe.remove(); + const clipped = (['left', 'top', 'right', 'bottom'] as const).filter((side) => + side === 'left' || side === 'top' ? ring[side] < clip[side] : ring[side] > clip[side], + ); + return { style: style.outlineStyle, color: style.outlineColor, focusColor, clipped }; +} + +/** Moves the keyboard highlight to the menu's second option and checks that option's ring. */ +async function expectWholeRingOnSecond(page: Page, options: Locator) { + const second = options.nth(1); + await page.keyboard.press('ArrowDown'); + await expect(second).toHaveAttribute('data-highlighted'); + const { focusColor, ...ring } = await second.evaluate(readRing); + expect(ring).toEqual({ style: 'solid', color: focusColor, clipped: [] }); +} + +for (const theme of THEMES) { + test.describe(`keyboard highlight ring in menus: ${theme}`, () => { + test.use({ colorScheme: theme, viewport: { width: 1280, height: 800 } }); + + for (const select of SELECTS) { + test(`${select.name}: the highlighted option has its whole ring in the focus color`, async ({ + page, + }) => { + const trigger = await select.open(page); + await trigger.focus(); + await page.keyboard.press('Enter'); + const options = page.locator('[data-input-modality] [cui-select-item]'); + await expect(options.first()).toHaveAttribute('data-highlighted'); + await expectWholeRingOnSecond(page, options); + }); + } + + test('the user menu: the highlighted item has its whole ring in the focus color', async ({ + page, + }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: /^User menu/ }).focus(); + await page.keyboard.press('Enter'); + const items = page.getByRole('menu').getByRole('menuitem'); + await expect(items.first()).toBeFocused(); + await expectWholeRingOnSecond(page, items); + }); + }); +} diff --git a/src/styles.css b/src/styles.css index ebec2c75..8f4f38e4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -430,6 +430,16 @@ select:focus-visible { outline-offset: 1px !important; } +/* click-ui rings the keyboard-highlighted option of a Select or Dropdown in + its accent (near-black in the light theme), 2px inside the option. A + Select's option list is as wide as its trigger, 2px wider than the + popover's clip box, so that ring loses its right edge. Draw it in the app + focus color, 4px inside. */ +[data-input-modality='keyboard'] [data-highlighted] { + outline-color: var(--focus-ring-color) !important; + outline-offset: -4px !important; +} + .auth-card { box-sizing: border-box; }