diff --git a/packages/docs/page-config/composables/input-mask/examples/CreditCard.vue b/packages/docs/page-config/composables/input-mask/examples/CreditCard.vue new file mode 100644 index 0000000000..bf8fb82355 --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/examples/CreditCard.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/docs/page-config/composables/input-mask/examples/Date.vue b/packages/docs/page-config/composables/input-mask/examples/Date.vue new file mode 100644 index 0000000000..951d456400 --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/examples/Date.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/docs/page-config/composables/input-mask/examples/DefaultRegex.vue b/packages/docs/page-config/composables/input-mask/examples/DefaultRegex.vue new file mode 100644 index 0000000000..418b87f469 --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/examples/DefaultRegex.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/docs/page-config/composables/input-mask/examples/Ipv6Regex.vue b/packages/docs/page-config/composables/input-mask/examples/Ipv6Regex.vue new file mode 100644 index 0000000000..d2ffdc1bf4 --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/examples/Ipv6Regex.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/docs/page-config/composables/input-mask/examples/Numeral.vue b/packages/docs/page-config/composables/input-mask/examples/Numeral.vue new file mode 100644 index 0000000000..e922f99601 --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/examples/Numeral.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/docs/page-config/composables/input-mask/examples/Phone.vue b/packages/docs/page-config/composables/input-mask/examples/Phone.vue new file mode 100644 index 0000000000..4f2a6103de --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/examples/Phone.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/docs/page-config/composables/input-mask/examples/PhoneExtended.vue b/packages/docs/page-config/composables/input-mask/examples/PhoneExtended.vue new file mode 100644 index 0000000000..a260973643 --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/examples/PhoneExtended.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/docs/page-config/composables/input-mask/index.ts b/packages/docs/page-config/composables/input-mask/index.ts new file mode 100644 index 0000000000..de4fa32d67 --- /dev/null +++ b/packages/docs/page-config/composables/input-mask/index.ts @@ -0,0 +1,69 @@ +export default definePageConfig({ + blocks: [ + block.title('Input Mask'), + block.paragraph('Masks are used to format the input value. Instead of having built-in masks we provide a composable, that you can use with any input.'), + block.paragraph('Vuestic UI comes with a few predefined masks. You need to manually import them and use with `useInputMask` composable.'), + block.paragraph('`useInputMask` composable is designed to work with Regex based masks. It is a flexible solution that allows you to create any mask you need.'), + + block.subtitle('Examples'), + + block.example('DefaultRegex', { + title: 'Regex mask', + description: 'When creating a mask we use regex syntax, that allows you to deeply customize the mask.', + }), + + block.collapse('Regex basic syntax', [ + block.paragraph('Here is a list of regex tokens that you can use to create a mask:'), + + block.list([ + '`\\d` - any digit', + '`\\w` - any word character', + '`.` - any character', + '`[a-z]` - any character from a to z. Notice, can be also `[a-f]` - any character from a to f', + '`[0-9]` - any digit', + ]), + + block.paragraph('Mask support quantifiers syntax, allowing you to specify how many times the character should appear. Here are some examples:'), + + block.list([ + '`\\d?` - any digit or nothing', + '`\\d*` - any number of digits', + '`\\d+` - at least one digit', + '`\\d{3}` - exactly 3 digits', + '`\\d{3,}` - at least 3 digits', + '`\\d{3,5}` - from 3 to 5 digits', + ]), + + block.paragraph('Obviously, you can replace `\\d` with any token. You can also use groups to group characters, for example `(\\d - \\d)?`'), + block.alert('Notice that maximum repetition is 10.', 'warning'), + + block.paragraph('In case you need to use brackets in the mask, you need to escape them: `\\(\\d{3}\\)`'), + + block.paragraph('You can also use `|` to separate different options: `\\d{3}|\\w{3}`'), + ]), + + block.example('CreditCard', { + title: 'Credit card', + description: 'You can easily define credit card mask with regex', + }), + + block.example('Date', { + title: 'Date mask', + description: 'Date mask is a predefined mask that allows you to format the date input.', + }), + + block.example('Numeral', { + title: 'Numeral mask', + description: 'Numeral mask is a predefined mask that allows you to format the number input. You can decide if decimal is allowed and how many decimal places are allowed.', + }), + + block.example('Phone', { + title: 'Phone mask', + description: 'There is no predefined phone mask. You can create your own mask using regex syntax. This is an example for Ukrainian phone number in internationl', + }), + + block.paragraph('You can also create regex masks for any other phone format and use format functions based on user input. You can also write format function in plain JS and we will handle the rest for you.'), + + block.example('PhoneExtended') + ] +}) diff --git a/packages/docs/page-config/navigationRoutes.ts b/packages/docs/page-config/navigationRoutes.ts index 4a5a21231c..e59c7e72c8 100644 --- a/packages/docs/page-config/navigationRoutes.ts +++ b/packages/docs/page-config/navigationRoutes.ts @@ -131,6 +131,20 @@ export const navigationRoutes: NavigationRoute[] = [ }, ], }, + { + name: 'composables', + displayName: 'Composables', + disabled: true, + children: [ + { + name: 'input-mask', + displayName: 'Input Mask', + meta: { + badge: navigationBadge.new('1.10.0'), + }, + } + ] + }, { name: "ui-elements", displayName: "UI Elements", diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index 9f92c7a5d8..b8566773be 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -75,4 +75,5 @@ export * from './useElementTextColor' export * from './useElementBackground' export * from './useImmediateFocus' export * from './useNumericProp' +export * from './useInputMask' export * from './useElementRect' diff --git a/packages/ui/src/composables/useInputMask/cursor.ts b/packages/ui/src/composables/useInputMask/cursor.ts new file mode 100644 index 0000000000..5084d903e1 --- /dev/null +++ b/packages/ui/src/composables/useInputMask/cursor.ts @@ -0,0 +1,97 @@ +import { MaskToken } from './mask' + +export enum CursorPosition { + BeforeChar = -1, + Any = 0, + AfterChar = 1 +} + +export class Cursor extends Number { + constructor (public position: number, private tokens: Token[], private reversed: boolean = false) { + super(position) + } + + private move (direction: -1 | 1, amount: number, cursorPosition = CursorPosition.Any) { + if (this.tokens.every((t) => t.static)) { + if (direction === 1) { + this.position = this.tokens.length + return this.position + } else { + this.position = 0 + return this.position + } + } + + for (let i = this.position; i <= this.tokens.length && i >= -1; i += direction) { + const current = this.tokens[i] + const next = this.tokens[i + direction] + const prev = this.tokens[i - direction] + + if (amount < 0) { + this.position = i + return this.position + } + + if (!current?.static) { + amount-- + } + + if (cursorPosition <= CursorPosition.Any) { + if (direction === -1 && !next?.static && current?.static) { + amount-- + } + if (direction === 1 && !prev?.static && current?.static) { + amount-- + } + } + + if (cursorPosition >= CursorPosition.Any) { + if (direction === 1 && !prev?.static && current === undefined) { + amount-- + } else if (direction === 1 && current === undefined && next?.static) { + amount-- + } else if (direction === 1 && current === undefined && next === undefined) { + amount-- + } + } + + if (amount < 0) { + this.position = i + return this.position + } + } + + return this.position + } + + moveBack (amount: number, cursorPosition = CursorPosition.Any) { + return this.move(-1, amount, cursorPosition) + } + + moveForward (amount: number, cursorPosition = CursorPosition.Any) { + return this.move(1, amount, cursorPosition) + } + + updateTokens (newTokens: Token[], fromEnd: boolean = false) { + if (fromEnd) { + // When reversed, we need to update position from the end + this.position = this.tokens.length - this.position + this.tokens = newTokens + this.position = this.tokens.length - this.position + } else { + this.tokens = newTokens + } + } + + valueOf () { + if (this.position < 0) { + return 0 + } + + if (this.position > this.tokens.length) { + return this.tokens.length + } + + return this.position + } +} diff --git a/packages/ui/src/composables/useInputMask/index.ts b/packages/ui/src/composables/useInputMask/index.ts new file mode 100644 index 0000000000..e4488a9432 --- /dev/null +++ b/packages/ui/src/composables/useInputMask/index.ts @@ -0,0 +1,4 @@ +export { useInputMask } from './useInputMask' +export { createMaskFromRegex, compareWithMask } from './masks/regex' +export { createNumeralMask } from './masks/numeral' +export { createMaskDate } from './masks/date' diff --git a/packages/ui/src/composables/useInputMask/mask.ts b/packages/ui/src/composables/useInputMask/mask.ts new file mode 100644 index 0000000000..7a327f308a --- /dev/null +++ b/packages/ui/src/composables/useInputMask/mask.ts @@ -0,0 +1,16 @@ +import { Cursor } from './cursor' + +export type MaskToken = { + static: boolean, +} + +export type Mask = { + format: (text: string) => { + text: string, + tokens: Token[] + data?: Data, + }, + handleCursor: (selectionStart: Cursor, selectionEnd: Cursor, oldTokens: Token[], newTokens: Token[], text: string, data?: Data) => any, + unformat: (text: string, tokens: Token[]) => string, + reverse: boolean +} diff --git a/packages/ui/src/composables/useInputMask/masks/date.ts b/packages/ui/src/composables/useInputMask/masks/date.ts new file mode 100644 index 0000000000..36fd091a03 --- /dev/null +++ b/packages/ui/src/composables/useInputMask/masks/date.ts @@ -0,0 +1,138 @@ +import { CursorPosition } from '../cursor' +import { Mask, MaskToken } from '../mask' + +type MaskTokenDate = MaskToken & { + expect: 'm' | 'd' | 'y' | string, +} + +const parseTokens = (format: string) => { + return format.split('').map((char) => { + if (char === 'm' || char === 'd' || char === 'y') { + return { static: false, expect: char } + } + + return { static: true, expect: char } + }) +} + +type MinorToken = { value: string, expect: string, static: boolean } +type MajorToken = { value: string, expect: string, tree: MinorToken[] } + +const getMaxDays = (year: number, month: number) => { + if (month === 2) { + return year % 4 === 0 ? 29 : 28 + } + + if ([4, 6, 9, 11].includes(month)) { + return 30 + } + + return 31 +} + +export const createMaskDate = (format: string = 'yyyy/mm/dd'): Mask => { + const tokens = parseTokens(format) + + return { + format: (text: string) => { + const minorTokens = [] as MinorToken[] + let additionalTokens = 0 + let valueOffset = 0 + let tokenOffset = 0 + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[tokenOffset] + + if (token.static) { + minorTokens.push({ value: token.expect, expect: token.expect, static: true }) + tokenOffset++ + + if (token.expect === text[i]) { + valueOffset++ + } else { + additionalTokens++ + } + continue + } + + if (text[valueOffset] === undefined) { + break + } + + if (!/\d/.test(text[valueOffset])) { + valueOffset++ + continue + } + + minorTokens.push({ value: text[valueOffset], expect: token.expect, static: false }) + valueOffset++ + tokenOffset++ + } + + const majorTokens = minorTokens.reduce((acc, p, index) => { + if (acc[acc.length - 1]?.expect === p.expect) { + acc[acc.length - 1].value += p.value + acc[acc.length - 1].tree.push(p) + return acc + } + + acc.push({ + value: p.value, + expect: p.expect, + tree: [p], + }) + + return acc + }, [] as MajorToken[]) + + const year = majorTokens.find((p) => p.expect === 'y') + const month = majorTokens.find((p) => p.expect === 'm') + + majorTokens.forEach((p) => { + if (p.expect === 'm') { + const num = parseInt(p.value) + + if (num > 12) { + p.value = '12' + } + + if (num > 1 && num < 10) { + p.value = '0' + num + additionalTokens += 1 + } + } + + if (p.expect === 'd') { + const num = parseInt(p.value) + + const maxDays = getMaxDays(Number(year?.value), Number(month?.value)) + + if (num > maxDays) { + p.value = maxDays.toString() + } + + if (num > 3 && num < 10) { + p.value = '0' + num + additionalTokens += 1 + } + } + }) + + return { + text: majorTokens.reduce((acc, p) => acc + p.value, ''), + tokens: tokens, + data: additionalTokens, + } + }, + handleCursor (cursorStart, cursorEnd, tokens, newTokens, data, additionalTokens = 0) { + cursorStart.updateTokens(newTokens) + cursorEnd.updateTokens(newTokens) + cursorStart.moveForward(data.length + additionalTokens, CursorPosition.Any) + cursorEnd.position = cursorStart.position + }, + unformat: (text: string, tokens: MaskTokenDate[]) => { + return text.replace(/\//g, '') + }, + reverse: false, + } +} diff --git a/packages/ui/src/composables/useInputMask/masks/numeral.ts b/packages/ui/src/composables/useInputMask/masks/numeral.ts new file mode 100644 index 0000000000..816f75cada --- /dev/null +++ b/packages/ui/src/composables/useInputMask/masks/numeral.ts @@ -0,0 +1,49 @@ +import { Mask, MaskToken } from '../mask' +import { createMaskFromRegex, RegexToken } from './regex' + +const DELIMITER = ' ' +const DECIMAL = '.' + +type NumeralToken = RegexToken & { isDecimal?: boolean} + +export const createNumeralMask = (): Mask => { + const intMask = createMaskFromRegex(/(\d{3} )*(\d{3})/, { reverse: true }) + const decimalMask = createMaskFromRegex(/(\d{3} )*(\d{3})/, { reverse: false }) + + return { + format: (text: string) => { + const hasDecimal = text.includes(DECIMAL) + + if (!hasDecimal) { + return intMask.format(text) + } + + const [int = '', decimal = '', ...rest] = text.split(DECIMAL) + + const intResult = intMask.format(int) + const decimalResult = decimalMask.format(decimal + rest.join('')) + + return { + text: intResult.text + DECIMAL + decimalResult.text, + tokens: [...intResult.tokens, { type: 'char', static: false, expect: DECIMAL, isDecimal: true }, ...decimalResult.tokens] as NumeralToken[], + } + }, + handleCursor (selectionStart, selectionEnd, oldTokens, newTokens, data) { + const decimalIndex = newTokens.findIndex((token) => token.isDecimal) + + if (decimalIndex === -1) { + return intMask.handleCursor(selectionStart, selectionEnd, oldTokens, newTokens, data) + } + + if (selectionStart.position < decimalIndex) { + intMask.handleCursor(selectionStart, selectionEnd, oldTokens, newTokens, data) + } else { + decimalMask.handleCursor(selectionStart, selectionEnd, oldTokens, newTokens, data) + } + }, + unformat: (text: string, tokens: MaskToken[]) => { + return parseFloat(text.replace(/ /g, '')).toString() + }, + reverse: false, + } +} diff --git a/packages/ui/src/composables/useInputMask/masks/parser.ts b/packages/ui/src/composables/useInputMask/masks/parser.ts new file mode 100644 index 0000000000..af5e21ec0d --- /dev/null +++ b/packages/ui/src/composables/useInputMask/masks/parser.ts @@ -0,0 +1,228 @@ +interface TokenBase { + type: string + expect: string +} + +interface TokenChar extends TokenBase { + type: 'char' +} + +interface TokenRegex extends TokenBase { + type: 'regex' +} + +interface TokenRepeated extends TokenBase { + type: 'repeated', + tree: Token[] + min: number, + max: number, + content: string +} + +interface TokenGroup extends TokenBase { + type: 'group', + tree: Token[] +} + +interface TokenOrRegex extends TokenBase { + type: 'or regex', + left: Token[], + right: Token[] +} + +export type Token = TokenChar | TokenRegex | TokenRepeated | TokenGroup | TokenOrRegex + +const or = (...args: RegExp[]) => new RegExp(args.map((r) => r.source).join("|"), 'g') + +const TOKEN_SPLIT_REGEX = or( + /(\{[^}]*\})/,// Token required to have limits {1, 3}, {1,}, {1} + /(\\[dws.])/, + /(^\([^)]*\)$)/, // group like (test) + /(\[[^\]]*\])/, // split by [^3]{1}, [a-z], [0-9]{1, 3} + /(?:)/ // split for each letter + ) + +/** + * Checks if the symbol contains correct (.) + * + * @example + * `(.)(.)` must be invalid - two groups + * `((.)(.))` is valid - single group with nested groups + */ +const isMaskSingleGroup = (symbol: string) => { + if (!symbol.startsWith('(' ) || !symbol.endsWith(')')) { return false } + + let groupDepth = 0 + for (let i = 0; i < symbol.length; i++) { + const char = symbol[i] + if (char === '(') { + groupDepth += 1 + } else if (char === ')') { + groupDepth -= 1 + + if (groupDepth === 0 && i !== symbol.length - 1) { + return false + } + } + } + + return groupDepth === 0 +} + +/** + * Parse raw tokens (strings). Split tokens into groups: char, (), {}, [], etc. + * + * @example + * + * `(.)(.){1,2}` -> ['(.)', '(.){1,2}'] + * `((.)|(.)){2}text(.)` -> ['((.)|(.)){2}', 'text', '(.)'] + */ +const parseRawTokens = (symbol: string) => { + let group = 0 + let groups = [] + let currentChunk = '' + + let i = 0 + + while (i < symbol.length) { + if (symbol[i] === '(' && symbol[i - 1] !== '\\') { + if (group === 0 && currentChunk.length > 0) { + groups.push(...currentChunk.split(TOKEN_SPLIT_REGEX).filter((v) => v !== '' && v !== undefined)) + currentChunk = '' + } + group += 1 + } + + currentChunk += symbol[i] + + if (symbol[i] === ')' && symbol[i - 1] !== '\\') { + group -= 1 + + if (group === 0 && currentChunk.length > 0) { + groups.push(currentChunk) + currentChunk = '' + } + } + + i++ + } + + if (currentChunk.length > 0) { + groups.push(...currentChunk.split(TOKEN_SPLIT_REGEX).filter((v) => v !== '' && v !== undefined)) + } + + return groups + .map((g) => g.replace(/^\?[:!>]/, '')) // Remove group modifiers + .filter((v) => v !== '' && v !== undefined) +} + +// TODO: Maybe add more symbols to support +const RESERVED_SLASH_SYMBOLS = ['d', 'D', 'w', 'W', 's', 'S', '.'] + +const MAX_REPEATED = 10 + +/** Build ast of tokens */ +export const parseTokens = (mask: string, directlyInGroup = false): Token[] => { + let tokens: Token[] = [] + + // Handle or in group, if single group - treat as directly in group + if (isMaskSingleGroup(mask)) { + mask = mask.slice(1, -1) // Remove brackets + directlyInGroup = true + } + + const rawTokens = parseRawTokens(mask) + + for (let i = 0; i < rawTokens.length; i++) { + const rawToken = rawTokens[i] + + if (rawToken === '\\') { + // Ignore \, it is used in pair with next token + continue + } + + if (!RESERVED_SLASH_SYMBOLS.includes(rawToken) && rawTokens[i - 1] === '\\') { + tokens.push({ type: 'char', expect: rawToken }) + continue + } + + if (rawToken === '|') { + if (directlyInGroup) { + tokens = [{ + type: 'or regex', + expect: mask, + left: [...tokens], + right: parseTokens(`(${rawTokens.slice(i + 1).join('')})`) + }] + + break + } + + const prevToken = tokens.pop()! + const nextToken = parseTokens(rawTokens[i + 1]) + + tokens.push({ + type: 'or regex', + expect: `${prevToken}|${rawTokens[i + 1]}`, + left: [prevToken], + right: nextToken + }) + + continue + } + + if (rawToken.startsWith('{') && rawToken.endsWith('}') && rawToken.length > 2) { + const [v, min, delimiter, max] = rawToken.split(/\{(\d+)(,\s?)?(\d+)?\}$/) + + const prevToken = tokens.pop()! + + tokens.push({ + type: 'repeated', + expect: prevToken.expect + rawToken, + tree: [prevToken], + min: parseInt(min), + max: max ? parseInt(max) : delimiter ? MAX_REPEATED : parseInt(min), + content: rawToken + }) + continue + } + + if (rawToken.endsWith('*')) { + const prevToken = tokens.pop()! + tokens.push({ type: 'repeated', expect: prevToken.expect + rawToken, tree: [prevToken], min: 0, max: MAX_REPEATED, content: prevToken.expect }) + continue + } + + if (rawToken.endsWith('+')) { + const prevToken = tokens.pop()! + tokens.push({ type: 'repeated', expect: prevToken.expect + rawToken, tree: [prevToken], min: 1, max: MAX_REPEATED, content: prevToken.expect }) + continue + } + + if (rawToken.endsWith('?')) { + const prevToken = tokens.pop()! + tokens.push({ type: 'repeated', expect: prevToken.expect + rawToken, tree: [prevToken], min: 0, max: 1, content: prevToken.expect }) + continue + } + + if (['$', '^'].includes(rawToken)) { + // Ignore start and end of the string - they're not important for masking + continue + } + + if (rawToken.startsWith('(') && rawToken.endsWith(')')) { + const tree = parseTokens(rawToken.slice(1, -1), true) + tokens.push({ type: 'group', expect: rawToken, tree }) + continue + } + + if (rawToken.length === 1 && rawToken !== '.') { + tokens.push({ type: 'char', expect: rawToken }) + continue + } + + tokens.push({ type: 'regex', expect: rawToken }) + } + + return tokens +} diff --git a/packages/ui/src/composables/useInputMask/masks/regex.ts b/packages/ui/src/composables/useInputMask/masks/regex.ts new file mode 100644 index 0000000000..90dcfc027b --- /dev/null +++ b/packages/ui/src/composables/useInputMask/masks/regex.ts @@ -0,0 +1,294 @@ +import { CursorPosition } from '../cursor' +import { type Mask } from '../mask' +import { Token, parseTokens } from './parser' + +export type RegexToken = { + /** + * Char means it is single char and we can compare input value using simple `===` + * Regex means we need to use regex to compare input value (e.g. `\d`, `[a-z]`) + */ + type: 'char' | 'regex', + /** + * Expected character or regex source + */ + expect: string, + /** + * Static means users forced to input this char, meaning masked input can suggest this char + */ + static: boolean, + + /** + * Dynamic means this char is not forced and can be skipped + */ + dynamic: boolean +} + +type PossibleResult = RegexToken[] + +export const normalizeTokens = (tokens: Token[], dynamic = false) => { + let possibleResults: PossibleResult[] = [[]] + + for (const token of tokens) { + if (token.type === 'group') { + const newResults: PossibleResult[] = [] + possibleResults.forEach((result) => { + normalizeTokens(token.tree, dynamic).forEach((result2) => { + newResults.push([...result, ...result2]) + }) + }) + possibleResults = newResults + } + + if (token.type === 'char' || token.type === 'regex') { + const newResults: PossibleResult[] = [] + possibleResults.forEach((result) => { + newResults.push([...result, { + type: token.type, + expect: token.expect, + static: token.type === 'char' && (!dynamic || result.length > 0), + dynamic: dynamic, + }]) + }) + possibleResults = newResults + } + + if (token.type === 'repeated') { + const possibleResults2: PossibleResult[] = [] + for (let i = token.min; i <= token.max && i <= 100; i++) { + const isDynamic = i !== token.min + + normalizeTokens(token.tree, isDynamic || dynamic).forEach((result) => { + const repeated = (new Array(i).fill(result)).flat() as RegexToken[] + possibleResults2.push(repeated) + }) + } + + const newResults: PossibleResult[] = [] + possibleResults.forEach((result) => { + possibleResults2.forEach((result2) => { + newResults.push([...result, ...result2]) + }) + }) + possibleResults = newResults + } + + if (token.type === 'or regex') { + const newPossibleResults: PossibleResult[] = [] + + possibleResults.forEach((existingResult) => { + normalizeTokens(token.left, true).forEach((result) => { + newPossibleResults.push([...existingResult, ...result]) + }) + + normalizeTokens(token.right, true).forEach((result) => { + newPossibleResults.push([...existingResult, ...result]) + }) + }) + + possibleResults = newPossibleResults + } + } + + return possibleResults + .reduce((acc, result) => { + if (acc.find((r) => r.length === result.length && r.every((t, i) => t.expect === result[i].expect))) { + return acc + } + + return [...acc, result] + }, [] as PossibleResult[]) +} + +export const compareWithMask = (mask: PossibleResult, value: string) => { + if (!value) { return true } + + for (let i = 0; i < mask.length; i++) { + if (value[i] === undefined) { + return true + } + if (mask[i].type === 'char' && mask[i].expect !== value?.[i]) { + return false + } + + if (mask[i].type === 'regex' && !new RegExp(mask[i].expect).test(value[i])) { + return false + } + } + + return value.length <= mask.length +} + +const compareWithToken = (token: Token, value: string) => { + if (token.type === 'char' && token.expect !== value) { + return false + } + + if (token.type === 'regex' && !new RegExp(token.expect).test(value)) { + return false + } + + return true +} + +const formatByRegexTokens = (possibleResults: PossibleResult[], value: string, reverse = false) => { + if (reverse) { + possibleResults = possibleResults.map((result) => result.slice().reverse()) + value = value.split('').reverse().join('') + } + + // TODO: Maybe optimize this? + let suggestedCharsCount = 0 + let text = '' + let valueOffset = 0 + let tokensOffset = 0 + + const maxPossibleMask = possibleResults.reduce((acc, mask) => Math.max(acc, mask.length), 0) + const foundTokens: (RegexToken)[] = [] + + while (valueOffset < value.length || tokensOffset < maxPossibleMask) { + // Filter out possible results that not match with current text + possibleResults = possibleResults + .filter((tokens) => { + return compareWithMask(tokens, text) + }) + + const possibleToken = possibleResults + .map((mask) => mask[tokensOffset]) + .filter((token) => token !== undefined) + + if (possibleToken.length === 0) { + break + } + + const possibleSuggestions = possibleToken.filter((token) => token.type === 'char') + + const staticCharts = possibleToken.filter((token) => token.static) + + const isOnePossibleStaticChar = staticCharts.reduce((acc, char) => { + if (acc === null) { + return char + } + + if (acc.expect !== char.expect) { + return null + } + + return acc + }, null as RegexToken | null) + + if (possibleSuggestions.length > 0) { + const suggestedChar = possibleSuggestions[0]?.expect ?? '' + let canBeSuggested = possibleSuggestions.every((token) => token.expect === suggestedChar) && value[valueOffset]?.length > 0 + + const onlyStaticLeft = possibleResults.length === 1 && possibleResults[0].slice(tokensOffset).every((token) => token.static) + + if (possibleSuggestions[0].dynamic) { + canBeSuggested = canBeSuggested && value[valueOffset]?.length > 0 + } + + if (isOnePossibleStaticChar && value[valueOffset]?.length > 0) { + canBeSuggested = value[valueOffset] !== isOnePossibleStaticChar.expect + } + + if (possibleToken.some((token) => compareWithToken(token, value[valueOffset]))) { + canBeSuggested = false + } + + if (onlyStaticLeft) { + canBeSuggested = true + } + + if (canBeSuggested) { + if (suggestedChar !== value[valueOffset]) { + text += suggestedChar + foundTokens.push(possibleSuggestions[0]) + tokensOffset += 1 + suggestedCharsCount += 1 + continue + } + } + } + + if (valueOffset >= value.length) { + break + } + + const charCorrectTokens = possibleToken.filter((token) => { + if (token.type === 'char') { + return token.expect === value[valueOffset] + } + + if (token.type === 'regex') { + return new RegExp(token.expect).test(value[valueOffset]) + } + + return false + }) + + if (value[valueOffset] !== undefined) { + if (charCorrectTokens.length > 0) { + text += value[valueOffset] + foundTokens.push(charCorrectTokens[0]) + tokensOffset++ + } + } + + valueOffset++ + } + + if (reverse) { + return { + text: text.split('').reverse().join(''), + tokens: foundTokens.reverse(), + data: suggestedCharsCount, + } + } + + return { + text, + tokens: foundTokens, + data: suggestedCharsCount, + } +} + +const unformat = (text: string, tokens: RegexToken[]) => { + const value = text + + if (!value) { return '' } + + return tokens.reduce((acc, token, i) => { + if (token.static) { + return acc + } + + if (compareWithToken(token, value[i]) && value[i] !== undefined) { + return acc + value[i] + } + + return acc + }, '') +} + +export const createMaskFromRegex = (regex: RegExp, options = { reverse: false }): Mask => { + const tokens = parseTokens(regex.source) + const possibleResults = normalizeTokens(tokens) + + return { + format: (text: string) => { + return formatByRegexTokens(possibleResults, text, options.reverse) + }, + handleCursor (cursorStart, cursorEnd, oldTokens, newTokens, data, suggestedCount = 0) { + cursorStart.updateTokens(newTokens, options.reverse) + cursorEnd.updateTokens(newTokens, options.reverse) + + if (!options.reverse) { + cursorStart.moveForward(data.length, CursorPosition.AfterChar) + cursorEnd.position = cursorStart.position + } else { + cursorStart.position = cursorEnd.position + } + }, + unformat, + reverse: options.reverse, + } +} diff --git a/packages/ui/src/composables/useInputMask/tests/PossibleTokens.vue b/packages/ui/src/composables/useInputMask/tests/PossibleTokens.vue new file mode 100644 index 0000000000..4841f89b29 --- /dev/null +++ b/packages/ui/src/composables/useInputMask/tests/PossibleTokens.vue @@ -0,0 +1,121 @@ + + + diff --git a/packages/ui/src/composables/useInputMask/tests/Tokens.vue b/packages/ui/src/composables/useInputMask/tests/Tokens.vue new file mode 100644 index 0000000000..404e523126 --- /dev/null +++ b/packages/ui/src/composables/useInputMask/tests/Tokens.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/ui/src/composables/useInputMask/useInputMask.stories.ts b/packages/ui/src/composables/useInputMask/useInputMask.stories.ts new file mode 100644 index 0000000000..60ca5bc42c --- /dev/null +++ b/packages/ui/src/composables/useInputMask/useInputMask.stories.ts @@ -0,0 +1,239 @@ +import { computed, ref } from 'vue' +import { defineStory } from '../../../.storybook/types' +import { useInputMask } from './useInputMask' +import TokensRenderer from './tests/Tokens.vue' +import PossibleTokens from './tests/PossibleTokens.vue' +import { createMaskFromRegex } from './masks/regex' +import { createMaskDate } from './masks/date' +import { createNumeralMask } from './masks/numeral' +import { parseTokens } from './masks/parser' + +export default { + title: 'composables/useInputMask', + tags: ['autodocs'], +} + +export const Default = defineStory({ + story: () => ({ + components: { TokensRenderer, PossibleTokens }, + setup () { + const reverse = ref(false) + const value = ref('3809312345678') + const input = ref() + const regex = ref(/(\d{1,3}( \d{3}){1,2},?\d{1,3}|\d{3}|\d{1,3}( \d{3}){1,2})/.source) + const mask = computed(() => { + try { + if (!regex.value) { return /./ } + + return new RegExp(regex.value) + } catch { + return /./ + } + }) + + const tokens = computed(() => parseTokens(mask.value.source)) + const regexMask = computed(() => createMaskFromRegex(mask.value, { reverse: reverse.value })) + + const text = ref('') + + const { masked, unmasked } = useInputMask(regexMask, input) + + return { value, regex, input, masked, unmasked, text, tokens, reverse } + }, + template: ` +

Regex

+ + Reverse ({{ reverse}}) +

Text

+ + +

Tokens

+ + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const Phone = defineStory({ + story: () => ({ + setup () { + const value = ref('3809312345678') + const input = ref() + + const phoneMask = createMaskFromRegex(/\+(\d{1,3}) \(\d{2,3}\) \d\d\d-\d\d-\d\d/) + + const { masked, unmasked } = useInputMask(phoneMask, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const CreditCard = defineStory({ + story: () => ({ + setup () { + const value = ref('1111222233334444') + const input = ref() + + const creditCardMask = createMaskFromRegex(/(\d{4} ){3}\d{4}/) + const { masked, unmasked } = useInputMask(creditCardMask, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const WithOptionalGroup = defineStory({ + story: () => ({ + setup () { + const value = ref('') + const input = ref() + + const { masked, unmasked } = useInputMask(createMaskFromRegex(/(\+(\d{1,3}) )?\(\d{2,3}\) (\d){3}-\d\d-\d\d/), input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const WithOrGroup = defineStory({ + story: () => ({ + setup () { + const value = ref('') + const input = ref() + + const { masked, unmasked } = useInputMask(createMaskFromRegex(/\+(7 \(\d{3}\)|380 \(\d{2}\)) (\d){3}-\d\d-\d\d/), input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const Ipv6 = defineStory({ + story: () => ({ + setup () { + const value = ref('1234567890123456') + const input = ref() + + const ipv6Regex = createMaskFromRegex(/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))/) + + const { masked, unmasked } = useInputMask(ipv6Regex, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const Date = defineStory({ + story: () => ({ + setup () { + const value = ref('1234567890123456') + const input = ref() + + const dateMask = createMaskDate() + + const { masked, unmasked } = useInputMask(dateMask, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const Numeral = defineStory({ + story: () => ({ + setup () { + const value = ref('123456') + const input = ref() + + const numeralRegex = createMaskFromRegex(/(\d{3} - )*(\d{3})/) + const { masked, unmasked } = useInputMask(numeralRegex, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const ReversedNumeral = defineStory({ + story: () => ({ + setup () { + const value = ref('123456') + const input = ref() + + const numeralRegex = createMaskFromRegex(/(\d{3} - )*(\d{3})/, { reverse: true }) + const { masked, unmasked } = useInputMask(numeralRegex, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + +export const NumeralWithDecimal = defineStory({ + story: () => ({ + setup () { + const value = ref('123456') + const input = ref() + + const numeralRegex = createNumeralMask() + const { masked, unmasked } = useInputMask(numeralRegex, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) diff --git a/packages/ui/src/composables/useInputMask/useInputMask.ts b/packages/ui/src/composables/useInputMask/useInputMask.ts new file mode 100644 index 0000000000..adc62c6b91 --- /dev/null +++ b/packages/ui/src/composables/useInputMask/useInputMask.ts @@ -0,0 +1,136 @@ +import { ComponentPublicInstance, MaybeRef, Ref, computed, isRef, ref, unref, watch } from 'vue' +import { Mask, MaskToken } from './mask' +import { Cursor, CursorPosition } from './cursor' +import { unwrapEl } from '../../utils/unwrapEl' + +const extractInput = (el: HTMLElement | null | undefined | ComponentPublicInstance) => { + const htmlEl = unwrapEl(el) + + if (!htmlEl) { + return null + } + + if (htmlEl.tagName === 'INPUT') { + return htmlEl as HTMLInputElement + } + + return htmlEl.querySelector('input') +} + +export const useInputMask = (mask: MaybeRef>, el: Ref) => { + const inputText = ref('') + + const formatted = ref({ + text: '', + tokens: [], + data: undefined, + }) as Ref<{ + text: string, + tokens: Token[], + data?: any + }> + + const input = computed(() => extractInput(el.value)) + + const setInputValue = (value: string, options?: InputEventInit) => { + input.value!.value = value + input.value!.dispatchEvent(new InputEvent('input', options)) + } + + const onBeforeInput = (e: InputEvent) => { + const { inputType } = e + const eventTarget = e.target as HTMLInputElement + + const data = e.data === null ? '' : e.data + + const currentValue = eventTarget.value + + const selectionStart = eventTarget.selectionStart ?? 0 + const selectionEnd = eventTarget.selectionEnd ?? 0 + + const cursorStart = new Cursor(selectionStart, formatted.value!.tokens) + const cursorEnd = new Cursor(selectionEnd, formatted.value!.tokens) + + // All input types: https://w3c.github.io/input-events/#interface-InputEvent-Attributes + + if (inputType === 'deleteContentBackward') { + if (+cursorStart === +cursorEnd) { + // From 1[]2 to [1]2 + cursorStart.moveBack(1, CursorPosition.AfterChar) + } + } else if (inputType === 'deleteContentForward' || inputType === 'deleteContent' || inputType === 'deleteByCut') { + if (+cursorStart === +cursorEnd) { + // From 1[]23 to 1[2]3 + cursorEnd.moveForward(1, CursorPosition.AfterChar) + } + } + + const tokens = formatted.value.tokens + inputText.value = currentValue.slice(0, +cursorStart) + data + currentValue.slice(+cursorEnd) + formatted.value = unref(mask).format(inputText.value) + + unref(mask).handleCursor(cursorStart, cursorEnd, tokens, formatted.value.tokens, data, formatted.value.data) + + setInputValue(formatted.value!.text, e) + + eventTarget.setSelectionRange(+cursorStart, +cursorEnd) + + e.preventDefault() + } + + const onKeydown = (e: KeyboardEvent) => { + const el = e.target as HTMLInputElement + + if (e.key === 'ArrowLeft') { + if (el.selectionStart === el.selectionEnd) { + const cursor = new Cursor((el.selectionStart ?? 0), formatted.value!.tokens) + cursor.moveBack(1) + el.setSelectionRange(+cursor, +cursor) + } else { + el.setSelectionRange(el.selectionStart, el.selectionStart) + } + + e.preventDefault() + } + + if (e.key === 'ArrowRight') { + if (el.selectionStart === el.selectionEnd) { + const cursor = new Cursor((el.selectionEnd ?? 0), formatted.value!.tokens) + cursor.moveForward(1) + el.setSelectionRange(+cursor, +cursor) + } else { + el.setSelectionRange(el.selectionEnd, el.selectionEnd) + } + e.preventDefault() + } + } + + watch(input, (newValue, oldValue) => { + if (newValue) { + const input = extractInput(newValue) + + formatted.value = unref(mask).format(newValue.value) + const cursor = new Cursor((newValue.selectionEnd ?? 0), formatted.value!.tokens) + cursor.moveForward(1) + setInputValue(formatted.value.text) + newValue.setSelectionRange(+cursor, +cursor) + + newValue.addEventListener('beforeinput', onBeforeInput) + newValue.addEventListener('keydown', onKeydown) + } + if (oldValue) { + oldValue.removeEventListener('beforeinput', onBeforeInput) + oldValue.removeEventListener('keydown', onKeydown) + } + }, { immediate: true }) + + const unmasked = computed(() => { + return unref(mask).unformat(formatted.value.text, formatted.value.tokens) + }) + + return { + inputText: formatted, + masked: computed(() => formatted.value?.text ?? ''), + unmasked, + } +} diff --git a/packages/ui/src/main.ts b/packages/ui/src/main.ts index 99d87ec70b..44c1ed327f 100644 --- a/packages/ui/src/main.ts +++ b/packages/ui/src/main.ts @@ -6,6 +6,11 @@ export { useIcon as useIcons, type ValidationRule, useForm, + useInputMask, + createMaskFromRegex, + createNumeralMask, + createMaskDate, + compareWithMask, } from './composables' export * from './services/vue-plugin'