feat(forms): add useMaskedField and useMaskedInput composables for input masking

This commit is contained in:
2026-06-09 13:54:52 +07:00
parent 6de7c72fb3
commit 07937e26db
426 changed files with 12981 additions and 311 deletions
@@ -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;
}