diff --git a/propr-ui/e2e/global-search-native-zoom.pw.ts b/propr-ui/e2e/global-search-native-zoom.pw.ts index 86b1e056d0..61d980ede2 100644 --- a/propr-ui/e2e/global-search-native-zoom.pw.ts +++ b/propr-ui/e2e/global-search-native-zoom.pw.ts @@ -70,6 +70,8 @@ for (const layout of layouts) { await expect(opener).toBeFocused(); // Without a query the sheet's own backdrop takes the press, with the same result. await sendSearch(page); + await expect(input).toBeVisible(); + await expect(input).toBeFocused(); await page.mouse.click(viewport.width - 4, viewport.height - 4); await expect(sheet).toHaveCount(0); await expect(opener).toBeFocused(); @@ -87,3 +89,37 @@ for (const layout of layouts) { await context.close(); }); } + +// A press can reach the sheet after it appears but before its field takes focus (the field is +// focused in an effect after the sheet commits). Closing then must still return focus to where +// the operator was, not to the field that is being removed. The press is dispatched from a +// MutationObserver so it lands in exactly that interval on every run. +test('a backdrop press as the narrow Search sheet appears still returns focus', async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 320, height: 568 }, deviceScaleFactor: 2 }); + const page = await context.newPage(); + await openPage(page); + const opener = page.locator('main').getByRole('button').first(); + await opener.focus(); + const pressedBeforeFieldFocus = await page.evaluate(event => new Promise(resolve => { + const observer = new MutationObserver(() => { + const sheet = document.querySelector('[data-testid="mobile-search-overlay"]'); + if (!sheet) return; + observer.disconnect(); + const unfocused = !sheet.contains(document.activeElement); + sheet.firstElementChild!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + resolve(unfocused); + }); + observer.observe(document.body, { childList: true, subtree: true }); + window.dispatchEvent(new CustomEvent(event, { detail: 'search', cancelable: true })); + }), DESKTOP_UI_COMMAND_EVENT); + expect(pressedBeforeFieldFocus).toBe(true); + await expect(page.getByRole('dialog', { name: 'Search' })).toHaveCount(0); + await expect(opener).toBeFocused(); + + // The next request opens a usable sheet as usual. + await sendSearch(page); + await expect(page.getByRole('dialog', { name: 'Search' }).getByRole('combobox', { name: 'Search' })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(opener).toBeFocused(); + await context.close(); +}); diff --git a/propr-ui/e2e/mobile-menu-search.fixture.ts b/propr-ui/e2e/mobile-menu-search.fixture.ts new file mode 100644 index 0000000000..4e60f5e314 --- /dev/null +++ b/propr-ui/e2e/mobile-menu-search.fixture.ts @@ -0,0 +1,74 @@ +/** A touch-only phone with global search results for long saved plans and a long task. */ +import { expect, type Browser, type Locator, type Page } from '@playwright/test'; +import { fixture, minutesAgo } from './dashboard-sections.fixture'; + +export const repository = 'example-organisation/very-long-repository-name-for-search-layout-validation-workspace'; +// Titles at the lengths real saved plans reach (~170 and ~500 characters). +export const mediumTitle = 'Make the repository settings page keep unsaved branch protection edits when the operator ' + + 'switches tabs, and show which fields still differ from the saved configuration'; +export const longTitle = 'Consolidate the release readiness checklist so that every packaging target (signed Linux ' + + 'RPM and DEB, notarised macOS DMG, Windows MSI) reports the same artefact provenance, verifies the ' + + 'update feed signature against packages/desktop/src/update/verification/release-feed-signature-policy.ts, ' + + 'records the reviewer who approved the build, links the matching changelog entry, and refuses to publish ' + + 'when any target is missing a reproducible build attestation or the smoke suite on clean hosts fails'; + +const plans = [mediumTitle, longTitle, 'Short plan about search layout'].map((name, index) => ({ + draft_id: `plan-${index}`, repository, name, initial_prompt: `Synthetic plan ${index + 1} for the layout check.`, + status: 'review', created_at: minutesAgo((index + 1) * 60), updated_at: minutesAgo((index + 1) * 60), + issue_summary: { total: 3, pending: 1, processing: 1, merged: 1, closed: 0 }, +})); +const tasks = [{ + id: 'task-1', title: longTitle, status: 'completed', prNumber: 2480, score: 9, repository, + issueNumber: 2400, model: 'gpt-6-astra', subtitle: 'Synthetic task.', createdAt: minutesAgo(3 * 24 * 60), +}]; + +export const viewports = [ + { name: '320', width: 320, height: 640 }, + { name: '390', width: 390, height: 844 }, +]; + +/** `prepare` runs before the first navigation (e.g. to install init scripts). */ +export async function openPhone(browser: Browser, viewport: { width: number; height: number }, prepare?: (page: Page) => Promise) { + const context = await browser.newContext({ viewport, hasTouch: true, isMobile: true, deviceScaleFactor: 2 }); + const page = await context.newPage(); + await prepare?.(page); + await fixture(page, viewport, [], []); + await page.route('**/api/planner/drafts?**', route => route.fulfill({ json: { drafts: plans, total: plans.length, page: 1, limit: 5, hasMore: false } })); + await page.route('**/api/tasks?**', route => route.fulfill({ json: { tasks, total: tasks.length } })); + await page.goto('/'); + await expect(page.getByRole('main')).toBeVisible(); + return { context, page }; +} + +// Wait out the sheet's rise and the scrim's fade so taps land on settled boxes. +export async function settle(page: Page) { + await page.evaluate(async () => { + const running = () => document.getAnimations().filter(animation => + animation.playState !== 'finished' && animation.playState !== 'idle' && animation.effect?.getComputedTiming().endTime !== Infinity); + for (let pending = running(); pending.length; pending = running()) await Promise.allSettled(pending.map(animation => animation.finished)); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} + +export const noDocumentOverflow = (page: Page) => page.evaluate(() => + document.documentElement.scrollWidth <= document.documentElement.clientWidth); + +/** The element really under the control's centre is the control: nothing covers it. */ +export const isHitTarget = (control: Locator) => control.evaluate(element => { + const { left, top, width, height } = element.getBoundingClientRect(); + const hit = document.elementFromPoint(left + width / 2, top + height / 2); + return Boolean(hit && element.contains(hit)); +}); + +export async function openSearchFromMenu(page: Page) { + const moreButton = page.getByRole('navigation', { name: 'Primary navigation' }).getByRole('button', { name: 'More' }); + await moreButton.tap(); + const menu = page.getByRole('dialog', { name: 'ProPR', exact: true }); + await expect(menu).toBeVisible(); + await settle(page); + await menu.getByRole('button', { name: 'Search', exact: true }).tap(); + await expect(menu).toHaveCount(0); + const sheet = page.getByRole('dialog', { name: 'Search' }); + await expect(sheet).toBeVisible(); + return { moreButton, sheet, input: sheet.getByRole('combobox', { name: 'Search' }) }; +} diff --git a/propr-ui/e2e/mobile-menu-search.pw.ts b/propr-ui/e2e/mobile-menu-search.pw.ts new file mode 100644 index 0000000000..cae6e61779 --- /dev/null +++ b/propr-ui/e2e/mobile-menu-search.pw.ts @@ -0,0 +1,104 @@ +import { expect, test } from '@playwright/test'; +import { captureTarget } from './dashboard-sections.fixture'; +import { isHitTarget, longTitle, mediumTitle, noDocumentOverflow, openPhone, openSearchFromMenu, settle, viewports } from './mobile-menu-search.fixture'; + +// Below `md` the toolbar (and its search field) is hidden, so the ProPR menu's +// Search row is the only way a touch-only operator reaches global search. +// These run in a plain browser: no desktop adapter, no keyboard to open it. + +for (const viewport of viewports) { + test(`touch opens global search from the ProPR menu at ${viewport.name}px`, async ({ browser }) => { + const { context, page } = await openPhone(browser, viewport); + expect(await page.evaluate(() => 'proprDesktop' in window)).toBe(false); + await expect(page.getByRole('banner', { name: 'Application toolbar' })).toBeHidden(); + + // The menu keeps its name, mark and destinations, with Search in its header. + const moreButton = page.getByRole('navigation', { name: 'Primary navigation' }).getByRole('button', { name: 'More' }); + await moreButton.tap(); + const menu = page.getByRole('dialog', { name: 'ProPR', exact: true }); + await settle(page); + await expect(menu.getByRole('heading', { level: 2, name: 'ProPR', exact: true }).locator('img')).toHaveAttribute('alt', ''); + for (const name of ['Goals', 'Plans', 'Automations', 'Repositories', 'Settings']) { + await expect(menu.getByRole('link', { name, exact: true })).toHaveCount(1); + } + const searchRow = menu.getByRole('button', { name: 'Search', exact: true }); + await expect(searchRow).toBeInViewport({ ratio: 1 }); + const searchBox = (await searchRow.boundingBox())!; + expect(searchBox.height).toBeGreaterThanOrEqual(44); + expect(searchBox.width).toBeGreaterThanOrEqual(44); + expect(await isHitTarget(searchRow)).toBe(true); + // Title, then Search, then Close, never overlapping. + const titleBox = (await menu.getByRole('heading', { level: 2 }).boundingBox())!; + const closeBox = (await menu.getByRole('button', { name: 'Close ProPR menu' }).boundingBox())!; + expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(searchBox.x + 0.5); + expect(searchBox.x + searchBox.width).toBeLessThanOrEqual(closeBox.x + 0.5); + // The workspace destinations stay above the fold. + await expect(menu.getByRole('link', { name: 'Repositories', exact: true })).toBeInViewport({ ratio: 1 }); + expect(await noDocumentOverflow(page)).toBe(true); + await captureTarget(page, `mobile-menu-search-button-${viewport.name}`); + await menu.getByRole('button', { name: 'Close ProPR menu' }).tap(); + await expect(menu).toHaveCount(0); + + // Search opens alone: the menu is gone, only one modal remains, the field has focus. + const { sheet, input } = await openSearchFromMenu(page); + await expect(input).toBeFocused(); + await expect(page.locator('[aria-modal="true"]')).toHaveCount(1); + expect(await page.evaluate(() => document.body.style.overflow)).toBe(''); + const close = sheet.getByRole('button', { name: 'Close search' }); + await expect(close).toBeInViewport({ ratio: 1 }); + expect(await isHitTarget(close)).toBe(true); + expect(await isHitTarget(input)).toBe(true); + await captureTarget(page, `mobile-menu-search-open-${viewport.name}`); + + // Real results: long titles in full with metadata beneath, nothing spilling sideways. + await input.fill('search'); + const palette = page.getByTestId('global-search-palette'); + const options = palette.getByRole('option'); + await expect(options).toHaveCount(4); + for (const index of [0, 1, 3]) { + const row = options.nth(index); + const title = row.getByText(index === 0 ? mediumTitle : longTitle, { exact: true }); + expect(await title.evaluate(el => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight)).toBe(true); + const titleBox = (await title.boundingBox())!; + const metaBox = (await row.getByTestId('global-search-result-meta').boundingBox())!; + expect(metaBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height - 1); + } + const paletteBox = (await palette.boundingBox())!; + expect(paletteBox.x).toBeGreaterThanOrEqual(0); + expect(paletteBox.x + paletteBox.width).toBeLessThanOrEqual(viewport.width); + await expect(palette).not.toContainText('gpt-6-astra'); + expect(await noDocumentOverflow(page)).toBe(true); + await expect(close).toBeInViewport({ ratio: 1 }); + expect(await isHitTarget(close)).toBe(true); + await captureTarget(page, `mobile-menu-search-results-${viewport.name}`); + + // Close returns focus to More; reopening works the same way every time. + await close.tap(); + await expect(sheet).toHaveCount(0); + await expect(moreButton).toBeFocused(); + await expect(page.locator('[aria-modal="true"]')).toHaveCount(0); + for (let attempt = 0; attempt < 2; attempt += 1) { + const reopened = await openSearchFromMenu(page); + await expect(reopened.input).toBeFocused(); + await expect(reopened.input).toHaveValue(''); + await page.keyboard.press('Escape'); + await expect(reopened.sheet).toHaveCount(0); + await expect(moreButton).toBeFocused(); + } + + // A tap on the long task result opens that task and closes everything. + const final = await openSearchFromMenu(page); + await final.input.fill('search'); + await expect(options).toHaveCount(4); + await options.nth(3).scrollIntoViewIfNeeded(); + await options.nth(3).tap(); + await expect(page).toHaveURL(/\/tasks\/task-1$/); + await expect(page.locator('[aria-modal="true"]')).toHaveCount(0); + expect(await noDocumentOverflow(page)).toBe(true); + + // Ctrl+K still takes the same route on the new page. + await page.keyboard.press('Control+k'); + await expect(page.getByRole('dialog', { name: 'Search' }).getByRole('combobox', { name: 'Search' })).toBeFocused(); + await context.close(); + }); +} diff --git a/propr-ui/e2e/mobile-search-keyboard-viewport.pw.ts b/propr-ui/e2e/mobile-search-keyboard-viewport.pw.ts new file mode 100644 index 0000000000..828145176a --- /dev/null +++ b/propr-ui/e2e/mobile-search-keyboard-viewport.pw.ts @@ -0,0 +1,140 @@ +/** + * The narrow Search sheet opened by touch from the ProPR menu keeps its field and Close + * search inside the visible area while an on-screen keyboard shrinks and pans the visual + * viewport, and its results scroll in their own pane above the keyboard. + */ +import { expect, test, type CDPSession, type Locator, type Page } from '@playwright/test'; +import { captureVisible, expectShown, expectUnframed, installKeyboardViewport, listenerCount, setKeyboard, type Band } from './keyboard-viewport.fixture'; +import { longTitle, openPhone, openSearchFromMenu } from './mobile-menu-search.fixture'; + +// Measured on the Android emulator in Chrome with Gboard open over the Search sheet: the +// layout viewport stays 747px tall while the visible band is 421.09px, and a swipe that the +// results could not take panned it down to 301.82px. The 320px phone keeps the same share. +const phones = [ + { name: '412', layout: { width: 412, height: 747 }, keyboard: { height: 421.09, offsetTop: 0 }, panned: { height: 421.09, offsetTop: 301.82 } }, + { name: '320', layout: { width: 320, height: 640 }, keyboard: { height: 360.75, offsetTop: 0 }, panned: { height: 360.75, offsetTop: 258.58 } }, +]; + +const innerSize = (page: Page) => page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })); + +/** The palette and its results pane lie inside the band; the pane scrolls rather than running behind the keyboard. */ +async function expectPaneAboveKeyboard(palette: Locator, list: Locator, band: Band) { + const box = (await palette.boundingBox())!; + expect(box.y, 'palette starts inside the visible area').toBeGreaterThanOrEqual(band.offsetTop - 0.5); + expect(box.y + box.height, 'palette ends above the keyboard').toBeLessThanOrEqual(band.offsetTop + band.height + 0.5); + const { clientHeight, scrollHeight } = await list.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight })); + expect(scrollHeight, 'the results scroll inside their own pane').toBeGreaterThan(clientHeight + 1); +} + +/** + * Drags the results pane with trusted touch until `target` shows inside it and the band. The + * finger stays on the pane: the palette around it clips (`overflow: hidden`) but does not + * scroll, and its footer would take a drag as a pan of the whole page. + */ +async function swipeResults(page: Page, touch: CDPSession, list: Locator, target: Locator, band: Band) { + const measure = () => target.evaluate((element, { offsetTop, height }) => { + const pane = element.closest('.overflow-y-auto')!.getBoundingClientRect(); + const low = Math.max(offsetTop, pane.top); const high = Math.min(offsetTop + height, pane.bottom); + const box = element.getBoundingClientRect(); + return { top: box.top - low, bottom: high - box.bottom, low, high }; + }, band); + for (let step = 0; step < 30; step++) { + const { top, bottom, low, high } = await measure(); + if (top >= -0.5 && bottom >= -0.5) break; + // Past Chromium's 15px touch slop, slowly enough not to fling. + const distance = (top < 0 ? 1 : -1) * Math.min(15 + Math.ceil(top < 0 ? -top : -bottom) + 1, high - low - 6); + const x = Math.round(page.viewportSize()!.width / 2); const y = (low + high) / 2; + await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y: Math.round(y - distance / 2) }] }); + for (let move = 1; move <= 8; move++) await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: Math.round(y - distance / 2 + distance * move / 8) }] }); + await page.waitForTimeout(50); + await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await list.evaluate(element => new Promise(resolve => { + let last = NaN; let still = 0; + const check = () => { still = element.scrollTop === last ? still + 1 : 0; last = element.scrollTop; if (still >= 3) resolve(); else requestAnimationFrame(check); }; + requestAnimationFrame(check); + })); + } + const { top, bottom } = await measure(); + expect(top, `${target} starts inside the results pane`).toBeGreaterThanOrEqual(-0.5); + expect(bottom, `${target} ends inside the results pane, above the keyboard`).toBeGreaterThanOrEqual(-0.5); + await expectShown([target], band); +} + +for (const phone of phones) { + test(`Search stays usable above the on-screen keyboard at ${phone.name}px`, async ({ browser }) => { + const { context, page } = await openPhone(browser, phone.layout, target => installKeyboardViewport(target, phone.layout)); + const touch = await context.newCDPSession(page); + const full: Band = { height: phone.layout.height, offsetTop: 0 }; + const idle = await listenerCount(page); + const { moreButton, sheet, input } = await openSearchFromMenu(page); + const close = sheet.getByRole('button', { name: 'Close search' }); + const palette = page.getByTestId('global-search-palette'); + // The pane that scrolls the results: the listbox's container. + const list = palette.getByRole('listbox').locator('..'); + const options = palette.getByRole('option'); + await expect(input).toBeFocused(); + + // The keyboard opens over the focused field, then the query returns long results. + await setKeyboard(page, phone.keyboard); + await input.pressSequentially('search'); + await expect(options).toHaveCount(4); + expect(await innerSize(page)).toEqual(phone.layout); + await expectShown([input, close], phone.keyboard); + await expectPaneAboveKeyboard(palette, list, phone.keyboard); + // Every title and its metadata stay whole; nothing was cut to make the list fit. + const lastTitle = options.nth(3).getByText(longTitle, { exact: true }); + expect(await lastTitle.evaluate(el => el.scrollHeight <= el.clientHeight && el.scrollWidth <= el.clientWidth)).toBe(true); + await expect(options.nth(3).getByTestId('global-search-result-meta')).toBeVisible(); + await captureVisible(page, `mobile-search-keyboard-${phone.name}`, phone.keyboard); + + // A trusted swipe scrolls the results pane to the end of the last result (taller than the + // pane, so its metadata line); the field and Close stay put. + const lastMeta = options.nth(3).getByTestId('global-search-result-meta'); + await swipeResults(page, touch, list, lastMeta, phone.keyboard); + expect(await list.evaluate(element => element.scrollTop)).toBeGreaterThan(0); + await expectShown([input, close, lastMeta], phone.keyboard); + await expect(input).toBeFocused(); + + // The browser pans the visible band (a scroll without a resize): the sheet follows it. + await setKeyboard(page, phone.panned, ['scroll']); + expect(await innerSize(page)).toEqual(phone.layout); + await expectShown([input, close], phone.panned); + await expectPaneAboveKeyboard(palette, list, phone.panned); + await swipeResults(page, touch, list, options.first().getByTestId('global-search-result-meta'), phone.panned); + await expectShown([input, close], phone.panned); + await captureVisible(page, `mobile-search-keyboard-panned-${phone.name}`, phone.panned); + + // Filtering to Tasks with the keyboard up keeps the field focused and the palette inside the band. + await palette.getByRole('tab', { name: /^Tasks/ }).tap(); + await expect(options).toHaveCount(1); + await expect(input).toBeFocused(); + const filtered = (await palette.boundingBox())!; + expect(filtered.y + filtered.height).toBeLessThanOrEqual(phone.panned.offsetTop + phone.panned.height + 0.5); + await expectShown([input, close], phone.panned); + await swipeResults(page, touch, list, options.first().getByTestId('global-search-result-meta'), phone.panned); + + // Close search still answers a tap while panned; focus returns to More. + await close.tap(); + await expect(sheet).toHaveCount(0); + await expect(moreButton).toBeFocused(); + await setKeyboard(page, full); + expect(await listenerCount(page)).toBe(idle); + + // Reopened with the keyboard already up and panned: placed from the current band, and a + // tap on a result opens it. Dismissing the keyboard first restores the stylesheet layout. + const reopened = await openSearchFromMenu(page); + await setKeyboard(page, phone.panned); + await expectShown([reopened.input, reopened.sheet.getByRole('button', { name: 'Close search' })], phone.panned); + await setKeyboard(page, full); + await expectUnframed(reopened.sheet); + await setKeyboard(page, phone.keyboard); + await reopened.input.pressSequentially('search'); + await expect(options).toHaveCount(4); + await expectPaneAboveKeyboard(palette, list, phone.keyboard); + await swipeResults(page, touch, list, lastMeta, phone.keyboard); + await lastMeta.tap(); + await expect(page).toHaveURL(/\/tasks\/task-1$/); + await expect(page.locator('[aria-modal="true"]')).toHaveCount(0); + await context.close(); + }); +} diff --git a/propr-ui/src/components/GlobalHeader.nativeSearch.test.tsx b/propr-ui/src/components/GlobalHeader.nativeSearch.test.tsx index f8b50834aa..59d1e9e15f 100644 --- a/propr-ui/src/components/GlobalHeader.nativeSearch.test.tsx +++ b/propr-ui/src/components/GlobalHeader.nativeSearch.test.tsx @@ -11,7 +11,14 @@ vi.mock('../hooks/useHeaderStats', () => ({ useHeaderStats: () => ({ systemHealth: { isHealthy: true, agents: [] } }), })); vi.mock('./GlobalHeaderComponents', () => ({ SystemHealth: () => null })); -vi.mock('./MobileBottomNavigation', () => ({ default: () => })); +vi.mock('./MobileBottomNavigation', () => ({ + default: ({ onSearch }: { onSearch?: () => void }) => ( + <> + + + + ), +})); vi.mock('./QuickAddTodo', () => ({ default: () => null })); // A local stand-in for the search hook: real typing state, one task result, no network. vi.mock('../hooks/useGlobalSearch', () => ({ @@ -115,6 +122,19 @@ describe('GlobalHeader native Search command', () => { expect(screen.getByLabelText('Location')).toHaveTextContent('/tasks'); }); + it('opens the same sheet from the mobile menu\'s Search row, without a desktop adapter', () => { + setWide(false); + renderHeader(false); + const menuSearch = screen.getByRole('button', { name: 'Menu Search' }); + menuSearch.focus(); + fireEvent.click(menuSearch); + const sheet = screen.getByRole('dialog', { name: 'Search' }); + expect(within(sheet).getByRole('combobox', { name: 'Search' })).toHaveFocus(); + fireEvent.click(within(sheet).getByRole('button', { name: 'Close search' })); + expect(screen.queryByRole('dialog', { name: 'Search' })).not.toBeInTheDocument(); + expect(menuSearch).toHaveFocus(); + }); + it('routes Ctrl/Cmd+K the same way as the native command at narrow widths', () => { setWide(false); renderHeader(false); diff --git a/propr-ui/src/components/GlobalHeader.tsx b/propr-ui/src/components/GlobalHeader.tsx index c159353ef7..8d57b0e193 100644 --- a/propr-ui/src/components/GlobalHeader.tsx +++ b/propr-ui/src/components/GlobalHeader.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { ChevronDown } from 'lucide-react'; import { DESKTOP_UI_COMMAND_EVENT } from '../desktop/useDesktopNativeCommands'; @@ -72,11 +72,11 @@ function useHeaderKeyboardShortcuts( } /** - * Routes a Search request (Ctrl/Cmd+K, or the native menu's Search command) to - * a field the operator can actually use. From `md` that is the toolbar's own - * input; below it (a narrow window, or enlarged text) the toolbar is - * `display: none`, so focusing its input would silently do nothing, and the - * request opens the search sheet instead. `mobileSearch` is 0 while the sheet + * Routes a Search request (Ctrl/Cmd+K, the native menu's Search command, or the + * ProPR mobile menu's Search row) to a field the operator can actually use. + * From `md` that is the toolbar's own input; below it (a narrow window, or + * enlarged text) the toolbar is `display: none`, so focusing its input would + * silently do nothing, and the request opens the search sheet instead. `mobileSearch` is 0 while the sheet * is closed and increments per request, so a repeat refocuses the field. */ function useSearchRequests(searchInputRef: React.RefObject) { @@ -107,11 +107,18 @@ function useSearchRequests(searchInputRef: React.RefObject { sheetOpenRef.current = false; setMobileSearch(0); + }, []); + + // Restore focus once the sheet has unmounted, not when it is asked to close: a press that + // lands right after the sheet appears can close it before its field takes focus, and that + // pending focus would otherwise move focus into the field being removed, leaving none. + useLayoutEffect(() => { + if (mobileSearch) return; const target = returnFocusRef.current; returnFocusRef.current = null; // After a route change the previous element may be gone; leave focus to the new page then. if (target?.isConnected) target.focus(); - }, []); + }, [mobileSearch]); // Widening past `md` reveals the toolbar field, so the sheet steps aside. useEffect(() => { @@ -349,6 +356,7 @@ const GlobalHeader: React.FC = ({ user, onLogout, onMenuToggl unreadCount={inboxUnreadCount} systemHealth={systemHealth} quickAddRequest={mobileQuickAddRequest} + onSearch={requestSearch} /> {mobileSearch > 0 && } diff --git a/propr-ui/src/components/GlobalSearch.tsx b/propr-ui/src/components/GlobalSearch.tsx index a087c1329d..e10cbe38bd 100644 --- a/propr-ui/src/components/GlobalSearch.tsx +++ b/propr-ui/src/components/GlobalSearch.tsx @@ -58,31 +58,55 @@ interface PaletteAnchor { left: number; backdropTop: number; compact: boolean; + /** The visible area's lower edge (above an on-screen keyboard). */ + bottom: number; +} + +/** + * The lower edge of what the user can see, in layout-viewport coordinates. An on-screen keyboard + * shrinks and pans the visual viewport while the layout viewport keeps its size, so the window's + * lower edge may lie behind the keyboard. Pinch zoom is left alone: it pans rather than resizes. + */ +function visibleBottom() { + const viewport = window.visualViewport; + if (!viewport || Math.abs(viewport.scale - 1) > 0.01) return window.innerHeight; + return Math.min(window.innerHeight, viewport.offsetTop + viewport.height); } function measureAnchor(container: HTMLElement | null): PaletteAnchor { const input = container?.getBoundingClientRect(); - if (!input) return { top: 0, left: VIEWPORT_GUTTER, backdropTop: 0, compact: false }; + if (!input) return { top: 0, left: VIEWPORT_GUTTER, backdropTop: 0, compact: false, bottom: window.innerHeight }; const bar = container?.closest('header')?.getBoundingClientRect(); const maxLeft = window.innerWidth - Math.min(PALETTE_WIDTH, window.innerWidth - 2 * VIEWPORT_GUTTER) - VIEWPORT_GUTTER; const top = input.bottom + 8; + const bottom = visibleBottom(); return { top, left: Math.max(VIEWPORT_GUTTER, Math.min(input.left, maxLeft)), backdropTop: bar ? bar.bottom : input.bottom, - compact: window.innerHeight - top - VIEWPORT_GUTTER < COMPACT_PALETTE_HEIGHT, + compact: bottom - top - VIEWPORT_GUTTER < COMPACT_PALETTE_HEIGHT, + bottom, }; } /** Track the input's position while the palette is open (the toolbar differs between web and desktop). */ function usePaletteAnchor(containerRef: React.RefObject, open: boolean): PaletteAnchor { - const [anchor, setAnchor] = useState({ top: 0, left: 0, backdropTop: 0, compact: false }); + const [anchor, setAnchor] = useState(() => ({ top: 0, left: 0, backdropTop: 0, compact: false, bottom: window.innerHeight })); useLayoutEffect(() => { if (!open) return; const update = () => setAnchor(measureAnchor(containerRef.current)); update(); + // An on-screen keyboard resizes and pans the visual viewport without a window resize. The + // narrow Search sheet, which registered first, follows it before the input is measured here. + const viewport = window.visualViewport; window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); + viewport?.addEventListener('resize', update); + viewport?.addEventListener('scroll', update); + return () => { + window.removeEventListener('resize', update); + viewport?.removeEventListener('resize', update); + viewport?.removeEventListener('scroll', update); + }; }, [containerRef, open]); return anchor; } @@ -319,7 +343,8 @@ const GlobalSearch: React.FC = ({ inputRef: externalInputRef, {/* Results palette: the Studio mega-dropdown (640px wide, up to 70vh tall), left-aligned with the search input just below it, over a dimmed backdrop. It never runs past the window's lower edge, even when the input sits - lower (the narrow Search sheet below the Linux window controls). + lower (the narrow Search sheet below the Linux window controls), nor + behind an on-screen keyboard; its list then scrolls on its own. Clicking the backdrop closes it. */} {showDropdown && (
= ({ inputRef: externalInputRef,
{resultState === 'loading' && (
@@ -385,7 +410,7 @@ const GlobalSearch: React.FC = ({ inputRef: externalInputRef,
-
+
{activeItem && ( diff --git a/propr-ui/src/components/MobileBottomNavigation.test.tsx b/propr-ui/src/components/MobileBottomNavigation.test.tsx index fa2560ae59..5763035080 100644 --- a/propr-ui/src/components/MobileBottomNavigation.test.tsx +++ b/propr-ui/src/components/MobileBottomNavigation.test.tsx @@ -60,7 +60,8 @@ function renderNavigation( initialEntry = '/tasks/task-1', onLogout = vi.fn(), currentUser: CurrentUser | null = user, - isDemoMode = false + isDemoMode = false, + onSearch = vi.fn(), ) { render( @@ -70,6 +71,7 @@ function renderNavigation( isDemoMode={isDemoMode} unreadCount={7} systemHealth={systemHealth} + onSearch={onSearch} /> @@ -236,6 +238,29 @@ describe('MobileBottomNavigation', () => { expect(moreButton).toHaveFocus(); }); + it('opens global search from the ProPR sheet, closing the sheet first and parking focus on More', () => { + let state: { sheetOpen: boolean; focused: Element | null } | null = null; + const onSearch = vi.fn(() => { + state = { sheetOpen: Boolean(screen.queryByRole('dialog', { name: 'ProPR' })), focused: document.activeElement }; + }); + renderNavigation('/', vi.fn(), user, false, onSearch); + const moreButton = screen.getByRole('button', { name: 'More' }); + fireEvent.click(moreButton); + const sheet = screen.getByRole('dialog', { name: 'ProPR' }); + // Search leads the sheet, ahead of identity and every destination. + const search = within(sheet).getByRole('button', { name: 'Search' }); + expect(search).toHaveAttribute('aria-haspopup', 'dialog'); + expect(search.compareDocumentPosition(within(sheet).getByRole('button', { name: 'Sign out' })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + fireEvent.click(search); + + expect(onSearch).toHaveBeenCalledTimes(1); + // The search request records the focused element as its return target. + expect(state!.focused).toBe(moreButton); + expect(screen.queryByRole('dialog', { name: 'ProPR' })).not.toBeInTheDocument(); + expect(screen.getByTestId('location')).toHaveTextContent(/^\/$/); + }); + it('closes on route navigation and marks More active for its destinations', () => { renderNavigation('/inbox'); const moreButton = screen.getByRole('button', { name: 'More' }); diff --git a/propr-ui/src/components/MobileBottomNavigation.tsx b/propr-ui/src/components/MobileBottomNavigation.tsx index 632aa129e9..0942009222 100644 --- a/propr-ui/src/components/MobileBottomNavigation.tsx +++ b/propr-ui/src/components/MobileBottomNavigation.tsx @@ -11,7 +11,6 @@ import { Inbox, LayoutDashboard, ListTodo, - LogOut, MoreHorizontal, Plug, Plus, @@ -27,10 +26,11 @@ import type { CurrentUser } from '../api/proprTypes'; import { userHasPermission } from '../contexts/AuthContext'; import { publicAssetUrl } from '../config/runtimeMode'; import AgentTankSidebar from './AgentTankSidebar'; -import UserAvatar from './UserAvatar'; import QuickAddTodo from './QuickAddTodo'; import { creationActions } from './creationActions'; import BottomSheet from './MobileBottomSheet'; +import MobileMenuSearchButton from './MobileMenuSearchButton'; +import MobileProfileRow from './MobileProfileRow'; interface MobileBottomNavigationProps { user: CurrentUser | null; @@ -40,6 +40,8 @@ interface MobileBottomNavigationProps { systemHealth: HeaderStats['systemHealth']; /** Increments to open the Create sheet with quick add expanded (Desktop New To-do). */ quickAddRequest?: number; + /** Opens global search through the header's shared request path (Ctrl/Cmd+K, native Search). */ + onSearch: () => void; } type SheetId = 'create' | 'more' | 'usage'; @@ -194,34 +196,6 @@ const MoreLink: React.FC<{ item: MoreItem; onNavigate: () => void }> = ({ item, ); -// Identity and Sign out share a line while the name has room to be read; -// with narrow screens or enlarged text Sign out wraps beneath it instead of -// squeezing the name down to a couple of letters. -const ProfileRow: React.FC<{ user: CurrentUser; onLogout: () => void }> = ({ user, onLogout }) => ( -
-
- -
-

{user.displayName || user.username}

-

@{user.username}

-
-
- -
-); - const HealthDetails: React.FC<{ systemHealth: HeaderStats['systemHealth'] }> = ({ systemHealth }) => { const rows = [ ['Daemon', systemHealth.daemon], @@ -264,6 +238,7 @@ const MobileBottomNavigation: React.FC = ({ unreadCount, systemHealth, quickAddRequest, + onSearch, }) => { const location = useLocation(); const [openSheet, setOpenSheet] = useState(null); @@ -282,6 +257,14 @@ const MobileBottomNavigation: React.FC = ({ const backToMore = useCallback(() => setOpenSheet('more'), []); // Following a link closes the sheet without pulling focus back to the bar. const closeForNavigation = () => setOpenSheet(null); + // The sheet closes before search opens, so the two modals never compete for + // focus. Focus parks on More first: the search request remembers it as the + // place to return to, since the button pressed here is about to unmount. + const openSearch = () => { + setOpenSheet(null); + moreButtonRef.current?.focus(); + onSearch(); + }; const toggleSheet = (sheet: SheetId) => setOpenSheet(current => (current === sheet ? null : sheet)); useEffect(() => { @@ -344,9 +327,10 @@ const MobileBottomNavigation: React.FC = ({ title="ProPR" titleIcon={} closeLabel="Close ProPR menu" + headerAction={} onClose={closeMore} > - {user && } + {user && }