Skip to content

Commit a687f7a

Browse files
authored
feat(mask): create useMask composable (#21736)
1 parent fc86d05 commit a687f7a

File tree

8 files changed

+205
-115
lines changed

8 files changed

+205
-115
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"exposed": {
3+
"isValid": "Check if a string is valid for the mask.",
4+
"isComplete": "Check if a string is complete for the mask.",
5+
"mask": "Apply mask to a string.",
6+
"unmask": "Remove mask from a string."
7+
}
8+
}

packages/docs/src/pages/en/components/mask-inputs.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ At its core, the `v-mask-input` is a wrapper around [v-text-field](/components/t
4242
| Component | Description |
4343
| - | - |
4444
| [v-mask-input](/api/v-mask-input/) | Primary Component |
45+
| [useMask](/api/use-mask/) | Masking composable |
4546

4647
<ApiInline hide-links />
4748

@@ -76,6 +77,23 @@ Vuetify includes several pre-configured masks for common use cases:
7677
| time | ##:## | 23:59 |
7778
| time-with-seconds | ##:##:## | 23:59:59 |
7879

80+
### useMask composable
81+
82+
The `useMask` composable provides a set of methods for working with masks.
83+
84+
```js
85+
import { useMask } from 'vuetify'
86+
87+
const mask = useMask({ mask: '####-####' })
88+
89+
mask.mask('12345678') // 1234-5678
90+
mask.unmask('1234-5678') // 12345678
91+
mask.isValid('abc') // false
92+
mask.isValid('1234') // true
93+
mask.isComplete('1234') // false
94+
mask.isComplete('1234-5678') // true
95+
```
96+
7997
### Examples
8098

8199
#### Using Built in Masks

packages/vuetify/src/composables/__tests__/mask.spec.ts

Lines changed: 0 additions & 69 deletions
This file was deleted.

packages/vuetify/src/composables/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export { useLayout } from './layout'
1111
export { useLocale, useRtl } from './locale'
1212
export { useTheme } from './theme'
1313
export { useHotkey } from './hotkey'
14+
export { useMask } from './mask'
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Composables
2+
import { isMaskDelimiter, useMask } from '../mask'
3+
4+
// Types
5+
import type { MaskProps } from '../mask'
6+
7+
describe('mask', () => {
8+
it.each([
9+
[{ mask: '(#', modelValue: '5' }, '(5'],
10+
[{ mask: '(#', modelValue: '(' }, '('],
11+
[{ mask: '(###) #', modelValue: '4567' }, '(456) 7'],
12+
[{ mask: '#### - #### - #### - ####', modelValue: '444444444' }, '4444 - 4444 - 4'],
13+
[{ mask: 'A## - ####', modelValue: 'A314444' }, 'A31 - 4444'],
14+
[{ mask: '## - ##', modelValue: '55' }, '55 - '],
15+
[{ mask: '## - ##', modelValue: '' }, ''],
16+
[{ mask: 'Aa', modelValue: 'aa' }, 'Aa'],
17+
[{ mask: 'aa', modelValue: 'AA' }, 'aa'],
18+
[{ mask: 'Aa', modelValue: 'A1' }, 'A'],
19+
[{ mask: 'NnNnNn', modelValue: '12abAB' }, '12AbAb'],
20+
[{ mask: '#a', modelValue: 'a' }, ''],
21+
[{ mask: '#)', modelValue: '1' }, '1)'],
22+
[{ mask: '(###)!!', modelValue: '123' }, '(123)!!'],
23+
[{ mask: '##.##', modelValue: '1234' }, '12.34'],
24+
[{ mask: '#', modelValue: null }, ''],
25+
[{ mask: '\\#(###)', modelValue: '123' }, '#(123)'],
26+
[{ mask: '\\####', modelValue: '1' }, '#1'],
27+
[{ mask: '+38(###)', modelValue: '43' }, '+38(43'],
28+
])('mask %#', (props, expected) => {
29+
const { mask } = useMask(props as MaskProps)
30+
expect(mask(props.modelValue)).toEqual(expected)
31+
})
32+
33+
it.each([
34+
[{ mask: '(#) (#)', modelValue: ' 5 6 ' }, '(5) (6)'],
35+
])('should trim spaces', (props, expected) => {
36+
const { mask } = useMask(props as MaskProps)
37+
expect(mask(props.modelValue)).toEqual(expected)
38+
})
39+
40+
it.each([
41+
[{ mask: '(#', modelValue: '(5' }, '5'],
42+
[{ mask: '####', modelValue: '1111' }, '1111'],
43+
[{ mask: '(###)#', modelValue: '(123)4' }, '1234'],
44+
[{ mask: '(###) #)', modelValue: '(456) 7)' }, '4567'],
45+
[{ mask: '#### - #### - #', modelValue: '4444 - 4444 - 4' }, '444444444'],
46+
[{ mask: 'NNN - ####', modelValue: 'A31 - 4444' }, 'A314444'],
47+
[{ mask: '\\#(###)', modelValue: '#(123)' }, '123'],
48+
[{ mask: '\\#(###)', modelValue: '123' }, '123'],
49+
[{ mask: '\\####', modelValue: '#(123)' }, '(123)'],
50+
[{ mask: '\\####', modelValue: '#1' }, '1'],
51+
[{ mask: '#-#', modelValue: '2-23' }, '223'],
52+
[{ mask: '+38(###)', modelValue: '+38(43' }, '43'],
53+
[{ mask: '+38(###)', modelValue: '43' }, '43'],
54+
[{ mask: '', modelValue: null }, null],
55+
])('unmask %#', (props, expected) => {
56+
const { unmask } = useMask(props as MaskProps)
57+
expect(unmask(props.modelValue)).toEqual(expected)
58+
})
59+
60+
it.each([
61+
['a', false],
62+
['-', true],
63+
])('isMaskDelimiter', (input, expected) => {
64+
expect(isMaskDelimiter(input)).toEqual(expected)
65+
})
66+
67+
describe('The test method', () => {
68+
it.each([
69+
[{ mask: '####', text: '1234' }, true],
70+
[{ mask: '####', text: '123' }, true],
71+
[{ mask: '####', text: '12345' }, false],
72+
[{ mask: '##/##/####', text: '12/34/5678' }, true],
73+
[{ mask: '##/##/####', text: '12345678' }, true],
74+
[{ mask: '##/##/####', text: '12/34/567' }, true],
75+
[{ mask: '##/##/####', text: '123456789' }, false],
76+
[{ mask: '(###) ###-####', text: '(123) 456-7890' }, true],
77+
[{ mask: '(###) ###-####', text: '1234567890' }, true],
78+
[{ mask: '(###) ###-####', text: '(123) 456-789' }, true],
79+
[{ mask: 'A##', text: 'A12' }, true],
80+
[{ mask: 'A##', text: 'a12' }, false],
81+
[{ mask: 'A##', text: 'A1' }, true],
82+
[{ mask: '', text: '' }, false],
83+
[{ mask: '', text: 'abc' }, true],
84+
])('should check if the text is valid for the mask', (props, expected) => {
85+
const { isValid } = useMask(props as MaskProps)
86+
expect(isValid(props.text)).toEqual(expected)
87+
})
88+
89+
it.each([
90+
[{ mask: '####', text: '1234' }, true],
91+
[{ mask: '####', text: '123' }, false],
92+
[{ mask: '####', text: '12345' }, false],
93+
[{ mask: '##/##/####', text: '12/34/5678' }, true],
94+
[{ mask: '##/##/####', text: '12345678' }, true],
95+
[{ mask: '##/##/####', text: '12/34/567' }, false],
96+
[{ mask: '##/##/####', text: '123456789' }, false],
97+
[{ mask: '(###) ###-####', text: '(123) 456-7890' }, true],
98+
[{ mask: '(###) ###-####', text: '1234567890' }, true],
99+
[{ mask: '(###) ###-####', text: '(123) 456-789' }, false],
100+
[{ mask: 'A##', text: 'A12' }, true],
101+
[{ mask: 'A##', text: 'a12' }, false],
102+
[{ mask: 'A##', text: 'A1' }, false],
103+
[{ mask: '', text: '' }, false],
104+
[{ mask: '', text: 'abc' }, false],
105+
])('should check if the text is complete for the mask', (props, expected) => {
106+
const { isComplete } = useMask(props as MaskProps)
107+
expect(isComplete(props.text)).toEqual(expected)
108+
})
109+
110+
it('should handle null and undefined text', () => {
111+
const { isValid } = useMask({ mask: '####' })
112+
expect(isValid('')).toBe(false)
113+
expect(isValid(null as any)).toBe(false)
114+
expect(isValid(undefined as any)).toBe(false)
115+
})
116+
})
117+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './mask'

packages/vuetify/src/composables/mask.ts renamed to packages/vuetify/src/composables/mask/mask.ts

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
// Utilities
2-
import { computed, shallowRef } from 'vue'
2+
import { computed } from 'vue'
33
import { isObject, propsFactory } from '@/util'
44

55
// Types
6-
import type { PropType, Ref } from 'vue'
6+
import type { PropType } from 'vue'
77

88
export interface MaskProps {
99
mask: string | MaskOptions | undefined
10-
returnMaskedValue?: boolean
1110
}
1211

1312
export interface MaskOptions {
@@ -17,7 +16,6 @@ export interface MaskOptions {
1716

1817
export const makeMaskProps = propsFactory({
1918
mask: [String, Object] as PropType<string | MaskOptions>,
20-
returnMaskedValue: Boolean,
2119
}, 'mask')
2220

2321
export type MaskItem = {
@@ -73,7 +71,7 @@ const defaultTokens: Record<string, MaskItem> = {
7371
},
7472
}
7573

76-
export function useMask (props: MaskProps, inputRef: Ref<HTMLInputElement | undefined>) {
74+
export function useMask (props: MaskProps) {
7775
const mask = computed(() => {
7876
if (typeof props.mask === 'string') {
7977
if (props.mask in presets) return presets[props.mask]
@@ -87,8 +85,6 @@ export function useMask (props: MaskProps, inputRef: Ref<HTMLInputElement | unde
8785
...(isObject(props.mask) ? props.mask.tokens : null),
8886
}
8987
})
90-
const selection = shallowRef(0)
91-
const lazySelection = shallowRef(0)
9288

9389
function isMask (char: string): boolean {
9490
return char in tokens.value
@@ -196,42 +192,23 @@ export function useMask (props: MaskProps, inputRef: Ref<HTMLInputElement | unde
196192
return newText
197193
}
198194

199-
function setCaretPosition (newSelection: number) {
200-
selection.value = newSelection
201-
inputRef.value && inputRef.value.setSelectionRange(selection.value, selection.value)
202-
}
203-
204-
function resetSelections () {
205-
if (!inputRef.value?.selectionEnd) return
195+
function isValid (text: string): boolean {
196+
if (!text) return false
206197

207-
selection.value = inputRef.value.selectionEnd
208-
lazySelection.value = 0
209-
210-
for (let index = 0; index < selection.value; index++) {
211-
isMaskDelimiter(inputRef.value.value[index]) || lazySelection.value++
212-
}
198+
return unmaskText(text) === unmaskText(maskText(text))
213199
}
214200

215-
function updateRange () {
216-
if (!inputRef.value) return
217-
resetSelections()
201+
function isComplete (text: string): boolean {
202+
if (!text) return false
218203

219-
let selection = 0
220-
const newValue = inputRef.value.value
221-
222-
if (newValue) {
223-
for (let index = 0; index < newValue.length; index++) {
224-
if (lazySelection.value <= 0) break
225-
isMaskDelimiter(newValue[index]) || lazySelection.value--
226-
selection++
227-
}
228-
}
229-
setCaretPosition(selection)
204+
const maskedText = maskText(text)
205+
return maskedText.length === mask.value.length && isValid(text)
230206
}
231207

232208
return {
233-
updateRange,
234-
maskText,
235-
unmaskText,
209+
isValid,
210+
isComplete,
211+
mask: maskText,
212+
unmask: unmaskText,
236213
}
237214
}

0 commit comments

Comments
 (0)