diff --git a/docs/branch-review-records/10685ba8c4bac91df3e8b00a440f4fca3019fc74ac081bec1630d13a1540b0a1.record.md b/docs/branch-review-records/10685ba8c4bac91df3e8b00a440f4fca3019fc74ac081bec1630d13a1540b0a1.record.md new file mode 100644 index 000000000..1d8edc73f --- /dev/null +++ b/docs/branch-review-records/10685ba8c4bac91df3e8b00a440f4fca3019fc74ac081bec1630d13a1540b0a1.record.md @@ -0,0 +1 @@ +| 2026-08-17 | PR-2040 | bbb90caeca3c7cbbaebcc29892a6e553bf085bc9 | src/components/services/services-navigator-page.tsx, src/components/services/service-group-nav.tsx (deleted), tests/ui-tools.spec.ts, docs/design-system/* | OPENED PR #2040: folded the standalone services browse nav (All/Urgent/Public MH/More) into the Filter services sheet as a multi-select facet, reusing previously-unwired plumbing in service-core-groups.ts. Deleted ServiceGroupNav. Focused+broader Vitest (126 tests) green, typecheck/lint/design-system-contract clean, manual Chromium walkthrough confirmed correct rendering and URL toggling. | test:focused (81 passed), targeted vitest sweep (45 passed), typecheck, eslint, check:design-system-contract, manual browser walkthrough | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 5d22c379b..f2ae7f7c2 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1001,7 +1001,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 24 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 2 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 6f34baf01..e91d736d4 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1518,7 +1518,6 @@ "src/components/forms/form-detail-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", - "src/components/services/service-group-nav.tsx", "src/components/tools/tools-search-results-page.tsx", "src/components/ui/confirm-dialog.tsx" ], @@ -1546,7 +1545,6 @@ "src/components/forms/form-detail-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", - "src/components/services/service-group-nav.tsx", "src/components/tools/tools-search-results-page.tsx" ], "designSync": { diff --git a/src/components/services/service-group-nav.tsx b/src/components/services/service-group-nav.tsx deleted file mode 100644 index b0866e99f..000000000 --- a/src/components/services/service-group-nav.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { ChevronDown } from "lucide-react"; -import { useId, useState } from "react"; - -import { Sheet } from "@/components/ui/sheet"; -import { cn } from "@/components/ui-primitives"; -import { serviceCoreGroups, type ServiceCoreGroupId } from "@/lib/service-core-groups"; - -type GroupValue = ServiceCoreGroupId | null; - -export function ServiceGroupNav({ - activeGroup, - hrefForGroup, - counts, - className, -}: { - activeGroup: GroupValue; - hrefForGroup: (group: GroupValue) => string; - counts?: Partial> & { all?: number }; - className?: string; -}) { - const [moreOpen, setMoreOpen] = useState(false); - const panelId = useId(); - const phonePrimary = serviceCoreGroups.slice(0, 2); - const phoneMore = serviceCoreGroups.slice(2); - const moreActive = phoneMore.some((group) => group.id === activeGroup); - - const groupLink = (group: (typeof serviceCoreGroups)[number], phone = false) => ( - setMoreOpen(false)} - className={cn( - "inline-flex min-h-tap min-w-0 items-center justify-center gap-1.5 rounded-lg border px-3 text-xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10", - activeGroup === group.id - ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" - : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]", - )} - > - {phone ? group.shortLabel : group.label} - {typeof counts?.[group.id] === "number" ? ( - {counts[group.id]} - ) : null} - - ); - - return ( - - ); -} diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 3e2432722..64ed80986 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -30,17 +30,17 @@ import { resultFilterFacetGroup, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { ServiceGroupNav } from "@/components/services/service-group-nav"; import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; import { cn } from "@/components/ui-primitives"; import { useResultSort } from "@/components/use-result-sort"; import { compactBestUseTitle } from "@/lib/compact-best-use-title"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { - readServiceCoreGroup, + readServiceCoreGroupSelection, serviceCoreGroupLabel, serviceCoreGroups, - serviceMatchesCoreGroup, + serviceMatchesCoreGroupSelection, + writeServiceCoreGroupSelectionToParams, type ServiceCoreGroupId, } from "@/lib/service-core-groups"; import { @@ -410,7 +410,7 @@ export function ServicesNavigatorPage() { const searchParams = useSearchParams(); const [sortValue, setSortValue] = useResultSort(); const urlQuery = searchParams.get("q")?.trim() || searchParams.get("query")?.trim() || ""; - const activeGroup = readServiceCoreGroup(searchParams.get("group")); + const activeGroupSelection = useMemo(() => readServiceCoreGroupSelection(searchParams.get("group")), [searchParams]); const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: urlQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : urlQuery; const deferredQuery = useDeferredValue(query); @@ -426,8 +426,8 @@ export function ServicesNavigatorPage() { return []; }, [deferredQuery, query, searchableRecords]); const groupedMatches = useMemo( - () => rankedMatches.filter((service) => serviceMatchesCoreGroup(service, activeGroup)), - [activeGroup, rankedMatches], + () => rankedMatches.filter((service) => serviceMatchesCoreGroupSelection(service, activeGroupSelection)), + [activeGroupSelection, rankedMatches], ); // Facet selection lives in the URL, alongside `q`/`group`, so a filtered @@ -441,8 +441,15 @@ export function ServicesNavigatorPage() { // without discarding the query. Gated on the UNFACETED result set so the // segment does not flicker away as facets are applied — narrowing with // facets only ever makes "catalogue > results" more true, never less. + // Apply the group selection to the full catalogue so that scope=all also + // honours the group facet — without this, switching to "All services" while + // a group is selected would show unfiltered results and an inconsistent count. + const groupedAllRecords = useMemo( + () => searchableRecords.filter((service) => serviceMatchesCoreGroupSelection(service, activeGroupSelection)), + [activeGroupSelection, searchableRecords], + ); const showResultScope = searchableRecords.length > groupedMatches.length; - const facetBaseMatches = resultScope === "all" ? searchableRecords : groupedMatches; + const facetBaseMatches = resultScope === "all" ? groupedAllRecords : groupedMatches; const facetedMatches = useMemo( () => filterServicesByFacets(facetBaseMatches, facetSelection, substanceLens), [facetBaseMatches, facetSelection, substanceLens], @@ -458,32 +465,51 @@ export function ServicesNavigatorPage() { [groupedMatches, facetSelection, substanceLens], ); const allScopedCount = useMemo( - () => filterServicesByFacets(searchableRecords, facetSelection, substanceLens).length, - [searchableRecords, facetSelection, substanceLens], + () => filterServicesByFacets(groupedAllRecords, facetSelection, substanceLens).length, + [groupedAllRecords, facetSelection, substanceLens], ); const activeFilterCount = - serviceFacetSelectionSize(facetSelection) + (substanceLens === "all" ? 0 : 1) + (resultScope === "all" ? 1 : 0); + serviceFacetSelectionSize(facetSelection) + + (substanceLens === "all" ? 0 : 1) + + (resultScope === "all" ? 1 : 0) + + activeGroupSelection.size; const relevanceRankMap = useMemo(() => { const map = new Map(); rankedMatches.forEach((service, index) => map.set(service.slug, index + 1)); return map; }, [rankedMatches]); + // Group-agnostic base for the group facet's own "how many if I also ticked + // this" counts — `facetBaseMatches` cannot be reused here because it is + // already narrowed by `activeGroupSelection`, which would make every + // unselected group option read as a near-empty intersection with the + // group(s) already active rather than a true widening count. + const coreGroupBaseMatches = resultScope === "all" ? searchableRecords : rankedMatches; + const coreGroupFacetedBase = useMemo( + () => filterServicesByFacets(coreGroupBaseMatches, facetSelection, substanceLens), + [coreGroupBaseMatches, facetSelection, substanceLens], + ); const groupCounts = useMemo( () => Object.fromEntries( - serviceCoreGroups.map((group) => [ - group.id, - rankedMatches.filter((service) => serviceMatchesCoreGroup(service, group.id)).length, - ]), + serviceCoreGroups.map((group) => { + const candidate = activeGroupSelection.has(group.id) + ? activeGroupSelection + : new Set([...activeGroupSelection, group.id]); + return [ + group.id, + coreGroupFacetedBase.filter((service) => serviceMatchesCoreGroupSelection(service, candidate)).length, + ]; + }), ) as Record, - [rankedMatches], + [activeGroupSelection, coreGroupFacetedBase], ); const [selectedSlugs, setSelectedSlugs] = useState([]); const selected = searchableRecords.filter((service) => selectedSlugs.includes(service.slug)); const [showComparison, setShowComparison] = useState(false); const filterPanelId = useId(); const [filterOpen, setFilterOpen] = useState(false); - const heading = query || (activeGroup ? serviceCoreGroupLabel(activeGroup) : "Browse services"); + const activeGroupLabel = activeGroupSelection.size === 1 ? serviceCoreGroupLabel([...activeGroupSelection][0]) : null; + const heading = query || (activeGroupLabel ?? "Browse services"); const accountData = useAccountData(); const [saveNotice, setSaveNotice] = useState(null); // The provider rolls back a failed mutation from its pre-request snapshot. @@ -584,16 +610,17 @@ export function ServicesNavigatorPage() { [updateFilterParams], ); - // Never touches `q`/`query`/`group` — docs/filter-contract.md section 6: - // clearing filters and clearing a search are different intentions. The - // old query-replacing suggestion rail conflated the two (clearing its - // choices also replaced the search). It is gone; this reset now changes - // only scope and narrowing dimensions. + // Never touches `q`/`query` — docs/filter-contract.md section 6: clearing + // filters and clearing a search are different intentions. The old + // query-replacing suggestion rail conflated the two (clearing its choices + // also replaced the search). `group` is now a facet like the rest (folded + // out of the standalone browse nav), so it clears here too. const clearAllFilters = useCallback(() => { updateFilterParams((params) => { for (const dimension of serviceFacetDimensions) params.delete(dimension); params.delete("substance"); params.delete("scope"); + params.delete("group"); }); }, [updateFilterParams]); @@ -607,14 +634,27 @@ export function ServicesNavigatorPage() { }); } - function hrefForGroup(group: ServiceCoreGroupId | null) { + // Only ever called with an empty escape from the zero-results state below — + // individual group values toggle through `toggleCoreGroupValue` instead. + function hrefWithGroupCleared() { const params = new URLSearchParams(searchParams.toString()); params.set("run", "1"); - if (group) params.set("group", group); - else params.delete("group"); + params.delete("group"); return `/services?${params.toString()}`; } + const toggleCoreGroupValue = useCallback( + (value: ServiceCoreGroupId) => { + updateFilterParams((params) => { + const current = readServiceCoreGroupSelection(params.get("group")); + const next = new Set(current); + if (!next.delete(value)) next.add(value); + writeServiceCoreGroupSelectionToParams(params, next); + }); + }, + [updateFilterParams], + ); + // `substance_flags` is an exact partition (measured 2026-08-12: all 219 // services carry exactly one of general/aod) — a lens, not a facet. See // src/lib/service-facets.ts for the full measurement note. @@ -649,6 +689,26 @@ export function ServicesNavigatorPage() { [facetBaseMatches, facetSelection, setSubstanceLensValue, substanceLens, substanceOptionValues], ); + // Service categories overlap (see service-core-groups.ts), so this is a + // facet — many-of-N, OR within the group — rather than the one-of-N lens + // the standalone browse nav it replaces used to be. + const coreGroupFacetGroup = useMemo( + () => + resultFilterFacetGroup({ + id: "core-group", + label: "Service group", + selected: activeGroupSelection, + options: serviceCoreGroups.map((group) => ({ + value: group.id, + label: group.label, + hint: String(groupCounts[group.id]), + disabled: groupCounts[group.id] === 0 && !activeGroupSelection.has(group.id), + })), + onToggle: toggleCoreGroupValue, + }), + [activeGroupSelection, groupCounts, toggleCoreGroupValue], + ); + // Keep the option lists and handlers stable while unrelated page state // changes (for example opening the sheet or updating the shortlist). const facetGroups = useMemo( @@ -707,8 +767,25 @@ export function ServicesNavigatorPage() { }); } } + for (const value of activeGroupSelection) { + chips.push({ + id: `core-group-${value}`, + groupLabel: "Service group", + valueLabel: serviceCoreGroupLabel(value), + onRemove: () => toggleCoreGroupValue(value), + }); + } return chips; - }, [facetSelection, resultScope, setResultScopeValue, setSubstanceLensValue, substanceLens, toggleFacetValue]); + }, [ + activeGroupSelection, + facetSelection, + resultScope, + setResultScopeValue, + setSubstanceLensValue, + substanceLens, + toggleCoreGroupValue, + toggleFacetValue, + ]); return ( -
- -
- setFilterOpen(false)} panelId={filterPanelId} testId="service-filter-panel" title="Filter services" - groups={[substanceGroup, ...facetGroups]} + groups={[substanceGroup, coreGroupFacetGroup, ...facetGroups]} onClearAll={activeFilterCount > 0 ? clearAllFilters : undefined} summary={{ count: displayedMatches.length, noun: displayedMatches.length === 1 ? "service" : "services" }} - chromeResetKey={`${deferredQuery}|${activeGroup ?? ""}|${resultScope}`} + chromeResetKey={`${deferredQuery}|${[...activeGroupSelection].sort().join(",")}|${resultScope}`} scope={ showResultScope ? { @@ -912,7 +981,7 @@ export function ServicesNavigatorPage() {

Show all services diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 1513fce71..969d5252e 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1416,7 +1416,6 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { const referralProgress = page.getByRole("navigation", { name: "Referral progress" }); await expect(referralProgress).toBeVisible(); await expect(referralProgress.locator('[aria-current="step"]')).toHaveText("Search"); - await expect(page.getByRole("navigation", { name: "Service groups" })).toBeVisible(); await expect(page.getByTestId("services-shortlist-bar")).toHaveCount(0); // The row is compact by contract: the Catchment/Eligibility/Cost strip @@ -1431,6 +1430,19 @@ test.describe("Clinical KB tools directory and legacy launcher", () => { // Exercise a real facet, then clear only that facet while preserving q. await page.getByTestId("service-filter-trigger-desktop").click(); const filterPanel = page.getByTestId("service-filter-panel"); + + // The old standalone "Service groups" browse nav is folded into this + // sheet as a facet (ledger follow-up to #163) rather than a separate + // route-driven row above the results. + await expect(filterPanel.getByRole("button", { name: /^Service group/ })).toBeVisible(); + await filterPanel.getByRole("button", { name: /^Service group/ }).click(); + const urgentGroupFacet = filterPanel.getByRole("button", { name: /^Crisis & urgent/ }); + await expect(urgentGroupFacet).toBeVisible(); + await urgentGroupFacet.click(); + await expect(page).toHaveURL(/group=urgent/); + await urgentGroupFacet.click(); + await expect(page).not.toHaveURL(/group=urgent/); + await filterPanel.getByRole("button", { name: /^Acuity/ }).click(); const crisisFacet = filterPanel.getByRole("button", { name: /^Crisis \/ urgent/ }); await expect(crisisFacet).toBeVisible();