Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
76 changes: 70 additions & 6 deletions packages/ui/src/components/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -260,10 +266,18 @@ function HeaderLayout({
const [row, setRow] = useState<HTMLDivElement | null>(null)
const [lead, setLead] = useState<HTMLDivElement | null>(null)
const [end, setEnd] = useState<HTMLDivElement | null>(null)
const [cover, setCover] = useState<HTMLDivElement | null>(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 (
<div
className={cx(
Expand All @@ -276,12 +290,16 @@ function HeaderLayout({
<div
ref={setRow}
className={cx(
'flex items-center justify-between gap-4 border-b border-(--theme-border) px-4',
'relative flex items-center justify-between gap-4 border-b border-(--theme-border) px-4',
compact ? 'h-8' : 'h-12',
)}
>
{/* Grows to the room the actions leave, so the trail can measure it. */}
<div ref={setLead} className="flex min-w-0 flex-1 items-center gap-2">
<div
ref={setLead}
inert={covered}
className={cx('flex min-w-0 flex-1 items-center gap-2', covered && 'invisible')}
>
{navToggle?.()}
{hasTrail && <Breadcrumbs crumbs={breadcrumbs} heading={heading} />}
{present(titleActions) && (
Expand All @@ -290,7 +308,11 @@ function HeaderLayout({
{hasTrail && breadcrumbPin?.({ crumbs: breadcrumbs })}
</div>
{present(actions) && (
<div ref={setEnd} className="flex shrink-0 items-center gap-1">
<div
ref={setEnd}
inert={covered}
className={cx('flex shrink-0 items-center gap-1', covered && 'invisible')}
>
<HeaderActionsFitContext value={fit}>{actions}</HeaderActionsFitContext>
{moved.length > 0 && (
<ActionMenu
Expand All @@ -303,6 +325,12 @@ function HeaderLayout({
)}
</div>
)}
{/* 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. */}
<div
ref={setCover}
className="pointer-events-none absolute inset-0 flex items-center px-4"
/>
</div>
{present(toolbar) && <div className="px-4">{toolbar}</div>}
</div>
Expand Down Expand Up @@ -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)
Expand All @@ -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<HTMLElement>('[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
}
3 changes: 3 additions & 0 deletions packages/ui/src/components/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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',
Expand Down
49 changes: 47 additions & 2 deletions packages/ui/src/components/headerActionsFit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import type { MenuEntry } from './MenuEntries'
export class HeaderActionsFit {
private entries = new Map<string, MenuEntry>()
private overflow: readonly string[] = []
private covers = false
private cover: HTMLElement | null = null
private coverers = new Set<string>()
private version = 0
private listeners = new Set<() => void>()

Expand Down Expand Up @@ -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) {
Expand All @@ -73,8 +118,8 @@ export class HeaderActionsFit {
export const HeaderActionsFitContext = createContext<HeaderActionsFit | null>(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`
Expand Down
160 changes: 160 additions & 0 deletions packages/ui/src/list/headerSearch.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// @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 }) => <svg className={className} />

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 (
<PageHeaderProvider>
<header data-testid="bar">
<PageHeaderBar />
</header>
<PageHeader
breadcrumbs={[
{ label: 'Billing', href: '/billing' },
{ label: 'Budgets', href: '/billing/budgets' },
{ label: 'Research computing' },
]}
actions={
<>
<ListToolbar search={search} searchLabel="Search allocations" />
<PageHeaderAction icon={Icon} label="Edit allocations" onClick={() => {}} />
<PageHeaderAddButton label="New allocation" onClick={() => {}} />
</>
}
/>
<p data-testid="query">{search.filter}</p>
</PageHeaderProvider>
)
}

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 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 () => {
layOut(360)
render(<AllocationsHeader />)
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(<AllocationsHeader />)
// The bar's markup, but for the tooltip the focused button opens.
const markup = () => row().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(<AllocationsHeader />)
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(<AllocationsHeader />)
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(<AllocationsHeader />)
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())
})
})
Loading
Loading