Input Mask
stimeo--input-mask
Formats a field against a fixed pattern, preserving the caret.
The stimeo--input-mask controller formats a field in place against a fixed pattern (9 = digit, a = letter, * = alphanumeric, anything else a literal): it inserts the separators as you type, rejects characters the token does not allow, and — crucially — keeps the caret in place on insert, Backspace, and range replacement. It separates the masked display from the raw value, syncing the unmasked digits to the hidden [data-stimeo--input-mask-unmask] field within the same form so the server receives the raw value. Tokens are configurable (your tokens merge over the defaults). It is idempotent — connect re-formats a server-rendered value — and holds no module-scope state, so it is stable across Turbo restore/morph. It reflects data-mask-complete / data-mask-empty and dispatches stimeo--input-mask:change. Currency Input owns money formatting; this is the generic mask. Behavior only.
Raw value sent to the server:
(empty)
<%# Markup for the input mask demo.
The controller formats the field in place against the pattern (9=digit), inserts
the separators, rejects non-digits, preserves the caret, and syncs the raw value
to the hidden field. demo.js mirrors that hidden raw value (and the complete flag)
into the visible readout below, since the unmask field is hidden. %>
<div class="mask-demo">
<form class="mask-demo__form">
<label class="mask-demo__field">
<span><%= t("components.input_mask.demo.label") %></span>
<input type="text" class="demo-input" inputmode="numeric"
data-controller="stimeo--input-mask"
data-stimeo--input-mask-pattern-value="(999) 999-9999"
data-action="input->stimeo--input-mask#format"
aria-describedby="mask-hint"
placeholder="<%= t("components.input_mask.demo.placeholder") %>">
<input type="hidden" name="phone" data-stimeo--input-mask-unmask>
</label>
<p id="mask-hint" class="mask-demo__hint"><%= t("components.input_mask.demo.hint") %></p>
</form>
<p class="mask-demo__raw"
data-empty="<%= t("components.input_mask.demo.empty") %>"
data-complete="<%= t("components.input_mask.demo.complete") %>">
<%= t("components.input_mask.demo.raw_label") %>
<code data-mask-demo="raw"><%= t("components.input_mask.demo.empty") %></code>
<span data-mask-demo="status"></span>
</p>
</div>
/*
* Presentation-only styles for the input mask demo.
* The library formats the value, preserves the caret, and reflects
* data-mask-complete / data-mask-empty; this CSS owns layout and the readout, and
* can react to the completion flag for a finished-state affordance.
*/
.mask-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.mask-demo__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
font-weight: 600;
}
/* The library marks a fully-filled mask with data-mask-complete. */
.mask-demo .demo-input[data-mask-complete] {
border-color: var(--leaf-500);
}
.mask-demo__hint {
margin: 0.25rem 0 0;
font-size: 0.8rem;
font-weight: 400;
color: var(--color-text-muted);
}
.mask-demo__raw {
margin: 0;
font-size: 0.9rem;
}
.mask-demo__raw code {
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
background: var(--surface-subtle);
font-family: ui-monospace, monospace;
}
// Consumer-side JS for the input mask demo (demo-only).
// The raw (unmasked) value the controller syncs lives in a hidden field, so this
// mirrors it into a visible readout from the change event, and shows a "complete"
// note when every slot is filled. Localized strings come from data attributes.
const readout = document.querySelector(".mask-demo__raw");
const raw = document.querySelector("[data-mask-demo='raw']");
const status = document.querySelector("[data-mask-demo='status']");
if (readout && raw) {
document.addEventListener("stimeo--input-mask:change", (event) => {
const { unmasked, complete } = event.detail;
raw.textContent = unmasked || readout.dataset.empty;
if (status) status.textContent = complete ? ` ${readout.dataset.complete}` : "";
});
}
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--input-mask"
Values
| Name | Description | Attribute |
|---|---|---|
pattern
|
The mask pattern (9 / a / * tokens and literals; default empty). |
data-stimeo--input-mask-pattern-value |
tokens
|
Placeholder → regex-source map; your keys merge over the defaults. | data-stimeo--input-mask-tokens-value |
unmaskToHidden
|
Whether to sync the raw value to the hidden field (default true). |
data-stimeo--input-mask-unmask-to-hidden-value |
Actions
| Name | Description | Action |
|---|---|---|
format
|
Formats the field, preserving the caret; bound to the input event. |
stimeo--input-mask#format |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the value changes; detail carries masked, unmasked, complete. |
stimeo--input-mask:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-mask-complete |
The masked input | "true" once every token slot in the pattern is filled. |
data-mask-empty |
The masked input | "true" while the value is empty (e.g. for placeholder styling). |