mirror of
https://github.com/tabler/tabler.git
synced 2026-10-13 13:47:00 +04:00
Make InputMask native, without IMask
This commit is contained in:
1 parent
9c7afc5002
commit
b9499d4cdd
6 files changed
+543
-116
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
"@tabler/docs": patch
|
||||
"@tabler/preview": patch
|
||||
---
|
||||
|
||||
`InputMask` no longer needs IMask. It formats `data-mask` patterns natively, with the same `0`, `a` and `*` characters, and adds the `tokens` and `placeholderChar` options, a function as `mask`, the `unmaskedValue` and `isComplete` properties and the `accept.bs.input-mask` and `complete.bs.input-mask` events. IMask object masks (`Number`, `Date`, a RegExp) still work when IMask is loaded, but are deprecated.
|
||||
+247
-15
@@ -6,10 +6,13 @@
|
||||
*/
|
||||
|
||||
import BaseComponent from './bootstrap/base-component'
|
||||
import EventHandler from './bootstrap/dom/event-handler'
|
||||
import { initAll } from './bootstrap/util/component-functions'
|
||||
import type { ElementSelector } from './bootstrap/types'
|
||||
|
||||
// The part of an IMask instance the component uses
|
||||
type MaskFunction = (unmaskedValue: string) => string
|
||||
|
||||
// The part of an IMask instance the legacy fallback uses
|
||||
interface IMaskInstance {
|
||||
value: string
|
||||
unmaskedValue: string
|
||||
@@ -19,9 +22,14 @@ interface IMaskInstance {
|
||||
}
|
||||
|
||||
type ComponentConfig = {
|
||||
/** an IMask mask: a pattern string, `Number`, `Date`, a RegExp or a mask object */
|
||||
mask: unknown
|
||||
/** a pattern string, or a function that returns one for the current unmasked value */
|
||||
mask: string | MaskFunction
|
||||
/** show the mask only as far as the user has typed */
|
||||
lazy: boolean
|
||||
/** the character that stands for an empty slot when `lazy` is `false` */
|
||||
placeholderChar: string
|
||||
/** extra pattern characters, each with the RegExp one typed character has to match */
|
||||
tokens: Record<string, RegExp>
|
||||
}
|
||||
|
||||
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
|
||||
@@ -31,43 +39,96 @@ type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
|
||||
*/
|
||||
|
||||
const NAME = 'input-mask'
|
||||
const DATA_KEY = `bs.${NAME}`
|
||||
const EVENT_KEY = `.${DATA_KEY}`
|
||||
|
||||
const EVENT_ACCEPT = `accept${EVENT_KEY}`
|
||||
const EVENT_COMPLETE = `complete${EVENT_KEY}`
|
||||
|
||||
const ATTRIBUTE_MASK = 'data-mask'
|
||||
const ATTRIBUTE_VISIBLE = 'data-mask-visible'
|
||||
const ATTRIBUTE_PLACEHOLDER_CHAR = 'data-mask-placeholder-char'
|
||||
|
||||
const SELECTOR_DATA_MASK = `[${ATTRIBUTE_MASK}]`
|
||||
|
||||
const ESCAPE = '\\'
|
||||
|
||||
const TOKENS: Record<string, RegExp> = {
|
||||
'0': /\d/,
|
||||
'a': /\p{L}/u,
|
||||
'*': /./,
|
||||
}
|
||||
|
||||
const Default: ComponentConfig = {
|
||||
mask: '',
|
||||
lazy: true,
|
||||
placeholderChar: '_',
|
||||
tokens: {},
|
||||
}
|
||||
|
||||
const DefaultType: Record<keyof ComponentConfig, string> = {
|
||||
mask: '(string|function|regexp|object|array)',
|
||||
lazy: 'boolean',
|
||||
placeholderChar: 'string',
|
||||
tokens: 'object',
|
||||
}
|
||||
|
||||
const isLegacyMask = (mask: unknown): boolean => typeof mask !== 'string' && (typeof mask !== 'function' || mask === Number || mask === Date)
|
||||
|
||||
type Slot = { token: RegExp | null; char: string }
|
||||
|
||||
// `\0` makes a literal of a token character; every other character outside the token map is a literal
|
||||
const parse = (mask: string, tokens: Record<string, RegExp>): Slot[] => {
|
||||
const slots: Slot[] = []
|
||||
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const char = mask[i]
|
||||
|
||||
if (char === ESCAPE && i + 1 < mask.length) {
|
||||
slots.push({ token: null, char: mask[++i] })
|
||||
} else {
|
||||
slots.push({ token: tokens[char] ?? null, char })
|
||||
}
|
||||
}
|
||||
|
||||
return slots
|
||||
}
|
||||
|
||||
/**
|
||||
* Class definition
|
||||
*
|
||||
* Wraps the IMask plugin (https://imask.js.org), loaded separately as
|
||||
* `window.IMask`. Without the plugin the component is inert.
|
||||
* A native input mask. Pattern characters: `0` a digit, `a` a letter, `*` any
|
||||
* character; `\` makes the next one a literal. Add more with the `tokens` option.
|
||||
*/
|
||||
|
||||
class InputMask extends BaseComponent {
|
||||
declare _element: HTMLInputElement
|
||||
declare _config: ComponentConfig
|
||||
_mask: IMaskInstance | null = null
|
||||
_legacy: IMaskInstance | null = null
|
||||
_unmasked = ''
|
||||
_complete = false
|
||||
_onInput = (event: Event): void => this._handleInput(event as InputEvent)
|
||||
|
||||
constructor(element: ElementSelector, config?: ComponentConfigInput) {
|
||||
super(element, config)
|
||||
|
||||
if (!this._element || !this._config.mask || !window.IMask) {
|
||||
if (!this._element || !this._config.mask) {
|
||||
return
|
||||
}
|
||||
|
||||
// Every option from the config reaches IMask, not only the two typed ones.
|
||||
this._mask = new window.IMask(this._element, { ...this._config })
|
||||
// deprecated(2.0): IMask object masks (`Number`, `Date`, a RegExp) stay on the IMask library when it is loaded
|
||||
if (isLegacyMask(this._config.mask)) {
|
||||
if (window.IMask) {
|
||||
this._legacy = new window.IMask(this._element, { ...this._config })
|
||||
} else {
|
||||
console.warn('Tabler InputMask: only string and function masks are supported without IMask.')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this._element.addEventListener('input', this._onInput)
|
||||
this.update()
|
||||
}
|
||||
|
||||
// Getters
|
||||
@@ -83,32 +144,203 @@ class InputMask extends BaseComponent {
|
||||
return NAME
|
||||
}
|
||||
|
||||
/** The IMask instance, for options the component does not expose. */
|
||||
get mask(): IMaskInstance | null {
|
||||
return this._mask
|
||||
/** The value as shown in the input, with the literals. */
|
||||
get value(): string {
|
||||
return this._legacy ? this._legacy.value : this._element.value
|
||||
}
|
||||
|
||||
/** Only what the user typed, without the literals and the placeholders. */
|
||||
get unmaskedValue(): string {
|
||||
return this._legacy ? this._legacy.unmaskedValue : this._unmasked
|
||||
}
|
||||
|
||||
/** `true` when every slot of the mask is filled. */
|
||||
get isComplete(): boolean {
|
||||
return this._complete
|
||||
}
|
||||
|
||||
// deprecated(2.0): the IMask instance; the component itself answers the same members
|
||||
get mask(): this | IMaskInstance | null {
|
||||
return this._legacy ?? (this._config.mask ? this : null)
|
||||
}
|
||||
|
||||
// Public
|
||||
/** Re-applies the mask after the value was changed from code. */
|
||||
update(): void {
|
||||
this._mask?.updateValue()
|
||||
if (this._legacy) {
|
||||
this._legacy.updateValue()
|
||||
return
|
||||
}
|
||||
|
||||
this._unmasked = this._extract(this._element.value).unmasked
|
||||
this._render(this._unmasked, null)
|
||||
}
|
||||
|
||||
// deprecated(2.0): IMask method names
|
||||
updateValue(): void {
|
||||
this.update()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.dispose()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._mask?.destroy()
|
||||
this._legacy?.destroy()
|
||||
this._element.removeEventListener('input', this._onInput)
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
// Private
|
||||
_slots(unmasked: string): Slot[] {
|
||||
const { mask, tokens } = this._config
|
||||
|
||||
return parse(typeof mask === 'function' ? mask(unmasked) : mask, { ...TOKENS, ...tokens })
|
||||
}
|
||||
|
||||
_extract(raw: string, caret: number | null = null): { unmasked: string; count: number | null } {
|
||||
// A dynamic mask is resolved by what the value holds, so the first pass reads it with the previous mask
|
||||
let slots = this._slots(this._unmasked)
|
||||
let unmasked = ''
|
||||
let count: number | null = null
|
||||
let position = 0
|
||||
|
||||
for (let i = 0; i <= raw.length; i++) {
|
||||
if (i === caret) {
|
||||
count = unmasked.length
|
||||
}
|
||||
|
||||
const char = raw[i]
|
||||
|
||||
if (char === undefined) {
|
||||
break
|
||||
}
|
||||
|
||||
while (position < slots.length && !slots[position].token && slots[position].char !== char) {
|
||||
position++
|
||||
}
|
||||
|
||||
if (position >= slots.length) {
|
||||
break
|
||||
}
|
||||
|
||||
const { token } = slots[position]
|
||||
|
||||
if (!token) {
|
||||
position++
|
||||
continue
|
||||
}
|
||||
|
||||
if (char !== this._config.placeholderChar && token.test(char)) {
|
||||
unmasked += char
|
||||
position++
|
||||
slots = this._slots(unmasked)
|
||||
}
|
||||
}
|
||||
|
||||
return { unmasked, count }
|
||||
}
|
||||
|
||||
_format(unmasked: string): { text: string; ends: number[]; complete: boolean } {
|
||||
const slots = this._slots(unmasked)
|
||||
const { lazy, placeholderChar } = this._config
|
||||
const ends: number[] = []
|
||||
let text = ''
|
||||
let used = 0
|
||||
let complete = true
|
||||
|
||||
for (const slot of slots) {
|
||||
if (!slot.token) {
|
||||
if (!lazy || used < unmasked.length) {
|
||||
text += slot.char
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (used < unmasked.length) {
|
||||
text += unmasked[used++]
|
||||
ends.push(text.length)
|
||||
} else {
|
||||
complete = false
|
||||
|
||||
if (lazy) {
|
||||
break
|
||||
}
|
||||
|
||||
text += placeholderChar
|
||||
}
|
||||
}
|
||||
|
||||
return { text, ends, complete: complete && slots.some((slot) => slot.token) }
|
||||
}
|
||||
|
||||
_render(unmasked: string, typed: number | null): void {
|
||||
const { text, ends, complete } = this._format(unmasked)
|
||||
const changed = text !== this._element.value
|
||||
|
||||
if (changed) {
|
||||
this._element.value = text
|
||||
}
|
||||
|
||||
if (typed !== null && document.activeElement === this._element) {
|
||||
const caret = typed === 0 ? 0 : (ends[Math.min(typed, ends.length) - 1] ?? text.length)
|
||||
this._element.setSelectionRange(caret, caret)
|
||||
}
|
||||
|
||||
if (complete !== this._complete) {
|
||||
this._complete = complete
|
||||
|
||||
if (complete) {
|
||||
EventHandler.trigger(this._element, EVENT_COMPLETE, { value: text, unmaskedValue: unmasked })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_handleInput(event: InputEvent): void {
|
||||
if (event.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
const raw = this._element.value
|
||||
const caret = this._element.selectionStart ?? raw.length
|
||||
const previous = this._unmasked
|
||||
let { unmasked, count } = this._extract(raw, caret)
|
||||
let typed = count ?? unmasked.length
|
||||
|
||||
// Deleting a literal changes nothing after re-masking, so it removes the digit next to it
|
||||
if (unmasked === previous && event.inputType.startsWith('delete') && typed > 0) {
|
||||
const at = event.inputType === 'deleteContentForward' ? typed : typed - 1
|
||||
|
||||
if (at < unmasked.length) {
|
||||
unmasked = unmasked.slice(0, at) + unmasked.slice(at + 1)
|
||||
typed = at
|
||||
}
|
||||
}
|
||||
|
||||
this._unmasked = unmasked
|
||||
this._render(unmasked, typed)
|
||||
|
||||
if (unmasked !== previous) {
|
||||
EventHandler.trigger(this._element, EVENT_ACCEPT, { value: this._element.value, unmaskedValue: unmasked })
|
||||
}
|
||||
}
|
||||
|
||||
_mergeConfigObj(config?: Record<string, unknown>, element?: HTMLElement): Record<string, unknown> {
|
||||
// `data-mask` holds the pattern; `data-mask-visible="true"` shows it
|
||||
// before typing, which is IMask's `lazy: false`.
|
||||
// before typing, which is `lazy: false`.
|
||||
const dataOptions: Record<string, unknown> = {}
|
||||
const mask = element?.getAttribute(ATTRIBUTE_MASK)
|
||||
const placeholderChar = element?.getAttribute(ATTRIBUTE_PLACEHOLDER_CHAR)
|
||||
|
||||
if (mask) {
|
||||
dataOptions.mask = mask
|
||||
}
|
||||
|
||||
if (placeholderChar) {
|
||||
dataOptions.placeholderChar = placeholderChar
|
||||
}
|
||||
|
||||
// A bare `data-mask-visible` means visible, like `="true"`.
|
||||
if (element?.hasAttribute(ATTRIBUTE_VISIBLE)) {
|
||||
dataOptions.lazy = element.getAttribute(ATTRIBUTE_VISIBLE) === 'false'
|
||||
|
||||
@@ -4,28 +4,26 @@ import InputMask from '../../src/input-mask'
|
||||
|
||||
describe('InputMask', () => {
|
||||
let fixtureEl: HTMLElement
|
||||
let plugin: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureEl = getFixture()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
plugin = vi.fn(function (this: Record<string, unknown>) {
|
||||
this.updateValue = vi.fn()
|
||||
this.destroy = vi.fn()
|
||||
})
|
||||
window.IMask = plugin as unknown as Window['IMask']
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="00/00/0000" data-mask-visible="true">'
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="(00) 0000-0000">'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearFixture()
|
||||
delete window.IMask
|
||||
})
|
||||
|
||||
const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
|
||||
const lastInstance = (): Record<string, ReturnType<typeof vi.fn>> => plugin.mock.instances[plugin.mock.instances.length - 1] as Record<string, ReturnType<typeof vi.fn>>
|
||||
|
||||
const type = (el: HTMLInputElement, value: string, inputType = 'insertText', caret = value.length): void => {
|
||||
el.value = value
|
||||
el.setSelectionRange(caret, caret)
|
||||
el.dispatchEvent(new InputEvent('input', { inputType, bubbles: true }))
|
||||
}
|
||||
|
||||
describe('NAME', () => {
|
||||
it('should return plugin name', () => {
|
||||
@@ -33,62 +31,249 @@ describe('InputMask', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create the mask from data-mask and data-mask-visible', () => {
|
||||
const instance = new InputMask(input())
|
||||
describe('formatting', () => {
|
||||
it('should add literals as the user types', () => {
|
||||
const el = input()
|
||||
const instance = new InputMask(el)
|
||||
|
||||
expect(plugin).toHaveBeenCalledWith(input(), { mask: '00/00/0000', lazy: false })
|
||||
expect(instance.mask).toBe(plugin.mock.instances[0])
|
||||
type(el, '1')
|
||||
expect(el.value).toBe('(1')
|
||||
|
||||
type(el, '(12')
|
||||
type(el, '(123')
|
||||
expect(el.value).toBe('(12) 3')
|
||||
expect(instance.unmaskedValue).toBe('123')
|
||||
})
|
||||
|
||||
it('should default to a lazy mask', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="(00) 0000-0000">'
|
||||
it('should drop characters the token does not accept', () => {
|
||||
const el = input()
|
||||
new InputMask(el)
|
||||
|
||||
type(el, 'a1b2')
|
||||
expect(el.value).toBe('(12')
|
||||
})
|
||||
|
||||
it('should format a pasted value and cut it to the mask', () => {
|
||||
const el = input()
|
||||
const instance = new InputMask(el)
|
||||
|
||||
type(el, '+48 (12) 3456-7890 1234', 'insertFromPaste')
|
||||
expect(el.value).toBe('(48) 1234-5678')
|
||||
expect(instance.unmaskedValue).toBe('4812345678')
|
||||
})
|
||||
|
||||
it('should mask a value set before the component was created', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="00/00/0000" value="31122026">'
|
||||
new InputMask(input())
|
||||
|
||||
expect(plugin).toHaveBeenCalledWith(input(), { mask: '(00) 0000-0000', lazy: true })
|
||||
expect(input().value).toBe('31/12/2026')
|
||||
})
|
||||
|
||||
it('should let the config object win over the attributes', () => {
|
||||
new InputMask(input(), { mask: '0000', lazy: true })
|
||||
it('should re-apply the mask on update()', () => {
|
||||
const instance = new InputMask(input())
|
||||
|
||||
expect(plugin).toHaveBeenCalledWith(input(), { mask: '0000', lazy: true })
|
||||
input().value = '1234567890'
|
||||
instance.update()
|
||||
|
||||
expect(input().value).toBe('(12) 3456-7890')
|
||||
})
|
||||
|
||||
it('should keep the caret after the typed character in the middle', () => {
|
||||
const el = input()
|
||||
new InputMask(el)
|
||||
el.focus()
|
||||
|
||||
type(el, '(12) 3456-7890')
|
||||
type(el, '(192) 3456-7890', 'insertText', 3)
|
||||
|
||||
expect(el.value).toBe('(19) 2345-6789')
|
||||
expect(el.selectionStart).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleting', () => {
|
||||
it('should remove the digit before a deleted literal', () => {
|
||||
const el = input()
|
||||
const instance = new InputMask(el)
|
||||
|
||||
type(el, '(12) 3')
|
||||
type(el, '(12)3', 'deleteContentBackward', 4)
|
||||
|
||||
expect(instance.unmaskedValue).toBe('13')
|
||||
expect(el.value).toBe('(13')
|
||||
})
|
||||
|
||||
it('should clear a lazy mask when the value is emptied', () => {
|
||||
const el = input()
|
||||
new InputMask(el)
|
||||
|
||||
type(el, '(1')
|
||||
type(el, '', 'deleteContentBackward', 0)
|
||||
|
||||
expect(el.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('visible mask', () => {
|
||||
it('should show placeholders from the start', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="00/00" data-mask-visible="true">'
|
||||
new InputMask(input())
|
||||
|
||||
expect(input().value).toBe('__/__')
|
||||
})
|
||||
|
||||
it('should fill the slots and treat a bare attribute as visible', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="00/00" data-mask-visible>'
|
||||
const el = input()
|
||||
const instance = new InputMask(el)
|
||||
|
||||
type(el, '1__/__')
|
||||
expect(el.value).toBe('1_/__')
|
||||
expect(instance.unmaskedValue).toBe('1')
|
||||
})
|
||||
|
||||
it('should use the placeholder character from the attribute', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="00" data-mask-visible data-mask-placeholder-char="#">'
|
||||
new InputMask(input())
|
||||
|
||||
expect(input().value).toBe('##')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tokens', () => {
|
||||
it('should support letters and any character', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="aa-**">'
|
||||
const el = input()
|
||||
new InputMask(el)
|
||||
|
||||
type(el, '1ab-9z')
|
||||
expect(el.value).toBe('ab-9z')
|
||||
})
|
||||
|
||||
it('should use custom tokens', () => {
|
||||
const el = input()
|
||||
new InputMask(el, { mask: 'HH', tokens: { H: /[0-9a-f]/i } })
|
||||
|
||||
type(el, 'fg9')
|
||||
expect(el.value).toBe('f9')
|
||||
})
|
||||
|
||||
it('should treat an escaped token as a literal', () => {
|
||||
fixtureEl.innerHTML = ''
|
||||
const el = fixtureEl.appendChild(document.createElement('input'))
|
||||
new InputMask(el, { mask: '\\000', lazy: false })
|
||||
|
||||
expect(el.value).toBe('0__')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic mask', () => {
|
||||
it('should pick the mask from the unmasked value', () => {
|
||||
const el = input()
|
||||
new InputMask(el, { mask: (value: string) => (value.startsWith('34') || value.startsWith('37') ? '0000 000000 00000' : '0000 0000 0000 0000') })
|
||||
|
||||
type(el, '3782822463100005')
|
||||
expect(el.value).toBe('3782 822463 10000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('should fire accept with the values', () => {
|
||||
const el = input()
|
||||
new InputMask(el)
|
||||
const spy = vi.fn()
|
||||
el.addEventListener('accept.bs.input-mask', ((event: CustomEvent) => spy(event)) as EventListener)
|
||||
|
||||
type(el, '12')
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
const event = spy.mock.calls[0][0] as Event & { value: string; unmaskedValue: string }
|
||||
expect(event.value).toBe('(12')
|
||||
expect(event.unmaskedValue).toBe('12')
|
||||
})
|
||||
|
||||
it('should fire complete once when every slot is filled', () => {
|
||||
const el = input()
|
||||
const instance = new InputMask(el)
|
||||
const spy = vi.fn()
|
||||
el.addEventListener('complete.bs.input-mask', spy)
|
||||
|
||||
type(el, '(12) 3456-789')
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(instance.isComplete).toBe(false)
|
||||
|
||||
type(el, '(12) 3456-7890')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(instance.isComplete).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should let the config object win over the attributes', () => {
|
||||
const el = input()
|
||||
new InputMask(el, { mask: '0000' })
|
||||
|
||||
type(el, '12345')
|
||||
expect(el.value).toBe('1234')
|
||||
})
|
||||
|
||||
it('should stay inert without a mask', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask>'
|
||||
|
||||
const instance = new InputMask(input())
|
||||
|
||||
expect(plugin).not.toHaveBeenCalled()
|
||||
type(input(), 'abc')
|
||||
expect(input().value).toBe('abc')
|
||||
expect(instance.mask).toBeNull()
|
||||
})
|
||||
|
||||
it('should stay inert when the plugin is not loaded', () => {
|
||||
delete window.IMask
|
||||
it('should warn and stay inert for an IMask object mask without IMask', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
fixtureEl.innerHTML = '<input type="text">'
|
||||
new InputMask(input(), { mask: Number as unknown as string })
|
||||
|
||||
expect(() => new InputMask(input())).not.toThrow()
|
||||
expect(InputMask.getInstance(input())).toBeInstanceOf(InputMask)
|
||||
expect(warn).toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy IMask fallback', () => {
|
||||
afterEach(() => {
|
||||
delete window.IMask
|
||||
})
|
||||
|
||||
it('should hand an object mask to IMask when it is loaded', () => {
|
||||
const plugin = vi.fn(function (this: Record<string, unknown>) {
|
||||
this.updateValue = vi.fn()
|
||||
this.destroy = vi.fn()
|
||||
})
|
||||
window.IMask = plugin as unknown as Window['IMask']
|
||||
fixtureEl.innerHTML = '<input type="text">'
|
||||
|
||||
const instance = new InputMask(input(), { mask: Number as unknown as string })
|
||||
|
||||
expect(plugin).toHaveBeenCalledTimes(1)
|
||||
expect(instance.mask).toBe(plugin.mock.instances[0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('public API', () => {
|
||||
it('should forward update to the mask', () => {
|
||||
const instance = new InputMask(input())
|
||||
instance.update()
|
||||
|
||||
expect(lastInstance().updateValue).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should destroy the mask and remove the instance on dispose', () => {
|
||||
const instance = new InputMask(input())
|
||||
const element = input()
|
||||
const destroy = lastInstance().destroy
|
||||
it('should remove the listeners and the instance on dispose', () => {
|
||||
const el = input()
|
||||
const instance = new InputMask(el)
|
||||
|
||||
instance.dispose()
|
||||
type(el, 'abc')
|
||||
|
||||
expect(destroy).toHaveBeenCalledTimes(1)
|
||||
expect(InputMask.getInstance(element)).toBeNull()
|
||||
expect(el.value).toBe('abc')
|
||||
expect(InputMask.getInstance(el)).toBeNull()
|
||||
})
|
||||
|
||||
it('should answer the IMask member names', () => {
|
||||
const instance = new InputMask(input())
|
||||
|
||||
expect(instance.mask).toBe(instance)
|
||||
expect(typeof instance.updateValue).toBe('function')
|
||||
expect(typeof instance.destroy).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,29 +283,6 @@ describe('InputMask', () => {
|
||||
const second = InputMask.getOrCreateInstance(input())
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(plugin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config passthrough', () => {
|
||||
it('hands every config option to IMask', () => {
|
||||
fixtureEl.innerHTML = '<input type="text">'
|
||||
new InputMask(input(), { mask: '000', placeholderChar: '#' })
|
||||
|
||||
expect(plugin).toHaveBeenCalledWith(input(), { mask: '000', lazy: true, placeholderChar: '#' })
|
||||
})
|
||||
|
||||
it('accepts a non-string mask', () => {
|
||||
fixtureEl.innerHTML = '<input type="text">'
|
||||
expect(() => new InputMask(input(), { mask: Number })).not.toThrow()
|
||||
expect(() => new InputMask(fixtureEl.appendChild(document.createElement('input')), { mask: /^\d+$/ })).not.toThrow()
|
||||
})
|
||||
|
||||
it('treats a bare data-mask-visible as visible', () => {
|
||||
fixtureEl.innerHTML = '<input type="text" data-mask="00" data-mask-visible>'
|
||||
new InputMask(input())
|
||||
|
||||
expect(plugin).toHaveBeenCalledWith(input(), { mask: '00', lazy: false })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,34 +3,12 @@ title: Input mask
|
||||
order: 11
|
||||
summary: An input mask formats what the user types to a pattern, such as a phone number or a date, which makes the expected format clear and cuts validation errors.
|
||||
description: Format user input automatically with input masks for dates, phone numbers, and other patterns to reduce validation errors.
|
||||
docs-libs: [imask]
|
||||
related: [/ui/forms/elements]
|
||||
---
|
||||
|
||||
import Example from '@components/Example.astro'
|
||||
import TabsPackage from '@components/TabsPackage.astro'
|
||||
import CodeDocs from '@components/CodeDocs.astro';
|
||||
|
||||
## Installation
|
||||
|
||||
Install the imask library:
|
||||
|
||||
<TabsPackage name="imask" />
|
||||
|
||||
And import or require:
|
||||
|
||||
```javascript
|
||||
import IMask from 'imask'
|
||||
```
|
||||
|
||||
Or include it from a CDN:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/imask"></script>
|
||||
```
|
||||
|
||||
The [IMask documentation](https://imask.js.org/guide.html#installation) covers other ways to install it.
|
||||
|
||||
## Default markup
|
||||
|
||||
Use an input mask where the value has a fixed format, such as a phone number, a date or a card number.
|
||||
@@ -54,39 +32,89 @@ To create an input mask, add the `data-mask` attribute to the input element:
|
||||
<input type="text" id="mask-phone" name="input-mask" class="form-control" data-mask="(00) 0000-0000" data-mask-visible="true" placeholder="(00) 0000-0000" autocomplete="off" />
|
||||
</Example>
|
||||
|
||||
## More examples
|
||||
## Pattern
|
||||
|
||||
If you need more examples of input masks, you can find them in the [IMask documentation](https://imask.js.org/guide.html#masked-input).
|
||||
| Character | Accepts |
|
||||
| --- | --- |
|
||||
| `0` | a digit |
|
||||
| `a` | a letter |
|
||||
| `*` | any character |
|
||||
| `\` | makes the next character a literal, so `\0` is a plain `0` |
|
||||
|
||||
Every other character is a literal, and Tabler adds it for the user as they type. Paste works too: the value is cut to the pattern and the characters that do not fit are dropped.
|
||||
|
||||
<Example column>
|
||||
<label class="form-label" for="mask-date">Date</label>
|
||||
<input type="text" id="mask-date" class="form-control mb-3" data-mask="00/00/0000" placeholder="00/00/0000" autocomplete="off" />
|
||||
<label class="form-label" for="mask-plate">Plate</label>
|
||||
<input type="text" id="mask-plate" class="form-control" data-mask="aa-0000" data-mask-visible="true" autocomplete="off" />
|
||||
</Example>
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Attribute | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `mask` | `data-mask` | `''` | The pattern, or a function that gets the unmasked value and returns the pattern. |
|
||||
| `lazy` | `data-mask-visible="true"` | `true` | With `data-mask-visible` the whole pattern shows before the user types. |
|
||||
| `placeholderChar` | `data-mask-placeholder-char` | `_` | The character for an empty slot when the pattern is visible. |
|
||||
| `tokens` | | `{}` | Extra pattern characters, each with the RegExp one character has to match. |
|
||||
|
||||
## JavaScript
|
||||
|
||||
Tabler wraps the plugin in an `InputMask` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-mask`. This is the code that runs:
|
||||
Tabler creates an `InputMask` for every element with `data-mask`, the same way as the Bootstrap components. This is the code that runs:
|
||||
|
||||
<CodeDocs name="input-mask-init" file="core/js/src/input-mask.ts" />
|
||||
|
||||
Create the component yourself for a field added later. The pattern comes from `data-mask`, or from the config object, which wins:
|
||||
Create the component yourself for a field added later. The config object wins over the attributes:
|
||||
|
||||
```js
|
||||
const phone = new tabler.InputMask(document.getElementById('phone'), { mask: '(00) 0000-0000' });
|
||||
```
|
||||
|
||||
`data-mask-visible="true"` shows the pattern before the user types. In the config object that is `lazy: false`.
|
||||
|
||||
The `mask` getter returns the IMask instance, so everything from the [IMask API](https://imask.js.org/guide.html#api) is available:
|
||||
A custom token, here a hexadecimal digit:
|
||||
|
||||
```js
|
||||
const phone = tabler.InputMask.getOrCreateInstance(document.getElementById('phone'));
|
||||
console.log(phone.mask.unmaskedValue);
|
||||
new tabler.InputMask(document.getElementById('color'), {
|
||||
mask: '#HHHHHH',
|
||||
tokens: { H: /[0-9a-f]/i },
|
||||
});
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
A function as the mask changes the pattern while the user types, for example for a card number:
|
||||
|
||||
```js
|
||||
new tabler.InputMask(document.getElementById('card'), {
|
||||
mask: (value) => (/^3[47]/.test(value) ? '0000 000000 00000' : '0000 0000 0000 0000'),
|
||||
});
|
||||
```
|
||||
|
||||
### Methods and properties
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `value` | The value as shown, with the literals. |
|
||||
| `unmaskedValue` | Only what the user typed. Send this one to a server. |
|
||||
| `isComplete` | `true` when every slot is filled. |
|
||||
| `update()` | Re-applies the mask after the value was changed from code. |
|
||||
| `mask` | Getter. The IMask instance, or `null` when the library is not loaded. |
|
||||
| `dispose()` | Removes the mask and the component from the element. |
|
||||
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
|
||||
| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |
|
||||
|
||||
### Events
|
||||
|
||||
The events are triggered on the input.
|
||||
|
||||
| Event | Description |
|
||||
| --- | --- |
|
||||
| `accept.bs.input-mask` | The unmasked value changed. Has `value` and `unmaskedValue`. |
|
||||
| `complete.bs.input-mask` | The last slot was filled. Has `value` and `unmaskedValue`. |
|
||||
|
||||
```js
|
||||
input.addEventListener('complete.bs.input-mask', (event) => {
|
||||
console.log(event.unmaskedValue);
|
||||
});
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- A mask changes what the field accepts as the user types, which is easy to lose track of without sight. Say the expected format in a hint tied to the field with `aria-describedby`.
|
||||
|
||||
@@ -52,7 +52,7 @@ const rows = [
|
||||
]
|
||||
---
|
||||
|
||||
<DefaultLayout title="Form elements" pageMenu="form.elements" pageLibs={['nouislider', 'vanilla-calendar-pro', 'tom-select', 'imask']}>
|
||||
<DefaultLayout title="Form elements" pageMenu="form.elements" pageLibs={['nouislider', 'vanilla-calendar-pro', 'tom-select']}>
|
||||
<DocsLink slot="page-header-actions" path="/ui/forms/elements" />
|
||||
<div class="row row-cards">
|
||||
<div class="col-12">
|
||||
@@ -133,7 +133,7 @@ const rows = [
|
||||
<FormGroup label="Hour"><InputMask mask="00:00:00" visible /></FormGroup>
|
||||
<FormGroup label={'Date & Hour'}><InputMask mask="00/00/0000 00:00:00" visible /></FormGroup>
|
||||
<FormGroup label="ZIP Code"><InputMask mask="00000-000" visible /></FormGroup>
|
||||
<FormGroup label="Money"><InputMask mask="000.000.000.000.000,00" visible reverse /></FormGroup>
|
||||
<FormGroup label="Money"><InputMask mask="000.000.000.000.000,00" visible /></FormGroup>
|
||||
<FormGroup label="Telephone"><InputMask mask="0000-0000" visible /></FormGroup>
|
||||
<FormGroup label="Telephone with Code Area"><InputMask mask="(00) 0000-0000" visible /></FormGroup>
|
||||
<FormGroup label="IP Address"><InputMask mask="099.099.099.099" placeholder="000.000.000.000" visible /></FormGroup>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
---
|
||||
// IMask initializes from the data-mask attributes (tabler.js).
|
||||
interface Props {
|
||||
mask?: string
|
||||
visible?: boolean
|
||||
placeholder?: string
|
||||
reverse?: boolean
|
||||
}
|
||||
|
||||
const { mask = '00/00/0000', visible, placeholder, reverse } = Astro.props
|
||||
const { mask = '00/00/0000', visible, placeholder } = Astro.props
|
||||
const name = 'mask'
|
||||
---
|
||||
|
||||
<input type="text" name={`input-${name}`} class="form-control" data-mask={mask} data-mask-visible={visible ? 'true' : undefined} placeholder={placeholder ?? mask} data-mask-reverse={reverse ? 'true' : undefined} autocomplete="off" />
|
||||
<input type="text" name={`input-${name}`} class="form-control" data-mask={mask} data-mask-visible={visible ? 'true' : undefined} placeholder={placeholder ?? mask} autocomplete="off" />
|
||||
Reference in new issue
Block a user