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
14 changes: 11 additions & 3 deletions docs/search-chrome-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,17 @@ chrome and changes to it land on every mode at once. Keep these rules:
it appears. The `role="status"` / `aria-live="polite"` announcement stays either way — except
while faulted, when the spine goes `aria-live="off"` and the fault panel's `role="alert"`
makes the single announcement instead of both speaking.
3. **Sort is a segmented control, not a select.** Two values do not justify a menu you must open
to read. `ResultSortControl` renders `sortOptions` as `aria-pressed` buttons inside a
`role="group"` named "Sort results"; add a third order only if it still fits the rail.
3. **Sort is a segmented control, not a select — and it is `sm`-and-up.** Two values do not
justify a menu you must open to read. `ResultSortControl` renders `sortOptions` as
`aria-pressed` buttons inside a `role="group"` named "Sort results"; add a third order only if
it still fits the rail. Below 640px it is `hidden`: the two segments cost roughly half the
band's one line, and the query truncated to pay for a control set about once a session. Only
the affordance is `sm`-and-up — `?sort=` still carries an alpha order onto a phone and the
results honour it. The display class belongs in the component's own base string, because `cn`
is a plain join with no Tailwind conflict resolution. A page whose only utility is sort hides
the whole utilities group below `sm` (`hasPhoneUtilities`) rather than leaving an empty flex
child — in `inline` placement under 414px that child is `w-full basis-full`, i.e. a blank
second line.
4. **Native selects are pinned to 16px below `sm`.** The unlayered iOS anti-zoom rule in
`globals.css` ("Interactive element defaults") deliberately beats Tailwind's `text-*`
utilities on `input`/`select`/`textarea`. Do not fight it with `!important` or a per-call-site
Expand Down
70 changes: 35 additions & 35 deletions lighthouse-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,75 +20,75 @@
},
"baseline": {
"desktop-documents-search": {
"lcpMs": 912.6572000000001,
"cls": 0.09676386467668462,
"tbtMs": 77.5,
"lcpMs": 887,
"cls": 0.119,
"tbtMs": 1,
"fcpMs": 404.55240000000003,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-dsm": {
"lcpMs": 938.4955500000002,
"cls": 0.01330779747882682,
"lcpMs": 936,
"cls": 0.013,
"tbtMs": 0,
"fcpMs": 399.6637,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-forms": {
"lcpMs": 1567.6434999999997,
"cls": 0.05555523904347394,
"lcpMs": 870,
"cls": 0.06,
"tbtMs": 0,
"fcpMs": 393.5287,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-root": {
"lcpMs": 935.5017499999998,
"cls": 0,
"tbtMs": 64.99999999999989,
"lcpMs": 822,
"cls": 0.007,
"tbtMs": 3,
"fcpMs": 358.3345,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"desktop-therapy-compass": {
"lcpMs": 1024.6195499999997,
"lcpMs": 822,
"cls": 0,
"tbtMs": 42,
"tbtMs": 0,
"fcpMs": 397.3599,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-documents-search": {
"lcpMs": 2357.43,
"lcpMs": 2311,
"cls": 0,
"tbtMs": 398.87499999999864,
"tbtMs": 399,
"fcpMs": 2329.335,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-dsm": {
"lcpMs": 2744.673,
"lcpMs": 2319,
"cls": 0,
"tbtMs": 367.6859999999997,
"tbtMs": 322,
"fcpMs": 2325.841,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
Comment thread
BigSimmo marked this conversation as resolved.
},
"mobile-forms": {
"lcpMs": 2359.54,
"cls": 0.08045469317717091,
"tbtMs": 362.06400000000167,
"lcpMs": 2304,
"cls": 0.08,
"tbtMs": 324,
"fcpMs": 2291.215,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-root": {
"lcpMs": 3325.742,
"lcpMs": 3274,
"cls": 0,
"tbtMs": 638.9019999999982,
"fcpMs": 3325.742,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"tbtMs": 763,
"fcpMs": 3274,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
},
"mobile-therapy-compass": {
"lcpMs": 2791.386,
"lcpMs": 2338,
"cls": 0,
"tbtMs": 341.201,
"tbtMs": 302,
"fcpMs": 2327.357,
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/150.0.0.0 Safari/537.36"
"chromeVersion": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36"
}
},
"updatedAt": "2026-08-05T08:26:50.745Z"
"updatedAt": "2026-08-07T13:42:17.043Z"
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ function singularNoun(plural: string) {
}

/** Sort is a two-state choice, so it reads as a segmented control rather than a
select: a dropdown over two values makes you open a menu to learn nothing. */
select: a dropdown over two values makes you open a menu to learn nothing.
It is a `sm`-and-up control — see `ResultSortControl` for why the phone line
drops it. */
const sortOptions: ReadonlyArray<{ value: ResultSortValue; label: string }> = [
{ value: "relevance", label: "Relevance" },
{ value: "alpha", label: "A–Z" },
Expand Down Expand Up @@ -259,6 +261,14 @@ export function SearchResultsHeaderBand({
const hasUtilities = Boolean(
onSortChange || onViewChange || onSaveSearch || utilityControls || pageMobileControls || (partial && onRetry),
);
// Sort is the one utility that does not render below `sm`. A page whose only
// utility is sort would otherwise keep this group mounted on a phone as an
// empty flex child — and in `inline` placement below 414px that child is
// `w-full basis-full`, so it would take a whole empty second line under the
// count. Hide the group itself there instead of leaving a ghost row.
const hasPhoneUtilities = Boolean(
onViewChange || onSaveSearch || utilityControls || pageMobileControls || (partial && onRetry),
);
// See `mobileControlsPlacement`: absent a declaration, a page control is
// assumed to be a full-width select and keeps its own row, and a band with no
// page control collapses to one line.
Expand Down Expand Up @@ -428,7 +438,8 @@ export function SearchResultsHeaderBand({
<div
data-testid="search-query-ribbon-utilities"
className={cn(
"flex min-w-0 items-center gap-1.5 lg:flex-1",
hasPhoneUtilities ? "flex" : "hidden sm:flex",
"min-w-0 items-center gap-1.5 lg:flex-1",
// Row placement: wrap so a `w-full` phone select gets its own line
// instead of sharing a shrinkable flex line with Sort. At `sm+` the
// phone control is hidden and the track returns to a single row.
Expand Down Expand Up @@ -717,6 +728,22 @@ export function SearchResultsHeaderBand({
);
}

/**
* Sort, from `sm` up only. On a phone the two segments — "Relevance" and "A–Z" —
* cost roughly half the band's one line, which is the state the phone bar was
* reported in: count, query, sort and Filter competing for ~350px, with the
* query truncating to pay for a control that is set about once a session. The
* default order is relevance, which is what a phone reader wants anyway, so the
* phone drops the control rather than squeezing every neighbour around it.
*
* Only the affordance is `sm`-and-up. `?sort=` still carries an alpha order onto
* a phone from a link or a wider session, and the results honour it.
*
* The display class lives in this base string rather than in a caller's
* `className`: `cn` is a plain join with no Tailwind conflict resolution, so a
* base `inline-flex` plus a caller's `hidden` would resolve by stylesheet order
* rather than by intent.
*/
export function ResultSortControl({
value,
onChange,
Expand All @@ -731,7 +758,7 @@ export function ResultSortControl({
role="group"
aria-label="Sort results"
className={cn(
"inline-flex min-h-tap shrink-0 overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)] sm:min-h-10",
"hidden min-h-tap shrink-0 overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] shadow-[var(--shadow-inset)] sm:inline-flex sm:min-h-10",
className,
)}
>
Expand Down
7 changes: 4 additions & 3 deletions tests/ui-accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,9 +569,10 @@ test.describe("Clinical KB accessibility coverage", () => {
const bounds = element.getBoundingClientRect();
return { width: bounds.width, height: bounds.height };
});
// 44, not 48. This trigger sits in the ribbon's utility row beside
// `ResultSortControl`, which is `min-h-tap` (44px) — raising only this
// control would leave the row visibly ragged. The repo's `min-h-12` rule
// 44, not 48. This trigger sits in the ribbon's utility row, which is
// `min-h-tap` (44px) throughout — raising only this control would leave the
// row visibly ragged. (`ResultSortControl` set that rhythm before it became
// `sm`-and-up; the row still holds it.) The repo's `min-h-12` rule
// exists to stop generic a11y advice pulling production down to `min-h-11`
// (a known `ui-smoke` flake), not to override a page's own row rhythm; the
// sheet's own toggles, which have the room, are `min-h-12`.
Expand Down
51 changes: 36 additions & 15 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3553,10 +3553,22 @@ test.describe("Clinical KB UI smoke coverage", () => {
const queryRibbon = documentWorkspace.getByTestId("search-query-ribbon");
await expect(queryRibbon).toBeVisible();
// One filter surface, two slots: the ribbon's full-width row is suppressed
// below `sm` and the phone copy sits in the utility row beside Sort. Both
// are in the DOM, which is why they carry distinct test ids.
// below `sm` and the phone copy sits in the utility row. Both are in the
// DOM, which is why they carry distinct test ids.
await expect(queryRibbon.getByTestId("document-filter-trigger-wide")).toBeHidden();
await expect(queryRibbon.getByLabel("Sort results")).toBeVisible();
// Sort is `sm`-and-up. Its two segments cost about half the band's one line
// on a phone, and relevance — the default, and what `?sort=` still carries
// in from a link — is the order a phone reader wants.
//
// Mounted-and-hidden, asserted as two facts, because `toBeHidden()` alone
// cannot tell them apart: it passes for a hidden node AND for a node that
// does not exist. So `includeHidden` (plain `getByRole` filters hidden nodes
// out and would resolve to nothing) plus a count, then the visibility. A
// deleted control fails the count; a control returned to the phone line
// fails the hidden check.
const phoneSort = queryRibbon.getByRole("group", { name: "Sort results", includeHidden: true });
await expect(phoneSort).toHaveCount(1);
await expect(phoneSort).toBeHidden();
const mobileFilterTrigger = queryRibbon.getByTestId("document-filter-trigger-phone");
await expect(mobileFilterTrigger).toBeVisible();
await expect(mobileFilterTrigger).toHaveAccessibleName(/Filter/);
Expand Down Expand Up @@ -3587,17 +3599,22 @@ test.describe("Clinical KB UI smoke coverage", () => {
.poll(
async () =>
utilityTrack.evaluate((track) => {
const sort = track.querySelector('[role="group"][aria-label="Sort results"]');
const clipped = sort ? sort.getBoundingClientRect().right - track.getBoundingClientRect().right : 0;
// The last *rendered* child, not a named control. Sort is
// `sm`-and-up, so below 640 it is `display:none` and every width
// in this sweep would measure a zero-sized node and report a
// dutiful 0 — the sweep would go blind while still passing.
const rendered = Array.from(track.children).filter((child) => child.getClientRects().length > 0);
const last = rendered[rendered.length - 1];
const clipped = last ? last.getBoundingClientRect().right - track.getBoundingClientRect().right : 0;
return {
overflow: Math.max(0, track.scrollWidth - track.clientWidth),
sortClipped: Math.max(0, Math.round(clipped)),
controlClipped: Math.max(0, Math.round(clipped)),
masked: track.getAttribute("data-overflowing") === "true",
};
}),
{ message: `results-band utility rail clipped its own controls at ${width}px` },
)
.toEqual({ overflow: 0, sortClipped: 0, masked: false });
.toEqual({ overflow: 0, controlClipped: 0, masked: false });
// Below 414px the wrap is the active mechanism. Track overflow alone is
// blind to wrap failure: if the utilities group is pushed off-screen by
// the band's `overflow-hidden`, both `scrollWidth`/`clientWidth` and
Expand Down Expand Up @@ -3749,14 +3766,6 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(phoneFilterPanel).toHaveCount(0);
await expect(documentResults).toBeVisible();

// Sort is a segmented group of pressed buttons, not a select: the active order
// is readable without opening anything.
const documentSort = queryRibbon.getByRole("group", { name: "Sort results" });
await documentSort.getByRole("button", { name: "A–Z" }).click();
await expect(page).toHaveURL(/[?&]sort=alpha/);
await expect(documentSort.getByRole("button", { name: "A–Z" })).toHaveAttribute("aria-pressed", "true");
await documentSort.getByRole("button", { name: "Relevance" }).click();

const openDocumentLink = documentResults
.getByRole("link", { name: /Open Synthetic lithium monitoring protocol/i })
.last();
Expand All @@ -3773,6 +3782,18 @@ test.describe("Clinical KB UI smoke coverage", () => {

await page.setViewportSize({ width: 1440, height: 900 });
await expectNoPageHorizontalOverflow(page);

// Sort is a segmented group of pressed buttons, not a select: the active
// order is readable without opening anything. Exercised here rather than at
// 390px because the control is `sm`-and-up — the phone assertion above is
// that it is hidden; this is the proof it still works where it renders.
const documentSort = queryRibbon.getByRole("group", { name: "Sort results" });
await expect(documentSort).toBeVisible();
await documentSort.getByRole("button", { name: "A–Z" }).click();
await expect(page).toHaveURL(/[?&]sort=alpha/);
await expect(documentSort.getByRole("button", { name: "A–Z" })).toHaveAttribute("aria-pressed", "true");
await documentSort.getByRole("button", { name: "Relevance" }).click();

// The same panel, reached from the wide-viewport copy of the trigger.
const wideFilterTrigger = queryRibbon.getByTestId("document-filter-trigger-wide");
await expect(wideFilterTrigger).toBeVisible();
Expand Down
Loading
Loading