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