{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"lib-input-format","type":"registry:lib","title":"input-format helpers","description":"Formatting as you type, with no library: card numbers in groups, phone numbers, dates, expiry dates, times,","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/dashral/lib/input-format.ts","type":"registry:file","target":"components/dashral/lib/input-format.ts","content":"// Formatting as you type, with no library: card numbers in groups, phone numbers, dates, expiry dates, times,\n// numbers with thousands separators, or any pattern. Used by Input's `format` prop; usable on its own too.\n\nexport type CardBrand = \"visa\" | \"mastercard\" | \"amex\" | \"discover\" | \"diners\" | \"jcb\" | \"unionpay\" | \"unknown\"\n\n/**\n * - \"card\": 4-4-4-4 (Amex 4-6-5, Diners 4-6-4), up to 19 digits, and the brand.\n * - \"phone\": 4-4-4 digits, \"0812 3456 7890\"; pass a pattern for another country's style.\n * - \"date\": day, month and year in the locale's order and separator (\"31/12/2026\", \"12/31/2026\").\n * - \"expiry\": MM/YY. \"time\": HH:MM, 24-hour.\n * - \"number\": thousands separators in the locale's style; { type: \"number\", decimals: 2 } allows decimals.\n * - { pattern }: # is a digit, A a letter, * either; anything else is written as is, e.g. \"##.###.###.#-###.###\".\n */\nexport type InputFormat =\n  | \"card\"\n  | \"phone\"\n  | \"date\"\n  | \"expiry\"\n  | \"time\"\n  | \"number\"\n  | { type: \"number\"; decimals?: number }\n  | { pattern: string }\n\nexport type FormatResult = {\n  /** What the field shows. */\n  text: string\n  /** What it means: the digits (and letters) alone; for numbers, a plain \"1234.5\". */\n  raw: string\n  /** For \"card\": the brand, from the first digits. */\n  brand?: CardBrand\n}\n\nexport type InputFormatter = {\n  format: (text: string) => FormatResult\n  /** The characters that carry meaning; everything else is a separator the formatter adds. */\n  significant: RegExp\n  /** Where the caret goes in `formatted`, given the text that was before it as typed. */\n  caret: (before: string, formatted: string) => number\n}\n\nconst DIGIT = /\\d/\n\n/** Writes `chars` into a pattern. Separators only appear once a character follows them, so deleting never sticks. */\nfunction applyPattern(input: string, pattern: string) {\n  const chars = [...input].filter((c) => /[\\dA-Za-z]/.test(c))\n  let out = \"\"\n  let pending = \"\"\n  let raw = \"\"\n  let i = 0\n  for (const slot of pattern) {\n    if (i >= chars.length) break\n    const fits = slot === \"#\" ? DIGIT : slot === \"A\" ? /[A-Za-z]/ : slot === \"*\" ? /[\\dA-Za-z]/ : null\n    if (!fits) {\n      pending += slot\n      continue\n    }\n    // Skip what can't go in this slot (a letter where a digit belongs).\n    while (i < chars.length && !fits.test(chars[i]!)) i++\n    if (i >= chars.length) break\n    out += pending + chars[i]\n    raw += chars[i]\n    pending = \"\"\n    i++\n  }\n  return { text: out, raw }\n}\n\n/** Two-digit parts with a ceiling (a month, an hour): \"5\" becomes \"05\", and \"13\" can't be typed as a month. */\nfunction applySegments(input: string, segments: { length: number; max?: number }[], separator: string) {\n  const digits = input.replace(/\\D/g, \"\")\n  const parts: string[] = []\n  let i = 0\n  for (const segment of segments) {\n    if (i >= digits.length) break\n    let part = \"\"\n    if (segment.max !== undefined && segment.length === 2 && Number(digits[i]) > Math.floor(segment.max / 10)) {\n      part = `0${digits[i]}`\n      i += 1\n    } else {\n      part = digits.slice(i, i + segment.length)\n      i += part.length\n      if (segment.max !== undefined && part.length === segment.length && Number(part) > segment.max) part = String(segment.max)\n    }\n    parts.push(part)\n  }\n  return { text: parts.join(separator), raw: parts.join(\"\") }\n}\n\n/** The card's brand from its first digits. */\nexport function cardBrand(digits: string): CardBrand {\n  if (/^3[47]/.test(digits)) return \"amex\"\n  if (/^3(0[0-5]|[689])/.test(digits)) return \"diners\"\n  if (/^35/.test(digits)) return \"jcb\"\n  if (/^4/.test(digits)) return \"visa\"\n  if (/^(5[1-5]|2[2-7])/.test(digits)) return \"mastercard\"\n  if (/^6(011|5|4[4-9])/.test(digits)) return \"discover\"\n  if (/^62/.test(digits)) return \"unionpay\"\n  return \"unknown\"\n}\n\nconst cardPatterns: Partial<Record<CardBrand, string>> = { amex: \"#### ###### #####\", diners: \"#### ###### ####\" }\n\n/** The locale's day, month and year order and separator, e.g. [\"day\", \"month\", \"year\"] and \"/\". */\nfunction dateParts(locale: string) {\n  const parts = new Intl.DateTimeFormat(locale, { day: \"2-digit\", month: \"2-digit\", year: \"numeric\" }).formatToParts(new Date(2026, 11, 31))\n  const order = parts.filter((p) => p.type === \"day\" || p.type === \"month\" || p.type === \"year\").map((p) => p.type)\n  // Without the direction marks some locales (Arabic) put around it.\n  const separator = parts.find((p) => p.type === \"literal\")?.value.replace(/[\\u200e\\u200f\\u061c\\s]/g, \"\") || \"/\"\n  return { order, separator }\n}\n\n/** The formatter for a format and locale: call `format` on whatever the field holds. */\nexport function inputFormatter(format: InputFormat, locale = \"en-US\"): InputFormatter {\n  const base = baseFormatter(format, locale)\n  const grouped = format === \"number\" || (typeof format === \"object\" && \"type\" in format)\n  return {\n    ...base,\n    // Patterns put separators in fixed places, so the caret goes where the text before it ends up (\"5\" → \"05|\").\n    // Number groups move with the length, so there it goes after the same count of digits.\n    caret: grouped\n      ? (before, formatted) => caretAfter(formatted, countSignificant(before, base.significant), base.significant)\n      : (before, formatted) => Math.min(base.format(before).text.length, formatted.length),\n  }\n}\n\nfunction baseFormatter(format: InputFormat, locale: string): Omit<InputFormatter, \"caret\"> {\n  if (format === \"number\" || (typeof format === \"object\" && \"type\" in format)) {\n    const decimals = typeof format === \"object\" ? (format.decimals ?? 0) : 0\n    const sample = new Intl.NumberFormat(locale).formatToParts(12345.6)\n    const group = sample.find((p) => p.type === \"group\")?.value ?? \",\"\n    const mark = sample.find((p) => p.type === \"decimal\")?.value ?? \".\"\n    const escaped = mark.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n    return {\n      significant: new RegExp(decimals > 0 ? `[\\\\d${escaped}]` : \"\\\\d\"),\n      format(text) {\n        const kept = [...text].filter((c) => DIGIT.test(c) || (decimals > 0 && c === mark)).join(\"\")\n        const [whole = \"\", ...rest] = kept.split(mark)\n        const hasMark = decimals > 0 && kept.includes(mark)\n        const fraction = rest.join(\"\").slice(0, decimals)\n        const integer = whole.replace(/^0+(?=\\d)/, \"\")\n        const grouped = integer.replace(/\\B(?=(\\d{3})+(?!\\d))/g, group)\n        const shownInteger = grouped || (hasMark ? \"0\" : \"\")\n        return {\n          text: hasMark ? `${shownInteger}${mark}${fraction}` : shownInteger,\n          raw: hasMark ? `${integer || \"0\"}.${fraction}` : integer,\n        }\n      },\n    }\n  }\n\n  if (typeof format === \"object\") return { significant: /[\\dA-Za-z]/, format: (text) => applyPattern(text, format.pattern) }\n\n  switch (format) {\n    case \"card\":\n      return {\n        significant: DIGIT,\n        format(text) {\n          const digits = text.replace(/\\D/g, \"\")\n          const brand = cardBrand(digits)\n          return { ...applyPattern(digits, cardPatterns[brand] ?? \"#### #### #### #### ###\"), brand }\n        },\n      }\n    case \"phone\":\n      return { significant: DIGIT, format: (text) => applyPattern(text, \"#### #### #####\") }\n    case \"expiry\":\n      return { significant: DIGIT, format: (text) => applySegments(text, [{ length: 2, max: 12 }, { length: 2 }], \"/\") }\n    case \"time\":\n      return { significant: DIGIT, format: (text) => applySegments(text, [{ length: 2, max: 23 }, { length: 2, max: 59 }], \":\") }\n    case \"date\": {\n      const { order, separator } = dateParts(locale)\n      const segments = order.map((part) => (part === \"year\" ? { length: 4 } : { length: 2, max: part === \"month\" ? 12 : 31 }))\n      return { significant: DIGIT, format: (text) => applySegments(text, segments, separator) }\n    }\n  }\n}\n\n/** How many meaningful characters `text` has. */\nexport function countSignificant(text: string, significant: RegExp) {\n  let n = 0\n  for (const c of text) if (significant.test(c)) n++\n  return n\n}\n\n/** Where the caret goes in `text`: right after its `count`-th meaningful character. */\nexport function caretAfter(text: string, count: number, significant: RegExp) {\n  if (count <= 0) return 0\n  let n = 0\n  for (let i = 0; i < text.length; i++) {\n    if (significant.test(text[i]!) && ++n === count) return i + 1\n  }\n  return text.length\n}\n"}]}