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; }