Components · Data

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.

Stable · v1.0 shadcn/ui Wrong-patient control WCAG 2.2 AA

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

Patient search · results
Family name or full MRN · 3 characters minimum
HARLAND, Arthur J. 1964-03-11 · 61 y · M · MRN 44 812 907
HARLAND, Arthur T. 1971-08-02 · 55 y · M · MRN 44 812 970
HARLANDSON, Mira 1988-01-24 · 38 y · F · MRN 51 004 218
3 results · current admissions at this site only

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.

ElementRule
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.
Distinguishing fields are not optional

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

Recent searches are a wrong-patient vector

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.

Empty result versus failure
No patients match "harlnad"
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.

Do
HARLAND, Arthur J. 1964-03-11 · MRN 44 812 907
HARLAND, Arthur T. 1971-08-02 · MRN 44 812 970

Both Arthurs shown, both distinguishable, neither pre-selected.

Don't
Arthur Harland best match

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.

The rule

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.

Do

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.

Don't

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.

RuleHeld by How
Every result shows an identifier and a date of birthThe 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-selectedThe 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 soThe 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 oneThe 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 answerThe 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 runsYou 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 typingYou 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 identifiersYou 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 exactYou Both the ordering and the label are properties of the data you pass.
Clearing the box does not leave the last patients listed as currentThe 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 switchYou 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.
Why this is written down

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

Do
HARLAND, Arthur J. 1964-03-11 · MRN 44 812 907
HARLAND, Arthur T. 1971-08-02 · MRN 44 812 970

Both Arthurs shown with dates of birth and full MRNs. Neither pre-selected.

Don't
Arthur Harlandbest match

One result, pre-selected, no date of birth, no MRN — and a confidence claim instead of the data needed to check it.

Do
Patient search unavailable
The directory could not be reached. This is not a result — do not conclude the patient is absent.

Failure says it is a failure, and says what not to conclude from it.

Don't
No patients found.

An unreachable directory rendered as an empty result. The clinician concludes the patient is not here.

Accessibility

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.

Clinical safety notes

Risk controls this page specifies

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.

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.

PropTypeDefaultNotes
labelstring— Required. A visible label, never a placeholder.
querystring— Required. What is in the box.
onQueryChange(q: string) => void— Required. Running the search is yours, including any minimum length.
hitsSearchHit[]— 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.
totalMatchesMaybe<number>— Required. A Maybe, so a failed search cannot read as “0 matching records”.
forQuerystring— Required. The query these hits answer; hits for any other are labelled stale.
NotJustAnyMed.Tech Design System · Search · v1.0 · draft for review
Reference applications named in this system are fictional; all patient data shown is fabricated.