Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .design-sync/config.json

Large diffs are not rendered by default.

17 changes: 9 additions & 8 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/design-system/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 3 |
| `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 7 |
| `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 |
Expand Down
18 changes: 15 additions & 3 deletions docs/design-system/adoption-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1383,22 +1383,34 @@
"sourceExported": true,
"entryExported": true,
"directImportFiles": [
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/differentials-home.tsx",
"src/components/clinical-dashboard/medication-prescribing-workspace.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx"
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/specifiers/specifiers-home-page.tsx"
],
"productImportFiles": [
"src/components/applications-launcher-page.tsx",
"src/components/clinical-dashboard/differentials-home.tsx",
"src/components/clinical-dashboard/medication-prescribing-workspace.tsx",
"src/components/clinical-dashboard/patient-profile-panel.tsx",
"src/components/clinical-dashboard/settings-dialog.tsx",
"src/components/in-page-nav/in-page-nav-header.tsx"
"src/components/in-page-nav/in-page-nav-header.tsx",
"src/components/specifiers/specifiers-home-page.tsx"
],
"designSync": {
"listedInSourceMap": true,
"listedInDtsProps": true,
"preview": ".design-sync/previews/SegmentedControl.tsx",
"previewValid": true
},
"testFiles": ["tests/design-sync-visual-exports.test.ts", "tests/ui-v2-components.dom.test.tsx"],
"testFiles": [
"tests/design-sync-visual-exports.test.ts",
"tests/search-results-header-band.dom.test.tsx",
"tests/ui-v2-components.dom.test.tsx"
],
"baseline": {
"targetLayer": "v2",
"liveLayer": "v2",
Expand Down
2 changes: 1 addition & 1 deletion docs/outstanding-issues.md

Large diffs are not rendered by default.

38 changes: 14 additions & 24 deletions src/components/applications-launcher-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from "@/components/clinical-dashboard/result-filter-control";
import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context";
import { useFavouritesAccess } from "@/components/clinical-dashboard/use-favourites-access";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { cn, EmptyState } from "@/components/ui-primitives";
import { Chip, type ChipStatusTone } from "@/components/ui/chip";
import { Sheet } from "@/components/ui/sheet";
Expand Down Expand Up @@ -390,31 +391,20 @@ function FilterTabs({
const [filterOpen, setFilterOpen] = useState(false);
// `more` is the desktop "All" tab's overflow state, not a category of its own.
const resolvedFilter: LauncherFilter = activeFilter === "more" ? "all" : activeFilter;
// One array for the desktop rail and the phone sheet. The categories partition
// the tool list, so this is a lens at both breakpoints — the rail used to say
// many-of-N with `aria-pressed` while the sheet said one-of-N.
const launcherFilterOptions = desktopFilters.map((filter) => ({ value: filter.id, label: filter.label }));
return (
<>
<div className="hidden flex-wrap items-center gap-2 sm:flex" role="group" aria-label="Filter by tool category">
{desktopFilters.map((filter) => {
const active = filter.id === activeFilter || (filter.id === "all" && activeFilter === "more");
return (
<button
key={filter.id}
type="button"
id={`launcher-filter-desktop-${filter.id}`}
aria-pressed={active}
aria-controls="launcher-results-panel"
onClick={() => onFilterChange(filter.id)}
className={cn(
"inline-flex min-h-tap items-center justify-center whitespace-nowrap rounded-lg border px-4 text-xs font-bold transition",
active
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]"
: "border-[color:var(--border)] bg-[color:var(--surface-lux)] text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text-heading)]",
focusRing,
)}
>
{filter.label}
</button>
);
})}
<div className="hidden sm:block">
<SegmentedControl
value={resolvedFilter}
onChange={onFilterChange}
options={launcherFilterOptions}
label="Filter by tool category"
ariaControls="launcher-results-panel"
/>
</div>
{/* The phone half of the same control. This launcher renders no results
band, so the trigger sits inline here rather than in a ribbon slot —
Expand All @@ -440,7 +430,7 @@ function FilterTabs({
id: "category",
label: "Category",
value: resolvedFilter,
options: desktopFilters.map((filter) => ({ value: filter.id, label: filter.label })),
options: launcherFilterOptions,
onChange: (value) => {
setFilterOpen(false);
onFilterChange(value);
Expand Down
98 changes: 20 additions & 78 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-
import {
ResultFilterSheet,
ResultFilterTrigger,
type ResultFilterOption,
resultFilterGroup,
} from "@/components/clinical-dashboard/result-filter-control";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";
import { useDifferentialSearch } from "@/components/clinical-dashboard/use-differential-catalog";
import { useResultSort } from "@/components/use-result-sort";
Expand Down Expand Up @@ -251,73 +253,6 @@ function StatusBadge({ status, className }: { status: DifferentialRecord["status

type KindFilter = "all" | "presentation" | "diagnosis";

const resultTypeTabFocusRing =
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]";

function ResultTypeTabs({
activeFilter,
onFilterChange,
allCount,
presentationCount,
diagnosisCount,
}: {
activeFilter: KindFilter;
onFilterChange: (filter: KindFilter) => void;
allCount: number;
presentationCount: number;
diagnosisCount: number;
}) {
const tabs = [
{ id: "all" as const, label: "All", count: allCount },
{ id: "presentation" as const, label: "Presentations", count: presentationCount },
{ id: "diagnosis" as const, label: "Diagnoses", count: diagnosisCount },
];

// Single-select filters over one results list — modeled as a toggle group
// (role="group" + aria-pressed), not ARIA tabs (which would need tabpanels,
// aria-controls, and roving tabindex for content that does not exist here).
return (
<div
data-testid="differential-result-type-tabs"
role="group"
aria-label="Result type"
className="polished-scroll flex max-w-full items-center gap-1 overflow-x-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-raised)] p-1 shadow-[var(--shadow-inset)]"
>
{tabs.map((tab) => {
const active = activeFilter === tab.id;
return (
<button
key={tab.id}
type="button"
aria-pressed={active}
aria-label={`${tab.label} (${tab.count})`}
onClick={() => onFilterChange(tab.id)}
className={cn(
"inline-flex min-h-tap shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border px-2.5 text-xs font-bold min-[390px]:text-sm",
resultTypeTabFocusRing,
active
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"
: "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)]",
)}
>
{tab.label}
<span
className={cn(
"nums rounded-full px-1.5 text-2xs leading-tight",
active
? "bg-[color:var(--clinical-accent-contrast)]/15 text-[color:var(--clinical-accent-contrast)]"
: "bg-[color:var(--surface-subtle)] text-[color:var(--text-muted)]",
)}
>
{tab.count}
</span>
</button>
);
})}
</div>
);
}

function MatchBadge({ label }: { label: string }) {
// Match quality is a relevance signal, not a safety one — keep it in the
// accent family so red stays reserved for the emergent status badges.
Expand Down Expand Up @@ -870,6 +805,18 @@ function SearchResultsView({

const presentationCount = results.filter((result) => result.kind === "presentation").length;
const diagnosisCount = results.length - presentationCount;
// One array feeds both breakpoints. The counts used to be declared twice —
// as `count` on the desktop rail and again as `hint` in the sheet — which is
// how the two could drift, and how the same dimension came to claim one-of-N
// in the sheet (`role="radiogroup"`) and many-of-N on the rail (`aria-pressed`).
const kindFilterOptions = useMemo<ReadonlyArray<ResultFilterOption<KindFilter>>>(
() => [
{ value: "all", label: "All", hint: String(results.length) },
{ value: "presentation", label: "Presentations", hint: String(presentationCount) },
{ value: "diagnosis", label: "Diagnoses", hint: String(diagnosisCount) },
],
[diagnosisCount, presentationCount, results.length],
);
const visibleResults = useMemo(
() =>
sortResultItems(
Expand Down Expand Up @@ -1009,12 +956,11 @@ function SearchResultsView({
/>
}
filterControls={
<ResultTypeTabs
activeFilter={kindFilter}
onFilterChange={setKindFilter}
allCount={results.length}
presentationCount={presentationCount}
diagnosisCount={diagnosisCount}
<SegmentedControl
value={kindFilter}
onChange={setKindFilter}
options={kindFilterOptions}
label="Result type"
/>
}
/>
Expand All @@ -1031,11 +977,7 @@ function SearchResultsView({
id: "result-type",
label: "Show",
value: kindFilter,
options: [
{ value: "all", label: "All", hint: String(results.length) },
{ value: "presentation", label: "Presentations", hint: String(presentationCount) },
{ value: "diagnosis", label: "Diagnoses", hint: String(diagnosisCount) },
],
options: kindFilterOptions,
onChange: setKindFilter,
}),
]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
import { medicationIdentityBadges, type MedicationRecord } from "@/lib/medications";
import { SEMANTIC_TONE_META } from "@/lib/semantic-tone";
import { isDeployedClinicalKb } from "@/lib/deployed-app";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { cn, EmptyState, pageContainer } from "@/components/ui-primitives";

type MedicationPrescribingWorkspaceProps = {
Expand Down Expand Up @@ -262,50 +263,6 @@ function resultMatchesFilter(result: MedicationResult, filter: MedicationResultF
);
}

function FilterStrip({
activeFilter,
counts,
onFilterChange,
}: {
activeFilter: MedicationResultFilter;
counts: Record<MedicationResultFilter, number>;
onFilterChange: (filter: MedicationResultFilter) => void;
}) {
return (
<div
className="medication-filter-strip answer-suggestion-row-scroll flex gap-1.5 overflow-x-auto pb-1 [-webkit-overflow-scrolling:touch]"
aria-label="Medication result filters"
>
{medicationResultFilters.map((filter) => {
const active = activeFilter === filter.id;
const Icon = filter.icon;
return (
<button
key={filter.id}
type="button"
aria-pressed={active}
onClick={() => onFilterChange(filter.id)}
className={cn(
"inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-2xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:px-3 sm:text-xs",
active
? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"
: "border-[color:var(--border)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text-heading)]",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{filter.label}
<span
className={cn("nums text-2xs font-semibold", active ? "opacity-80" : "text-[color:var(--text-muted)]")}
>
{counts[filter.id]}
</span>
</button>
);
})}
</div>
);
}

function ResultToneIcon({ result, accent }: { result: MedicationResult; accent?: string }) {
const tone = result.tone === "teal" ? "teal" : result.tone === "blue" ? "blue" : "slate";
if (accent) {
Expand Down Expand Up @@ -455,6 +412,18 @@ function MedicationResults({
};
}, [activeFilter, catalog.data, profile, profileEmpty]);
const resultCount = rows.length;
// One array feeds the desktop rail and the phone sheet, so the counts cannot
// drift between them and the dimension states one-of-N at both breakpoints.
const filterOptions = useMemo<ReadonlyArray<SegmentedControlOption<MedicationResultFilter>>>(
() =>
medicationResultFilters.map((filter) => ({
value: filter.id,
label: filter.label,
icon: filter.icon,
hint: String(counts[filter.id]),
})),
[counts],
);
// The match-quality badge only earns its slot when it differentiates: hide it on
// "Exact clinical fit" rows when every visible row says the same thing.
const showMatchBadge = useMemo(() => new Set(rows.map((row) => row.result.match)).size > 1, [rows]);
Expand Down Expand Up @@ -485,7 +454,17 @@ function MedicationResults({
onToggle={() => setFilterOpen((current) => !current)}
/>
}
filterControls={<FilterStrip activeFilter={activeFilter} counts={counts} onFilterChange={setActiveFilter} />}
filterControls={
<SegmentedControl
value={activeFilter}
onChange={setActiveFilter}
options={filterOptions}
label="Medication result filters"
// `.medication-filter-strip` is load-bearing: ui-stress.spec.ts
// measures this row's first button for tap height and left inset.
className="medication-filter-strip"
/>
}
/>
{/* Phone-only by construction: the trigger that opens it lives in the
ribbon's `mobileControls` slot, which the band hides from `sm` up. */}
Expand All @@ -500,11 +479,7 @@ function MedicationResults({
id: "result-filter",
label: "Show",
value: activeFilter,
options: medicationResultFilters.map((filter) => ({
value: filter.id,
label: filter.label,
hint: String(counts[filter.id]),
})),
options: filterOptions,
onChange: setActiveFilter,
}),
]}
Expand Down
10 changes: 10 additions & 0 deletions src/components/clinical-dashboard/result-filter-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,13 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa
type="button"
role="radio"
aria-checked={selected}
// Without this the label and hint spans concatenate to "All8" in
// the accessible name — the name computation normalises the
// inter-element whitespace away, so a text-node separator cannot
// fix it. Same defect and same fix as SegmentedControl, which is
// what lets one shared option array announce identically on the
// desktop rail and in this sheet.
aria-label={option.hint ? `${option.label} (${option.hint})` : undefined}
// `aria-disabled` rather than `disabled`: a real disabled
// button leaves the tab order, so a keyboard or screen-reader
// user loses the option entirely and never learns why. Kept
Expand Down Expand Up @@ -435,6 +442,9 @@ function FilterFacetGroup({ group, panelId }: { group: ResultFilterFacetGroup; p
aria-pressed={selected}
aria-disabled={deadEnd || undefined}
aria-describedby={deadEnd ? deadEndDescId : undefined}
// Same concatenation defect as the lens chips above: a facet
// count would otherwise be announced as "Crisis12".
aria-label={option.hint ? `${option.label} (${option.hint})` : undefined}
onClick={() => {
if (deadEnd) return;
group.onToggle(option.value);
Expand Down
Loading
Loading