diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 692d1ede7..99f2cdd3e 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -26,6 +26,9 @@ WASM rebuild is needed for a changelog-only edit. ### 2026-08-26 - [`00e0617a`](https://github.com/quarto-dev/q2/commits/00e0617a): Comment bubbles now show the comment's full text — quoted words, emphasis, and inline code inside a comment were previously dropped from the bubble. +- [`c740728a`](https://github.com/quarto-dev/q2/commits/c740728a): All project-list dialogs (new project, rename, duplicate, connect/import, and friends) now share the accessible dialog chrome: Escape closes, Tab stays inside the dialog, and focus returns to the button that opened it. +- [`73d9cbe5`](https://github.com/quarto-dev/q2/commits/73d9cbe5): Tooltips are now styled and keyboard-accessible throughout — hover shows them after a short delay, keyboard focus shows them immediately, and Escape dismisses. +- [`7e5de6fe`](https://github.com/quarto-dev/q2/commits/7e5de6fe): File rows in the sidebar gain a visible ⋯ actions button (on hover or keyboard focus), and all menus are now fully keyboard-operable: arrow keys, Home/End, type-ahead, submenus, and Escape. ### 2026-08-25 diff --git a/hub-client/design-system.md b/hub-client/design-system.md new file mode 100644 index 000000000..891c6a0ff --- /dev/null +++ b/hub-client/design-system.md @@ -0,0 +1,56 @@ +# Hub-client design system + +The hub-client UI is built from a small set of tokens and primitives. +This page is the contract: follow it when adding or touching UI, and the +app stays coherent. The enforcement counterparts are `npm run lint:css` +(off-token values) and the `#/dev/` gallery (visual + keyboard coverage). + +## Tokens (`src/theme.css`) + +Three layers — new values land on the right one: + +1. **Primitive ramps** — raw palette/scale values (`--posit-teal`, + `--posit-blue-dark-2`). Never referenced directly by components. +2. **Semantic aliases** — meaning-named tokens (`--text-primary`, + `--border-color`, `--editor-accent-bg`), defined per theme. +3. **Scale tokens** — the shared scales: spacing (`--space-1`…`--space-8`, + 4px base), radii (`--radius-sm/md/lg`), elevation (`--shadow-1/2/3`), + z-layers (`--z-sticky`…`--z-max`), type (`--text-xs`…`--text-xl`, + `--font-weight-*`, `--leading-*`, `--font-mono`), motion + (`--duration-fast/base`, `--ease-out/standard`), and `--focus-ring`. + +Rules: no hex/rgb colors outside `theme.css`; no bare z-index integers; +no `outline: none` without a `:focus-visible` counterpart; logical +properties (`margin-inline-start`, `inset-inline-end`) over physical ones. +All enforced by `lint:css`. + +## Primitives + +| Primitive | Where | Notes | +|---|---|---| +| Buttons | `ui.css` `.qh-btn` (+ `.primary` `.outline` `.danger` `.ghost-accent`, `.small`), `.qh-icon-btn` (+ `.boxed`), `.qh-link` | Two sizes; disabled + focus-visible live on the base classes. Not buttons: `.view-toggle-btn` (segmented control), `.qh-pager` (nav strip), `.preview-btn` (header primary pill). | +| Menu | `components/Menu.tsx` | The only action menu. APG menu-button pattern: arrows/Home/End, type-ahead, submenus, Escape + focus return. Destructive items use `danger` and must be confirm-guarded or undoable. Popovers containing forms (avatar menu) are not menus — they use `.qh-menu` styling only. | +| Tooltip | `components/Tooltip.tsx` | The only tooltip. Never use `title=`. Non-interactive content only. | +| Notifications | `components/notifications.css` | Three tiers — transient (auto-dismiss), dismissible persistent, session banner. Pick by how long the information must live; see the file header. | +| Dialogs | `components/ModalDialog.tsx` | Every dialog routes through it (focus trap, restoration, Escape). Structure content with `.dialog-content` / `.dialog-actions`; form dialogs add `.qh-form-dialog`. | +| Form controls | `ui.css` `.qh-input`, `.qh-field-label`, `.qh-tabs` | Validation: `aria-invalid` + error text via `aria-describedby` (field-level) or `.qh-error.inline` (form-level). | +| Icons | `components/icons.tsx` | The only icon source. Decorative (`aria-hidden`), 24×24 stroke style, `currentColor`. | +| Utilities | `ui.css` `.qh-truncate`, `.qh-row-hover`, `.qh-active-accent-row` | Single-purpose shared classes — adopt, don't fork. | + +## The gallery (`#/dev/gallery`) + +Every primitive renders on the gallery page in its meaningful states, in +both themes, covered by Playwright visual baselines and axe-core scans +(`npm run test:visual`). The tokens gallery is `#/dev/tokens`. + +## Adding a component + +1. Style with tokens only — if a value has no token, add one at the right + layer in `theme.css` (or use the nearest scale step). +2. Reuse the primitives above. If none fits, that's a design decision — + build it accessibly (keyboard path, ARIA pattern from the APG) and add + it here. +3. Add the component to the gallery in its default/hover/focus/disabled/ + error states; the visual and axe baselines pick it up from there. +4. Every pointer affordance needs a keyboard path; every icon-only control + needs an `aria-label`; status is never conveyed by color alone. diff --git a/hub-client/e2e/baseline-a11y.visual.spec.ts b/hub-client/e2e/baseline-a11y.visual.spec.ts index c23606bcd..738b19470 100644 --- a/hub-client/e2e/baseline-a11y.visual.spec.ts +++ b/hub-client/e2e/baseline-a11y.visual.spec.ts @@ -48,6 +48,7 @@ const SCAN_PAGES: { page: string; label: string; selector: string }[] = [ { page: 'setup-migration', label: 'setup-migration', selector: '.setup-modal' }, { page: 'setup-fresh', label: 'setup-fresh', selector: '.setup-modal' }, { page: 'tokens', label: 'tokens', selector: 'text=Design tokens' }, + { page: 'gallery', label: 'gallery', selector: 'text=Component gallery' }, ]; /** key → { ruleId: nodeCount } */ diff --git a/hub-client/e2e/baseline-screens.visual.spec.ts b/hub-client/e2e/baseline-screens.visual.spec.ts index 852aa121b..7c08a7990 100644 --- a/hub-client/e2e/baseline-screens.visual.spec.ts +++ b/hub-client/e2e/baseline-screens.visual.spec.ts @@ -30,6 +30,7 @@ const BASELINE_PAGES: { page: string; label: string; selector: string }[] = [ { page: 'header', label: 'minimal-header', selector: '.minimal-header' }, { page: 'notifications', label: 'notifications', selector: '.ephemeral-session-banner' }, { page: 'tokens', label: 'tokens', selector: 'text=Design tokens' }, + { page: 'gallery', label: 'gallery', selector: 'text=Component gallery' }, ]; for (const { page, label, selector } of BASELINE_PAGES) { diff --git a/hub-client/e2e/baseline-screens.visual.spec.ts-snapshots/gallery-dark-chromium-linux.png b/hub-client/e2e/baseline-screens.visual.spec.ts-snapshots/gallery-dark-chromium-linux.png new file mode 100644 index 000000000..432d9a5b8 Binary files /dev/null and b/hub-client/e2e/baseline-screens.visual.spec.ts-snapshots/gallery-dark-chromium-linux.png differ diff --git a/hub-client/e2e/baseline-screens.visual.spec.ts-snapshots/gallery-light-chromium-linux.png b/hub-client/e2e/baseline-screens.visual.spec.ts-snapshots/gallery-light-chromium-linux.png new file mode 100644 index 000000000..26b18d8bc Binary files /dev/null and b/hub-client/e2e/baseline-screens.visual.spec.ts-snapshots/gallery-light-chromium-linux.png differ diff --git a/hub-client/e2e/files-header.spec.ts b/hub-client/e2e/files-header.spec.ts index 00d397735..3c42f8b32 100644 --- a/hub-client/e2e/files-header.spec.ts +++ b/hub-client/e2e/files-header.spec.ts @@ -2,10 +2,12 @@ * E2E: Files header action row (bd-qhn2raky). * * The New/Upload buttons — and the conditional Print button — are - * icon-only buttons that share the header row at equal width and fill it, + * icon-only buttons that share one compact, left-aligned header row, * whether two or three are present. Guards the regression where three * text buttons wrapped Upload onto a second row at the default 220px - * sidebar width. + * sidebar width. (The buttons grew to fill the row until the Phase 1 + * sidebar cleanup made them compact; the row-wrap guard is the + * behavior that still matters.) */ import { test, expect, type Page } from '@playwright/test'; @@ -29,37 +31,38 @@ async function openEditor(page: Page, title: string, content: string) { await expect(page.locator('.new-file-btn')).toBeVisible({ timeout: 30000 }); } -async function expectSingleFullRow(page: Page, selectors: string[]) { +async function expectSingleCompactRow(page: Page, selectors: string[]) { const boxes = []; for (const sel of selectors) { const btn = page.locator(sel); await expect(btn).toBeVisible(); boxes.push((await btn.boundingBox())!); } - // All on one row, equal width and height, in the given left-to-right order. + // All on one row (no wrapping), equal width and height, in the given + // left-to-right order. for (let i = 1; i < boxes.length; i++) { expect(Math.abs(boxes[i].y - boxes[0].y)).toBeLessThan(1); expect(Math.abs(boxes[i].width - boxes[0].width)).toBeLessThan(1); expect(Math.abs(boxes[i].height - boxes[0].height)).toBeLessThan(1); expect(boxes[i].x).toBeGreaterThan(boxes[i - 1].x); } - // Together they fill the row: 24px horizontal padding + 6px gaps. + // Compact and left-aligned: the first button starts at the header's + // 12px padding edge. const headerBox = (await page.locator('.sidebar-header').boundingBox())!; - const totalWidth = boxes.reduce((sum, b) => sum + b.width, 0); - expect(totalWidth).toBeCloseTo(headerBox.width - 24 - 6 * (boxes.length - 1), 0); + expect(Math.abs(boxes[0].x - headerBox.x - 12)).toBeLessThan(1); } test.describe('Files header action row', () => { test.setTimeout(90_000); - test('New and Upload fill the row at equal width', async ({ page }) => { + test('New and Upload share one compact row', async ({ page }) => { await openEditor(page, 'Two Button Row', '---\ntitle: Two Button Row\n---\n\nHello.\n'); await expect(page.locator('.print-file-btn')).toHaveCount(0); - await expectSingleFullRow(page, ['.new-file-btn', '.upload-asset-btn']); + await expectSingleCompactRow(page, ['.new-file-btn', '.upload-asset-btn']); }); - test('Print, New and Upload share one row at equal width', async ({ page }) => { + test('Print, New and Upload share one compact row', async ({ page }) => { await openEditor( page, 'Three Button Row', @@ -69,7 +72,7 @@ test.describe('Files header action row', () => { // Print appears once the format is detected as printable, and is // last in the row so the stable New/Upload pair doesn't shift. await expect(page.locator('.print-file-btn')).toBeVisible({ timeout: 30000 }); - await expectSingleFullRow(page, [ + await expectSingleCompactRow(page, [ '.new-file-btn', '.upload-asset-btn', '.print-file-btn', diff --git a/hub-client/e2e/helpers/axe-baseline.json b/hub-client/e2e/helpers/axe-baseline.json index c592c77c7..71825103a 100644 --- a/hub-client/e2e/helpers/axe-baseline.json +++ b/hub-client/e2e/helpers/axe-baseline.json @@ -11,6 +11,12 @@ "dialog-share|light": { "color-contrast": 2 }, + "gallery|dark": { + "color-contrast": 7 + }, + "gallery|light": { + "color-contrast": 3 + }, "minimal-header|dark": { "color-contrast": 1 }, diff --git a/hub-client/e2e/helpers/visual.ts b/hub-client/e2e/helpers/visual.ts index e7636d190..1a3793b23 100644 --- a/hub-client/e2e/helpers/visual.ts +++ b/hub-client/e2e/helpers/visual.ts @@ -91,52 +91,66 @@ export async function bootHarness( await page.goto(`/#/dev/${route}`); // Wait for the app's boot to create the identity record, then pin it. - await page.waitForFunction( - () => - new Promise((resolve) => { - const req = indexedDB.open('quarto-hub'); - req.onsuccess = () => { - const db = req.result; - if (!db.objectStoreNames.contains('userSettings')) { - db.close(); - resolve(false); - return; - } - const tx = db.transaction('userSettings', 'readonly'); - const get = tx.objectStore('userSettings').get('identity'); - get.onsuccess = () => { - db.close(); - resolve(!!get.result); + // The dev server can trigger a full-page reload mid-boot (vite re- + // optimizes dependencies on a cold cache — the CI environment), which + // destroys the execution context under either of these probes. Both + // are idempotent, so retry past the reload. + for (let attempt = 0; ; attempt++) { + try { + await page.waitForFunction( + () => + new Promise((resolve) => { + const req = indexedDB.open('quarto-hub'); + req.onsuccess = () => { + const db = req.result; + if (!db.objectStoreNames.contains('userSettings')) { + db.close(); + resolve(false); + return; + } + const tx = db.transaction('userSettings', 'readonly'); + const get = tx.objectStore('userSettings').get('identity'); + get.onsuccess = () => { + db.close(); + resolve(!!get.result); + }; + get.onerror = () => { + db.close(); + resolve(false); + }; + }; + req.onerror = () => resolve(false); + }), + undefined, + { timeout: 15000 }, + ); + await page.evaluate((identity) => { + return new Promise((resolve, reject) => { + const req = indexedDB.open('quarto-hub'); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction('userSettings', 'readwrite'); + tx.objectStore('userSettings').put(identity); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; }; - get.onerror = () => { - db.close(); - resolve(false); - }; - }; - req.onerror = () => resolve(false); - }), - undefined, - { timeout: 15000 }, - ); - await page.evaluate((identity) => { - return new Promise((resolve, reject) => { - const req = indexedDB.open('quarto-hub'); - req.onsuccess = () => { - const db = req.result; - const tx = db.transaction('userSettings', 'readwrite'); - tx.objectStore('userSettings').put(identity); - tx.oncomplete = () => { - db.close(); - resolve(); - }; - tx.onerror = () => { - db.close(); - reject(tx.error); - }; - }; - req.onerror = () => reject(req.error); - }); - }, FIXED_IDENTITY); + req.onerror = () => reject(req.error); + }); + }, FIXED_IDENTITY); + break; + } catch (err) { + if (attempt >= 2 || !/Execution context was destroyed/.test(String(err))) { + throw err; + } + await page.waitForLoadState('load'); + } + } await page.reload(); await page.waitForSelector(selector, { timeout: 15000 }); diff --git a/hub-client/e2e/menu-keyboard.visual.spec.ts b/hub-client/e2e/menu-keyboard.visual.spec.ts new file mode 100644 index 000000000..c96cc8ade --- /dev/null +++ b/hub-client/e2e/menu-keyboard.visual.spec.ts @@ -0,0 +1,141 @@ +/** + * Keyboard-interaction spec for the shared Menu component (APG menu-button + * pattern), driven against the `#/dev/gallery` menu demo. Runs in the + * no-server visual config because it needs the DevHarness, not screenshots. + * + * Covers: open focuses first item, ArrowUp/Down (wrapping, skipping + * disabled), Home/End, type-ahead, submenu open/close with ArrowRight/ + * ArrowLeft, Enter activates + closes + returns focus, Escape closes + + * returns focus. + * + * Phase 1 deliverable of the UI/UX modernization plan (bd-iguk0hpd). + */ + +import { test, expect, type Page } from '@playwright/test'; +import { bootHarness } from './helpers/visual'; + +const TRIGGER = 'button:has-text("Gallery menu")'; + +async function openMenu(page: Page) { + await page.click(TRIGGER); + const menu = page.locator('[role="menu"]'); + await expect(menu).toBeVisible(); + return menu; +} + +/** The top-level gallery menu (not a submenu). */ +function topMenu(page: Page) { + return page.locator('[role="menu"]', { hasNot: page.locator('[role="menu"]') }); +} + +test.beforeEach(async ({ page }) => { + await bootHarness(page, 'gallery', 'text=Component gallery', 'light'); +}); + +test('opens with first item focused; arrows move and wrap, skipping disabled', async ({ page }) => { + const menu = await openMenu(page); + // First item focused on open. + await expect(page.locator('[role="menuitem"]').first()).toBeFocused(); + + const items = menu.locator('[role="menuitem"]'); + // Locator order: Open(0), Move to(1), Copy link(2), Duplicate(3), + // Unavailable(4, disabled), Delete(5). Arrow nav skips the disabled item. + await page.keyboard.press('ArrowDown'); + await expect(items.nth(1)).toBeFocused(); // Move to + await page.keyboard.press('ArrowUp'); + await expect(items.first()).toBeFocused(); // wraps back up + await page.keyboard.press('ArrowUp'); + await expect(items.nth(5)).toBeFocused(); // wraps to Delete (last enabled) + await page.keyboard.press('ArrowDown'); + await expect(items.first()).toBeFocused(); // wraps to top +}); + +test('Home/End jump to first/last enabled item', async ({ page }) => { + const menu = await openMenu(page); + const items = menu.locator('[role="menuitem"]'); + await page.keyboard.press('End'); + await expect(items.nth(5)).toBeFocused(); // Delete, past the disabled item + await page.keyboard.press('Home'); + await expect(items.first()).toBeFocused(); +}); + +test('type-ahead focuses the matching item', async ({ page }) => { + const menu = await openMenu(page); + const items = menu.locator('[role="menuitem"]'); + await page.keyboard.press('d'); + await expect(items.nth(3)).toBeFocused(); // Duplicate + // The harness mocks timers; advance past the 500ms type-ahead window so + // the next keystroke starts a fresh buffer. + await page.clock.runFor(600); + await page.keyboard.press('c'); + await expect(items.nth(2)).toBeFocused(); // Copy link +}); + +test('submenu: ArrowRight opens and focuses first item; ArrowLeft returns', async ({ page }) => { + const menu = await openMenu(page); + await page.keyboard.press('ArrowDown'); // Move to + const parent = menu.locator('[role="menuitem"][aria-haspopup="menu"]'); + await expect(parent).toBeFocused(); + + await page.keyboard.press('ArrowRight'); + await expect(parent).toHaveAttribute('aria-expanded', 'true'); + const submenu = page.locator('.qh-submenu [role="menuitem"]'); + await expect(submenu.first()).toBeFocused(); + await expect(submenu.first()).toHaveText('Alpha'); + + await page.keyboard.press('ArrowLeft'); + await expect(parent).toHaveAttribute('aria-expanded', 'false'); + await expect(parent).toBeFocused(); +}); + +test('Enter activates the item, closes the menu, returns focus to the trigger', async ({ + page, +}) => { + await openMenu(page); + await page.keyboard.press('Enter'); // activates "Open" + await expect(page.locator('[role="menu"]')).toHaveCount(0); + await expect(page.locator(TRIGGER)).toBeFocused(); + await expect(page.getByTestId('menu-last-action')).toHaveText('Last action: open'); +}); + +test('Escape closes the menu and returns focus to the trigger', async ({ page }) => { + await openMenu(page); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Escape'); + await expect(page.locator('[role="menu"]')).toHaveCount(0); + await expect(page.locator(TRIGGER)).toBeFocused(); +}); + +test('keepOpen item stays open and records its action', async ({ page }) => { + const menu = await openMenu(page); + await page.keyboard.press('c'); // type-ahead to Copy link + await page.keyboard.press('Enter'); + await expect(topMenu(page)).toBeVisible(); + await expect(page.getByTestId('menu-last-action')).toHaveText('Last action: copy'); +}); + +test('pointer: clicking an item closes; clicking the trigger toggles', async ({ page }) => { + const menu = await openMenu(page); + await menu.locator('[role="menuitem"]', { hasText: 'Duplicate' }).click(); + await expect(page.locator('[role="menu"]')).toHaveCount(0); + await expect(page.getByTestId('menu-last-action')).toHaveText('Last action: duplicate'); + + // Toggle: open, then click the trigger again to close. + await openMenu(page); + await page.click(TRIGGER); + await expect(page.locator('[role="menu"]')).toHaveCount(0); +}); + +test('pointer: clicking a disabled item neither activates nor closes', async ({ page }) => { + const menu = await openMenu(page); + await menu.locator('[role="menuitem"]', { hasText: 'Unavailable action' }).click({ force: true }); + await expect(topMenu(page)).toBeVisible(); + await expect(page.getByTestId('menu-last-action')).toHaveText('Last action: (none)'); +}); + +test('pointer: clicking outside closes without focusing the trigger', async ({ page }) => { + await openMenu(page); + await page.click('h1'); + await expect(page.locator('[role="menu"]')).toHaveCount(0); + await expect(page.locator(TRIGGER)).not.toBeFocused(); +}); diff --git a/hub-client/e2e/projects-home-dialogs.visual.spec.ts b/hub-client/e2e/projects-home-dialogs.visual.spec.ts new file mode 100644 index 000000000..f34f1ede2 --- /dev/null +++ b/hub-client/e2e/projects-home-dialogs.visual.spec.ts @@ -0,0 +1,62 @@ +/** + * Interaction spec for the ProjectsHome dialogs after their conversion to + * the shared ModalDialog (Phase 1): opened through the real UI (New menu, + * Connect/Import button), each must present the WCAG dialog contract — + * role="dialog", aria-modal, labelled by its title, Escape closes, focus + * returns to the trigger, and the header close button works. + * + * Runs in the no-server visual config (DevHarness, not screenshots). + */ + +import { test, expect } from '@playwright/test'; +import { bootHarness } from './helpers/visual'; + +test.beforeEach(async ({ page }) => { + await bootHarness(page, 'projects-home', '.projects-home', 'light'); +}); + +test('New project dialog opens via the New menu and closes with focus return', async ({ + page, +}) => { + const trigger = page.getByRole('button', { name: '+ New ▾' }); + await trigger.click(); + const menu = page.locator('[role="menu"]'); + await expect(menu).toBeVisible(); + + // Keyboard: first item focused; Enter opens the dialog. + await expect(menu.locator('[role="menuitem"]').first()).toBeFocused(); + await page.keyboard.press('Enter'); + + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAttribute('aria-modal', 'true'); + await expect(dialog.locator('h2')).toHaveText('New default'); + // Focus is inside the dialog (the name input autofocuses). + await expect(dialog.locator('#qh-new-name')).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(page.locator('[role="dialog"]')).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); + +test('Connect/Import dialog: header close button closes and returns focus', async ({ page }) => { + const trigger = page.locator('button:has-text("Connect / Import")'); + await trigger.click(); + + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('h2')).toHaveText('Add an existing project'); + + // Tab cycles within the dialog (focus trap): Shift+Tab from the first + // focusable wraps to the last. + await dialog.locator('.close-btn').focus(); + await page.keyboard.press('Shift+Tab'); + const focused = await page.evaluate(() => document.activeElement?.textContent); + // The Import submit is disabled until a file is chosen, so the last + // enabled focusable is Cancel. + expect(focused).toBe('Cancel'); + + await dialog.locator('.close-btn').click(); + await expect(page.locator('[role="dialog"]')).toHaveCount(0); + await expect(trigger).toBeFocused(); +}); diff --git a/hub-client/e2e/projects-home.spec.ts b/hub-client/e2e/projects-home.spec.ts index d3a009295..247f53017 100644 --- a/hub-client/e2e/projects-home.spec.ts +++ b/hub-client/e2e/projects-home.spec.ts @@ -68,11 +68,11 @@ async function moveProjectToCollection( collectionName: string, ): Promise { const row = page.locator('.qh-row', { hasText: projectName }); - await row.getByRole('button', { name: '⋯' }).click(); - await page.getByRole('button', { name: /^Move to collection/ }).click(); + await row.getByRole('button', { name: `Actions for ${projectName}` }).click(); + await page.getByRole('menuitem', { name: /^Move to collection/ }).click(); await page .locator('.qh-submenu') - .getByRole('button', { name: collectionName, exact: true }) + .getByRole('menuitem', { name: collectionName, exact: true }) .click(); await expect( collectionSection(page, collectionName).locator('.qh-card', { hasText: projectName }), @@ -116,7 +116,7 @@ test.describe('Collections projects home', () => { await section.locator('.qh-card', { hasText: 'Alpha Project' }).click({ button: 'right' }); const menu = section.locator('.qh-card', { hasText: 'Alpha Project' }).locator('.qh-menu'); await expect(menu).toBeVisible(); - await expect(menu.getByRole('button', { name: 'Open', exact: true })).toBeVisible(); + await expect(menu.getByRole('menuitem', { name: 'Open', exact: true })).toBeVisible(); await page.keyboard.press('Escape'); await expect(menu).not.toBeVisible(); @@ -124,7 +124,7 @@ test.describe('Collections projects home', () => { // two projects were seeded milliseconds apart, so recency order between // them is not asserted — A-to-Z is the deterministic check.) await section.getByRole('button', { name: /^Sort collection/ }).click(); - await section.getByRole('button', { name: 'A to Z', exact: true }).click(); + await section.getByRole('menuitem', { name: 'A to Z', exact: true }).click(); await expect( section.getByRole('button', { name: /^Sort collection \(A to Z\)/ }), ).toBeVisible(); diff --git a/hub-client/scripts/lint-css-exceptions.json b/hub-client/scripts/lint-css-exceptions.json index 860a31c44..8f10f511c 100644 --- a/hub-client/scripts/lint-css-exceptions.json +++ b/hub-client/scripts/lint-css-exceptions.json @@ -1,8 +1,5 @@ { "no-outline-none-without-focus-visible": { - "components/FileSidebar.css": [ - "*" - ], "components/NewFileDialog.css": [ "*" ], @@ -15,9 +12,6 @@ "components/ProjectSetSetup.css": [ "*" ], - "components/ProjectsHome.css": [ - "*" - ], "components/ReplayDrawer.css": [ "*" ], @@ -43,8 +37,7 @@ "border-left: 2px solid var(--sidebar-active-accent);", "margin-left: -2px;", "padding-left: 22px;", - "right: 4px;", - "text-align: left;" + "right: 4px;" ], "components/OutlinePanel.css": [ "margin-left: 4px;", @@ -98,12 +91,6 @@ "components/SkipLink.css": [ "left: 8px;" ], - "components/Toast.css": [ - "left: 50%;" - ], - "components/UpdateAvailableToast.css": [ - "right: 20px;" - ], "components/debug-inspector/DebugInspectorPanel.css": [ "border-right: 1px solid var(--debug-surface-0);", "margin-left: 6px;", @@ -138,4 +125,4 @@ "text-align: left;" ] } -} +} \ No newline at end of file diff --git a/hub-client/src/components/DevGalleryPage.tsx b/hub-client/src/components/DevGalleryPage.tsx new file mode 100644 index 000000000..79a4c009b --- /dev/null +++ b/hub-client/src/components/DevGalleryPage.tsx @@ -0,0 +1,283 @@ +/** + * Dev-only component gallery (#/dev/gallery): every design-system primitive + * in its meaningful states, in both themes, covered by the Playwright + * visual baselines and the menu keyboard-interaction spec. This is the + * drift-prevention counterpart to lint:css — a new component belongs here + * before it ships (see hub-client/design-system.md). + * + * Layout uses inline styles (out of lint:css scope, like DevTokensPage); + * the primitives themselves use their real classes from ui.css. + * + * Phase 1 deliverable of the UI/UX modernization plan (bd-iguk0hpd). + */ + +import React, { useState } from 'react'; +import { Menu, MenuItem, MenuDivider, MenuLabel, MenuSubmenu } from './Menu'; +import Tooltip from './Tooltip'; +import { + FilePlusIcon, + UploadIcon, + PrintIcon, + SwitchIcon, + ShareIcon, + PreviewIcon, + ForkIcon, + PeekIcon, + PeopleIcon, + SortIcon, + MoreIcon, + LayoutMarkupIcon, + LayoutSplitIcon, + LayoutPreviewIcon, + CommentsExpandIcon, + CommentsShowIcon, + CommentsHideIcon, +} from './icons'; +import '../ui.css'; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +/** Menu demo: a menu-button opening the shared Menu, exercising a strong + * item, a submenu, a hint, a divider, and a danger item. */ +function MenuDemo() { + const [open, setOpen] = useState(false); + const [lastAction, setLastAction] = useState('(none)'); + const triggerRef = React.useRef(null); + const pick = (action: string) => () => setLastAction(action); + return ( +
+
+ + {open && ( + setOpen(false)} + triggerRef={triggerRef} + aria-label="Gallery actions" + > + GALLERY + + Open + + + Alpha + Beta + + + Copy link + + + Duplicate + + + Unavailable action + + + + Delete + + + )} +
+ + Last action: {lastAction} + +
+ ); +} + +export default function DevGalleryPage() { + const [inputValue, setInputValue] = useState('Editable text'); + return ( +
+

+ Component gallery +

+ +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + +
+ +
+ +
+ +
+ + + + + +
+ +
+ + setInputValue(e.target.value)} + aria-label="Example input" + /> + + + + + + + + + + + + Error text wired via aria-describedby. + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/hub-client/src/components/DevHarness.tsx b/hub-client/src/components/DevHarness.tsx index e2b27f553..5d0089bd3 100644 --- a/hub-client/src/components/DevHarness.tsx +++ b/hub-client/src/components/DevHarness.tsx @@ -22,6 +22,7 @@ import Toast from './Toast'; import UpdateAvailableToast from './UpdateAvailableToast'; import EphemeralSessionBanner from './EphemeralSessionBanner'; import DevTokensPage from './DevTokensPage'; +import DevGalleryPage from './DevGalleryPage'; import { ViewModeProvider } from './ViewModeContext'; import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; import type { FileEntry } from '@quarto/preview-renderer/types/project'; @@ -223,6 +224,8 @@ const DEV_PAGES: Record React.ReactNode> = { tokens: () => , + gallery: () => , + 'projects-home': () => ( {}} diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index 12d7d6bad..50eaada96 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -22,7 +22,7 @@ .diagnostic-item { padding: 4px 0; font-size: 13px; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-family: var(--font-mono); } .diagnostic-item.diagnostic-error { @@ -117,7 +117,7 @@ } .current-file-indicator .file-path { - font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 13px; color: var(--editor-text-dim); } @@ -408,18 +408,6 @@ flex-shrink: 0; } -/* Ephemeral-session banner — `q2 preview --ui editor` without --allow-edit */ -.ephemeral-session-banner { - background: var(--warning-bg); - color: var(--warning-text); - border-bottom: 1px solid var(--warning-border); - padding: 4px 16px; - font-size: 12px; - text-align: center; - flex-shrink: 0; - cursor: help; -} - /* Replay mode banner — overlays bottom edge of header */ .replay-mode-banner { position: absolute; @@ -531,7 +519,7 @@ margin: 0; white-space: pre-wrap; word-break: break-word; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-family: var(--font-mono); font-size: 12px; line-height: 1.4; color: var(--error-overlay-text); @@ -584,7 +572,7 @@ } .preview-error-pass1-failure .diagnostic-source-file code { - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-family: var(--font-mono); background: var(--alpha-black-05); padding: 1px 4px; border-radius: 2px; diff --git a/hub-client/src/components/EphemeralSessionBanner.test.tsx b/hub-client/src/components/EphemeralSessionBanner.test.tsx index 1ed140066..201b6bf61 100644 --- a/hub-client/src/components/EphemeralSessionBanner.test.tsx +++ b/hub-client/src/components/EphemeralSessionBanner.test.tsx @@ -12,7 +12,7 @@ */ import { describe, it, expect, afterEach } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; import EphemeralSessionBanner from './EphemeralSessionBanner'; afterEach(cleanup); @@ -26,11 +26,12 @@ describe('EphemeralSessionBanner', () => { expect(banner.textContent).toContain("edits won't be saved to disk"); }); - it('hover text explains the cause and names the --allow-edit fix', () => { + it('tooltip explains the cause and names the --allow-edit fix', () => { render(); - const { title } = screen.getByRole('status'); - expect(title).toContain('never written'); - expect(title).toContain('--allow-edit'); + fireEvent.focus(screen.getByRole('status')); + const tip = screen.getByRole('tooltip'); + expect(tip.textContent).toContain('never written'); + expect(tip.textContent).toContain('--allow-edit'); }); }); diff --git a/hub-client/src/components/EphemeralSessionBanner.tsx b/hub-client/src/components/EphemeralSessionBanner.tsx index fee049d59..f0e3bc846 100644 --- a/hub-client/src/components/EphemeralSessionBanner.tsx +++ b/hub-client/src/components/EphemeralSessionBanner.tsx @@ -8,14 +8,16 @@ * guest reads the host's value through the tunnel). The copy is fixed * — nothing here is interpolated from server data. */ + +import Tooltip from './Tooltip'; +import './notifications.css'; + export default function EphemeralSessionBanner() { return ( -
- Ephemeral session — edits won't be saved to disk -
+ +
+ Ephemeral session — edits won't be saved to disk +
+
); } diff --git a/hub-client/src/components/FileSidebar.css b/hub-client/src/components/FileSidebar.css index 996c24ebe..e77998876 100644 --- a/hub-client/src/components/FileSidebar.css +++ b/hub-client/src/components/FileSidebar.css @@ -14,24 +14,21 @@ .sidebar-header { display: flex; align-items: center; - justify-content: flex-end; + /* Compact, left-aligned buttons — right-alignment leaves a gap that + reads as a missing button. */ + justify-content: flex-start; flex-wrap: wrap; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--sidebar-border); } -/* Sidebar-header buttons are icon-only .qh-btn.small.outline (ui.css). - They grow to equal widths so together they fill the header row, whether - two or three (conditional Print) are present; inline-flex centers the - SVG glyph. */ +/* Sidebar-header buttons are icon-only .qh-btn.small.outline (ui.css), + each wrapped in a Tooltip anchor span (the wrapper is the flex item). + inline-flex centers the SVG glyph. */ .sidebar-header .new-file-btn, .sidebar-header .upload-asset-btn, .sidebar-header .print-file-btn { - flex: 1 1 0; - /* Never shrink below the glyph: if the header is ever too narrow, the - buttons wrap whole rather than clip. */ - min-width: min-content; display: inline-flex; align-items: center; justify-content: center; @@ -209,42 +206,25 @@ font-size: 32px; } -/* Context menu */ -.context-menu { - position: fixed; - background: var(--context-menu-bg); - border: 1px solid var(--context-menu-border); - border-radius: 6px; - padding: 4px 0; - min-width: 120px; - box-shadow: 0 4px 12px var(--context-menu-shadow); - z-index: var(--z-modal); -} +/* Context menu: the shared .qh-menu component (ui.css) with .qh-menu-fixed + placement. */ -.context-menu button { - display: block; - width: 100%; - padding: 8px 16px; - background: none; - border: none; - border-radius: 6px; - text-align: left; - color: var(--editor-text-dim); +/* Visible kebab affordance on file rows — the menu is no longer + right-click-only. Hidden until the row is hovered or the button is + focused (keyboard users always have a visible, tabbable path). */ +.file-item .file-kebab { + margin-inline-start: auto; + padding: 2px 4px; font-size: 13px; - cursor: pointer; - transition: background 0.15s; -} - -.context-menu button:hover { - background: var(--context-menu-hover); -} - -.context-menu button.danger { - color: var(--context-menu-danger); + color: var(--editor-text-muted); + opacity: 0; + flex-shrink: 0; } -.context-menu button.danger:hover { - background: var(--context-menu-danger-bg); +.file-item:hover .file-kebab, +.file-item .file-kebab:focus-visible, +.file-item .file-kebab[aria-expanded="true"] { + opacity: 1; } /* ── Full-text search ─────────────────────────────────────────────── */ diff --git a/hub-client/src/components/FileSidebar.tsx b/hub-client/src/components/FileSidebar.tsx index c5a6a31cc..0ebb3912c 100644 --- a/hub-client/src/components/FileSidebar.tsx +++ b/hub-client/src/components/FileSidebar.tsx @@ -19,72 +19,11 @@ import { import { resolveDefaultDestination } from './fileUpload'; import { buildSnippet, type SearchFiles, type SearchResult } from '../services/search'; import { openPrintableDocument } from '../services/printableDocument'; +import { FilePlusIcon, UploadIcon, PrintIcon, MoreIcon } from './icons'; +import { Menu, MenuItem } from './Menu'; +import Tooltip from './Tooltip'; import './FileSidebar.css'; -/** Document with a plus — "new file". Matches the MinimalHeader icon style. */ -function FilePlusIcon() { - return ( - - ); -} - -/** Arrow rising out of a tray — "upload". */ -function UploadIcon() { - return ( - - ); -} - -/** Printer — "open printable version". */ -function PrintIcon() { - return ( - - ); -} - export interface FileSidebarProps { files: FileEntry[]; currentFile: FileEntry | null; @@ -126,6 +65,8 @@ interface ContextMenuState { x: number; y: number; file: FileEntry | null; + /** The kebab button that opened the menu (kebab-opened menus only). */ + trigger?: HTMLElement | null; } /** Image extensions for drag-drop detection */ @@ -332,13 +273,6 @@ export default function FileSidebar({ setContextMenu((prev) => ({ ...prev, visible: false })); }, []); - // Handle clicks outside context menu - const handleSidebarClick = useCallback(() => { - if (contextMenu.visible) { - closeContextMenu(); - } - }, [contextMenu.visible, closeContextMenu]); - // Rename handlers const startRename = useCallback((file: FileEntry) => { setRenamingFile(file); @@ -451,7 +385,11 @@ export default function FileSidebar({ const lastSlash = file.path.lastIndexOf('/'); const parentFolderPath = lastSlash >= 0 ? file.path.slice(0, lastSlash) : ''; + const rowTip = onOpenInNewTab + ? `${file.path} — Ctrl/Cmd+click to open in new tab` + : file.path; return ( +
handleFileDragStart(e, file) : undefined } - title={ - onOpenInNewTab - ? `${file.path}\nCtrl/Cmd+click to open in new tab` - : file.path - } > {getFileIcon(file.path)} {isRenaming ? ( @@ -483,7 +416,39 @@ export default function FileSidebar({ ) : ( {fileName} )} + {!isRenaming && (onOpenInNewTab || onCopyLink || onRenameFile || onDeleteFile) && ( + + )}
+
); }; @@ -543,7 +508,6 @@ export default function FileSidebar({ key={result.path} className={`search-result qh-row-hover qh-active-accent-row ${isActive ? 'active' : ''}`} onClick={() => onSelectFile(file)} - title={result.path} >
{getFileIcon(result.path)} @@ -573,35 +537,37 @@ export default function FileSidebar({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - onClick={handleSidebarClick} >
- - - {canOpenPrintable && ( + + + + + + {canOpenPrintable && ( + + + )}
{printableError && ( @@ -628,14 +594,15 @@ export default function FileSidebar({ aria-label="Search files" /> {isSearching && ( - + + + )}
)} @@ -664,34 +631,36 @@ export default function FileSidebar({ {/* Context Menu */} {contextMenu.visible && contextMenu.file && ( -
closeContextMenu()} + triggerRef={{ current: contextMenu.trigger ?? null }} + aria-label={`Actions for ${contextMenu.file.path}`} > {onOpenInNewTab && ( - + )} {onCopyLink && ( - + )} {onRenameFile && ( - + )} {onDeleteFile && ( - + )} -
+ )} ); diff --git a/hub-client/src/components/JoinCollectionLanding.tsx b/hub-client/src/components/JoinCollectionLanding.tsx index bff4f47aa..4dbc1be4a 100644 --- a/hub-client/src/components/JoinCollectionLanding.tsx +++ b/hub-client/src/components/JoinCollectionLanding.tsx @@ -10,6 +10,7 @@ */ import { useState, useEffect } from 'react'; +import Tooltip from './Tooltip'; import type { CollectionsStatus } from '../hooks/useCollectionSets'; import { CollectionConnectError } from '../services/collectionConnectError'; import type { UserSettings } from '../services/storage/types'; @@ -142,13 +143,14 @@ export default function JoinCollectionLanding({
{COLOR_PALETTE.map((c) => ( -
diff --git a/hub-client/src/components/MarkdownSummary.css b/hub-client/src/components/MarkdownSummary.css index c73c7f03c..ec0d6bc87 100644 --- a/hub-client/src/components/MarkdownSummary.css +++ b/hub-client/src/components/MarkdownSummary.css @@ -9,7 +9,7 @@ .markdown-summary-content { margin: 0; padding: 8px; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-family: var(--font-mono); font-size: 6px; line-height: 1.4; color: var(--summary-text); diff --git a/hub-client/src/components/Menu.tsx b/hub-client/src/components/Menu.tsx new file mode 100644 index 000000000..c8feb6c7d --- /dev/null +++ b/hub-client/src/components/Menu.tsx @@ -0,0 +1,406 @@ +/** + * Menu — the single accessible menu primitive for hub-client. + * + * Implements the WAI-ARIA APG menu/menu-button pattern: + * - `role="menu"` container with `role="menuitem"` children + * - ArrowUp/ArrowDown move focus (wrapping); Home/End jump to first/last + * - Type-ahead: printable characters focus the next matching item + * - ArrowRight opens a submenu (MenuSubmenu); ArrowLeft closes it + * - Escape closes the menu and returns focus to the trigger + * - Tab closes the menu (menus do not trap focus) + * - Pointer-down outside the menu closes it (without stealing focus) + * - The focused item is scrolled into view + * + * Usage: render inside a `.qh-menu-anchor` (position: relative) parent for + * anchored placement, or pass `fixed={{ x, y }}` for context-menu placement + * at cursor coordinates. The trigger element owns open/close state; pass it + * via `triggerRef` so trigger clicks aren't treated as outside clicks and + * focus returns to it on close. + * + * Destructive-action pattern (the single rule for the app): menu items + * that destroy data use `danger` styling and must be guarded by a + * confirmation dialog (see ProjectsHome's remove flow) unless the action + * is trivially undoable. "Confirm vs undo" is decided per action at its + * call site, but every destructive menu item must have one or the other. + */ + +import { + useEffect, + useId, + useRef, + useCallback, + useState, + type ReactNode, + type MouseEvent as ReactMouseEvent, +} from 'react'; + +export interface MenuProps { + /** Close the menu. `returnFocus` is true for keyboard-driven closes. */ + onClose: (returnFocus: boolean) => void; + /** Fixed-position placement (context menus). Omit for anchored placement. */ + fixed?: { x: number; y: number }; + /** The trigger element; clicks on it are not "outside" clicks. */ + triggerRef?: { current: HTMLElement | null }; + /** CSS selector whose matches are not "outside" clicks — use when the + * trigger owns toggle behavior and sits inside a shared anchor (e.g. + * '.qh-menu-anchor'). */ + ignoreOutsideSelector?: string; + /** Accessible name for the menu. */ + 'aria-label'?: string; + className?: string; + children: ReactNode; +} + +const ITEM_SELECTOR = '[role="menuitem"]:not([aria-disabled="true"])'; + +export function Menu({ + onClose, + fixed, + triggerRef, + ignoreOutsideSelector, + 'aria-label': ariaLabel, + className = '', + children, +}: MenuProps) { + const rootRef = useRef(null); + // Element to return focus to on keyboard-driven close: the trigger if + // given, otherwise whatever was focused when the menu opened. + const returnFocusRef = useRef(null); + if (returnFocusRef.current === null && typeof document !== 'undefined') { + returnFocusRef.current = + triggerRef?.current ?? + (document.activeElement instanceof HTMLElement ? document.activeElement : null); + } + + const items = useCallback((): HTMLElement[] => { + const root = rootRef.current; + if (!root) return []; + return Array.from(root.querySelectorAll(ITEM_SELECTOR)); + }, []); + + const focusItem = useCallback( + (el: HTMLElement | undefined) => { + if (!el) return; + el.focus(); + el.scrollIntoView({ block: 'nearest' }); + }, + [], + ); + + const close = useCallback( + (returnFocus: boolean) => { + // Return focus synchronously, BEFORE the state updates this close + // triggers are committed. If the activating item opens a dialog, + // the dialog captures its restore target during render — and with + // the trigger already focused, that target is the trigger, not the + // menu item about to be unmounted. The dialog's autoFocus child + // then takes focus on commit (no steal), and focus returns here + // when the dialog closes. + if (returnFocus) { + const target = returnFocusRef.current; + if (target?.isConnected) target.focus(); + } + onClose(returnFocus); + }, + [onClose], + ); + + // Focus the first item on open. + useEffect(() => { + const first = items()[0]; + first?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Viewport-edge flip for fixed (context-menu) placement: if the menu + // overflows the viewport, shift it back inside. + useEffect(() => { + const root = rootRef.current; + if (!root || !fixed) return; + const rect = root.getBoundingClientRect(); + const margin = 4; + let { x, y } = fixed; + if (rect.bottom > window.innerHeight - margin) { + y = Math.max(margin, window.innerHeight - margin - rect.height); + } + if (rect.right > window.innerWidth - margin) { + x = Math.max(margin, window.innerWidth - margin - rect.width); + } + if (x !== fixed.x || y !== fixed.y) { + root.style.top = `${y}px`; + root.style.left = `${x}px`; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Close on pointer-down outside the menu (and outside the trigger, which + // owns its own toggle behavior). + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const target = e.target as HTMLElement; + if (rootRef.current?.contains(target)) return; + if (triggerRef?.current?.contains(target)) return; + if (ignoreOutsideSelector && target.closest(ignoreOutsideSelector)) return; + close(false); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [close, triggerRef, ignoreOutsideSelector]); + + const typeAhead = useRef({ buffer: '', timer: 0 }); + useEffect(() => { + const ta = typeAhead.current; + return () => window.clearTimeout(ta.timer); + }, []); + + const onKeyDown = (e: React.KeyboardEvent) => { + const list = items(); + const activeIndex = list.indexOf(document.activeElement as HTMLElement); + switch (e.key) { + case 'ArrowDown': + case 'ArrowUp': { + e.preventDefault(); + const delta = e.key === 'ArrowDown' ? 1 : -1; + const next = + activeIndex === -1 + ? 0 + : (activeIndex + delta + list.length) % list.length; + focusItem(list[next]); + return; + } + case 'Home': + e.preventDefault(); + focusItem(list[0]); + return; + case 'End': + e.preventDefault(); + focusItem(list[list.length - 1]); + return; + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + close(true); + return; + case 'Tab': + // Menus don't trap focus; close and let the tab proceed. + close(false); + return; + case 'Enter': + case ' ': { + // Activate explicitly rather than relying on the browser's + // default button activation: that click is dispatched AFTER this + // handler returns — by then close() has moved focus back to the + // trigger, and the stray click would land on the trigger itself + // (re-opening the menu) instead of the item. + e.preventDefault(); + const item = activeIndex === -1 ? undefined : list[activeIndex]; + item?.click(); + return; + } + default: + break; + } + // Type-ahead: accumulate printable characters, focus the next item + // whose text starts with the buffer. Space is excluded — it activates + // the focused item rather than extending the buffer. + if (e.key.length === 1 && e.key !== ' ' && !e.metaKey && !e.ctrlKey && !e.altKey) { + const ta = typeAhead.current; + window.clearTimeout(ta.timer); + ta.buffer += e.key.toLowerCase(); + ta.timer = window.setTimeout(() => { + ta.buffer = ''; + }, 500); + const start = activeIndex === -1 ? 0 : activeIndex + 1; + for (let i = 0; i < list.length; i += 1) { + const candidate = list[(start + i) % list.length]; + if (candidate.textContent?.trim().toLowerCase().startsWith(ta.buffer)) { + focusItem(candidate); + break; + } + } + } + }; + + // Activating an item closes the menu and returns focus to the trigger. + // Items that need the menu to stay open (e.g. in-place "copied!" + // feedback) stop propagation in their own handler, so this never runs. + const onClick = (e: ReactMouseEvent) => { + const item = (e.target as HTMLElement).closest('[role="menuitem"]'); + if (!item) return; + // Disabled items don't activate — the click must not close the menu. + if (item.getAttribute('aria-disabled') === 'true') return; + close(true); + }; + + return ( +
+ {children} +
+ ); +} + +export interface MenuItemProps { + onSelect: () => void; + /** Destructive action — must be confirm-guarded or undoable (see header). */ + danger?: boolean; + strong?: boolean; + accent?: boolean; + disabled?: boolean; + /** Right-aligned hint text (e.g. a shortcut or id). */ + hint?: ReactNode; + /** Second line of muted explanatory text. */ + subtext?: ReactNode; + /** Keep the menu open on select (for in-place feedback like "Copied!"). */ + keepOpen?: boolean; + children: ReactNode; +} + +export function MenuItem({ + onSelect, + danger, + strong, + accent, + disabled, + hint, + subtext, + keepOpen, + children, +}: MenuItemProps) { + const classes = [ + 'qh-menu-item', + danger ? 'danger' : '', + strong ? 'strong' : '', + accent ? 'accent' : '', + hint ? 'with-hint' : '', + ] + .filter(Boolean) + .join(' '); + return ( + + ); +} + +export function MenuDivider() { + return
; +} + +export function MenuLabel({ children }: { children: ReactNode }) { + // role="presentation": a label is not a menuitem but may appear as a + // child of role="menu". + return ( +
+ {children} +
+ ); +} + +export interface MenuSubmenuProps { + /** The parent item's label. */ + label: ReactNode; + children: ReactNode; +} + +/** + * A submenu parent item. Opens on click, ArrowRight, or Enter/Space; + * ArrowLeft inside the submenu closes it and refocuses this item. + */ +export function MenuSubmenu({ label, children }: MenuSubmenuProps) { + const [open, setOpen] = useState(false); + const itemRef = useRef(null); + const itemId = useId(); + + // APG: activating the parent opens the submenu and focuses its first + // item. Open-only, never a toggle: hover may already have opened the + // submenu, and a toggling click would close it again under the pointer. + const openAndFocusFirst = () => { + setOpen(true); + requestAnimationFrame(() => { + const first = itemRef.current + ?.closest('[data-submenu-parent]') + ?.querySelector('.qh-submenu [role="menuitem"]'); + first?.focus(); + }); + }; + + return ( +
setOpen(true)} + onMouseLeave={(e) => { + // Don't collapse under the keyboard user: if focus is inside this + // submenu, the mouse leaving must not drop the focused subtree. + if (e.currentTarget.contains(document.activeElement)) return; + setOpen(false); + }} + > + + {open && ( +
{ + if (e.key === 'ArrowLeft') { + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + itemRef.current?.focus(); + } + }} + > + {children} +
+ )} +
+ ); +} diff --git a/hub-client/src/components/MinimalHeader.css b/hub-client/src/components/MinimalHeader.css index f74211cfa..a88cd83de 100644 --- a/hub-client/src/components/MinimalHeader.css +++ b/hub-client/src/components/MinimalHeader.css @@ -17,24 +17,14 @@ gap: 6px; } -/* Shared icon action button (switch / share / preview). */ -.minimal-header .icon-btn { - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - background: none; - border: 1px solid transparent; - border-radius: 6px; +/* Header icon actions (switch / share) use the shared .qh-icon-btn.boxed + system from ui.css; only the header-context colors are overridden here. */ +.minimal-header .qh-icon-btn { color: var(--editor-text-muted); - cursor: pointer; - flex-shrink: 0; transition: background 0.15s, color 0.15s, border-color 0.15s; } -.minimal-header .icon-btn:hover { +.minimal-header .qh-icon-btn:hover { background: var(--view-toggle-hover-bg); color: var(--editor-text); } @@ -94,7 +84,7 @@ } .header-doc .file-path { - font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 13px; color: var(--editor-text-dim); min-width: 0; @@ -127,6 +117,12 @@ user-select: none; } +/* Focusable for its tooltip — needs a visible indicator (WCAG 2.4.7). */ +.header-right .connection-indicator:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .header-right .connection-indicator.online { border-color: var(--editor-success); } diff --git a/hub-client/src/components/MinimalHeader.tsx b/hub-client/src/components/MinimalHeader.tsx index 9b7893ffb..08ccf0fd9 100644 --- a/hub-client/src/components/MinimalHeader.tsx +++ b/hub-client/src/components/MinimalHeader.tsx @@ -6,6 +6,8 @@ */ import ViewToggleControl from './ViewToggleControl'; +import { SwitchIcon, ShareIcon, PreviewIcon } from './icons'; +import Tooltip from './Tooltip'; import './MinimalHeader.css'; interface MinimalHeaderProps { @@ -20,73 +22,6 @@ interface MinimalHeaderProps { isOnline?: boolean; } -/** Grid of four squares — "switch / all projects". */ -function SwitchIcon() { - return ( - - ); -} - -/** Connected nodes — "share". */ -function ShareIcon() { - return ( - - ); -} - -/** Outward corners — "fullscreen preview". */ -function PreviewIcon() { - return ( - - ); -} - export default function MinimalHeader({ currentFilePath, projectName, @@ -99,23 +34,25 @@ export default function MinimalHeader({ return (
- - {onShare && ( + + + {onShare && ( + + + )}
-
- - {isOnline ? 'Online' : 'Offline'} -
+
+
+ {onToggleFullscreenPreview && !isFullscreenPreview && ( - + + + )}
diff --git a/hub-client/src/components/ModalDialog.tsx b/hub-client/src/components/ModalDialog.tsx index d993cff9a..b21642c03 100644 --- a/hub-client/src/components/ModalDialog.tsx +++ b/hub-client/src/components/ModalDialog.tsx @@ -45,14 +45,30 @@ export default function ModalDialog({ const dialogRef = useRef(null); // Capture the element focused when the dialog opens and return focus - // to it when the dialog closes (unmounts). + // to it when the dialog closes (unmounts). The capture must happen + // during render, not in an effect: an autoFocus child takes focus + // during commit, before effects run, and would otherwise be captured + // as the restore target (and it's detached by unmount, so focus would + // be lost to ). const restoreFocusTo = useRef(null); - useEffect(() => { + if (restoreFocusTo.current === null && typeof document !== 'undefined') { restoreFocusTo.current = document.activeElement; + } + useEffect(() => { + const restoreTo = restoreFocusTo.current; return () => { - if (restoreFocusTo.current instanceof HTMLElement) { - restoreFocusTo.current.focus(); + if (!(restoreTo instanceof HTMLElement) || !restoreTo.isConnected) { + return; } + // Defer past the commit: StrictMode's mount-time double-effect runs + // this cleanup while the dialog stays in the DOM (and the autoFocus + // child has just taken focus) — restoring there would steal focus + // straight back. A real unmount removes the dialog, so by microtask + // time the ref is detached and the restore fires only then. + queueMicrotask(() => { + if (dialogRef.current?.isConnected) return; + restoreTo.focus(); + }); }; }, []); diff --git a/hub-client/src/components/OutlinePanel.tsx b/hub-client/src/components/OutlinePanel.tsx index 358b36b6d..5804f1500 100644 --- a/hub-client/src/components/OutlinePanel.tsx +++ b/hub-client/src/components/OutlinePanel.tsx @@ -8,6 +8,7 @@ */ import { useState, useCallback } from 'react'; +import Tooltip from './Tooltip'; import type { Symbol, SymbolKind } from '@quarto/preview-renderer/types/intelligence'; import type { ThumbnailMap } from '../hooks/useSectionThumbnails'; import './OutlinePanel.css'; @@ -106,17 +107,18 @@ function SymbolTree({ {isCollapsed ? '▶' : '▼'} )} - + + {thumbnail && ( - - - - - -); +/** Fork glyph for the duplicate affordance. */ +const forkIcon = ; /** Magnifying glass for the hover-to-peek affordance. */ -const peekIcon = ( - -); +const peekIcon = ; /** Base64-encode without blowing the arg-spread limit on large files. */ function toBase64(bytes: Uint8Array): string { @@ -263,7 +255,7 @@ export default function ProjectsHome({ // Menus / popovers. openMenu identifies the ⋯ menu by project id or // `collection:`; submenus and the peek popover are tracked separately. const [openMenu, setOpenMenu] = useState(null); - const [moveSubmenuOpen, setMoveSubmenuOpen] = useState(false); + const [newMenuOpen, setNewMenuOpen] = useState(false); const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); const [peekFor, setPeekFor] = useState(null); @@ -463,7 +455,6 @@ export default function ProjectsHome({ const closeAllMenus = useCallback(() => { setOpenMenu(null); - setMoveSubmenuOpen(false); setNewMenuOpen(false); setAvatarMenuOpen(false); setPeekFor(null); @@ -1025,11 +1016,17 @@ export default function ProjectsHome({ return ( {shown.map((u, i) => ( - - {u.initials} - + + + {u.initials} + + ))} - {extra > 0 && +{extra}} + {extra > 0 && ( + + +{extra} + + )} ); }; @@ -1046,117 +1043,89 @@ export default function ProjectsHome({ ); } - // Menus are plain groups of buttons, not ARIA menus: role="menu" - // would require menuitem children and the full menu keyboard pattern, - // which these action lists don't implement (WCAG 4.1.2). + // The shared APG menu primitive (components/Menu.tsx): arrow-key nav, + // type-ahead, Escape with focus return, submenus. const renderProjectMenu = (item: ProjectItem) => ( -
- -
- - {moveSubmenuOpen === 'move' && ( -
- {collections - .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) - .map((collection) => ( - - ))} - {collectionOf(item.indexDocId) && ( - - )} - -
+ {collection.name} + + ))} + {collectionOf(item.indexDocId) && ( + { requestMove(item.indexDocId, null); closeAllMenus(); }}> + No collection + )} -
-
- - {moveSubmenuOpen === 'add' && ( -
- {collections - .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) - .map((collection) => ( - - ))} - {collections.every((c) => c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) && ( -
- Already in every collection -
- )} + openNewCollection(item.indexDocId)}> + + New collection… + + + + {collections + .filter((c) => !c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) + .map((collection) => ( + { addToCollection(item.indexDocId, collection.id); closeAllMenus(); }} + > + {collection.name} + + ))} + {collections.every((c) => c.projectIds.includes(item.indexDocId.replace(/^automerge:/, ''))) && ( +
+ Already in every collection
)} -
- - - - - -
- -
+ + ); /** Refresh a peek summary via a short background connection. Contributors @@ -1300,7 +1269,9 @@ export default function ProjectsHome({
INVITE BY LINK
- {inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}… + + {inviteUrl.replace(/^https?:\/\//, '').slice(0, 34)}… + {peekFor === item.indexDocId && renderPeek(item)} - + + + + ); })()} - + + + {openMenu === sortMenuKey && ( -
+ closeAllMenus()} ignoreOutsideSelector=".qh-menu-anchor, .qh-peek" aria-label="Sort collection"> {(['newest', 'oldest', 'name'] as SortOrder[]).map((o) => ( - + ))} -
+ )}
- + + + {membersFor === collection.id && renderMembersPopover(collection)} {openMenu === menuKey && ( -
- - -
- -
+ + )}
{collectionItems.length === 0 ? ( @@ -1520,24 +1487,28 @@ export default function ProjectsHome({ ) : (
{page > 0 && ( - + + + )}
{pageItems.map(renderCard)}
{page < pageCount - 1 ? ( - + + + ) : pageCount > 1 ? (
{page + 1}/{pageCount} @@ -1580,18 +1551,17 @@ export default function ProjectsHome({ + New ▾ {newMenuOpen && ( -
-
START FROM — QUARTO PROJECT TYPES
+ setNewMenuOpen(false)} ignoreOutsideSelector=".qh-menu-anchor" aria-label="New project"> + START FROM — QUARTO PROJECT TYPES {(projectChoices.length > 0 ? projectChoices : [{ id: 'default', name: 'Default', description: 'A minimal Quarto project' }] ).map((choice) => ( - + openNewDialog(choice)}> + {choice.name} + ))} -
+ )}
@@ -1599,7 +1569,7 @@ export default function ProjectsHome({ className="qh-avatar" style={authPicture ? undefined : { backgroundColor: userSettings?.userColor ?? 'var(--posit-blue)' }} onClick={() => setAvatarMenuOpen((v) => !v)} - title={userSettings?.userName} + aria-label={userSettings?.userName ? `Account: ${userSettings.userName}` : 'Account'} > {authPicture ? ( @@ -1650,13 +1620,14 @@ export default function ProjectsHome({
CURSOR COLOR
{COLOR_PALETTE.map((color) => ( -
@@ -1725,17 +1696,17 @@ export default function ProjectsHome({ Sort {sortMenuOpen && ( -
+ setSortMenuOpen(false)} ignoreOutsideSelector=".qh-menu-anchor" aria-label="Sort projects"> {(['newest', 'oldest', 'name'] as SortOrder[]).map((o) => ( - + ))} -
+ )}
{everythingElse.length === 0 ? ( @@ -1754,14 +1725,12 @@ export default function ProjectsHome({ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); - setMoveSubmenuOpen(false); setOpenMenu(openMenu === item.indexDocId ? null : item.indexDocId); }} > @@ -1779,27 +1748,29 @@ export default function ProjectsHome({ > {peekFor === item.indexDocId && renderPeek(item)} - + + + - -
- -
-
+
+
+ + +
+ + )} {/* New collection */} {newCollectionDialog && ( -
setNewCollectionDialog(null)}> -
e.stopPropagation()}> -

New collection

- {newCollectionDialog.forProject && ( -

The project will be moved onto it.

- )} -
{ e.preventDefault(); commitNewCollection(); }}> + setNewCollectionDialog(null)} + className="qh-form-dialog" + > + { e.preventDefault(); commitNewCollection(); }}> +
+ {newCollectionDialog.forProject && ( +

The project will be moved onto it.

+ )} -
- - -
- -
-
+
+
+ + +
+ + )} {/* Rename collection */} {renameCollectionTarget && ( -
setRenameCollectionTarget(null)}> -
e.stopPropagation()}> -

Rename collection

-

Renames it for everyone subscribed to it.

-
{ - e.preventDefault(); - if (renameCollectionValue.trim()) { - onRenameCollection?.(renameCollectionTarget.id, renameCollectionValue.trim()); - } - setRenameCollectionTarget(null); - }} - > + setRenameCollectionTarget(null)} + className="qh-form-dialog" + > + { + e.preventDefault(); + if (renameCollectionValue.trim()) { + onRenameCollection?.(renameCollectionTarget.id, renameCollectionValue.trim()); + } + setRenameCollectionTarget(null); + }} + > +
+

Renames it for everyone subscribed to it.

setRenameCollectionValue(e.target.value)} autoFocus /> -
- - -
- -
-
+
+
+ + +
+ + )} {/* Generic destructive confirmation */} {confirmState && ( -
setConfirmState(null)}> -
e.stopPropagation()}> -

{confirmState.title}

+ setConfirmState(null)} + className="qh-form-dialog" + > +

{confirmState.body}

-
- - -
-
+
+ + +
+ )} {/* Shared-collection move warning */} {pendingMove && ( -
setPendingMove(null)}> -
e.stopPropagation()}> -

Move "{pendingMove.name}" out of {pendingMove.fromName}?

+ setPendingMove(null)} + className="qh-form-dialog" + > +

Please note you're changing {pendingMove.othersCount === 1 ? "another person's" @@ -1961,33 +1947,36 @@ export default function ProjectsHome({ /> Don't show this again -

- - -
-
+
+ + +
+ )} {/* Rename dialog */} {renameFor && ( -
setRenameFor(null)}> -
e.stopPropagation()}> -

Rename project

-
{ e.preventDefault(); commitRename(); }}> + setRenameFor(null)} + className="qh-form-dialog" + > + { e.preventDefault(); commitRename(); }}> +
-
- - -
- -
-
+
+
+ + +
+ + )} {/* New project dialog */} {newDialogChoice && ( -
setNewDialogChoice(null)}> -
e.stopPropagation()}> -

New {newDialogChoice.name.toLowerCase()}

-

Starter files will be created for you

- {formError &&
{formError}
} -
+ setNewDialogChoice(null)} + className="qh-form-dialog" + > + +
+

Starter files will be created for you

+ {formError &&
{formError}
} setShowServerField(true)}>Change…
)} -
- - -
- -
-
+
+
+ + +
+ + )} {/* Connect / Import dialog */} {addDialogOpen && ( -
setAddDialogOpen(false)}> -
e.stopPropagation()}> -

Add an existing project

+ setAddDialogOpen(false)} + className="qh-form-dialog wide" + > +
@@ -2148,7 +2143,7 @@ export default function ProjectsHome({ onChange={(e) => setImportTitle(e.target.value)} placeholder="My imported project" /> -
+
-
+ )}
- - {__GIT_COMMIT_HASH__} - + + + {__GIT_COMMIT_HASH__} + + collections UI exploration
diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 7a2c64a0a..04360d688 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -116,7 +116,7 @@ .replay-drawer__position { font-size: 11px; color: var(--editor-text-muted); - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-family: var(--font-mono); } .replay-drawer__actor { @@ -125,7 +125,7 @@ gap: 4px; font-size: 11px; color: var(--editor-text-dim); - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-family: var(--font-mono); border: 1px solid var(--replay-actor-border); border-radius: 3px; padding: 0 4px; @@ -207,7 +207,7 @@ width: auto; padding: 0 6px; font-size: 10px; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + font-family: var(--font-mono); margin-left: 3px; } diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 99e966c23..9f664c172 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { render, screen, fireEvent, act, cleanup } from '@testing-library/react'; import ReplayDrawer from './ReplayDrawer'; import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; @@ -384,7 +384,7 @@ describe('ReplayDrawer', () => { expect(pill.getAttribute('aria-busy')).toBe('true'); }); - it('title text follows the on/off state when enabled', () => { + it('tooltip text follows the on/off state when enabled', () => { const { rerender } = render( { onAttributionChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Show authors overlay'); + const pill = screen.getByLabelText(/Authors/); + fireEvent.focus(pill); + expect(screen.getByRole('tooltip').textContent).toBe('Show authors overlay'); + fireEvent.blur(pill); rerender( { onAttributionChange={vi.fn()} />, ); - expect(screen.getByLabelText(/Authors/).getAttribute('title')).toBe('Hide authors overlay'); + fireEvent.focus(screen.getByLabelText(/Authors/)); + expect(screen.getByRole('tooltip').textContent).toBe('Hide authors overlay'); }); - it('renders disabled with an explanatory title when attributionDisabled is true', () => { + it('renders disabled with an explanatory tooltip when attributionDisabled is true', () => { render( { ); const pill = screen.getByLabelText(/unavailable/); expect((pill as HTMLButtonElement).disabled).toBe(true); - expect(pill.getAttribute('title')).toMatch(/not available for this format/); + // Disabled buttons swallow pointer events, so the tooltip's hover + // handlers live on its wrapper span. Hover shows after a 400ms delay. + vi.useFakeTimers(); + fireEvent.mouseOver(pill.parentElement!); + act(() => vi.advanceTimersByTime(450)); + expect(screen.getByRole('tooltip').textContent).toMatch(/not available for this format/); + vi.useRealTimers(); }); it('suppresses on/generating modifier classes while disabled', () => { diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index ccbda1e3f..cae7e8111 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -3,6 +3,8 @@ import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; import { actorColor } from '../utils/palette'; import type { ActorIdentity } from '@quarto/preview-runtime'; import { getActorId } from '@quarto/preview-runtime'; +import { CommentsExpandIcon, CommentsShowIcon, CommentsHideIcon } from './icons'; +import Tooltip from './Tooltip'; import './ReplayDrawer.css'; import './ViewToggleControl.css'; @@ -75,12 +77,6 @@ function CommentsModeToggle({ /** Outstanding-comment count; badge hidden when 0 or absent. */ count?: number; }) { - // Speech-bubble outline shared by the show/hide icons; the expand - // icon is the same bubble with a taller body. - const bubblePath = - 'M1 0 h10 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 H5 L2 10 V7 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z'; - const tallBubblePath = - 'M1 0 h10 a1 1 0 0 1 1 1 v6 a1 1 0 0 1 -1 1 H5 L2 10 V8 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z'; return (
- - - + + + + + + + + + {count !== undefined && count > 0 && ( {count} @@ -165,22 +156,23 @@ function AttributionToggle({ attributionOn, onAttributionChange, generating, dis ? 'Authors overlay unavailable for this format' : `Authors overlay ${attributionOn ? 'on' : 'off'}`; return ( - + + + ); } @@ -326,7 +318,6 @@ export default function ReplayDrawer({ className="replay-drawer__toggle" onClick={disabled ? undefined : controls.enter} disabled={disabled} - title={disabled ? 'Replay is not available for binary files' : undefined} > Replay diff --git a/hub-client/src/components/ShareDialog.css b/hub-client/src/components/ShareDialog.css index 70580432f..658102109 100644 --- a/hub-client/src/components/ShareDialog.css +++ b/hub-client/src/components/ShareDialog.css @@ -53,7 +53,7 @@ .url-field input { color: var(--editor-text-muted); - font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 13px; cursor: text; } diff --git a/hub-client/src/components/Toast.css b/hub-client/src/components/Toast.css deleted file mode 100644 index 1f5adc701..000000000 --- a/hub-client/src/components/Toast.css +++ /dev/null @@ -1,26 +0,0 @@ -.toast { - position: fixed; - bottom: 20px; - left: 50%; - transform: translateX(-50%); - background: var(--toast-bg); - color: var(--toast-text); - padding: 10px 20px; - border-radius: 6px; - font-size: 14px; - font-family: system-ui, -apple-system, sans-serif; - box-shadow: 0 4px 12px var(--toast-shadow); - z-index: var(--z-toast); - animation: toast-fade-in 0.2s ease-out; -} - -@keyframes toast-fade-in { - from { - opacity: 0; - transform: translateX(-50%) translateY(10px); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } -} diff --git a/hub-client/src/components/Toast.tsx b/hub-client/src/components/Toast.tsx index 90e36cbfb..dc208987a 100644 --- a/hub-client/src/components/Toast.tsx +++ b/hub-client/src/components/Toast.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import './Toast.css'; +import './notifications.css'; interface ToastProps { message: string; diff --git a/hub-client/src/components/Tooltip.css b/hub-client/src/components/Tooltip.css new file mode 100644 index 000000000..6d92e844f --- /dev/null +++ b/hub-client/src/components/Tooltip.css @@ -0,0 +1,31 @@ +/* Tooltip — token-styled, non-interactive (APG tooltip pattern). + Rendered in a portal on document.body with fixed positioning computed + in Tooltip.tsx (flip + clamp), so overflow containers never clip it. */ + +.qh-tooltip-anchor { + display: inline-flex; +} + +/* Block-level anchors (file rows, cards) need the anchor to not disturb + flex/grid layout. */ +.qh-tooltip-anchor.qh-tooltip-anchor-block { + display: contents; +} + +.qh-tooltip { + /* top/left are set inline from measured coordinates; the bubble is + visibility:hidden until then. */ + position: fixed; + z-index: var(--z-toast); + padding: var(--space-1) var(--space-2); + background: var(--text-primary); + color: var(--bg-modal); + font-size: var(--text-xs); + font-weight: var(--font-weight-normal); + line-height: var(--leading-tight); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-2); + width: max-content; + max-width: 300px; + pointer-events: none; +} diff --git a/hub-client/src/components/Tooltip.tsx b/hub-client/src/components/Tooltip.tsx new file mode 100644 index 000000000..bf8a66ea2 --- /dev/null +++ b/hub-client/src/components/Tooltip.tsx @@ -0,0 +1,165 @@ +/** + * Tooltip — the single tooltip primitive for hub-client, replacing native + * `title` attributes (which are unstyled, delay-less, and inaccessible to + * touch/keyboard users). + * + * Behavior (WAI-ARIA APG tooltip pattern): + * - Shows after a 400ms hover delay; immediately on keyboard focus + * - Wired via `aria-describedby` on the wrapped element + * - Escape dismisses; hides on pointer-leave and blur + * - Sits below the anchor (the native title-tooltip position); flips + * above only when it would overflow the viewport bottom, and clamps + * horizontally inside the viewport + * - Non-interactive content only — never put focusable elements inside + * + * The bubble renders in a portal on document.body with fixed positioning, + * so it is never clipped by overflow containers (the sidebar is + * overflow-hidden and would cut off an in-place bubble — native title + * tooltips never clipped because the OS drew them). + * + * Usage: wrap a single element that accepts aria-describedby and event + * handlers (a DOM element or a component forwarding them): + * + * - - + + + + + + + + +
); } diff --git a/hub-client/src/components/debug-inspector/DebugInspectorPanel.css b/hub-client/src/components/debug-inspector/DebugInspectorPanel.css index 69f2bfad5..c7423f141 100644 --- a/hub-client/src/components/debug-inspector/DebugInspectorPanel.css +++ b/hub-client/src/components/debug-inspector/DebugInspectorPanel.css @@ -19,7 +19,7 @@ color: var(--debug-text); border-top: 2px solid var(--debug-blue); box-shadow: 0 -4px 24px var(--alpha-black-45); - font-family: 'Source Code Pro', ui-monospace, monospace; + font-family: var(--font-mono); font-size: 12px; } diff --git a/hub-client/src/components/icons.tsx b/hub-client/src/components/icons.tsx new file mode 100644 index 000000000..3bc408dd1 --- /dev/null +++ b/hub-client/src/components/icons.tsx @@ -0,0 +1,261 @@ +/** + * Shared icon module — the single source for UI icons in hub-client. + * + * Contract: + * - Icons are **decorative**: every icon renders `aria-hidden="true"`. + * Meaning is conveyed by the wrapping control's `aria-label` (icon-only + * buttons) or by visible text next to the icon. Never give an icon its + * own accessible name. + * - One visual style: 24×24 viewBox, `currentColor` stroke, stroke-width 2, + * round caps/joins (the Lucide/Feather style already in use). + * - Sizes come from the `size` prop (default 16). Use 16 for toolbar/row + * icons, 13 for compact list affordances, 12×10 layout glyphs are the + * fixed-size exception (view-toggle and comments-mode pictograms). + * - Color is always `currentColor` — never hardcode a fill/stroke color; + * the parent control's CSS owns color. + * + * When adding an icon: follow the style above, give it a doc comment + * naming the concept it represents, and render it in the DevHarness + * gallery (`#/dev/gallery`) so states are covered by visual baselines. + */ + +import type { ReactNode } from 'react'; + +export interface IconProps { + /** Width/height in px. Default 16. */ + size?: number; +} + +interface StrokeIconProps extends IconProps { + children: ReactNode; + /** Override stroke width (default 2). */ + strokeWidth?: number; +} + +/** Shared wrapper for the standard 24×24 stroke-icon style. */ +function StrokeIcon({ size = 16, strokeWidth = 2, children }: StrokeIconProps) { + return ( + + ); +} + +/** Document with a plus — "new file". */ +export function FilePlusIcon({ size }: IconProps) { + return ( + + + + + + + ); +} + +/** Arrow rising out of a tray — "upload". */ +export function UploadIcon({ size }: IconProps) { + return ( + + + + + + ); +} + +/** Printer — "open printable version". */ +export function PrintIcon({ size }: IconProps) { + return ( + + + + + + ); +} + +/** Grid of four squares — "switch / all projects". */ +export function SwitchIcon({ size }: IconProps) { + return ( + + + + + + + ); +} + +/** Connected nodes — "share". */ +export function ShareIcon({ size }: IconProps) { + return ( + + + + + + + + ); +} + +/** Outward corners — "fullscreen preview". */ +export function PreviewIcon({ size }: IconProps) { + return ( + + + + + + + ); +} + +/** Three nodes with branch lines — "duplicate / fork". */ +export function ForkIcon({ size = 13 }: IconProps) { + return ( + + + + + + + ); +} + +/** Magnifying glass — "peek / preview on hover". */ +export function PeekIcon({ size = 13 }: IconProps) { + return ( + + + + + ); +} + +/** Two people — "collection members". */ +export function PeopleIcon({ size = 12 }: IconProps) { + return ( + + + + + + + ); +} + +/** Opposing vertical arrows — "sort order". */ +export function SortIcon({ size = 13 }: IconProps) { + return ( + + + + + ); +} + +/** Horizontal ellipsis — "more actions" (kebab/menu affordance). */ +export function MoreIcon({ size = 16 }: IconProps) { + return ( + + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Layout pictograms (fixed 12×10 viewBox) — view-toggle glyphs. */ +/* ------------------------------------------------------------------ */ + +interface LayoutGlyphProps { + children: ReactNode; +} + +function LayoutGlyph({ children }: LayoutGlyphProps) { + return ( + + ); +} + +/** Wide left pane, dim right pane — "expand markup". */ +export function LayoutMarkupIcon() { + return ( + + + + + ); +} + +/** Two equal panes — "split equally". */ +export function LayoutSplitIcon() { + return ( + + + + + ); +} + +/** Dim left pane, wide right pane — "expand preview". */ +export function LayoutPreviewIcon() { + return ( + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Comments-mode pictograms (fixed 12×10 viewBox) — replay drawer. */ +/* ------------------------------------------------------------------ */ + +const BUBBLE_PATH = + 'M1 0 h10 a1 1 0 0 1 1 1 v5 a1 1 0 0 1 -1 1 H5 L2 10 V7 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z'; +const TALL_BUBBLE_PATH = + 'M1 0 h10 a1 1 0 0 1 1 1 v6 a1 1 0 0 1 -1 1 H5 L2 10 V8 H1 a1 1 0 0 1 -1 -1 V1 a1 1 0 0 1 1 -1 Z'; + +/** Tall comment bubble — "expand all comments". */ +export function CommentsExpandIcon() { + return ( + + + + ); +} + +/** Comment bubble — "show comment bubbles". */ +export function CommentsShowIcon() { + return ( + + + + ); +} + +/** Dim comment bubble with strike-through — "hide comment bubbles". */ +export function CommentsHideIcon() { + return ( + + + + + ); +} diff --git a/hub-client/src/components/notifications.css b/hub-client/src/components/notifications.css new file mode 100644 index 000000000..348362b14 --- /dev/null +++ b/hub-client/src/components/notifications.css @@ -0,0 +1,119 @@ +/* ---- notification system ---- + One notification model, three tiers. Pick by how long the information + must live, not by which component happens to render it: + + 1. Transient status (.toast) — brief confirmation feedback ("Auto-saved"). + Auto-dismisses after ~2s, role="status", bottom-center. No actions, + no dismiss button: it is gone before the user needs one. + 2. Dismissible persistent (.update-available-toast) — something the user + must be able to act on or explicitly dismiss (the SW update prompt). + Stays until acted on or dismissed, bottom-right (must not read as + transient), role="status", always has a labelled dismiss button. + 3. Session banner (.ephemeral-session-banner) — a mode indicator that is + true for the whole session (ephemeral edits). Inline at the top of + the layout, warning-tinted, never dismissible: dismissing a mode + indicator would misrepresent the session. + + Placement rules: floating notifications stack from the bottom edge — + transient centered, persistent in the corner (persistent wins the + corner so the transient never covers its actions). Banners are in + flow, above the content they describe. */ + +.toast { + position: fixed; + bottom: 20px; + /* Direction-agnostic horizontal centering (no physical left/translate). */ + inset-inline: 0; + margin-inline: auto; + width: max-content; + max-width: calc(100vw - 40px); + background: var(--toast-bg); + color: var(--toast-text); + padding: 10px 20px; + border-radius: var(--radius-sm); + font-size: var(--text-md); + box-shadow: 0 4px 12px var(--toast-shadow); + z-index: var(--z-toast); + animation: toast-fade-in 0.2s ease-out; +} + +@keyframes toast-fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Tier 2: persistent. Sits in the corner and stays put; above the + transient tier so the two never overlap. */ +.update-available-toast { + position: fixed; + bottom: 20px; + inset-inline-end: 20px; + display: flex; + align-items: center; + gap: 12px; + background: var(--toast-bg); + color: var(--toast-text); + padding: 10px 12px 10px 16px; + border-radius: var(--radius-md); + font-size: var(--text-md); + box-shadow: 0 4px 12px var(--toast-shadow); + z-index: var(--z-max); + animation: update-available-toast-in 0.2s ease-out; +} + +.update-available-toast-dismiss { + background: none; + border: none; + padding: 4px 6px; + font-size: 16px; + line-height: 1; + color: inherit; + opacity: 0.7; + cursor: pointer; +} + +.update-available-toast-dismiss:hover { + opacity: 1; +} + +.update-available-toast-dismiss:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); + opacity: 1; +} + +@keyframes update-available-toast-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Tier 3: session banner. In flow at the top of the editor layout; + `q2 preview --ui editor` without --allow-edit. */ +.ephemeral-session-banner { + background: var(--warning-bg); + color: var(--warning-text); + border-bottom: 1px solid var(--warning-border); + padding: 4px 16px; + font-size: var(--text-sm); + text-align: center; + flex-shrink: 0; + cursor: help; +} + +/* Focusable for its tooltip — needs a visible indicator (WCAG 2.4.7). */ +.ephemeral-session-banner:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} diff --git a/hub-client/src/components/tabs/AboutTab.css b/hub-client/src/components/tabs/AboutTab.css index 5d5821b18..30606cd38 100644 --- a/hub-client/src/components/tabs/AboutTab.css +++ b/hub-client/src/components/tabs/AboutTab.css @@ -91,12 +91,18 @@ } .commit-hash { - font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 13px; color: var(--accent-secondary); cursor: help; } +/* Focusable for its tooltip — needs a visible indicator (WCAG 2.4.7). */ +.commit-hash:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + /* Changelog Modal */ .changelog-modal-overlay { position: fixed; diff --git a/hub-client/src/components/tabs/AboutTab.tsx b/hub-client/src/components/tabs/AboutTab.tsx index c8706d3cc..609bd7950 100644 --- a/hub-client/src/components/tabs/AboutTab.tsx +++ b/hub-client/src/components/tabs/AboutTab.tsx @@ -8,6 +8,7 @@ */ import { useState, useEffect } from 'react'; +import Tooltip from '../Tooltip'; import { renderContentToHtml, isWasmReady } from '@quarto/preview-runtime'; import changelogMd from '../../../changelog.md?raw'; import moreInfoMd from '../../../resources/more-info.md?raw'; @@ -166,12 +167,11 @@ export default function AboutTab({ wasmStatus }: AboutTabProps) {
commit - - {__GIT_COMMIT_HASH__} - + + + {__GIT_COMMIT_HASH__} + +
diff --git a/hub-client/src/components/tabs/ProjectTab.css b/hub-client/src/components/tabs/ProjectTab.css index 707d2d6d2..6ed2a2a7d 100644 --- a/hub-client/src/components/tabs/ProjectTab.css +++ b/hub-client/src/components/tabs/ProjectTab.css @@ -45,7 +45,7 @@ } .doc-id-value { - font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 12px; color: var(--editor-text-dim); } @@ -64,7 +64,7 @@ } .sync-server { - font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--font-mono); font-size: 12px; color: var(--editor-text-muted); word-break: break-all; diff --git a/hub-client/src/components/tabs/ProjectTab.tsx b/hub-client/src/components/tabs/ProjectTab.tsx index 25a2c8b89..af0e746d1 100644 --- a/hub-client/src/components/tabs/ProjectTab.tsx +++ b/hub-client/src/components/tabs/ProjectTab.tsx @@ -12,6 +12,7 @@ */ import { useState, useCallback } from 'react'; +import Tooltip from '../Tooltip'; import html2canvas from 'html2canvas'; import { projectFolderName } from '@quarto/quarto-sync-client'; import type { ProjectEntry } from '@quarto/preview-renderer/types/project'; @@ -119,14 +120,15 @@ export default function ProjectTab({ project, onExportZip }: ProjectTabProps) {
- + +
diff --git a/hub-client/src/components/tabs/SettingsTab.css b/hub-client/src/components/tabs/SettingsTab.css index 52bb4a3e8..d0b1e552a 100644 --- a/hub-client/src/components/tabs/SettingsTab.css +++ b/hub-client/src/components/tabs/SettingsTab.css @@ -33,6 +33,13 @@ pointer-events: none; } +/* The native checkbox is invisible; the focus ring lands on the custom + box so keyboard users can see focus (WCAG 2.4.7). */ +.setting-toggle input[type="checkbox"]:focus-visible + .setting-name::before { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .setting-name { font-size: 14px; font-weight: 500; diff --git a/hub-client/src/theme.css b/hub-client/src/theme.css index e2813e19a..7836fa954 100644 --- a/hub-client/src/theme.css +++ b/hub-client/src/theme.css @@ -164,8 +164,8 @@ --font-weight-bold: 700; --leading-tight: 1.25; --leading-base: 1.5; - /* The dominant monospace stack (4 of 10 use sites; the rest migrate in - Phase 1's monospace unification). */ + /* The single monospace stack — all app surfaces migrated in Phase 1. + (src/debug/debug.css is a standalone page with its own local token.) */ --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; /* Motion — 100-200ms, ease-out entrances; animate transform/opacity diff --git a/hub-client/src/ui.css b/hub-client/src/ui.css index 7155fb662..9ce6db3d1 100644 --- a/hub-client/src/ui.css +++ b/hub-client/src/ui.css @@ -36,11 +36,28 @@ border-inline-start-color: var(--sidebar-active-accent); } -/* ---- buttons ---- */ +/* ---- buttons ---- + One button system, two sizes: + - .qh-btn default text button; variants: .primary .outline + .danger .ghost-accent + - .qh-btn.small compact text button + - .qh-icon-btn icon-only button (inline-size fit); .boxed gives a + fixed 28x28 target (header chrome) + - .qh-link inline link-styled action + States are defined once on the base class (hover per variant, + disabled and focus-visible shared). Deliberately NOT part of this + system: .view-toggle-btn (a segmented-control member with an active + state, not a button look), .qh-pager (a vertical navigation strip), + and .minimal-header .preview-btn (the header's filled primary pill, + on header-specific tokens). Radii/paddings stay on their current + values until the Phase 5 grid-alignment pass. */ .qh-btn { - font-size: 13px; - font-weight: 600; + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); + /* Buttons don't inherit color (UA stylesheet) — the base must declare + it, or bare .qh-btn renders black-on-dark in the dark theme. */ + color: var(--text-primary); padding: 8px 14px; border-radius: 7px; border: 1px solid transparent; @@ -49,13 +66,19 @@ white-space: nowrap; } +.qh-btn:disabled { opacity: 0.5; cursor: default; } + +.qh-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + .qh-btn.primary { background: var(--posit-teal); color: var(--text-on-accent); } .qh-btn.primary:hover { filter: brightness(1.08); } -.qh-btn.primary:disabled { opacity: 0.5; cursor: default; } .qh-btn.outline { border-color: var(--border-color); @@ -63,7 +86,6 @@ } .qh-btn.outline:hover { background: var(--input-bg-alpha); } -.qh-btn.outline:disabled { opacity: 0.5; cursor: default; } .qh-btn.danger { background: var(--posit-red); @@ -82,8 +104,8 @@ .qh-btn.ghost-accent:hover { background: var(--posit-teal-alpha-08); } .qh-btn.small { - font-size: 12px; - font-weight: 400; + font-size: var(--text-sm); + font-weight: var(--font-weight-normal); padding: 5px 11px; border-radius: 6px; color: var(--text-primary); @@ -96,16 +118,19 @@ border: none; padding: 0; font-size: 11.5px; - font-weight: 600; + font-weight: var(--font-weight-semibold); color: var(--accent-secondary); cursor: pointer; } .qh-link:hover { text-decoration: underline; } -.qh-link.muted { color: var(--text-secondary); font-weight: 400; } +.qh-link.muted { color: var(--text-secondary); font-weight: var(--font-weight-normal); } .qh-link.danger { color: var(--error-text); } .qh-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; background: none; border: none; padding: 2px 8px; @@ -117,6 +142,22 @@ } .qh-icon-btn:hover { background: var(--input-bg-alpha); color: var(--text-primary); } +.qh-icon-btn:disabled { opacity: 0.5; cursor: default; } + +.qh-icon-btn:focus-visible { + outline: var(--focus-ring); + outline-offset: var(--focus-ring-offset); +} + +/* Fixed 28x28 target for header chrome (WCAG 2.5.8 floor is 24px). */ +.qh-icon-btn.boxed { + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + flex-shrink: 0; +} /* ---- menus ---- */ @@ -139,6 +180,16 @@ .qh-menu-right { left: auto; right: 0; } +/* Context-menu placement at cursor coordinates (fixed instead of + anchor-relative); the Menu component flips at viewport edges. */ +.qh-menu-fixed { + position: fixed; + top: auto; + left: auto; + min-width: 180px; + z-index: var(--z-modal); +} + .qh-menu-label { font-size: 10.5px; font-weight: 700; @@ -165,12 +216,12 @@ } .qh-menu-item:hover { background: var(--context-menu-hover); } +.qh-menu-item[aria-disabled="true"] { opacity: 0.5; cursor: default; } +.qh-menu-item[aria-disabled="true"]:hover { background: none; } .qh-menu-item.strong { font-weight: 600; } .qh-menu-item.accent { color: var(--posit-teal); font-weight: 600; } .qh-menu-item.danger { color: var(--error-text); } -.qh-menu-item.two-line .strong { font-weight: 600; } - .qh-menu-item.with-hint { flex-direction: row; align-items: center; @@ -260,7 +311,13 @@ margin-top: 22px; } -/* ---- forms ---- */ +/* ---- forms ---- + .qh-input is the canonical text input/select. Focus is a border-color + change (the established pattern — no additional ring). Validation: one + pattern — the input gets aria-invalid="true" and error text is wired + via aria-describedby (inline, field-level); form-level failures use + .qh-error.inline below the fields. See the gallery's Form controls + section for both. */ .qh-field-label { display: block; @@ -285,6 +342,13 @@ .qh-input:focus { border-color: var(--border-focus); } .qh-input.focus-accent:focus { border-color: var(--posit-teal); } +.qh-input:disabled { opacity: 0.5; cursor: default; } + +.qh-input[aria-invalid="true"], +.qh-input[aria-invalid="true"]:focus { + border-color: var(--error-text); +} + select.qh-input { appearance: auto; } .qh-tabs { @@ -404,6 +468,13 @@ select.qh-input { appearance: auto; } padding: 20px; } +/* ProjectsHome form dialogs on the shared ModalDialog chrome: zero the + .qh-dialog padding so the header/footer hairlines span edge to edge, + matching the editor dialogs. */ +.qh-dialog.qh-form-dialog { + padding: 0; +} + .dialog-actions { display: flex; justify-content: flex-end;