diff --git a/docs/site-map.md b/docs/site-map.md index 6602ceb5ad..7493bddff4 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1194,9 +1194,13 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/mockups/development` - Route discovered from app directory Source: `src/app/mockups/development/page.tsx`. - `/mockups/development/clinical-trust` - Route discovered from app directory Source: `src/app/mockups/development/clinical-trust/page.tsx`. - `/mockups/development/ledger` - Route discovered from app directory Source: `src/app/mockups/development/ledger/page.tsx`. +- `/mockups/dictionary-az-filter` - Route discovered from app directory Source: `src/app/mockups/dictionary-az-filter/page.tsx`. +- `/mockups/dictionary-az-state` - Route discovered from app directory Source: `src/app/mockups/dictionary-az-state/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/dictionary-control-row` - Route discovered from app directory Source: `src/app/mockups/dictionary-control-row/page.tsx`. +- `/mockups/dictionary-heading-controls` - Route discovered from app directory Source: `src/app/mockups/dictionary-heading-controls/page.tsx`. +- `/mockups/dictionary-trailing-toolbar` - Route discovered from app directory Source: `src/app/mockups/dictionary-trailing-toolbar/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 f8316a5f6c..c50b35dff3 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -107,6 +107,66 @@ row in place on the page. Shared mockup chrome is suppressed because each frame The tab rail reads Terms · Topics · More rather than Search · Browse · More: this round assumes Search and Browse have merged into one destination, with the site-wide composer as the mode’s only search surface. +## Dictionary — compact trailing toolbar (2026-08-24) + +Runnable study at [`/mockups/dictionary-trailing-toolbar`](../src/app/mockups/dictionary-trailing-toolbar/page.tsx). Two frames. + +The original Filter band stays on browse and search (count, optional query, Filter). Compact Terms / Abbreviations and A–Z sit **under that band on the right**. Topics live inside Filter, not on the title row. + +Shared mockup chrome is suppressed because each frame draws its own top bar, mode nav and composer. + +## Dictionary — browse vs search (recommended split, 2026-08-24) + +Runnable study at [`/mockups/dictionary-az-state`](../src/app/mockups/dictionary-az-state/page.tsx). Two frames only. + +Filter is the shared `ResultFilterTrigger`. Abbreviations live inside that sheet as Show. A–Z is browse navigation: while the query is empty, A–Z and Filter sit together in the trailing corner of the heading row; as soon as a search runs, A–Z stands down and Filter occupies the original results band. Tap the composer (or the clear X) to feel the handoff. + +| State | A–Z | Filter | +| ------ | ----------------------------------- | --------------------------------------- | +| Browse | Trailing corner, paired with Filter | Original trigger, same trailing cluster | +| Search | Hidden | Original trigger, in the results band | + +The earlier three-home study at [`/mockups/dictionary-az-filter`](../src/app/mockups/dictionary-az-filter/page.tsx) asked A–Z to live on a search screen. This page is the recommended alternative. + +Shared mockup chrome is suppressed because each frame draws its own top bar, mode nav and composer. + +## Dictionary — original Filter at the top, three A–Z homes (2026-08-24) + +Runnable study at [`/mockups/dictionary-az-filter`](../src/app/mockups/dictionary-az-filter/page.tsx). Three frames only. + +The Filter button is the shared `ResultFilterTrigger` used on every search page, sitting in the results band under the title. Abbreviations leave the page and become a Show lens inside that sheet. A–Z is the remaining placement question. + +| Direction | A–Z home | Trade-off | +| --------- | ------------------------------------------ | --------------------------------------------------------------------- | +| 01 Title | Next to **Clinical terms** | Band stays count + query + Filter, like other modes | +| 02 Band | Beside Filter in the original results band | Two narrowing controls on the one line that already carries the query | +| 03 Filter | Inside Filter, under Show | Quietest band; A–Z is one extra tap | + +Shared mockup chrome is suppressed because each frame draws its own top bar, mode nav and composer. + +## Dictionary — Filter in the bar, Terms and A–Z beside the title (2026-08-23) + +Runnable study at [`/mockups/dictionary-heading-controls`](../src/app/mockups/dictionary-heading-controls/page.tsx). +This is a layout question, not a production change. + +Round 1 asked whether Filter can leave the control row. Round 2 moved the query display **under** the Terms / Abbreviations buttons once text is there. Round 3 asks where **A–Z** goes during that search: production and directions 03–06 hide it, because a letter jump does not rank a result set. If it must stay visible, 07–10 try four homes on the 06 query-field chrome. + +| Direction | When text is there | Trade-off | +| --------------------- | ---------------------------------------------------------------- | --------------------------------------------------- | +| Now | Band above the toggle | Count and query share one truncated line | +| 01 Persistent bar | Toggle on the heading; Filter always in a slim bar | A bar is present even with no query | +| 02 Ribbon only | Toggle on the heading; no bar until a search | You cannot facet the idle catalogue | +| 03 Band under buttons | Title → toggle → production band | Query still truncates against Filter | +| 04 Query chip | Title → toggle → count + dismissible query chip + Filter | Chip and Filter can wrap at 320 px | +| 05 Query owns a line | Title → toggle → full query, then count / clear / Filter | Extra row, but both words of the query stay visible | +| 06 Query field | Title → toggle → query field + Filter; count lives on the toggle | Field is a second composer echo | +| 07 Beside the toggle | A–Z stays on the button row during search | Competes with Abbreviations on a 390 px row | +| 08 In the query row | A–Z sits with Filter beside the query field | The field is narrower; query may truncate | +| 09 Next to the title | A–Z is a heading companion | Title row wraps on 320 px | +| 10 Inside Filter | No page chip; letter jump lives in the Filter sheet | A–Z is one extra tap, and not visible while reading | + +Shared mockup chrome is suppressed because each frame draws its own top bar, mode nav and composer. + ## 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-az-filter/page.tsx b/src/app/mockups/dictionary-az-filter/page.tsx new file mode 100644 index 0000000000..c1f261f0e1 --- /dev/null +++ b/src/app/mockups/dictionary-az-filter/page.tsx @@ -0,0 +1,5 @@ +import { DictionaryAzFilterMockupsPage } from "@/components/dictionary-az-filter-mockups"; + +export default function DictionaryAzFilterMockupRoute() { + return ; +} diff --git a/src/app/mockups/dictionary-az-state/page.tsx b/src/app/mockups/dictionary-az-state/page.tsx new file mode 100644 index 0000000000..1bf607830f --- /dev/null +++ b/src/app/mockups/dictionary-az-state/page.tsx @@ -0,0 +1,5 @@ +import { DictionaryAzStateMockupsPage } from "@/components/dictionary-az-state-mockups"; + +export default function DictionaryAzStateMockupRoute() { + return ; +} diff --git a/src/app/mockups/dictionary-heading-controls/page.tsx b/src/app/mockups/dictionary-heading-controls/page.tsx new file mode 100644 index 0000000000..a2f5d4c06f --- /dev/null +++ b/src/app/mockups/dictionary-heading-controls/page.tsx @@ -0,0 +1,5 @@ +import { DictionaryHeadingControlsMockupsPage } from "@/components/dictionary-heading-controls-mockups"; + +export default function DictionaryHeadingControlsMockupRoute() { + return ; +} diff --git a/src/app/mockups/dictionary-trailing-toolbar/page.tsx b/src/app/mockups/dictionary-trailing-toolbar/page.tsx new file mode 100644 index 0000000000..df0516559f --- /dev/null +++ b/src/app/mockups/dictionary-trailing-toolbar/page.tsx @@ -0,0 +1,5 @@ +import { DictionaryTrailingToolbarMockupsPage } from "@/components/dictionary-trailing-toolbar-mockups"; + +export default function DictionaryTrailingToolbarMockupRoute() { + return ; +} diff --git a/src/app/mockups/mockups-layout-client.tsx b/src/app/mockups/mockups-layout-client.tsx index 010a9f93fe..d2f49e2097 100644 --- a/src/app/mockups/mockups-layout-client.tsx +++ b/src/app/mockups/mockups-layout-client.tsx @@ -24,13 +24,10 @@ 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"); - // Same reason: this study draws its own tab rail, page card and site-wide - // composer inside every frame, so shared chrome would read as a second real - // header and a second real search bar over the row under review. - const isDictionaryControlRowMockup = pathname === "/mockups/dictionary-control-row"; + // Dictionary chrome studies draw their own top bar, mode nav, page card and + // composer inside every frame, so shared mockup chrome would read as a second + // real header and a second real search bar over the row under review. + const isDictionaryChromeStudyMockup = pathname.startsWith("/mockups/dictionary-"); // 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"; @@ -139,8 +136,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isSidebarLiveMockup && !isCaringContactMockup && !isCarePlanMockup && - !isDictionaryBrowseHeaderMockup && - !isDictionaryControlRowMockup + !isDictionaryChromeStudyMockup } chromeVisible={ !isSourceOverlayRedesignMockup && @@ -165,8 +161,7 @@ export function MockupsLayoutClient({ children }: { children: ReactNode }) { !isSidebarLiveMockup && !isCaringContactMockup && !isCarePlanMockup && - !isDictionaryBrowseHeaderMockup && - !isDictionaryControlRowMockup + !isDictionaryChromeStudyMockup } > {children} diff --git a/src/components/dictionary-az-filter-mockups.tsx b/src/components/dictionary-az-filter-mockups.tsx new file mode 100644 index 0000000000..16a6332b36 --- /dev/null +++ b/src/components/dictionary-az-filter-mockups.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { useId, useState } from "react"; +import { ChevronDown, Menu, MessageSquarePlus, Plus, Search, SendHorizontal, X } from "lucide-react"; + +import { ResultFilterTrigger } from "@/components/clinical-dashboard/result-filter-control"; +import { focusRing, letters, ResultRow, type SampleEntry } from "@/components/dictionary-browse-header-mockups"; +import { cn } from "@/components/ui-primitives"; + +/* ------------------------------------------------------------------ * + * Dictionary → original Filter at the top, A–Z homes (2026-08-24) + * + * Three frames only. Shared decisions: + * - The Filter control is the real ResultFilterTrigger used on every + * search page, sitting in the results band at the top (under the title). + * - Abbreviations leave the page. They are a Show lens inside Filter, + * because Terms / Abbreviations partition the list (one is active). + * - A–Z is the open question. Each frame tries one home. + * + * 01 Title A–Z sits next to Clinical terms. + * 02 Band A–Z sits beside Filter in the original results band. + * 03 Filter A–Z joins Abbreviations inside the Filter sheet. + * ------------------------------------------------------------------ */ + +const QUERY = "mental state examination"; +const TERM_COUNT = 2; +const ABBR_COUNT = 2; +const emptyLetters = new Set(["J", "K", "Q", "U", "V", "W", "X", "Y", "Z"]); + +type AzHome = "title" | "band" | "sheet"; +type Scope = "terms" | "abbreviations"; + +const queryEntries: readonly SampleEntry[] = [ + { + term: "Mental state examination", + kind: "Clinical finding", + alias: "MSE", + definition: "A structured assessment of appearance, behaviour, speech, mood, thought, perception and cognition.", + }, + { + term: "Mini-Mental State Examination", + kind: "Scale", + alias: "MMSE", + definition: "A brief 30-point screen of orientation, registration, attention, recall and language.", + }, +]; + +function PhoneFrame({ + azHome, + label, + note, + startSheetOpen = false, +}: { + azHome: AzHome; + label: string; + note: string; + startSheetOpen?: boolean; +}) { + const panelId = useId(); + const [scope, setScope] = useState("terms"); + const [letter, setLetter] = useState("All"); + const [query, setQuery] = useState(QUERY); + const [sheet, setSheet] = useState<"none" | "filter" | "letter">(startSheetOpen ? "filter" : "none"); + const searching = query.length > 0; + const count = scope === "abbreviations" ? ABBR_COUNT : TERM_COUNT; + const noun = scope === "abbreviations" ? "abbreviations" : "terms"; + const activeCount = (scope === "abbreviations" ? 1 : 0) + (letter === "All" ? 0 : 1); + const filterOpen = sheet === "filter"; + const letterOpen = sheet === "letter"; + + const letterChip = ( + + ); + + return ( +
+
+
+

{label}

+

{note}

+
+ 390 px +
+
+
+ +
+
+ {["Terms", "Topics", "More"].map((tab, index) => ( + + {tab} + + ))} +
+ +
+
+

+ Clinical terms +

+ {azHome === "title" ? letterChip : null} +
+ +
+
+
+
+ +
+ {queryEntries.map((entry) => ( + + ))} +
+
+ +
+ +
+ + {sheet !== "none" ? ( +
+ +
+ {letters.map((option) => { + const vacant = emptyLetters.has(option); + return ( + + ); + })} +
+ + ) : ( + <> +

+ Show partitions the list. A–Z + {azHome === "sheet" ? " lives here too." : " stays on the page."} +

+ +

+ Show +

+
+ {( + [ + { value: "terms" as const, label: "Terms", hint: String(TERM_COUNT) }, + { + value: "abbreviations" as const, + label: "Abbreviations", + hint: String(ABBR_COUNT), + }, + ] as const + ).map((option) => { + const checked = scope === option.value; + return ( + + ); + })} +
+ + {azHome === "sheet" ? ( + <> +

+ Jump to letter +

+ +
+ {letters.map((option) => { + const vacant = emptyLetters.has(option); + return ( + + ); + })} +
+ + ) : null} + + + + )} +
+
+ ) : null} +
+ + ); +} + +export function DictionaryAzFilterMockupsPage() { + return ( +
+
+
+

+ Dictionary · original Filter +

+

+ Filter at the top. Abbreviations in the sheet. Three homes for A–Z. +

+

+ The Filter button is the same control every search page uses, in the results band under the title. There is + no Terms / Abbreviations toggle on the page — that choice is a Show lens inside Filter. Tap Filter on 01 or + 02 to see it. Frame 03 starts with the sheet open so A–Z in Filter is visible. +

+
+
+ +
+

+ Three directions +

+

+ Same band, same Filter, same query. Only A–Z moves. +

+
+ + + +
+
+
+ ); +} diff --git a/src/components/dictionary-az-state-mockups.tsx b/src/components/dictionary-az-state-mockups.tsx new file mode 100644 index 0000000000..a75fce1fe4 --- /dev/null +++ b/src/components/dictionary-az-state-mockups.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { useId, useState, type ReactNode } from "react"; +import { ChevronDown, Menu, MessageSquarePlus, Plus, Search, SendHorizontal, X } from "lucide-react"; + +import { ResultFilterTrigger } from "@/components/clinical-dashboard/result-filter-control"; +import { + focusRing, + letters, + ResultRow, + sampleEntries, + type SampleEntry, +} from "@/components/dictionary-browse-header-mockups"; +import { cn } from "@/components/ui-primitives"; + +/* ------------------------------------------------------------------ * + * Dictionary → browse vs search (recommended split, 2026-08-24) + * + * Two frames only. Same Filter, same Abbreviations-in-Filter rule. + * A–Z is browse navigation, so it stands down the moment a query runs. + * On browse, A–Z and Filter sit together in the trailing corner so the title + * stays a title and the two list controls read as one toolbar. + * ------------------------------------------------------------------ */ + +const QUERY = "mental state examination"; +const TERM_COUNT_BROWSE = sampleEntries.length; +const TERM_COUNT_SEARCH = 2; +const ABBR_COUNT = 2; +const emptyLetters = new Set(["J", "K", "Q", "U", "V", "W", "X", "Y", "Z"]); + +type Scope = "terms" | "abbreviations"; +type Sort = "relevance" | "az" | "za"; +type Sheet = "none" | "filter" | "letter"; + +const queryEntries: readonly SampleEntry[] = [ + { + term: "Mental state examination", + kind: "Clinical finding", + alias: "MSE", + definition: "A structured assessment of appearance, behaviour, speech, mood, thought, perception and cognition.", + }, + { + term: "Mini-Mental State Examination", + kind: "Scale", + alias: "MMSE", + definition: "A brief 30-point screen of orientation, registration, attention, recall and language.", + }, +]; + +function PhoneChrome({ children }: { children: ReactNode }) { + return ( + <> +
+ +
+
+ {["Terms", "Topics", "More"].map((tab, index) => ( + + {tab} + + ))} +
+ {children} + + ); +} + +function ShowChips({ + scope, + termHint, + onChange, +}: { + scope: Scope; + termHint: number; + onChange: (value: Scope) => void; +}) { + return ( + <> +

Show

+
+ {( + [ + { value: "terms" as const, label: "Terms", hint: String(termHint) }, + { value: "abbreviations" as const, label: "Abbreviations", hint: String(ABBR_COUNT) }, + ] as const + ).map((option) => { + const checked = scope === option.value; + return ( + + ); + })} +
+ + ); +} + +function SortChips({ searching, sort, onChange }: { searching: boolean; sort: Sort; onChange: (value: Sort) => void }) { + const options: ReadonlyArray<{ value: Sort; label: string }> = searching + ? [ + { value: "relevance", label: "Best match" }, + { value: "az", label: "A–Z" }, + { value: "za", label: "Z–A" }, + ] + : [ + { value: "az", label: "A–Z" }, + { value: "za", label: "Z–A" }, + ]; + + return ( + <> +

Sort

+

This A–Z is order, not a letter jump.

+
+ {options.map((option) => { + const checked = sort === option.value; + return ( + + ); + })} +
+ + ); +} + +function LetterGrid({ letter, onPick }: { letter: string; onPick: (value: string) => void }) { + return ( + <> +

+ Letters with no entry stay visible but are not selectable. +

+ +
+ {letters.map((option) => { + const vacant = emptyLetters.has(option); + return ( + + ); + })} +
+ + ); +} + +function PhoneFrame({ startSearching }: { startSearching: boolean }) { + const panelId = useId(); + const [scope, setScope] = useState("terms"); + const [letter, setLetter] = useState("All"); + const [sort, setSort] = useState(startSearching ? "relevance" : "az"); + const [query, setQuery] = useState(startSearching ? QUERY : ""); + const [sheet, setSheet] = useState("none"); + const searching = query.length > 0; + const noun = scope === "abbreviations" ? "abbreviations" : "terms"; + const count = searching + ? scope === "abbreviations" + ? ABBR_COUNT + : TERM_COUNT_SEARCH + : scope === "abbreviations" + ? ABBR_COUNT + : letter === "M" + ? TERM_COUNT_SEARCH + : TERM_COUNT_BROWSE; + const activeCount = (scope === "abbreviations" ? 1 : 0) + (sort === "za" ? 1 : 0); + const filterOpen = sheet === "filter"; + const letterOpen = sheet === "letter"; + const rows = searching ? queryEntries : letter === "M" ? queryEntries : sampleEntries; + + const goSearch = () => { + setQuery(QUERY); + setLetter("All"); + setSort("relevance"); + setSheet("none"); + }; + const goBrowse = () => { + setQuery(""); + setSort("az"); + setSheet("none"); + }; + + const filterTrigger = ( + setSheet((current) => (current === "filter" ? "none" : "filter"))} + title="Filter the dictionary catalogue" + labelVisibility="always" + /> + ); + + return ( +
+
+
+

+ {searching ? "Search" : "Browse"} +

+

+ {searching + ? "Original Filter in the band. No A–Z. Abbreviations live in Show." + : "A–Z and Filter share the trailing corner. No results band yet."} +

+
+ 390 px +
+
+ +
+
+

+ Clinical terms +

+ {searching ? null : ( +
+ + {filterTrigger} +
+ )} +
+ + {searching ? ( +
+
+
+
+ ) : null} + +
+ {rows.map((entry) => ( + + ))} +
+
+
+ +
+ +
+ + {sheet !== "none" ? ( +
+ + + )} +
+
+ ) : null} +
+ + ); +} + +export function DictionaryAzStateMockupsPage() { + return ( +
+
+
+

+ Dictionary · recommended split +

+

+ Browse keeps A–Z. Search keeps Filter. +

+

+ Two phones, one rule. Abbreviations live inside the original Filter sheet as Show. While browsing, A–Z and + Filter sit together in the trailing corner so the title stays a title. The moment a query runs, A–Z stands + down and Filter moves into the results band — the same control every other search page uses. Tap the + composer (or the clear X) to feel the handoff. +

+
+
+ +
+

+ Two states +

+

+ Left starts as browse. Right starts as search. Same component, different starting query. +

+
+ + +
+
+
+ ); +} diff --git a/src/components/dictionary-heading-controls-mockups.tsx b/src/components/dictionary-heading-controls-mockups.tsx new file mode 100644 index 0000000000..8ae074901b --- /dev/null +++ b/src/components/dictionary-heading-controls-mockups.tsx @@ -0,0 +1,870 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, Funnel, Menu, MessageSquarePlus, Plus, Search, SendHorizontal, X } from "lucide-react"; + +import { focusRing, letters, ResultRow, type SampleEntry } from "@/components/dictionary-browse-header-mockups"; +import { cn } from "@/components/ui-primitives"; + +/* ------------------------------------------------------------------ * + * Dictionary → heading companions, round 2 (2026-08-24) + * + * Round 1 asked whether Filter can leave the control row. Round 2 starts + * from the live search state: the query band currently sits *above* the + * Terms / Abbreviations toggle, so a two-word query and Filter fight for + * one line while the buttons sit underneath looking orphaned. + * + * Image two (browse) already has the stack to keep: + * title → toggle + A–Z → results + * These directions put the query display bar *under* those buttons when + * text is there, and try four ways to stop the count and the typed words + * jamming into one truncated phrase. + * + * Tap the composer in any frame to run “mental state examination” and + * clear it again. + * + * A–Z currently stands down during a search — the live catalogue does the + * same, because a letter jump is meaningless against a ranked result set. + * Round 3 asks where it should live if it must stay visible once text is + * there: beside the toggle, in the query row, next to the title, or inside + * Filter. + * ------------------------------------------------------------------ */ + +const TERM_COUNT = 96; +const ABBR_COUNT = 11; +const QUERY = "mental state examination"; +const QUERY_TERMS = 2; +const QUERY_ABBRS = 2; + +type BrowseLayout = "now" | "persistent" | "ribbon"; +type SearchLayout = "under-band" | "under-chip" | "under-own-line" | "under-field"; +type Layout = BrowseLayout | SearchLayout; +type LetterHome = "hide" | "buttons" | "bar" | "heading" | "sheet"; + +function usePageState({ + startSearching = false, + empty = false, + startSheet = "none", + letterHome = "hide", +}: { + startSearching?: boolean; + empty?: boolean; + startSheet?: "none" | "letter" | "filter"; + letterHome?: LetterHome; +} = {}) { + const [scope, setScope] = useState<"terms" | "abbr">("terms"); + const [letter, setLetter] = useState("All"); + const [query, setQuery] = useState(startSearching || empty ? QUERY : ""); + const [sheet, setSheet] = useState<"none" | "letter" | "filter">(startSheet); + const searching = query.length > 0; + const termCount = searching ? (empty ? 0 : QUERY_TERMS) : TERM_COUNT; + const abbrCount = searching ? (empty ? 0 : QUERY_ABBRS) : ABBR_COUNT; + const count = scope === "abbr" ? abbrCount : termCount; + const noun = + scope === "abbr" + ? Number(count) === 1 + ? "abbreviation" + : "abbreviations" + : Number(count) === 1 + ? "term" + : "terms"; + return { + scope, + setScope, + letter, + setLetter, + query, + setQuery, + sheet, + setSheet, + searching, + empty, + termCount, + abbrCount, + count, + noun, + letterHome, + }; +} + +type PageState = ReturnType; + +const emptyLetters = new Set(["J", "K", "Q", "U", "V", "W", "X", "Y", "Z"]); + +const catalogueEntries: 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 queryEntries: readonly SampleEntry[] = [ + { + term: "Mental state examination", + kind: "Clinical finding", + alias: "MSE", + definition: "A structured assessment of appearance, behaviour, speech, mood, thought, perception and cognition.", + }, + { + term: "Mini-Mental State Examination", + kind: "Scale", + alias: "MMSE", + definition: "A brief 30-point screen of orientation, registration, attention, recall and language.", + }, +]; + +function ScopeToggle({ state }: { state: PageState }) { + const options = [ + { value: "terms" as const, label: "Terms", count: state.termCount }, + { value: "abbr" as const, label: "Abbreviations", count: state.abbrCount }, + ]; + return ( +
+ {options.map((option) => { + const active = state.scope === option.value; + return ( + + ); + })} +
+ ); +} + +function LetterChip({ state }: { state: PageState }) { + return ( + + ); +} + +function HeadingCompanions({ state, letterOnly = false }: { state: PageState; letterOnly?: boolean }) { + return ( +
+ {letterOnly ? null : } + {letterOnly || !state.searching ? : null} +
+ ); +} + +function FilterControl({ state }: { state: PageState }) { + return ( + + ); +} + +function ClearQuery({ state }: { state: PageState }) { + return ( + + ); +} + +function CountPhrase({ state, muted = false }: { state: PageState; muted?: boolean }) { + return ( +

+ {state.count}{" "} + {state.noun} +

+ ); +} + +function ResultsBar({ state, always }: { state: PageState; always: boolean }) { + if (!always && !state.searching) return null; + return ( +
+

+ {state.count}{" "} + {state.noun} + {state.searching ? ( + <> + · + {state.query} + + ) : null} +

+ {state.searching ? : null} + +
+ ); +} + +function NowControlRow({ state }: { state: PageState }) { + return ( +
+ + {state.searching ? null : } + + + +
+ ); +} + +/* Image two’s button row: title stays alone, toggle + A–Z sit on the next + line. Filter only joins this row while browsing — once there is text, it + moves into the display bar underneath. */ +function ButtonsThenBarRow({ + state, + showFilter, + showLetter, +}: { + state: PageState; + showFilter: boolean; + showLetter: boolean; +}) { + return ( +
+ + {showLetter ? : null} + {showFilter ? ( + + + + ) : null} +
+ ); +} + +function QueryBandCard({ state }: { state: PageState }) { + return ( +
+
+
+
+ ); +} + +function QueryChipBar({ state }: { state: PageState }) { + return ( +
+ + + {state.query} + + + + + +
+ ); +} + +function QueryOwnLineBar({ state }: { state: PageState }) { + return ( +
+

{state.query}

+
+ + + + + +
+
+ ); +} + +function QueryFieldBar({ state }: { state: PageState }) { + return ( +
+
+

{state.query}

+ +
+ {state.letterHome === "bar" ? : null} + +
+ ); +} + +function PageHeading({ + state, + companions, + letterOnly = false, +}: { + state: PageState; + companions: boolean; + letterOnly?: boolean; +}) { + return ( +
+

+ Clinical terms +

+ {companions || letterOnly ? : null} +
+ ); +} + +function EmptyCatalogue({ state }: { state: PageState }) { + return ( +
+
+ ); +} + +function SiteComposer({ state }: { state: PageState }) { + return ( +
+ +
+ ); +} + +function SheetLayer({ state }: { state: PageState }) { + const close = () => state.setSheet("none"); + if (state.sheet === "none") return null; + return ( +
+ +
+ {letters.map((option) => { + const vacant = emptyLetters.has(option); + return ( + + ); + })} +
+ + ) : ( + <> + {state.letterHome === "sheet" ? ( +
+

+ Jump to letter +

+ +
+ {letters.map((option) => { + const vacant = emptyLetters.has(option); + return ( + + ); + })} +
+
+ ) : null} +

+ {state.letterHome === "sheet" + ? "A–Z lives in this sheet during a search, next to topics, kinds and sources." + : "Topics, kinds and sources stay in this sheet. Scope and letter stay on the page so the sheet does not hide the view you are already in."} +

+ + )} +
+ + ); +} + +function isSearchLayout(layout: Layout): layout is SearchLayout { + return layout === "under-band" || layout === "under-chip" || layout === "under-own-line" || layout === "under-field"; +} + +function PhoneFrame({ + layout, + label, + note, + width = 390, + startSearching = false, + empty = false, + letterHome = "hide", + startSheet = "none", +}: { + layout: Layout; + label: string; + note: string; + width?: number; + startSearching?: boolean; + empty?: boolean; + letterHome?: LetterHome; + startSheet?: "none" | "letter" | "filter"; +}) { + const state = usePageState({ startSearching, empty, letterHome, startSheet }); + const buttonsThenBar = isSearchLayout(layout); + const companions = layout === "persistent" || layout === "ribbon"; + const showLetterOnButtons = letterHome === "buttons" || (letterHome === "hide" && !state.searching); + return ( +
+
+
+

{label}

+

{note}

+
+ {width} px +
+
+
+ +
+
+ {[ + { label: "Terms", active: true }, + { label: "Topics", active: false }, + { label: "More", active: false }, + ].map((tab) => ( + + {tab.label} + + ))} +
+
+ + {layout === "now" ? ( + <> + {state.searching ? ( +
+

+ {state.count}{" "} + {state.noun} + · + {state.query} +

+ +
+ ) : null} + + + ) : null} + {layout === "persistent" || layout === "ribbon" ? ( + + ) : null} + {buttonsThenBar ? ( + <> + + {state.searching ? ( + layout === "under-band" ? ( + + ) : layout === "under-chip" ? ( + + ) : layout === "under-own-line" ? ( + + ) : ( + + ) + ) : null} + + ) : null} + {state.empty && state.searching ? ( + + ) : ( +
+ {(state.searching ? queryEntries : catalogueEntries).map((entry) => ( + + ))} +
+ )} +
+ + +
+
+ ); +} + +export function DictionaryHeadingControlsMockupsPage() { + return ( +
+
+
+

+ Dictionary · heading companions +

+

+ Buttons first, query bar underneath +

+

+ Browse already works as title, then Terms / A–Z, then results. The live search state inverts that: the query + band sits above the toggle, so “mental state examination” and Filter share one jammed line. These frames + keep the button row where it is and put the display bar underneath once there is text. A–Z currently + disappears during a search — the same as production. The section below tries four homes for it. Tap any + composer to clear or restore the query. +

+
+
+ +
+

+ When text is there +

+

+ Same stack as the browse frame: title, then the joined toggle, then the query display. Four ways to stop the + count and the typed words colliding. +

+
+ + + + +
+
+ +
+

+ Where A–Z goes +

+

+ Today it stands down the moment a query runs, because a letter jump does not rank a result set. If it must + stay on screen, it has to sit somewhere that does not fight the query field. All four frames use direction 06 + as the search chrome. +

+
+ + + + +
+
+ +
+

+ Zero matches +

+

+ The jammed live state. Count is zero, so it must not sit inside the query field. Direction 06 keeps “0” on the + toggle and the typed words in the field. +

+
+ + +
+
+ +
+

+ Browse, for comparison +

+

+ Round 1 frames. Image two is 02: title, then toggle and A–Z, no bar until a search. +

+
+ + + +
+
+ +
+

+ 320 px · query under the buttons +

+

+ Direction 05 at the stress width. The query keeps both words; Filter stays on the count row. +

+
+ +
+
+
+ ); +} diff --git a/src/components/dictionary-trailing-toolbar-mockups.tsx b/src/components/dictionary-trailing-toolbar-mockups.tsx new file mode 100644 index 0000000000..8ef87657af --- /dev/null +++ b/src/components/dictionary-trailing-toolbar-mockups.tsx @@ -0,0 +1,449 @@ +"use client"; + +import { useId, useState, type ReactNode } from "react"; +import { ChevronDown, Menu, MessageSquarePlus, Plus, Search, SendHorizontal, X } from "lucide-react"; + +import { ResultFilterTrigger } from "@/components/clinical-dashboard/result-filter-control"; +import { + focusRing, + letters, + ResultRow, + sampleEntries, + type SampleEntry, +} from "@/components/dictionary-browse-header-mockups"; +import { cn } from "@/components/ui-primitives"; + +/* ------------------------------------------------------------------ * + * Dictionary → compact trailing toolbar (2026-08-24) + * + * Title stays a title. The original Filter search-band stays on browse and + * search. Compact Terms / Abbreviations and A–Z sit under that band on the + * right. Topics live inside Filter. + * ------------------------------------------------------------------ */ + +const QUERY = "mental state examination"; +const TERM_COUNT_BROWSE = sampleEntries.length; +const TERM_COUNT_SEARCH = 2; +const ABBR_COUNT = 2; +const emptyLetters = new Set(["J", "K", "Q", "U", "V", "W", "X", "Y", "Z"]); + +const topicOptions = [ + { id: "all", label: "All topics" }, + { id: "assessment", label: "Assessment" }, + { id: "therapies", label: "Therapies" }, + { id: "findings", label: "Clinical findings" }, +] as const; + +type Scope = "terms" | "abbreviations"; +type Sheet = "none" | "letter" | "filter"; + +const queryEntries: readonly SampleEntry[] = [ + { + term: "Mental state examination", + kind: "Clinical finding", + alias: "MSE", + definition: "A structured assessment of appearance, behaviour, speech, mood, thought, perception and cognition.", + }, + { + term: "Mini-Mental State Examination", + kind: "Scale", + alias: "MMSE", + definition: "A brief 30-point screen of orientation, registration, attention, recall and language.", + }, +]; + +const chip = + "inline-flex h-7 max-h-7 shrink-0 items-center gap-0.5 overflow-hidden rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] px-1.5 text-2xs font-semibold leading-none text-[color:var(--clinical-accent)]"; + +function PhoneChrome({ children }: { children: ReactNode }) { + return ( + <> +
+ +
+
+ {["Terms", "Topics", "More"].map((tab, index) => ( + + {tab} + + ))} +
+ {children} + + ); +} + +function ScopeToggle({ + scope, + termCount, + onChange, +}: { + scope: Scope; + termCount: number; + onChange: (value: Scope) => void; +}) { + const options = [ + { value: "terms" as const, label: "Terms", count: termCount }, + { value: "abbreviations" as const, label: "Abbreviations", count: ABBR_COUNT }, + ]; + return ( +
+ {options.map((option) => { + const active = scope === option.value; + return ( + + ); + })} +
+ ); +} + +function PhoneFrame({ startSearching }: { startSearching: boolean }) { + const panelId = useId(); + const [scope, setScope] = useState("terms"); + const [letter, setLetter] = useState("All"); + const [topic, setTopic] = useState<(typeof topicOptions)[number]["id"]>("all"); + const [query, setQuery] = useState(startSearching ? QUERY : ""); + const [sheet, setSheet] = useState("none"); + const searching = query.length > 0; + const termCount = searching ? TERM_COUNT_SEARCH : TERM_COUNT_BROWSE; + const noun = scope === "abbreviations" ? "abbreviations" : "terms"; + const count = scope === "abbreviations" ? ABBR_COUNT : termCount; + const rows = searching ? queryEntries : letter === "M" ? queryEntries : sampleEntries; + const filterOpen = sheet === "filter"; + const topicActive = topic !== "all"; + + const goSearch = () => { + setQuery(QUERY); + setLetter("All"); + setSheet("none"); + }; + const goBrowse = () => { + setQuery(""); + setSheet("none"); + }; + + return ( +
+
+
+

+ {searching ? "Search" : "Browse"} +

+

+ {searching + ? "The original Filter band stays. Query and clear sit in it. Compact Terms / Abbr. and A–Z sit under the band." + : "The original Filter band stays even without a query. Compact Terms / Abbr. and A–Z sit under the band."} +

+
+ 390 px +
+
+ +
+
+

+ Clinical terms +

+
+
+
+ + +
+
+ +
+ {rows.map((entry) => ( + + ))} +
+
+
+ +
+ +
+ + {sheet !== "none" ? ( +
+ +
+ {letters.map((option) => { + const vacant = emptyLetters.has(option); + return ( + + ); + })} +
+ + ) : ( + <> +

+ Topics, sort, kind, and source live here. Terms / Abbreviations and A–Z stay on the page under this + band. +

+

+ Topics +

+
+ {topicOptions.map((option) => { + const checked = topic === option.id; + return ( + + ); + })} +
+

+ Sort +

+
+ {(searching ? ["Best match", "A–Z", "Z–A"] : ["A–Z", "Z–A"]).map((option, index) => ( + + {option} + + ))} +
+ + + )} +
+
+ ) : null} +
+ + ); +} + +export function DictionaryTrailingToolbarMockupsPage() { + return ( +
+
+
+

+ Dictionary · trailing toolbar +

+

+ Keep the Filter band. Put Terms, Abbreviations, and A–Z under it. +

+

+ The original Filter band stays on browse and search — count, optional query, and Filter. Compact Terms / + Abbreviations and A–Z sit underneath on the right. Topics live inside Filter. Tap the composer to feel the + handoff. +

+
+
+ +
+

+ Two states +

+

+ Left starts as browse. Right starts as search. Filter is always in the band. Compact Terms / Abbreviations and + A–Z sit under it. Topics live inside Filter. +

+
+ + +
+
+
+ ); +}