feat(mask-field): MaskField — single-input character mask (Tier-1)

Generic pattern mask for free-form fixed-literal inputs (phone, SSN,
credit card, IBAN, postal code, plate) — the confirmed gap across
ark/bits/radix/react-aria (radix #1412 "not planned").

- Zero-dependency mask engine (mask-engine.ts): tokens 9/A/*, literals,
  escape, optional [] tail, accept-if-fits + caret mapping. Pure/DOM-free,
  node-tested (25 tests). Promotable to arts/mask later.
- Soma provider composes base Field (OR-merge state, inputId register);
  reject-and-revert input + caret restore via tick(); commit-set on
  blur/Enter only. value = masked string, unmaskedValue derived.
- Single role=textbox (never fake spinbutton segments); truthful value
  announcement; inputmode from mask; date/number/OTP delegate out.
- Sema pack commit-set -> form.commit.subtle + tap (mirrors NumberField).
- Eidos option-C wrapper + recipe (25 tokens) + demo (v2 6-tab testbed).

Passes component:audit mask-field (0 err), 30 unit tests, recipe-css-
contract, component-api-contract, eidos-lint (0 invalid), check, smoke.

Note: soma barrel export + demo-app registration (sema pack + nav link)
live in soma/components/index.ts and web/routes/uix/+layout@.svelte, left
unstaged because they carry unrelated branch WIP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent f5eb8acb40
commit 153d1ced98

@ -0,0 +1,142 @@
# MaskField
Single-input **character mask** for free-form fixed-literal patterns — phone,
national id / SSN, credit card, IBAN, postal code, license plate. The value is
formatted live against a `mask` pattern as the user types; literals auto-insert,
invalid characters are rejected, and the caret is preserved across the reformat.
```svelte
<script lang="ts">
import { MaskField } from '$uix/eidos/components/mask-field';
let phone = $state('');
</script>
<MaskField mask="(999) 999-9999" bind:value={phone}>
<MaskField.Input placeholder="(555) 123-4567" />
</MaskField>
```
Token alphabet (default, overridable via `definitions`): **`9`** = digit,
**`A`** = letter, **`*`** = alphanumeric. Every other character is a literal;
`\` escapes a token char into a literal (`\9` → literal "9"); `[ … ]` marks an
optional (variable-length) tail (`99999[-9999]` = ZIP or ZIP+4).
## Baseline
**No Air baseline.** MaskField is a new component — there is no `air/mask-field`
predecessor to recover. It fills a gap the whole Field family left open:
`NumberField` covers numeric/`Intl` formatting, `DateField`/`TimeField` cover
segmented date/time, `CssField` covers CSS dimensions, `PasswordField` covers
sensitivity masking, `PinInput` covers fixed-length codes — none cover an
arbitrary positional character mask. The design was set from a full reference
sweep (imask, Cleave, Maskito, Ark UI, Bits UI, Radix, React Aria) rather than a
prior in-house baseline.
## Comparativa
A generic pattern mask is a **confirmed gap** across the component libraries: an
adversarial docs check found none in Ark UI, Bits UI or Radix (the request
radix-ui/primitives #1412 was closed *"not planned"*), and React Aria
deliberately avoids character masks for accessibility reasons.
| Feature | imask | Cleave | Maskito | Ark / Bits / Radix | React Aria | UIX MaskField | Decision |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Per-char token mask (`9`/`A`/`*` + literals) | ✅ (`0`/`a`/`*`) | ❌ blocks only | ✅ RegExp array | ❌ | ❌ segmented | ✅ | implementar — core |
| Optional group `[ ]` (variable tail) | ✅ | ❌ | ⚠️ via dynamic | ❌ | ❌ | ✅ | implementar — core |
| Custom token definitions (char → RegExp) | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | implementar — merged over defaults |
| Caret preservation / skip literals | ✅ | ⚠️ buggy | ✅ | n/a | ✅ non-editable | ✅ | implementar — engine maps caret, soma restores |
| Honest a11y (announced value = committed value) | ❌ silently drops | ❌ | ⚠️ | ✅ | ✅ | ✅ | implementar — reject-and-revert, single textbox |
| Unmasked + typed value views | ✅ 3 views | ⚠️ getRawValue | ⚠️ transform | n/a | ✅ domain object | ⚠️ raw only | diferir — `typedValue` is v2 |
| Number / currency / percent mask | ✅ | ✅ numeral | ✅ kit | Ark ✅ Intl | ✅ Intl | ➡️ delegate | descartar — NumberField owns it |
| Date / time / datetime entry | ✅ | ✅ | ✅ | ✅ segmented | ✅ segmented | ➡️ delegate | descartar — DateField/TimeField own it |
| Pure DOM-free engine (testable core) | ✅ | ✅ cleave-zen | ✅ transform | ❌ | ⚠️ hooks | ✅ | implementar — `mask-engine.ts`, node-tested |
| blocks+delimiters / eager / dynamic / pipe | ✅ | ⚠️ | ✅ | ❌ | ❌ | ⏳ | diferir — v2 roadmap |
Sources: imask.js.org, cleave.js / cleave-zen, maskito.dev, ark-ui.com,
bits-ui.com, radix-ui.com, react-aria.adobe.com (2026 docs).
## Decisiones
- **`value` is the masked display string** (imask/Cleave/Maskito convention,
and consistent with `NumberField` whose visible input carries `name` and
submits the formatted string). The raw/unmasked value is a read-only derived
(`unmaskedValue`, exposed on the provider snippet). No hidden input in MVP —
the visible input carries `name` and submits the masked value.
- **Own zero-dependency engine.** The masking logic lives in a pure, DOM-free
module (`mask-engine.ts`) co-located in the soma component and unit-tested in
the node project. It mirrors Maskito's transform/binder split and cleave-zen's
pure-function lesson. Promotable to `arts/mask` when a second consumer appears
(no premature abstraction today — one consumer).
- **Default token alphabet `9`/`A`/`*`** (jQuery masked-input / inputmask /
Cleave tradition — the most widely-recognised), not imask's `0`/`a`/`*`.
Custom `definitions` merge over the defaults.
- **Single `role="textbox"`, never fake spinbutton segments.** A char mask is a
*value affordance*; the announced value stays truthful (rejected keystrokes
revert the visible value rather than being silently dropped — the imask a11y
flaw we deliberately do not copy). Segmented editing is DateField/TimeField's
job for bounded numeric units.
- **Delegation, not re-implementation.** Number/currency → `NumberField`;
date/time/datetime → `DateField`/`TimeField`; CSS dimensions → `CssField`;
OTP → `PinInput`; sensitivity/reveal → `PasswordField`. MaskField owns *only*
free-form fixed-literal patterns.
- **Commit on blur / Enter only.** `commit-set` (commit·set, intent neutral,
sequence post) fires once per changed value — never per keystroke (typing is
plain data flow; a per-keystroke commit would beep on every character).
## Gaps
Deferred and dropped capabilities, each with an explicit disposition:
| Capability | Disposition | Ships in | Why |
| --- | --- | --- | --- |
| `blocks` + `delimiters` sugar | diferir (v2) | Cleave | Token pattern already expresses it; sugar is additive |
| `validate()` predicate hook (blur) | diferir (v2) | imask RegExp/fn | MVP rejects per char-class; schema seam is v2 |
| Eager mode (auto-insert/strip while typing) | diferir (v2) | imask, Cleave | Lazy default covers the common case |
| `typedValue` view (parse to Number/Date) | diferir (v2) | imask, React Aria | Raw + masked cover MVP |
| Dynamic mask (`modify(value)` → config) | diferir (v2) | imask, Maskito | Explicit chooser only when a consumer needs it |
| Format-only headless pipe | diferir (v2) | imask, cleave-zen | Engine helpers already exported for this |
| Number / currency / date masking | descartar | imask, Cleave | Redundant — `NumberField` / `DateField` own it |
| Nested `MaskedRange`/`MaskedEnum` | descartar | imask | Low value vs the token model |
| Auto best-fit dynamic dispatch | descartar | imask | Surprising heuristic; explicit list preferred |
| Credit-card brand auto-detection | descartar | Cleave | Domain concern, not a generic primitive |
| babel / core-js runtime dependency | descartar | imask | Violates zero-dependency doctrine |
## Out of scope (v2 roadmap)
If real consumers ask for it, the additive layer is: `blocks`/`delimiters`
sugar compiling to a token pattern · a blur-time `validate()` predicate · eager
mode · `typedValue` · soma-level dynamic mask via `modify()` · a format-only
pipe (the pure `compileMask` / `unmaskValue` / `conformValue` helpers are
already exported from `$soma/components/mask-field` for server/initial-value
seeding). None are blocking; each lands behind a real request.
## Sema events
| Event | Family · Verb | Intent | Sequence | Target | When |
| --- | --- | --- | --- | --- | --- |
| `commit-set` | commit · set | neutral | post | provider | Blur / Enter, once per changed value |
Typing emits no semantic event (high-frequency editing). Perceptual signature:
`form.commit.subtle` sound + `tap` haptic — identical to NumberField (both are
field commits). Pack: `src/uix/sema/components/mask-field.ts`.
## Anatomy
| Part | Element | Role | Notes |
| --- | --- | --- | --- |
| `MaskField` (Provider) | `<div data-mask-field>` | — | Bordered chrome; owns mask, value, completeness |
| `MaskField.Input` | `<input data-mask-field-input>` | textbox | The masked input; `inputmode` derives from the mask |
Visual props (eidos): `size` (`xs`–`xl`), `variant` (`surface` / `outline` /
`ghost`), `color` (intent palette, tints the focus border/ring).
## Accessibility
- Single `role="textbox"` — the mask is a value affordance, not a spinbutton
composition.
- `inputmode="numeric"` for all-digit masks (mobile keypad); `text` otherwise.
- Inside a `Field`, `Field.Label`'s `for=` targets the input (id registered via
the FieldProvider seam); format hints ride `Field.HelperText` through
`aria-describedby`; `disabled`/`readonly`/`required`/`invalid` OR-merge.
- The announced value always equals the committed value — a rejected keystroke
reverts the visible text rather than being silently swallowed.

@ -0,0 +1,30 @@
// MaskField — single-input character mask for free-form fixed-literal
// patterns (phone, national id, credit card, IBAN, postal code, plate).
//
// import { MaskField } from '$uix/eidos/components/mask-field';
//
// <MaskField mask="(999) 999-9999" bind:value>
// <MaskField.Input placeholder="(555) 123-4567" />
// </MaskField>
import MaskFieldComponent from './mask-field.svelte';
import Input from './mask-field-input.svelte';
type MaskFieldNamespace = typeof MaskFieldComponent & {
Input: typeof Input;
};
const MaskField = MaskFieldComponent as MaskFieldNamespace;
MaskField.Input = Input;
export { MaskField };
export default MaskField;
export type {
MaskFieldProps,
MaskFieldInputProps as InputProps,
MaskFieldProviderSnippetProps as ProviderSnippetProps,
MaskFieldSize,
MaskFieldVariant,
MaskFieldColor
} from './types';

@ -0,0 +1,8 @@
<script lang="ts">
import * as MaskField from '$soma/components/mask-field';
import type { MaskFieldInputProps } from './types';
let { ref = $bindable(null), children, ...rest }: MaskFieldInputProps = $props();
</script>
<MaskField.Input {...rest} bind:ref />

@ -0,0 +1,141 @@
/*
* MaskField — single-line masked input for fixed-literal patterns (phone,
* national id, credit card, IBAN, postal code, plate).
*
* Mirrors the shared text-input chrome (border, radius, focus ring, padding,
* disabled opacity) so every field in the system reads the same — the mask is
* a value affordance inside the control, not a structural change. The control
* stays a single flex row: the bordered box IS the provider, the borderless
* input fills it.
*
* No transitions on the chrome: an external `invalid` flip (e.g. a form
* re-validating onChange) would otherwise replay the border transition; the
* box snaps to its target state instead.
*/
[data-mask-field] {
--_mask-field-height: var(--mask-field-height-md);
--_mask-field-px: var(--mask-field-px-md);
--_mask-field-font-size: var(--mask-field-font-size-md);
/* Focus accent per data-color (foundation role tokens; default primary). */
--_mask-field-accent-border: var(--color-primary-border);
--_mask-field-border-color: var(--mask-field-border);
--_mask-field-bg: var(--mask-field-bg);
display: flex;
align-items: center;
inline-size: 100%;
min-inline-size: 0;
min-block-size: var(--_mask-field-height);
padding-inline: var(--_mask-field-px);
border: var(--mask-field-border-width) solid var(--_mask-field-border-color);
border-radius: var(--mask-field-radius);
background: var(--_mask-field-bg);
color: var(--mask-field-color);
font-family: var(--mask-field-font-family);
font-size: var(--_mask-field-font-size);
line-height: var(--mask-field-line-height);
}
/* ── Input ─────────────────────────────────────────────────────────── */
[data-mask-field-input] {
min-inline-size: 0;
inline-size: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
outline: none;
/* Masked patterns are digit-heavy — keep glyph columns aligned. */
font-variant-numeric: tabular-nums;
}
[data-mask-field-input]::placeholder {
color: var(--mask-field-placeholder-color);
}
[data-mask-field-input][data-disabled] {
cursor: not-allowed;
}
/* ── Color accents (focus border tint) ─────────────────────────────── */
[data-mask-field][data-color='secondary'] {
--_mask-field-accent-border: var(--color-secondary-border);
}
[data-mask-field][data-color='neutral'] {
--_mask-field-accent-border: var(--color-neutral-border);
}
[data-mask-field][data-color='affirm'] {
--_mask-field-accent-border: var(--color-affirm-border);
}
[data-mask-field][data-color='fulfill'] {
--_mask-field-accent-border: var(--color-fulfill-border);
}
[data-mask-field][data-color='risk'] {
--_mask-field-accent-border: var(--color-risk-border);
}
[data-mask-field][data-color='threat'] {
--_mask-field-accent-border: var(--color-threat-border);
}
[data-mask-field][data-color='loss'] {
--_mask-field-accent-border: var(--color-loss-border);
}
/* ── Sizes ─────────────────────────────────────────────────────────── */
[data-mask-field][data-size='xs'] {
--_mask-field-height: var(--mask-field-height-xs);
--_mask-field-px: var(--mask-field-px-xs);
--_mask-field-font-size: var(--mask-field-font-size-xs);
}
[data-mask-field][data-size='sm'] {
--_mask-field-height: var(--mask-field-height-sm);
--_mask-field-px: var(--mask-field-px-sm);
--_mask-field-font-size: var(--mask-field-font-size-sm);
}
[data-mask-field][data-size='lg'] {
--_mask-field-height: var(--mask-field-height-lg);
--_mask-field-px: var(--mask-field-px-lg);
--_mask-field-font-size: var(--mask-field-font-size-lg);
}
[data-mask-field][data-size='xl'] {
--_mask-field-height: var(--mask-field-height-xl);
--_mask-field-px: var(--mask-field-px-xl);
--_mask-field-font-size: var(--mask-field-font-size-xl);
}
/* ── Variants ──────────────────────────────────────────────────────── */
[data-mask-field][data-variant='outline'] {
--_mask-field-bg: transparent;
}
[data-mask-field][data-variant='ghost'] {
--_mask-field-border-color: transparent;
--_mask-field-bg: transparent;
padding-inline: 0;
}
/* ── State chrome ──────────────────────────────────────────────────── */
[data-mask-field]:hover:not([data-disabled]):not([data-focused]) {
--_mask-field-border-color: var(--color-border-strong);
}
[data-mask-field][data-focused] {
--_mask-field-border-color: var(--_mask-field-accent-border);
/* Flush outline ring (no offset → no gap) so it survives forced-colors. */
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 0;
}
[data-mask-field][data-invalid] {
--_mask-field-border-color: var(--mask-field-border-invalid);
}
[data-mask-field][data-disabled] {
opacity: var(--mask-field-disabled-opacity);
cursor: not-allowed;
}

@ -0,0 +1,30 @@
<script lang="ts">
import './mask-field.css';
import { ActiveEidos } from '$uix/eidos';
import * as MaskField from '$soma/components/mask-field';
import type { MaskFieldProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
value = $bindable(''),
children: bodyContent,
...rest
}: MaskFieldProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<MaskField.Provider
{...rest}
bind:value
data-size={resolvedSize}
data-variant={variant}
data-color={color}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</MaskField.Provider>

@ -0,0 +1,28 @@
import type {
ProviderProps,
InputProps,
ProviderSnippetProps as MaskFieldProviderSnippetProps
} from '$soma/components/mask-field';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type MaskFieldSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type MaskFieldVariant = ControlVariant;
export type MaskFieldColor = ColorRole;
/**
* Props for the eidos `<MaskField>`. Extends soma's headless primitive with
* visual sizing only. Mask pattern, value, completeness and Field integration
* are owned by soma.
*/
export type MaskFieldProps = ProviderProps & {
/** Visual size. @default 'md' */
size?: ResponsiveProp<MaskFieldSize>;
/** Surface treatment for the input chrome. @default 'surface' */
variant?: MaskFieldVariant;
/** Accent palette for the focus ring. @default 'primary' */
color?: MaskFieldColor;
};
export type MaskFieldInputProps = InputProps;
export type { MaskFieldProviderSnippetProps };

@ -2024,6 +2024,31 @@
--search-field-transition-duration: var(--duration-fast);
--search-field-transition-ease: var(--ease-default);
--search-field-disabled-opacity: var(--opacity-disabled);
--mask-field-height-xs: var(--size-xs-control-height);
--mask-field-height-sm: var(--size-sm-control-height);
--mask-field-height-md: var(--size-md-control-height);
--mask-field-height-lg: var(--size-lg-control-height);
--mask-field-height-xl: var(--size-xl-control-height);
--mask-field-px-xs: var(--space-2);
--mask-field-px-sm: var(--space-2-5);
--mask-field-px-md: var(--space-3);
--mask-field-px-lg: var(--space-3-5);
--mask-field-px-xl: var(--space-4);
--mask-field-font-family: var(--font-ui);
--mask-field-font-size-xs: var(--size-xs-font-size);
--mask-field-font-size-sm: var(--size-sm-font-size);
--mask-field-font-size-md: var(--size-md-font-size);
--mask-field-font-size-lg: var(--size-lg-font-size);
--mask-field-font-size-xl: var(--size-xl-font-size);
--mask-field-line-height: var(--leading-ui);
--mask-field-radius: var(--radius-md);
--mask-field-border-width: var(--border-width);
--mask-field-border: var(--color-border-default);
--mask-field-border-invalid: var(--color-risk-border);
--mask-field-bg: var(--color-surface-default);
--mask-field-color: var(--color-content-primary);
--mask-field-placeholder-color: var(--color-content-muted);
--mask-field-disabled-opacity: var(--opacity-disabled);
--password-field-height-xs: var(--size-xs-control-height);
--password-field-height-sm: var(--size-sm-control-height);
--password-field-height-md: var(--size-md-control-height);

@ -2085,6 +2085,33 @@ export const THEME_BASE_RECIPE_TOKENS = {
'transition-ease': 'var(--ease-default)',
'disabled-opacity': 'var(--opacity-disabled)'
},
'mask-field': {
'height-xs': 'var(--size-xs-control-height)',
'height-sm': 'var(--size-sm-control-height)',
'height-md': 'var(--size-md-control-height)',
'height-lg': 'var(--size-lg-control-height)',
'height-xl': 'var(--size-xl-control-height)',
'px-xs': 'var(--space-2)',
'px-sm': 'var(--space-2-5)',
'px-md': 'var(--space-3)',
'px-lg': 'var(--space-3-5)',
'px-xl': 'var(--space-4)',
'font-family': 'var(--font-ui)',
'font-size-xs': 'var(--size-xs-font-size)',
'font-size-sm': 'var(--size-sm-font-size)',
'font-size-md': 'var(--size-md-font-size)',
'font-size-lg': 'var(--size-lg-font-size)',
'font-size-xl': 'var(--size-xl-font-size)',
'line-height': 'var(--leading-ui)',
radius: 'var(--radius-md)',
'border-width': 'var(--border-width)',
border: 'var(--color-border-default)',
'border-invalid': 'var(--color-risk-border)',
bg: 'var(--color-surface-default)',
color: 'var(--color-content-primary)',
'placeholder-color': 'var(--color-content-muted)',
'disabled-opacity': 'var(--opacity-disabled)'
},
'password-field': {
'height-xs': 'var(--size-xs-control-height)',
'height-sm': 'var(--size-sm-control-height)',

@ -45,6 +45,7 @@ import { imagePickerLangs } from './image-picker';
import { knobLangs } from './knob';
import { linkPreviewLangs } from './link-preview';
import { listboxLangs } from './listbox';
import { maskFieldLangs } from './mask-field';
import { mediaPlayerLangs } from './media-player';
import { menubarLangs } from './menubar';
import { meterLangs } from './meter';
@ -149,6 +150,7 @@ export const componentLangs = {
knob: knobLangs,
'link-preview': linkPreviewLangs,
listbox: listboxLangs,
'mask-field': maskFieldLangs,
'media-player': mediaPlayerLangs,
menubar: menubarLangs,
'menu-dial': menuDialLangs,

@ -0,0 +1,8 @@
import type { LangNode } from '$libs/langs';
export const maskFieldLangs = {
label: {
es: 'Campo con máscara',
en: 'Mask field'
}
} satisfies LangNode;

@ -0,0 +1,116 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* MaskField — a single-input character mask for free-form fixed-literal
* patterns (phone, national id, credit card, IBAN, postal code, license
* plate). Fills the gap left by NumberField/DateField/CssField: those cover
* numeric, date/time and CSS-dimension value spaces; MaskField covers
* arbitrary positional patterns where every slot is one character class and
* there are no independently-steppable sub-values.
*
* Surface:
*
* <MaskField mask="(999) 999-9999" bind:value>
* <MaskField.Input />
* </MaskField>
*
* APG: plain textbox pattern. The mask is a VISUAL affordance — the input
* stays a single `role="textbox"`, never fragmented into fake spinbutton
* segments (that is DateField/TimeField's job for bounded numeric units).
* The announced value is always truthful: rejected keystrokes revert the
* visible value rather than being silently swallowed, so a screen reader
* always reads back the committed value.
*
* Value model: `value` is the MASKED display string (imask/cleave/maskito
* convention, and consistent with NumberField whose visible input carries
* `name` and submits the formatted string). The raw/unmasked value is
* exposed by soma as a read-only derived. No hidden input in MVP.
*
* Token alphabet (default, overridable via `definitions`):
* 9 = digit, A = letter, * = alphanumeric; every other char is a literal.
*/
export const maskFieldMorfo = {
name: 'MaskField',
kebab: 'mask-field',
scope: ['soma', 'sema', 'eidos'],
expression: 'pack',
texts: {
label: '#?components.mask-field.label|Mask Field'
},
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/',
events: [
// Value committed on blur / Enter. `commit` family is for terminal,
// confirmed actions — NOT per keystroke (typing is plain data flow with
// no evaluative load; a per-keystroke commit would beep and pulse on
// every character, matching number-field / search-field / password-field
// which fire `commit-*` only on confirmed actions).
{
name: 'commit-set',
semantic: {
family: 'commit',
verb: 'set',
target: v.partRef('provider'),
intent: 'neutral',
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
{ attr: 'data-focused', value: v.propRef('focused'), severity: 'optional' },
{ attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' },
// Mask fully satisfied (every required slot filled). Intrinsic to a
// mask; lets eidos surface a "complete" affordance and the consumer
// gate submission without re-deriving completeness.
{ attr: 'data-complete', value: v.propRef('complete'), severity: 'optional' }
],
aria: []
},
{
name: 'Input',
kebab: 'input',
archetype: 'input',
kind: 'public',
defaultElement: 'input',
role: 'textbox',
optional: false,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.mask-field.label|Mask Field'),
severity: 'recommended'
},
{
attr: 'aria-invalid',
value: v.propRef('invalid'),
severity: 'optional',
ariaBoolean: true
},
{
attr: 'aria-required',
value: v.propRef('required'),
severity: 'optional',
ariaBoolean: true
}
],
keyboard: [{ key: 'Enter', action: 'commit' }]
}
]
} as const satisfies Morfo;

@ -29,6 +29,7 @@ export { formSema } from './form';
export { imageAdjustmentsSema } from './image-adjustments';
export { imagePickerSema } from './image-picker';
export { knobSema } from './knob';
export { maskFieldSema } from './mask-field';
export { menubarSema } from './menubar';
export { navigationMenuSema } from './navigation-menu';
export { numberFieldSema } from './number-field';

@ -0,0 +1,27 @@
import { semaSelector } from '$uix/morfo'
import { maskFieldMorfo } from '$uix/morfo/components/mask-field'
import { soundTuning } from '../sounds'
import type { Sema } from '../sema-map'
/**
* MaskField perceptual defaults.
*
* Typing is a high-frequency editing operation and stays silent. The only
* discrete user decision is the value commit on blur / Enter — a subtle field
* commit, identical to number-field. The masked box IS the provider, so
* commit-set targets the provider.
*/
const onProvider = (matchers?: Parameters<typeof semaSelector<typeof maskFieldMorfo>>[2]) =>
semaSelector(maskFieldMorfo, 'provider', matchers)
export const maskFieldSema: Sema = {
name: 'mask-field',
cascade: [
{
selector: onProvider({ eventName: 'commit-set' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
}
]
}

@ -0,0 +1,73 @@
# MaskField (soma)
Headless single-input **character mask** for free-form fixed-literal patterns
(phone, national id, credit card, IBAN, postal code, plate). The provider owns
the mask engine, value, caret and ARIA; the visual layer lives in
`$uix/eidos/components/mask-field`.
```svelte
<script lang="ts">
import * as MaskField from '$soma/components/mask-field';
let value = $state('');
</script>
<MaskField.Provider mask="(999) 999-9999" bind:value>
<MaskField.Input />
</MaskField.Provider>
```
## Parts
| Part | Element | Role | Data attr |
| --- | --- | --- | --- |
| `Provider` | `<div>` | — | `data-mask-field` (+ `data-disabled/readonly/required/invalid/focused/empty/complete`) |
| `Input` | `<input>` | textbox | `data-mask-field-input` |
## The mask engine (`mask-engine.ts`)
Pure, DOM-free, node-testable. Exported from the barrel for format-only use
(compute the unmasked value / completeness off the component, seed a server
value):
- `compileMask(pattern, definitions?) → CompiledMask` — tokens `9`/`A`/`*` +
literals, `\` escape, `[ … ]` optional tail.
- `applyMask(rawInput, caret, compiled, opts?) → { masked, unmasked, complete, caret }`
— the single call the provider runs per input/paste event (accept-if-fits +
auto-insert literals + caret mapping).
- `conformValue(value, compiled, opts?)` / `unmaskValue(value, compiled)` /
`firstEditableCaret(masked, compiled)` — helpers.
- `DEFAULT_MASK_DEFINITIONS` — `{ '9': /[0-9]/, A: /[a-zA-Z]/, '*': /[a-zA-Z0-9]/ }`.
The engine never touches the DOM or reads a selection — it takes the raw string
+ caret and returns the conformed string + caret. Caret restoration and DOM
writes are the provider's job (via `$adom`). Promotable to `arts/mask` unchanged
when a second consumer appears.
## Value model
`value` (bindable) is the **masked** display string; `unmaskedValue` is a
read-only derived (raw input chars, literals stripped) exposed via the provider
snippet props. `onValueChange` fires per accepted keystroke; `onValueCommit`
fires on blur / Enter (once per changed value) and drives the `commit-set`
semantic event.
## Field integration
`MaskFieldProvider` reads an optional parent `FieldProvider` (`.get()`):
`disabled`/`readonly`/`required`/`invalid` OR-merge with the Field's state, and
the Input id registers into `field.inputId` (direct assignment, A30) so
`Field.Label`'s `for=`, `Field.HelperText` and `Field.ErrorText` wire against the
masked input automatically.
## Sema
One event — `commit-set` (commit · set, intent neutral, sequence post, target
provider) — fired on blur / Enter. Typing is silent. Pack:
`src/uix/sema/components/mask-field.ts`.
## Accessibility
Single `role="textbox"` — never fragmented into fake spinbutton segments (that
is DateField/TimeField's job). `inputmode` derives from the mask (`numeric` when
every slot is a digit, `text` otherwise). Rejected keystrokes revert the visible
value, so a screen reader always reads back exactly the committed value.

@ -0,0 +1,28 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { MaskFieldInputProvider, MaskFieldProvider } from '../mask-field-provider.svelte';
import type { MaskFieldInputProps } from '../types';
const uid = $props.id();
const provider = MaskFieldProvider.require();
let {
ref = $bindable(null),
id = provider.opts.inputId.current || createId(uid, 'mask-field-input'),
...restProps
}: MaskFieldInputProps = $props();
const state = MaskFieldInputProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v as HTMLInputElement | null)
)
});
const mergedProps = $derived(mergeProps({}, restProps, state.props));
</script>
<input {...mergedProps} />

@ -0,0 +1,68 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { MaskFieldProvider } from '../mask-field-provider.svelte';
import type { MaskFieldProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'mask-field'),
inputId = createId(uid, 'mask-field-input'),
mask,
definitions,
lazy = true,
placeholderChar = '_',
value = $bindable(''),
onValueChange = () => {},
onValueCommit,
disabled = false,
readonly = false,
required = false,
invalid = false,
name,
placeholder,
'aria-label': ariaLabel,
children,
child,
...restProps
}: MaskFieldProps = $props();
const state = MaskFieldProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
inputId: readableActive(() => inputId),
value: writableActive(
() => value,
(v) => (value = v)
),
mask: readableActive(() => mask),
definitions: readableActive(() => definitions),
lazy: readableActive(() => lazy),
placeholderChar: readableActive(() => placeholderChar),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
placeholder: readableActive(() => placeholder),
ariaLabel: readableActive(() => ariaLabel),
onValueChange: readableActive(() => onValueChange),
onValueCommit: readableActive(() => onValueCommit)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -0,0 +1,19 @@
export { default as Provider } from './components/mask-field.svelte';
export { default as Input } from './components/mask-field-input.svelte';
export type {
MaskFieldProps as ProviderProps,
MaskFieldInputProps as InputProps,
MaskFieldProviderSnippetProps as ProviderSnippetProps
} from './types';
// Pure, DOM-free mask engine — exposed for format-only use (compute the
// unmasked value / completeness off the component, seed server values).
export {
compileMask,
applyMask,
conformValue,
unmaskValue,
DEFAULT_MASK_DEFINITIONS
} from './mask-engine';
export type { MaskDefinitions, CompiledMask, MaskApplyResult, MaskFormatOptions } from './mask-engine';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,4 @@
/** Idlangref constants for the MaskField component. */
export const MASK_FIELD_LANGS = {
LABEL: '#?components.mask-field.label|Mask Field'
} as const;

@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import {
compileMask,
applyMask,
conformValue,
unmaskValue,
firstEditableCaret,
DEFAULT_MASK_DEFINITIONS
} from './mask-engine';
const PHONE = compileMask('(999) 999-9999');
const PLATE = compileMask('AA-99');
const ZIP4 = compileMask('99999[-9999]');
describe('compileMask', () => {
it('counts input slots and literals for a phone pattern', () => {
expect(PHONE.inputCount).toBe(10);
expect(PHONE.requiredCount).toBe(10);
expect(PHONE.tokens.filter((t) => t.kind === 'literal')).toHaveLength(4); // ( ) space -
});
it('marks optional-group slots as not required', () => {
expect(ZIP4.inputCount).toBe(9); // 5 + 4
expect(ZIP4.requiredCount).toBe(5);
const optionalInputs = ZIP4.tokens.filter((t) => t.kind === 'input' && t.optional);
expect(optionalInputs).toHaveLength(4);
});
it('treats an escaped token char as a literal', () => {
const m = compileMask('\\9AA'); // literal "9", then two letters
expect(m.inputCount).toBe(2);
expect(m.tokens[0]).toMatchObject({ kind: 'literal', literal: '9' });
});
it('exposes the default 9/A/* alphabet', () => {
expect(DEFAULT_MASK_DEFINITIONS['9'].test('5')).toBe(true);
expect(DEFAULT_MASK_DEFINITIONS['9'].test('x')).toBe(false);
expect(DEFAULT_MASK_DEFINITIONS.A.test('x')).toBe(true);
expect(DEFAULT_MASK_DEFINITIONS.A.test('5')).toBe(false);
expect(DEFAULT_MASK_DEFINITIONS['*'].test('5')).toBe(true);
expect(DEFAULT_MASK_DEFINITIONS['*'].test('x')).toBe(true);
expect(DEFAULT_MASK_DEFINITIONS['*'].test('-')).toBe(false);
});
});
describe('applyMask — typing', () => {
it('inserts the leading literal and places the caret after the first digit', () => {
const r = applyMask('1', 1, PHONE);
expect(r.masked).toBe('(1');
expect(r.unmasked).toBe('1');
expect(r.caret).toBe(2);
expect(r.complete).toBe(false);
});
it('auto-inserts intermediate literals as slots fill', () => {
expect(applyMask('123', 3, PHONE).masked).toBe('(123');
expect(applyMask('1234', 4, PHONE).masked).toBe('(123) 4');
expect(applyMask('1234567', 7, PHONE).masked).toBe('(123) 456-7');
});
it('fully conforms a complete phone number', () => {
const r = applyMask('1234567890', 10, PHONE);
expect(r.masked).toBe('(123) 456-7890');
expect(r.unmasked).toBe('1234567890');
expect(r.complete).toBe(true);
expect(r.caret).toBe('(123) 456-7890'.length);
});
it('rejects characters that do not fit the next slot (accept-if-fits)', () => {
const r = applyMask('12a3', 4, PHONE);
expect(r.masked).toBe('(123');
expect(r.unmasked).toBe('123');
});
it('drops overflow characters beyond the pattern length', () => {
const r = applyMask('123456789012', 12, PHONE);
expect(r.unmasked).toBe('1234567890');
expect(r.masked).toBe('(123) 456-7890');
});
});
describe('applyMask — pasting / re-typing literals', () => {
it('absorbs literals the user typed or pasted', () => {
const r = applyMask('(12', 3, PHONE);
expect(r.masked).toBe('(12');
expect(r.unmasked).toBe('12');
expect(r.caret).toBe(3);
});
it('conforms a fully pre-formatted pasted value', () => {
const r = applyMask('(123) 456-7890', 14, PHONE);
expect(r.masked).toBe('(123) 456-7890');
expect(r.unmasked).toBe('1234567890');
expect(r.complete).toBe(true);
});
it('conforms a raw digit-only pasted value', () => {
const r = conformValue('1234567890', PHONE);
expect(r.masked).toBe('(123) 456-7890');
});
});
describe('applyMask — caret across a deletion', () => {
it('keeps the caret before the trailing literal after backspacing it', () => {
// "12-3" with mask "99-9", user backspaced the '-' → raw "123", caret 2
const dash = compileMask('99-9');
const r = applyMask('123', 2, dash);
expect(r.masked).toBe('12-3');
expect(r.caret).toBe(2);
});
});
describe('applyMask — lazy vs showMask', () => {
it('lazy (default) shows no placeholder tail', () => {
expect(applyMask('12', 2, PHONE).masked).toBe('(12');
});
it('non-lazy renders placeholders and every literal', () => {
const r = applyMask('12', 2, PHONE, { lazy: false });
expect(r.masked).toBe('(12_) ___-____');
expect(r.caret).toBe(3);
});
it('honours a custom placeholder char', () => {
const r = applyMask('', 0, PHONE, { lazy: false, placeholderChar: '#' });
expect(r.masked).toBe('(###) ###-####');
});
});
describe('applyMask — token classes', () => {
it('letters and digits with a literal separator (license plate)', () => {
const r = applyMask('ab12', 4, PLATE);
expect(r.masked).toBe('ab-12');
expect(r.complete).toBe(true);
});
it('rejects a digit where a letter is expected', () => {
const r = applyMask('a1', 2, PLATE);
expect(r.unmasked).toBe('a'); // '1' rejected at the second letter slot
});
it('escaped token char renders as a literal prefix', () => {
const m = compileMask('\\9AA');
const r = applyMask('xy', 2, m);
expect(r.masked).toBe('9xy');
});
});
describe('applyMask — optional group', () => {
it('is complete at the required length without the optional tail', () => {
const r = applyMask('12345', 5, ZIP4);
expect(r.masked).toBe('12345');
expect(r.complete).toBe(true);
});
it('is incomplete below the required length', () => {
expect(applyMask('1234', 4, ZIP4).complete).toBe(false);
});
it('renders the optional tail once the user types into it', () => {
const r = applyMask('123456789', 9, ZIP4);
expect(r.masked).toBe('12345-6789');
expect(r.complete).toBe(true);
});
});
describe('helpers', () => {
it('unmaskValue strips a masked string to input chars', () => {
expect(unmaskValue('(123) 456', PHONE)).toBe('123456');
});
it('firstEditableCaret skips a present leading literal', () => {
expect(firstEditableCaret('(1', PHONE)).toBe(1);
expect(firstEditableCaret('', PHONE)).toBe(0);
});
it('custom definitions override the default alphabet', () => {
// hex mask: H = [0-9a-fA-F]
const hex = compileMask('#HHHHHH', { H: /[0-9a-fA-F]/ });
const r = applyMask('1a2b3c', 6, hex);
expect(r.masked).toBe('#1a2b3c');
expect(applyMask('1g', 2, hex).unmasked).toBe('1'); // 'g' is not hex
});
});

@ -0,0 +1,267 @@
/**
* MaskField pattern engine — pure, DOM-free, node-testable.
*
* Co-located in the soma component for now (single consumer). If a second
* consumer appears (e.g. NumberField grouping, a format-only pipe) promote it
* to `arts/mask` unchanged — nothing here touches the DOM, timers, or Svelte.
*
* Token alphabet (default, overridable via `definitions`):
* 9 = digit, A = letter, * = alphanumeric.
* Every other character is a literal. `\` escapes the next character so a
* token char renders as a literal (`\9` → literal "9"). `[ ... ]` marks an
* optional (variable-length) tail: enclosed input slots don't count toward
* completeness.
*
* The engine never mutates an input or reads a selection — it takes the raw
* string + caret and returns the conformed string + caret. Caret restoration
* and DOM writes are soma's job (via `$adom`).
*/
/** Character-class predicates keyed by the token char used in the pattern. */
export type MaskDefinitions = Record<string, RegExp>;
/**
* Default token alphabet. `9`/`A`/`*` is the widely-recognised masking
* convention (jQuery masked-input, inputmask, cleave); consumers override or
* extend via the `definitions` option.
*/
export const DEFAULT_MASK_DEFINITIONS: MaskDefinitions = {
'9': /[0-9]/,
A: /[a-zA-Z]/,
'*': /[a-zA-Z0-9]/
};
/** Character used to escape the following pattern character into a literal. */
const ESCAPE_CHAR = '\\';
const OPTIONAL_OPEN = '[';
const OPTIONAL_CLOSE = ']';
interface LiteralToken {
kind: 'literal';
literal: string;
optional: boolean;
}
interface InputToken {
kind: 'input';
/** Predicate a candidate char must satisfy to fill this slot. */
test: RegExp;
/** The pattern char that produced this slot (e.g. '9') — used for hints. */
source: string;
optional: boolean;
}
export type MaskToken = LiteralToken | InputToken;
export interface CompiledMask {
readonly pattern: string;
readonly tokens: readonly MaskToken[];
/** Count of non-optional input slots — the threshold for `complete`. */
readonly requiredCount: number;
/** Total input slots (required + optional). */
readonly inputCount: number;
}
export interface MaskFormatOptions {
/**
* When `true` (default), only rendered up to the last filled slot — no
* placeholder tail. When `false`, empty slots render `placeholderChar` and
* every literal is shown (the "showMask" affordance).
*/
lazy?: boolean;
/** Placeholder for empty input slots when `lazy` is false. @default '_' */
placeholderChar?: string;
}
export interface MaskApplyResult {
/** The conformed display string. */
masked: string;
/** The accepted input characters only (literals stripped). */
unmasked: string;
/** True when every required slot is filled. */
complete: boolean;
/** Caret position (index into `masked`) after conforming. */
caret: number;
}
/**
* Compile a pattern string into a token list. Cheap enough to call per render,
* but callers typically memoize on `(pattern, definitions)`.
*/
export function compileMask(
pattern: string,
definitions: MaskDefinitions = DEFAULT_MASK_DEFINITIONS
): CompiledMask {
const tokens: MaskToken[] = [];
let optionalDepth = 0;
let requiredCount = 0;
let inputCount = 0;
for (let i = 0; i < pattern.length; i++) {
const ch = pattern[i];
if (ch === ESCAPE_CHAR && i + 1 < pattern.length) {
// Next char is a forced literal, regardless of what it is.
tokens.push({ kind: 'literal', literal: pattern[++i], optional: optionalDepth > 0 });
continue;
}
if (ch === OPTIONAL_OPEN) {
optionalDepth++;
continue;
}
if (ch === OPTIONAL_CLOSE) {
if (optionalDepth > 0) optionalDepth--;
continue;
}
const test = definitions[ch];
if (test) {
const optional = optionalDepth > 0;
tokens.push({ kind: 'input', test, source: ch, optional });
inputCount++;
if (!optional) requiredCount++;
} else {
tokens.push({ kind: 'literal', literal: ch, optional: optionalDepth > 0 });
}
}
return { pattern, tokens, requiredCount, inputCount };
}
/**
* Extract the accepted input characters from a raw string, and how many of
* them fell before `caret`. Literals in the raw string are absorbed (a user
* re-typing / pasting a formatted value works); characters that don't fit the
* next input slot are dropped (accept-if-fits).
*/
function extract(
rawInput: string,
caret: number,
compiled: CompiledMask
): { chars: string; beforeCaret: number } {
const { tokens } = compiled;
let tokenIdx = 0;
let chars = '';
let beforeCaret = 0;
for (let i = 0; i < rawInput.length; i++) {
const c = rawInput[i];
while (tokenIdx < tokens.length) {
const tk = tokens[tokenIdx];
if (tk.kind === 'literal') {
if (c === tk.literal) {
// User typed/pasted the literal — absorb it, consume the char.
tokenIdx++;
break;
}
// Auto-skip the literal and retry this char against the next slot.
tokenIdx++;
continue;
}
// Input slot.
if (tk.test.test(c)) {
chars += c;
tokenIdx++;
if (i < caret) beforeCaret++;
break;
}
// Char rejected by this slot — drop it.
break;
}
// tokenIdx exhausted → remaining chars are dropped.
}
return { chars, beforeCaret };
}
/**
* Conform a raw input string + caret to the mask. This is the single entry
* point the soma provider calls on every input / paste event.
*/
export function applyMask(
rawInput: string,
rawCaret: number,
compiled: CompiledMask,
options: MaskFormatOptions = {}
): MaskApplyResult {
const lazy = options.lazy ?? true;
const placeholderChar = options.placeholderChar ?? '_';
const { tokens, requiredCount } = compiled;
const { chars, beforeCaret } = extract(rawInput, rawCaret, compiled);
let masked = '';
let ui = 0;
let rendered = 0;
let caret = -1;
for (let t = 0; t < tokens.length; t++) {
const tk = tokens[t];
if (tk.kind === 'literal') {
if (ui < chars.length) {
masked += tk.literal;
} else if (!lazy) {
masked += tk.literal;
} else {
break; // lazy: stop at the first trailing literal with no input left
}
continue;
}
// Input slot.
if (ui < chars.length) {
masked += chars[ui++];
rendered++;
if (rendered === beforeCaret && caret === -1) caret = masked.length;
} else if (!lazy) {
masked += placeholderChar;
} else {
break; // lazy: stop at the first empty slot
}
}
if (caret === -1) caret = beforeCaret === 0 ? 0 : masked.length;
return {
masked,
unmasked: chars,
complete: chars.length >= requiredCount,
caret
};
}
/**
* Conform an external value (e.g. a bound `value` set from outside) with the
* caret at the end. Used to normalize the initial / externally-set display.
*/
export function conformValue(
value: string,
compiled: CompiledMask,
options: MaskFormatOptions = {}
): MaskApplyResult {
return applyMask(value, value.length, compiled, options);
}
/** Strip a masked string down to its accepted input characters. */
export function unmaskValue(value: string, compiled: CompiledMask): string {
return extract(value, 0, compiled).chars;
}
/**
* Caret index of the first editable slot in a (freshly rendered) masked value,
* skipping leading literals. Used on focus so the caret lands on the first
* slot rather than before a leading literal like "(".
*/
export function firstEditableCaret(masked: string, compiled: CompiledMask): number {
const { tokens } = compiled;
let pos = 0;
for (const tk of tokens) {
if (tk.kind === 'literal') {
// Only skip a leading literal if it's actually present in `masked`.
if (masked[pos] === tk.literal) pos++;
else break;
} else {
break;
}
}
return pos;
}

@ -0,0 +1,260 @@
// @vitest-environment jsdom
import { tick } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createActiveDom } from '$adom';
import { state } from '$libs/reactive';
import type { Morfo } from '$uix/morfo';
import { Soma } from '$soma/core/soma.svelte';
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
import { FieldProvider } from '../field';
import type { MaskDefinitions } from './mask-engine';
import { MaskFieldInputProvider, MaskFieldProvider } from './mask-field-provider.svelte';
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
let result!: T;
const cleanup = $effect.root(() => {
result = fn();
});
return { result, cleanup };
}
function installSomaHarness() {
const dom = createActiveDom();
const soma = {
dom,
langs: {
ts: vi.fn((key: string) => (key.includes('mask-field') ? 'Mask Field' : key))
},
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(MaskFieldProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(FieldProvider, 'get').mockReturnValue(undefined);
return { dom };
}
function maskOpts(root = document.createElement('div')) {
return {
id: state('mask-root'),
ref: state<HTMLElement | null>(root),
inputId: state('mask-input'),
value: state(''),
mask: state('(999) 999-9999'),
definitions: state<MaskDefinitions | undefined>(undefined),
lazy: state(true),
placeholderChar: state('_'),
disabled: state(false),
readonly: state(false),
required: state(false),
invalid: state(false),
name: state<string | undefined>('phone'),
placeholder: state<string | undefined>(undefined),
ariaLabel: state<string | undefined>(undefined),
onValueChange: state<((value: string) => void) | undefined>(undefined),
onValueCommit: state<((value: string) => void) | undefined>(undefined)
};
}
function makeInput(): HTMLInputElement {
const el = document.createElement('input');
document.body.append(el);
return el;
}
describe('MaskFieldProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('exposes root/input props and projects root state attrs', async () => {
const { dom } = installSomaHarness();
const root = document.createElement('div');
const inputEl = makeInput();
document.body.append(root);
const opts = maskOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = MaskFieldProvider.create(opts);
vi.spyOn(MaskFieldProvider, 'require').mockReturnValue(provider);
const input = MaskFieldInputProvider.create({
id: state('mask-input'),
ref: state<HTMLElement | null>(inputEl)
});
return { provider, input };
});
expect(result.provider.props).toMatchObject({
id: 'mask-root',
'data-mask-field': ''
});
expect(result.input.props).toMatchObject({
id: 'mask-input',
'data-mask-field-input': '',
role: 'textbox',
value: '',
name: 'phone',
// all-digit mask → numeric keypad on mobile
inputmode: 'numeric',
'aria-label': 'Mask Field'
});
await tick();
expect(root.getAttribute('data-empty')).toBe('');
expect(root.hasAttribute('data-complete')).toBe(false);
cleanup();
dom.dispose();
});
it('conforms typed input, auto-inserts literals and rejects bad chars', async () => {
const { dom } = installSomaHarness();
const inputEl = makeInput();
const opts = maskOpts();
const onValueChange = vi.fn();
opts.onValueChange.current = onValueChange;
const { result, cleanup } = withEffectRoot(() => {
const provider = MaskFieldProvider.create(opts);
vi.spyOn(MaskFieldProvider, 'require').mockReturnValue(provider);
const input = MaskFieldInputProvider.create({
id: state('mask-input'),
ref: state<HTMLElement | null>(inputEl)
});
provider.setInputRef(inputEl);
return { provider, input };
});
// User types four digits → literals appear.
inputEl.value = '1234';
result.input.props.oninput({ currentTarget: inputEl } as never);
expect(opts.value.current).toBe('(123) 4');
expect(inputEl.value).toBe('(123) 4');
expect(onValueChange).toHaveBeenLastCalledWith('(123) 4');
expect(result.provider.unmaskedValue).toBe('1234');
// Complete number.
inputEl.value = '(123) 4567890';
result.input.props.oninput({ currentTarget: inputEl } as never);
expect(opts.value.current).toBe('(123) 456-7890');
expect(result.provider.isComplete).toBe(true);
// A letter is rejected — value unchanged, DOM reverted.
inputEl.value = '(123) 456-7890x';
result.input.props.oninput({ currentTarget: inputEl } as never);
expect(opts.value.current).toBe('(123) 456-7890');
expect(inputEl.value).toBe('(123) 456-7890');
cleanup();
dom.dispose();
});
it('commits once on Enter and dedupes the blur that follows', () => {
const { dom } = installSomaHarness();
const inputEl = makeInput();
const opts = maskOpts();
const onValueCommit = vi.fn();
opts.onValueCommit.current = onValueCommit;
const { result, cleanup } = withEffectRoot(() => {
const provider = MaskFieldProvider.create(opts);
vi.spyOn(MaskFieldProvider, 'require').mockReturnValue(provider);
const input = MaskFieldInputProvider.create({
id: state('mask-input'),
ref: state<HTMLElement | null>(inputEl)
});
return { provider, input };
});
opts.value.current = '(123) 456-7890';
const enter = { key: 'Enter', preventDefault: vi.fn() };
result.input.props.onkeydown(enter as never);
expect(enter.preventDefault).toHaveBeenCalledOnce();
expect(onValueCommit).toHaveBeenCalledExactlyOnceWith('(123) 456-7890');
// Blur with the same value → no second commit.
result.input.props.onblur({ currentTarget: inputEl } as never);
expect(onValueCommit).toHaveBeenCalledOnce();
cleanup();
dom.dispose();
});
it('does not commit an unchanged / disabled field', () => {
const { dom } = installSomaHarness();
const inputEl = makeInput();
const opts = maskOpts();
const onValueCommit = vi.fn();
opts.onValueCommit.current = onValueCommit;
opts.disabled.current = true;
const { result, cleanup } = withEffectRoot(() => {
const provider = MaskFieldProvider.create(opts);
vi.spyOn(MaskFieldProvider, 'require').mockReturnValue(provider);
const input = MaskFieldInputProvider.create({
id: state('mask-input'),
ref: state<HTMLElement | null>(inputEl)
});
return { provider, input };
});
opts.value.current = '(123) 4';
result.input.props.onblur({ currentTarget: inputEl } as never);
expect(onValueCommit).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
it('OR-merges enclosing Field flags, registers inputId and joins describedby', () => {
const { dom } = installSomaHarness();
const field = {
inputId: state(''),
labelId: state('field-label'),
helperId: state('field-helper'),
errorId: state('field-error'),
isDisabled: true,
isReadonly: false,
isRequired: true,
isInvalid: true
};
vi.spyOn(FieldProvider, 'get').mockReturnValue(field as unknown as FieldProvider);
const opts = maskOpts();
const { result, cleanup } = withEffectRoot(() => {
const provider = MaskFieldProvider.create(opts);
vi.spyOn(MaskFieldProvider, 'require').mockReturnValue(provider);
const input = MaskFieldInputProvider.create({
id: state('mask-input'),
ref: state<HTMLElement | null>(makeInput())
});
return { input };
});
// Input id registered so Field.Label's `for=` targets it.
expect(field.inputId.current).toBe('mask-input');
expect(result.input.props).toMatchObject({
disabled: true,
required: true,
// Field.Label supplies the name via `for=` → drop the input's aria-label.
'aria-label': undefined,
'aria-invalid': 'true',
'aria-describedby': 'field-helper field-error',
'data-disabled': ''
});
cleanup();
dom.dispose();
});
});

@ -0,0 +1,321 @@
import { tick } from 'svelte';
import { context, type WithRefOpts } from '../../provider';
import {
readableActive,
state,
watch,
type Active,
type ActiveProps,
type StateProps
} from '$libs/reactive';
import type { OnChangeFn, SomaInputEvent, SomaFocusEvent, SomaKeyboardEvent } from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { FieldProvider } from '../field';
import { MASK_FIELD_LANGS } from './langs';
import { maskFieldMorfo } from '../../../morfo/components/mask-field';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import {
applyMask,
compileMask,
conformValue,
unmaskValue,
DEFAULT_MASK_DEFINITIONS,
type CompiledMask,
type MaskDefinitions,
type MaskFormatOptions
} from './mask-engine';
// ── Root ─────────────────────────────────────────────────────────────────────
interface MaskFieldOpts
extends
WithRefOpts,
StateProps<{ value: string }>,
ActiveProps<{
inputId: string;
mask: string;
definitions: MaskDefinitions | undefined;
lazy: boolean;
placeholderChar: string;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
name: string | undefined;
placeholder: string | undefined;
ariaLabel: string | undefined;
onValueChange: OnChangeFn<string> | undefined;
onValueCommit: OnChangeFn<string> | undefined;
}> {}
export class MaskFieldProvider {
readonly opts: MaskFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<MaskFieldProvider>('MaskField');
static get(): MaskFieldProvider | undefined {
return this.ctx.getOr(undefined) as MaskFieldProvider | undefined;
}
static require(): MaskFieldProvider {
return this.ctx.get();
}
static create(opts: MaskFieldOpts) {
return new MaskFieldProvider(opts);
}
/**
* Optional parent Field context. When present, MaskField inherits
* `disabled`/`readonly`/`required`/`invalid` and its Input registers into
* Field's `inputId` so `Field.Label` and `Field.HelperText/ErrorText` wire
* correctly against the MaskField input element.
*/
readonly field = FieldProvider.get();
inputRef = state<HTMLInputElement | null>(null);
focused = $state(false);
/** Last value `commit()` fired for — dedupes blur-after-Enter and no-op blurs. */
private committedValue: string;
private constructor(opts: MaskFieldOpts) {
this.opts = opts;
this.committedValue = opts.value.current;
this.soma = Soma.require();
this.runtime = this.soma.runtime(maskFieldMorfo, {
props: {
disabled: () => this.isDisabled,
readonly: () => this.isReadonly,
required: () => this.isRequired,
invalid: () => this.isInvalid,
focused: () => this.focused,
empty: () => this.isEmpty,
complete: () => this.isComplete
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: MaskFieldProvider.ctx,
syncAttrs: true
});
// Normalize an externally-set value into its masked form. Runs on mount
// and whenever value / mask / format options change — but NOT while the
// user is typing (the input handler owns the value then). Idempotent:
// conforming an already-masked value returns it unchanged, so no loop.
watch.pre(
() => [this.opts.value.current, this.compiled, this.formatOptions] as const,
([value]) => {
if (this.focused) return;
const conformed = conformValue(value, this.compiled, this.formatOptions).masked;
if (conformed !== value) this.setValue(conformed);
}
);
}
// ── Field-aware flags (OR-merge) ─────────────────────────────────────────
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
);
readonly isReadonly = $derived.by(
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
);
readonly isRequired = $derived.by(
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly isInvalid = $derived.by(
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
);
// ── Mask model ───────────────────────────────────────────────────────────
/** Compiled mask. Custom `definitions` merge OVER the default 9/A/* alphabet. */
readonly compiled: CompiledMask = $derived.by(() =>
compileMask(this.opts.mask.current, {
...DEFAULT_MASK_DEFINITIONS,
...(this.opts.definitions.current ?? {})
})
);
readonly formatOptions: MaskFormatOptions = $derived.by(() => ({
lazy: this.opts.lazy.current,
placeholderChar: this.opts.placeholderChar.current
}));
/** Raw input characters (literals stripped) of the current value. */
readonly unmaskedValue = $derived.by(() => unmaskValue(this.opts.value.current, this.compiled));
readonly isComplete = $derived.by(
() => conformValue(this.opts.value.current, this.compiled, this.formatOptions).complete
);
readonly isEmpty = $derived.by(() => this.opts.value.current === '');
/** Numeric keypad on mobile when every slot is a digit; text keyboard otherwise. */
readonly inputMode = $derived.by((): 'numeric' | 'text' => {
const inputs = this.compiled.tokens.filter((t) => t.kind === 'input');
return inputs.length > 0 && inputs.every((t) => t.source === '9') ? 'numeric' : 'text';
});
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() => {
// Field.Label wins via aria-labelledby — drop the input's own aria-label.
if (this.field?.labelId.current) return undefined;
return this.opts.ariaLabel.current || this.soma.langs.ts(MASK_FIELD_LANGS.LABEL) || undefined;
});
setInputRef(el: HTMLInputElement | null) {
this.inputRef.current = el;
}
// ── Mutations ────────────────────────────────────────────────────────────
/** Live value update (per keystroke). No semantic event — see morfo comment. */
setValue(masked: string) {
if (masked === this.opts.value.current) return;
this.opts.value.current = masked;
this.opts.onValueChange.current?.(masked);
}
/** Confirmed value on blur / Enter. Fires `commit-set` once per changed value. */
commit() {
if (this.isDisabled || this.isReadonly) return;
const v = this.opts.value.current;
if (v === this.committedValue) return;
this.committedValue = v;
this.opts.onValueCommit.current?.(v);
void this.runtime.trigger('commit-set');
}
// ── Snippet props ─────────────────────────────────────────────────────────
readonly snippetProps = $derived.by(() => ({
value: this.opts.value.current,
unmaskedValue: this.unmaskedValue,
isEmpty: this.isEmpty,
isComplete: this.isComplete,
isFocused: this.focused
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}
// ── Input ────────────────────────────────────────────────────────────────────
interface MaskFieldInputOpts extends WithRefOpts {}
export class MaskFieldInputProvider {
readonly opts: MaskFieldInputOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MaskFieldInputOpts) {
return new MaskFieldInputProvider(opts);
}
readonly provider: MaskFieldProvider;
private constructor(opts: MaskFieldInputOpts) {
this.opts = opts;
this.provider = MaskFieldProvider.require();
this.runtimePart = this.provider.runtime.part('input', {
id: opts.id,
ref: opts.ref,
owner: this
});
// Register this input id with the parent Field so `Field.Label`'s `for=`
// targets it (A30 — direct assignment, never `$effect`).
if (this.provider.field) {
this.provider.field.inputId.current = opts.id.current;
}
$effect(() => {
this.provider.setInputRef(opts.ref.current as HTMLInputElement | null);
});
}
/** ids from parent Field (helper + error) joined for aria-describedby. */
readonly describedBy = $derived.by(() => {
const field = this.provider.field;
if (!field) return undefined;
const ids = [field.helperId.current, field.isInvalid ? field.errorId.current : ''].filter(
Boolean
);
return ids.length > 0 ? ids.join(' ') : undefined;
});
readonly handleInput = (e: SomaInputEvent<HTMLInputElement>) => {
const el = e.currentTarget;
const p = this.provider;
if (p.isDisabled || p.isReadonly) {
// Should not fire (native disabled/readonly), but revert defensively.
el.value = p.opts.value.current;
return;
}
const raw = el.value;
const caretIn = el.selectionStart ?? raw.length;
const result = applyMask(raw, caretIn, p.compiled, p.formatOptions);
// Synchronous DOM write — covers the rejected-char case where the masked
// value is unchanged, so Svelte's reactive `value` effect won't re-run to
// fix `el.value`.
el.value = result.masked;
el.setSelectionRange(result.caret, result.caret);
p.setValue(result.masked);
// When the value DID change, Svelte re-writes `el.value` after the flush
// and drops the caret to the end — restore it once the DOM settles.
const caret = result.caret;
void tick().then(() => {
if (p.inputRef.current === el) el.setSelectionRange(caret, caret);
});
};
readonly handleFocus = (_e: SomaFocusEvent<HTMLInputElement>) => {
this.provider.focused = true;
};
readonly handleBlur = (_e: SomaFocusEvent<HTMLInputElement>) => {
this.provider.focused = false;
this.provider.commit();
};
readonly handleKeydown = (e: SomaKeyboardEvent<HTMLInputElement>) => {
if (this.provider.isDisabled || this.provider.isReadonly) return;
if (e.key === KEYS.ENTER) {
e.preventDefault();
this.provider.commit();
}
};
readonly props = $derived.by(() => {
const p = this.provider;
return this.runtimePart.assert({
// Morfo-declared attrs (role=textbox, data-disabled/readonly/invalid,
// aria-invalid/required as booleans, aria-label default) resolved
// against the component-level sources.
...this.runtimePart.renderProps(),
value: p.opts.value.current,
inputmode: p.inputMode,
placeholder: p.opts.placeholder.current,
name: p.opts.name.current || undefined,
disabled: p.isDisabled || undefined,
readonly: p.isReadonly || undefined,
required: p.isRequired || undefined,
autocomplete: 'off' as const,
autocorrect: 'off' as const,
autocapitalize: 'off' as const,
spellcheck: false,
// Field-aware label (undefined when a Field.Label supplies
// aria-labelledby) — overrides the morfo's static translationRef default.
'aria-label': p.resolvedAriaLabel.current,
'aria-describedby': this.describedBy,
oninput: this.handleInput,
onfocus: this.handleFocus,
onblur: this.handleBlur,
onkeydown: this.handleKeydown
} as const);
});
}

@ -0,0 +1,114 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type { PrimitiveDivAttributes, PrimitiveInputAttributes } from '../../types';
import type { MaskDefinitions } from './mask-engine';
/** Snippet props exposed by `MaskField.Provider`. */
export type MaskFieldProviderSnippetProps = {
/** Current masked display value. */
value: string;
/** The accepted input characters only (literals stripped). */
unmaskedValue: string;
/** `true` when `value === ''`. */
isEmpty: boolean;
/** `true` when every required slot is filled. */
isComplete: boolean;
/** `true` while the Input has focus. */
isFocused: boolean;
};
// ── Root provider ──────────────────────────────────────────────────────────
/**
* Props for the root `MaskField.Provider`.
*
* A `<div>` container wrapping a single `<input>` whose value is formatted
* live against `mask`. Participates in `Field.Provider` — `disabled` /
* `readonly` / `required` / `invalid` are OR-merged with the parent Field's
* state, and the Input registers into `Field`'s `inputId` so `Field.Label`,
* `Field.HelperText` and `Field.ErrorText` wire automatically.
*/
export type MaskFieldProps = WithChild<
{
/** DOM id for the root container. Auto-generated if omitted. */
id?: string;
/** DOM id for the inner input. Auto-generated if omitted. */
inputId?: string;
/**
* Mask pattern. Token chars are input slots; everything else is a
* literal. Default alphabet: `9` = digit, `A` = letter, `*` =
* alphanumeric. `\` escapes a token char into a literal (`\9` → literal
* "9"); `[ ... ]` marks an optional (variable-length) tail. Examples:
* `"(999) 999-9999"`, `"AA-9999"`, `"99999[-9999]"`.
*/
mask: string;
/**
* Custom token predicates, merged OVER the default `9`/`A`/`*` alphabet.
* Each key is a single pattern char mapping to a `RegExp` a candidate
* char must satisfy. E.g. `{ H: /[0-9a-fA-F]/ }` for a hex slot.
*/
definitions?: MaskDefinitions;
/**
* When `true` (default) the field shows only up to the last filled
* slot. When `false`, empty slots render `placeholderChar` and every
* literal is shown. @default true
*/
lazy?: boolean;
/** Placeholder for empty slots when `lazy` is false. @default '_' */
placeholderChar?: string;
/** Current masked value. Bindable. @default '' */
value?: string;
/** Fired on every accepted value change (keystroke, paste). Emits the masked string. */
onValueChange?: OnChangeFn<string>;
/** Fired on blur / Enter when the value changed since the last commit. Emits the masked string. */
onValueCommit?: OnChangeFn<string>;
// Flags — OR-merged with the enclosing `Field.Provider`
/** @default false */
disabled?: boolean;
/** @default false */
readonly?: boolean;
/** @default false */
required?: boolean;
/** External invalid flag. @default false */
invalid?: boolean;
// Form
/** Name for native form submission. Submits the masked value. */
name?: string;
/** Placeholder forwarded to the input (shown when empty). */
placeholder?: string;
/**
* Accessible name. Ignored when inside a `Field.Provider` with a
* `Field.Label`. Defaults to a translated `'Mask Field'`.
*/
'aria-label'?: string;
children?: Snippet<[MaskFieldProviderSnippetProps]>;
},
MaskFieldProviderSnippetProps
> &
Without<PrimitiveDivAttributes, { 'aria-label'?: string }>;
// ── Input ──────────────────────────────────────────────────────────────────
/**
* Props for `MaskField.Input` — the real `<input>`. The consumer never passes
* `value` / `inputmode` / the event handlers directly; the Provider drives
* them.
*/
export type MaskFieldInputProps = WithChild<{ id?: string }, { _default: never }, HTMLInputElement> &
Without<
PrimitiveInputAttributes,
{
value?: unknown;
inputmode?: unknown;
oninput?: unknown;
onkeydown?: unknown;
onfocus?: unknown;
onblur?: unknown;
}
>;

@ -0,0 +1,608 @@
<script lang="ts">
import {
MaskField,
type MaskFieldColor,
type MaskFieldSize,
type MaskFieldVariant
} from '$uix/eidos/components/mask-field';
import {
compileMask,
conformValue,
unmaskValue,
DEFAULT_MASK_DEFINITIONS,
type MaskDefinitions
} from '$soma/components/mask-field';
import { Field } from '$uix/eidos/components/field';
import { compileMorfo } from '$uix/morfo';
import { maskFieldMorfo } from '@/uix/morfo/components/mask-field';
import { getActiveUix } from '$active-uix';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
const uix = getActiveUix();
let tab = $state<Tab>('live');
// ── soma props ───────────────────────────────────────────────────────
let value = $state('');
let mask = $state('(999) 999-9999');
let definitions = $state<MaskDefinitions | undefined>(undefined);
let lazy = $state(true);
let placeholderChar = $state('_');
let placeholder = $state('(555) 123-4567');
let name = $state('phone');
let disabled = $state(false);
let readonly = $state(false);
let required = $state(false);
let invalid = $state(false);
// ── Field integration ────────────────────────────────────────────────
let fieldDisabled = $state(false);
let fieldReadonly = $state(false);
let fieldRequired = $state(false);
let fieldInvalid = $state(false);
// ── eidos props ──────────────────────────────────────────────────────
let size = $state<MaskFieldSize>('md');
let variant = $state<MaskFieldVariant>('surface');
let color = $state<MaskFieldColor>('primary');
type TraceEntry = { event: string; family: string; intent?: string; at: number };
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// ── Presets (mask patterns) ──────────────────────────────────────────
type Preset = {
label: string;
mask: string;
definitions?: MaskDefinitions;
placeholder: string;
};
const PRESETS: Preset[] = [
{ label: 'US phone', mask: '(999) 999-9999', placeholder: '(555) 123-4567' },
{ label: 'US SSN', mask: '999-99-9999', placeholder: '123-45-6789' },
{ label: 'Credit card', mask: '9999 9999 9999 9999', placeholder: '4242 4242 4242 4242' },
{ label: 'Expiry MM/YY', mask: '99/99', placeholder: '08/27' },
{ label: 'Date', mask: '99/99/9999', placeholder: '31/12/2026' },
{ label: 'ES IBAN', mask: 'AA99 9999 9999 9999 9999 9999', placeholder: 'ES91 2100 0418 4502 0005 1332' },
{ label: 'ES plate', mask: '9999 AAA', placeholder: '1234 BCD' },
{ label: 'ZIP+4 (optional)', mask: '99999[-9999]', placeholder: '90210-1234' },
{
label: 'Hex colour',
mask: '#HHHHHH',
definitions: { H: /[0-9a-fA-F]/ },
placeholder: '#1a2b3c'
},
{ label: 'IPv4-ish', mask: '999.999.999.999', placeholder: '192.168.000.001' }
];
function applyPreset(p: Preset) {
value = '';
mask = p.mask;
definitions = p.definitions;
placeholder = p.placeholder;
}
// ── Derived readout (via the pure engine — same the provider runs) ────
const compiledMask = $derived(
compileMask(mask, { ...DEFAULT_MASK_DEFINITIONS, ...(definitions ?? {}) })
);
const unmaskedValue = $derived(unmaskValue(value, compiledMask));
const isComplete = $derived(conformValue(value, compiledMask, { lazy, placeholderChar }).complete);
const isEmpty = $derived(value === '');
const displayValue = $derived(isEmpty ? 'empty' : value);
const compiled = compileMorfo(maskFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const variants: MaskFieldVariant[] = ['surface', 'outline', 'ghost'];
const colors: MaskFieldColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const sizes: MaskFieldSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '-',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { MaskField } from '$uix/eidos/components/mask-field';",
` let value = $state('');`,
'</' + 'script>',
'',
'<MaskField',
` mask="${mask}"`,
' bind:value',
!lazy && ' lazy={false}',
placeholderChar !== '_' && ` placeholderChar="${placeholderChar}"`,
name && ` name="${name}"`,
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
'>',
` <MaskField.Input placeholder="${placeholder}" />`,
'</MaskField>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · Mask field</div>
<h1 data-uix-page-title>MaskField</h1>
<p data-uix-page-lede>
Single-input character mask for free-form fixed-literal patterns — phone, national id, credit
card, IBAN, postal code, plate. Soma owns the mask engine, caret and ARIA; Eidos adds visual
size. Date, time and number formats deliberately route to DateField / TimeField / NumberField.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill><span data-uix-meta-key>parts</span>{compiled.parts.order.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>events</span>{events.length}</span>
<span data-uix-meta-pill><span data-uix-meta-key>sizes</span>5</span>
<span data-uix-meta-pill><span data-uix-meta-key>tokens</span>9 / A / *</span>
</div>
</header>
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef}>
<div style="inline-size: 18rem; max-inline-size: 100%;">
<MaskField
{mask}
{definitions}
bind:value
{lazy}
{placeholderChar}
{placeholder}
{name}
{disabled}
{readonly}
{required}
{invalid}
{size}
{variant}
{color}
>
<MaskField.Input
aria-label="Masked value"
data-perm-step="0"
data-perm-mode={'type="5551234567"'}
/>
</MaskField>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>type a value and press Enter (or blur) to commit</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span>
<span data-uix-stage-trace-event>{entry.event}</span>
· {entry.family}{entry.intent ? ' · ' + entry.intent : ''}
</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>value</span>
<span data-uix-stage-trace-event>{displayValue}</span>
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>API</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
<span data-uix-layer-badge="soma">soma</span> owns the mask engine + caret.
<span data-uix-layer-badge="eidos">eidos</span> owns size, variant and color.
</p>
<div data-uix-subsection-head>Presets · pattern scenarios</div>
<div data-uix-controls>
<div style="display: flex; flex-wrap: wrap; gap: var(--uix-space-1); padding: var(--uix-space-2) 0;">
{#each PRESETS as p (p.label)}
<button
data-uix-chip
data-active={mask === p.mask}
onclick={() => applyPreset(p)}>{p.label}</button
>
{/each}
</div>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · mask model
</div>
<div data-uix-controls>
<label data-uix-control style="min-inline-size: 22rem;">
<span data-uix-control-label>mask <span data-uix-control-hint>9=digit A=letter *=alnum</span></span>
<input type="text" bind:value={mask} spellcheck="false" />
</label>
<label data-uix-control>
<span data-uix-control-label>placeholder</span>
<input type="text" bind:value={placeholder} />
</label>
<label data-uix-control>
<span data-uix-control-label>name <span data-uix-control-hint>form submit</span></span>
<input type="text" bind:value={name} />
</label>
<label data-uix-control>
<span data-uix-control-label>placeholderChar</span>
<input type="text" maxlength="1" bind:value={placeholderChar} style="inline-size: 4rem;" />
</label>
<label data-uix-control>
<span data-uix-control-label>lazy <span data-uix-control-hint>hide placeholder tail</span></span>
<span data-uix-switch>
<input type="checkbox" bind:checked={lazy} />
<span data-uix-switch-label>{lazy ? 'on' : 'off (showMask)'}</span>
</span>
</label>
{#each ['disabled', 'readonly', 'required', 'invalid'] as flag}
<label data-uix-control>
<span data-uix-control-label>{flag}</span>
<span data-uix-switch>
{#if flag === 'disabled'}
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
{:else if flag === 'readonly'}
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span>
{:else if flag === 'required'}
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>{required ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>{invalid ? 'on' : 'off'}</span>
{/if}
</span>
</label>
{/each}
</div>
<div data-uix-subsection-head>Live value readout</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: var(--uix-space-2) var(--uix-space-3); padding: var(--uix-space-3); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-surface-subtle); font-size: var(--font-size-sm);">
<span style="color: var(--color-content-secondary);">value (masked):</span>
<code style="font-family: monospace;">{value || '(empty)'}</code>
<span style="color: var(--color-content-secondary);">unmaskedValue:</span>
<code style="font-family: monospace;">{unmaskedValue || '(empty)'}</code>
<span style="color: var(--color-content-secondary);">isComplete:</span>
<code style="font-family: monospace;">{isComplete}</code>
<span style="color: var(--color-content-secondary);">isEmpty:</span>
<code style="font-family: monospace;">{isEmpty}</code>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> Field integration
</div>
<p data-uix-section-desc>
Wrapped in <code>&lt;Field&gt;</code>, MaskField OR-merges the field's
<code>disabled</code>/<code>readonly</code>/<code>required</code>/<code>invalid</code>
and registers its input so <code>Field.Label</code> and
<code>Field.HelperText</code>/<code>ErrorText</code> wire automatically.
</p>
<div data-uix-controls>
{#each ['disabled', 'readonly', 'required', 'invalid'] as flag}
<label data-uix-control>
<span data-uix-control-label>Field.{flag}</span>
<span data-uix-switch>
{#if flag === 'disabled'}
<input type="checkbox" bind:checked={fieldDisabled} />
<span data-uix-switch-label>{fieldDisabled ? 'on' : 'off'}</span>
{:else if flag === 'readonly'}
<input type="checkbox" bind:checked={fieldReadonly} />
<span data-uix-switch-label>{fieldReadonly ? 'on' : 'off'}</span>
{:else if flag === 'required'}
<input type="checkbox" bind:checked={fieldRequired} />
<span data-uix-switch-label>{fieldRequired ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={fieldInvalid} />
<span data-uix-switch-label>{fieldInvalid ? 'on' : 'off'}</span>
{/if}
</span>
</label>
{/each}
</div>
<div style="max-inline-size: 22rem; margin-block-start: var(--space-3);">
<Field
disabled={fieldDisabled}
readonly={fieldReadonly}
required={fieldRequired}
invalid={fieldInvalid}
>
<Field.Label>Phone number</Field.Label>
<MaskField mask="(999) 999-9999" {size} {variant} {color}>
<MaskField.Input placeholder="(555) 123-4567" />
</MaskField>
<Field.HelperText>Format: (999) 999-9999</Field.HelperText>
<Field.ErrorText>Enter a complete phone number</Field.ErrorText>
</Field>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · visual treatment
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as item}
<button data-uix-chip data-active={variant === item} onclick={() => (variant = item)}
>{item}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as item}
<button data-uix-chip data-active={color === item} onclick={() => (color = item)}
>{item}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · mask, size, variant, color</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API + reference comparison</h2>
<p data-uix-section-desc>
A generic pattern mask is a genuine gap: none of Ark UI, Bits UI, Radix or React Aria ships
one (Radix #1412 closed "not planned"). MaskField fills it with an own zero-dependency
engine; date/number/currency deliberately route to the specialised fields.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>Feature</th><th>imask</th><th>Cleave</th><th>Maskito</th><th>Ark / Bits / Radix</th><th>React Aria</th><th>UIX</th><th>Decision</th>
</tr>
</thead>
<tbody>
<tr><td class="name">Per-char token mask</td><td>✅</td><td>❌ blocks</td><td>✅</td><td>❌</td><td>❌</td><td>✅</td><td>Core: 9 / A / * + literals + escape.</td></tr>
<tr><td class="name">Optional group [ ]</td><td>✅</td><td>❌</td><td>⚠️</td><td>❌</td><td>❌</td><td>✅</td><td>Variable-length tail (ZIP+4).</td></tr>
<tr><td class="name">Custom definitions</td><td>✅</td><td>❌</td><td>✅</td><td>❌</td><td>❌</td><td>✅</td><td>char → RegExp, merged over defaults.</td></tr>
<tr><td class="name">Caret preservation</td><td>✅</td><td>⚠️</td><td>✅</td><td>n/a</td><td>✅</td><td>✅</td><td>Engine maps caret; soma restores it.</td></tr>
<tr><td class="name">unmasked value</td><td>✅</td><td>⚠️</td><td>⚠️</td><td>n/a</td><td>✅</td><td>✅</td><td>Read-only derived + snippet prop.</td></tr>
<tr><td class="name">Honest a11y (truthful value)</td><td>❌</td><td>❌</td><td>⚠️</td><td>✅</td><td>✅</td><td>✅</td><td>Single role=textbox; reject-and-revert.</td></tr>
<tr><td class="name">Number / currency mask</td><td>✅</td><td>✅</td><td>✅</td><td>Ark ✅</td><td>✅</td><td>➡️</td><td>Delegate to NumberField.</td></tr>
<tr><td class="name">Date / time entry</td><td>✅</td><td>✅</td><td>✅</td><td>✅ segmented</td><td>✅ segmented</td><td>➡️</td><td>Delegate to DateField / TimeField.</td></tr>
<tr><td class="name">blocks+delimiters / eager / dynamic</td><td>✅</td><td>⚠️</td><td>✅</td><td>❌</td><td>❌</td><td>⏳</td><td>Deferred to v2.</td></tr>
</tbody>
</table>
</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Owner</th><th>Default</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">mask</td><td>soma</td><td class="default">—</td><td>Pattern: 9/A/* slots + literals; \ escapes; [ ] optional tail.</td></tr>
<tr><td class="name">value</td><td>soma</td><td class="default">''</td><td>Bindable masked string.</td></tr>
<tr><td class="name">definitions</td><td>soma</td><td class="default">9/A/*</td><td>Custom token → RegExp, merged over defaults.</td></tr>
<tr><td class="name">lazy</td><td>soma</td><td class="default">true</td><td>false = render placeholder + all literals.</td></tr>
<tr><td class="name">placeholderChar</td><td>soma</td><td class="default">'_'</td><td>Empty-slot glyph when not lazy.</td></tr>
<tr><td class="name">disabled / readonly / required / invalid</td><td>soma</td><td class="default">false</td><td>OR-merged with the enclosing Field.</td></tr>
<tr><td class="name">name</td><td>soma</td><td class="default">—</td><td>Submits the masked value.</td></tr>
<tr><td class="name">onValueChange / onValueCommit</td><td>soma</td><td class="default">—</td><td>Per keystroke / on blur+Enter.</td></tr>
<tr><td class="name">size</td><td>eidos</td><td class="default">md</td><td>xs | sm | md | lg | xl</td></tr>
<tr><td class="name">variant</td><td>eidos</td><td class="default">surface</td><td>surface | outline | ghost</td></tr>
<tr><td class="name">color</td><td>eidos</td><td class="default">primary</td><td>Focus ring / border accent.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Part</th><th>Marker</th><th>Element</th><th>Role</th><th>Optional</th></tr></thead>
<tbody>
{#each partsList as part}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Keyboard</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Key</th><th>Action</th></tr></thead>
<tbody>
{#each maskFieldMorfo.parts[1].keyboard ?? [] as key}
<tr><td class="name">{key.key}</td><td>{key.action}</td></tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events + perceptual signature
</h2>
<p data-uix-section-desc>
Typing is silent (high-frequency editing). The only discrete decision is the value commit on
blur / Enter — a subtle field commit, identical to NumberField.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Name</th><th>Family</th><th>Verb</th><th>Sequence</th><th>Target</th><th>Play</th></tr></thead>
<tbody>
{#each events as action}
<tr>
<td class="name">{action.name}</td>
<td class="type">{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? 'pre'}</td>
<td>{action.target}</td>
<td>
<button
data-uix-play
onclick={() => {
const target = stageRef?.querySelector('[data-mask-field]') as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target
});
}}>▶ play</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Selectors live in <code>src/uix/eidos/components/mask-field/mask-field.css</code> and consume
Soma focus/disabled/invalid attrs plus Eidos <code>data-size</code> / <code>data-variant</code> / <code>data-color</code>.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-mask-field]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Root chrome — border, radius, padding, bg.</td>
</tr>
<tr>
<td class="name"><code>[data-mask-field][data-size][data-variant][data-color]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Visual scale, surface treatment and focus accent.</td>
</tr>
<tr>
<td class="name"><code>[data-mask-field][data-focused]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Focus ring + accent border from the headless layer.</td>
</tr>
<tr>
<td class="name"><code>[data-mask-field][data-invalid]</code> / <code>[data-disabled]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Invalid border + disabled opacity.</td>
</tr>
<tr>
<td class="name"><code>[data-mask-field-input]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Borderless input, tabular-nums, placeholder colour.</td>
</tr>
<tr>
<td class="name"><code>[data-mask-field][data-event]</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Transient perceptual event styling from Sema projection.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr><td class="name">Role</td><td>Single <code>role="textbox"</code> — NEVER fragmented into fake spinbutton segments (that is DateField/TimeField's job).</td></tr>
<tr><td class="name">Truthful value</td><td>Rejected keystrokes revert the visible value, so a screen reader reads back exactly the committed value (unlike hard masks that silently drop chars).</td></tr>
<tr><td class="name">Input mode</td><td>All-digit masks set <code>inputmode="numeric"</code> for the mobile keypad; mixed masks use <code>text</code>.</td></tr>
<tr><td class="name">Labelling</td><td>Inside a Field, <code>Field.Label</code>'s <code>for=</code> targets the input; format hints ride <code>Field.HelperText</code> via <code>aria-describedby</code>.</td></tr>
<tr><td class="name">Keyboard</td><td>Enter commits; no reinvented keys.</td></tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>
Loading…
Cancel
Save

Powered by TurnKey Linux.