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
parent
f5eb8acb40
commit
153d1ced98
@ -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 };
|
||||
@ -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;
|
||||
@ -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><Field></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"><{part.defaultElement}></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…
Reference in new issue