+ {/* 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. */}
+
{scopeToggle}
{/* The alphabet is meaningless against a ranked result set, so it
stands down rather than competing with the words for the line.
@@ -440,12 +439,10 @@ export function DictionaryCataloguePage() {
same time, so nothing narrows the list without a visible
control saying so. */}
{searching ? null : letterChip}
- {showBand ? null : (
-
- {filterTrigger("desktop")}
- {filterTrigger("phone")}
-
- )}
+
+ {filterTrigger("desktop")}
+ {filterTrigger("phone")}
+
{/* 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
diff --git a/tests/ui-dictionary.spec.ts b/tests/ui-dictionary.spec.ts
index 8e6cd85b61..62a4e8ac3b 100644
--- a/tests/ui-dictionary.spec.ts
+++ b/tests/ui-dictionary.spec.ts
@@ -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(() => {