diff --git a/packages/ui/src/composables/useInputMask/cursor.spec.ts b/packages/ui/src/composables/useInputMask/cursor.spec.ts new file mode 100644 index 0000000000..e9b1d5a9d4 --- /dev/null +++ b/packages/ui/src/composables/useInputMask/cursor.spec.ts @@ -0,0 +1,157 @@ +import { type MaskToken } from './mask' +import { describe, it, expect } from 'vitest' + +import { Cursor, CursorPosition } from './cursor' + +const makeTokens = (str: string) => str.split('').map((char) => ({ static: char === 's' })) as MaskToken[] + +const printCursor = (cursor: Cursor) => { + const str = (cursor as any).tokens.map((t: MaskToken) => t.static ? 's' : 'd') as string[] + const i = cursor.position + str[i] = '|' + (str[i] ?? '') + return str.join('') +} + +describe('useInputMask/Cursor', () => { + describe('Before Char', () => { + describe('move forward', () => { + it('should move over one static token', () => { + const cursor = new Cursor(0, makeTokens('dsd')) + + expect(printCursor(cursor)).toBe('|dsd') + cursor.moveForward(1, CursorPosition.BeforeChar) + expect(printCursor(cursor)).toBe('ds|d') + }) + + it('should move over multiple static token', () => { + const cursor = new Cursor(0, makeTokens('dsssd')) + + expect(printCursor(cursor)).toBe('|dsssd') + cursor.moveForward(1, CursorPosition.BeforeChar) + expect(printCursor(cursor)).toBe('dsss|d') + }) + }) + + describe('move back', () => { + it('should move over one static token', () => { + const cursor = new Cursor(3, makeTokens('dsd')) + + expect(printCursor(cursor)).toBe('dsd|') + cursor.moveBack(1, CursorPosition.BeforeChar) + expect(printCursor(cursor)).toBe('ds|d') + cursor.moveBack(1, CursorPosition.BeforeChar) + expect(printCursor(cursor)).toBe('|dsd') + }) + + it('should move over multiple static token', () => { + const cursor = new Cursor(5, makeTokens('dsssd')) + + cursor.moveBack(1, CursorPosition.BeforeChar) + expect(printCursor(cursor)).toBe('dsss|d') + + cursor.moveBack(1, CursorPosition.BeforeChar) + expect(printCursor(cursor)).toBe('|dsssd') + }) + }) + }) + + describe('After Char', () => { + describe('move forward', () => { + it('should move over one static token', () => { + const cursor = new Cursor(0, makeTokens('dsd')) + + cursor.moveForward(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('d|sd') + + cursor.moveForward(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('dsd|') + }) + + it('should move over multiple static token', () => { + const cursor = new Cursor(0, makeTokens('dsssd')) + + cursor.moveForward(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('d|sssd') + + cursor.moveForward(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('dsssd|') + }) + }) + describe('move back', () => { + it('should move over one static token', () => { + const cursor = new Cursor(3, makeTokens('dsd')) + + cursor.moveBack(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('d|sd') + + cursor.moveBack(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('|dsd') + }) + + it('should move over multiple static token', () => { + const cursor = new Cursor(5, makeTokens('dsssd')) + + cursor.moveBack(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('d|sssd') + + cursor.moveBack(1, CursorPosition.AfterChar) + expect(printCursor(cursor)).toBe('|dsssd') + }) + }) + }) + + describe('Any Char p/move forward', () => { + describe('move forward', () => { + it('should move over one static token', () => { + const cursor = new Cursor(0, makeTokens('dsd')) + + cursor.moveForward(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('d|sd') + + cursor.moveForward(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('ds|d') + }) + + it('should move over multiple static token', () => { + const cursor = new Cursor(0, makeTokens('dsssd')) + + cursor.moveForward(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('d|sssd') + + cursor.moveForward(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('dsss|d') + + cursor.moveForward(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('dsssd|') + }) + }) + + describe('move back', () => { + it('should move over one static token', () => { + const cursor = new Cursor(3, makeTokens('dsd')) + + cursor.moveBack(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('ds|d') + + cursor.moveBack(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('d|sd') + + cursor.moveBack(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('|dsd') + }) + + it('should move over multiple static token', () => { + const cursor = new Cursor(5, makeTokens('dsssd')) + + cursor.moveBack(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('dsss|d') + + cursor.moveBack(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('d|sssd') + + cursor.moveBack(1, CursorPosition.Any) + expect(printCursor(cursor)).toBe('|dsssd') + }) + }) + }) +}) diff --git a/packages/ui/src/composables/useInputMask/cursor.ts b/packages/ui/src/composables/useInputMask/cursor.ts index 5084d903e1..a3c525363d 100644 --- a/packages/ui/src/composables/useInputMask/cursor.ts +++ b/packages/ui/src/composables/useInputMask/cursor.ts @@ -24,40 +24,55 @@ export class Cursor extends Number { 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] + const next = this.tokens[i + direction] as MaskToken || undefined + const prev = this.tokens[i - direction] as MaskToken || undefined - if (amount < 0) { + if (amount === 0) { this.position = i return this.position } - if (!current?.static) { - amount-- + if (next === undefined && current === undefined) { + this.position = i + return this.position } - if (cursorPosition <= CursorPosition.Any) { - if (direction === -1 && !next?.static && current?.static) { + if (cursorPosition === CursorPosition.AfterChar) { + if (current && !current.static && direction > 0) { amount-- + continue } - if (direction === 1 && !prev?.static && current?.static) { + if (!next?.static && direction < 0 && i !== this.position) { amount-- + if (amount === 0) { + this.position = i + return this.position + } + continue } } - - 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) { + if (cursorPosition === CursorPosition.BeforeChar) { + if (!next?.static) { amount-- + continue } } - if (amount < 0) { - this.position = i - return this.position + if (cursorPosition === CursorPosition.Any) { + if ((!current?.static || !next?.static) && direction > 0) { + amount-- + continue + } + + if (direction < 0) { + if (next && !next.static) { + amount-- + if (i !== this.position) { + this.position = i + return this.position + } + } + } } } diff --git a/packages/ui/src/composables/useInputMask/mask.ts b/packages/ui/src/composables/useInputMask/mask.ts index 7a327f308a..2a02863501 100644 --- a/packages/ui/src/composables/useInputMask/mask.ts +++ b/packages/ui/src/composables/useInputMask/mask.ts @@ -12,5 +12,4 @@ export type Mask = { }, 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 index 36fd091a03..2352a12398 100644 --- a/packages/ui/src/composables/useInputMask/masks/date.ts +++ b/packages/ui/src/composables/useInputMask/masks/date.ts @@ -1,9 +1,10 @@ import { CursorPosition } from '../cursor' import { Mask, MaskToken } from '../mask' -type MaskTokenDate = MaskToken & { - expect: 'm' | 'd' | 'y' | string, -} +type MaskTokenDate = { + expect: 'm' | 'd' | 'y', + static: false, +} | { expect: string, static: true } const parseTokens = (format: string) => { return format.split('').map((char) => { @@ -12,15 +13,27 @@ const parseTokens = (format: string) => { } return { static: true, expect: char } - }) + }) as MaskTokenDate[] } type MinorToken = { value: string, expect: string, static: boolean } -type MajorToken = { value: string, expect: string, tree: MinorToken[] } +type MajorToken = { value: string, expect: string, tree: MinorToken[], used?: boolean } + +const getFebMaxDays = (year: number) => { + if (Number.isNaN(year)) { + return 29 // Return max possible days: We need year first + } + + return year % 4 === 0 ? 29 : 28 +} const getMaxDays = (year: number, month: number) => { + if (Number.isNaN(month)) { + return 31 + } + if (month === 2) { - return year % 4 === 0 ? 29 : 28 + return getFebMaxDays(year) } if ([4, 6, 9, 11].includes(month)) { @@ -30,13 +43,24 @@ const getMaxDays = (year: number, month: number) => { return 31 } -export const createMaskDate = (format: string = 'yyyy/mm/dd'): Mask => { +const removeStaticCharsFromEnd = (tokens: T[]) => { + let i = tokens.length - 1 + + while (tokens[i] && tokens[i].static) { + i-- + } + + return tokens.slice(0, i + 1) +} + +export const createMaskDate = (format: string = 'yyyy/mm/dd'): Mask => { const tokens = parseTokens(format) + const cache = new Map() + return { - format: (text: string) => { + format (text: string) { const minorTokens = [] as MinorToken[] - let additionalTokens = 0 let valueOffset = 0 let tokenOffset = 0 @@ -47,10 +71,8 @@ export const createMaskDate = (format: string = 'yyyy/mm/dd'): Mask t.static) + + if (nextTokensHasStatic) { + tokenOffset++ + } else { + valueOffset++ + } + continue } @@ -69,7 +98,7 @@ export const createMaskDate = (format: string = 'yyyy/mm/dd'): Mask { + const majorTokens = removeStaticCharsFromEnd(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) @@ -85,54 +114,100 @@ export const createMaskDate = (format: string = 'yyyy/mm/dd'): Mask p.expect === 'y') - const month = majorTokens.find((p) => p.expect === 'm') - - majorTokens.forEach((p) => { - if (p.expect === 'm') { - const num = parseInt(p.value) + majorTokens.forEach((t, index, array) => { + if (t.expect === 'm') { + const num = parseInt(t.value) if (num > 12) { - p.value = '12' + t.value = '12' + t.tree[0].static = true + t.tree[1].static = false } - if (num > 1 && num < 10) { - p.value = '0' + num - additionalTokens += 1 + if (num < 1 && t.value.length === 2) { + t.value = '01' + t.tree[0].static = true + t.tree[1].static = false + } + + if (num > 1 && num < 10 && t.value.length === 1) { + t.value = '0' + num + t.tree.unshift({ value: '0', expect: 'm', static: true }) + t.tree[1].static = false } } - if (p.expect === 'd') { - const num = parseInt(p.value) + if (t.expect === 'd') { + // Find corresponding year and month + // If it is first day we found, seek first year and month + // If it is second day we found, seek second year and month + // and so on + const year = majorTokens.find((t) => t.expect === 'y' && t.used === undefined) + const month = majorTokens.find((t) => t.expect === 'm' && t.used === undefined) + if (year) { year.used = true } + if (month) { month.used = true } + + const m = Number(month?.value) + + const maxDays = getMaxDays(Number(year?.value), m) - const maxDays = getMaxDays(Number(year?.value), Number(month?.value)) + if (m === 2) { // Only for February + if (Number(t.value) >= 29) { + t.value = '29' + } - if (num > maxDays) { - p.value = maxDays.toString() + // If cached 29, means previously user entered 29, but it changed to + // 28 accidentally, when previous year changed to non-leap year + if (t.value === '28' && cache.get(index) === '29') { + t.value = '29' + } + + cache.set(index, t.value) + } + + const num = parseInt(t.value) + + if (num > maxDays && t.value.length === 2) { + t.value = maxDays.toString() + t.tree[0].static = true + t.tree[1].static = false } - if (num > 3 && num < 10) { - p.value = '0' + num - additionalTokens += 1 + if (num < 1 && t.value.length === 2) { + t.value = '01' + t.tree[0].static = true + t.tree[1].static = false + } + + if (num > 3 && num < 10 && t.value.length === 1) { + t.value = '0' + num + t.tree.unshift({ value: '0', expect: 'd', static: true }) + t.tree[1].static = false } } }) + const newText = majorTokens.reduce((acc, p) => acc + p.value, '') + + const newTokens = tokens.map((t) => ({ + ...t, + static: false, + })) + return { - text: majorTokens.reduce((acc, p) => acc + p.value, ''), - tokens: tokens, - data: additionalTokens, + text: newText, + tokens: newTokens, + data: majorTokens.reduce((acc, p) => acc.concat(p.tree), [] as MinorToken[]), } }, - handleCursor (cursorStart, cursorEnd, tokens, newTokens, data, additionalTokens = 0) { - cursorStart.updateTokens(newTokens) - cursorEnd.updateTokens(newTokens) - cursorStart.moveForward(data.length + additionalTokens, CursorPosition.Any) + handleCursor (cursorStart, cursorEnd, oldTokens, newTokens, data, minorTokens) { + cursorStart.updateTokens(minorTokens!) + cursorEnd.updateTokens(minorTokens!) + cursorStart.moveForward(data.length, CursorPosition.AfterChar) cursorEnd.position = cursorStart.position }, - unformat: (text: string, tokens: MaskTokenDate[]) => { + unformat: (text: string, tokens: MaskToken[]) => { 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 index 816f75cada..a8b313d796 100644 --- a/packages/ui/src/composables/useInputMask/masks/numeral.ts +++ b/packages/ui/src/composables/useInputMask/masks/numeral.ts @@ -44,6 +44,5 @@ export const createNumeralMask = (): Mask => { 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 index af5e21ec0d..98c6f79ff1 100644 --- a/packages/ui/src/composables/useInputMask/masks/parser.ts +++ b/packages/ui/src/composables/useInputMask/masks/parser.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-use-before-define */ interface TokenBase { type: string expect: string @@ -32,15 +33,15 @@ interface TokenOrRegex extends TokenBase { export type Token = TokenChar | TokenRegex | TokenRepeated | TokenGroup | TokenOrRegex -const or = (...args: RegExp[]) => new RegExp(args.map((r) => r.source).join("|"), 'g') +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} + /(\{[^}]*\})/, // 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 - ) + /(?:)/, // split for each letter +) /** * Checks if the symbol contains correct (.) @@ -50,7 +51,7 @@ const TOKEN_SPLIT_REGEX = or( * `((.)(.))` is valid - single group with nested groups */ const isMaskSingleGroup = (symbol: string) => { - if (!symbol.startsWith('(' ) || !symbol.endsWith(')')) { return false } + if (!symbol.startsWith('(') || !symbol.endsWith(')')) { return false } let groupDepth = 0 for (let i = 0; i < symbol.length; i++) { @@ -79,7 +80,7 @@ const isMaskSingleGroup = (symbol: string) => { */ const parseRawTokens = (symbol: string) => { let group = 0 - let groups = [] + const groups = [] let currentChunk = '' let i = 0 @@ -152,7 +153,7 @@ export const parseTokens = (mask: string, directlyInGroup = false): Token[] => { type: 'or regex', expect: mask, left: [...tokens], - right: parseTokens(`(${rawTokens.slice(i + 1).join('')})`) + right: parseTokens(`(${rawTokens.slice(i + 1).join('')})`), }] break @@ -165,7 +166,7 @@ export const parseTokens = (mask: string, directlyInGroup = false): Token[] => { type: 'or regex', expect: `${prevToken}|${rawTokens[i + 1]}`, left: [prevToken], - right: nextToken + right: nextToken, }) continue @@ -182,7 +183,7 @@ export const parseTokens = (mask: string, directlyInGroup = false): Token[] => { tree: [prevToken], min: parseInt(min), max: max ? parseInt(max) : delimiter ? MAX_REPEATED : parseInt(min), - content: rawToken + content: rawToken, }) continue } diff --git a/packages/ui/src/composables/useInputMask/masks/regex.ts b/packages/ui/src/composables/useInputMask/masks/regex.ts index 90dcfc027b..3b3f850766 100644 --- a/packages/ui/src/composables/useInputMask/masks/regex.ts +++ b/packages/ui/src/composables/useInputMask/masks/regex.ts @@ -45,7 +45,7 @@ export const normalizeTokens = (tokens: Token[], dynamic = false) => { newResults.push([...result, { type: token.type, expect: token.expect, - static: token.type === 'char' && (!dynamic || result.length > 0), + static: token.type === 'char', // && (!dynamic || result.length > 0), dynamic: dynamic, }]) }) @@ -289,6 +289,5 @@ export const createMaskFromRegex = (regex: RegExp, options = { reverse: false }) } }, unformat, - reverse: options.reverse, } } diff --git a/packages/ui/src/composables/useInputMask/useInputMask.stories.ts b/packages/ui/src/composables/useInputMask/useInputMask.stories.ts index 60ca5bc42c..54e210f9a4 100644 --- a/packages/ui/src/composables/useInputMask/useInputMask.stories.ts +++ b/packages/ui/src/composables/useInputMask/useInputMask.stories.ts @@ -178,6 +178,27 @@ export const Date = defineStory({ }), }) +export const DateRange = defineStory({ + story: () => ({ + setup () { + const value = ref('') + const input = ref() + + const dateMask = createMaskDate('dd/mm/yyyy - dd/mm/yyyy') + + const { masked, unmasked } = useInputMask(dateMask, input) + + return { value, input, masked, unmasked } + }, + template: ` + + +

masked: {{ masked }}

+

unmasked: {{ unmasked }}

+ `, + }), +}) + export const Numeral = defineStory({ story: () => ({ setup () { @@ -237,3 +258,55 @@ export const NumeralWithDecimal = defineStory({ `, }), }) + +export const CustomMask = defineStory({ + story: () => ({ + setup () { + const value = ref('123456') + const input = ref() + + const dateMask = createMaskFromRegex(/\d\d (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)/) + const { masked, unmasked } = useInputMask({ + ...dateMask, + format (text) { + const formatted = dateMask.format(text) + + const day = formatted.text.slice(0, 2) + const month = formatted.text.slice(3, 6) + + if (month === 'Feb' && parseInt(day) > 28) { + return { + ...formatted, + text: '28 Feb', + } + } + + if (['Apr', 'Jun', 'Sep', 'Nov'].includes(month) && parseInt(day) > 30) { + return { + ...formatted, + text: '30 ' + month, + } + } + + if (parseInt(day) > 31) { + return { + ...formatted, + text: '31 ' + month, + } + } + + return formatted + }, + + }, 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 index adc62c6b91..616c33f684 100644 --- a/packages/ui/src/composables/useInputMask/useInputMask.ts +++ b/packages/ui/src/composables/useInputMask/useInputMask.ts @@ -54,28 +54,35 @@ export const useInputMask = (mask: MaybeRef // All input types: https://w3c.github.io/input-events/#interface-InputEvent-Attributes if (inputType === 'deleteContentBackward') { + e.preventDefault() if (+cursorStart === +cursorEnd) { // From 1[]2 to [1]2 cursorStart.moveBack(1, CursorPosition.AfterChar) } } else if (inputType === 'deleteContentForward' || inputType === 'deleteContent' || inputType === 'deleteByCut') { + e.preventDefault() if (+cursorStart === +cursorEnd) { // From 1[]23 to 1[2]3 cursorEnd.moveForward(1, CursorPosition.AfterChar) } + } else if (inputType === 'insertText' || inputType === 'insertFromPaste') { + e.preventDefault() } const tokens = formatted.value.tokens inputText.value = currentValue.slice(0, +cursorStart) + data + currentValue.slice(+cursorEnd) formatted.value = unref(mask).format(inputText.value) + // If pasted, move cursor to the end of how much was pasted counting formatted value and static tokens + if (inputType === 'insertFromPaste') { + cursorStart.position += formatted.value.text.length - currentValue.length + } + 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) => { @@ -107,8 +114,6 @@ export const useInputMask = (mask: MaybeRef 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)