diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 879976a14b..1d0f53ad57 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 | 9 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 27 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 | | `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 3b0db9464e..3cae58c960 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1513,7 +1513,6 @@ "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/therapy-compass/filter-sheet.tsx", "src/components/ui/confirm-dialog.tsx" ], "productImportFiles": [ @@ -1542,8 +1541,7 @@ "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/therapy-compass/filter-sheet.tsx" + "src/components/services/service-group-nav.tsx" ], "designSync": { "listedInSourceMap": true, diff --git a/docs/filter-contract.md b/docs/filter-contract.md index 4b6e27e327..147db93a95 100644 --- a/docs/filter-contract.md +++ b/docs/filter-contract.md @@ -145,10 +145,11 @@ rather than showing an empty heading. - **`footerNote` counts what the filters actually govern.** Specifiers currently reports `results.length + catalogueMatches.length` while the groups narrow only `results` — the sheet claims to scope a list it half controls. A mode must not report a total its filters cannot move. -- **`onClearAll` never touches the query.** Therapy-compass's clear wipes the search box; the - shared sheet's does not. Clearing filters and clearing a search are different intentions. -- **One trigger component.** `ResultFilterTrigger`. Therapy-compass re-implements it with a - different icon, a hardcoded test id and a different label-hiding breakpoint. +- **`onClearAll` never touches the query.** Clearing filters and clearing a search are different + intentions. Therapy-compass now uses the shared sheet's filter-only clear; the composer's + explicit "Clear search" action remains responsible for deleting the query. +- **One trigger component.** `ResultFilterTrigger`. Therapy-compass now uses the shared trigger + at the phone breakpoint and the shared facet chips on desktop. - **Tap targets are `min-h-tap` (48px) on phone.** Do not relax to 44px for generic WCAG guidance; it reintroduces a known `ui-smoke` flake. @@ -183,6 +184,11 @@ Contract first, then one PR per mode: `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 `> 3 groups` chrome added to the shared sheet for this — see section 5. -4. **Documents last** — port its needle and collapse up into the shared component as the +4. **Therapy-compass** — converge runtime use of the bespoke phone-only filter sheet and trigger + onto `ResultFilterSheet`, `ResultFilterTrigger`, and `ResultFilterFacetChips`. Topics are OR + within their group. Review status and handout availability are independent one-option groups + that AND with Topics and with each other. Option counts and filtering share + `matchesTopics`/`matchesAvailability`, and Clear filters preserves the query. +5. **Documents last** — port its needle and collapse up into the shared component as the `> 20` tier, then converge. It is the largest surface and should move once the contract is proven elsewhere. diff --git a/src/components/therapy-compass/bindings.tsx b/src/components/therapy-compass/bindings.tsx index d4c39556e1..8649b377e7 100644 --- a/src/components/therapy-compass/bindings.tsx +++ b/src/components/therapy-compass/bindings.tsx @@ -112,6 +112,13 @@ export type TcBindings = { // ---- search --------------------------------------------------------- search: SearchOptions; searchResults: Therapy[]; + /** + * Query-only matches — same query, tags/reviewedOnly/briefOnly reset to + * `EMPTY_SEARCH`. The base for a facet option's count ("how many would I + * have if I ticked this as well" — docs/filter-contract.md section 3), + * which must never be narrowed by the very selection it is counting. + */ + queryMatches: Therapy[]; setQuery: (q: string) => void; submitQuery: (q: string) => void; // set query + go search toggleTag: (tag: string) => void; @@ -321,6 +328,16 @@ export function TcProvider({ children }: { children: ReactNode }) { // match aria-pressed state without waiting for useDeferredValue. return searchTherapies(therapies, { ...search, query: deferredSearch.query }); }, [therapies, deferredSearch.query, search]); + // Same query-deferral shape as `searchResults`, tags/reviewedOnly/briefOnly + // reset — so a facet count never lags behind or races ahead of the query + // typing the reader can see. + const queryMatches = useMemo(() => { + const liveQuery = search.query.trim(); + const deferredQuery = deferredSearch.query.trim(); + if (!liveQuery) return searchTherapies(therapies, EMPTY_SEARCH); + if (!deferredQuery) return []; + return searchTherapies(therapies, { ...EMPTY_SEARCH, query: deferredSearch.query }); + }, [therapies, deferredSearch.query, search.query]); const compareTherapies = useMemo( () => compareSlugs.map((sl) => bySlug.get(sl)).filter((t): t is Therapy => Boolean(t)), [compareSlugs, bySlug], @@ -422,6 +439,7 @@ export function TcProvider({ children }: { children: ReactNode }) { search, searchResults, + queryMatches, setQuery: (q) => patchSearch({ query: q }), submitQuery: (q) => { patchSearch({ query: q }); @@ -538,6 +556,7 @@ export function TcProvider({ children }: { children: ReactNode }) { relatedForSelected, search, searchResults, + queryMatches, compareSlugs, compareTherapies, recQuery, diff --git a/src/components/therapy-compass/data/select.ts b/src/components/therapy-compass/data/select.ts index cc60850dde..c0321faaa6 100644 --- a/src/components/therapy-compass/data/select.ts +++ b/src/components/therapy-compass/data/select.ts @@ -124,7 +124,7 @@ export function complexityLabel(complexity: string | null): string { export type SearchOptions = { query: string; - tags: string[]; // therapy must carry ALL selected tags + tags: string[]; // therapy must carry ANY selected tag (OR within the group — see matchesTopics) briefOnly: boolean; sheetOnly: boolean; reviewedOnly: boolean; @@ -138,6 +138,36 @@ export const EMPTY_SEARCH: SearchOptions = { reviewedOnly: false, }; +/** + * Topics facet predicate — OR within the group, matching every other adopted + * facet in the app (docs/filter-contract.md section 1). Picking both CBT and + * DBT means "either", not "a therapy tagged with both": AND-within-group is + * the exact defect class already fixed for document tags, where it made a + * second selection within one group a dead affordance instead of a widen. + * + * Exported so the filter sheet's option counts (`searchTherapies`'s own + * `hint` predicate) and the real filter run through one function — the count + * must be produced by the same predicate as the filter, or the two drift + * apart the moment the combination rule changes (section 3). + */ +export function matchesTopics(therapy: Therapy, topics: ReadonlySet): boolean { + if (topics.size === 0) return true; + const wanted = [...topics].map(lc); + return therapy.tags.some((tag) => wanted.includes(lc(tag))); +} + +/** + * Availability facet predicate — reviewed status and brief-intervention + * availability are independent constraints that AND together (they are two + * separate one-option facet groups, not options inside one group), and each + * ANDs against Topics in turn. Neither combines with the other by OR. + */ +export function matchesAvailability(therapy: Therapy, reviewedOnly: boolean, briefOnly: boolean): boolean { + if (reviewedOnly && therapy.reviewStatus !== "reviewed") return false; + if (briefOnly && !therapy.briefInterventionAvailable) return false; + return true; +} + function scoreTherapy(t: Therapy, q: string): number { if (!q) return 1; const name = lc(t.name); @@ -158,13 +188,12 @@ function scoreTherapy(t: Therapy, q: string): number { export function searchTherapies(therapies: Therapy[], opts: SearchOptions): Therapy[] { const q = opts.query.trim().toLowerCase(); - const wantTags = opts.tags.map(lc); + const topics = new Set(opts.tags); const scored = therapies .filter((t) => { - if (opts.briefOnly && !t.briefInterventionAvailable) return false; + if (!matchesAvailability(t, opts.reviewedOnly, opts.briefOnly)) return false; if (opts.sheetOnly && !t.patientSheetAvailable) return false; - if (opts.reviewedOnly && t.reviewStatus !== "reviewed") return false; - if (wantTags.length && !wantTags.every((wt) => t.tags.some((tag) => lc(tag) === wt))) return false; + if (!matchesTopics(t, topics)) return false; return scoreTherapy(t, q) > 0; }) .map((t) => ({ t, s: scoreTherapy(t, q) })); diff --git a/src/components/therapy-compass/filter-sheet.tsx b/src/components/therapy-compass/filter-sheet.tsx deleted file mode 100644 index b130960e11..0000000000 --- a/src/components/therapy-compass/filter-sheet.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { Sheet } from "@/components/ui/sheet"; - -import { outlineControl, softControl } from "./controls"; -import { CheckIcon, SlidersIcon, XIcon } from "./icons"; - -/** - * The phone filtering surface for therapy search. - * - * It replaces two native `