{"version":3,"file":"mask.mjs","names":[],"sources":["../../../../package/common/maskedInput/mask.ts"],"sourcesContent":["/**\n * A fixed-width digit mask: `99:99`, `99.99.9999`.\n *\n * Fixed means every character keeps its position. Clearing a digit in the middle leaves a hole\n * rather than pulling the rest left, which is what a date or a time wants: deleting the month\n * out of `11.11.2025` must not turn the year into the month.\n *\n * `9` marks a slot that takes a digit. Every other character is a literal the caret steps over\n * and the user cannot type into.\n */\n\nconst DIGIT_SLOT = '9';\n\nexport interface MaskSelection {\n  start: number;\n  end: number;\n}\n\nexport interface MaskState {\n  value: string;\n  selection: MaskSelection | null;\n}\n\nconst isDigit = (character: string | undefined): boolean =>\n  character !== undefined && character >= '0' && character <= '9';\n\n/** The mask indices that take a digit, in order. */\nexport function slotIndices(mask: string): number[] {\n  const slots: number[] = [];\n  for (let index = 0; index < mask.length; index++) if (mask[index] === DIGIT_SLOT) slots.push(index);\n  return slots;\n}\n\n/** The mask with every slot empty: `__.__.____`. */\nexport function emptyMask(mask: string, placeholder: string): string {\n  return [...mask].map((character) => (character === DIGIT_SLOT ? placeholder : character)).join('');\n}\n\n/**\n * Any string as one character per mask index, so slots can be read and written by position.\n *\n * Every other function here assumes a value exactly as long as the mask, with literals where the\n * mask has them. Putting a value through this is what guarantees that, whether it came from a\n * keystroke, from a consumer's state, or from nothing at all.\n */\nexport function toMask(value: string, mask: string, placeholder: string): string {\n  return [...mask]\n    .map((maskCharacter, index) => {\n      if (maskCharacter !== DIGIT_SLOT) return maskCharacter;\n      return isDigit(value[index]) ? value[index] : placeholder;\n    })\n    .join('');\n}\n\n/** Where the caret belongs so the next digit typed lands in `slot`. */\nfunction caretForSlot(slot: number, slots: number[], mask: string): number {\n  return slot >= slots.length ? mask.length : slots[slot];\n}\n\n/** The first slot at or after `index`, or the slot count when there is none. */\nfunction slotAtOrAfter(index: number, slots: number[]): number {\n  const found = slots.findIndex((position) => position >= index);\n  return found === -1 ? slots.length : found;\n}\n\n/** The last slot strictly before `index`, or -1. */\nfunction slotBefore(index: number, slots: number[]): number {\n  for (let slot = slots.length - 1; slot >= 0; slot--) if (slots[slot] < index) return slot;\n  return -1;\n}\n\n/**\n * Where the caret belongs when the field is entered: the first slot with no digit in it.\n *\n * A mask is as wide when empty as when full, so a click lands wherever it likes, and clicking at\n * the end of `__.__.____` would otherwise leave the caret past every slot with nowhere to put the\n * next digit.\n */\nexport function firstEmptyCaret(value: string, mask: string, placeholder: string): number {\n  const slots = slotIndices(mask);\n  const filled = toMask(value, mask, placeholder);\n  const empty = slots.findIndex((index) => !isDigit(filled[index]));\n  return empty === -1 ? mask.length : slots[empty];\n}\n\n/** One edit, as the region of the old value it replaced and the text it put there. */\nfunction editOf(previous: string, raw: string, caret: number) {\n  // Everything after the caret is untouched, so the replaced region ends exactly as far from the\n  // end of the old value as the caret is from the end of the new one. This is what the caret is\n  // for: a prefix/suffix diff alone cannot tell a forward Delete from a Backspace one place along,\n  // nor typing `1` over a `1` from typing nothing at all.\n  const to = Math.max(0, Math.min(previous.length, previous.length - (raw.length - caret)));\n\n  let from = 0;\n  while (from < to && from < caret && previous[from] === raw[from]) from++;\n\n  return { from, to, inserted: raw.slice(from, caret) };\n}\n\n/**\n * What the field should show after the browser has changed its raw text, and where the caret\n * belongs.\n *\n * Works from the edit rather than from key events, so a paste, a drag, a replaced selection, an\n * autofill and a keystroke all arrive the same way.\n */\nexport function maskChange(previous: string, raw: string, caret: number, mask: string, placeholder: string): MaskState {\n  const slots = slotIndices(mask);\n  const characters = [...toMask(previous, mask, placeholder)];\n  const at = (slot: number) => caretForSlot(slot, slots, mask);\n  const held = (from: number, to: number) => slots.filter((index) => index >= from && index < to);\n\n  const { from, to, inserted } = editOf(previous, raw, caret);\n  const digits = [...inserted].filter(isDigit);\n\n  // Something was typed that the mask has no room for: a separator the mask already draws, a\n  // letter, a symbol. The old library dropped these, and so does this — writing nothing would\n  // still be wrong, because the browser has already put the character in the field.\n  if (inserted.length > 0 && digits.length === 0) {\n    const caretSlot = slotAtOrAfter(from, slots);\n    return { value: characters.join(''), selection: { start: at(caretSlot), end: at(caretSlot) } };\n  }\n\n  if (inserted.length === 0) {\n    const touched = held(from, to);\n\n    // Backspace over a literal reads as deleting only that literal, which the user cannot have\n    // meant: nothing they typed is there. Take the digit in front of it instead, the way the\n    // caret would have if it had never been allowed to rest on a literal.\n    const previousSlot = slotBefore(from, slots);\n    const target = touched.length > 0 ? touched : previousSlot === -1 ? [] : [slots[previousSlot]];\n\n    for (const index of target) characters[index] = placeholder;\n    const caretSlot = touched.length > 0 ? slotAtOrAfter(from, slots) : Math.max(previousSlot, 0);\n    return { value: characters.join(''), selection: { start: at(caretSlot), end: at(caretSlot) } };\n  }\n\n  // A replaced selection clears what it covered first, so typing over a highlighted month does\n  // not leave its second digit behind.\n  for (const index of held(from, to)) characters[index] = placeholder;\n\n  let slot = slotAtOrAfter(from, slots);\n  // Typed past the last slot: a mask is as wide empty as full, so a click can leave the caret\n  // there with nothing after it. Fall back to the first slot still free, which is where the digit\n  // was going to end up anyway. A value with no free slot is left alone, as it should be.\n  if (slot >= slots.length) {\n    const free = slots.findIndex((index) => !isDigit(characters[index]));\n    if (free !== -1) slot = free;\n  }\n\n  for (const digit of digits) {\n    if (slot >= slots.length) break;\n    characters[slots[slot]] = digit;\n    slot++;\n  }\n\n  return { value: characters.join(''), selection: { start: at(slot), end: at(slot) } };\n}\n\n/** Whether a masked value holds no digits at all, so the field reads as empty. */\nexport function isBlank(value: string, mask: string): boolean {\n  return !slotIndices(mask).some((index) => isDigit(value[index]));\n}\n"],"mappings":"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"}