From 41f0fb259e24329e4e438fa96df6ece37020700d Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Sat, 19 Sep 2026 13:29:07 +0900 Subject: [PATCH 01/41] test(webui): pin Tooltip behavior before adopting the shared one The gallery tooltip is the only call site. These tests drive that call site rather than the component API, so the same assertions run before and after the props change from label to content. They require the trigger to keep an accessible description equal to the tooltip text, no extra tab stop around an already-focusable control, visibility on hover and keyboard focus, and no body portal inside a native modal. The shared-component assertion comes last in each case, so on the current local Tooltip they fail only there. The Escape case also fails today on the dismissal itself: the local CSS-only tooltip never implemented WCAG 1.4.13 dismissal. Refs #1902 --- .../design-system/ui-common-tooltip.test.tsx | 50 +++++++++++++++++++ webui/tests/ui-common-components.spec.ts | 40 +++++++++++++++ webui/tests/ui-common-helpers.ts | 28 +++++++++++ 3 files changed, 118 insertions(+) create mode 100644 webui/src/design-system/ui-common-tooltip.test.tsx create mode 100644 webui/tests/ui-common-components.spec.ts create mode 100644 webui/tests/ui-common-helpers.ts diff --git a/webui/src/design-system/ui-common-tooltip.test.tsx b/webui/src/design-system/ui-common-tooltip.test.tsx new file mode 100644 index 000000000..45c884a48 --- /dev/null +++ b/webui/src/design-system/ui-common-tooltip.test.tsx @@ -0,0 +1,50 @@ +// Copyright 2025-2026 Lablup Inc. Licensed under the Apache License, Version 2.0. +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DesignGallery } from '../gallery'; +import { t } from '../i18n/catalog'; +import { NativeModalContext } from './modal-context'; + +// Drives the real gallery call site rather than the component API, so the same +// assertions hold before and after the Tooltip props change. +let host: HTMLDivElement; +let root: Root; +beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }); host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); +afterEach(() => { act(() => root.unmount()); host.remove(); vi.unstubAllGlobals(); Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); }); + +const tooltipText = t('en', 'gallery.tooltip'); +const triggerText = t('en', 'gallery.hover_focus'); +function trigger(): HTMLButtonElement { + const button = [...host.querySelectorAll('button')].find((node) => node.textContent === triggerText); + if (!button) throw new Error('Missing gallery tooltip trigger'); + return button; +} +function description(element: Element): string { + return (element.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean).map((id) => document.getElementById(id)?.textContent ?? '').join(' ').trim(); +} +function hoverAndFocus(element: HTMLElement): void { + act(() => { element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); element.focus(); element.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); }); +} + +describe('Tooltip adoption at the gallery call site', () => { + it('describes the focusable trigger itself and adds no tab stop around it', () => { + act(() => root.render()); + const button = trigger(); + expect(description(button)).toBe(tooltipText); + for (let node = button.parentElement; node && !node.classList.contains('control-row'); node = node.parentElement) expect(node.tabIndex, node.className).toBeLessThan(0); + // Delivered by the shared component: asserted last so a pre-swap run fails here. + expect(button.closest('.tooltip__wrapper')).not.toBeNull(); + }); + + it('keeps tooltip content inside a native modal instead of portalling it to body', () => { + act(() => root.render()); + const inside = trigger(); + hoverAndFocus(inside); + expect(document.querySelector('.tooltip__content')).toBeNull(); + expect(description(inside)).toBe(tooltipText); + expect(inside.closest('.tooltip__wrapper')).toBeNull(); + act(() => root.render()); + expect(trigger().closest('.tooltip__wrapper')).not.toBeNull(); + }); +}); diff --git a/webui/tests/ui-common-components.spec.ts b/webui/tests/ui-common-components.spec.ts new file mode 100644 index 000000000..96a923879 --- /dev/null +++ b/webui/tests/ui-common-components.spec.ts @@ -0,0 +1,40 @@ +// Copyright 2025-2026 Lablup Inc. Licensed under the Apache License, Version 2.0. +import { expect, test } from '@playwright/test'; +import { expectSafeLayout } from './browser-assertions'; +import { bootGallery, variants } from './browser-fixtures'; +import { shownTooltips, text } from './ui-common-helpers'; + +// Each case asserts the product behavior first and the shared ui-common DOM +// last, so a run against the pre-adoption code fails on the last assertion. +test.describe('shared Tooltip', () => { + test('keyboard and pointer users get the description without an extra tab stop', async ({ page }) => { + await bootGallery(page, variants[0]); + const tooltip = text('gallery.tooltip'); + const trigger = page.getByRole('button', { name: text('gallery.hover_focus'), exact: true }); + await expect.poll(() => shownTooltips(page)).toEqual([]); + await page.getByRole('combobox', { name: text('gallery.select.native') }).focus(); + await page.keyboard.press('Tab'); + await expect(trigger).toBeFocused(); + await expect(trigger).toHaveAccessibleDescription(tooltip); + await expect.poll(() => shownTooltips(page)).toEqual([tooltip]); + await expectSafeLayout(page); + await page.getByRole('button', { name: text('gallery.dialog.open'), exact: true }).focus(); + await expect.poll(() => shownTooltips(page)).toEqual([]); + await trigger.hover(); + await expect.poll(() => shownTooltips(page)).toEqual([tooltip]); + await page.mouse.move(0, 0); + await expect.poll(() => shownTooltips(page)).toEqual([]); + await expect(page.locator('.tooltip__wrapper').filter({ has: trigger })).toHaveCount(1); + }); + + test('Escape dismisses a keyboard-opened tooltip and keeps focus (WCAG 1.4.13)', async ({ page }) => { + await bootGallery(page, variants[0]); + const trigger = page.getByRole('button', { name: text('gallery.hover_focus'), exact: true }); + await trigger.focus(); + await expect.poll(() => shownTooltips(page)).toEqual([text('gallery.tooltip')]); + await page.keyboard.press('Escape'); + await expect.poll(() => shownTooltips(page)).toEqual([]); + await expect(trigger).toBeFocused(); + await expect(page.locator('.tooltip__wrapper').filter({ has: trigger })).toHaveCount(1); + }); +}); diff --git a/webui/tests/ui-common-helpers.ts b/webui/tests/ui-common-helpers.ts new file mode 100644 index 000000000..2b39dfd8b --- /dev/null +++ b/webui/tests/ui-common-helpers.ts @@ -0,0 +1,28 @@ +// Copyright 2025-2026 Lablup Inc. Licensed under the Apache License, Version 2.0. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { Page } from '@playwright/test'; + +type Entry = { key: string; en: string; ko: string }; +const catalog = (JSON.parse(readFileSync(fileURLToPath(new URL('../../tests/fixtures/webui/strings.json', import.meta.url)), 'utf8')) as { strings: Entry[] }).strings; + +// Read copy from the checked fixture so these specs follow catalog edits. +export function text(key: string, locale: 'en' | 'ko' = 'en'): string { + const entry = catalog.find((item) => item.key === key); + if (!entry) throw new Error(`Missing catalog string ${key}`); + return entry[locale]; +} + +// A tooltip counts as shown only when a sighted user can perceive it: rendered, +// not visibility-hidden and not faded out. Playwright's toBeVisible ignores opacity. +export async function shownTooltips(page: Page): Promise { + return page.evaluate(() => Array.from(document.querySelectorAll('[role="tooltip"]')).filter((element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity) > 0.5 && rect.width > 0 && rect.height > 0; + }).map((element) => element.textContent?.trim() ?? '')); +} + +export async function horizontalOverflow(page: Page): Promise { + return page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); +} From c1857ec5b5cc5181e5dfad8a7fd84b5ac16ab875 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Sat, 19 Sep 2026 13:31:40 +0900 Subject: [PATCH 02/41] refactor(webui): adopt the shared Tooltip behind an adapter The local Tooltip in primitives.tsx duplicated the package's Tooltip. The adapter in the new common-overlays.tsx seam takes content instead of label and keeps three product guarantees the package does not give on its own: - The package sets aria-describedby on its wrapper div and only while open, so the focused control would lose its description. The adapter clones the child with aria-describedby pointing at an always-present hidden node holding the same text. - The wrapper defaults to tabIndex 0; the adapter passes -1 because the gallery wraps an already-focusable Button. - Inside NativeModalContext it keeps the in-place markup, because the package portals content into body, outside a native showModal() top layer (the common-select.tsx precedent). Escape now dismisses a keyboard-opened tooltip (WCAG 1.4.13), which the CSS-only tooltip never did. expectSafeLayout accepts the tooltip's measured top/left or its pre-measure visibility:hidden and nothing else. The pinned tests pass; each mitigation was reverted once and its test failed on the behavior assertion. Refs #1902 --- webui/src/design-system/common-components.css | 1 + webui/src/design-system/common-overlays.tsx | 21 +++++++++++++++++++ webui/src/design-system/primitives.tsx | 11 +--------- webui/src/gallery.tsx | 2 +- webui/tests/browser-assertions.ts | 2 ++ 5 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 webui/src/design-system/common-overlays.tsx diff --git a/webui/src/design-system/common-components.css b/webui/src/design-system/common-components.css index 6dffce204..46d852d7e 100644 --- a/webui/src/design-system/common-components.css +++ b/webui/src/design-system/common-components.css @@ -37,6 +37,7 @@ .ds-common-select .select--labelled { gap: var(--space-2); } .ds-common-select .select__label { color: var(--color-text); font-size: 0.9rem; font-weight: 650; } .select__dropdown--portal { max-width: calc(100vw - 16px); } +.tooltip__wrapper.ds-tooltip-trigger { min-width: 0; max-width: 100%; } .ds-common-table { color: var(--color-text); background: var(--material-content-bg); border-radius: var(--radius-lg); } @media (max-width: 560px), (pointer: coarse) { .app-toolbar .button.ds-icon-button.button--inline { min-width: 44px; min-height: 44px; } } :root[data-high-contrast="on"] .button.ds-button, :root[data-high-contrast="on"] .button.ds-icon-button { border-width: 2px; } diff --git a/webui/src/design-system/common-overlays.tsx b/webui/src/design-system/common-overlays.tsx new file mode 100644 index 000000000..659dc6013 --- /dev/null +++ b/webui/src/design-system/common-overlays.tsx @@ -0,0 +1,21 @@ +// Copyright 2025-2026 Lablup Inc. Licensed under the Apache License, Version 2.0. +import React, { useContext, useId } from 'react'; +import { Tooltip as CommonTooltip } from '@lablup/ui-common/components/Tooltip'; +import { NativeModalContext } from './modal-context'; + +type DescribedElement = React.ReactElement<{ 'aria-describedby'?: string }>; + +export function Tooltip(props: { content: string; children: DescribedElement }): React.JSX.Element { + const nativeModal = useContext(NativeModalContext); + const descriptionId = useId(); + // alpha.19 sets aria-describedby on its wrapper div, and only while open, so the + // focused control itself would lose its description. Describe the control from + // an always-present node instead. + const describedBy = [props.children.props['aria-describedby'], descriptionId].filter(Boolean).join(' '); + const trigger = React.cloneElement(props.children, { 'aria-describedby': describedBy }); + // alpha.19 portals the content into body, outside a native showModal() top layer + // (the common-select.tsx precedent), so modal descendants keep in-place markup. + if (nativeModal) return {trigger}{props.content}; + // tabIndex -1: the wrapped control is already a tab stop; the wrapper must not add one. + return {trigger}; +} diff --git a/webui/src/design-system/primitives.tsx b/webui/src/design-system/primitives.tsx index 6845837f2..4f77766de 100644 --- a/webui/src/design-system/primitives.tsx +++ b/webui/src/design-system/primitives.tsx @@ -6,6 +6,7 @@ import { NativeModalContext } from './modal-context'; export { Button, IconButton, StatusBadge, ProgressBar, EmptyState, Tabs, DataTable } from './common-adapters'; export type { ButtonProps, ButtonTone, LifecycleState, DataTableColumn, DataTablePersistedState, SortDirection } from './common-adapters'; export { Select } from './common-select'; +export { Tooltip } from './common-overlays'; export function Field(props: { label: string; value: string; onChange?: (value: string) => void; placeholder?: string; disabled?: boolean; busy?: boolean; error?: string; hint?: string; testId?: string }): React.JSX.Element { const id = useId(); @@ -129,16 +130,6 @@ export function Sheet(props: Omit): React.JSX.Element { return ; } -export function Tooltip(props: { label: string; children: React.ReactElement }): React.JSX.Element { - const id = useId(); - return ( - - {React.cloneElement(props.children, { 'aria-describedby': id } as Partial)} - {props.label} - - ); -} - export function Inspector(props: { title: string; children: React.ReactNode }): React.JSX.Element { return (