Skip to content
Closed
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-23 | 2316 | 28cbce0630dc7bc2f9cf9f48989186cf9a9840fa | dictionary catalogue control row and CI failure | fixed | design-system-contract,design-system-adoption,format-changed,vitest-focused,merge-tree |
81 changes: 39 additions & 42 deletions src/components/dictionary/dictionary-catalogue-pages.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,9 +121,13 @@ function catalogueNoun(scope: DictionaryCatalogueScope, count: number) {
* a hard constraint with committed tests behind it.
*
* The phone control row is sized to its own labels rather than to the viewport.
* It keeps the complete “Abbreviations” and “Filter” wordmarks, with tighter
* phone-only horizontal padding so the idle controls remain one balanced row at
* common phone widths and wrap, rather than clip, on compact screens.
* The Terms / Abbreviations switch is a joined two-cell toggle (one border, no
* gap), compact in type and height so it does not compete with Filter. Counts
* sit beside the labels with an explicit accessible name ("Terms (2)") so they
* do not concatenate. Filter stays on this row at every breakpoint — including
* during a search — rather than moving into the query ribbon. Idle controls
* remain one balanced row at common phone widths and wrap, rather than clip,
* on compact screens.
*/
export function DictionaryCataloguePage() {
const { searchParams, replace, setOne, toggleMany } = useDictionaryUrl();
Expand DownExpand Up@@ -285,48 +289,49 @@ export function DictionaryCataloguePage() {
})),
];

/* Sized to its own labels, not to the viewport, and joined into one border
with no gap. The counts sit inline, so the row needs no summary line of its
own while browsing. */
/* Joined two-cell toggle: one border, no gap, sized to its own labels.
Compact (28px, 11px type) so the row stays a control strip rather than a
second header. `aria-label` keeps the accessible name "Terms (2)" rather
than the concatenated "Terms2" jammed digits produced. Focus uses an inset
outline because the joined track clips an outset ring. */
const scopeToggle = (
<div
role="group"
aria-label="Show"
data-testid="dictionary-scope-toggle"
className="inline-flex min-h-tap shrink-0 items-stretch overflow-hidden rounded-lg border border-[color:var(--border)]"
className="inline-flex h-7 shrink-0 items-stretch overflow-hidden rounded-md border border-[color:var(--border)] bg-[color:var(--clinical-accent-soft)]"
>
{scopeOptions.map((option) => {
const active = params.scope === option.value;
const count = scopeCounts[option.value];
return (
<button
key={option.value}
type="button"
aria-pressed={active}
aria-label={`${option.label} (${count})`}
aria-controls="dictionary-catalogue-results"
onClick={() => setOne("view", option.value, "definitions")}
className={cn(
"inline-flex items-center gap-0.5 px-1.5 text-xs font-extrabold transition-colors motion-reduce:transition-none sm:gap-1 sm:px-3",
focusRing,
"inline-flex h-full items-center gap-1 px-2 text-2xs font-semibold leading-none tracking-tight transition-colors motion-reduce:transition-none sm:px-2.5",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]",
active
? "bg-[color:var(--tone-purple)] text-[color:var(--surface)] forced-colors:outline forced-colors:outline-2 forced-colors:[outline-color:Highlight]"
: "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)] hover:bg-[color:var(--tone-purple-soft)]",
: "bg-transparent text-[color:var(--clinical-accent)] hover:bg-[color:var(--tone-purple-soft)]",
)}
>
{option.label}
{/* No `opacity-80` de-emphasis, which the design-scratch mockup
carries: on the inactive segment it drops accent-on-accent-soft
to 3.42:1 and axe fails it as a serious contrast violation. The
count is already secondary by being a number after a word. */}
<span className="nums">{scopeCounts[option.value]}</span>
<span>{option.label}</span>
<span className="nums font-medium tabular-nums">{count}</span>
</button>
);
})}
</div>
);

/* The phone's whole alphabet in one 63px control. 27 chips cost a band and a
/* The phone's whole alphabet in one chip. 27 chips cost a band and a
horizontal scroll; the rail below is the same control at a width that can
afford it. */
afford it. Keep the 48px tap floor (`min-h-tap min-w-tap`) so this control
matches Filter on both axes; the compact toggle stays visually smaller. */
const letterChip = (
<button
type="button"
Expand All@@ -337,12 +342,12 @@ export function DictionaryCataloguePage() {
data-testid="dictionary-letter-chip"
title="Jump to a letter"
className={cn(
"inline-flex min-h-tap shrink-0 items-center gap-0.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-1.5 text-xs font-extrabold text-[color:var(--clinical-accent)] sm:hidden",
"inline-flex min-h-tap min-w-tap shrink-0 items-center gap-0.5 overflow-hidden rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-1.5 text-2xs font-semibold leading-none text-[color:var(--clinical-accent)] sm:hidden",
focusRing,
)}
>
{params.letter === "all" ? "A–Z" : params.letter}
<ChevronDown className="size-icon-sm shrink-0 text-[color:var(--text-muted)]" aria-hidden="true" />
<ChevronDown className="size-icon-xs shrink-0 text-[color:var(--text-muted)]" aria-hidden="true" />
<span className="sr-only">
{params.letter === "all" ? " · jump to a letter" : ` · jump to a letter, currently ${params.letter}`}
</span>
Expand All@@ -362,8 +367,9 @@ export function DictionaryCataloguePage() {
);

/* Clears the query from the band's own line, which is where the reader is
looking when they decide they are done with it. It is one shared control at
every breakpoint; Filter has the band’s dedicated phone slot. */
looking when they decide they are done with it. Filter stays on the control
row with Terms / Abbreviations, so the ribbon only carries the query, the
match count, and this dismiss control. */
const clearQueryControl = (
<button
type="button"
Expand DownExpand Up@@ -413,39 +419,30 @@ export function DictionaryCataloguePage() {
matchCount={hits.length}
status="ready"
resultNoun={noun}
utilityControls={
<>
{searching ? clearQueryControl : null}
<span className="hidden shrink-0 sm:flex">{filterTrigger("desktop")}</span>
</>
}
mobileControls={filterTrigger("phone")}
mobileControlsPlacement="inline"
utilityControls={searching ? clearQueryControl : undefined}
appliedFilters={appliedFilters}
onClearFilters={activeCount ? clearFilters : undefined}
/>
</div>
) : null}
<div className="border-y border-[color:var(--border)] bg-[color:var(--surface)]">
<div className="mx-auto grid w-full max-w-[76rem] gap-1.5 px-3 py-2.5 sm:gap-3 sm:px-6 sm:py-3">
{/* The phone gutter and internal gaps are deliberately tight enough
for the complete labels to stay on one row at 390px. At 320px the
intrinsic controls still wrap rather than squeezing counts or
clipping the Filter wordmark. */}
<div className="flex flex-wrap items-center gap-2">
<div className="mx-auto grid w-full max-w-[76rem] gap-1.5 px-4 py-2 sm:gap-3 sm:px-6 sm:py-3">
{/* Filter is a permanent member of this row, including during a
search. The compact toggle is vertically centred against the
taller Filter; at 320px the intrinsic controls wrap rather than
squeezing counts or clipping the Filter wordmark. */}
<div className="flex flex-wrap items-center gap-1.5">
{scopeToggle}
{/* The alphabet is meaningless against a ranked result set, so it
stands down rather than competing with the words for the line.
`dictionaryCatalogue` drops the letter from the predicate at the
same time, so nothing narrows the list without a visible
control saying so. */}
{searching ? null : letterChip}
{showBand ? null : (
<span className="ml-auto flex items-center gap-2">
<span className="hidden sm:flex">{filterTrigger("desktop")}</span>
<span className="flex sm:hidden">{filterTrigger("phone")}</span>
</span>
)}
<span className="ml-auto inline-flex shrink-0 items-center gap-2">
<span className="hidden sm:flex">{filterTrigger("desktop")}</span>
<span className="flex sm:hidden">{filterTrigger("phone")}</span>
</span>
</div>
{/* Wraps rather than scrolls: 27 chips overrun the 76rem container by
a chip's width, and a rail that clips Z is worse than a rail that
Expand Down
96 changes: 67 additions & 29 deletions tests/ui-dictionary.spec.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -144,65 +144,103 @@ test("merges search and browse into one catalogue with a measured phone header",
// offset below it is wrong until it settles (#XPY409, docs/testing.md).
await page.waitForTimeout(1200);

// Browsing: one row of controls, no summary line, and the scope toggle sized
// to its own full labels rather than to the viewport.
// Browsing: no summary line, and the joined two-cell toggle sized to its own
// labels rather than stretching to the viewport. Compact in type and height;
// counts use an explicit accessible name so it is "Abbreviations (N)" rather
// than a jammed "AbbreviationsN".
const toggle = page.getByTestId("dictionary-scope-toggle");
await expect(toggle).toBeVisible();
await expect(toggle.getByRole("button", { name: /Abbreviations/ })).toBeVisible();
await expect(toggle.getByRole("button", { name: /Abbreviations \(\d+\)/ })).toBeVisible();
await expect(toggle.getByRole("button", { name: /Terms \(\d+\)/ })).toBeVisible();
const toggleBox = await toggle.boundingBox();
expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(48);
// The complete labels remain intrinsic rather than stretching to fill the row.
expect(toggleBox?.width ?? 0).toBeLessThan(260);
expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(24);
expect(toggleBox?.height ?? 0).toBeLessThanOrEqual(36);
// Intrinsic, not full-bleed — the joined cells must not consume the Filter slot.
expect(toggleBox?.width ?? 0).toBeLessThan(300);
expect(toggleBox?.width ?? 0).toBeGreaterThan(140);
await expect(page.getByTestId("dictionary-letter-chip")).toBeVisible();
await expect(page.getByTestId("dictionary-filter-trigger-phone").getByText("Filter", { exact: true })).toBeVisible();
const letterChip = page.getByTestId("dictionary-letter-chip");
const letterBox = await letterChip.boundingBox();
expect(letterBox?.height ?? 0).toBeGreaterThanOrEqual(48);
expect(letterBox?.width ?? 0).toBeGreaterThanOrEqual(48);
const filter = page.getByTestId("dictionary-filter-trigger-phone");
await expect(filter.getByText("Filter", { exact: true })).toBeVisible();
await expect(page.getByTestId("search-query-ribbon")).toHaveCount(0);
// Every control on one line: same top edge, no wrap at 390px.
const rowTops = await page.evaluate(() => {
const ids = ["dictionary-scope-toggle", "dictionary-letter-chip", "dictionary-filter-trigger-phone"];
return ids.map((id) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect().top ?? -1);
// Filter stays on this row, to the right of the toggle. The compact toggle
// plus the letter chip may wrap Filter at 390px; that is preferred to
// clipping a count. Same top edge is required when they do fit.
const browseRow = await page.evaluate(() => {
const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null;
const toggle = box("dictionary-scope-toggle");
const letter = box("dictionary-letter-chip");
const filter = box("dictionary-filter-trigger-phone");
const sameRow = Boolean(
toggle &&
filter &&
filter.top < toggle.bottom - 4 &&
filter.bottom > toggle.top + 4 &&
filter.left >= toggle.right - 2,
);
return {
toggleTop: toggle?.top ?? -1,
letterTop: letter?.top ?? -1,
filterTop: filter?.top ?? -1,
filterBottomPastToggle: (filter?.top ?? 0) >= (toggle?.bottom ?? 9999) - 2,
sameRow,
overflow: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth,
};
});
expect(rowTops.every((top) => top > 0)).toBe(true);
expect(Math.max(...rowTops) - Math.min(...rowTops)).toBeLessThanOrEqual(2);
expect(browseRow.toggleTop).toBeGreaterThan(0);
expect(browseRow.letterTop).toBeGreaterThan(0);
expect(browseRow.filterTop).toBeGreaterThan(0);
expect(browseRow.overflow).toBeLessThanOrEqual(2);
expect(browseRow.sameRow || browseRow.filterBottomPastToggle).toBe(true);

// At 320px the same controls want more than the track has, and the contract
// is that the row WRAPS rather than clipping a count out of view.
// At 320px the same controls may wrap; the contract is that they do not clip
// a count out of view, whether they stay on one line or wrap.
await page.setViewportSize({ width: 320, height: 760 });
await page.waitForTimeout(400);
const narrowRow = await page.evaluate(() => {
const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null;
return {
toggleWidth: box("dictionary-scope-toggle")?.width ?? 0,
toggleTop: box("dictionary-scope-toggle")?.top ?? 0,
filterTop: box("dictionary-filter-trigger-phone")?.top ?? 0,
overflow: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0) - window.innerWidth,
};
});
expect(narrowRow.filterTop).toBeGreaterThan(narrowRow.toggleTop);
// Same intrinsic width as at 390px — it wrapped, it did not shrink.
expect(narrowRow.toggleWidth).toBeGreaterThan(160);
expect(narrowRow.toggleWidth).toBeGreaterThan(140);
expect(narrowRow.overflow).toBeLessThanOrEqual(2);
await page.setViewportSize({ width: 390, height: 844 });

// Searching: the query gets a line of its own and the alphabet stands down.
// Searching: the query gets a line of its own, the alphabet stands down, and
// Filter stays on the Terms / Abbreviations row rather than joining the ribbon.
await gotoDictionary(page, "/dictionary/search?q=tardive+dyskinesia", "dictionary-catalogue-main");
await page.waitForTimeout(1200);
const ribbon = page.getByTestId("search-query-ribbon");
await expect(ribbon).toBeVisible();
await expect(ribbon.getByTestId("dictionary-clear-query")).toBeVisible();
await expect(
ribbon.getByTestId("dictionary-filter-trigger-phone").getByText("Filter", { exact: true }),
).toBeVisible();
await expect(ribbon.getByTestId("dictionary-filter-trigger-phone")).toHaveCount(0);
await expect(filter.getByText("Filter", { exact: true })).toBeVisible();
await expect(page.getByTestId("dictionary-letter-chip")).toHaveCount(0);

const resultControls = await page.evaluate(() => {
const ribbon = document.querySelector('[data-testid="search-query-ribbon"]');
const box = (testId: string) => ribbon?.querySelector(`[data-testid="${testId}"]`)?.getBoundingClientRect() ?? null;
const box = (id: string) => document.querySelector(`[data-testid="${id}"]`)?.getBoundingClientRect() ?? null;
const toggle = box("dictionary-scope-toggle");
const filter = box("dictionary-filter-trigger-phone");
const toggleMid = toggle ? (toggle.top + toggle.bottom) / 2 : -1;
const filterMid = filter ? (filter.top + filter.bottom) / 2 : -1;
return {
clearTop: box("dictionary-clear-query")?.top ?? -1,
filterTop: box("dictionary-filter-trigger-phone")?.top ?? -1,
sameRow: Boolean(
toggle &&
filter &&
filter.top < toggle.bottom - 4 &&
filter.bottom > toggle.top + 4 &&
filter.left >= toggle.right - 2,
),
centersAligned: Math.abs(filterMid - toggleMid) <= 4,
};
});
expect(Math.abs(resultControls.clearTop - resultControls.filterTop)).toBeLessThanOrEqual(2);
expect(resultControls.sameRow).toBe(true);
expect(resultControls.centersAligned).toBe(true);

// Its own line: the band sits entirely above the control row.
const geometry = await page.evaluate(() => {
Expand Down
Loading