feat(forms): add useMaskedField and useMaskedInput composables for input masking
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user