diff --git a/docs/branch-review-records/2149be40c9723dfdcf39c3831bee991df0e35a86a2f2426a566763d4c7df1916.record.md b/docs/branch-review-records/2149be40c9723dfdcf39c3831bee991df0e35a86a2f2426a566763d4c7df1916.record.md new file mode 100644 index 0000000000..2b8ae599f9 --- /dev/null +++ b/docs/branch-review-records/2149be40c9723dfdcf39c3831bee991df0e35a86a2f2426a566763d4c7df1916.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/header-redesign-mockups-3ms5kn | 430a58a98f9d8bedd21cbbf8e4e522381aa5c4c9 | Dictionary browse header rebuilt on the selected direction (production /dictionary/browse) | approved | verify:pr-local all 18 steps completed / none failed, check:bundle-budget within tolerance, Chromium 390px+1440px dark/light review; Playwright suite delegated to CI Production UI (browser-revision drift #255) | diff --git a/docs/branch-review-records/599419ed3f41f0407ee7e9c44816a1c23691e853437cb963c33d0dcd860f4099.record.md b/docs/branch-review-records/599419ed3f41f0407ee7e9c44816a1c23691e853437cb963c33d0dcd860f4099.record.md new file mode 100644 index 0000000000..80aa64a969 --- /dev/null +++ b/docs/branch-review-records/599419ed3f41f0407ee7e9c44816a1c23691e853437cb963c33d0dcd860f4099.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/header-redesign-mockups-3ms5kn | a33ab97e5b55edcb26ace179471d97b7cf71118e | Dictionary Browse header redesign mockup study (design scratch) | approved | verify:pr-local (673 files/7276 tests pass), build, check:bundle-budget, check:rag:fixtures, check:medication-interactions, check:medication-lexicon-report, Chromium dark+light screenshot review | diff --git a/docs/branch-review-records/5f15d81f006cc85637c12e37a6c1fc41c800de4a1f9f93e6f6d66745a57a5636.record.md b/docs/branch-review-records/5f15d81f006cc85637c12e37a6c1fc41c800de4a1f9f93e6f6d66745a57a5636.record.md new file mode 100644 index 0000000000..50a2612e95 --- /dev/null +++ b/docs/branch-review-records/5f15d81f006cc85637c12e37a6c1fc41c800de4a1f9f93e6f6d66745a57a5636.record.md @@ -0,0 +1 @@ +| 2026-08-18 | claude/header-redesign-mockups-3ms5kn | 8083a20d6a3496ed154b927157bd621735886795 | Dictionary Browse header round two — letter dropdown + Abbreviations in Filters (design scratch) | approved | verify:pr-local all 18 steps completed / none failed, check:bundle-budget within tolerance, Chromium dark+light screenshot review | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index e84ea49e4b..5951d5361d 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -999,7 +999,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `RetrievalStateBanner` | answer | yes | yes | inherited-global-root | yes | no | 1 | | `SafeBoldText` | layout | yes | yes | inherited-global-root | yes | no | 8 | | `SearchField` | controls | yes | yes | no | yes | no | 0 | -| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 8 | +| `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 | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index 2d88695c2e..5b3dde031f 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1454,6 +1454,7 @@ "src/components/applications-launcher-page.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", + "src/components/dictionary/dictionary-catalogue-pages.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", @@ -1464,6 +1465,7 @@ "src/components/applications-launcher-page.tsx", "src/components/clinical-dashboard/patient-profile-panel.tsx", "src/components/clinical-dashboard/settings-dialog.tsx", + "src/components/dictionary/dictionary-catalogue-pages.tsx", "src/components/factsheets/factsheets-search-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/therapy-compass/screens/compare-screen.tsx", diff --git a/docs/site-map.md b/docs/site-map.md index 9bd77fe332..2f546effe1 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1018,6 +1018,8 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/caring-contacts/templates` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/templates/page.tsx`. - `/mockups/caring-contacts/templates/[pathwayId]` - Route discovered from app directory Source: `src/app/mockups/caring-contacts/templates/[pathwayId]/page.tsx`. - `/mockups/development` - Route discovered from app directory Source: `src/app/mockups/development/page.tsx`. +- `/mockups/dictionary-browse-header` - Route discovered from app directory Source: `src/app/mockups/dictionary-browse-header/page.tsx`. +- `/mockups/dictionary-browse-header-compact` - Route discovered from app directory Source: `src/app/mockups/dictionary-browse-header-compact/page.tsx`. - `/mockups/document-navigation-contract` - Route discovered from app directory Source: `src/app/mockups/document-navigation-contract/page.tsx`. - `/mockups/document-navigation-final` - Route discovered from app directory Source: `src/app/mockups/document-navigation-final/page.tsx`. - `/mockups/document-navigation-final-review` - Route discovered from app directory Source: `src/app/mockups/document-navigation-final-review/page.tsx`. diff --git a/mockups/README.md b/mockups/README.md index 696a41a229..1e9c3ac758 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -43,6 +43,42 @@ Some document-search mockups include live handoff routes (for example `document- - Full three-direction study: [`/mockups/privacy-page-directions`](../src/app/mockups/privacy-page-directions/page.tsx) - Static comps: [`public/mockups/privacy-page-redesign-2026-08/`](../public/mockups/privacy-page-redesign-2026-08/README.md) +## Dictionary Browse header study (2026-08-18) + +Runnable study at [`/mockups/dictionary-browse-header`](../src/app/mockups/dictionary-browse-header/page.tsx). The +brief was to drop the description line under **Browse terms** and the orphaned A–Z / Z–A sort pill that floats on its +own row, then rebuild the header for the phone. All three directions move sort into the Filters sheet, where the other +modes already keep it; they differ in how much letter navigation stays on screen. + +| Direction | Phone chrome before a result | Trade-off | +| --------------------------- | ---------------------------- | ------------------------------------------------------------- | +| 01 Compact title bar | 2 bands | Keeps both browse views explicit; least vertical saving | +| 02 Fused letter rail (rec.) | 2 bands | Abbreviations becomes a rail chip rather than a separate view | +| 03 Index rail + jump sheet | 1 band | Edge rail is a fine-motor target; jump sheet is the a11y path | + +The current header is rendered side by side at the top of the page for comparison. Shared mockup chrome is suppressed +because each frame draws its own top bar, mode nav and composer. + +## Dictionary Browse header, round two (2026-08-18) + +Runnable study at [`/mockups/dictionary-browse-header-compact`](../src/app/mockups/dictionary-browse-header-compact/page.tsx), +a follow-up to the round-one study above. Every version replaces the 27-chip horizontal letter rail with a **letter +dropdown on phones** and moves **Abbreviations out of the header into the Filters sheet** beside sort. + +| Version | Phone chrome | Trade-off | +| ------------------------------ | ------------ | ------------------------------------------------------------------- | +| 01 Title bar + letter dropdown | 2 rows | Title still costs a row the mode nav already implies | +| 02 Single fused row (rec.) | 1 row | An active filter chip costs the row its title and count at 390 px | +| 03 Slim toolbar, title retired | 1 slim bar | Phone loses its visual page title; depends on the mode nav above it | + +Demoting a view switch into a sheet hides state, so each version surfaces an active **Abbreviations** chip beside the +letter control. Without it the header would claim 96 terms while listing 24 abbreviations. + +Note for anyone extending these: the mockup stylesheet only emits Tailwind classes that some source actually uses, and +no production file uses a bare `grid-cols-6` (only `xl:grid-cols-6`). The 26-letter pickers therefore pin +`gridTemplateColumns` inline rather than depending on class generation — a bare `grid-cols-6` silently collapses them +to one column. + ## Phone Choose mode sheet YES comps Runnable study at [`/mockups/phone-mode-sheet-yes`](../src/app/mockups/phone-mode-sheet-yes/page.tsx): design review of the shipping phone mode sheet plus **YES 01 perfected** (sectioned clinical list — shipping recommendation) and YES 02 (icon deck alternate). Shared mockup chrome is suppressed so only the in-frame sheet is judged. diff --git a/src/app/mockups/dictionary-browse-header-compact/page.tsx b/src/app/mockups/dictionary-browse-header-compact/page.tsx new file mode 100644 index 0000000000..e8d1832631 --- /dev/null +++ b/src/app/mockups/dictionary-browse-header-compact/page.tsx @@ -0,0 +1,5 @@ +import { DictionaryBrowseHeaderCompactMockupsPage } from "@/components/dictionary-browse-header-compact-mockups"; + +export default function DictionaryBrowseHeaderCompactMockupRoute() { + return ; +} diff --git a/src/app/mockups/dictionary-browse-header/page.tsx b/src/app/mockups/dictionary-browse-header/page.tsx new file mode 100644 index 0000000000..62433a6413 --- /dev/null +++ b/src/app/mockups/dictionary-browse-header/page.tsx @@ -0,0 +1,5 @@ +import { DictionaryBrowseHeaderMockupsPage } from "@/components/dictionary-browse-header-mockups"; + +export default function DictionaryBrowseHeaderMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 455b81b95b..cfc472e524 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -24,6 +24,9 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { const isStandaloneDocumentFlow = pathname === "/mockups/document-search"; const isUniversalSearchRedesignMockup = pathname === "/mockups/universal-search-redesign"; const isSearchHeadingMockup = pathname === "/mockups/search-heading"; + // Draws its own phone/desktop frames with a top bar, mode nav and composer in + // every frame, because the header under study sits directly beneath them. + const isDictionaryBrowseHeaderMockup = pathname.startsWith("/mockups/dictionary-browse-header"); // Renders the results header inside its own device frames; the shared composer // would read as a second, real search bar over the study. const isSearchRefineAdaptiveMockup = pathname === "/mockups/search-refine-adaptive"; @@ -122,7 +125,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isPinnedPlusMenuMockup && !isPhoneModeSheetYesMockup && !isSidebarLiveMockup && - !isCaringContactMockup + !isCaringContactMockup && + !isDictionaryBrowseHeaderMockup } chromeVisible={ !isSourceOverlayRedesignMockup && @@ -144,7 +148,8 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isPinnedPlusMenuMockup && !isPhoneModeSheetYesMockup && !isSidebarLiveMockup && - !isCaringContactMockup + !isCaringContactMockup && + !isDictionaryBrowseHeaderMockup } > {children} diff --git a/src/components/dictionary-browse-header-compact-mockups.tsx b/src/components/dictionary-browse-header-compact-mockups.tsx new file mode 100644 index 0000000000..e8318b0ae0 --- /dev/null +++ b/src/components/dictionary-browse-header-compact-mockups.tsx @@ -0,0 +1,678 @@ +"use client"; + +import { useState } from "react"; +import { ArrowUpDown, Check, ChevronDown, SlidersHorizontal, X } from "lucide-react"; + +import { + CountPill, + DesktopFrame, + PhoneFrame, + ResultRow, + focusRing, + letters, + sampleEntries, +} from "@/components/dictionary-browse-header-mockups"; +import { cn } from "@/components/ui-primitives"; + +/* ------------------------------------------------------------------ * + * Dictionary → Browse header, round two (2026-08-18) + * + * Follow-up to /mockups/dictionary-browse-header. Same two removals as + * before (description line, orphaned A–Z / Z–A sort pill), plus two new + * moves the whole round shares: + * + * 1. The 27-chip horizontal letter rail becomes a dropdown on phones. + * 2. Abbreviations stops being a header segment and becomes a Show + * option inside the Filters sheet, next to sort. + * + * Demoting a view switch into a sheet hides state, so every version + * surfaces an active "Abbreviations" chip beside the letter trigger + * when that option is on. Without it the header lies about what the + * list contains. + * ------------------------------------------------------------------ */ + +type Version = "titlebar" | "single" | "toolbar"; + +const versions: ReadonlyArray<{ + id: Version; + number: string; + name: string; + summary: string; + strengths: readonly string[]; + cost: string; + chrome: string; + recommended?: boolean; +}> = [ + { + id: "titlebar", + number: "01", + name: "Title bar + letter dropdown", + summary: + "Keeps a full-size page title, then one sticky toolbar: the alphabetical dropdown takes the width and Filters & sort keeps its full label beside it. Desktop keeps the chip rail, which still fits there.", + strengths: ["Strongest page identity", "Full-width dropdown target", "Desktop keeps one-tap letters"], + cost: "Two bands on phone — the title still costs a row that the mode nav already implies, and an active filter chip adds a third.", + chrome: "2 rows on phone", + recommended: true, + }, + { + id: "single", + number: "02", + name: "Single fused row", + summary: + "Title, letter dropdown, count and Filters share one sticky line. The dropdown shrinks to a chip beside the title, and the same row serves desktop.", + strengths: ["One row, both breakpoints", "Adapts rather than overflows", "Nothing scrolls horizontally"], + cost: "The letter chip is a smaller target than a full-width control, and at 390 px an active filter chip costs the row its title and count — both stand down to the mode nav until the filter clears.", + chrome: "1 row on phone", + }, + { + id: "toolbar", + number: "03", + name: "Slim toolbar, title retired", + summary: + "The phone mode nav already reads Browse, so the visible heading goes (kept for screen readers) and only a slim toolbar remains: letters, count, Filters. Desktop keeps its title, having no tab bar to inherit it from.", + strengths: ["Smallest possible chrome", "Results start immediately", "Toolbar reads as controls, not decoration"], + cost: "Phone loses its visual page title; it only works while the mode nav above it stays.", + chrome: "1 slim bar on phone", + }, +]; + +/* ------------------------------- state ------------------------------- */ + +function useBrowseState() { + const [letter, setLetter] = useState("All"); + const [abbrOnly, setAbbrOnly] = useState(false); + const [sort, setSort] = useState<"az" | "za">("az"); + const [letterOpen, setLetterOpen] = useState(false); + const [filtersOpen, setFiltersOpen] = useState(false); + const activeFilters = (abbrOnly ? 1 : 0) + (sort === "za" ? 1 : 0); + return { + letter, + setLetter, + abbrOnly, + setAbbrOnly, + sort, + setSort, + letterOpen, + setLetterOpen, + filtersOpen, + setFiltersOpen, + activeFilters, + }; +} + +type BrowseState = ReturnType; + +/* ------------------------------ controls ----------------------------- */ + +function LetterTrigger({ + value, + onOpen, + grow = false, + size = "regular", +}: { + value: string; + onOpen: () => void; + grow?: boolean; + size?: "regular" | "chip"; +}) { + return ( + + ); +} + +function AbbrChip({ onClear, size = "regular" }: { onClear: () => void; size?: "regular" | "chip" }) { + return ( + + ); +} + +function FiltersControl({ + count, + labelled = false, + onOpen, + size = "regular", +}: { + count: number; + labelled?: boolean; + onOpen: () => void; + size?: "regular" | "chip"; +}) { + return ( + + ); +} + +/* ------------------------------ overlays ----------------------------- */ + +function SheetShell({ + title, + onClose, + children, + anchored = false, +}: { + title: string; + onClose: () => void; + children: React.ReactNode; + anchored?: boolean; +}) { + if (anchored) { + return ( +
+
+ ); + } + + return ( +
+
+ ); +} + +function SheetHeading({ title, onClose }: { title: string; onClose: () => void }) { + return ( +
+

{title}

+ +
+ ); +} + +function LetterPicker({ + value, + onSelect, + onClose, + anchored, +}: { + value: string; + onSelect: (next: string) => void; + onClose: () => void; + anchored?: boolean; +}) { + return ( + + +
+ {letters.map((option) => ( + + ))} +
+
+ ); +} + +function OptionRow({ + label, + hint, + selected, + onSelect, +}: { + label: string; + hint?: string; + selected: boolean; + onSelect: () => void; +}) { + return ( + + ); +} + +function BrowseFilterSheet({ state, anchored }: { state: BrowseState; anchored?: boolean }) { + const close = () => state.setFiltersOpen(false); + return ( + +
+
+

+ Show · moved here from the header +

+
+ state.setAbbrOnly(false)} + /> + state.setAbbrOnly(true)} + /> +
+
+
+

+ Sort +

+
+ {(["az", "za"] as const).map((option) => ( + + ))} +
+
+
+

+ Topics +

+
+ {["Mood", "Psychosis", "Legal / ethical", "Therapies"].map((topic) => ( + + {topic} + + ))} +
+
+ +
+
+ ); +} + +/* ------------------------------- headers ----------------------------- */ + +function LetterRailDesktop({ value, onSelect }: { value: string; onSelect: (next: string) => void }) { + return ( +
+ {["All", ...letters].map((option) => ( + + ))} +
+ ); +} + +function VersionHeader({ version, compact, state }: { version: Version; compact: boolean; state: BrowseState }) { + const count = state.abbrOnly ? "24 abbreviations" : "96 terms"; + + if (version === "titlebar") { + return ( +
+
+

+ Browse terms +

+ {count} + {compact ? null : ( + <> + {state.abbrOnly ? state.setAbbrOnly(false)} /> : null} + state.setFiltersOpen(true)} /> + + )} +
+
+ {compact ? ( + <> +
+ state.setLetterOpen(true)} grow /> + state.setFiltersOpen(true)} /> +
+ {state.abbrOnly ? ( +
+ state.setAbbrOnly(false)} /> +
+ ) : null} + + ) : ( +
+ +
+ )} +
+
+ ); + } + + if (version === "single") { + return ( +
+

+ Browse terms +

+ state.setLetterOpen(true)} + size={compact ? "chip" : "regular"} + /> + {state.abbrOnly ? ( + state.setAbbrOnly(false)} size={compact ? "chip" : "regular"} /> + ) : null} + + {state.abbrOnly ? 24 : 96} + + state.setFiltersOpen(true)} + size={compact ? "chip" : "regular"} + /> +
+ ); + } + + return ( + <> + {compact ? ( +

Browse terms

+ ) : ( +
+

Browse terms

+
+ )} +
+ state.setLetterOpen(true)} size="chip" /> + {state.abbrOnly ? state.setAbbrOnly(false)} size="chip" /> : null} + {count} + state.setFiltersOpen(true)} size="chip" /> +
+ + ); +} + +/* -------------------------------- frames ------------------------------ */ + +function VersionResults({ state }: { state: BrowseState }) { + const rows = state.abbrOnly ? sampleEntries.slice(0, 2) : sampleEntries; + return ( +
+ {[...rows, ...rows].map((entry, index) => ( + + ))} +
+ ); +} + +function VersionFrames({ version }: { version: Version }) { + const desktop = useBrowseState(); + const phone = useBrowseState(); + + const overlayFor = (state: BrowseState, anchored: boolean) => { + if (state.letterOpen) { + return ( + { + state.setLetter(next); + state.setLetterOpen(false); + }} + onClose={() => state.setLetterOpen(false)} + /> + ); + } + if (state.filtersOpen) return ; + return null; + }; + + return ( + <> + + + + + + + + + + ); +} + +/* --------------------------------- page -------------------------------- */ + +export function DictionaryBrowseHeaderCompactMockupsPage() { + return ( +
+
+
+

+ Dictionary · Browse · round two +

+

+ Letters in a dropdown, Abbreviations in Filters +

+

+ Three compact takes on the title-bar direction. All three replace the 27-chip horizontal rail with an + alphabetical dropdown on phones and move Abbreviations out of the header into the Filters sheet beside sort. + They differ only in how much header survives around those two controls.{" "} + + 01 is the selected direction + + ; 02 and 03 stay here as the compact alternatives it was chosen over. +

+

+ Every frame is live. Open the letter dropdown, then open Filters and switch to{" "} + Abbreviations only — a purple chip appears beside the letter + control. That chip is load-bearing: a view switch demoted into a sheet is otherwise invisible, and the + header would claim to list all 96 terms while showing 24 abbreviations. +

+
+
+ +
+ {versions.map((version) => ( +
+
+
+ + {version.number} + +
+
+

+ {version.name} +

+ {version.recommended ? ( + + Selected direction + + ) : null} + + {version.chrome} + +
+

{version.summary}

+

+ Trade-off · {version.cost} +

+
+
+
+ {version.strengths.map((strength) => ( + + {strength} + + ))} +
+
+ +
+ +
+
+ ))} +
+
+ ); +} diff --git a/src/components/dictionary-browse-header-mockups.tsx b/src/components/dictionary-browse-header-mockups.tsx new file mode 100644 index 0000000000..d6e095264b --- /dev/null +++ b/src/components/dictionary-browse-header-mockups.tsx @@ -0,0 +1,855 @@ +"use client"; + +import { useState } from "react"; +import { + ArrowRight, + Check, + ChevronDown, + Filter, + GitCompareArrows, + Menu, + MessageSquarePlus, + Plus, + Search, + SendHorizontal, + SlidersHorizontal, + X, +} from "lucide-react"; + +import { cn } from "@/components/ui-primitives"; + +/* ------------------------------------------------------------------ * + * Dictionary → Browse header redesign study (2026-08-18) + * + * Brief: drop the description line under "Browse terms", drop the + * orphaned A–Z / Z–A sort pill that floats on its own row, and rebuild + * the header so the phone layout reaches a result without five stacked + * bands. Sort survives inside the Filters sheet in all three, which is + * where the other modes already keep it. + * ------------------------------------------------------------------ */ + +export const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +export const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); + +type Direction = "titlebar" | "rail" | "index"; + +export type SampleEntry = { + term: string; + kind: string; + alias?: string; + definition: string; +}; + +export const sampleEntries: readonly SampleEntry[] = [ + { + term: "Acceptance and commitment therapy", + kind: "Therapy", + alias: "ACT", + definition: "A therapy that develops acceptance, present-moment awareness and action guided by personal values.", + }, + { + term: "Acute dystonia", + kind: "Clinical finding", + definition: + "A sudden medication-associated sustained muscle contraction that produces abnormal posture or movement.", + }, + { + term: "Advance statement", + kind: "Legal / ethical", + definition: + "A written record of a person's treatment preferences made while they have capacity, for use in later care.", + }, +]; + +const directions: ReadonlyArray<{ + id: Direction; + number: string; + name: string; + summary: string; + strengths: readonly string[]; + cost: string; + bands: string; + recommended?: boolean; +}> = [ + { + id: "titlebar", + number: "01", + name: "Compact title bar", + summary: + "The smallest honest edit: title and count share a line, the view switch and letter rail fuse into one sticky control band, and sort moves into Filters.", + strengths: ["Lowest migration risk", "Keeps both browse views explicit", "Rail stays pinned while scrolling"], + cost: "Still two bands of chrome before the first result on a phone.", + bands: "2 bands on phone", + }, + { + id: "rail", + number: "02", + name: "Fused letter rail", + summary: + "The A–Z / Abbreviations segment disappears. All and Abbr become pinned lead chips on the letter rail itself, so one row does both jobs.", + strengths: ["One control row total", "Abbr stays visible, never scrolls away", "Largest vertical saving"], + cost: "Abbreviations reads as a peer of a letter rather than a separate view.", + bands: "2 bands on phone", + recommended: true, + }, + { + id: "index", + number: "03", + name: "Index rail + jump sheet", + summary: + "No horizontal scroller at all. The title carries the active scope and opens a jump sheet — tap it in either frame below; a Contacts-style index rail rides the right edge of the list.", + strengths: ["Results start after one band", "Scales past 96 terms", "Sticky letter headings orient the scroll"], + cost: "The edge rail is a fine-motor target; the jump sheet is the accessible path and must stay first-class.", + bands: "1 band on phone", + }, +]; + +/* ---------------------------- shared bits ---------------------------- */ + +export function ResultRow({ entry }: { entry: SampleEntry }) { + return ( +
+
+ ); +} + +function LetterChip({ + value, + active, + onSelect, + tone = "accent", +}: { + value: string; + active: boolean; + onSelect: () => void; + tone?: "accent" | "purple"; +}) { + return ( + + ); +} + +function FilterButton({ count, labelled = false, onOpen }: { count: number; labelled?: boolean; onOpen: () => void }) { + return ( + + ); +} + +export function CountPill({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/* ------------------------- direction 01: title bar ------------------------- */ + +function TitleBarHeader({ compact }: { compact: boolean }) { + const [view, setView] = useState<"az" | "abbr">("az"); + const [letter, setLetter] = useState("All"); + + return ( +
+
+ {!compact ? ( +

+ Complete catalogue +

+ ) : null} +
+

+ Browse terms +

+ 96 terms +
+
+
+
+
+ {(["az", "abbr"] as const).map((option) => ( + + ))} +
+ undefined} /> +
+
+ {["All", ...letters].map((value) => ( + setLetter(value)} /> + ))} +
+
+
+ ); +} + +/* ------------------------- direction 02: fused rail ------------------------ */ + +function FusedRailHeader({ compact }: { compact: boolean }) { + const [scope, setScope] = useState("All"); + + return ( +
+
+

+ Browse terms +

+ 96 + + undefined} /> +
+
+
+ setScope("All")} /> + setScope("Abbr")} tone="purple" /> +
+
+ {letters.map((value) => ( + setScope(value)} /> + ))} +
+
+
+ ); +} + +/* --------------------- direction 03: index rail + sheet -------------------- */ + +function IndexHeader({ compact, scope, onOpenSheet }: { compact: boolean; scope: string; onOpenSheet: () => void }) { + return ( +
+ + 96 + undefined} /> +
+ ); +} + +function JumpSheet({ + scope, + onSelect, + onClose, +}: { + scope: string; + onSelect: (value: string) => void; + onClose: () => void; +}) { + return ( +
+ +
+
+ + +
+
+ {letters.map((value) => ( + + ))} +
+ + + ); +} + +function IndexRail({ + scope, + onSelect, + compact, +}: { + scope: string; + onSelect: (value: string) => void; + compact: boolean; +}) { + return ( +
+ {letters.map((value) => ( + + ))} +
+ ); +} + +/* ------------------------------ device frames ------------------------------ */ + +export function PhoneChrome() { + return ( + <> +
+ +
+
+ {[ + { label: "Search", active: false }, + { label: "Browse", active: true }, + { label: "More", active: false }, + ].map((tab) => ( + + {tab.label} + + ))} +
+ + ); +} + +export function PhoneComposer() { + return ( +
+
+
+
+ ); +} + +export function PhoneFrame({ + children, + label, + overlay, +}: { + children: React.ReactNode; + label: string; + overlay?: React.ReactNode; +}) { + return ( +
+
+ {label} + 390 px · scrollable +
+
+ +
{children}
+ + {overlay} +
+
+ ); +} + +export function DesktopFrame({ + children, + label, + overlay, +}: { + children: React.ReactNode; + label: string; + overlay?: React.ReactNode; +}) { + return ( +
+
+ {label} + 1440 px +
+
+
+ {children} + {overlay} +
+
+
+ ); +} + +function ResultList({ + withIndexRail, + scope, + onSelect, + compact = false, +}: { + withIndexRail?: boolean; + scope?: string; + onSelect?: (value: string) => void; + compact?: boolean; +}) { + return ( +
+ {withIndexRail ? ( +
+ A +
+ ) : ( +

96 showing

+ )} +
+ {[...sampleEntries, ...sampleEntries].map((entry, index) => ( + + ))} +
+ {withIndexRail && scope && onSelect ? : null} +
+ ); +} + +function SimpleDirectionFrames({ direction }: { direction: Exclude }) { + const Header = direction === "titlebar" ? TitleBarHeader : FusedRailHeader; + return ( + <> + +
+ + + +
+ + + + ); +} + +function IndexDirectionFrames() { + const [desktopScope, setDesktopScope] = useState("All"); + const [desktopSheet, setDesktopSheet] = useState(false); + const [phoneScope, setPhoneScope] = useState("All"); + const [phoneSheet, setPhoneSheet] = useState(false); + + return ( + <> + { + setDesktopScope(value); + setDesktopSheet(false); + }} + onClose={() => setDesktopSheet(false)} + /> + ) : null + } + > + setDesktopSheet(true)} /> + + + { + setPhoneScope(value); + setPhoneSheet(false); + }} + onClose={() => setPhoneSheet(false)} + /> + ) : null + } + > + setPhoneSheet(true)} /> + + + + ); +} + +/* --------------------------------- page ---------------------------------- */ + +export function DictionaryBrowseHeaderMockupsPage() { + const [showBefore, setShowBefore] = useState(true); + + return ( +
+
+
+

+ Dictionary · Browse +

+

+ Three headers for Browse terms, rebuilt for the phone +

+

+ All three drop the description line and the orphaned A–Z / Z–A sort pill. Sort moves into the Filters sheet, + which is where every other mode already keeps it. What differs is how much of the letter navigation stays on + screen. +

+ +
+
+ + {showBefore ? ( +
+
+
+

+ Today — five stacked bands +

+

+ Kicker, title, description, view switch, a sort pill alone on its own row, then the letter rail. On a + 390 px phone the first result lands below the fold. +

+
+
+ + + + + + + + +
+
+
+ ) : null} + +
+ {directions.map((direction) => ( +
+
+
+ + {direction.number} + +
+
+

+ {direction.name} +

+ {direction.recommended ? ( + + Recommended + + ) : null} + + {direction.bands} + +
+

+ {direction.summary} +

+

+ Trade-off · {direction.cost} +

+
+
+
+ {direction.strengths.map((strength) => ( + + {strength} + + ))} +
+
+ +
+ {direction.id === "index" ? : } +
+
+ ))} +
+
+ ); +} + +/* The header exactly as it ships today, for side-by-side judgement. */ +function CurrentHeader({ compact }: { compact: boolean }) { + return ( +
+
+

+ Complete catalogue +

+

+ Browse terms +

+

+ Scan the same source-linked result system by letter or abbreviation. +

+
+
+
+
+
+ + A–Z + + + Abbreviations + +
+ + + + A–Z + +
+
+ {["All", ...letters].map((value) => ( + + {value} + + ))} +
+
+
+
+ ); +} diff --git a/src/components/dictionary/dictionary-catalogue-pages.tsx b/src/components/dictionary/dictionary-catalogue-pages.tsx index e562cbe11d..4a98f1ea82 100644 --- a/src/components/dictionary/dictionary-catalogue-pages.tsx +++ b/src/components/dictionary/dictionary-catalogue-pages.tsx @@ -9,6 +9,7 @@ import { ResultFilterSheet, ResultFilterTrigger, resultFilterFacetGroup, + resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { DictionaryResultRow } from "@/components/dictionary/dictionary-result-row"; @@ -17,9 +18,11 @@ import { type PageSection } from "@/components/in-page-nav/page-section-index"; import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; import { InformationPageFooter, InformationPageShell } from "@/components/information-page-shell"; import { cn } from "@/components/ui-primitives"; +import { SegmentedControl } from "@/components/ui/segmented-control"; import { allDictionaryEntries, browseDictionary, + dictionaryBrowseLetter, dictionaryKindLabel, dictionaryTopicEntries, findDictionaryTopic, @@ -325,6 +328,18 @@ export function DictionaryBrowsePage() { const sort = searchParams.get("sort") === "za" ? "za" : "az"; const [filterOpen, setFilterOpen] = useState(false); const hits = browseDictionary({ view, letter, topics, kinds, sort }); + // Both counts come from the same predicate as the list, so a segment's number + // is what pressing it actually returns (docs/filter-contract.md). + const viewCount = (candidate: "az" | "abbreviations") => + browseDictionary({ view: candidate, letter, topics, kinds, sort }).length; + // Letters the current view and facets can actually fill. The rest stay + // visible — the alphabet is a fixed mental model — but inert, so the index + // can never strand the reader on an empty page. + const availableLetters = new Set( + browseDictionary({ view, letter: "all", topics, kinds, sort }).map(dictionaryBrowseLetter), + ); + // Sort lives in the sheet but is not a filter: it reorders rather than + // narrows, so counting it in the badge would claim results were withheld. const activeCount = topics.length + kinds.length; const clearFilters = () => replace((next) => { @@ -332,6 +347,17 @@ export function DictionaryBrowsePage() { next.delete("kind"); }); const groups = [ + resultFilterGroup({ + id: "sort", + label: "Sort", + value: sort, + options: [ + { value: "az", label: "A–Z" }, + { value: "za", label: "Z–A" }, + ], + onChange: (value) => setOne("sort", value, "az"), + note: "one only", + }), resultFilterFacetGroup({ id: "topics", label: "Topics", @@ -354,97 +380,149 @@ export function DictionaryBrowsePage() { return ( <> -
-

+

+ {/* Desktop-only: on a phone the mode-nav rail sitting directly above + already reads "Browse", so the kicker repeats it for a whole band. */} +

Complete catalogue

-

+

Browse terms

-
+
-
- {(["az", "abbreviations"] as const).map((option) => ( + setOne("view", next, "az")} + options={[ + { value: "az", label: "Definitions", hint: String(viewCount("az")) }, + { value: "abbreviations", label: "Abbreviations", hint: String(viewCount("abbreviations")) }, + ]} + layout="equal" + ariaControls="dictionary-browse-results" + // Equal segments need an explicit desktop width: shrink-to-fit + // derives the width from spans that are themselves truncating, + // which settles on a box too narrow for "Abbreviations". + className="w-full sm:w-[22rem]" + /> +
+ {/* Phone takes the alphabet as a native select — 27 chips cost a + whole band and a horizontal scroll, and the native picker is + a better target than any of them. The rail below is the same + control at a width that can afford it. */} + + + setFilterOpen((open) => !open)} + title="Filter and sort browse results" + /> + +
+
+ {/* 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 + takes two rows on the narrower desktop widths. */} +
- - -
-
-
-

- {hits.length} showing -

-
- {hits.map((hit) => ( - - ))} -
+
+ {hits.length ? ( +
+ {hits.map((hit) => ( + + ))} +
+ ) : ( +
+
+ )}
All published entries link a source · Specialist clinical approval remains pending @@ -455,10 +533,20 @@ export function DictionaryBrowsePage() { onClose={() => setFilterOpen(false)} panelId="dictionary-browse-filters" testId="dictionary-browse-filters" - title="Filter browse results" + title="Filter and sort" groups={groups} onClearAll={activeCount ? clearFilters : undefined} - summary={{ count: hits.length, noun: hits.length === 1 ? "term" : "terms" }} + summary={{ + count: hits.length, + noun: + view === "abbreviations" + ? hits.length === 1 + ? "abbreviation" + : "abbreviations" + : hits.length === 1 + ? "term" + : "terms", + }} onApply={() => setFilterOpen(false)} /> diff --git a/src/lib/dictionary.ts b/src/lib/dictionary.ts index eed07dfe6a..0f38c6ad45 100644 --- a/src/lib/dictionary.ts +++ b/src/lib/dictionary.ts @@ -220,6 +220,19 @@ export function searchDictionary(filters: DictionaryFilters): DictionarySearchHi }); } +/** + * The letter a hit files under in the browse index. + * + * Exported because the alphabetical control has to offer exactly the letters the + * list can actually show. Deriving that from a second copy of this expression is + * how an index comes to offer a letter that strands the reader on an empty page. + */ +export function dictionaryBrowseLetter(hit: DictionarySearchHit) { + const title = + hit.type === "entry" ? hit.entry.term : hit.type === "abbreviation" ? hit.abbreviation : hit.topic.title; + return title.charAt(0).toLocaleUpperCase(); +} + export function browseDictionary(params: { view: "az" | "abbreviations"; letter: string; @@ -237,9 +250,7 @@ export function browseDictionary(params: { }; let hits = searchDictionary(filters).filter((hit) => { if (!params.letter || params.letter === "all") return true; - const title = - hit.type === "entry" ? hit.entry.term : hit.type === "abbreviation" ? hit.abbreviation : hit.topic.title; - return title.charAt(0).toLocaleUpperCase() === params.letter.toLocaleUpperCase(); + return dictionaryBrowseLetter(hit) === params.letter.toLocaleUpperCase(); }); if (params.sort === "za") hits = hits.reverse(); return hits; diff --git a/tests/dictionary-data.test.ts b/tests/dictionary-data.test.ts index 4679d4031c..0ae940e3c1 100644 --- a/tests/dictionary-data.test.ts +++ b/tests/dictionary-data.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + browseDictionary, dictionaryAliasSenses, + dictionaryBrowseLetter, dictionaryCatalogueIssues, dictionaryCompareHref, dictionaryComparisonPair, @@ -95,4 +97,27 @@ describe("clinical dictionary catalogue", () => { ); expect(dictionaryComparisonPair("affect", "delirium")).toBeNull(); }); + + it("files every browse hit under the letter the alphabetical index offers", () => { + // The browse header derives its selectable letters from + // `dictionaryBrowseLetter` and offers the rest inert. If the two ever + // disagreed the index would strand the reader on an empty page, which is the + // failure this pins: for both views, a letter the helper reports must return + // results, and a letter it does not report must return none. + for (const view of ["az", "abbreviations"] as const) { + const all = browseDictionary({ view, letter: "all", topics: [], kinds: [], sort: "az" }); + expect(all.length).toBeGreaterThan(0); + const available = new Set(all.map(dictionaryBrowseLetter)); + for (const letter of "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { + const hits = browseDictionary({ view, letter, topics: [], kinds: [], sort: "az" }); + expect(hits.length > 0).toBe(available.has(letter)); + } + // Every hit accounted for exactly once across the per-letter partition. + const partitioned = [...available].reduce( + (total, letter) => total + browseDictionary({ view, letter, topics: [], kinds: [], sort: "az" }).length, + 0, + ); + expect(partitioned).toBe(all.length); + } + }); });