@signalapp/minimask
Version:
Simple HTML input masking
1 lines • 9.48 kB
Source Map (JSON)
{"version":3,"sources":["../src/minimask.ts","../src/getNextInputState.ts","../src/formatters/cc-exp.ts"],"sourcesContent":["// Copyright 2025 Signal Messenger, LLC\n// SPDX-License-Identifier: AGPL-3.0-only\nimport { getNextInputState } from \"./getNextInputState\"\n\nexport { createCreditCardExpirationFormatter } from \"./formatters/cc-exp\"\n\nexport type FormatterToken = Readonly<{\n\t/**\n\t * This should be a single character\n\t */\n\tchar: string\n\t/**\n\t * The index of the `char` in the original string. Two tokens can share the\n\t * same index, but they should always be in order.\n\t */\n\tindex: number\n\t/**\n\t * If `mask` is true, the `char` will be dropped from the unformatted string.\n\t */\n\tmask: boolean\n}>\n\n/**\n * A generator function or a function returning an iterable that returns a\n * sequence of tokens representing the formatted string.\n */\nexport type Formatter = (input: string) => Iterable<FormatterToken>\n\nlet DELETE_BACKWARD = new Set([\n\t\"deleteContentBackward\",\n\t\"deleteWordBackward\",\n\t\"deleteSoftLineBackward\",\n\t\"deleteHardLineBackward\",\n])\n\n/**\n * Bind to an input element, masking the value of it.\n */\nexport function minimask(input: HTMLInputElement, formatter: Formatter) {\n\t// This should never be completely empty, the historyIndex should always\n\t// point to the current value\n\tlet history: string[] = [input.value]\n\tlet historyIndex = 0\n\n\tlet onInput = (event: InputEvent) => {\n\t\tconst inputType = event.inputType\n\n\t\tif (inputType === \"historyUndo\") {\n\t\t\t// Move the historyIndex back, but retain items to redo later\n\t\t\thistoryIndex = Math.max(historyIndex - 1, 0)\n\t\t\tinput.value = history[historyIndex]!\n\t\t} else if (inputType === \"historyRedo\") {\n\t\t\t// Move the historyIndex forwards\n\t\t\thistoryIndex = Math.min(historyIndex + 1, history.length - 1)\n\t\t\tinput.value = history[historyIndex]!\n\t\t} else {\n\t\t\tlet isDeleting = DELETE_BACKWARD.has(inputType)\n\t\t\tlet { value, start, end } = getNextInputState(\n\t\t\t\tformatter,\n\t\t\t\tinput.value,\n\t\t\t\tinput.selectionStart ?? 0,\n\t\t\t\tinput.selectionEnd ?? 0,\n\t\t\t\tisDeleting,\n\t\t\t)\n\t\t\tinput.value = value\n\t\t\tinput.setSelectionRange(start, end)\n\n\t\t\t// If the input has changed:\n\t\t\tif (value !== history[historyIndex]) {\n\t\t\t\t// Move the historyIndex forwards\n\t\t\t\thistoryIndex++\n\t\t\t\t// Truncate the redos from our current position, and add our new state to the end\n\t\t\t\thistory.splice(historyIndex, Infinity, input.value)\n\t\t\t}\n\t\t}\n\t}\n\n\tinput.addEventListener(\"input\", onInput as (event: Event) => void)\n\n\treturn function unsubscribe(): void {\n\t\tinput.removeEventListener(\"input\", onInput as (event: Event) => void)\n\t}\n}\n","// Copyright 2025 Signal Messenger, LLC\n// SPDX-License-Identifier: AGPL-3.0-only\nimport type { Formatter } from \"./minimask\"\n\nexport type InputState = Readonly<{\n\tvalue: string\n\tstart: number\n\tend: number\n}>\n\n/**\n * Use the formatter to print the next value of the input, and compute the\n * next selection range using the indexes in the tokens.\n */\nexport function getNextInputState(\n\tformatter: Formatter,\n\tprevValue: string,\n\tprevStart: number,\n\tprevEnd: number,\n\tisDeleting: boolean,\n): InputState {\n\tlet value = \"\"\n\tlet start = null\n\tlet end = null\n\tlet cursor = 0\n\tlet lastIndex = 0\n\n\tfor (let token of formatter(prevValue)) {\n\t\t// Only include mask chars if requested\n\t\tvalue += token.char\n\n\t\t// If deleting backwards, place the cursor before any mask chars\n\t\tif (isDeleting && token.mask) continue\n\n\t\t// We may have skipped some indexes\n\t\twhile (cursor <= token.index) {\n\t\t\tif (cursor === prevStart) start ??= lastIndex\n\t\t\tif (cursor === prevEnd) end ??= lastIndex\n\t\t\tcursor += 1\n\t\t}\n\n\t\t// Last index will skip mask chars when deleting\n\t\tlastIndex = value.length\n\t}\n\n\t// If deleting from the end of the input, drop any trailing mask chars\n\tif (isDeleting && end === null) {\n\t\tvalue = value.slice(0, lastIndex)\n\t}\n\n\t// If we never found our selection, it must have been after the last token\n\tstart ??= value.length\n\tend ??= value.length\n\n\treturn { value, start, end }\n}\n","// Copyright 2025 Signal Messenger, LLC\n// SPDX-License-Identifier: AGPL-3.0-only\nimport type { Formatter, FormatterToken } from \"../minimask\"\n\nfunction isDigit(char: string): boolean {\n\treturn /\\d/.test(char)\n}\n\nexport function createCreditCardExpirationFormatter(): Formatter {\n\treturn function creditCardExpirationFormatter(input: string) {\n\t\tlet chars = input.split(\"\")\n\t\tlet index = 0\n\t\tlet char = chars[index]\n\n\t\tfunction next() {\n\t\t\tchar = chars[++index]\n\t\t}\n\n\t\tfunction take(): FormatterToken {\n\t\t\tif (char == null) throw new Error()\n\t\t\tlet token: FormatterToken = { char, index, mask: false }\n\t\t\tnext()\n\t\t\treturn token\n\t\t}\n\n\t\tif (char == \"/\") {\n\t\t\treturn []\n\t\t}\n\n\t\twhile (char != null && !isDigit(char)) {\n\t\t\tnext()\n\t\t}\n\n\t\tif (char == null) {\n\t\t\treturn []\n\t\t}\n\n\t\tlet month1: FormatterToken | null = take()\n\t\tlet month2: FormatterToken | null\n\n\t\tif (month1.char === \"0\") {\n\t\t\tif (char == null || !isDigit(char) || char === \"0\") {\n\t\t\t\treturn [month1]\n\t\t\t} else {\n\t\t\t\tmonth2 = take()\n\t\t\t}\n\t\t} else if (month1.char === \"1\") {\n\t\t\tif (char == null) {\n\t\t\t\treturn [month1]\n\t\t\t} else if (char === \"0\" || char === \"1\" || char === \"2\") {\n\t\t\t\tmonth2 = take()\n\t\t\t} else {\n\t\t\t\tmonth2 = month1\n\t\t\t\tmonth1 = null\n\t\t\t}\n\t\t} else {\n\t\t\tmonth2 = month1\n\t\t\tmonth1 = null\n\t\t}\n\n\t\tlet slashIndex: number | null = null\n\t\tif (char != null && !isDigit(char)) {\n\t\t\tslashIndex = index\n\t\t} else {\n\t\t\tslashIndex = month2.index\n\t\t\tmonth1 ??= { char: \"0\", index: month2.index, mask: false }\n\t\t}\n\n\t\tlet year = []\n\n\t\twhile (char != null) {\n\t\t\tif (isDigit(char)) {\n\t\t\t\tyear.push(take())\n\t\t\t\tif (year.length >= 4) {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tnext()\n\t\t\t}\n\t\t}\n\n\t\tif (year.length >= 4) {\n\t\t\tyear = year.slice(2, 4)\n\t\t} else {\n\t\t\tyear = year.slice(0, 2)\n\t\t}\n\n\t\tlet tokens: Array<FormatterToken> = []\n\t\tif (month1 != null) tokens.push(month1)\n\t\tif (month2 != null) tokens.push(month2)\n\t\tif (month2 != null) {\n\t\t\ttokens.push({ char: \"/\", index: slashIndex, mask: true })\n\t\t}\n\t\treturn tokens.concat(year)\n\t}\n}\n"],"mappings":"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","names":[]}