diff --git a/common/changes/office-ui-fabric-react/magellan-textFieldMasking_2018-01-24-01-35.json b/common/changes/office-ui-fabric-react/magellan-textFieldMasking_2018-01-24-01-35.json new file mode 100644 index 00000000000000..7b96f54ae05dbd --- /dev/null +++ b/common/changes/office-ui-fabric-react/magellan-textFieldMasking_2018-01-24-01-35.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "TextField: Implemented input masking.", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "law@microsoft.com" +} \ No newline at end of file diff --git a/packages/experiments/src/components/LayoutGroup/examples/LayoutGroup.Basic.Example.tsx b/packages/experiments/src/components/LayoutGroup/examples/LayoutGroup.Basic.Example.tsx index af2f78150fb4ff..4491a37d6b0adb 100644 --- a/packages/experiments/src/components/LayoutGroup/examples/LayoutGroup.Basic.Example.tsx +++ b/packages/experiments/src/components/LayoutGroup/examples/LayoutGroup.Basic.Example.tsx @@ -37,8 +37,15 @@ export class LayoutGroupBasicExample extends React.Component<{}, {}> { } /> - - + + diff --git a/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/MaskedTextField.test.tsx b/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/MaskedTextField.test.tsx new file mode 100644 index 00000000000000..57569386ee6cd1 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/MaskedTextField.test.tsx @@ -0,0 +1,333 @@ +import * as React from 'react'; +import * as ReactTestUtils from 'react-dom/test-utils'; +import * as renderer from 'react-test-renderer'; +import { mount } from 'enzyme'; +import { + KeyCodes +} from '../../../Utilities'; + +import { MaskedTextField } from './MaskedTextField'; + +describe('MaskedTextField', () => { + function mockEvent(targetValue: string = ''): ReactTestUtils.SyntheticEventData { + const target: EventTarget = { value: targetValue } as HTMLInputElement; + const event: ReactTestUtils.SyntheticEventData = { target }; + + return event; + } + + it('renders TextField correctly', () => { + const component = renderer.create( + + ); + const tree = component.toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('Moves caret on focus', () => { + const component = mount( + + ); + + const input = component.find('input'); + input.simulate('focus'); + expect((input.getDOMNode() as HTMLInputElement).selectionStart).toEqual(7); + }); + + it('can change single character', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate pressing the '1' key + input.simulate('keyDown', { keyCode: KeyCodes.one }); + inputDOM.setSelectionRange(8, 8); + input.simulate('change', mockEvent('mask: (1___) ___ - ____')); + expect(inputDOM.value).toEqual('mask: (1__) ___ - ____'); + + // Simulate pressing the '2' key + input.simulate('keyDown', { keyCode: KeyCodes.two }); + inputDOM.setSelectionRange(9, 9); + input.simulate('change', mockEvent('mask: (12__) ___ - ____')); + expect(inputDOM.value).toEqual('mask: (12_) ___ - ____'); + }); + + it('can replace single character', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate pressing the '1' key + input.simulate('keyDown', { keyCode: KeyCodes.one }); + inputDOM.setSelectionRange(8, 8); + input.simulate('change', mockEvent('mask: (1___) ___ - ____')); + expect(inputDOM.value).toEqual('mask: (1__) ___ - ____'); + + // Replacing a character + input.simulate('keyDown', { keyCode: KeyCodes.two }); + inputDOM.setSelectionRange(8, 8); + input.simulate('change', mockEvent('mask: (21__) ___ - ____')); + expect(inputDOM.value).toEqual('mask: (2__) ___ - ____'); + }); + + it('should ignore incorrect format characters', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate pressing the '1' key + input.simulate('keyDown', { keyCode: KeyCodes.one }); + inputDOM.setSelectionRange(8, 8); + input.simulate('change', mockEvent('mask: (1___) ___ - ____')); + expect(inputDOM.value).toEqual('mask: (1__) ___ - ____'); + + // Simulate pressing the 'a' key + input.simulate('keyDown', { keyCode: KeyCodes.a }); + inputDOM.setSelectionRange(9, 9); + input.simulate('change', mockEvent('mask: (1a__) ___ - ____')); + expect(inputDOM.value).toEqual('mask: (1__) ___ - ____'); + }); + + it('should backspace', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate backspacing the '2' + inputDOM.setSelectionRange(9, 9); + input.simulate('keyDown', { keyCode: KeyCodes.backspace }); + input.simulate('change', mockEvent('mask: (13) 456 - 7890')); + expect(inputDOM.value).toEqual('mask: (1_3) 456 - 7890'); + }); + + it('should delete', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate deleting the '3' + inputDOM.setSelectionRange(9, 9); + input.simulate('keyDown', { keyCode: KeyCodes.del }); + input.simulate('change', mockEvent('mask: (12) 456 - 7890')); + expect(inputDOM.value).toEqual('mask: (12_) 456 - 7890'); + }); + + it('should ctrl backspace', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate backspacing the '123' + inputDOM.setSelectionRange(10, 10); + input.simulate('keyDown', { keyCode: KeyCodes.backspace, ctrlKey: true }); + inputDOM.setSelectionRange(7, 7); + input.simulate('change', mockEvent('mask: () 456 - 7890')); + expect(inputDOM.value).toEqual('mask: (___) 456 - 7890'); + }); + + it('should ctrl delete', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate deleting the '123' + inputDOM.setSelectionRange(7, 7); + input.simulate('keyDown', { keyCode: KeyCodes.del, ctrlKey: true }); + input.simulate('change', mockEvent('mask: () 456 - 7890')); + expect(inputDOM.value).toEqual('mask: (___) 456 - 7890'); + }); + + it('should backspace and delete selections', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Simulate selecting and backspacing the '123' + // Also select the preceding '(' + inputDOM.setSelectionRange(6, 10); + input.simulate('keyDown', { keyCode: KeyCodes.backspace }); + input.simulate('change', mockEvent('mask: 456 - 7890')); + expect(inputDOM.value).toEqual('mask: (___) 456 - 7890'); + + // Simulate selecting and deleting the '456' + // also select the proceding ' ' + inputDOM.setSelectionRange(12, 16); + input.simulate('keyDown', { keyCode: KeyCodes.del }); + input.simulate('change', mockEvent('mask: (___) - 7890')); + expect(inputDOM.value).toEqual('mask: (___) ___ - 7890'); + }); + + it('should paste characters', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Paste a 7777 into the start of the input + inputDOM.setSelectionRange(0, 0); + input.simulate('paste'); + input.simulate('change', mockEvent('7777mask: (123) 456 - 7890')); + expect(inputDOM.value).toEqual('mask: (777) 756 - 7890'); + + // Paste a 9999 into the end + inputDOM.setSelectionRange(22, 22); + input.simulate('paste'); + input.simulate('change', mockEvent('mask: (777) 756 - 78909999')); + expect(inputDOM.value).toEqual('mask: (777) 756 - 7890'); + + // Paste invalid characters mixed with valid characters + inputDOM.setSelectionRange(0, 0); + input.simulate('paste'); + input.simulate('change', mockEvent('1a2b3cmask: (777) 756 - 7890')); + expect(inputDOM.value).toEqual('mask: (123) 756 - 7890'); + }); + + it('should paste over selected characters', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Paste a 000 over the '123' + inputDOM.setSelectionRange(7, 10); + input.simulate('paste'); + input.simulate('change', mockEvent('mask: (000) 456 - 7890')); + expect(inputDOM.value).toEqual('mask: (000) 456 - 7890'); + + // Replace all characters with a paste + inputDOM.setSelectionRange(0, 22); + input.simulate('paste'); + input.simulate('change', mockEvent('98765')); + expect(inputDOM.value).toEqual('mask: (987) 65_ - ____'); + }); + + it('should replace selected text a char added', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Replace '23) 45' text with '6' + inputDOM.setSelectionRange(8, 14); + input.simulate('keyDown', { keyCode: KeyCodes.six }); + inputDOM.setSelectionRange(9, 9); + input.simulate('change', mockEvent('mask: (166 - 7890')); + expect(inputDOM.value).toEqual('mask: (16_) __6 - 7890'); + + // Replace all text with '9' + inputDOM.setSelectionRange(0, 22); + input.simulate('keyDown', { keyCode: KeyCodes.nine }); + inputDOM.setSelectionRange(1, 1); + input.simulate('change', mockEvent('9')); + expect(inputDOM.value).toEqual('mask: (9__) ___ - ____'); + }); + + it('should ignore overflowed characters', () => { + const component = mount( + + ); + + const input = component.find('input'), + inputDOM = input.getDOMNode() as HTMLInputElement; + input.simulate('focus'); + + // Add '1' to the end + inputDOM.setSelectionRange(22, 22); + input.simulate('keyDown', { keyCode: KeyCodes.one }); + inputDOM.setSelectionRange(23, 23); + input.simulate('change', mockEvent('mask: (123) 456 - 78901')); + expect(inputDOM.value).toEqual('mask: (123) 456 - 7890'); + }); +}); diff --git a/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/MaskedTextField.tsx b/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/MaskedTextField.tsx new file mode 100644 index 00000000000000..82f9161ea475de --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/MaskedTextField.tsx @@ -0,0 +1,406 @@ +import * as React from 'react'; +import { TextField } from '../TextField'; +import { + ITextField, + ITextFieldProps +} from '../TextField.types'; +import { + autobind, + BaseComponent, + KeyCodes, +} from '../../../Utilities'; + +import { + clearNext, + clearPrev, + clearRange, + DEFAULT_MASK_FORMAT_CHARS, + getLeftFormatIndex, + getMaskDisplay, + getRightFormatIndex, + IMaskValue, + insertString, + parseMask +} from '@uifabric/utilities/lib/inputMask'; + +/** + * props.mask: + * The string containing the prompt and format characters. + * Example: + * 'Phone Number: (999) 9999' + * + * _maskCharData + * An array of data containing information regarding the format characters, + * their indices inside the display text, and their corresponding values. + * Example: + * [ + * { value: '1', displayIndex: 16, format: /[0-9]/ }, + * { value: '2', displayIndex: 17, format: /[0-9]/ }, + * { displayIndex: 18, format: /[0-9]/ }, + * { value: '4', displayIndex: 22, format: /[0-9]/ }, + * ... + * ] + */ +export interface IMaskedTextFieldState { + /** + * The mask string formatted with the input value. + * This is what is displayed inside the TextField + * Example: + * 'Phone Number: 12_ - 4___' + */ + displayValue: string; + /** The index into the rendered value of the first unfilled format character */ + maskCursorPosition?: number; +} + +export const DEFAULT_MASK_CHAR = '_'; + +enum inputChangeType { + default, + backspace, + delete, + textPasted +} + +export class MaskedTextField extends BaseComponent implements ITextField { + public static defaultProps: ITextFieldProps = { + maskChar: DEFAULT_MASK_CHAR, + maskFormat: DEFAULT_MASK_FORMAT_CHARS, + }; + /** + * Tell BaseComponent to bypass resolution of componentRef. + */ + protected _shouldUpdateComponentRef = false; + + private _textField: ITextField; + private _maskCharData: IMaskValue[]; + // True if the TextField is focused + private _isFocused: boolean; + // True if the TextField was not focused and it was clicked into + private _moveCursorOnMouseUp: boolean; + + // The stored selection data prior to input change events. + private _changeSelectionData: { + changeType: inputChangeType + selectionStart: number, + selectionEnd: number + } | null; + + constructor(props: ITextFieldProps) { + super(props); + + // Translate mask into charData + this._maskCharData = parseMask(props.mask, props.maskFormat); + // If an initial value is provided, use it to populate the format chars + props.value && this.setValue(props.value); + + this._isFocused = false; + this._moveCursorOnMouseUp = false; + + this.state = { + displayValue: getMaskDisplay( + props.mask, + this._maskCharData, + props.maskChar), + }; + } + + public componentWillReceiveProps(newProps: ITextFieldProps) { + if (newProps.mask !== this.props.mask) { + this._maskCharData = parseMask(newProps.mask, newProps.maskFormat); + this.state = { + displayValue: getMaskDisplay( + newProps.mask, + this._maskCharData, + newProps.maskChar), + }; + } + } + + public componentDidUpdate() { + // Move the cursor to the start of the mask format on update + if (this.state.maskCursorPosition) { + this._textField.setSelectionRange(this.state.maskCursorPosition, this.state.maskCursorPosition); + } + } + + public render() { + return ( + + ); + } + + /** + * @return The value of all filled format characters or undefined if not all format characters are filled + */ + public get value(): string | undefined { + let value = ''; + + for (let i = 0; i < this._maskCharData.length; i++) { + if (!this._maskCharData[i].value) { + return undefined; + } + value += this._maskCharData[i].value; + } + return value; + } + + /** + * + */ + public setValue(newValue: string): void { + let valueIndex = 0, + charDataIndex = 0; + + while (valueIndex < newValue.length && + charDataIndex < this._maskCharData.length) { + // Test if the next character in the new value fits the next format character + const testVal = newValue[valueIndex]; + if (this._maskCharData[charDataIndex].format.test(testVal)) { + this._maskCharData[charDataIndex].value = testVal; + charDataIndex++; + } + valueIndex++; + } + } + + public focus(): void { + this._textField && this._textField.focus(); + } + + public select(): void { + this._textField && this._textField.select(); + } + + public setSelectionStart(value: number): void { + this._textField && this._textField.setSelectionStart(value); + } + + public setSelectionEnd(value: number): void { + this._textField && this._textField.setSelectionEnd(value); + } + + public setSelectionRange(start: number, end: number): void { + this._textField && this._textField.setSelectionRange(start, end); + } + + public get selectionStart(): number | null { + return this._textField && this._textField.selectionStart !== null ? this._textField.selectionStart : -1; + } + + public get selectionEnd(): number | null { + return this._textField && this._textField.selectionEnd ? this._textField.selectionEnd : -1; + } + + @autobind + private _onFocus(event: React.FocusEvent) { + const { + onFocus + } = this.props; + + if (onFocus) { + onFocus(event); + } + + this._isFocused = true; + + // Move the cursor position to the rightmost unfilled position + for (let i = 0; i < this._maskCharData.length; i++) { + if (!this._maskCharData[i].value) { + this.setState({ + maskCursorPosition: this._maskCharData[i].displayIndex + }); + break; + } + } + } + + @autobind + private _onBlur(event: React.FocusEvent) { + const { + onBlur + } = this.props; + + if (onBlur) { + onBlur(event); + } + + this._isFocused = false; + this._moveCursorOnMouseUp = true; + } + + @autobind + private _onMouseDown(event: React.MouseEvent) { + if (!this._isFocused) { + this._moveCursorOnMouseUp = true; + } + } + + @autobind + private _onMouseUp(event: React.MouseEvent) { + // Move the cursor on mouseUp after focusing the textField + if (this._moveCursorOnMouseUp) { + this._moveCursorOnMouseUp = false; + // Move the cursor position to the rightmost unfilled position + for (let i = 0; i < this._maskCharData.length; i++) { + if (!this._maskCharData[i].value) { + this.setState({ + maskCursorPosition: this._maskCharData[i].displayIndex + }); + break; + } + } + } + } + + @autobind + private _onBeforeChange(value: String) { + if (this._changeSelectionData === null) { + this._changeSelectionData = { + changeType: inputChangeType.default, + selectionStart: this._textField.selectionStart !== null ? this._textField.selectionStart : -1, + selectionEnd: this._textField.selectionEnd !== null ? this._textField.selectionEnd : -1 + }; + } + } + + @autobind + private _onInputChange(value: string) { + if (!this._changeSelectionData) { + return; + } + + const { displayValue } = this.state; + + // The initial value of cursorPos does not matter + let cursorPos = 0; + const { + changeType, + selectionStart, + selectionEnd + } = this._changeSelectionData; + + if (changeType === inputChangeType.textPasted) { + const charsSelected = selectionEnd - selectionStart, + charCount = value.length + charsSelected - displayValue.length, + startPos = selectionStart, + pastedString = value.substr(startPos, charCount); + + // Clear any selected characters + if (charsSelected) { + this._maskCharData = clearRange(this._maskCharData, selectionStart, charsSelected); + } + cursorPos = insertString(this._maskCharData, startPos, pastedString); + } else if (changeType === inputChangeType.delete || + changeType === inputChangeType.backspace) { + // isDel is true If the characters are removed LTR, otherwise RTL + const isDel = changeType === inputChangeType.delete, + charCount = selectionEnd - selectionStart; + + if (charCount) { // charCount is > 0 if range was deleted + this._maskCharData = clearRange(this._maskCharData, selectionStart, charCount); + cursorPos = getRightFormatIndex(this._maskCharData, selectionStart); + } else { // If charCount === 0, there was no selection and a single character was deleted + if (isDel) { + this._maskCharData = clearNext(this._maskCharData, selectionStart); + cursorPos = getRightFormatIndex(this._maskCharData, selectionStart); + } else { + this._maskCharData = clearPrev(this._maskCharData, selectionStart); + cursorPos = getLeftFormatIndex(this._maskCharData, selectionStart); + } + } + } else if (value.length > displayValue.length) { + // This case is if the user added characters + const charCount = value.length - displayValue.length, + startPos = selectionEnd - charCount, + enteredString = value.substr(startPos, charCount); + + cursorPos = insertString(this._maskCharData, startPos, enteredString); + } else if (value.length <= displayValue.length) { + /** + * This case is reached only if the user has selected a block of 1 or more + * characters and input a character replacing the characters they've selected. + */ + const charCount = 1, + selectCount = displayValue.length + charCount - value.length, + startPos = selectionEnd - charCount, + enteredString = value.substr(startPos, charCount); + + // Clear the selected range + this._maskCharData = clearRange(this._maskCharData, startPos, selectCount); + // Insert the printed character + cursorPos = insertString(this._maskCharData, startPos, enteredString); + } + + this._changeSelectionData = null; + + this.setState({ + displayValue: getMaskDisplay( + this.props.mask, + this._maskCharData, + this.props.maskChar), + maskCursorPosition: cursorPos + }); + } + + @autobind + private _onKeyDown(event: React.KeyboardEvent) { + this._changeSelectionData = null; + if (this._textField.value) { + const { + keyCode, + ctrlKey, + metaKey + } = event; + + // Ignore ctrl and meta keydown + if (ctrlKey || metaKey) { + return; + } + + // On backspace or delete, store the selection and the keyCode + if (keyCode === KeyCodes.backspace || keyCode === KeyCodes.del) { + const selectionStart = (event.target as HTMLInputElement).selectionStart, + selectionEnd = (event.target as HTMLInputElement).selectionEnd; + + // Check if backspace or delete press is valid. + if (!(keyCode === KeyCodes.backspace && selectionEnd && selectionEnd > 0) + && !(keyCode === KeyCodes.del && selectionStart !== null && selectionStart < this._textField.value.length)) { + return; + } + + this._changeSelectionData = { + changeType: keyCode === KeyCodes.backspace ? + inputChangeType.backspace : + inputChangeType.delete, + selectionStart: selectionStart !== null ? selectionStart : -1, + selectionEnd: selectionEnd !== null ? selectionEnd : -1 + }; + } + } + } + + @autobind + private _onPaste(event: React.ClipboardEvent) { + const selectionStart = (event.target as HTMLInputElement).selectionStart, + selectionEnd = (event.target as HTMLInputElement).selectionEnd; + // Store the paste selection range + this._changeSelectionData = { + changeType: inputChangeType.textPasted, + selectionStart: selectionStart !== null ? selectionStart : -1, + selectionEnd: selectionEnd !== null ? selectionEnd : -1 + }; + } +} diff --git a/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/__snapshots__/MaskedTextField.test.tsx.snap b/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/__snapshots__/MaskedTextField.test.tsx.snap new file mode 100644 index 00000000000000..7eb59c12207525 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/__snapshots__/MaskedTextField.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MaskedTextField renders TextField correctly 1`] = ` +
+
+ +
+ +
+
+
+`; diff --git a/packages/office-ui-fabric-react/src/components/TextField/TextField.types.ts b/packages/office-ui-fabric-react/src/components/TextField/TextField.types.ts index 41d6d0bf120031..2e88a28cb8a740 100644 --- a/packages/office-ui-fabric-react/src/components/TextField/TextField.types.ts +++ b/packages/office-ui-fabric-react/src/components/TextField/TextField.types.ts @@ -228,6 +228,32 @@ export interface ITextFieldProps extends React.AllHTMLAttributes { @@ -21,7 +24,11 @@ export class TextFieldBasicExample extends React.Component { label='With error message' errorMessage='Error message' /> + ); } -} \ No newline at end of file +} diff --git a/packages/office-ui-fabric-react/src/components/TextField/index.ts b/packages/office-ui-fabric-react/src/components/TextField/index.ts index 4ec96447ffa23b..20b2d0efda0b01 100644 --- a/packages/office-ui-fabric-react/src/components/TextField/index.ts +++ b/packages/office-ui-fabric-react/src/components/TextField/index.ts @@ -1,2 +1,3 @@ export * from './TextField'; export * from './TextField.types'; +export * from './MaskedTextField/MaskedTextField'; diff --git a/packages/utilities/src/inputMask.test.ts b/packages/utilities/src/inputMask.test.ts new file mode 100644 index 00000000000000..48ec8fcfe6b6ec --- /dev/null +++ b/packages/utilities/src/inputMask.test.ts @@ -0,0 +1,160 @@ +import { + clearNext, + clearPrev, + clearRange, + getLeftFormatIndex, + getMaskDisplay, + getRightFormatIndex, + IMaskValue, + insertString, + parseMask +} from './inputMask'; + +const inputMask = 'Phone number m\\ask: (999) 999 - 9999'; + +const values: IMaskValue[] = [ + { value: '1', format: /[0-9]/, displayIndex: 20 }, + { value: '2', format: /[0-9]/, displayIndex: 21 }, + { value: '3', format: /[0-9]/, displayIndex: 22 }, + { value: '4', format: /[0-9]/, displayIndex: 25 }, + { value: '5', format: /[0-9]/, displayIndex: 26 }, + { value: undefined, format: /[0-9]/, displayIndex: 27 }, + { value: undefined, format: /[0-9]/, displayIndex: 31 }, + { value: undefined, format: /[0-9]/, displayIndex: 32 }, + { value: undefined, format: /[0-9]/, displayIndex: 33 }, + { value: undefined, format: /[0-9]/, displayIndex: 34 }, +]; + +function resetValues(charData: IMaskValue[], maxIndex: number = Infinity): void { + for (let i = 0; i < charData.length; i++) { + if (i < maxIndex) { + charData[i].value = (i + 1).toString()[0]; + } else { + charData[i].value = undefined; + } + } +} + +describe('inputMask', () => { + it('parses mask sucessfully', () => { + let result = parseMask(inputMask); + expect(result.length).toEqual(values.length); + + for (let i = 0; i < values.length; i++) { + expect(result[i].displayIndex).toEqual(values[i].displayIndex); + } + }); + + it('parsing ignores escaped format characters', () => { + let result = parseMask('Esc\\aped some ch\\ar\\acters: (999) 999 - 9999 \\*'); + expect(result.length).toEqual(values.length); + }); + + it('generates displayedMask', () => { + resetValues(values, 5); + let result = getMaskDisplay(inputMask, values, '_'); + expect(result).toEqual('Phone number mask: (123) 45_ - ____'); + + result = getMaskDisplay(inputMask, values); + expect(result).toEqual('Phone number mask: (123) 45'); + + resetValues(values, 0); + result = getMaskDisplay(inputMask, values); + expect(result).toEqual('Phone number mask: ('); + }); + + it('generated displayedMask doesn\'t render escape codes', () => { + const maskString = 'Esc\\aped Ch\\ar\\acters: (999) 999 - 9999'; + const maskValues = parseMask(maskString); + + resetValues(maskValues, 5); + + let result = getMaskDisplay(maskString, maskValues, '_'); + expect(result).toEqual('Escaped Characters: (123) 45_ - ____'); + + result = getMaskDisplay(maskString, maskValues); + expect(result).toEqual('Escaped Characters: (123) 45'); + }); + + it('clearNext works as intended', () => { + resetValues(values, 5); + let result = clearNext(values, 20); + expect(result[0].value).toBeUndefined(); + + resetValues(values, 5); + result = clearNext(values, 22); + expect(result[2].value).toBeUndefined(); + + resetValues(values, 5); + result = clearNext(values, 23); + expect(result[3].value).toBeUndefined(); + expect(result[2].value).toBeDefined(); + }); + + it('clearPrev works as intended', () => { + resetValues(values, 5); + let result = clearPrev(values, 25); + expect(result[2].value).toBeUndefined(); + + resetValues(values, 5); + result = clearPrev(values, 21); + expect(result[0].value).toBeUndefined(); + + resetValues(values, 5); + result = clearPrev(values, 20); + expect(result[0].value).toBeDefined(); + }); + + it('clearRange works as intended', () => { + resetValues(values, 5); + let result = clearRange(values, 23, 5); + expect(result[2].value).toBeDefined(); + expect(result[3].value).toBeUndefined(); + expect(result[4].value).toBeUndefined(); + + resetValues(values, 5); + result = clearRange(values, 22, 5); + expect(result[2].value).toBeUndefined(); + expect(result[3].value).toBeUndefined(); + expect(result[4].value).toBeUndefined(); + }); + + it('getLeftFormatIndex works as intended', () => { + resetValues(values, 5); + let result = getLeftFormatIndex(values, 23); + expect(result).toEqual(22); + + resetValues(values, 5); + result = getLeftFormatIndex(values, 22); + expect(result).toEqual(21); + + resetValues(values, 5); + result = getLeftFormatIndex(values, 25); + expect(result).toEqual(22); + }); + + it('getRightFormatIndex works as intended', () => { + resetValues(values, 5); + let result = getRightFormatIndex(values, 23); + expect(result).toEqual(25); + + resetValues(values, 5); + result = getRightFormatIndex(values, 22); + expect(result).toEqual(22); + + resetValues(values, 5); + result = getRightFormatIndex(values, 25); + expect(result).toEqual(25); + }); + + it('insertString works as intended', () => { + resetValues(values, 5); + let result = insertString(values, 23, '89asdf0asdf98'); + expect(values[3].value).toEqual('8'); + expect(values[4].value).toEqual('9'); + expect(values[5].value).toEqual('0'); + expect(values[6].value).toEqual('9'); + expect(values[7].value).toEqual('8'); + expect(result).toEqual(33); + }); +}); diff --git a/packages/utilities/src/inputMask.ts b/packages/utilities/src/inputMask.ts new file mode 100644 index 00000000000000..ab108e411ee035 --- /dev/null +++ b/packages/utilities/src/inputMask.ts @@ -0,0 +1,247 @@ +export interface IMaskValue { + value?: string; + /** + * This index refers to the index in the displayMask rather than the inputMask. + * This means that any escaped characters do not count toward this index. + */ + displayIndex: number; + format: RegExp; +} + +export const DEFAULT_MASK_FORMAT_CHARS: { [key: string]: RegExp } = { + '9': /[0-9]/, + 'a': /[a-zA-Z]/, + '*': /[a-zA-Z0-9]/ +}; + +/** + * Takes in the mask string and the formatCharacters and returns an array of MaskValues + * Example: + * mask = 'Phone Number: (999) - 9999' + * return = [ + * { value: undefined, displayIndex: 16, format: /[0-9]/ }, + * { value: undefined, displayIndex: 17, format: /[0-9]/ }, + * { value: undefined, displayIndex: 18, format: /[0-9]/ }, + * { value: undefined, displayIndex: 22, format: /[0-9]/ }, + * ] + * + * @param mask The string use to define the format of the displayed maskedValue. + * @param formatChars An object defining how certain characters in the mask should accept input. + */ +export function parseMask( + mask: string | undefined, + formatChars: { [key: string]: RegExp } = DEFAULT_MASK_FORMAT_CHARS): IMaskValue[] { + if (!mask) { + return []; + } + + let maskCharData: IMaskValue[] = []; + // Count the escape characters in the mask string. + let escapedChars = 0; + for (let i = 0; i + escapedChars < mask.length; i++) { + const maskChar = mask.charAt(i + escapedChars); + if (maskChar === '\\') { + escapedChars++; + } else { + // Check if the maskChar is a format character. + const maskFormat = formatChars[maskChar]; + if (maskFormat) { + maskCharData.push({ + /** + * Do not add escapedChars to the displayIndex. + * The index refers to a position in the mask's displayValue. + * Since the backslashes don't appear in the displayValue, + * we do not add them to the charData displayIndex. + */ + displayIndex: i, + format: maskFormat + }); + } + } + } + + return maskCharData; +} + +/** + * Takes in the mask string, an array of MaskValues, and the maskCharacter + * returns the mask string formatted with the input values and maskCharacter. + * If the maskChar is undefined, the maskDisplay is truncated to the last filled format character. + * Example: + * mask = 'Phone Number: (999) 999 - 9999' + * maskCharData = '12345' + * maskChar = '_' + * return = 'Phone Number: (123) 45_ - ___' + * + * Example: + * mask = 'Phone Number: (999) 999 - 9999' + * value = '12345' + * maskChar = undefined + * return = 'Phone Number: (123) 45' + * + * @param mask The string use to define the format of the displayed maskedValue. + * @param maskCharData The input values to insert into the mask string for displaying. + * @param maskChar? A character to display in place of unfilled mask format characters. + */ +export function getMaskDisplay(mask: string | undefined, maskCharData: IMaskValue[], maskChar?: string): string { + let maskDisplay = mask; + + if (!maskDisplay) { + return ''; + } + + // Remove all backslashes + maskDisplay = maskDisplay.replace(/\\/g, ''); + + // lastDisplayIndex is is used to truncate the string if necessary. + let lastDisplayIndex = 0; + if (maskCharData.length > 0) { + lastDisplayIndex = maskCharData[0].displayIndex - 1; + } + + /** + * For each input value, replace the character in the maskDisplay with the value. + * If there is no value set for the format character, use the maskChar. + */ + for (let charData of maskCharData) { + let nextChar = ' '; + if (charData.value) { + nextChar = charData.value; + if (charData.displayIndex > lastDisplayIndex) { + lastDisplayIndex = charData.displayIndex; + } + } else { + if (maskChar) { + nextChar = maskChar; + } + } + + // Insert the character into the maskdisplay at its corresponding index + maskDisplay = maskDisplay.slice(0, charData.displayIndex) + nextChar + + maskDisplay.slice(charData.displayIndex + 1); + } + + // Cut off all mask characters after the last filled format value + if (!maskChar) { + maskDisplay = maskDisplay.slice(0, lastDisplayIndex + 1); + } + + return maskDisplay; +} + +/** + * Get the next format index right of or at a specified index. + * If no index exists, returns the rightmost index. + * @param maskCharData + * @param index + */ +export function getRightFormatIndex(maskCharData: IMaskValue[], index: number): number { + for (let i = 0; i < maskCharData.length; i++) { + if (maskCharData[i].displayIndex >= index) { + return maskCharData[i].displayIndex; + } + } + return maskCharData[maskCharData.length - 1].displayIndex; +} + +/** + * Get the next format index left of a specified index. + * If no index exists, returns the leftmost index. + * @param maskCharData + * @param index + */ +export function getLeftFormatIndex(maskCharData: IMaskValue[], index: number): number { + for (let i = maskCharData.length - 1; i >= 0; i--) { + if (maskCharData[i].displayIndex < index) { + return maskCharData[i].displayIndex; + } + } + return maskCharData[0].displayIndex; +} + +/** + * Deletes all values in maskCharData with a displayIndex that falls inside the specified range. + * maskCharData is modified inline and also returned. + * @param maskCharData + * @param selectionStart + * @param selectionCount + */ +export function clearRange(maskCharData: IMaskValue[], selectionStart: number, selectionCount: number): IMaskValue[] { + for (let i = 0; i < maskCharData.length; i++) { + if (maskCharData[i].displayIndex >= selectionStart) { + if (maskCharData[i].displayIndex >= selectionStart + selectionCount) { + break; + } + maskCharData[i].value = undefined; + } + } + return maskCharData; +} + +/** + * Deletes the input character at or after a specified index and returns the new array of charData + * maskCharData is modified inline and also returned. + * @param maskCharData + * @param selectionStart + */ +export function clearNext(maskCharData: IMaskValue[], selectionStart: number): IMaskValue[] { + for (let i = 0; i < maskCharData.length; i++) { + if (maskCharData[i].displayIndex >= selectionStart) { + maskCharData[i].value = undefined; + break; + } + } + return maskCharData; +} + +/** + * Deletes the input character before a specified index and returns the new array of charData + * maskCharData is modified inline and also returned. + * @param maskCharData + * @param selectionStart + */ +export function clearPrev(maskCharData: IMaskValue[], selectionStart: number): IMaskValue[] { + for (let i = maskCharData.length - 1; i >= 0; i--) { + if (maskCharData[i].displayIndex < selectionStart) { + maskCharData[i].value = undefined; + break; + } + } + return maskCharData; +} + +/** + * Deletes all values in maskCharData with a displayIndex that falls inside the specified range. + * Modifies the maskCharData inplace with the passed string and returns the display index of the + * next format character after the inserted string. + * @param maskCharData + * @param selectionStart + * @param selectionCount + * @return The displayIndex of the next format character + */ +export function insertString(maskCharData: IMaskValue[], selectionStart: number, newString: string): number { + let stringIndex = 0, + nextIndex = 0; + // Iterate through _maskCharData finding values with a displayIndex after the specified range start + for (let i = 0; i < maskCharData.length && stringIndex < newString.length; i++) { + if (maskCharData[i].displayIndex >= selectionStart) { + nextIndex = maskCharData[i].displayIndex; + // Find the next character in the newString that matches the format + while (stringIndex < newString.length) { + // If the character matches the format regexp, set the maskCharData to the new character + if (maskCharData[i].format.test(newString.charAt(stringIndex))) { + maskCharData[i].value = newString.charAt(stringIndex++); + // Set the nextIndex to the display index of the next mask format character. + if (i + 1 < maskCharData.length) { + nextIndex = maskCharData[i + 1].displayIndex; + } else { + nextIndex++; + } + break; + } + stringIndex++; + } + } + } + return nextIndex; +}