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;
+}