diff --git a/src/components/therapy-compass/screens/search-screen.tsx b/src/components/therapy-compass/screens/search-screen.tsx
index 14c831219..da416d9e8 100644
--- a/src/components/therapy-compass/screens/search-screen.tsx
+++ b/src/components/therapy-compass/screens/search-screen.tsx
@@ -1,6 +1,9 @@
"use client";
-import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
+import {
+ MobileResultFilterControl,
+ SearchResultsHeaderBand,
+} from "@/components/clinical-dashboard/search-results-header-band";
import { useTcBindings } from "../bindings";
import { outlineControl, softControl } from "../controls";
@@ -17,6 +20,7 @@ export function SearchScreen() {
const q = b.search.query;
const results = b.searchResults;
const shown = results.slice(0, MAX_CARDS);
+ const availabilityFilterCount = Number(b.search.reviewedOnly) + Number(b.search.briefOnly);
return (
@@ -44,6 +48,55 @@ export function SearchScreen() {
matchCount={results.length}
loading={b.loading}
filterLabel="Filter therapy results"
+ mobileControls={
+
+ ({
+ value: tag,
+ label: `${b.search.tags.includes(tag) ? "✓ " : ""}${tag}`,
+ })),
+ ]}
+ onChange={(tag) => {
+ if (tag) b.toggleTag(tag);
+ }}
+ />
+ {
+ if (filter === "reviewed") b.toggleReviewedOnly();
+ if (filter === "brief") b.toggleBriefOnly();
+ if (filter === "clear") b.clearSearch();
+ }}
+ />
+
+ }
filterControls={
{QUICK_TAGS.map((tag) => {
diff --git a/tests/search-results-header-band.dom.test.tsx b/tests/search-results-header-band.dom.test.tsx
index 378ba8e1d..faee7738e 100644
--- a/tests/search-results-header-band.dom.test.tsx
+++ b/tests/search-results-header-band.dom.test.tsx
@@ -5,7 +5,10 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context";
-import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
+import {
+ MobileResultFilterControl,
+ SearchResultsHeaderBand,
+} from "@/components/clinical-dashboard/search-results-header-band";
describe("SearchResultsHeaderBand", () => {
it("presents the query and completed count as one labelled results ribbon", () => {
@@ -128,4 +131,47 @@ describe("SearchResultsHeaderBand", () => {
expect(onOpenSources).toHaveBeenCalledOnce();
expect(onFilterTables).toHaveBeenCalledOnce();
});
+
+ it("pairs sort with a page-specific dropdown on mobile without changing either action", async () => {
+ const user = userEvent.setup();
+ const onSortChange = vi.fn();
+ const onFilterChange = vi.fn();
+
+ render(
+
+ }
+ filterControls={
+
+ Desktop filters
+
+ }
+ />,
+ );
+
+ const pair = screen.getByTestId("search-query-ribbon-mobile-control-pair");
+ await user.selectOptions(within(pair).getByLabelText("Sort results"), "alpha");
+ await user.selectOptions(within(pair).getByLabelText("Filter by result type"), "diagnosis");
+
+ expect(onSortChange).toHaveBeenCalledWith("alpha");
+ expect(onFilterChange).toHaveBeenCalledWith("diagnosis");
+ expect(screen.getByTestId("search-query-ribbon-filters")).toHaveClass("hidden", "sm:block");
+ });
});
diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts
index 7f46d0cd6..7d1f133f0 100644
--- a/tests/ui-accessibility.spec.ts
+++ b/tests/ui-accessibility.spec.ts
@@ -389,7 +389,7 @@ test.describe("Clinical KB accessibility coverage", () => {
await expectNoBlockingAxeViolations(page, testInfo, { disableRules: ["color-contrast"] });
});
- test("differential result types are pressed filters instead of tabs", async ({ page }) => {
+ test("differential result types use an accessible mobile filter instead of tabs", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockMinimalDashboardApi(page);
await mockDifferentialSearch(page);
@@ -399,24 +399,18 @@ test.describe("Clinical KB accessibility coverage", () => {
await page.locator('button[aria-label="Search differential presentations"]:visible').click();
await expect(page.getByTestId("differentials-search-results")).toBeVisible();
- const filterGroup = page.getByRole("group", { name: "Result type", exact: true });
- await expect(filterGroup).toBeVisible();
- await expect(filterGroup.getByRole("tab")).toHaveCount(0);
- const allFilter = filterGroup.getByRole("button", { name: /^All \(\d+\)$/ });
- const presentationsFilter = filterGroup.getByRole("button", { name: /^Presentations \(\d+\)$/ });
- const diagnosesFilter = filterGroup.getByRole("button", { name: /^Diagnoses \(\d+\)$/ });
- await expect(allFilter).toHaveAttribute("aria-pressed", "true");
- await expect(presentationsFilter).toHaveAttribute("aria-pressed", "false");
-
- await presentationsFilter.focus();
- await page.keyboard.press("Space");
- await expect(presentationsFilter).toHaveAttribute("aria-pressed", "true");
- await expect(allFilter).toHaveAttribute("aria-pressed", "false");
-
- await diagnosesFilter.focus();
- await page.keyboard.press("Enter");
- await expect(diagnosesFilter).toHaveAttribute("aria-pressed", "true");
- await expect(presentationsFilter).toHaveAttribute("aria-pressed", "false");
+ const filterSelect = page.getByTestId("differential-result-type-select");
+ await expect(filterSelect).toBeVisible();
+ await expect(filterSelect).toHaveAccessibleName("Filter by result type");
+ await expect(filterSelect).toHaveValue("all");
+ await expect(page.getByRole("tab")).toHaveCount(0);
+
+ await filterSelect.focus();
+ await expect(filterSelect).toBeFocused();
+ await filterSelect.selectOption("presentation");
+ await expect(filterSelect).toHaveValue("presentation");
+ await filterSelect.selectOption("diagnosis");
+ await expect(filterSelect).toHaveValue("diagnosis");
});
test("guest upload action exposes the admin boundary and opens Sources", async ({ page }) => {
@@ -474,6 +468,12 @@ test.describe("Clinical KB accessibility coverage", () => {
const therapyRibbon = page.getByTestId("search-query-ribbon");
await expect(therapyRibbon.getByRole("heading", { name: "All" })).toBeVisible();
await expect(therapyRibbon.getByRole("group", { name: "Filter therapy results" })).toBeVisible();
+ const therapyTopics = therapyRibbon.getByTestId("therapy-topic-filter-select");
+ const therapyAvailability = therapyRibbon.getByTestId("therapy-availability-filter-select");
+ await expect(therapyTopics).toBeVisible();
+ await expect(therapyTopics).toHaveAccessibleName("Add or remove a therapy topic filter");
+ await expect(therapyAvailability).toBeVisible();
+ await expect(therapyAvailability).toHaveAccessibleName("Change therapy availability filters");
const searchInput = page.getByRole("textbox", { name: "Search therapies" });
await searchInput.focus();
@@ -484,14 +484,23 @@ test.describe("Clinical KB accessibility coverage", () => {
expect(inputFocus.outlineStyle).not.toBe("none");
expect(inputFocus.outlineWidth).toBeGreaterThanOrEqual(2);
- const clearButtonSize = await page
- .locator('[data-screen-label="Search"]')
- .getByRole("button", { name: "Clear", exact: true })
- .evaluate((element) => {
+ await therapyTopics.selectOption("CBT");
+ await expect(therapyTopics.locator('option[value=""]')).toHaveText("1 topics selected");
+ await therapyAvailability.selectOption("reviewed");
+ await expect(therapyAvailability.locator('option[value=""]')).toHaveText("1 more selected");
+
+ for (const control of [therapyTopics, therapyAvailability]) {
+ const controlSize = await control.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return { width: bounds.width, height: bounds.height };
});
- expect(clearButtonSize.height).toBeGreaterThanOrEqual(44);
+ expect(controlSize.width).toBeGreaterThanOrEqual(44);
+ expect(controlSize.height).toBeGreaterThanOrEqual(44);
+ }
+
+ await therapyAvailability.selectOption("clear");
+ await expect(therapyTopics.locator('option[value=""]')).toHaveText("All topics");
+ await expect(therapyAvailability.locator('option[value=""]')).toHaveText("Any availability");
await expectNoPageHorizontalOverflow(page);
diff --git a/tests/ui-formulation.spec.ts b/tests/ui-formulation.spec.ts
index 0a458ec71..9ae2f4c7f 100644
--- a/tests/ui-formulation.spec.ts
+++ b/tests/ui-formulation.spec.ts
@@ -100,7 +100,10 @@ test("keeps mobile search, domain filtering, record actions, and universal chrom
await expect(queryRibbon.getByRole("heading", { level: 1, name: "What if something goes wrong" })).toBeVisible();
await expect(queryRibbon.getByRole("group", { name: "Filter formulation mechanisms" })).toBeVisible();
await expect(page.getByRole("link", { name: "Worry", exact: true })).toBeVisible();
- await expect(page.getByRole("combobox", { name: "Filter by formulation domain" })).toBeVisible();
+ await expect(queryRibbon.getByTestId("formulation-pattern-select")).toBeVisible();
+ const domainSelect = queryRibbon.getByTestId("formulation-domain-select");
+ await expect(domainSelect).toBeVisible();
+ await expect(domainSelect).toHaveAccessibleName("Filter by formulation domain");
await expect(page.getByTestId("global-search-input").filter({ visible: true }).first()).toBeVisible();
await expect(page.getByText("Source status", { exact: true })).toHaveCount(0);
await expect(page.getByText("Source", { exact: true })).toHaveCount(0);
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index ba081f49a..1ec892593 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -2603,6 +2603,13 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(queryRibbon.getByRole("heading", { name: "sertraline" })).toBeVisible();
await expect(queryRibbon.getByRole("group", { name: "Result view" })).toBeVisible();
await expect(queryRibbon.getByRole("group", { name: "Filter factsheets by category" })).toBeVisible();
+ const categorySelect = queryRibbon.getByTestId("factsheet-category-select");
+ if (viewport.width < 640) {
+ await expect(categorySelect).toBeVisible();
+ await expect(categorySelect).toHaveAccessibleName("Filter factsheets by category");
+ } else {
+ await expect(categorySelect).toBeHidden();
+ }
await expectNoPageHorizontalOverflow(page);
}
});
@@ -3042,9 +3049,12 @@ test.describe("Clinical KB UI smoke coverage", () => {
const documentWorkspace = page.getByTestId("document-search-workspace");
const queryRibbon = documentWorkspace.getByTestId("search-query-ribbon");
await expect(queryRibbon).toBeVisible();
- await expect(queryRibbon.getByTestId("document-results-controls")).toBeVisible();
const resultsControls = queryRibbon.getByTestId("document-results-controls");
+ await expect(resultsControls).toBeHidden();
await expect(queryRibbon.getByLabel("Sort results")).toBeVisible();
+ const mobileTypeFilter = queryRibbon.getByTestId("document-source-type-select");
+ await expect(mobileTypeFilter).toBeVisible();
+ await expect(mobileTypeFilter).toHaveAccessibleName("Filter by source type");
const ribbonSourcesButton = queryRibbon.getByRole("button", { name: "Open source filters" });
await expect(ribbonSourcesButton).toBeVisible();
await expectMinTouchTarget(ribbonSourcesButton);
@@ -3058,15 +3068,11 @@ test.describe("Clinical KB UI smoke coverage", () => {
await expect(documentResults).toContainText("Best match");
await expect(documentResults).toContainText("1 table");
- const typeFilters = resultsControls.getByLabel("Filter by source type");
- if ((await typeFilters.count()) > 0) {
- const tablesFilter = typeFilters.getByRole("button", { name: /Tables/i });
- await expect(tablesFilter).toBeVisible();
- await expectMinTouchTarget(tablesFilter);
- await tablesFilter.click();
- await expect(tablesFilter).toHaveAttribute("aria-pressed", "true");
+ if ((await mobileTypeFilter.locator('option[value="tables"]').count()) > 0) {
+ await mobileTypeFilter.selectOption("tables");
+ await expect(mobileTypeFilter).toHaveValue("tables");
await expect(documentResults).toBeVisible();
- await typeFilters.getByRole("button", { name: /^All/i }).click();
+ await mobileTypeFilter.selectOption("all");
}
await queryRibbon.getByLabel("Sort results").selectOption("alpha");
@@ -3088,6 +3094,13 @@ test.describe("Clinical KB UI smoke coverage", () => {
await page.setViewportSize({ width: 1440, height: 900 });
await expectNoPageHorizontalOverflow(page);
+ await expect(resultsControls).toBeVisible();
+ const typeFilters = resultsControls.getByLabel("Filter by source type");
+ if ((await typeFilters.count()) > 0) {
+ const tablesFilter = typeFilters.getByRole("button", { name: /Tables/i });
+ await expect(tablesFilter).toBeVisible();
+ await expectMinTouchTarget(tablesFilter);
+ }
const dashboardMain = page.locator("main#main-content");
const scrollTopBeforeSources = await dashboardMain.evaluate((element) => element.scrollTop);
const openSourcesButton = queryRibbon.getByRole("button", { name: "Open source filters" });
diff --git a/tests/ui-specifiers.spec.ts b/tests/ui-specifiers.spec.ts
index 047b2d803..f13f33bb8 100644
--- a/tests/ui-specifiers.spec.ts
+++ b/tests/ui-specifiers.spec.ts
@@ -108,18 +108,21 @@ test("keeps mobile search, filters, results, and the fixed composer usable", asy
await expect(queryRibbon.getByRole("heading", { level: 1, name: "returns every winter" })).toBeVisible();
await expect(queryRibbon.getByRole("group", { name: "Filter specifier results" })).toBeVisible();
await expect(page.getByRole("link", { name: "Open With seasonal pattern" })).toBeVisible();
- await expect(page.getByRole("group", { name: "Filter by specifier family" })).toBeVisible();
- await expect(page.getByRole("combobox", { name: "Filter by diagnosis" })).toBeVisible();
+ const familySelect = queryRibbon.getByTestId("specifier-family-select");
+ const diagnosisSelect = queryRibbon.getByTestId("specifier-diagnosis-select");
+ await expect(familySelect).toBeVisible();
+ await expect(familySelect).toHaveAccessibleName("Filter by specifier family");
+ await expect(diagnosisSelect).toBeVisible();
+ await expect(diagnosisSelect).toHaveAccessibleName("Filter by diagnosis");
await expect(page.getByTestId("global-search-input").filter({ visible: true }).first()).toBeVisible();
await expect(page.getByText("Source status", { exact: true })).toHaveCount(0);
await expect(page.getByText("Source", { exact: true })).toHaveCount(0);
- const courseFilter = page.getByRole("button", { name: /^(Course|Course and onset)$/ });
- await courseFilter.click();
- await expect(courseFilter).toHaveAttribute("aria-pressed", "true");
+ await familySelect.selectOption("course-onset");
+ await expect(familySelect).toHaveValue("course-onset");
await expect(page.getByRole("link", { name: "Open With seasonal pattern" })).toBeVisible();
- await page.getByRole("combobox", { name: "Filter by diagnosis" }).selectOption("depressive");
+ await diagnosisSelect.selectOption("depressive");
await expect(page.getByRole("link", { name: "Open With seasonal pattern" })).toBeVisible();
await expectNoHorizontalOverflow(page);
diff --git a/tests/ui-stress.spec.ts b/tests/ui-stress.spec.ts
index 6674a2d63..0be0a37fc 100644
--- a/tests/ui-stress.spec.ts
+++ b/tests/ui-stress.spec.ts
@@ -442,12 +442,15 @@ test.describe("Medication responsive stress coverage", () => {
await expect(phoneResult).toBeVisible();
await expect(desktopResult).toBeHidden();
- const metrics = await page.evaluate(() => {
+ const metrics = await page.evaluate((viewportWidth) => {
const workspace = document.querySelector(".medication-results-workspace");
const patient = document.querySelector(".medication-patient-strip");
const filters = document.querySelector(".medication-filter-strip");
const card = document.querySelector('[data-testid="medication-result-acamprosate-phone"]');
- const firstFilter = filters?.querySelector("button");
+ const firstFilter =
+ viewportWidth < 640
+ ? document.querySelector('[data-testid="medication-result-filter-select"]')
+ : filters?.querySelector("button");
if (!workspace || !patient || !filters || !card || !firstFilter) return null;
const workspaceRect = workspace.getBoundingClientRect();
const patientRect = patient.getBoundingClientRect();
@@ -466,7 +469,7 @@ test.describe("Medication responsive stress coverage", () => {
filterLeft: filterRect.left,
filterHeight: filterRect.height,
};
- });
+ }, viewport.width);
expect(metrics).not.toBeNull();
expect(metrics?.filterHeight ?? 0).toBeGreaterThanOrEqual(42);
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index 62c79f134..035a16634 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -370,6 +370,12 @@ test.describe("Clinical KB tools launcher", () => {
await expect(page.locator("#launcher-results-panel")).toHaveAttribute("role", "group");
await expect(page.locator("#launcher-results-panel")).toHaveAttribute("aria-label", "All tools");
if (viewport.name === "mobile") {
+ const categorySelect = page.getByTestId("tool-category-select");
+ await expect(categorySelect).toBeVisible();
+ await expect(categorySelect).toHaveAccessibleName("Filter by tool category");
+ await categorySelect.selectOption("assessment");
+ await expect(page.locator("#launcher-results-panel")).toHaveAttribute("aria-label", "Assess tools");
+ await categorySelect.selectOption("all");
await page.getByTestId("application-row-medication-prescribing").click();
const selectedSheet = page.getByRole("dialog", { name: "Medication Prescribing" });
await expect(selectedSheet).toBeVisible();
@@ -752,6 +758,11 @@ test.describe("Clinical KB tools launcher", () => {
await expect(page.getByRole("button", { name: "Mode Services" })).toBeVisible();
const input = visibleGlobalSearchInput(page).first();
await expect(input).toBeVisible();
+ const quickFilter = page.getByTestId("service-quick-filter-select");
+ await expect(quickFilter).toBeVisible();
+ await expect(quickFilter).toHaveAccessibleName("Apply a quick service filter");
+ await quickFilter.selectOption("crisis");
+ await expect(page).toHaveURL(/\/services\?.*q=crisis/);
// Phones keep the full search results in the page instead of opening a
// command sheet over the small viewport.
@@ -1466,23 +1477,25 @@ test.describe("Clinical KB tools launcher", () => {
await submitDifferentialSearch(page, "acute confusion");
await expect(page.getByTestId("differentials-search-results")).toBeVisible();
- const tabs = page.getByTestId("differential-result-type-tabs");
- await expect(tabs).toBeVisible();
- await expect(tabs).toHaveAttribute("role", "group");
- await expect(tabs).toHaveAttribute("aria-label", "Result type");
- await expect(tabs.getByRole("button", { name: /All \(\d+\)/ })).toBeVisible();
- await expect(tabs.getByRole("button", { name: /Presentations \(\d+\)/ })).toBeVisible();
- await expect(tabs.getByRole("button", { name: /Diagnoses \(\d+\)/ })).toBeVisible();
-
- const tabMetrics = await tabs.getByRole("button").evaluateAll((buttons) =>
- buttons.map((button) => {
- const rect = button.getBoundingClientRect();
- return { height: rect.height, scrollHeight: button.scrollHeight };
- }),
- );
- for (const tab of tabMetrics) {
- expect(tab.scrollHeight).toBeLessThanOrEqual(tab.height + 1);
- }
+ const typeSelect = page.getByTestId("differential-result-type-select");
+ await expect(typeSelect).toBeVisible();
+ await expect(typeSelect).toHaveAccessibleName("Filter by result type");
+ await expect(typeSelect).toHaveValue("all");
+ await typeSelect.selectOption("diagnosis");
+ await expect(typeSelect).toHaveValue("diagnosis");
+ await typeSelect.selectOption("all");
+
+ const mobilePair = page.getByTestId("search-query-ribbon-mobile-control-pair");
+ const pairMetrics = await mobilePair.evaluate((element) => ({
+ width: element.getBoundingClientRect().width,
+ scrollWidth: element.scrollWidth,
+ controlHeights: Array.from(element.querySelectorAll("label")).map(
+ (label) => label.getBoundingClientRect().height,
+ ),
+ }));
+ expect(pairMetrics.scrollWidth).toBeLessThanOrEqual(pairMetrics.width + 1);
+ expect(pairMetrics.controlHeights).toHaveLength(2);
+ expect(Math.abs(pairMetrics.controlHeights[0] - pairMetrics.controlHeights[1])).toBeLessThanOrEqual(1);
const emergentBadge = page.getByTestId("differential-status-badge").first();
await expect(emergentBadge).toBeVisible();
@@ -1564,23 +1577,25 @@ test.describe("Clinical KB tools launcher", () => {
await submitDifferentialSearch(page, "acute confusion");
await expect(page.getByTestId("differentials-search-results")).toBeVisible();
- const tabs = page.getByTestId("differential-result-type-tabs");
- await expect(tabs).toBeVisible();
- await expect(tabs).toHaveAttribute("role", "group");
- await expect(tabs).toHaveAttribute("aria-label", "Result type");
- await expect(tabs.getByRole("button", { name: "All (8)" })).toBeVisible();
- await expect(tabs.getByRole("button", { name: "Presentations (1)" })).toBeVisible();
- await expect(tabs.getByRole("button", { name: "Diagnoses (7)" })).toBeVisible();
-
- const tabMetrics = await tabs.getByRole("button").evaluateAll((tabElements) =>
- tabElements.map((tab) => {
- const rect = tab.getBoundingClientRect();
- return { height: rect.height, scrollHeight: tab.scrollHeight };
- }),
- );
- for (const tab of tabMetrics) {
- expect(tab.scrollHeight).toBeLessThanOrEqual(tab.height + 1);
- }
+ const typeSelect = page.getByTestId("differential-result-type-select");
+ await expect(typeSelect).toBeVisible();
+ await expect(typeSelect).toHaveAccessibleName("Filter by result type");
+ await expect(typeSelect).toHaveValue("all");
+ await typeSelect.selectOption("presentation");
+ await expect(typeSelect).toHaveValue("presentation");
+ await typeSelect.selectOption("all");
+
+ const mobilePair = page.getByTestId("search-query-ribbon-mobile-control-pair");
+ const pairMetrics = await mobilePair.evaluate((element) => ({
+ width: element.getBoundingClientRect().width,
+ scrollWidth: element.scrollWidth,
+ controlHeights: Array.from(element.querySelectorAll("label")).map(
+ (label) => label.getBoundingClientRect().height,
+ ),
+ }));
+ expect(pairMetrics.scrollWidth).toBeLessThanOrEqual(pairMetrics.width + 1);
+ expect(pairMetrics.controlHeights).toHaveLength(2);
+ expect(Math.abs(pairMetrics.controlHeights[0] - pairMetrics.controlHeights[1])).toBeLessThanOrEqual(1);
const emergentBadge = page.getByTestId("differential-status-badge").first();
await expect(emergentBadge).toBeVisible();
@@ -2210,6 +2225,9 @@ test.describe("Responsive layout guards", () => {
await expect(queryRibbon).toBeVisible();
await expect(queryRibbon.getByRole("heading", { name: "acamprosate renal dose" })).toBeVisible();
await expect(queryRibbon.getByRole("status")).toBeVisible();
+ const resultFilter = queryRibbon.getByTestId("medication-result-filter-select");
+ await expect(resultFilter).toBeVisible();
+ await expect(resultFilter).toHaveAccessibleName("Filter medication results");
await expect(bottomDock).toBeVisible();
await page.locator("main#main-content").evaluate((main) => main.scrollTo({ top: main.scrollHeight }));
const resultBox = await resultCard.boundingBox();