Search
Usually the moment a clinician picks a patient — which makes it the most direct route to a wrong-patient error in the whole product, and the reason none of its conveniences are free.
Overview
Consumer search optimises for speed to a result: predictive, forgiving, first-hit-usually-right. Every one of those behaviours is a wrong-patient mechanism when the results are people, and surnames repeat far more often on one ward than designers expect.
So patient search here is deliberately slower: it will not guess, it will not auto-select, and it will not show a result the clinician cannot distinguish from its neighbour.
Never guesses
No auto-selection, no auto-navigation on a single hit, no "did you mean". Selection is always a deliberate act.
Distinguishing
Every result carries enough to tell it apart from a similar one. A list of names is not a list of patients.
Honest about scope
What was searched, and what was not, is stated. A result set with an unstated boundary is indistinguishable from a complete one.
Anatomy
Two Arthur Harlands, born seven years apart, with MRNs differing by a transposed digit. This is the case the component is designed around, and it is not rare.
| Element | Rule |
|---|---|
| Input | autocomplete="off", spellcheck="false", no autocorrect.
Browser autofill on a patient field can suggest a different patient from earlier in
the shift — see Text field. |
| Scope hint | What can be searched and the minimum query length, before typing. |
| Result | Name and date of birth and age and sex and full MRN. All five, always. Never name alone. |
| Footer | Result count and the boundary of the search — the site, the admission status, the time window. |
A result showing only a name is unusable in exactly the situation where care is needed — two patients with the same surname on one ward. Date of birth is the field clinicians actually cross-check against a wristband, so it appears on every result, and the MRN is shown in full and grouped rather than truncated. The same reasoning as Patient header.
Behaviour
- Minimum three characters before any patient query runs. Shorter queries return result sets nobody can meaningfully scan.
- Never auto-select, never auto-navigate — not on a single result, not on an exact MRN match. The clinician picks, always.
- Never reorder results while typing. A list that resequences under the pointer causes the same failure as an unstaged queue insertion — see Data grid.
- Debounce, but never drop keystrokes. The query that runs is the query the clinician finished typing.
- Exact MRN matches rank first and are labelled as exact, but are still selected deliberately.
- No fuzzy matching on identifiers. Fuzzy matching on names is helpful; on an MRN it is a mechanism for returning the wrong patient.
A dropdown of recently viewed patients is convenient and dangerous: it puts the previous patient one keystroke from the current one, on a shared workstation, for a clinician who has been interrupted. If it exists at all it is clearly separated from live results, explicitly labelled recently viewed — not a search result, cleared on user switch, and never the default focus target.
No results, and failed search
The same distinction as Empty state, and for the same reason: "this patient is not here" and "the search did not run" must never look alike.
Searched current admissions at this site. Check the spelling, or search by full MRN. Patients at other sites are not included.
The wording on the failure case is deliberate: "do not conclude the patient is absent". An unavailable search that renders as "no results" is the search equivalent of a failed queue rendering as an empty one.
This page argued that distinction long before the component could express it.
totalMatches was a plain number, so a directory that timed out rendered as
“0 matching records” — a claim about a patient produced by a network error.
It now takes the same Maybe union the rest of the package uses for a quantity it
did not compute, which also removed the searching flag: reporting a count while
still searching is no longer expressible.
Both Arthurs shown, both distinguishable, neither pre-selected.
One result, pre-selected, no date of birth, no MRN, and a confidence claim in place of the information needed to check it.
The answer must name its question
Debouncing decides when a query runs. It does nothing about the order the answers come back in. Two requests are in flight; the one for smi is served from a cold index and the one for smith from a warm one, and the slower answer arrives last and wins. The box says smith. The list is the Smiths, the Smitheys and the Smilies.
A result set states the query it answers, and is never presented as the answer to a different one. Nothing on screen distinguishes a stale list from a current one, and the reader has no reason to suspect it — which is what makes this a wrong-patient mechanism rather than an inconvenience.
- The component is given the query its hits answer, not asked to infer it.
forQueryis required, so results with no stated question cannot be rendered. Where it differs from what is in the box, the list says so and stays visible — hiding it would throw away work the reader can still use. - Discarding late answers is the caller's job, and it is not enough. A request that is superseded should be abandoned; the display still has to be honest in the window before that happens, and after a retry, and when two workstation tabs share a session.
- This is not the same rule as “never reorder while typing”. That one is about a list moving under a pointer. This one is about a list that is not the answer to the question on screen, and it survives perfect debouncing.
- The same applies to a retried search. An answer that arrives after the clinician has moved on to another patient is not a result; it is a message from a previous context, and it never replaces the current one.
smith
These results answer “smi”, not what is in the box now.
The mismatch is on screen. A clinician can see that what they are looking at has not caught up with what they typed.
smith
3 matching records
A confident count for a query nobody ran. The three records are the answer to something the clinician typed on the way past.
Non-patient search
Searching documentation, settings or an option list is ordinary search and carries none of
the above. It may autocomplete, may highlight a best match, and may act on Enter.
The constraints on this page apply specifically where the result set is people, orders
or clinical records — the component takes a subjects prop so the
distinction is explicit rather than a matter of judgement at each call site.
What the component enforces, and what it only asks for
This is the most consequential page in the package to get this wrong on, because a reader who assumes the component is holding these rules will ship a patient search that does not hold them. It is a controlled component: it has no state, no timer and no knowledge of your index.
| Rule | Held by | How |
|---|---|---|
| Every result shows an identifier and a date of birth | The component | SearchHit requires identifier and dateOfBirth. A hit with a name alone is not expressible.Asserted: Search: every result shows its identifier and date of birth |
| Nothing is ever auto-selected | The component | Selection happens only in a click handler on a rendered row. There is no code path that chooses a hit, not on a single result and not on an exact identifier match. Asserted: Search: choosing the second result selects the second patient |
| A truncated result list says so | The component | When totalMatches exceeds the rows given, the component renders the count and the warning that the record wanted may not be listed.Asserted: Search: a truncated result set says so |
| A failed search is not an empty one | The component | totalMatches is a Maybe, so a search that could not run cannot be rendered as one that found nothing — and hits are withheld under a failure, so stale rows cannot read as its result.Asserted: Search: nothing is listed when the search did not run |
| Results say which query they answer | The component | forQuery is required, and a mismatch with the box renders a notice.Asserted: Search: results for an earlier query say which query they answer |
| Minimum three characters before a query runs | You | There is no minLength here and no debounce. The component reports every keystroke to you; when you run a query is entirely yours. |
| Results never reorder while typing | You | hits is an array you pass. Nothing here can stop it arriving in a different order, and a list that resequences under a moving pointer is how the wrong patient is opened. |
| No fuzzy matching on identifiers | You | Matching happens in your index. Fuzzy matching on a name is helpful; on an MRN it is a mechanism for returning the wrong patient, and nothing on this page prevents it. |
| Exact identifier matches rank first, and are labelled exact | You | Both the ordering and the label are properties of the data you pass. |
| Clearing the box does not leave the last patients listed as current | The component | Results that answer an earlier query are labelled with that query whenever the box says something else — including when it is empty, which used to leave the last patients clickable under a blank field with nothing saying so. Asserted: Search: clearing the box does not leave the previous patients selectable and unlabelled |
| Recently viewed patients are cleared on a user switch | You | This component does not implement a recent list at all. If you add one, it is outside everything above — see the section on recent searches. |
A rule stated on a component page reads as a rule the component keeps. 5 of these 10 are; 5 are obligations this page was quietly handing to the reader. The three most dangerous rules on this page — the minimum query length, the ordering, and fuzzy matching on identifiers — are all yours. Only this table says so.
Do's and don'ts
Both Arthurs shown with dates of birth and full MRNs. Neither pre-selected.
One result, pre-selected, no date of birth, no MRN — and a confidence claim instead of the data needed to check it.
Failure says it is a failure, and says what not to conclude from it.
An unreachable directory rendered as an empty result. The clinician concludes the patient is not here.
Accessibility
- Not a combobox. Results are a named list of real buttons below the field, reached with Tab; each is the whole patient — name, identifier, date of birth — and is focused and read before Enter can select it. The combobox pattern was rejected on purpose: its highlighted-but-unfocused option, moved by arrow and committed by Enter, is the wrong-patient hazard in one keystroke. This bullet used to describe a combobox the component did not ship.
- Result count announced politely on change — "3 results" — never each result individually as the list rebuilds.
- Each result's accessible name carries the distinguishing fields, so a screen-reader user hears the date of birth and MRN rather than a bare name.
- Focus never moves to results automatically. The clinician tabs down when ready.
type="search"with a visible label, never a placeholder standing in for one.- Results reachable at 200 % zoom; the list scrolls internally rather than extending past the viewport.
Outcomes of use
What this contributes to, in the terms of Usability & context of use. These are attributes believed to contribute to an outcome; the outcome itself is settled by observing real use in a specified context, not by this page.
- Effectiveness — the patient opened is the patient intended. This is the component's entire reason for its constraints.
- Efficiency — deliberately traded. Requiring three characters and an explicit selection costs perhaps two seconds, against a wrong-patient event that costs far more.
- Satisfaction — confidence at the moment of selection. A clinician who has to re-check the header after searching has been failed by the search.
Clinical safety notes
Trace these in your risk file (ISO 14971) and usability engineering file (IEC 62366-1). Which of these the component keeps and which are yours is stated in the guarantees table; a control the component does not keep is one your risk file must show you keep.
- No auto-selection or auto-navigation. (the component) Mitigates: opening a patient nobody chose.
- Date of birth and full MRN on every result. (the component) Mitigates: same-surname mis-selection — the commonest wrong-patient mechanism.
- No fuzzy matching on identifiers. (you) Mitigates: a transposed digit returning a different patient as a near match.
- No browser autofill or autocorrect. (the component) Mitigates: a previous patient suggested into the current query.
- Results never reorder while typing. (you) Mitigates: selecting a row that moved.
- Failure distinguished from no-results. (the component) Mitigates: an unreachable directory read as "this patient does not exist".
- Search scope stated. (you) Mitigates: a site- or admission-limited result set read as exhaustive.
- Recent searches separated and cleared on user switch. (you) Mitigates: acting on the previous clinician's patient.
Implementation
$ npx shadcn@latest add https://md.notjustany.tech/r/search.json
<Search
label="Find a patient"
query={query}
forQuery={answered} // required: which query these hits answer
onQueryChange={setQuery}
totalMatches={{ kind: "value", value: 2 }}
hits={hits} // each carries name, identifier and dateOfBirth
onSelect={open}
/>
// 'totalMatches' is a Maybe, so a search that could not run cannot be
// rendered as a search that found nothing.
What the component takes. This table used to list subjects, minLength, scope, onSearchError — props the component does not have, and never had. Found by the registry gate’s props-table rule.
| Prop | Type | Default | Notes |
|---|---|---|---|
label | string | — | Required. A visible label, never a placeholder. |
query | string | — | Required. What is in the box. |
onQueryChange | (q: string) => void | — | Required. Running the search is yours, including any minimum length. |
hits | SearchHit[] | — | Required. Name, identifier and date of birth on every hit. |
onSelect | (hit: SearchHit) => void | — | Required. Called only when a person chooses a hit; nothing is auto-selected. |
totalMatches | Maybe<number> | — | Required. A Maybe, so a failed search cannot read as “0 matching records”. |
forQuery | string | — | Required. The query these hits answer; hits for any other are labelled stale. |
Related
- Patient header — where a selection lands.
- Text field — identifier input rules.
- Empty state — no-results versus failure.
- Sign in / sign out — clearing recents on user switch.