Fixes and prepare to publish

This commit is contained in:
Jamie Kyle 2025-07-14 15:32:07 -07:00
parent d9ae312547
commit 58bb757d22
10 changed files with 387 additions and 510 deletions

View File

@ -65,3 +65,9 @@ let input = document.querySelector("#creditCardNumber")
minimask(input, formatter)
```
## License
Copyright 2025 Signal Messenger, LLC
Licensed under the [AGPLv3](LICENSE)

View File

@ -1,5 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// @ts-check
import eslint from "@eslint/js"
import tseslint from "typescript-eslint"

View File

@ -1,3 +1,8 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";

View File

@ -1,3 +1,5 @@
<!-- Copyright 2025 Signal Messenger, LLC -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<!doctype html>
<html lang="en">
<head>

View File

@ -1,12 +1,10 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { minimask, type Formatter, type FormatterToken } from "../src/minimask"
import { createCreditCardExpirationFormatter } from "../src/formatters/cc-exp"
import creditCardType from "credit-card-type"
let CC_EXP_FORMATTER = createCreditCardExpirationFormatter({
zero: "optional",
slash: "eager",
month: "loose",
})
let CC_EXP_FORMATTER = createCreditCardExpirationFormatter()
let CC_NUMBER_FORMATTER: Formatter = (input) => {
let [cardType] = creditCardType(input)

View File

@ -1,197 +1,98 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Formatter, FormatterToken } from "../minimask"
function isDigit(char: string) {
function isDigit(char: string): boolean {
return /\d/.test(char)
}
function isMonth(input: string) {
const number = Number(input)
return number >= 1 && number <= 12
}
export function createCreditCardExpirationFormatter(): Formatter {
return function creditCardExpirationFormatter(input: string) {
let chars = input.split("")
let index = 0
let char = chars[index]
export type Options = Readonly<{
/**
* required:
* - `02/12`
* optional:
* - `2/12` or `02/12`
*/
zero?: "required" | "optional"
/**
* eager: Immediately insert a `/` as soon as we know we have a valid month (01-12)
* - `1` -> `1`
* - `2` -> `02/` (eager)
* - `12` -> `12/` (eager)
* - `123` -> `12/3`
* lazy: Wait to insert a `/` until we have our first year digit
* - `2` -> `2` (lazy)
* - `12` -> `12` (lazy)
* - `123` -> `12/3`
*/
slash?: "eager" | "lazy"
/**
* strict: Never allow the user to create a month that's not 1-12 (default)
* - `13` -> `01/3`
* - `13/45` -> `01/34` (strict)
* loose: Allow months 13-99 if immediately followed by a `/`
* - `13` -> `01/3`
* - `13/45` -> `13/45` (loose)
*
* Forcing months to be `strict` will make the input a little bit aggressive about
* reformatting the date (inserting zero, moving the slash around) when going back
* and editing the month, but `loose` will required you to validate the input and
* give the user feedback.
*/
month?: "strict" | "loose"
}>
export function createCreditCardExpirationFormatter(options: Options = {}): Formatter {
let zeroMode = options.zero ?? "required"
let slashMode = options.slash ?? "eager"
let monthMode = options.month ?? "strict"
return (input: string) => {
let cursor = 0
// Helper so we can use simple for-of loops to scan ahead
function* scan() {
while (cursor < input.length) {
let index = cursor
let char = input[index]!
yield [index, char] as const
if (cursor === index) {
throw new Error("cursor was not updated for next loop")
}
}
function next() {
char = chars[++index]
}
// Find the first digit in the input
let digit1: FormatterToken | null = null
for (let [index, char] of scan()) {
if (isDigit(char)) {
digit1 = { char, index, mask: false }
cursor++ // take
break
}
cursor++ // skip
function take(): FormatterToken {
if (char == null) throw new Error()
let token: FormatterToken = { char, index, mask: false }
next()
return token
}
// Return an empty input early if no digits
if (digit1 == null) {
if (char == "/") {
return []
}
// Note: A single digit month is shifted right to `month2` because we need
// to make room for a zero.
let month1: FormatterToken | null = digit1
let month2: FormatterToken | null = null
while (char != null && !isDigit(char)) {
next()
}
if (char == null) {
return []
}
let month1: FormatterToken | null = take()
let month2: FormatterToken | null
if (month1.char === "0") {
for (let [index, char] of scan()) {
// Skip any additional non-digits/zeroes until we reach another digit 1-9
if (isDigit(char) && char !== "0") {
month2 = { char, index, mask: false }
cursor++ // take
if (char == null || !isDigit(char) || char === "0") {
return [month1]
} else {
month2 = take()
}
} else if (month1.char === "1") {
if (char == null) {
return [month1]
} else if (char === "0" || char === "1" || char === "2") {
month2 = take()
} else {
month2 = month1
month1 = null
}
} else {
month2 = month1
month1 = null
}
let slashIndex: number | null = null
if (char != null && !isDigit(char)) {
slashIndex = index
} else {
slashIndex = month2.index
month1 ??= { char: "0", index: month2.index, mask: false }
}
let year = []
while (char != null) {
if (isDigit(char)) {
year.push(take())
if (year.length >= 4) {
break
}
cursor++ // skip
}
} else {
for (let [index, char] of scan()) {
if (isDigit(char)) {
// If the next digit forms a month, immediately take it and move on
if (isMonth(`${digit1.char}${char}`)) {
month2 = { char, index, mask: false }
cursor++ // take
break
}
// In loose mode, 13-99 are treated as months if they are immediately
// followed by a slash. This makes the input thrash less when editing
// the month
if (monthMode === "loose") {
const next = input[cursor + 1]
if (next != null && !isDigit(next)) {
month2 = { char, index, mask: false }
cursor++ // take
break
}
}
}
// Fallback: Make room for zero
month2 = month1
month1 = null
break
}
// If we never found another digit, move 2-9 to `month2`
if (month2 == null && digit1.char !== "1") {
month2 = month1
month1 = null
}
}
// If we have a second month char, eagerly append a slash
let slash: FormatterToken | null = null
if (month2 != null) {
for (let [index, char] of scan()) {
// Try to use an existing non-digit if it exists
if (!isDigit(char)) {
slash = { char: "/", index, mask: true }
cursor++ // take
}
break
}
}
let year: FormatterToken[] = []
// Take up to the next 4 digits as year chars
for (let [index, char] of scan()) {
if (isDigit(char)) {
year.push({ char, index, mask: false })
cursor++ // take
if (year.length >= 4) break
} else {
cursor++ // skip
next()
}
}
// If we have a 4-digit year (could be pasted) take the last two digits,
// otherwise force the user to type only the first two digits
if (year.length === 4) {
year = year.slice(2) // take the last two YYYY chars
console.log(year)
if (year.length >= 4) {
year = year.slice(2, 4)
} else {
year = year.slice(0, 2) // take the first two YY chars
year = year.slice(0, 2)
}
// This is where we decide if we want to inject a `/` and/or a leading zero
// depending on if we have our "2nd" month digit yet (which could be a
// single digit month 2-9)
let tokens: Array<FormatterToken> = []
if (month1 != null) tokens.push(month1)
if (month2 != null) tokens.push(month2)
if (month2 != null) {
// slash=eager: Always insert a slash if we have a 2nd month digit
// slash=lazy: Only insert a slash if we have some year digits
if (slashMode === "eager" || (slashMode === "lazy" && year.length > 0)) {
slash ??= { char: "/", index: month2.index, mask: true }
}
// zero=required: Only insert a zero...
// - slash=lazy: Always
// - slash=eager: If the slash was inserted
// zero=optional: Never, only use a zero if the user typed it
if (zeroMode === "required" && (slashMode === "lazy" || slash != null)) {
month1 ??= { char: "0", index: month2.index, mask: false }
}
tokens.push({ char: "/", index: slashIndex, mask: true })
}
// Build the final set of tokens
let results: FormatterToken[] = []
if (month1 != null) results.push(month1)
if (month2 != null) results.push(month2)
if (slash != null) results.push(slash)
return results.concat(year)
return tokens.concat(year)
}
}

View File

@ -1,3 +1,5 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Formatter } from "./minimask"
export type InputState = Readonly<{
@ -12,30 +14,43 @@ export type InputState = Readonly<{
*/
export function getNextInputState(
formatter: Formatter,
includeMask: boolean,
prevValue: string,
prevStart: number,
prevEnd: number,
isDeleting: boolean,
): InputState {
let value = ""
let start = null
let end = null
let cursor = 0
let lastIndex = 0
for (let token of formatter(prevValue)) {
let index = value.length
// Only include mask chars if requested
if (!token.mask || includeMask) {
value += token.char
}
value += token.char
// If deleting backwards, place the cursor before any mask chars
if (isDeleting && token.mask) continue
// We may have skipped some indexes
while (cursor <= token.index) {
if (cursor === prevStart) start ??= index
if (cursor === prevEnd) end ??= index
if (cursor === prevStart) start ??= lastIndex
if (cursor === prevEnd) end ??= lastIndex
cursor += 1
}
// Last index will skip mask chars when deleting
lastIndex = value.length
}
// If deleting from the end of the input, drop any trailing mask chars
if (isDeleting && end === null) {
value = value.slice(0, lastIndex)
}
// If we never found our selection, it must have been after the last token
start ??= value.length
end ??= value.length
return { value, start, end }
}

View File

@ -1,9 +1,11 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { getNextInputState } from "./getNextInputState"
export type FormatterToken = Readonly<{
/**
* This should be a single character
*/
* This should be a single character
*/
char: string
/**
* The index of the `char` in the original string. Two tokens can share the
@ -48,68 +50,47 @@ export function unformat(formatter: Formatter, input: string): string {
return result
}
let DELETE_BACKWARD = new Set(["deleteContentBackward", "deleteWordBackward", "deleteSoftLineBackward", "deleteHardLineBackward"])
let DELETE_FORWARD = new Set(["deleteContentForward", "deleteWordForward", "deleteSoftLineForward", "deleteHardLineForward"])
function shouldUnformat(input: HTMLInputElement, inputType: string) {
return (
// If the user has selected a range, don't bother unformatting the input
// because they've been specific about what they are trying to edit, we'll
// re-format it later
input.selectionStart === input.selectionEnd &&
(
// Can't delete backwards if we're already at the start
(DELETE_BACKWARD.has(inputType) && input.selectionEnd !== 0) ||
// Can't delete forwards if we're already at the end
(DELETE_FORWARD.has(inputType) && input.selectionStart !== input.value.length)
)
)
}
let DELETE_BACKWARD = new Set([
"deleteContentBackward",
"deleteWordBackward",
"deleteSoftLineBackward",
"deleteHardLineBackward",
])
/**
* Bind to an input element, masking the value of it.
*/
export function minimask(input: HTMLInputElement, formatter: Formatter) {
let update = (includeMask: boolean) => {
let { value, start, end } = getNextInputState(
formatter,
includeMask,
input.value,
input.selectionStart ?? 0,
input.selectionEnd ?? 0,
)
input.value = value
input.setSelectionRange(start, end)
}
// This should never be completely empty, the historyIndex should always
// point to the current value
let history: string[] = [input.value]
let historyIndex = 0
let onBeforeInput = (event: InputEvent) => {
// Avoid unformatting the input if its not going to result in any change
// since it won't fire an `input` event if it doesn't change.
//
// Really this is only needed when hitting Delete/Backspace s
if (shouldUnformat(input, event.inputType)) {
update(false)
}
}
let onInput = (event: InputEvent) => {
if (event.inputType === "historyUndo") {
const inputType = event.inputType
if (inputType === "historyUndo") {
// Move the historyIndex back, but retain items to redo later
historyIndex = Math.max(historyIndex - 1, 0)
input.value = history[historyIndex]!
} else if (event.inputType === "historyRedo") {
} else if (inputType === "historyRedo") {
// Move the historyIndex forwards
historyIndex = Math.min(historyIndex + 1, history.length - 1)
input.value = history[historyIndex]!
} else {
update(true)
let isDeleting = DELETE_BACKWARD.has(inputType)
let { value, start, end } = getNextInputState(
formatter,
input.value,
input.selectionStart ?? 0,
input.selectionEnd ?? 0,
isDeleting,
)
input.value = value
input.setSelectionRange(start, end)
// If the input has changed:
if (input.value !== history[historyIndex]) {
if (value !== history[historyIndex]) {
// Move the historyIndex forwards
historyIndex++
// Truncate the redos from our current position, and add our new state to the end
@ -118,13 +99,9 @@ export function minimask(input: HTMLInputElement, formatter: Formatter) {
}
}
// Fires any time the input *might* be about to change
input.addEventListener("beforeinput", onBeforeInput)
// Fires any time the input *did* change (not guaranteed every time beforeinput fires)
input.addEventListener("input", onInput as (event: Event) => void)
return function unsubscribe(): void {
input.removeEventListener("beforeinput", onBeforeInput)
input.removeEventListener("input", onInput as (event: Event) => void)
}
}

View File

@ -1,9 +1,10 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { suite, test, expect } from "vitest"
import { createCreditCardExpirationFormatter } from "../../src/formatters/cc-exp"
import type { Options } from "../../src/formatters/cc-exp"
function setup(options: Options = {}) {
let formatter = createCreditCardExpirationFormatter(options)
function setup() {
let formatter = createCreditCardExpirationFormatter()
return function check(input: string, expectedResult: string, expectedIndexes: number[]) {
test(`${input} -> ${expectedResult} (${expectedIndexes.join(",")})`, () => {
let tokens = Array.from(formatter(input))
@ -63,15 +64,17 @@ suite("CC_EXP_FORMATTER", () => {
check("19", "01/9", [0, 0, 0, 1])
})
})
suite("leading non-digits: drop", () => {
suite("leading non-digits/non-slash: drop", () => {
check(" ", "", [])
check("/", "", [])
check("%", "", [])
check("!@#$%", "", [])
check(" 1", "1", [1])
check("/1", "1", [1])
check("!@#$%1", "1", [5])
})
suite("leading slash: drop all chars", () => {
check("/1", "", [])
})
})
suite("month/year splitter", () => {
suite("0/ -> 0: drop slash", () => {
@ -88,16 +91,16 @@ suite("CC_EXP_FORMATTER", () => {
check("08/", "08/", [0, 1, 2])
check("09/", "09/", [0, 1, 2])
})
suite("1/-9/ -> 01/-09/: prefix with 0", () => {
check("1/", "01/", [0, 0, 1])
check("2/", "02/", [0, 0, 1])
check("3/", "03/", [0, 0, 1])
check("4/", "04/", [0, 0, 1])
check("5/", "05/", [0, 0, 1])
check("6/", "06/", [0, 0, 1])
check("7/", "07/", [0, 0, 1])
check("8/", "08/", [0, 0, 1])
check("9/", "09/", [0, 0, 1])
suite("1/-9/ -> 1/-9/: don't prefix with 0", () => {
check("1/", "1/", [0, 1])
check("2/", "2/", [0, 1])
check("3/", "3/", [0, 1])
check("4/", "4/", [0, 1])
check("5/", "5/", [0, 1])
check("6/", "6/", [0, 1])
check("7/", "7/", [0, 1])
check("8/", "8/", [0, 1])
check("9/", "9/", [0, 1])
})
suite("10-12/: unchanged", () => {
check("10/", "10/", [0, 1, 2])
@ -154,10 +157,10 @@ suite("CC_EXP_FORMATTER", () => {
suite("000 -> 0: drop extra zeroes", () => {
check("000", "0", [0])
})
suite("001-009 -> 01/-09/: drop extra zeroes, insert slash", () => {
check("001", "01/", [0, 2, 2])
check("002", "02/", [0, 2, 2])
check("009", "09/", [0, 2, 2])
suite("001-009 -> 0: drop everything on second zero", () => {
check("001", "0", [0])
check("002", "0", [0])
check("009", "0", [0])
})
suite("01Y-09Y -> 01/Y-09/Y: insert slash", () => {
check("010", "01/0", [0, 1, 1, 2])
@ -190,15 +193,15 @@ suite("CC_EXP_FORMATTER", () => {
suite("0000 -> 0: drop extra zeroes", () => {
check("0000", "0", [0])
})
suite("0001-0009 -> 01/-09/: drop extra zeroes, insert slash", () => {
check("0001", "01/", [0, 3, 3])
check("0002", "02/", [0, 3, 3])
check("0009", "09/", [0, 3, 3])
suite("0001-0009 -> 0: drop everything on second zero", () => {
check("0001", "0", [0])
check("0002", "0", [0])
check("0009", "0", [0])
})
suite("001Y-009Y -> 01/Y-09/Y: drop extra zero, insert slash", () => {
check("0010", "01/0", [0, 2, 2, 3])
check("0012", "01/2", [0, 2, 2, 3])
check("0099", "09/9", [0, 2, 2, 3])
suite("001*-009* -> 0: drop everything on second zero", () => {
check("0010", "0", [0])
check("0012", "0", [0])
check("0099", "0", [0])
})
suite("01YY-09YY -> 01/YY-09/YY: insert slash", () => {
check("0100", "01/00", [0, 1, 1, 2, 3])
@ -231,20 +234,20 @@ suite("CC_EXP_FORMATTER", () => {
suite("00000 -> 0: drop extra zeroes", () => {
check("00000", "0", [0])
})
suite("00001-00009 -> 01/-09/: drop extra zeroes, insert slash", () => {
check("00001", "01/", [0, 4, 4])
check("00002", "02/", [0, 4, 4])
check("00009", "09/", [0, 4, 4])
suite("00001-00009 -> 0: drop everything on second zero", () => {
check("00001", "0", [0])
check("00002", "0", [0])
check("00009", "0", [0])
})
suite("0001Y-0009Y -> 01/Y-09/Y: drop extra zeroes, insert slash", () => {
check("00010", "01/0", [0, 3, 3, 4])
check("00012", "01/2", [0, 3, 3, 4])
check("00099", "09/9", [0, 3, 3, 4])
suite("0001Y-0009Y -> 0: drop everything on second zero", () => {
check("00010", "0", [0])
check("00012", "0", [0])
check("00099", "0", [0])
})
suite("001YY-009YY -> 01/YY-09/YY: drop extra zero, insert slash", () => {
check("00100", "01/00", [0, 2, 2, 3, 4])
check("00123", "01/23", [0, 2, 2, 3, 4])
check("00999", "09/99", [0, 2, 2, 3, 4])
suite("001YY-009YY -> 0: drop everything on second zero", () => {
check("00100", "0", [0])
check("00123", "0", [0])
check("00999", "0", [0])
})
suite("01YY*-09YY* -> 01/YY-09/YY: insert slash, drop extra YY digit", () => {
check("01000", "01/00", [0, 1, 1, 2, 3])
@ -277,25 +280,25 @@ suite("CC_EXP_FORMATTER", () => {
suite("000000 -> 0: drop extra zeroes", () => {
check("000000", "0", [0])
})
suite("000001-000009 -> 01/-09/: drop extra zeroes, insert slash", () => {
check("000001", "01/", [0, 5, 5])
check("000002", "02/", [0, 5, 5])
check("000009", "09/", [0, 5, 5])
suite("000001-000009 -> 0: drop everything on second zero", () => {
check("000001", "0", [0])
check("000002", "0", [0])
check("000009", "0", [0])
})
suite("00001*-00009* -> 01/Y-09/Y: drop extra zeroes, insert slash", () => {
check("000010", "01/0", [0, 4, 4, 5])
check("000012", "01/2", [0, 4, 4, 5])
check("000099", "09/9", [0, 4, 4, 5])
suite("00001*-00009* -> 0: drop everything on second zero", () => {
check("000010", "0", [0])
check("000012", "0", [0])
check("000099", "0", [0])
})
suite("0001YY-0009YY -> 01/YY-09/YY: drop extra zeroes, insert slash", () => {
check("000100", "01/00", [0, 3, 3, 4, 5])
check("000123", "01/23", [0, 3, 3, 4, 5])
check("000999", "09/99", [0, 3, 3, 4, 5])
suite("0001YY-0009YY -> 0: drop everything on second zero", () => {
check("000100", "0", [0])
check("000123", "0", [0])
check("000999", "0", [0])
})
suite("001YY*-009YY* -> 01/YY-09/YY: drop extra zeroes, insert slash, drop extra YY digit", () => {
check("001000", "01/00", [0, 2, 2, 3, 4])
check("001234", "01/23", [0, 2, 2, 3, 4])
check("009999", "09/99", [0, 2, 2, 3, 4])
suite("001YY*-009YY* -> 0: drop everything on second zero", () => {
check("001000", "0", [0])
check("001234", "0", [0])
check("009999", "0", [0])
})
suite("01**YY-09**YY -> 01/YY-09/YY: insert slash, drop extra YYYY digits", () => {
check("010000", "01/00", [0, 1, 1, 4, 5])
@ -328,30 +331,30 @@ suite("CC_EXP_FORMATTER", () => {
suite("0000000 -> 0: drop extra zeroes", () => {
check("0000000", "0", [0])
})
suite("0000001-0000009 -> 01/-09/: drop extra zeroes, insert slash", () => {
check("0000001", "01/", [0, 6, 6])
check("0000002", "02/", [0, 6, 6])
check("0000009", "09/", [0, 6, 6])
suite("0000001-0000009 -> 0: drop everything on second zero", () => {
check("0000001", "0", [0])
check("0000002", "0", [0])
check("0000009", "0", [0])
})
suite("000001*-000009* -> 01/Y-09/Y: drop extra zeroes, insert slash", () => {
check("0000010", "01/0", [0, 5, 5, 6])
check("0000012", "01/2", [0, 5, 5, 6])
check("0000099", "09/9", [0, 5, 5, 6])
suite("000001*-000009* -> 0: drop everything on second zero", () => {
check("0000010", "0", [0])
check("0000012", "0", [0])
check("0000099", "0", [0])
})
suite("00001YY-00009YY -> 01/YY-09/YY: drop extra zeroes, insert slash", () => {
check("0000100", "01/00", [0, 4, 4, 5, 6])
check("0000123", "01/23", [0, 4, 4, 5, 6])
check("0000999", "09/99", [0, 4, 4, 5, 6])
suite("00001YY-00009YY -> 0: drop everything on second zero", () => {
check("0000100", "0", [0])
check("0000123", "0", [0])
check("0000999", "0", [0])
})
suite("0001YY*-0009YY* -> 01/YY-09/YY: drop extra zeroes, insert slash, drop extra YY digit", () => {
check("0001000", "01/00", [0, 3, 3, 4, 5])
check("0001234", "01/23", [0, 3, 3, 4, 5])
check("0009999", "09/99", [0, 3, 3, 4, 5])
suite("0001YY*-0009YY* -> 0: drop everything on second zero", () => {
check("0001000", "0", [0])
check("0001234", "0", [0])
check("0009999", "0", [0])
})
suite("001**YY-009**YY -> 01/YY-09/YY: drop extra zeroes, insert slash, drop extra YYYY digits", () => {
check("0010000", "01/00", [0, 2, 2, 5, 6])
check("0012345", "01/45", [0, 2, 2, 5, 6])
check("0099999", "09/99", [0, 2, 2, 5, 6])
suite("001**YY-009**YY -> 0: drop everything on second zero", () => {
check("0010000", "0", [0])
check("0012345", "0", [0])
check("0099999", "0", [0])
})
suite("01**YY*-09**YY* -> 01/YY-09/YY: insert slash, drop extra YYYY digits", () => {
check("0100000", "01/00", [0, 1, 1, 4, 5])
@ -381,90 +384,4 @@ suite("CC_EXP_FORMATTER", () => {
})
})
})
suite("options", () => {
suite("zero:required", () => {
const check = setup({ zero: "required" })
check("0", "0", [0])
check("00", "0", [0])
check("1", "1", [0])
check("2", "02/", [0, 0, 0])
check("12", "12/", [0, 1, 1])
check("13", "01/3", [0, 0, 0, 1])
check("01", "01/", [0, 1, 1])
check("02", "02/", [0, 1, 1])
check("01/", "01/", [0, 1, 2])
})
suite("zero:optional", () => {
const check = setup({ zero: "optional" })
check("0", "0", [0])
check("00", "0", [0])
check("1", "1", [0])
check("2", "2/", [0, 0])
check("12", "12/", [0, 1, 1])
check("13", "1/3", [0, 0, 1])
check("01", "01/", [0, 1, 1])
check("02", "02/", [0, 1, 1])
check("01/", "01/", [0, 1, 2])
})
suite("slash:eager", () => {
const check = setup({ slash: "eager" })
check("0", "0", [0])
check("1", "1", [0])
check("1/", "01/", [0, 0, 1])
check("2", "02/", [0, 0, 0])
check("12", "12/", [0, 1, 1])
check("13", "01/3", [0, 0, 0, 1])
check("01", "01/", [0, 1, 1])
})
suite("slash:lazy", () => {
const check = setup({ slash: "lazy" })
check("0", "0", [0])
check("1", "1", [0])
check("1/", "01/", [0, 0, 1])
check("2", "02", [0, 0])
check("12", "12", [0, 1])
check("13", "01/3", [0, 0, 0, 1])
check("01", "01", [0, 1])
})
suite("month:strict", () => {
const check = setup({ month: "strict" })
check("0", "0", [0])
check("00", "0", [0])
check("1", "1", [0])
check("12", "12/", [0, 1, 1])
check("13", "01/3", [0, 0, 0, 1])
check("23", "02/3", [0, 0, 0, 1])
check("0/", "0", [0])
check("1/", "01/", [0, 0, 1])
check("2/", "02/", [0, 0, 1])
check("13/", "01/3", [0, 0, 0, 1])
check("23/", "02/3", [0, 0, 0, 1])
})
suite("month:loose", () => {
const check = setup({ month: "loose" })
check("0", "0", [0])
check("00", "0", [0])
check("1", "1", [0])
check("2", "02/", [0, 0, 0])
check("12", "12/", [0, 1, 1])
check("13", "01/3", [0, 0, 0, 1])
check("23", "02/3", [0, 0, 0, 1])
check("0/", "0", [0])
check("1/", "01/", [0, 0, 1])
check("2/", "02/", [0, 0, 1])
check("13/", "13/", [0, 1, 2])
check("23/", "23/", [0, 1, 2])
})
suite("loose settings", () => {
const check = setup({
zero: "optional",
slash: "lazy",
month: "loose",
})
check("1/", "1/", [0, 1])
check("13/", "13/", [0, 1, 2])
check("13", "1/3", [0, 0, 1])
})
})
})

View File

@ -1,7 +1,25 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FormatterToken } from "../dist/minimask"
import { getNextInputState, type InputState } from "../src/getNextInputState"
import type { Formatter } from "../src/minimask"
import { assert, expect, suite, test } from "vitest"
/**
* In order to make before/after input states easier to test, you can use a
* visual string representation (a "viz") of the input like:
*
* "ABC|DEF"
*
* Where the input is "ABCDEF" and the cursor is placed between "C" and "D"
*
* Or you can have a selection like:
*
* "AB|CD|EF"
*
* Where the selection starts between "B" and "C" and ends between "D" and "E"
*/
function vizToState(input: string): InputState {
let value = input.split("|").join("")
let start = input.indexOf("|")
@ -63,132 +81,169 @@ suite("viz", () => {
fail("|A|B|C|", "cannot have more than two cursors")
})
let TEST_FORMATTER: Formatter = function* (input) {
let cursor = 0
for (let match of input.matchAll(/[A-Z]/g)) {
if (cursor === 3) {
yield { char: "-", index: match.index, mask: true }
}
if (cursor === 6) {
yield { char: "-", index: match.index, mask: true }
yield { char: "-", index: match.index, mask: true }
}
yield { char: match[0], index: match.index, mask: false }
cursor += 1
}
function isLetter(char: string): boolean {
return /[A-Z]/.test(char)
}
suite("unformat", () => {
function check(input: string, expected: string) {
test(`${input} -> ${expected}`, () => {
let TEST_FORMATTER: Formatter = (input) => {
let tokens: FormatterToken[] = []
let chars = input.split("")
let index = 0
function letter() {
while (true) {
let char = chars[index]
if (char == null) return
if (isLetter(char)) {
tokens.push({ char, index, mask: false })
index++
break
} else {
index++
}
}
}
function dash() {
let char = chars[index]
if (char == null) return
if (!isLetter(char)) {
tokens.push({ char: "-", index, mask: true })
index++
} else if (index < chars.length) {
tokens.push({ char: "-", index: index - 1, mask: true })
}
}
letter()
letter()
letter()
dash()
letter()
letter()
letter()
dash()
dash()
letter()
letter()
letter()
return tokens
}
suite("getNextInputState", () => {
function check(input: string, expectedNormal: string, expectedDeleting: string) {
test(`${input} -> ${expectedNormal} (normal)`, () => {
let state = vizToState(input)
let result = getNextInputState(TEST_FORMATTER, false, state.value, state.start, state.end)
let result = getNextInputState(TEST_FORMATTER, state.value, state.start, state.end, false)
let actual = stateToViz(result)
expect(actual).toBe(expected)
expect(actual).toBe(expectedNormal)
})
test(`${input} -> ${expectedNormal} (deleting)`, () => {
let state = vizToState(input)
console.log(state)
let result = getNextInputState(TEST_FORMATTER, state.value, state.start, state.end, true)
// console.log(result)
let actual = stateToViz(result)
expect(actual).toBe(expectedDeleting)
})
}
suite("cursor", () => {
check("|ABC-DEF", "|ABCDEF")
check("A|BC-DEF", "A|BCDEF")
check("AB|C-DEF", "AB|CDEF")
check("ABC|-DEF", "ABC|DEF")
check("ABC-|DEF", "ABC|DEF")
check("ABC-D|EF", "ABCD|EF")
check("ABC-DE|F", "ABCDE|F")
check("ABC-DEF|", "ABCDEF|")
})
suite("selection", () => {
check("|A|BC-DEF", "|A|BCDEF")
check("|AB|C-DEF", "|AB|CDEF")
check("|ABC|-DEF", "|ABC|DEF")
check("|ABC-|DEF", "|ABC|DEF")
check("|ABC-D|EF", "|ABCD|EF")
check("|ABC-DE|F", "|ABCDE|F")
check("|ABC-DEF|", "|ABCDEF|")
check("A|BC-DEF|", "A|BCDEF|")
check("AB|C-DEF|", "AB|CDEF|")
check("ABC|-DEF|", "ABC|DEF|")
check("ABC-|DEF|", "ABC|DEF|")
check("ABC-D|EF|", "ABCD|EF|")
check("ABC-DE|F|", "ABCDE|F|")
})
suite("multiple dropped chars before", () => {
check("ABC-DEF--G|HI", "ABCDEFG|HI")
check("ABC-DEF--|GHI", "ABCDEF|GHI")
check("ABC-DEF-|-GHI", "ABCDEF|GHI")
check("ABC-DEF|--GHI", "ABCDEF|GHI")
check("ABC-DE|F--GHI", "ABCDE|FGHI")
})
suite("poorly formatted", () => {
check("|*A*B*C*D*E*F*", "|ABCDEF")
check("*|A*B*C*D*E*F*", "|ABCDEF")
check("*A|*B*C*D*E*F*", "A|BCDEF")
check("*A*|B*C*D*E*F*", "A|BCDEF")
check("*A*B|*C*D*E*F*", "AB|CDEF")
check("*A*B*|C*D*E*F*", "AB|CDEF")
check("*A*B*C|*D*E*F*", "ABC|DEF")
check("*A*B*C*|D*E*F*", "ABC|DEF")
check("*A*B*C*D|*E*F*", "ABCD|EF")
check("*A*B*C*D*|E*F*", "ABCD|EF")
check("*A*B*C*D*E|*F*", "ABCDE|F")
check("*A*B*C*D*E*|F*", "ABCDE|F")
check("*A*B*C*D*E*F|*", "ABCDEF|")
check("*A*B*C*D*E*F*|", "ABCDEF|")
})
})
suite("format", () => {
function check(input: string, expected: string) {
test(`${input} -> ${expected}`, () => {
let state = vizToState(input)
let result = getNextInputState(TEST_FORMATTER, true, state.value, state.start, state.end)
let actual = stateToViz(result)
expect(actual).toBe(expected)
suite("formatted", () => {
suite("cursor", () => {
check("|ABC-DEF", "|ABC-DEF", "|ABC-DEF")
check("A|BC-DEF", "A|BC-DEF", "A|BC-DEF")
check("AB|C-DEF", "AB|C-DEF", "AB|C-DEF")
check("ABC|-DEF", "ABC|-DEF", "ABC|-DEF") // deleting moves before mask chars
check("ABC-|DEF", "ABC-|DEF", "ABC|-DEF") // deleting moves before mask chars
check("ABC-D|EF", "ABC-D|EF", "ABC-D|EF")
check("ABC-DE|F", "ABC-DE|F", "ABC-DE|F")
check("ABC-DEF|", "ABC-DEF|", "ABC-DEF|")
})
}
suite("selection", () => {
check("|A|BC-DEF", "|A|BC-DEF", "|A|BC-DEF")
check("|AB|C-DEF", "|AB|C-DEF", "|AB|C-DEF")
check("|ABC|-DEF", "|ABC|-DEF", "|ABC|-DEF")
check("|ABC-|DEF", "|ABC-|DEF", "|ABC|-DEF") // deleting moves before mask chars
check("|ABC-D|EF", "|ABC-D|EF", "|ABC-D|EF")
check("|ABC-DE|F", "|ABC-DE|F", "|ABC-DE|F")
check("|ABC-DEF|", "|ABC-DEF|", "|ABC-DEF|")
check("A|BC-DEF|", "A|BC-DEF|", "A|BC-DEF|")
check("AB|C-DEF|", "AB|C-DEF|", "AB|C-DEF|")
check("ABC|-DEF|", "ABC|-DEF|", "ABC|-DEF|")
check("ABC-|DEF|", "ABC-|DEF|", "ABC|-DEF|") // deleting moves before mask chars
check("ABC-D|EF|", "ABC-D|EF|", "ABC-D|EF|")
check("ABC-DE|F|", "ABC-DE|F|", "ABC-DE|F|")
})
suite("multiple dropped chars before", () => {
check("ABC-DEF--G|HI", "ABC-DEF--G|HI", "ABC-DEF--G|HI")
check("ABC-DEF--|GHI", "ABC-DEF--|GHI", "ABC-DEF|--GHI") // deleting moves before mask chars
check("ABC-DEF-|-GHI", "ABC-DEF-|-GHI", "ABC-DEF|--GHI") // deleting moves before mask chars
check("ABC-DEF|--GHI", "ABC-DEF|--GHI", "ABC-DEF|--GHI")
check("ABC-DE|F--GHI", "ABC-DE|F--GHI", "ABC-DE|F--GHI")
})
suite("poorly formatted", () => {
check("|*A*B*C*D*E*F*", "|ABC-DEF-", "|ABC-DEF-")
check("*|A*B*C*D*E*F*", "|ABC-DEF-", "|ABC-DEF-")
check("*A|*B*C*D*E*F*", "A|BC-DEF-", "A|BC-DEF-")
check("*A*|B*C*D*E*F*", "A|BC-DEF-", "A|BC-DEF-")
check("*A*B|*C*D*E*F*", "AB|C-DEF-", "AB|C-DEF-")
check("*A*B*|C*D*E*F*", "AB|C-DEF-", "AB|C-DEF-")
check("*A*B*C|*D*E*F*", "ABC|-DEF-", "ABC|-DEF-")
check("*A*B*C*|D*E*F*", "ABC-|DEF-", "ABC|-DEF-") // deleting moves before mask chars
check("*A*B*C*D|*E*F*", "ABC-D|EF-", "ABC-D|EF-")
check("*A*B*C*D*|E*F*", "ABC-D|EF-", "ABC-D|EF-")
check("*A*B*C*D*E|*F*", "ABC-DE|F-", "ABC-DE|F-")
check("*A*B*C*D*E*|F*", "ABC-DE|F-", "ABC-DE|F-")
check("*A*B*C*D*E*F|*", "ABC-DEF|-", "ABC-DEF|") // deleting trims trailing mask chars
check("*A*B*C*D*E*F*|", "ABC-DEF-|", "ABC-DEF|") // deleting trims trailing mask chars
})
})
suite("cursor", () => {
check("|ABCDEF", "|ABC-DEF")
check("A|BCDEF", "A|BC-DEF")
check("AB|CDEF", "AB|C-DEF")
check("ABC|DEF", "ABC|-DEF")
check("ABCD|EF", "ABC-D|EF")
check("ABCDE|F", "ABC-DE|F")
check("ABCDEF|", "ABC-DEF|")
})
suite("selection", () => {
check("|A|BCDEF", "|A|BC-DEF")
check("|AB|CDEF", "|AB|C-DEF")
check("|ABC|DEF", "|ABC|-DEF")
check("|ABCD|EF", "|ABC-D|EF")
check("|ABCDE|F", "|ABC-DE|F")
check("|ABCDEF|", "|ABC-DEF|")
check("A|BCDEF|", "A|BC-DEF|")
check("AB|CDEF|", "AB|C-DEF|")
check("ABC|DEF|", "ABC|-DEF|")
check("ABCD|EF|", "ABC-D|EF|")
check("ABCDE|F|", "ABC-DE|F|")
})
suite("multiple dropped chars before", () => {
check("ABCDEFG|HI", "ABC-DEF--G|HI")
check("ABCDEF|GHI", "ABC-DEF|--GHI")
check("ABCDE|FGHI", "ABC-DE|F--GHI")
})
suite("poorly formatted", () => {
check("|*A*B*C*D*E*F*", "|ABC-DEF")
check("*|A*B*C*D*E*F*", "|ABC-DEF")
check("*A|*B*C*D*E*F*", "A|BC-DEF")
check("*A*|B*C*D*E*F*", "A|BC-DEF")
check("*A*B|*C*D*E*F*", "AB|C-DEF")
check("*A*B*|C*D*E*F*", "AB|C-DEF")
check("*A*B*C|*D*E*F*", "ABC|-DEF")
check("*A*B*C*D|*E*F*", "ABC-D|EF")
check("*A*B*C*D*|E*F*", "ABC-D|EF")
check("*A*B*C*D*E|*F*", "ABC-DE|F")
check("*A*B*C*D*E*|F*", "ABC-DE|F")
check("*A*B*C*D*E*F|*", "ABC-DEF|")
check("*A*B*C*D*E*F*|", "ABC-DEF|")
suite("unformatted", () => {
suite("cursor", () => {
check("|ABCDEF", "|ABC-DEF", "|ABC-DEF")
check("A|BCDEF", "A|BC-DEF", "A|BC-DEF")
check("AB|CDEF", "AB|C-DEF", "AB|C-DEF")
check("ABC|DEF", "ABC-|DEF", "ABC|-DEF")
check("ABCD|EF", "ABC-D|EF", "ABC-D|EF")
check("ABCDE|F", "ABC-DE|F", "ABC-DE|F")
check("ABCDEF|", "ABC-DEF|", "ABC-DEF|")
})
suite("selection", () => {
check("|A|BCDEF", "|A|BC-DEF", "|A|BC-DEF")
check("|AB|CDEF", "|AB|C-DEF", "|AB|C-DEF")
check("|ABC|DEF", "|ABC-|DEF", "|ABC|-DEF") // deleting moves before mask chars
check("|ABCD|EF", "|ABC-D|EF", "|ABC-D|EF")
check("|ABCDE|F", "|ABC-DE|F", "|ABC-DE|F")
check("|ABCDEF|", "|ABC-DEF|", "|ABC-DEF|")
check("A|BCDEF|", "A|BC-DEF|", "A|BC-DEF|")
check("AB|CDEF|", "AB|C-DEF|", "AB|C-DEF|")
check("ABC|DEF|", "ABC-|DEF|", "ABC|-DEF|") // deleting moves before mask chars
check("ABCD|EF|", "ABC-D|EF|", "ABC-D|EF|")
check("ABCDE|F|", "ABC-DE|F|", "ABC-DE|F|")
})
suite("multiple dropped chars before", () => {
check("ABCDEFG|HI", "ABC-DEF--G|HI", "ABC-DEF--G|HI")
check("ABCDEF|GHI", "ABC-DEF--|GHI", "ABC-DEF|--GHI")
check("ABCDE|FGHI", "ABC-DE|F--GHI", "ABC-DE|F--GHI")
})
suite("poorly formatted", () => {
check("|*A*B*C*D*E*F*", "|ABC-DEF-", "|ABC-DEF-")
check("*|A*B*C*D*E*F*", "|ABC-DEF-", "|ABC-DEF-")
check("*A|*B*C*D*E*F*", "A|BC-DEF-", "A|BC-DEF-")
check("*A*|B*C*D*E*F*", "A|BC-DEF-", "A|BC-DEF-")
check("*A*B|*C*D*E*F*", "AB|C-DEF-", "AB|C-DEF-")
check("*A*B*|C*D*E*F*", "AB|C-DEF-", "AB|C-DEF-")
check("*A*B*C|*D*E*F*", "ABC|-DEF-", "ABC|-DEF-")
check("*A*B*C*D|*E*F*", "ABC-D|EF-", "ABC-D|EF-")
check("*A*B*C*D*|E*F*", "ABC-D|EF-", "ABC-D|EF-")
check("*A*B*C*D*E|*F*", "ABC-DE|F-", "ABC-DE|F-")
check("*A*B*C*D*E*|F*", "ABC-DE|F-", "ABC-DE|F-")
check("*A*B*C*D*E*F|*", "ABC-DEF|-", "ABC-DEF|") // deleting trims trailing mask chars
check("*A*B*C*D*E*F*|", "ABC-DEF-|", "ABC-DEF|") // deleting trims trailing mask chars
})
})
})