Select
One value from a list too long to show all at once — roughly seven to fifty options. Below seven, use a Radio group; above fifty, the user is searching, not choosing.
Which one
| Control | Answers | Takes effect |
|---|---|---|
| Checkbox | Any number, including none | On Save |
| Radio | Exactly one, all options visible | On Save |
| Select | Exactly one, from a longer list | On Save |
| Switch | On or off | Immediately |
The field
This is a button and a listbox, not a native <select>. That is
what makes the menu stylable and consistent across browsers — and it is why every keyboard
behaviour is ours rather than the platform's. Shown open.
The ward the patient is being admitted to.
<div class="sr-select">
<label class="sr-select__label" for="ward">Ward</label>
<div class="sr-select__control">
<button type="button" class="sr-select__trigger" id="ward" aria-haspopup="listbox" aria-expanded="false" data-placeholder="true">
<span class="sr-select__value">Select a ward</span>
</button>
<div class="sr-select__menu" role="listbox" hidden>
<div class="sr-select__option" role="option" aria-selected="false">Aneurin ward</div>
</div>
</div>
</div>
States
The placeholder is not a value. "Select a ward" must never be submittable, and it stays in secondary text until a real choice is made.
Select a ward to continue
<!-- data-placeholder="true" is what keeps "Select a ward" in secondary
text. Remove it when a real value is set, or the value renders as
though it were still a placeholder. -->
<div class="sr-select">
<label class="sr-select__label" for="s1">Placeholder</label>
<div class="sr-select__control">
<button type="button" class="sr-select__trigger" id="s1" aria-haspopup="listbox" aria-expanded="false" data-placeholder="true">
<span class="sr-select__value">Select a ward</span>
</button>
</div>
</div>
<div class="sr-select sr-select--error">
<label class="sr-select__label" for="s2">Error <span class="sr-select__required">*</span></label>
<div class="sr-select__control">
<button type="button" class="sr-select__trigger" id="s2" aria-haspopup="listbox" aria-expanded="false" aria-describedby="s2-err" data-placeholder="true">
<span class="sr-select__value">Select a ward</span>
</button>
</div>
<p class="sr-select__error" id="s2-err">Select a ward to continue</p>
</div>
<div class="sr-select">
<label class="sr-select__label" for="s3">Disabled</label>
<div class="sr-select__control">
<button type="button" class="sr-select__trigger" id="s3" disabled>
<span class="sr-select__value">Aneurin ward</span>
</button>
</div>
</div>
One value from a list too long to show all at once.
When to use
- One value from a known, fixed list that is too long for radios, a ward, a clinician, a specialty, a reason code.
- Where the list is stable enough that the user does not need to search it, but long enough that showing every option would crowd the form.
- Roughly seven to fifty options. Below seven, radios are better; above fifty, the user is searching rather than choosing.
When not to use
- Fewer than about seven options. Use a Radio group
, a select hides the options behind a click, and comparing them costs the user an extra step for no gain.
- Two options. Use radios or a segmented control. A two-item select is the worst version of both.
- A long or unbounded list, every patient, every drug. Use Autocomplete, which filters as you type.
- More than one answer. A select takes one value. For several, use a Checkbox group.
- Dates and times. Use the date and time fields, which accept typing. A select of 31 days is a wheel by another name, and rules that out.
How it works
- It is a button and a listbox, not a native
<select>. That is what makes the menu stylable, the options able to carry a trailing chevron for nested lists, and the open state consistent across browsers. The cost is that every keyboard behaviour is ours to get right, so do not fork it. - The trigger shows the current value, in secondary text while it is still a placeholder. "Select a ward" is a placeholder, not a value, it must never be submittable.
- Order the list the way the user thinks. Frequency first where you know it, then alphabetical. Never by database ID.
- Group long lists rather than making the menu taller. The menu caps at 280px and scrolls; past that, structure beats scrolling.
- The menu closes on selection, on Escape, and on click outside. It never closes on scroll, a menu that vanishes when the page moves under it reads as a bug.
- Label, hint and error are the same stack as Input, so a form mixing the two lines up.
- Required shows an asterisk after the label. Decorative only; the requirement is carried programmatically.
Do & don't
| Do | Don't |
|---|---|
| Write the label as what is being chosen, "Ward" | Write it as an instruction, "Please choose a ward" |
| Keep the placeholder unselectable | Let "Select a ward" submit as a value |
| Put frequent options at the top | Order by internal code |
| Group a long list under headings | Let the menu grow past a screenful |
| Use Autocomplete once the list is searchable | Bolt a search box onto a select |
Accessibility
- The trigger is a real
buttonwitharia-haspopupandaria-expanded; the menu isrole="listbox"and each optionrole="option"witharia-selected. - Full keyboard: Enter/Space/Down to open, Up/Down to move, Home/End to jump, typeahead to skip, Enter to choose, Escape to close and return focus to the trigger. Focus never escapes to the page behind an open menu.
- The label is programmatically associated with the trigger, and the hint and error are referenced from it, so all three are announced together.
- Focus ring is a 3px Cyan/700 outer stroke, drawn outside the control so it is never clipped by the field box.
- The open state is not colour alone, the chevron rotates.
- Error is exposed programmatically, not only as a red border, and the message says what to do rather than what went wrong.
- Disabled uses
aria-disabledand leaves the control out of the tab order.
Known gaps
- No Blazor implementation yet.
- No multi-select variant. Where one has been asked for, the answer so far has been a checkbox group; if that stops being enough it needs a decision record rather than an option flag.
- Very long lists are not virtualised. The menu caps at 280px and scrolls, which is fine to about fifty options and untested well past that.
Accessibility requirements
| Requirement | WCAG SC | How Single Record meets it | Test method |
|---|---|---|---|
| The trigger exposes its role and state | 4.1.2 | A real button with aria-haspopup and aria-expanded; the menu is role="listbox" with role="option" children carrying aria-selected. | Screen reader announce, open and close |
| Label is programmatically associated | 1.3.1 | A real label bound to the trigger, so the field announces as "Ward, collapsed" rather than as a bare button. | Screen reader announce |
| Fully keyboard operable | 2.1.1 | Enter, Space or Down to open; Up/Down to move; Home/End to jump; typeahead to skip; Enter to choose; Escape to close and return focus to the trigger. | Keyboard only |
| Focus does not escape an open menu | 2.4.3 | Focus stays within the listbox while open and returns to the trigger on close, so the tab order never lands behind the menu. | Keyboard tab with menu open |
| Open state is not colour alone | 1.4.1 | The chevron rotates 180°, in addition to the focus ring and the menu appearing. | Greyscale review |
| Error is exposed and actionable | 3.3.1, 3.3.3 | The field is marked invalid, the message is referenced from it, and it says what to do. | Screen reader, submit empty |
| Focus visible and not clipped | 2.4.7 | A 3px Cyan/700 outer stroke drawn outside the field box, so the field border never crops it. | Keyboard tab |
| Target size | 2.5.8 | 40px trigger and 40px options clear the 24px minimum outright; mobile layouts take the full 44px. | Measure, touch device |
| Menu length stays reachable | 2.4.3 | The menu caps at 280px and scrolls; past that the list is grouped rather than made taller. | Keyboard, long list |