From 67f3b96b825279a5d4248878314d868730fbeb70 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Sat, 24 Oct 2026 02:38:48 -0500 Subject: [PATCH 1/2] feat(ui): on a phone a list's search takes the whole header bar, and the header never lets its actions overlap the breadcrumbs --- packages/ui/README.md | 8 + packages/ui/src/components/PageHeader.tsx | 76 +++++++- packages/ui/src/components/Provider.tsx | 3 + .../ui/src/components/headerActionsFit.ts | 49 ++++- .../ui/src/list/headerSearch.dom.test.tsx | 165 ++++++++++++++++ packages/ui/src/list/listPage.stories.tsx | 167 +++++++++++++++- packages/ui/src/list/listSearch.tsx | 179 ++++++++++++++++-- 7 files changed, 623 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/list/headerSearch.dom.test.tsx diff --git a/packages/ui/README.md b/packages/ui/README.md index 69120a55..545d80e4 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -228,6 +228,14 @@ A page declares its header in one place, and the shell shows it: such as a log's follow and refresh: its buttons are outlined, and `primary` fills the one, if any, the panel leads to. **`toolbar`** is a row under the bar, such as tabs or a time range. +- A list's controls may sit in **`actions`**: a `ListToolbar` there, with its + filter, display options and search, leaves the list's count under its rows. + Where its search field, opened, wouldn't fit beside the trail, as on a phone, + the open search takes the whole bar until it closes; the rest of the bar + stays as it was underneath, hidden and `inert`, and focus returns to the + search button. While a search term narrows the list with the field closed, + the button shows a dot and its name says so (`searchActive`). Nothing to set + up: the bar measures it. - **`hidden`** takes the bar away while the page is mounted, for a page that fills the window. diff --git a/packages/ui/src/components/PageHeader.tsx b/packages/ui/src/components/PageHeader.tsx index 2a8cec66..e3551865 100644 --- a/packages/ui/src/components/PageHeader.tsx +++ b/packages/ui/src/components/PageHeader.tsx @@ -13,7 +13,13 @@ import { MoreIcon } from '../icons' import { ActionMenu } from './ActionMenu' import { type Breadcrumb, Breadcrumbs } from './Breadcrumbs' import { buttonClasses } from './buttonClasses' -import { HeaderActionsFit, HeaderActionsFitContext, overflowingActions } from './headerActionsFit' +import { + ACTION_GAP, + HeaderActionsFit, + HeaderActionsFitContext, + MENU_BUTTON_WIDTH, + overflowingActions, +} from './headerActionsFit' import { useSlots, useStrings } from './Provider' import { type PageHeaderRegion, PageHeaderStore, type RegionNodes } from './pageHeaderStore' @@ -260,10 +266,18 @@ function HeaderLayout({ const [row, setRow] = useState(null) const [lead, setLead] = useState(null) const [end, setEnd] = useState(null) + const [cover, setCover] = useState(null) useActionsFit(fit, row, lead, end) + useLayoutEffect(() => { + fit.setCoverNode(cover) + return () => fit.setCoverNode(null) + }, [fit, cover]) useSyncExternalStore(fit.subscribe, fit.getVersion, fit.getVersion) const moved = fit.menu() const hasTrail = breadcrumbs.length > 0 + // A search that takes the bar covers the rest, which stays laid out as it was, so closing the + // search puts the bar back exactly, but can't be seen, reached or pressed meanwhile. + const covered = fit.isCovered() return (
{/* Grows to the room the actions leave, so the trail can measure it. */} -
+
{navToggle?.()} {hasTrail && } {present(titleActions) && ( @@ -290,7 +308,11 @@ function HeaderLayout({ {hasTrail && breadcrumbPin?.({ crumbs: breadcrumbs })}
{present(actions) && ( -
+
{actions} {moved.length > 0 && ( )} + {/* The layer a search takes the bar in, from its start to its end. It stays laid out, + so the field can take focus the moment it arrives. */} +
{present(toolbar) &&
{toolbar}
}
@@ -376,14 +404,17 @@ function useActionsFit( Number.parseFloat(style.paddingRight || '0') - leadMin - ROW_GAP + const shown = end.getBoundingClientRect().width + const moved = items.filter((id) => fit.isOverflowed(id)) fit.setOverflow( overflowingActions( items.map((id) => ({ id, width: width(id) })), - end.getBoundingClientRect().width, - items.filter((id) => fit.isOverflowed(id)), + shown, + moved, room, ), ) + fit.setSearchCovers(searchCovers(end, items, moved, width, shown, room)) } measure() const resize = new ResizeObserver(measure) @@ -400,3 +431,36 @@ function useActionsFit( } }, [fit, row, lead, end, widths]) } + +// The search field's width where it opens in the bar (w-72). +const SEARCH_FIELD_WIDTH = 288 + +/** + * Whether a search in the actions, opened in place, would leave the trail too little room even + * with every action in the "…" menu, so it takes the whole bar instead. It works from the + * actions' width as they are now, whether the search shows as its button or its field, so the + * answer is the same either way. + */ +function searchCovers( + end: HTMLElement, + items: readonly string[], + moved: readonly string[], + width: (id: string) => number, + shown: number, + room: number, +): boolean { + const search = end.querySelector('[data-header-search]') + const control = search?.firstElementChild + if (!control) { + return false + } + let least = shown - control.getBoundingClientRect().width + SEARCH_FIELD_WIDTH + const still = items.filter((id) => !moved.includes(id)) + if (still.length > 0) { + least -= still.reduce((sum, id) => sum + width(id) + ACTION_GAP, 0) + if (moved.length === 0) { + least += MENU_BUTTON_WIDTH + ACTION_GAP + } + } + return least > room + 0.5 +} diff --git a/packages/ui/src/components/Provider.tsx b/packages/ui/src/components/Provider.tsx index 3dc78603..6448a9da 100644 --- a/packages/ui/src/components/Provider.tsx +++ b/packages/ui/src/components/Provider.tsx @@ -654,6 +654,8 @@ export interface UIStrings { /** Where the page sits, for `Pagination` too narrow for page numbers: it shows "5 / 12". */ pageOfTotal: (page: number, total: number) => string removeSearchTerm: (term: string) => string + /** The search button's name while a query it holds narrows the list, its field closed. */ + searchActive: (label: string) => string } /** The chip row of a list's active filters, its Filter menu and its pickers. Field and option * labels come from the host's fields, already translated. */ @@ -1567,6 +1569,7 @@ const DEFAULTS: UIProviderValue = { page: (page) => `Page ${page}`, pageOfTotal: (page, total) => `Page ${page} of ${total}`, removeSearchTerm: (term) => `Remove filter ${term}`, + searchActive: (label) => `${label}, on`, }, filters: { addFilter: 'Filter', diff --git a/packages/ui/src/components/headerActionsFit.ts b/packages/ui/src/components/headerActionsFit.ts index ffe28377..b19c7f52 100644 --- a/packages/ui/src/components/headerActionsFit.ts +++ b/packages/ui/src/components/headerActionsFit.ts @@ -11,6 +11,9 @@ import type { MenuEntry } from './MenuEntries' export class HeaderActionsFit { private entries = new Map() private overflow: readonly string[] = [] + private covers = false + private cover: HTMLElement | null = null + private coverers = new Set() private version = 0 private listeners = new Set<() => void>() @@ -60,6 +63,48 @@ export class HeaderActionsFit { this.changed() } + /** Whether an open search would take the whole bar: its field, opened in place, wouldn't fit + * beside the trail even with every action in the "…" menu. The bar measures it. */ + searchCovers(): boolean { + return this.covers + } + + setSearchCovers(covers: boolean) { + if (covers !== this.covers) { + this.covers = covers + this.changed() + } + } + + /** Where a search that takes the bar renders its field: a layer over the bar's row. */ + coverNode(): HTMLElement | null { + return this.cover + } + + setCoverNode(node: HTMLElement | null) { + if (node !== this.cover) { + this.cover = node + this.changed() + } + } + + /** Whether a search has the bar now, so the bar hides and disables what it covers. */ + isCovered(): boolean { + return this.coverers.size > 0 + } + + setCovering(id: string, covering: boolean) { + if (covering === this.coverers.has(id)) { + return + } + if (covering) { + this.coverers.add(id) + } else { + this.coverers.delete(id) + } + this.changed() + } + private changed() { this.version++ for (const listener of this.listeners) { @@ -73,8 +118,8 @@ export class HeaderActionsFit { export const HeaderActionsFitContext = createContext(null) // gap-1 between the bar's actions, and the size-6 "…" button. -const ACTION_GAP = 4 -const MENU_BUTTON_WIDTH = 24 +export const ACTION_GAP = 4 +export const MENU_BUTTON_WIDTH = 24 /** * The trailing actions to move into the "…" menu so the rest fit in `room` diff --git a/packages/ui/src/list/headerSearch.dom.test.tsx b/packages/ui/src/list/headerSearch.dom.test.tsx new file mode 100644 index 00000000..3400a1b9 --- /dev/null +++ b/packages/ui/src/list/headerSearch.dom.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { PageHeader, PageHeaderBar, PageHeaderProvider } from '../components/PageHeader' +import { PageHeaderAction, PageHeaderAddButton } from '../components/PageHeaderAction' +import { memoryStorage } from '../test/storage' +import { ListToolbar } from './ListToolbar' +import { useListSearch } from './listSearch' + +const Icon = ({ className }: { className?: string }) => + +beforeEach(() => { + Object.defineProperty(window, 'localStorage', { value: memoryStorage(), configurable: true }) +}) +afterEach(() => vi.restoreAllMocks()) + +// A bar `barWidth` wide: the search button 28px, its field 288px, and each action 100px, with +// the bar's 4px gaps between the actions. +function layOut(barWidth: number) { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function ( + this: HTMLElement, + ) { + return this.classList.contains('h-12') ? barWidth : 0 + }) + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: Element, + ) { + const el = this as HTMLElement + const search = (control: Element | null | undefined) => + control?.tagName === 'BUTTON' ? 28 : control ? 288 : 0 + let width = 0 + if (el.parentElement?.dataset['headerAction'] !== undefined) { + width = 100 + } else if (el.parentElement?.dataset['headerSearch'] !== undefined) { + width = search(el) + } else if (el.parentElement?.classList.contains('h-12') && !el.querySelector('nav')) { + const shown = [...el.querySelectorAll('[data-header-action]')].filter( + (item) => !item.hasAttribute('hidden'), + ).length + const menu = el.querySelector('[aria-label="More actions"]') ? 1 : 0 + width = + search(el.querySelector('[data-header-search]')?.firstElementChild) + + shown * 100 + + menu * 24 + + (shown + menu) * 4 + } + return { width, height: 24, top: 0, left: 0, right: width, bottom: 24 } as DOMRect + }) +} + +function AllocationsHeader() { + const search = useListSearch(true, 'test-allocations') + return ( + +
+ +
+ + + {}} /> + {}} /> + + } + /> +

{search.filter}

+
+ ) +} + +const bar = () => within(screen.getByTestId('bar')) +const trail = () => bar().getByRole('navigation', { name: 'Breadcrumb', hidden: true }) +const searchButton = () => bar().getByRole('button', { name: /^Search allocations/, hidden: true }) +const field = () => screen.getByRole('textbox', { name: 'Search allocations' }) +/** The layer over the bar's row that a search takes the bar in. */ +const cover = () => trail().parentElement?.parentElement?.lastElementChild as HTMLElement + +describe('a list search in a narrow page header', () => { + it('takes the whole bar, covering the trail and the other controls', async () => { + layOut(360) + render() + fireEvent.click(searchButton()) + await waitFor(() => expect(field()).toHaveFocus()) + expect(cover()).toContainElement(field()) + // The trail, the other controls and the page's actions are hidden and can't be reached. + const lead = trail().parentElement as HTMLElement + const end = searchButton().closest('[inert]') + expect(lead).toHaveAttribute('inert') + expect(lead).toHaveClass('invisible') + expect(end).not.toBeNull() + expect(end).toHaveClass('invisible') + expect(end).toContainElement(searchButton()) + }) + + it('gives the bar back exactly when it closes, focus on the search button', async () => { + layOut(360) + render() + // The bar's markup, but for the tooltip the focused button opens. + const markup = () => + (trail().parentElement?.parentElement as HTMLElement).innerHTML.replaceAll( + ' data-popup-open=""', + '', + ) + const before = markup() + fireEvent.click(searchButton()) + await waitFor(() => expect(field()).toHaveFocus()) + fireEvent.keyDown(field(), { key: 'Escape' }) + await waitFor(() => expect(searchButton()).toHaveFocus()) + expect(screen.queryByRole('textbox')).toBeNull() + const lead = trail().parentElement as HTMLElement + expect(lead).not.toHaveAttribute('inert') + expect(lead).not.toHaveClass('invisible') + expect(markup()).toBe(before) + }) + + it('closes from its clear button too, handing focus back', async () => { + layOut(360) + render() + fireEvent.keyDown(window, { key: '/' }) + await waitFor(() => expect(field()).toHaveFocus()) + fireEvent.change(field(), { target: { value: 'gen' } }) + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + await waitFor(() => expect(searchButton()).toHaveFocus()) + expect(trail().parentElement).not.toHaveAttribute('inert') + }) + + it('keeps a term on when it closes, and its button says so', async () => { + layOut(360) + render() + fireEvent.click(searchButton()) + await waitFor(() => expect(field()).toHaveFocus()) + fireEvent.change(field(), { target: { value: 'gen' } }) + fireEvent.keyDown(field(), { key: 'Enter' }) + fireEvent.keyDown(field(), { key: 'Escape' }) + const button = await waitFor(() => + bar().getByRole('button', { name: 'Search allocations, on' }), + ) + await waitFor(() => expect(button).toHaveFocus()) + expect(screen.queryByRole('textbox')).toBeNull() + // Opening it again shows the term. + fireEvent.click(button) + await waitFor(() => expect(field()).toHaveFocus()) + expect(screen.getByRole('button', { name: 'Remove filter gen' })).toBeInTheDocument() + }) +}) + +describe('a list search in a wide page header', () => { + it('opens in place, beside the trail', async () => { + layOut(1200) + render() + fireEvent.click(searchButton()) + await waitFor(() => expect(field()).toHaveFocus()) + expect(cover()).toBeEmptyDOMElement() + expect(trail().parentElement).not.toHaveAttribute('inert') + expect(bar().getByRole('button', { name: 'Edit allocations' })).toBeVisible() + fireEvent.keyDown(field(), { key: 'Escape' }) + await waitFor(() => expect(searchButton()).toHaveFocus()) + }) +}) diff --git a/packages/ui/src/list/listPage.stories.tsx b/packages/ui/src/list/listPage.stories.tsx index 96cf6161..fae75e20 100644 --- a/packages/ui/src/list/listPage.stories.tsx +++ b/packages/ui/src/list/listPage.stories.tsx @@ -4,11 +4,19 @@ import { expect, fn, userEvent, waitFor, within } from 'storybook/test' import { Button } from '../components/Button' import { useContextMenu } from '../components/ContextMenu' import { PageHeader, PageHeaderBar, PageHeaderProvider } from '../components/PageHeader' -import { PageHeaderAddButton } from '../components/PageHeaderAction' +import { PageHeaderAction, PageHeaderAddButton } from '../components/PageHeaderAction' import { Pagination } from '../components/Pagination' +import { type UILinkComponent, UIProvider } from '../components/Provider' import { StatusLabel } from '../components/StatusLabel' import { Table } from '../components/Table' -import { ComputeIcon, KeyIcon, RefreshIcon, StorageIcon } from '../icons' +import { + AllocationIcon, + ComputeIcon, + KeyIcon, + RefreshIcon, + SidebarOpenIcon, + StorageIcon, +} from '../icons' import { ActionToolbar } from './ActionToolbar' import { ListColumns, ListPage, ListRow, useListNavigate } from './ListPage' import { ListCellText, listTableProps, NameCell } from './ListTable' @@ -583,3 +591,158 @@ export const StackedOnLines: StoryObj = { ]) }, } + +interface Allocation { + team: string + lead: string + budget: string + used: string +} + +const ALLOCATIONS: Allocation[] = [ + { team: 'Climate modeling', lead: 'maya', budget: '$42,000', used: '68%' }, + { team: 'Genomics pipeline', lead: 'arjun', budget: '$18,500', used: '91%' }, + { team: 'Materials simulation', lead: 'lena', budget: '$27,000', used: '34%' }, + { team: 'Wind tunnel CFD', lead: 'tomas', budget: '$12,750', used: '12%' }, +] + +// Links render as anchors that go nowhere, as the workshop's own do. +const StoryLink: UILinkComponent = ({ href, onClick, children, ...rest }) => ( + { + e.preventDefault() + onClick?.(e) + }} + > + {children} + +) + +// The host's navigation toggle, where an app frame puts it: first in the bar. +const headerSlots = { + link: StoryLink, + navToggle: () => ( + + ), +} + +function AllocationsPage({ searching }: { searching: boolean }) { + const search = useListSearch(true) + const view = useListView({ + storageKey: 'story-header-allocations', + columns: [ + { key: 'team', label: 'Team', alwaysVisible: true }, + { key: 'budget', label: 'Budget', align: 'end' }, + { key: 'used', label: 'Used', align: 'end', stack: false }, + ], + facets: [ + { + key: 'lead', + label: 'Lead', + options: ALLOCATIONS.map((row) => ({ value: row.lead, label: row.lead })), + matches: (row, selected) => selected.includes(row.lead), + }, + ], + }) + const { openMenu, contextMenu } = useContextMenu() + const goTo = useListNavigate() + const { setFilter, openSearch } = search + useEffect(() => { + if (searching) { + openSearch() + setFilter('sim') + } + }, [searching, setFilter, openSearch]) + const query = search.filter.trim().toLowerCase() + const shown = view.applyFilters( + ALLOCATIONS.filter((row) => row.team.toLowerCase().includes(query)), + ) + return ( + + +
+ + + + {}} + /> + {}} /> + + } + /> + 0, + }} + noMatches="No allocations match" + > + + + {shown.map((row) => ( + {} }]} + goTo={goTo} + openMenu={openMenu} + > + } + name={row.team} + subtitle={`Led by ${row.lead}`} + href={null} + /> + + {row.budget} + + + {row.used} + + + ))} +
+
+ {contextMenu} +
+
+
+ ) +} + +/** A list whose controls sit in the page header, so its count can sit under its rows: the + * filter, display options and search, then the page's own actions. On a phone the header folds + * its actions into "…" and the trail to the current page before anything overlaps. */ +export const ControlsInTheHeader: StoryObj = { + parameters: { layout: 'fullscreen' }, + render: () => , +} + +/** The search open with a query typed. Where the field fits beside the trail it opens in place; + * on a narrower header it takes the whole bar until it closes. */ +export const ControlsInTheHeaderSearching: StoryObj = { + parameters: { layout: 'fullscreen' }, + render: () => , +} diff --git a/packages/ui/src/list/listSearch.tsx b/packages/ui/src/list/listSearch.tsx index df81872b..53b576a9 100644 --- a/packages/ui/src/list/listSearch.tsx +++ b/packages/ui/src/list/listSearch.tsx @@ -1,9 +1,25 @@ -import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { + createContext, + type KeyboardEvent as ReactKeyboardEvent, + type Ref, + use, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react' +import { createPortal } from 'react-dom' import { buttonClasses } from '../components/buttonClasses' +import { HeaderActionsFitContext } from '../components/headerActionsFit' import { ariaKeyShortcuts } from '../components/Kbd' import { useLocale, usePersistedState, useStrings } from '../components/Provider' import { isTextEntryContext } from '../components/shortcutKey' import { Tooltip } from '../components/Tooltip' +import { cx } from '../cx' import { formatInteger } from '../format' import { CloseIcon, RemoveIcon, SearchIcon } from '../icons' @@ -110,9 +126,79 @@ export function ListSearchControl({ }) { const { common, list } = useStrings() const label = labelProp ?? common.search - if (search.searchOpen || search.terms.length > 0) { - return ( -
+ // In a page header's actions, the bar says whether an open search fits beside the trail or + // takes the whole bar. + const fit = use(HeaderActionsFitContext) + useSyncExternalStore(fit?.subscribe ?? subscribeNever, fit?.getVersion ?? zero, zero) + const covers = fit?.searchCovers() ?? false + const coverNode = fit?.coverNode() ?? null + const covering = covers && search.searchOpen && coverNode !== null + // Where the search takes the bar, it shows as its button until it opens, even while terms + // narrow the list; elsewhere the terms keep the field open. + const field = covering || (!covers && (search.searchOpen || search.terms.length > 0)) + const active = search.terms.length > 0 || search.filter.trim() !== '' + const id = useId() + useLayoutEffect(() => { + fit?.setCovering(id, covering) + }, [fit, id, covering]) + useLayoutEffect(() => (fit ? () => fit.setCovering(id, false) : undefined), [fit, id]) + + // Closing the field from inside it hands focus back to the button that opened it, rather than + // dropping it to the page. + const button = useRef(null) + const returnFocus = useRef(false) + useLayoutEffect(() => { + if (!field && returnFocus.current) { + returnFocus.current = false + // After the bar has re-rendered, so a button it covered is no longer inert. + queueMicrotask(() => button.current?.focus()) + } + }) + // The field moves between the bar's row and the layer over it as the bar resizes; it keeps + // focus when it does. + const { inputRef, searchOpen } = search + const wasCovering = useRef(covering) + useLayoutEffect(() => { + if (wasCovering.current === covering) { + return + } + wasCovering.current = covering + if (!searchOpen) { + return + } + // Once the bar has re-rendered: taking it makes the control that opened the search inert. + queueMicrotask(() => { + const focused = document.activeElement + if (!focused || focused === document.body || coverNode?.parentElement?.contains(focused)) { + inputRef.current?.focus() + } + }) + }) + + if (field) { + const close = () => { + returnFocus.current = true + search.clearSearch() + } + // Escape belongs to the field, not the window: a dialog or menu open over the list closes on + // its own Escape and keeps the search. + const closeOnEscape = (e: ReactKeyboardEvent) => { + if (e.key === 'Escape' && search.searchOpen) { + e.preventDefault() + // The terms keep the field in place, and focus with it. + returnFocus.current = covering || search.terms.length === 0 + search.closeSearch() + return true + } + return false + } + const box = ( +
{search.terms.map((term) => ( { - // Escape belongs to the field, not the window: a dialog or menu - // open over the list closes on its own Escape and keeps the search. - if (e.key === 'Escape' && search.searchOpen) { - e.preventDefault() - search.closeSearch() - } else if (e.key === 'Enter') { + if (closeOnEscape(e)) { + return + } + if (e.key === 'Enter') { e.preventDefault() search.addTerm(search.filter) } else if (e.key === 'Backspace' && !search.filter) { @@ -157,25 +241,92 @@ export function ListSearchControl({ />
) + if (covering && coverNode) { + // The button keeps its place in the covered row, so the bar's layout doesn't move. + return ( + <> + + + + {createPortal(box, coverNode)} + + ) + } + return fit ? ( + + {box} + + ) : ( + box + ) } + const control = ( + + ) + return fit ? ( + + {control} + + ) : ( + control + ) +} + +const subscribeNever = () => () => {} +const zero = () => 0 + +/** The search closed: a button, with a dot while a query it holds narrows the list. */ +function SearchButton({ + ref, + label, + name = label, + active, + onOpen, +}: { + ref?: Ref + label: string + name?: string + active: boolean + onOpen: () => void +}) { return ( ) From 32d90098700ce36f8cf000352ccf040c8f9c4e48 Mon Sep 17 00:00:00 2001 From: Michael McQuade Date: Sat, 24 Oct 2026 02:43:48 -0500 Subject: [PATCH 2/2] test(ui): name the header row once in the search tests --- packages/ui/src/list/headerSearch.dom.test.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/list/headerSearch.dom.test.tsx b/packages/ui/src/list/headerSearch.dom.test.tsx index 3400a1b9..2439ce1f 100644 --- a/packages/ui/src/list/headerSearch.dom.test.tsx +++ b/packages/ui/src/list/headerSearch.dom.test.tsx @@ -22,9 +22,7 @@ function layOut(barWidth: number) { ) { return this.classList.contains('h-12') ? barWidth : 0 }) - vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function ( - this: Element, - ) { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { const el = this as HTMLElement const search = (control: Element | null | undefined) => control?.tagName === 'BUTTON' ? 28 : control ? 288 : 0 @@ -79,7 +77,8 @@ const trail = () => bar().getByRole('navigation', { name: 'Breadcrumb', hidden: const searchButton = () => bar().getByRole('button', { name: /^Search allocations/, hidden: true }) const field = () => screen.getByRole('textbox', { name: 'Search allocations' }) /** The layer over the bar's row that a search takes the bar in. */ -const cover = () => trail().parentElement?.parentElement?.lastElementChild as HTMLElement +const row = () => (trail().parentElement as HTMLElement).parentElement as HTMLElement +const cover = () => row().lastElementChild as HTMLElement describe('a list search in a narrow page header', () => { it('takes the whole bar, covering the trail and the other controls', async () => { @@ -102,11 +101,7 @@ describe('a list search in a narrow page header', () => { layOut(360) render() // The bar's markup, but for the tooltip the focused button opens. - const markup = () => - (trail().parentElement?.parentElement as HTMLElement).innerHTML.replaceAll( - ' data-popup-open=""', - '', - ) + const markup = () => row().innerHTML.replaceAll(' data-popup-open=""', '') const before = markup() fireEvent.click(searchButton()) await waitFor(() => expect(field()).toHaveFocus())