@speles7172/controls
v0.5.1
Published
Reusable React building blocks — a data table, searchable selects, a date range filter with a Gregorian and Hebrew calendar, a modal, badges and the loading and empty states — over static options or a transport you supply.
Downloads
334
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,
<DateRangeFilter> and the <Calendar> grid inside it, 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 date range
<DateRangeFilter> is the filter every screen was building out of three
controls that have to agree with each other: a <select> of named ranges and
two date inputs.
import { DateRangeFilter } from '@speles7172/controls';
<DateRangeFilter
label="Occurred"
value={range} // { kind: 'preset', preset: 'this_month' }
onChange={setRange}
calendars={['gregorian', 'hebrew']}
/>;What it holds is a description of a range — "this month", "the last 7 days",
two dates — rather than two instants, so that "today" is still today tomorrow
and so that the endpoint behind the screen resolves the same words to the same
two timestamps. resolveDateRange does that, on either side, and
@speles7172/controls-client compiles the WHERE.
The Hebrew calendar is a real calendar here, not a relabelling: months of 29 or
30 days depending on the length of the year, thirteen of them in a leap year,
and a year that begins at Rosh Hashanah. Dates are written as
ט״ו בשבט תשפ״ו, or transliterated with hebrewLanguage="en".
Every word it renders is a prop, the range is picked in two clicks with the
range drawn under the pointer in between, and name makes it work inside a
plain <form>. <Calendar> is exported on its own for a form with one date
field. The whole feature — the value, the resolution, the SQL, the calendar —
is written up in docs/DATES.md.
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
