Playground
A text field with a label, help text, native constraint validation and an optional submit arrow. It exposes a live value property, so data-bind works as it does on a native <input>.
Examples
Label and hint
<sb-input label="Call sign" placeholder="e.g. Maverick" hint="Shown to the rest of the crew."></sb-input>
Validation
Validation starts once a value is committed (Enter, or leaving a changed field) or submitted, and then runs on every keystroke and on every new value, like the browser's :user-invalid. required, minlength, pattern and type use the browser's own rules. Set error to replace the default message.
<sb-input label="Email" type="email" required placeholder="you@starbase.dev"></sb-input>
<sb-input label="Launch code" pattern="[0-9]{4}" error="Four digits, commander." placeholder="0000"></sb-input>
Two-way binding
Declare the signal first, because data-bind only writes to a signal that already has a value.
<div data-signals:_dest="''">
<sb-input label="Destination" data-bind:_dest__prop.value__event.input placeholder="Type a planet"></sb-input>
<p data-text="$_dest ? 'Plotting course to ' + $_dest + '…' : 'Awaiting destination'"></p>
</div>
Submit with the arrow
sb-submit fires on Enter or on the arrow button, but only when the value is valid. Send it to your backend with @post. (Enter also commits the value, so sb-change follows.)
<div data-signals:_log="''">
<sb-input action required placeholder="Transmit a message..." data-on:sb-submit="$_log = 'Sent: ' + evt.detail.value"></sb-input>
<p data-text="$_log"></p>
</div>
With commands
Give it a name, and it emits sb-change with { name, value } when a value is committed (Enter, or leaving a changed field): ready to post as a command. The value attribute is the server's value: a new one replaces what the user typed, and re-sent identical markup leaves an edit alone. To clear the field, the server sends value="".
With confirm, it sets :state(pending) while the local value differs from the server's, and revert() goes back to the server's value when a command is rejected:
<sb-input name="callsign" label="Call sign" confirm value="STARBASE-1" rev="7"
data-on:sb-change="@post('/cmd/flight', {payload: evt.detail})"
data-on:datastar-fetch="evt.detail.el === el && evt.detail.type === 'error' && el.revert()"></sb-input>
A server that answers with the value it already had (it upper-cased starbase-1 back to STARBASE-1, or ignored a no-op) sends identical markup, so the edit would stay pending. Render rev, a revision that changes with every applied command for this field (a counter will do): a new rev means the server has answered, and its value wins even when it is unchanged. The same clears a chat-style field after its message was sent: value="" with a new rev.
Commands and components lists the handler and the command behind this call sign, and the Showcase runs it.
Forms
Inside a <form>, sb-input submits its value under its name (name=…, also when it is empty), new FormData(form) and Datastar's contentType: 'form' include it, and a form reset brings back the server's value and clears the error, without firing input, change or sb-change. A disabled field is left out, like a native one.
It is not a form-associated element yet (Rocket can't declare one), so the form's own validation (checkValidity(), :invalid) doesn't see required, minlength or pattern, Enter emits sb-submit instead of submitting the form, and <fieldset disabled>, <label for> and the form attribute don't reach it. With commands, sb-change carries { name, value } (see With commands).
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size: it fills the width it is given, up to
26rem; setmax-inline-sizeon the element to change that. The field is2.75remtall: setblock-sizeon::part(input)(and::part(button), withaction). - Fonts: the label, the text you type and the hint use your page's font.
- Colours: the field is
--sb-control-bgwith a--sb-control-borderedge (--sb-control-border-hoveron hover) and--sb-control-text; the placeholder and the hint are--sb-control-placeholder, the label--sb-text-2. Focus draws a--sb-brand-lightedge with a--sb-brand-subtleglow, and errors are--sb-danger. The submit button has a--sb-brand-lightedge on--sb-brand-subtle, and is--sb-brandwith a--sb-text-on-brandarrow on hover. Corners are--sb-control-radius. - Parts:
label,inputandbutton(the submit arrow, withaction). Your page's::part()rules win over the component's own, without!important.
<style>
.my-input { max-inline-size: 18rem; --sb-control-radius: 0; }
.my-input::part(input) { block-size: 2.25rem; font-size: 0.875rem; }
.my-input::part(label) { text-transform: uppercase; letter-spacing: 0.08em; }
</style>
<sb-input class="my-input" label="Call sign" placeholder="e.g. Maverick"></sb-input>
Accessibility
label is a <label> for the native <input>; without one, the element's aria-label or the placeholder names it. The hint and the error describe it (aria-describedby). The error is a polite live region, announced after what the screen reader is saying, and aria-invalid follows the validation state. disabled disables the native input (and the arrow), which takes it out of the tab order. Right to left, the submit arrow points the other way. In forced colors, focus shows as an outline.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.
<script type="importmap">
{ "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-input placeholder="Your message..." action></sb-input>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/input@b0f8e486a3e4/input.bundle.min.js" integrity="sha384-drnZq1QHiCL/p+Armbvm33GrACB52CZHkR2hkh3mjDsrQoJCoGLMi0R0hk71MdGZ"></script>
<sb-input placeholder="Your message..." action></sb-input>Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/input@b0f8e486a3e4/input.min.js": "sha384-CKciJlz9ZdafndJJfxAR2ZTvJd5e9uGhmwD5mwqGRubaE5lBnxBz4LYDV222McnQ"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/input@b0f8e486a3e4/input.min.js" integrity="sha384-CKciJlz9ZdafndJJfxAR2ZTvJd5e9uGhmwD5mwqGRubaE5lBnxBz4LYDV222McnQ"></script>
<sb-input placeholder="Your message..." action></sb-input>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/input@b0f8e486a3e4/input.bundle.min.js": "sha384-drnZq1QHiCL/p+Armbvm33GrACB52CZHkR2hkh3mjDsrQoJCoGLMi0R0hk71MdGZ"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>
<sb-input placeholder="Your message..." action></sb-input>Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<sb-input>input.bundle.min.jsone file 2.1 kB brotli
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/input/input.bundle.min.js"></script>
<sb-input placeholder="Your message..." action></sb-input>Or copy the module files, keeping each component's folder, and load each component's module:
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/input/input.min.js"></script>
<sb-input placeholder="Your message..." action></sb-input>Size
A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli |
|---|---|---|---|
input.bundle.min.js | 5.4 kB | 2.4 kB | 2.1 kB |
As module files: one request, 2.1 kB minified with brotli
For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
input.js | 12.2 kB | 4.7 kB | 4.0 kB | 2.1 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | "" | The server's value. A new one replaces local edits (to clear, send value=""); the live value is the value property. |
rev | string | "" | Revision of the server's value, for commands: change it whenever the server applies a command for this field, and its value wins even when it is unchanged (e.g. an edit normalised back to it). |
label | string | "" | Visible label. |
placeholder | string | "" | Placeholder text. |
type | "text" | "email" | "search" | "url" | "tel" | "password" | "text" | Input type. |
name | string | "" | Name reported in sb-change and sb-submit (e.g. the field of a command), and the name its value is submitted under in a form. |
required | boolean | false | Value must not be empty. |
minlength | number | 0 | Minimum length. |
pattern | string | "" | Regular expression the value must match. |
hint | string | "" | Help text below the field. |
error | string | "" | Message shown when invalid (defaults to the browser message). |
action | boolean | false | Show a submit arrow button. |
disabled | boolean | false | Disable the field (and the arrow). A form leaves it out. |
confirm | boolean | false | Server-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()). |
Events
| Name | Description |
|---|---|
input | On every keystroke (native, re-targeted to the host). |
change | When the value is committed. |
sb-change | When the value is committed (Enter, or leaving a changed field). detail: { name, value }: ready for a command. |
sb-submit | Enter or arrow button with a valid value. detail: { name, value }. |