Skip to content
Open
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
59 changes: 34 additions & 25 deletions docs/filter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,6 @@ component decides the renderer. No call site picks chips, rows or a segmented co
`kind` is optional and defaults to `lens`, because that is what all seven existing call sites
are. Adding the facet kind changed no rendered output.

**`renderAs` is the one exception, and it is a migration seam rather than a layout choice.** A
counted lens should be a segmented bar (section 5), and the component could derive that on its own
from `kind` plus "every option carries a count" — but doing so would restyle all thirteen lens call
sites in a single change, each needing its own browser proof. So `renderAs: "segmented"` is opt-in
per group while modes move over one at a time. It is deliberately not a free choice: it names the
same renderer the rule would derive, the component still refuses it for a group carrying a dead end,
and the end state is that the flag disappears and derivation takes over. Do not add a second value
to it, and do not read it as licence for a call site to pick its own layout.

**Which is which is a question about the data, not the UI.** Differentials'
All / Presentations / Diagnoses is a lens: a result cannot be both. Formulation's twelve domains
are facets: a mechanism routinely carries four. Rendering facets as radios — which formulation
Expand Down Expand Up @@ -125,8 +116,15 @@ cannot state either segment honestly. `data/differentials-snapshot.json` is 1.2
component deliberately never imports it — doing so to get an "all" count would put the whole snapshot
in the bundle — while `useDifferentialSearch` only ever receives query-matched results. So the page
can produce a constant `232`, but not "how many of the 232 survive the current urgency selection",
and section 3 requires both counts to come from the same predicate as the filter. `/api/differentials`
is no help either: its `total` is the _match_ count once `q` is present, not the catalogue's.
and section 3 requires both counts to come from the same predicate as the filter.

`/api/differentials` used to compound this: its `total` measured the records it was returning, which
under a query are the ranked matches, so it reported the caller's own result count rather than the
catalogue. That is fixed — all four branches of the route report the catalogue size, pinned by
`tests/differentials-route.test.ts` — so the honest figure is now available. What is still missing is
the _scoped_ count: "how many of the 232 survive the current urgency selection" needs the catalogue
in memory, which is the megabyte this client must not import. The total alone cannot satisfy
section 3.

Differentials **browse** (`differential-stream-workspace.tsx`) does get scope, because its server
component hands it a model carrying matched and unmatched entries together, distinguished by
Expand Down Expand Up @@ -167,20 +165,31 @@ still the right renderer for short bare labels.
**The same argument applies to a counted `lens`, and the answer there is the segmented bar.** A lens
is an exact partition, so it takes `SegmentedControl` rather than the two-column grid — which is what
`ChoiceChip`'s own contract already says: _"Compact many-of-many selection. Use SegmentedControl for
one-of-many choices."_ Opt in per group with `renderAs: "segmented"` on `resultFilterGroup()`; the
default stays `"chips"` so the twelve lens call sites that predate this render unchanged, and each
can move over with its own browser proof. Differentials is the first adopter — its Show (3) and
Clinical urgency (4) groups were the ragged wrapping row this rule exists to stop.

Two constraints on that renderer, both load-bearing:

- **A group carrying a dead-end option stays on chips.** `SegmentedControl` marks a disabled option
with the native `disabled` attribute, which takes it out of the tab order. A dead end has to stay
focusable and explained — a reader who has just narrowed to nothing needs to reach the option that
did it — so `renderAs` is ignored for such a group rather than silently degrading it.
- **Counts must be unit-free.** `SegmentedControl` uses one field for both the visible count and the
accessible name, so it has no `hintLabel` equivalent (see the rule below). A lens whose counts
carry a unit keeps the chip renderer until that second field exists.
one-of-many choices."_

**It is derived, never declared.** A lens whose options all carry a count renders as a segmented bar
because of what it is, not because a call site asked. There is no renderer flag, and adding one would
break section 1 — a mode declares semantics, and picking a layout is the thing that rule exists to
stop. An earlier revision shipped `renderAs: "segmented"` as a migration seam so modes could move one
at a time; it is gone, and the option list is what decides.

Two conditions bound it, both load-bearing:

- **At most five options.** That is where the chip tier above ends. A segmented bar is one control
read left to right; past five it wraps into rows and stops reading as one, which is the ragged
shape this rule exists to remove. A longer lens keeps the chip row.
A dead end does **not** send the group back to chips, and an earlier revision that made it do so was
wrong: documents' Source locality marks an option dead the moment its count reaches zero, so a
state-dependent renderer made the control morph from a segmented bar into a chip row while the reader
was using it. The shape of the option list decides the renderer; nothing about the current selection
can change it. `SegmentedControl` carries the dead end itself, on a `deadEnd` field kept deliberately
separate from `disabled` — `disabled` means "not on offer" and leaves the arrow path, `deadEnd` means
"your own narrowing emptied this" and stays on it with `aria-disabled` and a stated reason, exactly as
section 3 requires.

Counts may carry units. `SegmentedControl` takes the same `hint`/`hintLabel` split as an option (see
the rule below), so `"1 loaded source"` is announced while `1` is displayed. Before that split a
counted lens with a unit had to stay on chips — which is what kept documents' Source locality there.

**`hint` is announced, `hintLabel` is displayed.** `hint` carries the unit (`"1 loaded source"`) and
is what the option's accessible name is built from; `hintLabel` is the short visible form (`"1"`).
Expand Down
9 changes: 8 additions & 1 deletion src/app/api/differentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,14 @@ function publicDifferentialPayload(kind: DifferentialRecordKind, q: string | und
return {
records,
matches: ranked ? recordMatchesPayload(ranked) : undefined,
total: records.length,
// The catalogue size, not `records.length`. With `q` present `records` holds
// the ranked matches, so measuring it made `total` the match count — which is
// already in `records`/`matches` — while the other three branches here report
// the catalogue (`snapshot.presentations.length` above, `rows.length` on both
// owner paths). A caller asking "how many differentials are there" got the
// size of its own result set back, so the differentials filter could not
// state the catalogue figure and had to omit it.
total: differentialRecords.length,
governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status },
};
}
Expand Down
11 changes: 4 additions & 7 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1200,27 +1200,24 @@ function SearchResultsView({
panelId={filterPanelId}
testId="differential-filter-panel"
title="Filter differentials"
description="Narrow by result type, then by clinical urgency. Both narrow the same list together."
description="Narrow by result type, then by clinical urgency. Both narrow the same list."
groups={[
// Both dimensions are exact partitions carrying counts, which is the
// case `ChoiceChip` itself sends to `SegmentedControl`: a counted
// chip is wide enough that four of them wrap one per line and leave
// most of each row empty.
// Both dimensions are exact partitions carrying counts, so the sheet
// derives the segmented bar for them — see docs/filter-contract.md
// section 5. Nothing here selects a renderer.
resultFilterGroup({
id: "result-type",
label: "Show",
value: kindFilter,
options: kindFilterOptions,
onChange: setKindFilter,
renderAs: "segmented",
}),
resultFilterGroup({
id: "urgency",
label: "Clinical urgency",
value: urgencyFilter,
options: urgencyFilterOptions,
onChange: setUrgencyFilter,
renderAs: "segmented",
}),
]}
onClearAll={activeFilterCount > 0 ? clearAllFilters : undefined}
Expand Down
63 changes: 30 additions & 33 deletions src/components/clinical-dashboard/result-filter-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,25 +105,6 @@ export type ResultFilterLensGroup = ResultFilterGroupBase & {
than role alone. Omit for a sheet with no facet groups; the roving
radiogroup already says "one active" on its own there. */
note?: string;
/**
* How the one-of-N options are drawn. Defaults to `"chips"`, so every lens
* call site that predates this renders unchanged.
*
* `"segmented"` is the shape `docs/filter-contract.md` section 5 argues for
* once options carry counts: a counted chip is wide enough that four of them
* wrap one per line and leave most of each row empty, which is exactly the
* ragged column the counted-row renderer was added to fix for facets. A lens
* is an exact partition, so it gets the segmented bar rather than that
* two-column grid — `ChoiceChip`'s own contract sends one-of-many choices to
* `SegmentedControl`.
*
* Ignored for a group carrying a dead-end option. `SegmentedControl` marks a
* disabled option with the native `disabled` attribute, which drops it out of
* the tab order; this group keeps dead ends focusable and explained on
* purpose (see `isDeadEnd` below), and losing that is a real regression, so
* such a group falls back to chips.
*/
renderAs?: "chips" | "segmented";
};

export type ResultFilterFacetGroup = ResultFilterGroupBase & {
Expand Down Expand Up @@ -158,7 +139,6 @@ export function resultFilterGroup<Value extends string>(group: {
onChange: (value: Value) => void;
note?: string;
optionSections?: ReadonlyArray<ResultFilterOptionSection>;
renderAs?: "chips" | "segmented";
}): ResultFilterGroup {
return {
kind: "lens",
Expand All @@ -169,7 +149,6 @@ export function resultFilterGroup<Value extends string>(group: {
options: group.options,
note: group.note,
optionSections: group.optionSections,
renderAs: group.renderAs,
// The one narrowing, isolated here rather than repeated at seven call sites.
onChange: (value) => group.onChange(value as Value),
};
Expand Down Expand Up @@ -347,11 +326,25 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa
// selectable one when the value matches no option (a stale URL param, or a
// catalogue that dropped a category between renders).
const tabStopValue = selectable.some((o) => o.value === group.value) ? group.value : selectable[0]?.value;
// See `renderAs` on ResultFilterLensGroup for why a dead end vetoes the
// segmented bar: SegmentedControl disables such an option natively, which
// takes it out of the tab order, and an unreachable dead end cannot explain
// itself to the reader whose selection created it.
const useSegmented = group.renderAs === "segmented" && selectable.length === group.options.length;
// Derived, never declared — see docs/filter-contract.md section 5. A lens
// whose options all carry a count is a segmented bar: a counted chip is wide
// enough that four of them wrap one per line and leave most of each row
// empty, and `ChoiceChip`'s own contract sends one-of-many choices to
// `SegmentedControl`. One condition bounds it.
//
// Five options, because that is where section 5's chip tier ends. A segmented
// bar is one control read left to right; past five it wraps into rows and
// stops reading as one, which is the ragged shape this rule exists to remove.
//
// Dead ends do NOT veto it. They used to, and that was wrong: documents'
// Source locality marks an option dead when its count reaches zero, so a
// state-dependent veto made the control morph from a segmented bar into a
// chip row mid-interaction. `SegmentedControl` now keeps a dead end focusable
// and explained itself, exactly as the chip renderer does, so the shape of the
// option list decides the renderer and nothing about the reader's current
// selection can change it.
const everyOptionCounted = group.options.length > 0 && group.options.every((option) => Boolean(option.hint));
const useSegmented = everyOptionCounted && group.options.length <= 5;

const moveTo = useCallback(
(next: ResultFilterOption<string> | undefined) => {
Expand Down Expand Up @@ -412,14 +405,18 @@ function FilterRadioGroup({ group, panelId }: { group: ResultFilterLensGroup; pa
options={group.options.map((option): SegmentedControlOption<string> => ({
value: option.value,
label: option.label,
// `hint` only. SegmentedControl uses one field for both the
// visible count and the accessible name, whereas an option
// splits them into `hint` (announced, carries the unit) and
// `hintLabel` (displayed). Passing `hintLabel` here would
// strip the unit from the announced name, so a lens whose
// counts carry one has to stay on chips until SegmentedControl
// grows the second field.
// Both halves of the count. `hint` carries the unit and builds
// the accessible name; `hintLabel` is the short visible form.
// Documents' Source locality needs exactly this — "1 loaded
// source" announced, "1" displayed — and before the control took
// the second field, a lens whose counts carry a unit had to stay
// on chips.
hint: option.hint,
hintLabel: option.hintLabel,
// `deadEnd`, not `disabled`: an option the reader's own narrowing
// emptied stays on the arrow path and explains itself, where
// `disabled` would skip it entirely.
deadEnd: isDeadEnd(option),
}))}
// Not `equal`: that stretches every segment to the same width with
// `whitespace-nowrap` and truncates. "All priorities" and
Expand Down
Loading
Loading