import type { DirectiveBinding } from 'vue' interface PatternValue { pattern?: RegExp transform?: (value: string) => string } const DEFAULT_PATTERNS: Record = { pint: { pattern: /\d/ }, int: { pattern: /[\d-]/ }, pnum: { pattern: /[\d.]/ }, tel: { pattern: /[\d.\-()\s+]/ }, money: { pattern: /[\d.\s,]/ }, number: { pattern: /[\d\-.]/ }, num: { pattern: /[\d\-.]/ }, hex: { pattern: /[0-9a-f]/i }, email: { pattern: /[\w.\-@]/ }, alpha: { pattern: /[a-z_]/i }, alphanum: { pattern: /\w/ }, lower: { pattern: /[a-z]/i, transform: (v: string) => v.toLowerCase() }, upper: { pattern: /[A-Z]/i, transform: (v: string) => v.toUpperCase() }, slug: { pattern: /[a-z0-9-]/i, transform: (v: string) => v.toLowerCase().replace(/\s/g, '-') }, snake: { pattern: /[a-z_]/, transform: (v: string) => v.toLowerCase().replace(/\s/g, '_') }, } interface PatternState { validateOnly?: boolean pattern?: RegExp modifier?: string keydownEvent?: EventListenerOrEventListenerObject inputEvent?: EventListenerOrEventListenerObject compositionStartEvent?: EventListenerOrEventListenerObject compositionEndEvent?: EventListenerOrEventListenerObject composing?: boolean reformatting?: boolean } const stateMap = new WeakMap() function getTarget(el: HTMLElement): HTMLInputElement | HTMLTextAreaElement | null { if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { return el } const target = el.querySelector('input, textarea') return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target : null } function getModifiers(binding: DirectiveBinding): string { const modifiers = Object.keys(binding.modifiers) return modifiers.length > 0 ? modifiers[modifiers.length - 1] : '' } function getRegex(state: PatternState): RegExp { if (state.pattern != null) { return state.pattern } if (state.modifier != null && state.modifier !== '') { const presetPattern = (DEFAULT_PATTERNS as Record)[state.modifier]?.pattern return presetPattern ?? /./ } return /./ } function bindEvents(el: HTMLElement, target: HTMLInputElement | HTMLTextAreaElement, state: PatternState) { state.keydownEvent = ((event: KeyboardEvent) => { onKeydown(event, target, state) }) as EventListener state.inputEvent = ((event: InputEvent) => { onInput(event, target, state) }) as EventListener state.compositionStartEvent = (() => { onCompositionStart(state) }) as EventListener state.compositionEndEvent = ((event: CompositionEvent) => { onCompositionEnd(event, target, state) }) as EventListener target.addEventListener('keydown', state.keydownEvent) target.addEventListener('input', state.inputEvent, { capture: true }) target.addEventListener('compositionstart', state.compositionStartEvent) target.addEventListener('compositionend', state.compositionEndEvent) } function unbindEvents(target: HTMLInputElement | HTMLTextAreaElement, state: PatternState) { if (state.keydownEvent) { target.removeEventListener('keydown', state.keydownEvent) } if (state.inputEvent) { target.removeEventListener('input', state.inputEvent, { capture: true }) } if (state.compositionStartEvent) { target.removeEventListener('compositionstart', state.compositionStartEvent) } if (state.compositionEndEvent) { target.removeEventListener('compositionend', state.compositionEndEvent) } state.keydownEvent = undefined state.inputEvent = undefined state.compositionStartEvent = undefined state.compositionEndEvent = undefined } function isNavigationOrControlKey(event: KeyboardEvent): boolean { const allowedKeys = [ 'Tab', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ] if (event.ctrlKey || event.altKey || event.metaKey) { return true } return allowedKeys.includes(event.key) } function onKeydown(event: KeyboardEvent, target: HTMLInputElement | HTMLTextAreaElement, state: PatternState) { if (state.composing === true) { return } if (isNavigationOrControlKey(event)) { return } // If a transform is configured (e.g., slug/snake/lower/upper), // do not block keystrokes; normalization happens on input. if (state.modifier != null && state.modifier !== '') { const transformFn = (DEFAULT_PATTERNS as Record)[state.modifier]?.transform if (typeof transformFn === 'function') { return } } const regex = getRegex(state) const testKey = event.key if (!regex.test(testKey)) { event.preventDefault() } } function onCompositionStart(state: PatternState) { state.composing = true } function onCompositionEnd( event: CompositionEvent, target: HTMLInputElement | HTMLTextAreaElement, state: PatternState, ) { state.composing = false // Normalize after IME commits text onInput(event as unknown as InputEvent, target, state) } function sanitizeTransformed(value: string, state: PatternState): string { const regex = getRegex(state) let transformed: string if (state.modifier != null && state.modifier !== '') { const transformFn = (DEFAULT_PATTERNS as Record)[state.modifier]?.transform transformed = transformFn ? transformFn(value) : value } else { transformed = value } let result = '' for (let i = 0; i < transformed.length; i++) { const ch = transformed[i] if (regex.test(ch)) { result += ch } } return result } function onInput( event: InputEvent, target: HTMLInputElement | HTMLTextAreaElement, state: PatternState, ) { if (state.reformatting === true) { return } if (state.composing === true) { return } const originalValue = target.value const selectionEnd = target.selectionEnd ?? originalValue.length const left = originalValue.slice(0, selectionEnd) const sanitizedFull = sanitizeTransformed(originalValue, state) if (sanitizedFull === originalValue) { return } const leftSanitized = sanitizeTransformed(left, state) const newCursor = leftSanitized.length state.reformatting = true target.value = sanitizedFull target.setSelectionRange(newCursor, newCursor) state.reformatting = false } // Paste is handled naturally via the input event normalization const pattern = { beforeMount(el: HTMLElement, binding: DirectiveBinding) { const target = getTarget(el) if (target === null) { return } const state: PatternState = { modifier: getModifiers(binding), } if (typeof binding.value === 'object' && binding.value !== null) { state.pattern = binding.value.pattern ?? /./ state.validateOnly = binding.value.validateOnly === true } stateMap.set(el, state) bindEvents(el, target, state) }, updated(el: HTMLElement, binding: DirectiveBinding) { const target = getTarget(el) if (target === null) { return } const state = stateMap.get(el) if (state === undefined) { return } unbindEvents(target, state) state.modifier = getModifiers(binding) if (typeof binding.value === 'object' && binding.value !== null) { state.pattern = binding.value.pattern ?? /./ state.validateOnly = binding.value.validateOnly === true } bindEvents(el, target, state) }, unmounted(el: HTMLElement) { const target = getTarget(el) const state = stateMap.get(el) if (target !== null && state !== undefined) { unbindEvents(target, state) } stateMap.delete(el) }, getSSRProps() { return {} }, } export default pattern