Masked input

A field that lays out what is typed in a fixed shape: card numbers, expiry dates, phone numbers.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add masked-input
pnpm dlx ermineui add masked-input
yarn dlx ermineui add masked-input
bunx ermineui add masked-input

That copies 3 files into src/components/ui/masked-input/, and the 1 component it is built from. Files you already have are kept.

Use

import { MaskedInput, applyMask, masks, unmask } from '@/components/ui/masked-input'
<RangeSlider v-model="priceRange" :min="0" :max="200" :step="5" :min-gap="10" low-label="Lowest price" high-label="Highest price" />

<MaskedInput v-model="card" :mask="masks.card" autocomplete="cc-number" />
<MaskedInput v-model="code" mask="AA-####" />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
MaskedInput.vuesrc/components/ui/masked-input/MaskedInput.vue
index.tssrc/components/ui/masked-input/index.ts
mask.tssrc/components/ui/masked-input/mask.ts

It is built from: Input. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

MaskedInput.vue 33 lines
<script setup lang="ts">
import { computed } from 'vue'
import { Input } from '../input'
import { applyMask, caretAfter, digitsOnly, unmask } from './mask'

const props = defineProps<{
  /** The shape of the finished text: `#` is a digit, `A` a letter, `*` either. See mask.ts for ready-made ones. */
  mask: string
}>()

/** What was typed, without the spaces, slashes and brackets the mask adds: '4242424242424242', not '4242 4242 …'. */
const model = defineModel<string>({ default: '' })

const shown = computed(() => applyMask(unmask(model.value, props.mask), props.mask))

function onInput(event: Event) {
  const input = event.target as HTMLInputElement
  // How many real characters sit before the caret; the caret goes back after that many once the text is laid out again.
  const before = unmask(input.value.slice(0, input.selectionStart ?? input.value.length), props.mask).length
  const raw = unmask(input.value, props.mask)
  const masked = applyMask(raw, props.mask)
  model.value = raw
  input.value = masked
  // Typing at the end keeps the caret at the end, past any separator that was just added.
  const caret = before >= raw.length ? masked.length : caretAfter(masked, props.mask, before)
  input.setSelectionRange(caret, caret)
}
</script>

<template>
  <Input :model-value="shown" :inputmode="digitsOnly(mask) ? 'numeric' : undefined" :maxlength="mask.length" @input="onInput" />
</template>
index.ts 4 lines
/** Masked input — a field that lays out what is typed in a fixed shape: card numbers, expiry dates, phone numbers. */
export { default as MaskedInput } from './MaskedInput.vue'
export { applyMask, masks, unmask } from './mask'
mask.ts 61 lines
/**
 * A mask is the shape of the finished text. `#` stands for a digit, `A` for a letter and `*` for either;
 * every other character is put in for the typist: '##/##', '(###) ###-####', 'AA## ####'.
 */
const slots: Record<string, RegExp> = {
  '#': /\d/,
  A: /[a-z]/i,
  '*': /[a-z\d]/i,
}

/** Ready-made masks for the fields a checkout needs. */
export const masks = {
  /** Sixteen digits in fours. Covers Visa, Mastercard and Discover; American Express groups 4-6-5. */
  card: '#### #### #### ####',
  amex: '#### ###### #####',
  /** Month and year, MM/YY. */
  expiry: '##/##',
  cvc: '####',
  phoneUS: '(###) ###-####',
  zipUS: '#####',
} as const

/** Whether a mask takes digits only, in which case a phone can show its number pad. */
export const digitsOnly = (mask: string) => !/[A*]/.test(mask)

/** What the typist entered, without the characters the mask adds. Anything the mask has no place for is dropped. */
export function unmask(text: string, mask: string): string {
  const wanted = [...mask].filter((character) => character in slots)
  let raw = ''
  for (const character of text) {
    const slot = wanted[raw.length]
    if (!slot) break
    if (slots[slot]!.test(character)) raw += character
  }
  return raw
}

/**
 * The raw characters laid out in the mask. Separators appear only once there is something to put after
 * them, so Backspace never gets stuck on one: '12' stays '12', and '123' becomes '12/3'.
 */
export function applyMask(raw: string, mask: string): string {
  let out = ''
  let used = 0
  for (const character of mask) {
    if (used >= raw.length) break
    out += character in slots ? raw[used++] : character
  }
  return out
}

/** Where the caret belongs in masked text so that `count` raw characters sit before it. */
export function caretAfter(masked: string, mask: string, count: number): number {
  if (count <= 0) return 0
  let seen = 0
  for (let index = 0; index < masked.length; index++) {
    if (mask[index]! in slots && ++seen === count) return index + 1
  }
  return masked.length
}