Form inputs
Input takes any type, so an email or a URL field is a prop away and
not a component. The eight below exist because they each add behaviour a type cannot: a second value, a keyboard rule, a format that
has to survive a round trip.
Two shapes
Four of them wrap Input and put something in its
prefix or suffix slot, so a change to the field's border, height or focus ring reaches them for free.
The other four draw their own box, because what they are is not one line of text.
// four of them are an Input with a job on top of it
SearchInput Input + a clear button + a debounced callback
CurrencyInput Input + grouping on blur, bound to a number
TimeInput Input + steppers, bounds and a minute step
ColorInput Input + the native swatch, bound to a hex string
// four draw their own box, because the shape is not a field
InputAddon a fixed label welded to an InputGroup
PinInput one box per character
TagsInput chips and a field sharing one border
FileInput a button, a filename and a clear buttonSizes and tones carry across
size, tone, invalid and disabled, and means the same thing by them as Input does. Wrap any of them in Field for the label, hint and error.What each one binds
The bound value is the part worth reading twice: three of these bind something other than a string, and one of them binds nothing at all.
| Component | Binds | What it adds |
|---|---|---|
| SearchInput | string | A clear button and a debounced callback beside the live value. |
| InputAddon | — | A fixed label welded to an InputGroup: a prefix, a unit, a domain. |
| PinInput | string | One box per character, with paste spreading and arrow-key movement. |
| TagsInput | string[] | Free-text tokens committed on Enter, deduplicated and capped. |
| CurrencyInput | number | null | Grouping applied on blur, so the bound value stays a number. |
| TimeInput | string | Steppers, inclusive bounds and a minute step, over an HH:MM string. |
| ColorInput | string | The native swatch and a hex field kept in step with each other. |
| FileInput | File[] | A one-line picker with the same type, size and count rules as Dropzone. |
let query = $state(''); // SearchInput — string
let code = $state(''); // PinInput — string, shorter means incomplete
let tags = $state<string[]>([]); // TagsInput — string[]
let price = $state<number | null>(null); // CurrencyInput — number, null when empty
let start = $state('09:00'); // TimeInput — 'HH:MM', '' when empty
let brand = $state('#0f766e'); // ColorInput — '#rrggbb'
let files = $state<File[]>([]); // FileInput — File[]SearchInput
Two values, on purpose: bind:value follows
every keystroke so the field stays responsive, and onsearch fires once the typing stops. The timer
restarts on each keystroke rather than queueing, so a fast typist sends one request, not eight.
<script lang="ts">
import { SearchInput } from '@nqmcreative/ui/matte';
let value = $state('');
let results = $state<string[]>([]);
</script>
<!-- `value` updates on every keystroke; `onsearch` waits for the pause -->
<SearchInput
bind:value
debounce={300}
placeholder="Search invoices"
onsearch={async (query) => (results = await search(query))}
/>The clear button appears only while there is something to clear, and calls onclear after emptying the field.
InputAddon
The piece InputGroup was missing. It is not
the same as Input's prefix snippet: that renders inside the field's border, while an addon is its own box welded to the outside.
<InputGroup>
<InputAddon>https://</InputAddon>
<Input bind:value={handle} placeholder="studio" />
<InputAddon>.com</InputAddon>
</InputGroup>
<InputGroup>
<Input bind:value={weight} inputmode="decimal" />
<InputAddon>kg</InputAddon>
</InputGroup>Give it the same size as the field beside it — the
heights have to agree, and nothing enforces that for you. variant="plain" drops the tint when the addon should read
as part of the field rather than a label on it.
PinInput
The value is one compact string, so code.length is how many boxes are filled and code.length === length is "complete". A six-digit
paste into the first box fills all six; a paste into the third fills the three that are left.
<script lang="ts">
import { PinInput } from '@nqmcreative/ui/matte';
let code = $state('');
</script>
<PinInput
bind:value={code}
length={6}
groupAfter={3}
name="otp"
oncomplete={(value) => verify(value)}
/>Backspace on a filled box clears it, and on an empty one takes the box before — the way a caret
behaves in a single field. Arrow keys move without editing. mode="alphanumeric" takes letters, mask renders dots, and name adds one hidden field so a plain form posts the whole
code rather than six fragments.
TagsInput
For values that do not come from a list. MultiSelect is the one to reach for when the options
are known up front; this is the one for topics, labels and anything a person invents as they go.
<script lang="ts">
import { TagsInput } from '@nqmcreative/ui/matte';
let tags = $state(['svelte']);
</script>
<TagsInput
bind:tags
max={6}
minLength={2}
separators={[',', ' ']}
name="topics"
onreject={(reason) => toast.error(reason)}
/>A refusal is not a silent no-op
minLength, and a full list are all
refusals, and each one reaches onreject with its reason. A field that simply ignores the Enter key
reads as broken, so say why.Enter commits, as does any key in separators.
Backspace in an empty field peels the last chip off. A paste is split on the same separators, so
one paste can add several at once.
CurrencyInput
The bound value is a number, and null when the field is empty — which is not the
same as zero, and the difference matters the moment the amount is optional.
<script lang="ts">
import { CurrencyInput } from '@nqmcreative/ui/matte';
// A number, not a string — the field owns the formatting, you own the value.
let price = $state<number | null>(1250000);
</script>
<CurrencyInput bind:value={price} currency="Rp" group="." decimal="," precision={0} min={0} />Grouping is applied when the field loses focus and dropped while it has it. Formatting under the caret is what pushes the caret around mid-word, and the version of this field that does it is the version people fight with.
group and decimal are yours to set rather than Intl's to choose, because the field has to parse back
what it prints — and some regions group with a space nobody can type.
TimeInput
Always HH:MM, which is what <input type="time"> posts — so a form
behind this field needs no special case. Typing is forgiving: 9, 9:5 and 0905 all settle to 09:05 when the field is left.
<script lang="ts">
import { TimeInput } from '@nqmcreative/ui/matte';
// Always `HH:MM` — the same string <input type="time"> would post.
let start = $state('09:00');
</script>
<TimeInput bind:value={start} min="08:00" max="17:00" step={15} />Arrow keys and the steppers move by step minutes and stop
at the bounds rather than wrapping — rolling 23:55 round to 00:00 turns a late meeting into an early
one.
ColorInput
The native swatch and a hex field, kept in step. Shorthand is expanded and alpha is dropped when
the field settles, so the bound value is always the #rrggbb the swatch can actually show. A half-typed
value is left alone rather than corrected under the caret.
<script lang="ts">
import { ColorInput } from '@nqmcreative/ui/matte';
let brand = $state('#0f766e');
</script>
<ColorInput bind:value={brand} swatches={['#0f766e', '#b45309', '#1d4ed8']} />FileInput
The same validation as Dropzone — accept, maxSize, maxFiles, judged in that order — in one line
instead of a drop target. Reach for the drop target when files arrive by dragging, and for this
when the field sits in a form with six others.
<script lang="ts">
import { FileInput } from '@nqmcreative/ui/matte';
let files = $state<File[]>([]);
</script>
<FileInput
bind:files
name="avatar"
accept="image/*,.pdf"
maxSize={2 * 1024 * 1024}
onreject={(rejected) => console.warn(rejected)}
/>multiple is off by default, which is the difference
from Dropzone: one file reads as its name and weight,
several as a count with the list underneath.
The rules underneath
None of the behaviour above lives in a style. It is all in @nqmcreative/ui/core, which is why all three
styles agree about what a duplicate tag is, and why you can use the same rules in a field this
package does not ship.
// the rules these fields run on, usable without any of them
import {
addTag, splitTags, // tokens: dedup, cap, paste splitting
fillFrom, sanitisePin, // codes: where a paste lands, what a box takes
parseTime, clampTime, // clock: 'HH:MM' in and out, bounds and steps
formatGrouped, parseGrouped, // money: group on the way out, forgive on the way in
normaliseHex, // colour: '#abc' becomes '#aabbcc'
sortFiles, formatSize // files: accepted, rejected and why
} from '@nqmcreative/ui/core';