From 8d3536b3f76a02bb983ac7a02bb556cbfd811a9b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:18:10 +0800 Subject: [PATCH] feat(services): refine filters and referral progress --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 - docs/filter-contract.md | 28 +- src/components/services/service-group-nav.tsx | 113 ------- .../services/services-navigator-page.tsx | 285 ++++++++++-------- src/lib/service-core-groups.ts | 31 ++ tests/services-catalog.test.ts | 28 +- tests/ui-smoke.spec.ts | 2 +- tests/ui-tools.spec.ts | 63 ++-- 9 files changed, 273 insertions(+), 281 deletions(-) delete mode 100644 src/components/services/service-group-nav.tsx diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index e2eea76857..6e0d103d0e 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 | 10 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `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 4b369cea4e..81d830892b 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1515,7 +1515,6 @@ "src/components/forms/forms-search-results-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" ], @@ -1544,7 +1543,6 @@ "src/components/forms/forms-search-results-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/docs/filter-contract.md b/docs/filter-contract.md index 0469e77946..52d89db67c 100644 --- a/docs/filter-contract.md +++ b/docs/filter-contract.md @@ -28,10 +28,13 @@ does today — tells the reader they cannot hold two domains at once, which is f ### There is no `navigate` kind, and that is the point -Services' quick filters do not filter. They call `router.push` and **replace the query**, so -choosing one discards the search and its results with no warning and no undo. A control labelled -"Filter" must not do that. Query-replacing presets belong beside the composer as suggested -searches (`AnswerSuggestionChips`), not inside the filter sheet. +Services' former quick filters did not filter. They called `router.push` and **replaced the +query**, so choosing one discarded the search and its results with no warning and no undo. A +control labelled "Filter" must not do that. Moving those presets beside the results as suggested +searches still made a second search-navigation row compete with the result hierarchy, so the +production Services route removes them entirely. The separate service-group strip moves into the +sheet as the real, URL-backed **Service category** facet: its four categories overlap, so they use +OR-within multi-selection rather than pretending to be a one-of-N lens. Factsheets' category dimension is not this pattern, despite an earlier draft of this section grouping it with services' quick filters: `filterFactsheets(query, category)` ANDs the two, so @@ -93,9 +96,9 @@ scope segment — `These results N | All items N`, counts on both — built from tabindex and radio semantics. `ResultFilterSheet` reserves the slot (`scopeControl`) but does not build the segment itself: "meaningfully larger" and what the two counts mean are per-mode judgements the shared renderer cannot make. Services is the first mode to use it — see -`services-navigator-page.tsx`: the segment is gated on the catalogue exceeding the query/group -scoped result set (not the facet-narrowed one, so the segment does not flicker away as facets are -applied), and both counts reflect the current facet/lens selection. +`services-navigator-page.tsx`: the segment is gated on the catalogue exceeding the query-scoped +result set (not the facet-narrowed one, so the segment does not flicker away as facets are applied), +and both counts reflect the current category/facet/lens selection. It earns its place because it is the only escape from a filtered-to-zero state that does not discard the query: the commit becomes "Show N in all items" instead of a dead end. @@ -122,7 +125,7 @@ find-a-filter field and collapse-by-default disclosures. | > 3 groups, or > 20 options | chips plus find-a-filter and collapse-by-default, every group behind a disclosure header | `ResultFilterSheet` computes the threshold once across all facet groups. The option-count limb -catches a small number of very large groups, while the group-count limb covers services' five +catches a small number of very large groups, while the group-count limb covers services' six facet groups. Below the threshold every group renders as before. Collapse rules, when they apply: groups start collapsed; a group holding a selection opens @@ -161,11 +164,12 @@ Contract first, then one PR per mode: desktop rail so the breakpoints stop disagreeing. Done for differentials, medication, applications and specifiers (all `lens`), formulation (`facet`), and factsheets, whose real category lens now shares one counted option array between desktop and phone. -3. **Services** — move its query-replacing quick filters to the composer. Done for services: - five facets (catchments, age_groups, setting_flags, acuity_flags, housing_flags), +3. **Services** — remove its query-replacing quick filters instead of presenting them as filters or + a competing suggestion row. Done for services: Service category plus five catalogue facets + (catchments, age_groups, setting_flags, acuity_flags, housing_flags), substance_flags as a lens (an exact partition, not an accumulating constraint — see - `src/lib/service-facets.ts`), a URL round-trip alongside `q`/`group`, and the scope segment - (section 4). Services is also the first mode dense enough (5 facet groups) to exercise the + `src/lib/service-facets.ts`), a URL round-trip alongside `q`, and the scope segment (section 4). + Services is also the first mode dense enough (6 facet groups) to exercise the `> 3 groups` chrome added to the shared sheet for this — see section 5. 4. **Therapy-compass** — converge runtime use of the bespoke phone-only filter sheet and trigger onto `ResultFilterSheet`, `ResultFilterTrigger`, and `ResultFilterFacetChips`. Topics are OR diff --git a/src/components/services/service-group-nav.tsx b/src/components/services/service-group-nav.tsx deleted file mode 100644 index b0866e99f4..0000000000 --- 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 4e1212de64..3b3cad3a27 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -18,12 +18,12 @@ import { useAccountData } from "@/components/account-data-provider"; import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { + type AppliedFilterChip, SearchResultsEmptyState, SearchResultsHeaderBand, SearchResultsSkeleton, } from "@/components/clinical-dashboard/search-results-header-band"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; -import { AnswerSuggestionChips } from "@/components/clinical-dashboard/answer-suggestion-chips"; import { ResultFilterSheet, ResultFilterTrigger, @@ -31,17 +31,17 @@ import { resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { SegmentedControl } from "@/components/ui/segmented-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 { @@ -65,16 +65,6 @@ import { useRegistryRecords } from "@/lib/use-registry-records"; type ServiceResultScope = "results" | "all"; -const bestFitQuery = "13YARN crisis Aboriginal Torres Strait Islander phone"; -const serviceQuickFilters = [ - { label: "Best fit", query: bestFitQuery }, - { label: "Crisis", query: "crisis" }, - { label: "Culturally safe", query: "Aboriginal Torres Strait Islander" }, - { label: "Phone referral", query: "phone referral" }, - { label: "Free", query: "free" }, - { label: "WA", query: "WA" }, -] as const; - function displayText(value: string | null | undefined, fallback = "Confirm locally") { return value?.trim() ? value.trim() : fallback; } @@ -269,44 +259,63 @@ const referralStages = [ type ReferralStageId = (typeof referralStages)[number]["id"]; /** - * The progressive replacement for the old four-card numbered walkthrough - * (direction B, ledger #163): one ~20px line of dots that says where you are - * without spending a third of the fold saying it. + * A compact progress rail, not the old four-card walkthrough. It sits directly + * below the results heading because it describes the current search journey; + * category and suggestion navigation no longer compete for this position. * - * The accessible name is deliberately NOT "Referral workflow" — that name - * belongs to `ServiceReferralFlow` on the service record, and - * `tests/ui-tools.spec.ts` asserts it is absent from the results route so the - * removed walkthrough cannot creep back in. Reusing it here would satisfy that - * assertion's letter and defeat its purpose. - * - * "Refer" is never the active stage here; it is reached on the record itself. - * It stays in the rail because the point is showing the whole path, not just - * the part this page owns. + * The accessible name stays distinct from `ServiceReferralFlow` on the service + * record. "Refer" is reached there, but remains visible here so the whole path + * is understandable before a reader starts a shortlist. */ function ServiceReferralProgress({ active }: { active: ReferralStageId }) { const activeIndex = referralStages.findIndex((stage) => stage.id === active); return ( -