@speles7172/controls
v0.4.0
Published
Reusable React building blocks — a data table, searchable selects, a modal, badges and the loading and empty states — over static options or a transport you supply.
Readme
@speles7172/controls
Reusable React form controls. One package that grows: a control needed in two places belongs here rather than being copied into the second one.
Today that is <SearchableMultiSelect> and its single-select sibling
<SearchableSelect>, the typed field <ValueInput>,
<KeyValueList> for a setting that is really several, and <Modal> —
the dialog shell every console was otherwise going to write for itself.
npm install @speles7172/controlsReact 18 or 19, as a peer dependency.
A static list
The common case, and the one people forget is allowed. Eight statuses do not need a network request between a keystroke and a list.
import { SearchableMultiSelect } from '@speles7172/controls';
import '@speles7172/controls/styles.css';
<SearchableMultiSelect
label="Status"
options={[
{ value: 'draft', label: 'Draft' },
{ value: 'sent', label: 'Sent' },
{ value: 'paid', label: 'Paid' },
]}
value={statuses}
onChange={setStatuses}
/>;Forty thousand vendors
The same control. The difference is one prop.
<SearchableMultiSelect
label="Vendor"
transport={{
search: (query) =>
fetch(`/api/options/vendors?${new URLSearchParams(query as never)}`).then((r) => r.json()),
resolve: (values) =>
fetch(`/api/options/vendors/resolve?values=${values.join(',')}`)
.then((r) => r.json())
.then((body) => body.options),
}}
value={vendorIds}
onChange={setVendorIds}
minSearchLength={2}
/>Both endpoints are four lines with
@speles7172/controls-client.
Implement resolve. It is the difference between a form reopened three
months later showing "Acme Corp, Beta Ltd" and one showing two raw uuids: the
selected values are not in the first page of options, so there is nothing else
to render them from.
Props worth knowing
| Prop | |
|---|---|
| options / transport | Where options come from. Passing both uses the transport and treats the list as labels already known — how you seed the current selection without a resolve round trip. |
| single | One value at a time. <SearchableSelect> wraps this with a string \| null value. |
| minSearchLength | Search nothing until this many characters. For a source with no meaningful first page. |
| allowCustomValue | Offer a term that matches nothing as its own option, last in the list. For a field whose options are a convenience rather than the permitted set — a log source filter, say. Off by default: a picker over real records should not invent ids. |
| clearable | Default true. Set false for a field that must always hold something — a console's current database. Hides the clear affordances and stops the actions behind them, so re-choosing the selected value cannot empty it either. |
| maxSelected | Enforced in the state machine, not only in the styling — a control that merely looks disabled is one a keyboard walks straight past. |
| chips | Removable chips under the trigger. On by default past one selected value. |
| name | Emits one hidden input per value, so the control works in a plain <form> that reads FormData. |
| renderOption | Render an option yourself. Gets the option and the current term. |
| theme | light (default), dark, or auto. Light by default because the control sits in someone else's page, and the host decides what that page looks like. |
A field whose type varies
<ValueInput> is the control every admin form was writing a switch for: a
checkbox for a boolean, a date picker for a date, a textarea for JSON, a
searchable select for a reference to one of your records.
import { ValueInput } from '@speles7172/controls';
<ValueInput
type="number" // text | multiline | number | boolean | date
label="Payment window" // | datetime | json | choice | reference
value={value} // always a string, whatever the type
onChange={setValue}
min={1}
max={365}
hint="Days until an invoice is due."
error={error}
/>;The value is always a string, in every mode. One representation is what lets
a caller hold a whole form in one shape and lets the control stay ignorant of
what a value means — the caller encodes and decodes.
@speles7172/config-client/core has those functions if you want them.
choice and reference delegate to <SearchableSelect>, so a field over forty
thousand records is the same component as a field over three.
A list of labelled entries
<KeyValueList> edits a setting that is really several — a feature-flag set, a
set of named thresholds, an enum-like list somebody maintains.
import { KeyValueList } from '@speles7172/controls';
<KeyValueList
label="Feature flags"
itemType="boolean"
value={items} // [{ label: 'Invoice scanning', key: 'invoice_scan', value: 'true' }]
onChange={setItems}
/>;label is for a person, key is for code, value is the setting — separate
because they change independently: renaming "Invoice scanning" must not break
the invoice_scan every consumer matches on.
Two entries sharing a key are flagged on the row, because one of them is unreachable whichever way the list is read. Reordering is two buttons rather than a drag handle, and unlike the implementation this generalises, they work.
A dialog
<Modal> is the shell, not the content: a titled panel over a backdrop, with
the parts that get skipped when a dialog is written for the fourth time.
import { Modal } from '@speles7172/controls';
{open && (
<Modal title="Log record" onClose={close} wide theme="dark">
<RecordFields record={record} />
</Modal>
)}It closes on Escape and on a click on the backdrop — not on a click that
merely bubbles out of the panel, and not on a drag that started inside it. It
announces itself as role="dialog" aria-modal="true", named by its title unless
labelledBy names something else. It focuses the panel rather than the first
control, because focusing a text box scrolls a long dialog to it and reads the
wrong thing aloud. It traps Tab at both ends, so the third Tab is not on a
field behind the overlay. It returns focus to whatever opened it, so a record
opened from a table row hands the row back. And it stops the page behind from
scrolling, restoring whatever overflow the host had rather than clearing it.
Rendered in place rather than through a portal, deliberately: a console bridges
its palette onto --ctl-* with a descendant selector, and a portal to
document.body escapes it — a dark console would open a light dialog.
position: fixed covers the viewport from wherever the element sits. The one
thing that breaks that is an ancestor with transform, filter or
will-change, which becomes the containing block for fixed descendants; the fix
for that is on the ancestor.
Keyboard and screen readers
↑ ↓ move, wrapping. Home / End jump to the ends. Enter toggles the
active option — and does not submit the surrounding form. Escape closes.
Tab leaves: the popover is not a trap.
The trigger announces as "label, current value". While the popover is open
the search box is the combobox and owns aria-activedescendant, which is what
lets a screen reader follow the arrow keys.
Styling
Plain semantic HTML with ctl- class names and no styling dependency. Import
@speles7172/controls/styles.css for the default look, or write your own
against the same classes.
The stylesheet is driven entirely by custom properties on .ctl, so re-theming
is a few lines rather than a fork:
.my-app .ctl {
--ctl-accent: rebeccapurple;
--ctl-radius: 4px;
}An application with its own design system should skip the components and use
useOptionSelect directly — it is the whole state machine (debounce, discarding
stale responses, paging, label resolution) with none of the markup.
Why not a native <select multiple>
Because it is unusable for the job. No search, a scroll box four rows tall, and
a selection you lose the moment you ctrl-click without meaning to. That is what
@speles7172/audit-console's facet filters used to be, and an application with
fifty entity types had a filter it could not reasonably use.
Licence
MIT
