Merge pull request #141 from robonen/docs

feat(forms): add useMaskedField and useMaskedInput composables for in…
This commit is contained in:
2026-06-09 13:58:53 +07:00
committed by GitHub
426 changed files with 12981 additions and 311 deletions
@@ -1,4 +1,7 @@
export * from './mask';
export * from './useField';
export * from './useFieldArray';
export * from './useForm';
export * from './useFormContext';
export * from './useMaskedField';
export * from './useMaskedInput';
@@ -0,0 +1,265 @@
import { clamp, isFunction } from '@robonen/stdlib';
import type { ElementState, Mask, MaskExpression, OverwriteMode } from './types';
/**
* Resolve a (possibly dynamic) mask to a concrete {@link MaskExpression}.
*/
export function resolveMask(mask: Mask, state: ElementState): MaskExpression {
return isFunction(mask) ? mask(state) : mask;
}
/**
* Whether a mask slot is a fixed (literal) character rather than a matcher.
*/
export function isFixedCharacter(slot: RegExp | string): slot is string {
return typeof slot === 'string';
}
/**
* Whether `value` is a valid (possibly partial) prefix of the mask. Used as the
* conform fast-path and to derive a `complete` signal at full length.
*/
export function validateValueWithMask(value: string, mask: MaskExpression): boolean {
if (mask instanceof RegExp)
return mask.test(value);
if (value.length > mask.length)
return false;
for (let i = 0; i < value.length; i++) {
const slot = mask[i];
const char = value[i];
if (slot === undefined || char === undefined)
return false;
if (isFixedCharacter(slot)) {
if (char !== slot)
return false;
}
else if (!slot.test(char)) {
return false;
}
}
return true;
}
/**
* Whether `value` fully satisfies the mask (array: every slot filled).
*/
export function isMaskComplete(value: string, mask: MaskExpression): boolean {
if (mask instanceof RegExp)
return mask.test(value);
return value.length === mask.length && validateValueWithMask(value, mask);
}
/**
* Collect the contiguous run of fixed (literal) characters starting at
* `startIndex`, stopping at the first matcher slot or the end of the mask. These
* are auto-inserted before the next typed character (and a literal the user
* happens to type is harmlessly dropped at the following matcher slot).
*/
export function collectFixedCharacters(mask: ReadonlyArray<RegExp | string>, startIndex: number): string {
let fixed = '';
for (let i = startIndex; i < mask.length; i++) {
const slot = mask[i];
if (slot === undefined || !isFixedCharacter(slot))
break;
fixed += slot;
}
return fixed;
}
/**
* Conform a value to an array mask slot-by-slot: auto-insert fixed characters,
* accept matcher slots one character at a time, drop characters that don't fit,
* and track the caret in masked-output coordinates.
*/
export function guessValidValueByPattern(
state: ElementState,
mask: ReadonlyArray<RegExp | string>,
): ElementState {
const { value, selection } = state;
const [from, to] = selection;
let built = '';
let maskedFrom: number | null = null;
let maskedTo: number | null = null;
for (let i = 0; i <= value.length; i++) {
if (maskedFrom === null && i >= from)
maskedFrom = built.length;
if (maskedTo === null && i >= to)
maskedTo = built.length;
if (i === value.length)
break;
const char = value[i];
if (char === undefined)
continue;
built += collectFixedCharacters(mask, built.length);
const slot = mask[built.length];
if (slot === undefined)
break;
if (isFixedCharacter(slot))
built += slot;
else if (slot.test(char))
built += char;
// else: the character is rejected (built does not advance).
}
// Eagerly append the run of fixed characters that follows the filled slots
// (e.g. the trailing ')' once digits before it are present).
const finalValue = built.length > 0
? built + collectFixedCharacters(mask, built.length)
: built;
const end = finalValue.length;
return {
value: finalValue,
selection: [clamp(maskedFrom ?? end, 0, end), clamp(maskedTo ?? end, 0, end)],
};
}
/**
* Conform a value to a single-RegExp mask: keep the greedy prefix for which the
* value still matches, dropping the first character that breaks the pattern.
*/
export function guessValidValueByRegExp(state: ElementState, mask: RegExp): ElementState {
const { value, selection } = state;
const [from, to] = selection;
let accepted = '';
let newFrom = from;
let newTo = to;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (char === undefined)
continue;
if (mask.test(accepted + char)) {
accepted += char;
}
else {
if (i < from)
newFrom -= 1;
if (i < to)
newTo -= 1;
}
}
const end = accepted.length;
return {
value: accepted,
selection: [clamp(newFrom, 0, end), clamp(newTo, 0, end)],
};
}
/**
* Entry point: conform `state.value` to `mask`. Fast-returns when the value is
* already a valid prefix, otherwise dispatches to the array or RegExp guesser.
*/
export function calibrateValueByMask(state: ElementState, mask: Mask): ElementState {
const expression = resolveMask(mask, state);
if (validateValueWithMask(state.value, expression))
return state;
if (expression instanceof RegExp)
return guessValidValueByRegExp(state, expression);
return guessValidValueByPattern(state, expression);
}
/**
* Strip fixed mask characters from a (masked) state, returning the raw value and
* the selection remapped into that unmasked space. RegExp masks have no fixed
* characters, so the state is returned unchanged.
*/
export function removeFixedMaskCharacters(state: ElementState, mask: MaskExpression): ElementState {
if (mask instanceof RegExp)
return state;
const { value, selection } = state;
const [from, to] = selection;
let unmasked = '';
let newFrom = from;
let newTo = to;
for (let i = 0; i < value.length; i++) {
const char = value[i];
if (char === undefined)
continue;
const slot = mask[i];
const fixed = slot !== undefined && isFixedCharacter(slot);
if (fixed) {
if (i < from)
newFrom -= 1;
if (i < to)
newTo -= 1;
}
else {
unmasked += char;
}
}
const end = unmasked.length;
return {
value: unmasked,
selection: [clamp(newFrom, 0, end), clamp(newTo, 0, end)],
};
}
/**
* Resolve a (possibly function) {@link OverwriteMode} to a concrete value.
*/
export function resolveOverwriteMode(mode: OverwriteMode, state: ElementState): 'replace' | 'shift' {
return isFunction(mode) ? mode(state) : mode;
}
/**
* Under a collapsed caret, `replace` extends the selection to cover the next
* `newCharacters.length` characters (so they are overwritten); `shift` leaves it
* collapsed (pure insert). An existing range is returned untouched.
*/
export function applyOverwriteMode(state: ElementState, newCharacters: string, mode: OverwriteMode): ElementState {
const [from, to] = state.selection;
if (from !== to)
return state;
if (resolveOverwriteMode(mode, state) === 'replace') {
return {
value: state.value,
selection: [from, clamp(from + newCharacters.length, 0, state.value.length)],
};
}
return state;
}
/**
* Deep value + selection equality, used as the no-op-change guard.
*/
export function areElementStatesEqual(a: ElementState, b: ElementState): boolean {
// selection is always a [from, to] tuple — a direct compare avoids isEqual's
// per-call WeakMap allocation and deep-equality dispatch on this hot path.
return a.value === b.value && a.selection[0] === b.selection[0] && a.selection[1] === b.selection[1];
}
@@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest';
import {
calibrateValueByMask,
isMaskComplete,
removeFixedMaskCharacters,
} from './conform';
import { MASK_NOOP, MaskModel, maskTransform, normalizeMaskOptions, resolveMaskOptions, unmask } from './model';
import {
PHONE_COUNTRIES,
maskCardOptions,
maskDateOptions,
maskFromTemplate,
maskNumberOptions,
maskPhoneCountryOptions,
maskPhoneOptions,
} from './presets';
import type { ElementState } from './types';
const PHONE = maskPhoneOptions({ template: '+1 (###) ###-####' });
describe(maskFromTemplate, () => {
it('compiles tokens to matchers and keeps literals fixed', () => {
const mask = maskFromTemplate('##/##');
expect(mask).toHaveLength(5);
expect(mask[2]).toBe('/');
expect(mask[0]).toBeInstanceOf(RegExp);
});
});
describe('maskTransform — array (phone) mask', () => {
it('conforms a full number, inserting all fixed characters', () => {
expect(maskTransform('1234567890', PHONE)).toBe('+1 (123) 456-7890');
});
it('does not let a digit be eaten by a literal in the prefix', () => {
// The leading literal "+1 (" must be auto-inserted; the typed digits fill slots.
expect(maskTransform('12', PHONE)).toBe('+1 (12');
});
it('eagerly appends the trailing separator once a segment is filled', () => {
expect(maskTransform('123', PHONE)).toBe('+1 (123) ');
});
it('drops characters that do not fit a matcher slot', () => {
expect(maskTransform('1a2', PHONE)).toBe('+1 (12');
});
it('returns empty for empty input (no eager prefix)', () => {
expect(maskTransform('', PHONE)).toBe('');
});
});
describe('maskTransform — single RegExp mask', () => {
const HEX = { mask: /^#?[0-9a-f]*$/i };
it('keeps the greedy valid prefix', () => {
expect(maskTransform('#1a2zz', HEX)).toBe('#1a2');
});
});
describe('MaskModel — insertion in unmasked space', () => {
function model(initial: ElementState) {
return new MaskModel(initial, resolveMaskOptions(PHONE));
}
it('inserts a digit and places the caret after it (masked coords)', () => {
const m = model({ value: '+1 (12', selection: [6, 6] });
m.addCharacters('3');
expect(m.value).toBe('+1 (123) ');
// caret sits right after the 3rd digit, before the auto-inserted ") "
expect(m.selection[0]).toBe(7);
});
it('throws MASK_NOOP when an insertion changes nothing', () => {
const m = model({ value: '+1 (123) ', selection: [9, 9] });
// typing the auto-present space/paren area produces no change
let thrown: unknown;
try {
m.addCharacters(' ');
}
catch (error) {
thrown = error;
}
expect(thrown).toBe(MASK_NOOP);
});
});
describe('MaskModel — deletion across fixed characters', () => {
function model(initial: ElementState) {
return new MaskModel(initial, resolveMaskOptions(PHONE));
}
it('backspacing right after a fixed char deletes the preceding digit', () => {
// caret after ")" in "+1 (123) " (index 9). Backspace should remove the "3".
const m = model({ value: '+1 (123) ', selection: [9, 9] });
m.deleteCharacters(false);
expect(m.value).toBe('+1 (12');
});
it('forward-delete removes the next unmasked digit', () => {
const m = model({ value: '+1 (123', selection: [5, 5] }); // between 1 and 2
m.deleteCharacters(true);
expect(m.value).toBe('+1 (13');
});
});
describe(unmask, () => {
it('strips fixed characters from an array mask', () => {
expect(unmask('+1 (123) 456-7890', PHONE)).toBe('1234567890');
});
it('strips separators from the number preset via its preprocessor', () => {
const opts = maskNumberOptions({ thousandSeparator: ',', precision: 2 });
expect(unmask('1,234.50', opts)).toBe('1234.50');
});
it('strips date separators', () => {
expect(unmask('31/12/2024', maskDateOptions())).toBe('31122024');
});
});
describe(maskDateOptions, () => {
it('inserts separators while typing', () => {
expect(maskTransform('31122024', maskDateOptions())).toBe('31/12/2024');
});
it('clamps an out-of-range day segment', () => {
expect(maskTransform('99122024', maskDateOptions())).toBe('31/12/2024');
});
it('clamps an out-of-range month for mm/dd/yyyy', () => {
expect(maskTransform('99012024', maskDateOptions({ mode: 'mm/dd/yyyy' }))).toBe('12/01/2024');
});
it('supports ISO order with a dash separator', () => {
expect(maskTransform('20240131', maskDateOptions({ mode: 'yyyy-mm-dd' }))).toBe('2024-01-31');
});
});
describe(maskNumberOptions, () => {
it('groups thousands', () => {
expect(maskTransform('1234567', maskNumberOptions({ thousandSeparator: ',' }))).toBe('1,234,567');
});
it('keeps a decimal part within precision', () => {
expect(maskTransform('1234.567', maskNumberOptions({ thousandSeparator: ',', precision: 2 }))).toBe('1,234.56');
});
it('applies prefix and live max clamp', () => {
const opts = maskNumberOptions({ thousandSeparator: ',', prefix: '$', max: 100 });
expect(maskTransform('250', opts)).toBe('$100');
expect(maskTransform('50', opts)).toBe('$50');
});
it('drops non-numeric characters', () => {
expect(maskTransform('12a34', maskNumberOptions())).toBe('1234');
});
});
describe(maskPhoneCountryOptions, () => {
// Deterministic format assertions against an explicit country list.
const CUSTOM = maskPhoneCountryOptions({
countries: [
{ code: '1', template: '+# (###) ###-####' },
{ code: '34', template: '+## ### ### ###' },
{ code: '380', template: '+### (##) ###-##-##' },
],
});
it('formats by the matched dialing code', () => {
expect(maskTransform('12345678901', CUSTOM)).toBe('+1 (234) 567-8901');
expect(maskTransform('34612345678', CUSTOM)).toBe('+34 612 345 678');
});
it('matches the longest dialing-code prefix (+380, not +3…)', () => {
expect(maskTransform('380441234567', CUSTOM)).toBe('+380 (44) 123-45-67');
});
it('uses the fallback template before a code is recognized', () => {
expect(maskTransform('999', maskPhoneCountryOptions())).toBe('+999');
});
it('ships a mask for every country and switches by code with the default set', () => {
expect(PHONE_COUNTRIES.length).toBeGreaterThan(200);
expect(PHONE_COUNTRIES.some(country => country.code === '380')).toBeTruthy();
// No dialing code is a prefix of another (NANP territories are normalized to '1').
const codes = PHONE_COUNTRIES.map(country => country.code);
expect(codes.some(a => codes.some(b => a !== b && b.startsWith(a)))).toBeFalsy();
const masked = maskTransform('12025550123', maskPhoneCountryOptions());
expect(masked.startsWith('+1 ')).toBeTruthy();
expect(unmask(masked, maskPhoneCountryOptions())).toBe('12025550123');
});
});
describe(maskCardOptions, () => {
it('groups digits by the detected brand', () => {
expect(maskTransform('4111111111111111', maskCardOptions())).toBe('4111 1111 1111 1111');
expect(maskTransform('371449635398431', maskCardOptions())).toBe('3714 496353 98431'); // amex 4-6-5
});
it('falls back to 16-digit grouping for an unknown prefix', () => {
expect(maskTransform('0000000000000000', maskCardOptions())).toBe('0000 0000 0000 0000');
});
it('unmasks to the raw digits', () => {
expect(unmask('4111 1111 1111 1111', maskCardOptions())).toBe('4111111111111111');
});
});
describe(normalizeMaskOptions, () => {
it('compiles a template string', () => {
const opts = normalizeMaskOptions('##/##');
expect(maskTransform('1234', opts)).toBe('12/34');
});
it('wraps a bare array mask', () => {
const opts = normalizeMaskOptions([/\d/, /\d/]);
expect(maskTransform('99', opts)).toBe('99');
});
it('passes full options through', () => {
expect(normalizeMaskOptions(PHONE)).toBe(PHONE);
});
});
describe('isMaskComplete / calibrate / removeFixed', () => {
it('reports completeness for an array mask', () => {
const mask = maskFromTemplate('##/##');
expect(isMaskComplete('12/34', mask)).toBeTruthy();
expect(isMaskComplete('12/3', mask)).toBeFalsy();
});
it('calibrate fast-returns a valid prefix unchanged', () => {
const mask = maskFromTemplate('###');
const state: ElementState = { value: '12', selection: [2, 2] };
expect(calibrateValueByMask(state, mask)).toBe(state);
});
it('removeFixedMaskCharacters remaps the selection', () => {
const mask = maskFromTemplate('(##)');
// "(12)" caret after ")" → unmasked "12" caret at 2
const result = removeFixedMaskCharacters({ value: '(12)', selection: [4, 4] }, mask);
expect(result.value).toBe('12');
expect(result.selection).toEqual([2, 2]);
});
});
@@ -0,0 +1,40 @@
export type {
ElementState,
Mask,
MaskAction,
MaskExpression,
MaskOptionInput,
MaskOptions,
MaskPostprocessor,
MaskPreprocessor,
MaskPreprocessorParams,
MaskPreprocessorResult,
OverwriteMode,
SelectionRange,
} from './types';
export { isMaskComplete } from './conform';
export { maskTransform, normalizeMaskOptions, unmask } from './model';
export {
CARD_BRANDS,
DEFAULT_MASK_TOKENS,
findCardBrand,
findPhoneCountry,
isValidCardNumber,
maskCardOptions,
maskDateOptions,
maskFromTemplate,
maskNumberOptions,
maskPhoneCountryOptions,
maskPhoneOptions,
PHONE_COUNTRIES,
} from './presets';
export type {
CardBrand,
MaskCardParams,
MaskDateParams,
MaskNumberParams,
MaskPhoneCountryParams,
MaskPhoneParams,
PhoneCountry,
} from './presets';
@@ -0,0 +1,225 @@
import { isArray, isFunction, isString } from '@robonen/stdlib';
import {
applyOverwriteMode,
areElementStatesEqual,
calibrateValueByMask,
removeFixedMaskCharacters,
resolveMask,
} from './conform';
import { maskFromTemplate } from './presets';
import type {
ElementState,
Mask,
MaskAction,
MaskOptionInput,
MaskOptions,
MaskPostprocessor,
MaskPreprocessor,
ResolvedMaskOptions,
SelectionRange,
} from './types';
/**
* Thrown by {@link MaskModel} mutations that produce no change, so callers can
* swallow the gesture (e.g. typing a fixed character that is already present)
* without polluting input/undo history.
*/
export const MASK_NOOP = Symbol('mask-noop');
/**
* Apply {@link MaskOptions} defaults.
*/
export function resolveMaskOptions(options: MaskOptions): ResolvedMaskOptions {
return {
mask: options.mask,
preprocessors: options.preprocessors ?? [],
postprocessors: options.postprocessors ?? [],
overwriteMode: options.overwriteMode ?? 'shift',
};
}
/**
* Normalize the friendly authoring union to full {@link MaskOptions}: a template
* string is compiled via {@link maskFromTemplate}, a bare mask is wrapped, and
* full options pass through.
*/
export function normalizeMaskOptions(input: MaskOptionInput): MaskOptions {
if (isString(input))
return { mask: maskFromTemplate(input) };
if (input instanceof RegExp || isArray(input) || isFunction(input))
return { mask: input as Mask };
return input as MaskOptions;
}
/**
* Run the preprocessor chain, threading `{ elementState, data }`.
*/
export function runPreprocessors(
processors: readonly MaskPreprocessor[],
elementState: ElementState,
data: string,
action: MaskAction,
): { elementState: ElementState; data: string } {
let state = elementState;
let currentData = data;
for (const process of processors) {
const result = process({ elementState: state, data: currentData }, action);
state = result.elementState;
if (result.data !== undefined)
currentData = result.data;
}
return { elementState: state, data: currentData };
}
/**
* Run the postprocessor chain against `initialState`.
*/
export function runPostprocessors(
processors: readonly MaskPostprocessor[],
state: ElementState,
initialState: ElementState,
): ElementState {
let current = state;
for (const process of processors)
current = process(current, initialState);
return current;
}
/**
* The stateful masking model. Holds the masked `value`/`selection` and performs
* insertions/deletions in **unmasked space** (fixed characters stripped) before
* re-calibrating to the masked form — the device that makes backspacing across
* fixed characters and `overwriteMode` correct.
*/
export class MaskModel implements ElementState {
public value: string;
public selection: SelectionRange;
private readonly options: ResolvedMaskOptions;
constructor(initial: ElementState, options: ResolvedMaskOptions) {
this.options = options;
const calibrated = calibrateValueByMask(initial, options.mask);
this.value = calibrated.value;
this.selection = calibrated.selection;
}
/**
* Insert `characters` at the current selection. Throws {@link MASK_NOOP} when
* the result is identical to the current state.
*/
public addCharacters(characters: string): void {
const initial: ElementState = { value: this.value, selection: this.selection };
const mask = resolveMask(this.options.mask, initial);
const overwritten = applyOverwriteMode(initial, characters, this.options.overwriteMode);
const unmasked = removeFixedMaskCharacters(overwritten, mask);
const [from, to] = unmasked.selection;
const leading = unmasked.value.slice(0, from) + characters;
const nextValue = leading + unmasked.value.slice(to);
const caret = leading.length;
this.applyCalibrated({ value: nextValue, selection: [caret, caret] }, initial, true);
}
/**
* Delete from the current selection. A collapsed caret removes one *unmasked*
* character in the given direction (auto-skipping fixed characters); a range
* removes its unmasked span.
*/
public deleteCharacters(isForward: boolean): void {
const initial: ElementState = { value: this.value, selection: this.selection };
const mask = resolveMask(this.options.mask, initial);
const unmasked = removeFixedMaskCharacters(initial, mask);
let [from, to] = unmasked.selection;
if (from === to) {
if (isForward)
to = Math.min(to + 1, unmasked.value.length);
else
from = Math.max(from - 1, 0);
}
const nextValue = unmasked.value.slice(0, from) + unmasked.value.slice(to);
this.applyCalibrated({ value: nextValue, selection: [from, from] }, initial, false);
}
private applyCalibrated(candidate: ElementState, initial: ElementState, guardNoop: boolean): void {
const calibrated = calibrateValueByMask(candidate, this.options.mask);
if (guardNoop && areElementStatesEqual(initial, calibrated))
throw MASK_NOOP;
this.value = calibrated.value;
this.selection = calibrated.selection;
}
}
/**
* @name maskTransform
* @category Forms
* @description Pure, DOM-free masking: conform a string (or {@link ElementState})
* through the full preprocessor → mask → postprocessor pipeline. Ideal for SSR,
* server-side validation, and tests. Returns a `string` for string input and an
* {@link ElementState} for state input.
*
* @example
* maskTransform('1234567890', maskPhoneOptions({ template: '+1 (###) ###-####' }));
* // '+1 (123) 456-7890'
*
* @since 0.0.17
*/
export function maskTransform(value: string, options: MaskOptions): string;
export function maskTransform(state: ElementState, options: MaskOptions): ElementState;
export function maskTransform(valueOrState: string | ElementState, options: MaskOptions): string | ElementState {
const resolved = resolveMaskOptions(options);
const inputIsString = isString(valueOrState);
const initial: ElementState = inputIsString
? { value: valueOrState, selection: [valueOrState.length, valueOrState.length] }
: valueOrState;
const pre = runPreprocessors(resolved.preprocessors, initial, '', 'validation');
const model = new MaskModel(pre.elementState, resolved);
const post = runPostprocessors(
resolved.postprocessors,
{ value: model.value, selection: model.selection },
initial,
);
return inputIsString ? post.value : post;
}
/**
* @name unmask
* @category Forms
* @description The masked → raw bridge: strip a mask's fixed characters from a
* masked string, without needing a DOM element. RegExp masks have no fixed
* characters, so the value is returned unchanged.
*
* @example
* unmask('+1 (123) 456-7890', maskPhoneOptions({ template: '+1 (###) ###-####' }));
* // '1234567890'
*
* @since 0.0.17
*/
export function unmask(maskedValue: string, options: MaskOptions): string {
const resolved = resolveMaskOptions(options);
const state: ElementState = { value: maskedValue, selection: [maskedValue.length, maskedValue.length] };
// Run preprocessors so preset normalizers (e.g. the number mask stripping
// thousand separators/affixes) define the raw value too.
const pre = runPreprocessors(resolved.preprocessors, state, '', 'validation');
const mask = resolveMask(resolved.mask, pre.elementState);
return removeFixedMaskCharacters(pre.elementState, mask).value;
}
@@ -0,0 +1,76 @@
import { CARD_BRANDS, findCardBrand } from '@robonen/platform/multi';
import type { CardBrand } from '@robonen/platform/multi';
import type { ElementState, MaskExpression, MaskOptions } from '../types';
import { maskFromTemplate } from './template';
// Re-export the platform reference data + resolver + validator (source of truth
// lives in `@robonen/platform`; the Luhn primitive lives in `@robonen/encoding`).
export { CARD_BRANDS, findCardBrand, isValidCardNumber } from '@robonen/platform/multi';
export type { CardBrand } from '@robonen/platform/multi';
const NON_DIGIT = /\D/g;
const DEFAULT_CARD_FALLBACK = '#### #### #### ####';
/**
* Parameters for {@link maskCardOptions}.
*/
export interface MaskCardParams {
/**
* Known card brands, matched by {@link findCardBrand}.
*
* @default CARD_BRANDS
*/
readonly brands?: readonly CardBrand[];
/**
* Template used before a brand is recognized (and for unknown brands).
*
* @default '#### #### #### ####'
*/
readonly fallback?: string;
/**
* Token map applied to every template.
*/
readonly tokens?: Readonly<Record<string, RegExp>>;
}
/**
* @name maskCardOptions
* @category Forms
* @description A dynamic payment-card mask that groups digits by the detected
* brand. The mask is a function of state: it reads the digits, resolves the brand
* via {@link findCardBrand} (by IIN/BIN prefix), and applies that brand's grouping
* template (e.g. `#### ###### #####` for Amex) — falling back to a generic 16-digit
* grouping until a brand is recognized. The unmasked value is the digit string.
*
* @param {MaskCardParams} [params={}] Brands and fallback template
* @returns {MaskOptions} Ready-to-use mask options (a function mask)
*
* @example
* const card = useMaskedInput({ mask: maskCardOptions() });
* // <input v-bind="card.bind"> — 4111… → '4111 1111 1111 1111', 3714… → Amex 4-6-5
*
* @since 0.0.17
*/
export function maskCardOptions(params: MaskCardParams = {}): MaskOptions {
const brands = params.brands ?? CARD_BRANDS;
const fallback = params.fallback ?? DEFAULT_CARD_FALLBACK;
// 1-entry memo: resolveMask fires several times per keystroke with the same
// digits, and the expression is a pure function of those digits.
let lastDigits = '';
let lastExpression: MaskExpression = maskFromTemplate(fallback, params.tokens);
return {
mask: (state: ElementState): MaskExpression => {
const digits = state.value.replaceAll(NON_DIGIT, '');
if (digits === lastDigits)
return lastExpression;
const brand = findCardBrand(digits, brands);
lastDigits = digits;
lastExpression = maskFromTemplate(brand?.template ?? fallback, params.tokens);
return lastExpression;
},
};
}
@@ -0,0 +1,82 @@
import type { MaskOptions, MaskPostprocessor } from '../types';
/**
* Parameters for {@link maskDateOptions}.
*/
export interface MaskDateParams {
/**
* Segment order and separator style.
*
* @default 'dd/mm/yyyy'
*/
readonly mode?: 'dd/mm/yyyy' | 'mm/dd/yyyy' | 'yyyy-mm-dd';
/**
* Override the separator character.
*/
readonly separator?: string;
}
interface DateSegment {
readonly kind: 'day' | 'month' | 'year';
readonly length: number;
}
const DATE_SEGMENTS: Record<NonNullable<MaskDateParams['mode']>, readonly DateSegment[]> = {
'dd/mm/yyyy': [{ kind: 'day', length: 2 }, { kind: 'month', length: 2 }, { kind: 'year', length: 4 }],
'mm/dd/yyyy': [{ kind: 'month', length: 2 }, { kind: 'day', length: 2 }, { kind: 'year', length: 4 }],
'yyyy-mm-dd': [{ kind: 'year', length: 4 }, { kind: 'month', length: 2 }, { kind: 'day', length: 2 }],
};
const SEGMENT_MAX: Record<DateSegment['kind'], number> = { day: 31, month: 12, year: 9999 };
const ALL_DIGITS = /^\d+$/;
function clampDateSegments(segments: readonly DateSegment[], separator: string): MaskPostprocessor {
return (state) => {
const parts = state.value.split(separator);
const clamped = parts.map((part, index) => {
const segment = segments[index];
// Clamp only fully-typed segments so partial input (e.g. "3" → 31) isn't fought.
if (!segment || part.length < segment.length || !ALL_DIGITS.test(part))
return part;
const max = SEGMENT_MAX[segment.kind];
const value = Number(part);
return value > max ? String(max).padStart(segment.length, '0') : part;
});
return { value: clamped.join(separator), selection: state.selection };
};
}
/**
* @name maskDateOptions
* @category Forms
* @description Mask options for a date. Auto-inserts separators and clamps
* fully-typed day/month segments (day ≤ 31, month ≤ 12). No calendar/timezone
* validation — pair with a schema for that.
*
* @param {MaskDateParams} [params={}] Segment order and separator
* @returns {MaskOptions} Ready-to-use mask options
*
* @example
* maskDateOptions({ mode: 'dd/mm/yyyy' });
*
* @since 0.0.17
*/
export function maskDateOptions(params: MaskDateParams = {}): MaskOptions {
const mode = params.mode ?? 'dd/mm/yyyy';
const separator = params.separator ?? (mode === 'yyyy-mm-dd' ? '-' : '/');
const segments = DATE_SEGMENTS[mode];
const mask: Array<RegExp | string> = [];
segments.forEach((segment, index) => {
if (index > 0)
mask.push(separator);
for (let i = 0; i < segment.length; i++)
mask.push(/\d/);
});
return { mask, postprocessors: [clampDateSegments(segments, separator)] };
}
@@ -0,0 +1,11 @@
export { DEFAULT_MASK_TOKENS, maskFromTemplate } from './template';
export { maskPhoneOptions } from './phone';
export type { MaskPhoneParams } from './phone';
export { findPhoneCountry, maskPhoneCountryOptions, PHONE_COUNTRIES } from './phone-country';
export type { MaskPhoneCountryParams, PhoneCountry } from './phone-country';
export { CARD_BRANDS, findCardBrand, isValidCardNumber, maskCardOptions } from './card';
export type { CardBrand, MaskCardParams } from './card';
export { maskDateOptions } from './date';
export type { MaskDateParams } from './date';
export { maskNumberOptions } from './number';
export type { MaskNumberParams } from './number';
@@ -0,0 +1,237 @@
import { clamp } from '@robonen/stdlib';
import type { ElementState, MaskOptions, MaskPostprocessor, MaskPreprocessor } from '../types';
const ESCAPE_REGEXP = /[$()*+.?[\\\]^{|}]/g;
function escapeRegExp(source: string): string {
return source.replaceAll(ESCAPE_REGEXP, '\\$&');
}
/**
* Parameters for {@link maskNumberOptions}.
*/
export interface MaskNumberParams {
/**
* Decimal separator.
*
* @default '.'
*/
readonly decimalSeparator?: string;
/**
* Grouping separator for thousands. Empty disables grouping.
*
* @default ''
*/
readonly thousandSeparator?: string;
/**
* Max fractional digits. `0` means integer only.
*
* @default 0
*/
readonly precision?: number;
/**
* Upper bound — typed values above it snap down to it (applied live).
*/
readonly max?: number;
/**
* Allow a leading minus sign.
*
* @default false
*/
readonly allowNegative?: boolean;
/**
* Static prefix (e.g. `'$'`).
*
* @default ''
*/
readonly prefix?: string;
/**
* Static postfix (e.g. `' USD'`).
*
* @default ''
*/
readonly postfix?: string;
}
interface ResolvedNumberParams {
readonly decimalSeparator: string;
readonly thousandSeparator: string;
readonly precision: number;
readonly max: number | undefined;
readonly allowNegative: boolean;
readonly prefix: string;
readonly postfix: string;
}
/**
* Remove every `char` from `value`, remapping the selection to the stripped text.
*/
function stripCharacter(state: ElementState, char: string): ElementState {
if (!char)
return state;
const { value, selection } = state;
const [from, to] = selection;
let stripped = '';
let newFrom = from;
let newTo = to;
for (let i = 0; i < value.length; i++) {
const current = value[i];
if (current === undefined)
continue;
if (current === char) {
if (i < from)
newFrom -= 1;
if (i < to)
newTo -= 1;
}
else {
stripped += current;
}
}
const end = stripped.length;
return { value: stripped, selection: [clamp(newFrom, 0, end), clamp(newTo, 0, end)] };
}
/**
* Strip a fixed prefix/postfix, shifting the selection into the body.
*/
function stripAffixes(state: ElementState, prefix: string, postfix: string): ElementState {
let { value } = state;
let [from, to] = state.selection;
if (prefix && value.startsWith(prefix)) {
value = value.slice(prefix.length);
from -= prefix.length;
to -= prefix.length;
}
if (postfix && value.endsWith(postfix))
value = value.slice(0, -postfix.length);
const end = value.length;
return { value, selection: [clamp(from, 0, end), clamp(to, 0, end)] };
}
function numberPreprocessor(params: ResolvedNumberParams): MaskPreprocessor {
return ({ elementState, data }) => {
const withoutAffixes = stripAffixes(elementState, params.prefix, params.postfix);
const canonical = stripCharacter(withoutAffixes, params.thousandSeparator);
// Treat a typed '.'/',' as the configured decimal separator.
const normalizedData = params.precision > 0 && (data === '.' || data === ',')
? params.decimalSeparator
: data;
return { elementState: canonical, data: normalizedData };
};
}
function groupThousands(intDigits: string, separator: string): string {
if (!separator)
return intDigits;
let grouped = '';
for (let i = 0; i < intDigits.length; i++) {
if (i > 0 && (intDigits.length - i) % 3 === 0)
grouped += separator;
grouped += intDigits[i];
}
return grouped;
}
/**
* Map a caret in canonical (separator-free) coordinates onto the grouped body by
* walking the body and skipping thousand separators.
*/
function mapCaretToBody(body: string, separator: string, canonicalCaret: number): number {
if (!separator)
return canonicalCaret;
let significant = 0;
for (let i = 0; i < body.length; i++) {
if (significant === canonicalCaret)
return i;
if (body[i] !== separator)
significant += 1;
}
return body.length;
}
function numberPostprocessor(params: ResolvedNumberParams): MaskPostprocessor {
const { decimalSeparator, thousandSeparator, prefix, postfix, max } = params;
return (state) => {
const [caret] = state.selection;
let canonical = state.value;
// Live max clamp on the settled numeric value.
if (max !== undefined && canonical && !canonical.endsWith(decimalSeparator)) {
const numeric = Number(canonical.replace(decimalSeparator, '.'));
if (Number.isFinite(numeric) && numeric > max)
canonical = String(max).replace('.', decimalSeparator);
}
const negative = canonical.startsWith('-');
const unsigned = negative ? canonical.slice(1) : canonical;
const [intPart = '', fracPart] = unsigned.split(decimalSeparator);
const sign = negative ? '-' : '';
const grouped = groupThousands(intPart, thousandSeparator);
const body = sign + grouped + (fracPart !== undefined ? decimalSeparator + fracPart : '');
const value = prefix + body + postfix;
const bodyCaret = mapCaretToBody(body, thousandSeparator, clamp(caret, 0, canonical.length));
const mapped = prefix.length + clamp(bodyCaret, 0, body.length);
return { value, selection: [mapped, mapped] };
};
}
/**
* @name maskNumberOptions
* @category Forms
* @description Mask options for a formatted number: optional thousands grouping,
* decimal precision, sign, prefix/postfix, and a live upper-bound clamp. The
* unmasked value is the canonical, separator-free number string.
*
* @param {MaskNumberParams} [params={}] Formatting options
* @returns {MaskOptions} Ready-to-use mask options
*
* @example
* maskNumberOptions({ thousandSeparator: ',', precision: 2, prefix: '$' });
* // typing 1234.5 → '$1,234.5'
*
* @since 0.0.17
*/
export function maskNumberOptions(params: MaskNumberParams = {}): MaskOptions {
const resolved: ResolvedNumberParams = {
decimalSeparator: params.decimalSeparator ?? '.',
thousandSeparator: params.thousandSeparator ?? '',
precision: params.precision ?? 0,
max: params.max,
allowNegative: params.allowNegative ?? false,
prefix: params.prefix ?? '',
postfix: params.postfix ?? '',
};
const sign = resolved.allowNegative ? '-?' : '';
const decimal = resolved.precision > 0
? `(${escapeRegExp(resolved.decimalSeparator)}\\d{0,${resolved.precision}})?`
: '';
const mask = new RegExp(`^${sign}\\d*${decimal}$`);
return {
mask,
preprocessors: [numberPreprocessor(resolved)],
postprocessors: [numberPostprocessor(resolved)],
};
}
@@ -0,0 +1,84 @@
import { PHONE_COUNTRIES, findPhoneCountry } from '@robonen/platform/multi';
import type { PhoneCountry } from '@robonen/platform/multi';
import type { ElementState, MaskExpression, MaskOptions } from '../types';
import { maskFromTemplate } from './template';
// Re-export the platform reference data + resolver so mask consumers get them
// from one place (the source of truth lives in `@robonen/platform`).
export { PHONE_COUNTRIES, findPhoneCountry } from '@robonen/platform/multi';
export type { PhoneCountry } from '@robonen/platform/multi';
const NON_DIGIT = /\D/g;
const DEFAULT_PHONE_FALLBACK = '+###############';
/**
* Parameters for {@link maskPhoneCountryOptions}.
*/
export interface MaskPhoneCountryParams {
/**
* Known countries, matched by {@link findPhoneCountry}.
*
* @default PHONE_COUNTRIES
*/
readonly countries?: readonly PhoneCountry[];
/**
* Template used before any country code is recognized.
*
* @default '+###############'
*/
readonly fallback?: string;
/**
* Token map applied to every template.
*/
readonly tokens?: Readonly<Record<string, RegExp>>;
}
/**
* @name maskPhoneCountryOptions
* @category Forms
* @description A dynamic phone mask that switches format based on the typed
* country dialing code. The mask is a function of state: it reads the leading
* digits, resolves the country via {@link findPhoneCountry} (dialing code → area
* code → priority), and applies that country's template — falling back to a
* generic international template until a code is recognized. Defaults to the full
* {@link PHONE_COUNTRIES} set. The unmasked value is the digit string.
*
* @param {MaskPhoneCountryParams} [params={}] Countries and fallback template
* @returns {MaskOptions} Ready-to-use mask options (a function mask)
*
* @example
* // Typing "+1…" formats as US, "+380…" as Ukraine, etc.
* const phone = useMaskedInput({ mask: maskPhoneCountryOptions() });
* // <input v-bind="phone.bind">
*
* @example
* maskPhoneCountryOptions({
* countries: [{ code: '34', template: '+## ### ### ###' }], // Spain only
* fallback: '+#############',
* });
*
* @since 0.0.17
*/
export function maskPhoneCountryOptions(params: MaskPhoneCountryParams = {}): MaskOptions {
const countries = params.countries ?? PHONE_COUNTRIES;
const fallback = params.fallback ?? DEFAULT_PHONE_FALLBACK;
// 1-entry memo: resolveMask fires several times per keystroke with the same
// digits, and the expression is a pure function of those digits.
let lastDigits = '';
let lastExpression: MaskExpression = maskFromTemplate(fallback, params.tokens);
return {
mask: (state: ElementState): MaskExpression => {
const digits = state.value.replaceAll(NON_DIGIT, '');
if (digits === lastDigits)
return lastExpression;
const country = findPhoneCountry(digits, countries);
lastDigits = digits;
lastExpression = maskFromTemplate(country?.template ?? fallback, params.tokens);
return lastExpression;
},
};
}
@@ -0,0 +1,35 @@
import type { MaskOptions } from '../types';
import { maskFromTemplate } from './template';
/**
* Parameters for {@link maskPhoneOptions}.
*/
export interface MaskPhoneParams {
/**
* The phone template, e.g. `'+1 (###) ###-####'`.
*/
readonly template: string;
/**
* Override the token → matcher map.
*/
readonly tokens?: Readonly<Record<string, RegExp>>;
}
/**
* @name maskPhoneOptions
* @category Forms
* @description Mask options for a phone number, built from a single template
* string. For a mask that adapts to the typed country code, see
* {@link maskPhoneCountryOptions}.
*
* @param {MaskPhoneParams} params The template (and optional tokens)
* @returns {MaskOptions} Ready-to-use mask options
*
* @example
* maskPhoneOptions({ template: '+1 (###) ###-####' });
*
* @since 0.0.17
*/
export function maskPhoneOptions(params: MaskPhoneParams): MaskOptions {
return { mask: maskFromTemplate(params.template, params.tokens) };
}
@@ -0,0 +1,57 @@
/**
* Default {@link maskFromTemplate} tokens: `#` → digit, `A` → letter,
* `*` → letter or digit. Every other template character is a fixed literal.
*/
export const DEFAULT_MASK_TOKENS: Readonly<Record<string, RegExp>> = {
'#': /\d/,
A: /[a-z]/i,
'*': /[a-z0-9]/i,
};
// Compiled-mask cache for the default-token path. Function masks (phone/card)
// re-resolve the same template several times per keystroke; caching collapses
// each repeat to a Map hit instead of rebuilding a 12-17 element array.
const TEMPLATE_CACHE = new Map<string, ReadonlyArray<RegExp | string>>();
function compileTemplate(template: string, tokens: Readonly<Record<string, RegExp>>): Array<RegExp | string> {
const mask: Array<RegExp | string> = [];
for (const char of template)
mask.push(tokens[char] ?? char);
return mask;
}
/**
* @name maskFromTemplate
* @category Forms
* @description Compile a human-readable template into a mask array. Token
* characters become matcher slots; everything else becomes a fixed literal. With
* the default tokens the compiled array is cached and shared (frozen) per
* template string; pass a custom `tokens` map to opt out.
*
* @param {string} template e.g. `'+1 (###) ###-####'`
* @param {Record<string, RegExp>} [tokens=DEFAULT_MASK_TOKENS] Token → matcher map
* @returns {ReadonlyArray<RegExp | string>} The compiled mask expression
*
* @example
* maskFromTemplate('##/##/####'); // [/\d/, /\d/, '/', /\d/, /\d/, '/', /\d/, /\d/, /\d/, /\d/]
*
* @since 0.0.17
*/
export function maskFromTemplate(
template: string,
tokens: Readonly<Record<string, RegExp>> = DEFAULT_MASK_TOKENS,
): ReadonlyArray<RegExp | string> {
if (tokens !== DEFAULT_MASK_TOKENS)
return compileTemplate(template, tokens);
const cached = TEMPLATE_CACHE.get(template);
if (cached)
return cached;
const compiled = Object.freeze(compileTemplate(template, tokens));
TEMPLATE_CACHE.set(template, compiled);
return compiled;
}
@@ -0,0 +1,107 @@
/**
* A selection range as `[from, to]`. A collapsed caret has `from === to`.
*/
export type SelectionRange = readonly [from: number, to: number];
/**
* The DOM-free editable state the masking engine operates on: a value plus its
* selection. Structurally compatible with `@robonen/platform`'s `InputState`.
*/
export interface ElementState {
readonly value: string;
readonly selection: SelectionRange;
}
/**
* A concrete mask pattern: either a single {@link RegExp} validating the whole
* value, or an array of slots where a `string` is a fixed (auto-inserted)
* character and a {@link RegExp} validates exactly one character.
*/
export type MaskExpression = ReadonlyArray<RegExp | string> | RegExp;
/**
* A mask: a concrete {@link MaskExpression} or a function deriving one from the
* current {@link ElementState} (a dynamic mask).
*/
export type Mask = MaskExpression | ((state: ElementState) => MaskExpression);
/**
* The user gesture a preprocessor is reacting to.
*/
export type MaskAction = 'deleteBackward' | 'deleteForward' | 'insert' | 'validation';
/**
* How newly typed characters interact with the text under a collapsed caret:
* - `shift` — insert, pushing existing characters right (default);
* - `replace` — overwrite the following characters;
* - a function resolving the mode per {@link ElementState}.
*/
export type OverwriteMode = 'replace' | 'shift' | ((state: ElementState) => 'replace' | 'shift');
/**
* Input to a {@link MaskPreprocessor}.
*/
export interface MaskPreprocessorParams {
readonly elementState: ElementState;
readonly data: string;
}
/**
* Output of a {@link MaskPreprocessor}.
*/
export interface MaskPreprocessorResult {
readonly elementState: ElementState;
readonly data?: string;
}
/**
* Runs before the mask conforms a value — normalizes the upcoming state/data.
*/
export type MaskPreprocessor = (params: MaskPreprocessorParams, action: MaskAction) => MaskPreprocessorResult;
/**
* Runs after the mask conforms a value — applies finishing touches (separators,
* padding, clamping). Receives the conformed state and the pre-change state.
*/
export type MaskPostprocessor = (state: ElementState, initialState: ElementState) => ElementState;
/**
* A full mask configuration.
*/
export interface MaskOptions {
/**
* The mask pattern (or a function of state).
*/
readonly mask: Mask;
/**
* Normalizers run before conforming.
*/
readonly preprocessors?: readonly MaskPreprocessor[];
/**
* Finishers run after conforming.
*/
readonly postprocessors?: readonly MaskPostprocessor[];
/**
* Insert vs overwrite behavior.
*
* @default 'shift'
*/
readonly overwriteMode?: OverwriteMode;
}
/**
* The friendly authoring union accepted at every public boundary: a full
* {@link MaskOptions}, a bare {@link Mask}, or a template `string`
* (compiled via `maskFromTemplate`, e.g. `'+1 (###) ###-####'`).
*/
export type MaskOptionInput = Mask | string | MaskOptions;
/**
* {@link MaskOptions} after defaults are applied (internal).
*/
export interface ResolvedMaskOptions {
readonly mask: Mask;
readonly preprocessors: readonly MaskPreprocessor[];
readonly postprocessors: readonly MaskPostprocessor[];
readonly overwriteMode: OverwriteMode;
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { useForm } from '../useForm';
import { useMaskedField } from './index';
interface Values {
phone: string;
}
const form = useForm<Values>({ initialValues: { phone: '' }, validateOn: 'value' });
const { bind, display, complete, errorMessage } = useMaskedField('phone', {
mask: '+1 (###) ###-####',
validate: value => (typeof value === 'string' && value.length === 10) || 'Enter a complete phone number',
});
const onSubmit = form.handleSubmit((values) => {
// eslint-disable-next-line no-alert
globalThis.alert(`Submitted raw value: ${values.phone}`);
});
const inputClass = 'w-full rounded-lg border bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:outline-none focus:ring-2 focus:ring-(--ring)';
</script>
<template>
<form class="flex w-full max-w-sm flex-col gap-4" @submit.prevent="onSubmit">
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">
Phone number
</label>
<!-- One spread wires the ref, mask handlers, name, blur, and aria-invalid. -->
<input
v-bind="bind"
type="text"
inputmode="numeric"
placeholder="+1 (###) ###-####"
:class="[
inputClass,
errorMessage
? 'border-red-500/60 focus:border-red-500'
: 'border-(--border) focus:border-(--accent)',
]"
>
<p v-if="errorMessage" class="text-xs text-red-600 dark:text-red-400">
{{ errorMessage }}
</p>
<p v-else class="text-xs text-(--fg-subtle)">
Display is masked; the form stores the raw digits.
</p>
</div>
<div class="flex flex-wrap gap-2">
<span
class="inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium"
:class="complete
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-subtle)'"
>
{{ complete ? 'complete' : 'incomplete' }}
</span>
</div>
<button
type="submit"
class="inline-flex items-center justify-center rounded-lg border border-transparent bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
>
Submit
</button>
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 font-mono text-sm text-(--fg) tabular-nums">
<div>display: "{{ display }}"</div>
<div>form value (raw): "{{ form.values.phone }}"</div>
</div>
</form>
</template>
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { maskNumberOptions } from '../mask';
import { useMaskedField } from './index';
import type { UseMaskedFieldOptions, UseMaskedFieldReturn } from './index';
function mountField(options: UseMaskedFieldOptions): {
field: UseMaskedFieldReturn;
input: HTMLInputElement;
unmount: () => void;
} {
let field!: UseMaskedFieldReturn;
const wrapper = mount(
defineComponent({
setup() {
field = useMaskedField('phone', options);
return () => h('input', field.bind.value as Record<string, unknown>);
},
}),
{ attachTo: document.body },
);
return { field, input: wrapper.element as HTMLInputElement, unmount: () => wrapper.unmount() };
}
describe(useMaskedField, () => {
it('shows the masked value but stores the raw value in the field', async () => {
const { field, input, unmount } = mountField({ mask: '+1 (###) ###-####', initialValue: '' });
input.value = '1234567890';
input.dispatchEvent(new Event('input'));
await nextTick();
expect(field.display.value).toBe('+1 (123) 456-7890');
expect(field.unmasked.value).toBe('1234567890');
expect(field.value.value).toBe('1234567890');
expect(field.complete.value).toBeTruthy();
unmount();
});
it('stores the masked value when modelValue is "masked"', async () => {
const { field, input, unmount } = mountField({
mask: maskNumberOptions({ thousandSeparator: ',' }),
modelValue: 'masked',
initialValue: '',
});
input.value = '1234567';
input.dispatchEvent(new Event('input'));
await nextTick();
expect(field.display.value).toBe('1,234,567');
expect(field.value.value).toBe('1,234,567');
unmount();
});
it('re-seeds the input when the field value is set programmatically', async () => {
const { field, input, unmount } = mountField({ mask: '+1 (###) ###-####', initialValue: '' });
field.setValue('5551234567');
await nextTick();
expect(field.display.value).toBe('+1 (555) 123-4567');
expect(input.value).toBe('+1 (555) 123-4567');
unmount();
});
});
@@ -0,0 +1,69 @@
import { computed, watch } from 'vue';
import type { MaybeRefOrGetter } from 'vue';
import { useField } from '../useField';
import { useMaskedInput } from '../useMaskedInput';
import type { UseMaskedFieldOptions, UseMaskedFieldReturn } from './types';
export type { UseMaskedFieldOptions, UseMaskedFieldReturn } from './types';
/**
* @name useMaskedField
* @category Forms
* @description A masked form field: fuses {@link useField} with
* {@link useMaskedInput} so a formatted value is shown while the form stores the
* raw value (validation, dirty/touched, schema, and submit all read raw). Returns
* a single `bind` object to spread onto the input — it merges the field's
* `name`/`onBlur`/`aria-invalid` with the mask bindings (ref + handlers). Purely
* additive — it composes the existing form composables without modifying them.
*
* @param {MaybeRefOrGetter<string>} path The dotted field path
* @param {UseMaskedFieldOptions} options The mask plus any {@link useField} options
* @returns {UseMaskedFieldReturn} The field API plus `display`, `unmasked`, `complete`, `bind`
*
* @example
* const { bind, errorMessage } = useMaskedField('phone', { mask: '+1 (###) ###-####' });
* // <input v-bind="bind"> — the form stores the raw digits
*
* @since 0.0.17
*/
export function useMaskedField<T = string>(
path: MaybeRefOrGetter<string>,
options: UseMaskedFieldOptions<T>,
): UseMaskedFieldReturn<T> {
const field = useField<T>(path, options);
const writesMasked = options.modelValue === 'masked';
const mask = useMaskedInput({
mask: options.mask,
overwriteMode: options.overwriteMode,
onAccept: ({ masked, unmasked }) => {
field.handleChange((writesMasked ? masked : unmasked) as T);
},
});
// Form → element: when the stored value changes from elsewhere (reset,
// programmatic set), re-seed and re-conform the input. Skip echoes of our own
// writes via the value we last pushed to the form.
watch(
() => field.value.value,
(raw) => {
const desired = raw === null || raw === undefined ? '' : String(raw);
const written = writesMasked ? mask.masked.value : mask.unmasked.value;
if (written === desired)
return;
mask.setValue(desired);
},
{ immediate: true, flush: 'post' },
);
const bind = computed<Record<string, unknown>>(() => ({ ...field.attrs.value, ...mask.bind }));
return {
...field,
display: mask.masked,
unmasked: mask.unmasked,
complete: mask.complete,
bind,
};
}
@@ -0,0 +1,55 @@
import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import type { MaskOptionInput, OverwriteMode } from '../mask';
import type { UseFieldOptions, UseFieldReturn } from '../useForm';
/**
* Options for {@link useMaskedField}. Extends {@link UseFieldOptions} with the
* mask configuration.
*/
export interface UseMaskedFieldOptions<T = string> extends UseFieldOptions<T> {
/**
* The mask source: a full options object, a bare mask, or a template string.
* May be reactive.
*/
mask: MaybeRefOrGetter<MaskOptionInput>;
/**
* Which view of the value is written into the form (and submitted):
* - `unmasked` — the raw value (recommended: schemas validate clean data);
* - `masked` — the formatted display string.
*
* @default 'unmasked'
*/
modelValue?: 'masked' | 'unmasked';
/**
* Insert vs overwrite behavior under a collapsed caret.
*
* @default 'shift'
*/
overwriteMode?: OverwriteMode;
}
/**
* The reactive API returned by {@link useMaskedField}: everything from
* {@link useField} plus the masking views and a single `bind` object.
*/
export interface UseMaskedFieldReturn<T = string> extends UseFieldReturn<T> {
/**
* The masked display text (read-only; the input shows it via `bind`).
*/
display: ShallowRef<string>;
/**
* The raw unmasked value (mirrors what is written to the form by default).
*/
unmasked: Readonly<ShallowRef<string>>;
/**
* Whether the mask is fully satisfied.
*/
complete: ComputedRef<boolean>;
/**
* Spread onto the input (`<input v-bind="bind">`): merges the field's
* `name`/`onBlur`/`aria-invalid` with the mask bindings (ref + input handlers).
*/
bind: ComputedRef<Record<string, unknown>>;
}
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getCountryFlagByCode } from '@robonen/platform/multi';
import { findCardBrand, findPhoneCountry, isValidCardNumber, maskCardOptions, maskDateOptions, maskNumberOptions, maskPhoneCountryOptions } from '../mask';
import { useMaskedInput } from './index';
const phone = useMaskedInput({ mask: '+1 (###) ###-####' });
const intl = useMaskedInput({ mask: maskPhoneCountryOptions() });
// Resolve the country from the typed digits (dialing code → area code → priority)
// and show its flag via the platform helper.
const intlCountry = computed(() => findPhoneCountry(intl.unmasked.value));
const intlFlag = computed(() => {
const iso2 = intlCountry.value?.iso2;
return iso2 ? getCountryFlagByCode(iso2) : '🌐';
});
const money = useMaskedInput({
mask: maskNumberOptions({ thousandSeparator: ',', precision: 2, prefix: '$' }),
});
const date = useMaskedInput({ mask: maskDateOptions({ mode: 'dd/mm/yyyy' }) });
const card = useMaskedInput({ mask: maskCardOptions() });
// The grouping (and this label) follow the detected brand: Amex is 4-6-5, etc.
const cardBrand = computed(() => findCardBrand(card.unmasked.value)?.name ?? 'unknown');
// Luhn + length validation (separate from the mask, which only formats).
const cardValid = computed(() => isValidCardNumber(card.unmasked.value));
const inputClass = 'w-full rounded-lg border border-(--border) bg-(--bg) px-3 py-2 text-sm text-(--fg) placeholder:text-(--fg-subtle) transition focus:outline-none focus:border-(--accent) focus:ring-2 focus:ring-(--ring)';
const labelClass = 'text-xs font-medium uppercase tracking-wide text-(--fg-subtle)';
const readoutClass = 'flex flex-wrap items-center gap-2 font-mono text-xs text-(--fg-muted)';
function badgeClass(on: boolean): string {
return on
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-(--border) bg-(--bg-inset) text-(--fg-subtle)';
}
</script>
<template>
<div class="flex w-full max-w-sm flex-col gap-5">
<div class="flex flex-col gap-1.5">
<label :class="labelClass">Phone</label>
<input v-bind="phone.bind" type="text" placeholder="+1 (###) ###-####" :class="inputClass">
<div :class="readoutClass">
<span>raw: "{{ phone.unmasked.value }}"</span>
<span class="inline-flex items-center rounded-md border px-1.5 py-0.5 font-medium" :class="badgeClass(phone.complete.value)">
{{ phone.complete.value ? 'complete' : 'incomplete' }}
</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label :class="labelClass">International phone (mask follows the country code)</label>
<div class="flex items-center gap-2">
<span class="text-xl leading-none" aria-hidden="true">{{ intlFlag }}</span>
<input v-bind="intl.bind" type="text" inputmode="tel" placeholder="+1 / +7 / +44 / +380 …" :class="[inputClass, 'min-w-0 flex-1']">
</div>
<div :class="readoutClass">
<span>{{ intlCountry?.name ?? 'unknown country' }}</span>
<span>raw: "{{ intl.unmasked.value }}"</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label :class="labelClass">Amount</label>
<input v-bind="money.bind" type="text" inputmode="decimal" placeholder="$0.00" :class="inputClass">
<div :class="readoutClass">
<span>raw: "{{ money.unmasked.value }}"</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label :class="labelClass">Date</label>
<input v-bind="date.bind" type="text" inputmode="numeric" placeholder="dd/mm/yyyy" :class="inputClass">
<div :class="readoutClass">
<span>raw: "{{ date.unmasked.value }}"</span>
<span class="inline-flex items-center rounded-md border px-1.5 py-0.5 font-medium" :class="badgeClass(date.complete.value)">
{{ date.complete.value ? 'complete' : 'incomplete' }}
</span>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label :class="labelClass">Card number (mask follows the brand)</label>
<input v-bind="card.bind" type="text" inputmode="numeric" placeholder="#### #### #### ####" :class="inputClass">
<div :class="readoutClass">
<span>{{ cardBrand }}</span>
<span>raw: "{{ card.unmasked.value }}"</span>
<span class="inline-flex items-center rounded-md border px-1.5 py-0.5 font-medium" :class="badgeClass(cardValid)">
{{ cardValid ? 'valid' : 'invalid' }}
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, h, nextTick } from 'vue';
import { maskNumberOptions } from '../mask';
import type { MaskOptionInput } from '../mask';
import { useMaskedInput } from './index';
import type { UseMaskedInputReturn } from './index';
function mountInput(mask: MaskOptionInput): {
api: UseMaskedInputReturn;
input: HTMLInputElement;
unmount: () => void;
} {
let api!: UseMaskedInputReturn;
const wrapper = mount(
defineComponent({
setup() {
api = useMaskedInput({ mask });
return () => h('input', api.bind as unknown as Record<string, unknown>);
},
}),
{ attachTo: document.body },
);
return { api, input: wrapper.element as HTMLInputElement, unmount: () => wrapper.unmount() };
}
describe(useMaskedInput, () => {
it('binds the element via `bind` and conforms through ensureFitsMask', () => {
const { api, input, unmount } = mountInput('+1 (###) ###-####');
input.value = '1234567890';
api.ensureFitsMask();
expect(input.value).toBe('+1 (123) 456-7890');
expect(api.unmasked.value).toBe('1234567890');
expect(api.complete.value).toBeTruthy();
unmount();
});
it('masks a real beforeinput dispatched on the bound element', () => {
const { input, unmount } = mountInput('+1 (###) ###-####');
input.focus();
input.value = '+1 (12';
input.setSelectionRange(6, 6);
const event = new InputEvent('beforeinput', { inputType: 'insertText', data: '3', cancelable: true });
input.dispatchEvent(event);
expect(input.value).toBe('+1 (123) ');
expect(event.defaultPrevented).toBeTruthy();
unmount();
});
it('re-conforms on the input event (fallback path)', () => {
const { api, input, unmount } = mountInput('+1 (###) ###-####');
input.value = '99';
input.dispatchEvent(new Event('input'));
expect(input.value).toBe('+1 (99');
expect(api.unmasked.value).toBe('99');
unmount();
});
it('sets the value programmatically via setValue', () => {
const { api, input, unmount } = mountInput('+1 (###) ###-####');
api.setValue('5551234567');
expect(input.value).toBe('+1 (555) 123-4567');
expect(api.unmasked.value).toBe('5551234567');
unmount();
});
it('reports the raw value of a number mask through onAccept-fed refs', async () => {
const { api, input, unmount } = mountInput(maskNumberOptions({ thousandSeparator: ',' }));
await nextTick();
input.value = '1234567';
input.dispatchEvent(new Event('input'));
expect(input.value).toBe('1,234,567');
expect(api.unmasked.value).toBe('1234567');
unmount();
});
});
@@ -0,0 +1,185 @@
import { computed, shallowRef, toValue, watch } from 'vue';
import { readInputState, writeInputState } from '@robonen/platform/browsers';
import type { TextFieldElement } from '@robonen/platform/browsers';
import { isMaskComplete, resolveMask } from '../mask/conform';
import {
MASK_NOOP,
MaskModel,
maskTransform,
normalizeMaskOptions,
resolveMaskOptions,
runPostprocessors,
runPreprocessors,
unmask,
} from '../mask/model';
import type { ElementState, MaskOptionInput, ResolvedMaskOptions } from '../mask/types';
import type { MaskInputBindings, UseMaskedInputOptions, UseMaskedInputReturn } from './types';
export type { MaskInputBindings, UseMaskedInputOptions, UseMaskedInputReturn } from './types';
/**
* @name useMaskedInput
* @category Forms
* @description Headless input masking. Returns a `bind` object to spread onto an
* `<input>`/`<textarea>` (`<input v-bind="bind">`) — it carries the template ref
* and the event handlers, so there is no separate ref wiring. Conforms the value
* on every keystroke (insert/delete/paste/IME) with a correct caret, and exposes
* the `masked`/`unmasked` views plus a `complete` signal.
*
* @param {UseMaskedInputOptions} options The mask and behavior
* @returns {UseMaskedInputReturn} `bind`, `masked`, `unmasked`, `complete`, `ensureFitsMask`, `setValue`
*
* @example
* const phone = useMaskedInput({ mask: '+1 (###) ###-####' });
* // <input v-bind="phone.bind">
* // phone.unmasked.value → '1234567890'
*
* @example
* const amount = useMaskedInput({
* mask: maskNumberOptions({ thousandSeparator: ',', precision: 2, prefix: '$' }),
* onAccept: ({ unmasked }) => save(unmasked),
* });
*
* @since 0.0.17
*/
export function useMaskedInput(options: UseMaskedInputOptions): UseMaskedInputReturn {
const masked = shallowRef('');
const unmasked = shallowRef('');
const element = shallowRef<TextFieldElement | null>(null);
// Memoize the resolved options on the mask source: currentOptions() runs on
// every event/handler and every `complete` read, but the result is a pure
// function of the (referentially stable) mask source + overwriteMode.
let cachedSource: MaskOptionInput | undefined;
let cachedResolved: ResolvedMaskOptions | undefined;
function currentOptions(): ResolvedMaskOptions {
const source = toValue(options.mask);
if (source !== cachedSource || cachedResolved === undefined) {
cachedSource = source;
const base = normalizeMaskOptions(source);
cachedResolved = resolveMaskOptions({
...base,
overwriteMode: options.overwriteMode ?? base.overwriteMode,
});
}
return cachedResolved;
}
const complete = computed<boolean>(() => {
const value = masked.value;
const mask = resolveMask(currentOptions().mask, { value, selection: [value.length, value.length] });
return isMaskComplete(value, mask);
});
function commit(el: TextFieldElement, state: ElementState, options_: ResolvedMaskOptions): void {
writeInputState(el, state);
if (masked.value === el.value)
return;
masked.value = el.value;
unmasked.value = unmask(el.value, options_);
options.onAccept?.({ masked: masked.value, unmasked: unmasked.value });
}
function ensureFitsMask(): void {
const el = element.value;
if (!el)
return;
const options_ = currentOptions();
commit(el, maskTransform(readInputState(el), options_), options_);
}
function setValue(value: string): void {
const options_ = currentOptions();
const next = maskTransform({ value, selection: [value.length, value.length] }, options_);
const el = element.value;
if (el) {
commit(el, next, options_);
return;
}
// No element bound yet — keep the exposed refs in sync anyway.
if (masked.value !== next.value) {
masked.value = next.value;
unmasked.value = unmask(next.value, options_);
options.onAccept?.({ masked: masked.value, unmasked: unmasked.value });
}
}
function onBeforeinput(event: InputEvent): void {
const el = element.value;
if (!el)
return;
const { inputType, data } = event;
const options_ = currentOptions();
const before = readInputState(el);
try {
let next: ElementState;
if (inputType.startsWith('insert') && data !== null) {
const pre = runPreprocessors(options_.preprocessors, before, data, 'insert');
const model = new MaskModel(pre.elementState, options_);
model.addCharacters(pre.data);
next = runPostprocessors(options_.postprocessors, { value: model.value, selection: model.selection }, before);
}
else if (inputType.startsWith('delete')) {
const isForward = inputType.toLowerCase().includes('forward');
const pre = runPreprocessors(options_.preprocessors, before, '', isForward ? 'deleteForward' : 'deleteBackward');
const model = new MaskModel(pre.elementState, options_);
model.deleteCharacters(isForward);
next = runPostprocessors(options_.postprocessors, { value: model.value, selection: model.selection }, before);
}
else {
// Exotic input types (word delete, replacement, history) and composition
// fall through to the `input` handler's full re-conform.
return;
}
event.preventDefault();
commit(el, next, options_);
}
catch (error) {
if (error === MASK_NOOP) {
event.preventDefault();
return;
}
throw error;
}
}
function onInput(): void {
const el = element.value;
// Re-entrancy guard: skip when the value already matches what we produced.
if (!el || el.value === masked.value)
return;
ensureFitsMask();
}
const bind: MaskInputBindings = {
ref: (el) => {
element.value = (el as TextFieldElement | null) ?? null;
},
onInput,
onBeforeinput,
onCompositionend: ensureFitsMask,
};
// Initial calibration + re-conform when the element binds or the mask changes.
watch(
() => [element.value, toValue(options.mask)] as const,
([el]) => {
if (el && options.initialCalibration !== false)
ensureFitsMask();
},
{ immediate: true, flush: 'post' },
);
return { masked, unmasked, complete, bind, ensureFitsMask, setValue };
}
@@ -0,0 +1,88 @@
import type { ComputedRef, MaybeRefOrGetter, ShallowRef } from 'vue';
import type { MaskOptionInput, OverwriteMode } from '../mask';
/**
* Options for {@link useMaskedInput}.
*/
export interface UseMaskedInputOptions {
/**
* The mask source: a full options object, a bare mask expression, or a
* template string (e.g. `'+1 (###) ###-####'`). May be reactive.
*/
mask: MaybeRefOrGetter<MaskOptionInput>;
/**
* Insert vs overwrite behavior under a collapsed caret. Overrides any
* `overwriteMode` carried by the resolved mask options.
*
* @default 'shift'
*/
overwriteMode?: OverwriteMode;
/**
* Conform the element's current value once, as soon as it is bound.
*
* @default true
*/
initialCalibration?: boolean;
/**
* Called after each accepted change with both views of the value. The
* one-liner bridge for feeding the raw value into a form.
*/
onAccept?: (state: { masked: string; unmasked: string }) => void;
}
/**
* Bindings to spread onto the `<input>`/`<textarea>` via `v-bind`. Contains the
* template-ref callback that attaches the element plus the event handlers that
* drive masking — no separate `ref` wiring needed.
*/
export interface MaskInputBindings {
/**
* Template-ref callback — attaches the element (`v-bind` sets it for you).
*/
ref: (element: Element | null) => void;
/**
* `input` handler — re-conforms after exotic/composition input types.
*/
onInput: () => void;
/**
* `beforeinput` handler — the insert/delete masking pipeline.
*/
onBeforeinput: (event: InputEvent) => void;
/**
* `compositionend` handler — re-conforms after IME composition.
*/
onCompositionend: () => void;
}
/**
* The reactive API returned by {@link useMaskedInput}.
*/
export interface UseMaskedInputReturn {
/**
* The conformed, displayed value (kept in sync with the element).
*/
masked: ShallowRef<string>;
/**
* The raw value with fixed mask characters (and preset separators) stripped.
*/
unmasked: Readonly<ShallowRef<string>>;
/**
* Whether `masked` fully satisfies the mask.
*/
complete: ComputedRef<boolean>;
/**
* Spread onto the input element: `<input v-bind="bind">`.
*/
bind: MaskInputBindings;
/**
* Re-conform the element's current value (e.g. after a programmatic set).
*/
ensureFitsMask: () => void;
/**
* Set the value programmatically — treated as raw input and conformed.
*/
setValue: (value: string) => void;
}