From 251e8393adb6726c1de5bc0538d1ee36874d5184 Mon Sep 17 00:00:00 2001 From: Lambert W Date: Tue, 23 Jan 2018 17:33:20 -0800 Subject: [PATCH 01/21] TextField: Implemented masking --- .../src/components/TextField/TextField.tsx | 145 +++++++++++++++++- .../components/TextField/TextField.types.ts | 15 ++ .../examples/TextField.Basic.Example.tsx | 9 ++ 3 files changed, 167 insertions(+), 2 deletions(-) diff --git a/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx b/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx index bb711fbd70f7ee..02843e8005b74b 100644 --- a/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx +++ b/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx @@ -3,6 +3,7 @@ import { ITextField, ITextFieldProps } from './TextField.types'; import { Label } from '../../Label'; import { Icon } from '../../Icon'; import { + autobind, DelayedRender, BaseComponent, getId, @@ -27,8 +28,17 @@ export interface ITextFieldState { * - If we have done the validation and there is validation error, errorMessage is the validation error message. */ errorMessage?: string; + + /** The index into the rendered value of the first unfilled format character */ + maskCursorPosition?: number; } +const DEFAULT_MASK_FORMAT_CHARS: { [key: string]: RegExp } = { + '9': /[0-9]/, + 'a': /[a-zA-Z]/, + '*': /[a-zA-Z0-9]/ +}; + export class TextField extends BaseComponent implements ITextField { public static defaultProps: ITextFieldProps = { multiline: false, @@ -104,6 +114,14 @@ export class TextField extends BaseComponent i } } + public componentDidUpdate() { + if (this.state.maskCursorPosition) { + this.setSelectionRange(this.state.maskCursorPosition, this.state.maskCursorPosition); + } else { + this._extractValueFromMask(this._textElement.value, this._textElement); + } + } + public componentWillReceiveProps(newProps: ITextFieldProps) { const { onBeforeChange } = this.props; @@ -274,6 +292,12 @@ export class TextField extends BaseComponent i if (this.props.validateOnFocusIn) { this._validate(this.state.value); } + + if (this.state.maskCursorPosition) { + this.setSelectionRange(this.state.maskCursorPosition, this.state.maskCursorPosition); + } else { + this._extractValueFromMask(this._textElement.value, this._textElement); + } } private _onBlur(ev: React.FocusEvent) { @@ -365,6 +389,9 @@ export class TextField extends BaseComponent i } private _renderInput(): React.ReactElement> { + let { + mask + } = this.props; let inputProps = getNativeProps>(this.props, inputProperties, ['defaultValue']); return ( @@ -373,9 +400,10 @@ export class TextField extends BaseComponent i id={ this._id } { ...inputProps } ref={ this._resolveRef('_textElement') } - value={ this.state.value } + value={ this._getMaskValue() } onInput={ this._onInputChange } onChange={ this._onInputChange } + onKeyDown={ this._onKeyDown } className={ this._getTextElementClassName() } aria-label={ this.props.ariaLabel } aria-describedby={ this._isDescriptionAvailable ? this._descriptionId : null } @@ -386,9 +414,106 @@ export class TextField extends BaseComponent i ); } + private _getMaskValue(): string | undefined { + // TODO: Add more comments to this + let { + mask, + maskChar + } = this.props; + + let { value } = this.state; + + if (mask === undefined) { + return value; + } + + let outputMask = ''; + let valueIndex = 0; + + for (let i = 0; i < mask.length; i++) { + let c = mask.charAt(i); + if (c == '\\') { + // Escape next char + if (++i < mask.length) { + outputMask += mask.charAt(i); + } + } else { + // Search for default format char + const format = DEFAULT_MASK_FORMAT_CHARS[c]; + // Check if format exists + if (format) { + // Check for value char to correspond to mask character + // Check if value character satisfies format + if (value && valueIndex < value.length && format.test(value.charAt(valueIndex))) { + outputMask += value.charAt(valueIndex++); + } else { + // Push maskChar or just return mask as-is + if (maskChar) { + outputMask += maskChar; + } else { + return outputMask; + } + } + } else { + // Otherwise, add non-format mask character + outputMask += c; + } + } + } + + return outputMask; + } + + private _extractValueFromMask(maskedValue: string, element: HTMLInputElement | HTMLTextAreaElement): string { + let { + mask, + maskChar + } = this.props; + + if (!mask) { + return maskedValue; + } + let value = ''; + + // Find the char difference between the mask and the value + let valueIndex = 0; + let maskIndex = 0; + let escapeNext = false; + let skippedChars = 0; + while (valueIndex < maskedValue.length && maskIndex < mask.length) { + if (mask.charAt(maskIndex) == '\\') { + escapeNext = true; + maskIndex++; + continue; + } else { + if (!escapeNext && DEFAULT_MASK_FORMAT_CHARS[mask.charAt(maskIndex)]) { + let valueChar = maskedValue.charAt(valueIndex); + if (valueChar == maskChar) { + this.setState({ maskCursorPosition: valueIndex - skippedChars }); + return value; + } else { + value += valueChar; + } + } else { + escapeNext = false; + while ( + valueIndex < maskedValue.length && + maskedValue.charAt(valueIndex) != mask.charAt(maskIndex)) { + skippedChars++; + valueIndex++; + } + } + } + valueIndex++; + maskIndex++; + } + this.setState({ maskCursorPosition: valueIndex }); + return value; + } + private _onInputChange(event: React.FormEvent): void { const element: HTMLInputElement = event.target as HTMLInputElement; - const value: string = element.value; + let value: string = this._extractValueFromMask(element.value, element); // Avoid doing unnecessary work when the value has not changed. if (value === this._latestValue) { @@ -417,6 +542,22 @@ export class TextField extends BaseComponent i onBeforeChange(value); } + @autobind + private _onKeyDown(event: React.KeyboardEvent) { + const key = event.keyCode || event.charCode; + + // Check for backspace + if (key == 8) { + if (this.props.mask && this.state.maskCursorPosition && this.state.value) { + if ((event.target as HTMLInputElement).selectionStart >= this.state.maskCursorPosition) { + this.setState({ + value: this.state.value.slice(0, this.state.value.length - 1) + }) + } + } + } + } + private _validate(value: string | undefined): void { // In case of _validate called multi-times during executing validate logic with promise return. if (this._latestValidateValue === value) { 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 7f32537bc68d4c..404f4673b26f4b 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 @@ -232,4 +232,19 @@ export interface ITextFieldProps extends React.AllHTMLAttributes { public render() { return (
+ + From 71cea3f38c0bd7fa3988bc087cdf54f503f79a5b Mon Sep 17 00:00:00 2001 From: Lambert W Date: Tue, 23 Jan 2018 17:34:31 -0800 Subject: [PATCH 02/21] Changed example --- .../TextField/examples/TextField.Basic.Example.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/office-ui-fabric-react/src/components/TextField/examples/TextField.Basic.Example.tsx b/packages/office-ui-fabric-react/src/components/TextField/examples/TextField.Basic.Example.tsx index 13b837828b1e13..db99a922d28690 100644 --- a/packages/office-ui-fabric-react/src/components/TextField/examples/TextField.Basic.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/TextField/examples/TextField.Basic.Example.tsx @@ -6,15 +6,6 @@ export class TextFieldBasicExample extends React.Component { public render() { return (
- - @@ -30,6 +21,11 @@ export class TextFieldBasicExample extends React.Component { label='With error message' errorMessage='Error message' /> +
); } From 4bf00598a3f3845264b43380a21431230f6be6c2 Mon Sep 17 00:00:00 2001 From: Lambert W Date: Tue, 23 Jan 2018 17:35:25 -0800 Subject: [PATCH 03/21] Change file --- .../magellan-textFieldMasking_2018-01-24-01-35.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/office-ui-fabric-react/magellan-textFieldMasking_2018-01-24-01-35.json 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..7841fcc5519e70 --- /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] Inmplemented Input Masking", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "law@microsoft.com" +} \ No newline at end of file From ab1c2c7a5395a8d0118408d6c6069ec547fff0f0 Mon Sep 17 00:00:00 2001 From: Lambert W Date: Wed, 24 Jan 2018 14:51:38 -0800 Subject: [PATCH 04/21] Moved functions to util. Added tests --- .../__snapshots__/ColorPicker.test.tsx.snap | 5 + .../src/components/TextField/TextField.tsx | 156 +++++------------- .../__snapshots__/TextField.test.tsx.snap | 1 + packages/utilities/src/index.ts | 1 + packages/utilities/src/inputMask.test.ts | 52 ++++++ packages/utilities/src/inputMask.ts | 149 +++++++++++++++++ 6 files changed, 245 insertions(+), 119 deletions(-) create mode 100644 packages/utilities/src/inputMask.test.ts create mode 100644 packages/utilities/src/inputMask.ts diff --git a/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap index 0776b8f363c6dc..0252da899a09f2 100644 --- a/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap @@ -131,6 +131,7 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} + onKeyDown={[Function]} spellCheck={false} type="text" value="ffffff" @@ -165,6 +166,7 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} + onKeyDown={[Function]} spellCheck={false} type="text" value="255" @@ -199,6 +201,7 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} + onKeyDown={[Function]} spellCheck={false} type="text" value="255" @@ -233,6 +236,7 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} + onKeyDown={[Function]} spellCheck={false} type="text" value="255" @@ -267,6 +271,7 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} + onKeyDown={[Function]} spellCheck={false} type="text" value="100" diff --git a/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx b/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx index 02843e8005b74b..503a5b0d5c40bc 100644 --- a/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx +++ b/packages/office-ui-fabric-react/src/components/TextField/TextField.tsx @@ -7,6 +7,8 @@ import { DelayedRender, BaseComponent, getId, + getMaskDisplay, + getValueFromMaskDisplay, css, getNativeProps, inputProperties, @@ -33,12 +35,6 @@ export interface ITextFieldState { maskCursorPosition?: number; } -const DEFAULT_MASK_FORMAT_CHARS: { [key: string]: RegExp } = { - '9': /[0-9]/, - 'a': /[a-zA-Z]/, - '*': /[a-zA-Z0-9]/ -}; - export class TextField extends BaseComponent implements ITextField { public static defaultProps: ITextFieldProps = { multiline: false, @@ -115,10 +111,16 @@ export class TextField extends BaseComponent i } public componentDidUpdate() { - if (this.state.maskCursorPosition) { - this.setSelectionRange(this.state.maskCursorPosition, this.state.maskCursorPosition); - } else { - this._extractValueFromMask(this._textElement.value, this._textElement); + // Move the cursor to the start of the mask format on update + if (this.props.mask) { + if (this.state.maskCursorPosition) { + this.setSelectionRange(this.state.maskCursorPosition, this.state.maskCursorPosition); + } else { + getValueFromMaskDisplay( + this.props.mask, + this._textElement.value, + (index: number) => this.setState({ maskCursorPosition: index })); + } } } @@ -293,10 +295,16 @@ export class TextField extends BaseComponent i this._validate(this.state.value); } - if (this.state.maskCursorPosition) { - this.setSelectionRange(this.state.maskCursorPosition, this.state.maskCursorPosition); - } else { - this._extractValueFromMask(this._textElement.value, this._textElement); + // Move the cursor to the start of the mask format on focus + if (this.props.mask) { + if (this.state.maskCursorPosition) { + this.setSelectionRange(this.state.maskCursorPosition, this.state.maskCursorPosition); + } else { + getValueFromMaskDisplay( + this.props.mask, + this._textElement.value, + (index: number) => this.setState({ maskCursorPosition: index })); + } } } @@ -390,7 +398,8 @@ export class TextField extends BaseComponent i private _renderInput(): React.ReactElement> { let { - mask + mask, + maskChar } = this.props; let inputProps = getNativeProps>(this.props, inputProperties, ['defaultValue']); @@ -400,7 +409,7 @@ export class TextField extends BaseComponent i id={ this._id } { ...inputProps } ref={ this._resolveRef('_textElement') } - value={ this._getMaskValue() } + value={ mask ? getMaskDisplay(mask, this.state.value, maskChar) : this.state.value } onInput={ this._onInputChange } onChange={ this._onInputChange } onKeyDown={ this._onKeyDown } @@ -414,106 +423,14 @@ export class TextField extends BaseComponent i ); } - private _getMaskValue(): string | undefined { - // TODO: Add more comments to this - let { - mask, - maskChar - } = this.props; - - let { value } = this.state; - - if (mask === undefined) { - return value; - } - - let outputMask = ''; - let valueIndex = 0; - - for (let i = 0; i < mask.length; i++) { - let c = mask.charAt(i); - if (c == '\\') { - // Escape next char - if (++i < mask.length) { - outputMask += mask.charAt(i); - } - } else { - // Search for default format char - const format = DEFAULT_MASK_FORMAT_CHARS[c]; - // Check if format exists - if (format) { - // Check for value char to correspond to mask character - // Check if value character satisfies format - if (value && valueIndex < value.length && format.test(value.charAt(valueIndex))) { - outputMask += value.charAt(valueIndex++); - } else { - // Push maskChar or just return mask as-is - if (maskChar) { - outputMask += maskChar; - } else { - return outputMask; - } - } - } else { - // Otherwise, add non-format mask character - outputMask += c; - } - } - } - - return outputMask; - } - - private _extractValueFromMask(maskedValue: string, element: HTMLInputElement | HTMLTextAreaElement): string { - let { - mask, - maskChar - } = this.props; - - if (!mask) { - return maskedValue; - } - let value = ''; - - // Find the char difference between the mask and the value - let valueIndex = 0; - let maskIndex = 0; - let escapeNext = false; - let skippedChars = 0; - while (valueIndex < maskedValue.length && maskIndex < mask.length) { - if (mask.charAt(maskIndex) == '\\') { - escapeNext = true; - maskIndex++; - continue; - } else { - if (!escapeNext && DEFAULT_MASK_FORMAT_CHARS[mask.charAt(maskIndex)]) { - let valueChar = maskedValue.charAt(valueIndex); - if (valueChar == maskChar) { - this.setState({ maskCursorPosition: valueIndex - skippedChars }); - return value; - } else { - value += valueChar; - } - } else { - escapeNext = false; - while ( - valueIndex < maskedValue.length && - maskedValue.charAt(valueIndex) != mask.charAt(maskIndex)) { - skippedChars++; - valueIndex++; - } - } - } - valueIndex++; - maskIndex++; - } - this.setState({ maskCursorPosition: valueIndex }); - return value; - } - private _onInputChange(event: React.FormEvent): void { const element: HTMLInputElement = event.target as HTMLInputElement; - let value: string = this._extractValueFromMask(element.value, element); + let value: string = this.props.mask ? + getValueFromMaskDisplay( + this.props.mask, + element.value, + (index: number) => this.setState({ maskCursorPosition: index })) : + element.value; // Avoid doing unnecessary work when the value has not changed. if (value === this._latestValue) { @@ -544,15 +461,16 @@ export class TextField extends BaseComponent i @autobind private _onKeyDown(event: React.KeyboardEvent) { - const key = event.keyCode || event.charCode; + // When using an input mask, detect if a character at the end of the input was backspaced and delete it. + if (this.props.mask && this.state.maskCursorPosition && this.state.value) { + const key = event.keyCode || event.charCode; - // Check for backspace - if (key == 8) { - if (this.props.mask && this.state.maskCursorPosition && this.state.value) { + // Check for backspace + if (key === 8) { if ((event.target as HTMLInputElement).selectionStart >= this.state.maskCursorPosition) { this.setState({ value: this.state.value.slice(0, this.state.value.length - 1) - }) + }); } } } diff --git a/packages/office-ui-fabric-react/src/components/TextField/__snapshots__/TextField.test.tsx.snap b/packages/office-ui-fabric-react/src/components/TextField/__snapshots__/TextField.test.tsx.snap index 5fb70d8366cdf1..f44946ab9d81b3 100644 --- a/packages/office-ui-fabric-react/src/components/TextField/__snapshots__/TextField.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/TextField/__snapshots__/TextField.test.tsx.snap @@ -43,6 +43,7 @@ exports[`TextField renders TextField correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} + onKeyDown={[Function]} type="text" value="" /> diff --git a/packages/utilities/src/index.ts b/packages/utilities/src/index.ts index 0a81bde68bc5d9..0c1e1d0e01c3a9 100644 --- a/packages/utilities/src/index.ts +++ b/packages/utilities/src/index.ts @@ -8,6 +8,7 @@ export * from './EventGroup'; export * from './FabricPerformance'; export * from './GlobalSettings'; export * from './IDisposable'; +export * from './inputMask'; export * from './IPoint'; export * from './IRectangle'; export * from './IRenderFunction'; diff --git a/packages/utilities/src/inputMask.test.ts b/packages/utilities/src/inputMask.test.ts new file mode 100644 index 00000000000000..a898f990aacd06 --- /dev/null +++ b/packages/utilities/src/inputMask.test.ts @@ -0,0 +1,52 @@ +import { getMaskDisplay, getValueFromMaskDisplay } from './inputMask'; + +describe('inputMask', () => { + it('generates displayedMask', () => { + let result = getMaskDisplay('Phone number: (999) 999 - 9999', '12345', '_'); + expect(result).toEqual('Phone number: (123) 45_ - ____'); + + result = getMaskDisplay('Phone number: (999) 999 - 9999', '12345'); + expect(result).toEqual('Phone number: (123) 45'); + }); + + it('generates input value no changes', () => { + let result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (123) 45_ - ____'); + expect(result).toEqual('12345'); + + result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (123) 45'); + expect(result).toEqual('12345'); + }); + + it('generates input value after inserted char', () => { + let result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (123) 456_ - ____'); + expect(result).toEqual('123456'); + + result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (0123) 45'); + expect(result).toEqual('01245'); + }); + + it('generates input value after deleted char', () => { + let result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (123) 4_ - ____'); + expect(result).toEqual('1234'); + + result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (123) 4'); + expect(result).toEqual('1234'); + + result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (12) 45_ - ____'); + expect(result).toEqual('12'); + }); +}); diff --git a/packages/utilities/src/inputMask.ts b/packages/utilities/src/inputMask.ts new file mode 100644 index 00000000000000..8c8add9a589be3 --- /dev/null +++ b/packages/utilities/src/inputMask.ts @@ -0,0 +1,149 @@ +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 input value and returns what should be displayed in the input field; + * + * Takes + * Example: + * mask = 'Phone Number: (999) 999 - 9999' + * value = '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 value The validated user unput to insert into the mask string for displaying. + * @param maskChar? A character to display in place of unfilled mask format characters. + * @param maskFormat An object defining how certain characters in the mask should accept input. + * + * @public + */ +export function getMaskDisplay( + mask: string, + value?: string, + maskChar?: string, + maskFormat: { [key: string]: RegExp } = DEFAULT_MASK_FORMAT_CHARS): string | undefined { + + let outputMask = ''; + let valueIndex = 0; + + for (let i = 0; i < mask.length; i++) { + let c = mask.charAt(i); + if (c === '\\') { + // Escape next char + if (++i < mask.length) { + outputMask += mask.charAt(i); + } + } else { + // Search for default format char + const format = maskFormat[c]; + // Check if format exists + if (format) { + // Check for value char to correspond to mask character + // Check if value character satisfies format + if (value && valueIndex < value.length && format.test(value.charAt(valueIndex))) { + outputMask += value.charAt(valueIndex++); + } else { + // Push maskChar or just return mask as-is + if (maskChar) { + outputMask += maskChar; + } else { + return outputMask; + } + } + } else { + // Otherwise, add non-format mask character + outputMask += c; + } + } + } + + return outputMask; +} + +/** + * Takes the mask string and what is displayed in the input field and returns the input value. + * + * Example: No input changes to the maskedValue + * mask = 'Phone Number: (999) 999 - 9999' + * maskedValue = 'Phone Number: (123) 45_ - ___' + * return = '12345' + * + * Example: User typed '6' on the input + * mask = 'Phone Number: (999) 999 - 9999' + * maskedValue = 'Phone Number: (123) 456_ - ___' (note: The user entered a '6' between the '4' and '_') + * return = '123456' + * + * Example: User deleted '5' on the input + * mask = 'Phone Number: (999) 999 - 9999' + * maskedValue = 'Phone Number: (123) 4_ - ___' + * return = '1234' + * + * @param mask The string use to define the format of the displayed maskedValue. + * @param maskedValue The displayed mask string after an input change. + * @param cursorPositionCallback? A callback to move the input cursor to the start of the first unfilled mask format character. + * @param maskFormat An object defining how certain characters in the mask should accept input. + * + * @return The parsed value from the mask and maskedValue + * + * @public + */ +export function getValueFromMaskDisplay( + mask: string, + maskedValue: string, + cursorPositionCallback?: (index: number) => void, + maskFormat: { [key: string]: RegExp } = DEFAULT_MASK_FORMAT_CHARS): string { + + let value = ''; + + // Find the char difference between the mask and the value + let valueIndex = 0; + let maskIndex = 0; + + let escapeNext = false; + // Count of characters skipped to reach next non-format mask character + let skippedChars = 0; + while (valueIndex < maskedValue.length && maskIndex < mask.length) { + // Check for escaped characters and skip them + if (mask.charAt(maskIndex) === '\\') { + escapeNext = true; + maskIndex++; + } else { + const formatExp = maskFormat[mask.charAt(maskIndex)]; + // If next character is not escaped and is a format character + if (!escapeNext && formatExp) { + let valueChar = maskedValue.charAt(valueIndex); + // Check if the value does not match the format expression + if (!formatExp.test(valueChar)) { + cursorPositionCallback && cursorPositionCallback(valueIndex - skippedChars); + return value; + } else { + value += valueChar; + } + } else { + escapeNext = false; + // Otherwise, character in mask string is normal prompt text + // Skip characters in the maskedValue until you reach a character that matches the mask + while ( + valueIndex < maskedValue.length && + maskedValue.charAt(valueIndex) !== mask.charAt(maskIndex)) { + skippedChars++; + valueIndex++; + } + } + valueIndex++; + maskIndex++; + } + } + cursorPositionCallback && cursorPositionCallback(valueIndex); + return value; +} From cfd5e0aa84ad0f02e902a1a32778d636721b53e2 Mon Sep 17 00:00:00 2001 From: Lambert W Date: Wed, 24 Jan 2018 14:58:16 -0800 Subject: [PATCH 05/21] Added selection deletion tests --- packages/utilities/src/inputMask.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/utilities/src/inputMask.test.ts b/packages/utilities/src/inputMask.test.ts index a898f990aacd06..b43df8ef769119 100644 --- a/packages/utilities/src/inputMask.test.ts +++ b/packages/utilities/src/inputMask.test.ts @@ -48,5 +48,15 @@ describe('inputMask', () => { 'Phone number: (999) 999 - 9999', 'Phone number: (12) 45_ - ____'); expect(result).toEqual('12'); + + result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + ''); + expect(result).toEqual(''); + + result = getValueFromMaskDisplay( + 'Phone number: (999) 999 - 9999', + 'Phone number: (12'); + expect(result).toEqual('12'); }); }); From c61c77c4cd15dfb349f0a0c16289355f04aa388a Mon Sep 17 00:00:00 2001 From: Lambert W Date: Fri, 26 Jan 2018 14:37:05 -0800 Subject: [PATCH 06/21] Refactored textfield into separate components --- apps/todo-app/src/components/TodoForm.tsx | 10 +- .../inputs/textInput/FormTextInput.test.tsx | 4 +- .../Form/inputs/textInput/FormTextInput.tsx | 4 +- .../examples/LayoutGroup.Basic.Example.tsx | 13 +- .../components/ColorPicker/ColorPicker.tsx | 42 +- .../__snapshots__/ColorPicker.test.tsx.snap | 5 - .../ContextualMenu.Directional.Example.tsx | 3 +- .../src/components/DatePicker/DatePicker.tsx | 8 +- .../examples/DetailsList.Advanced.Example.tsx | 4 +- .../examples/DetailsList.Basic.Example.tsx | 4 +- .../examples/DetailsList.Compact.Example.tsx | 4 +- .../DetailsList.Documents.Example.tsx | 4 +- .../FocusTrapZone.Box.Click.Example.tsx | 4 +- .../examples/FocusTrapZone.Box.Example.tsx | 4 +- ...pZone.Box.FocusOnCustomElement.Example.tsx | 4 +- .../examples/FocusZone.Disabled.Example.tsx | 6 +- .../List/examples/List.Basic.Example.tsx | 4 +- .../List/examples/List.Scrolling.Example.tsx | 4 +- .../ScrollablePane.DetailsList.Example.tsx | 4 +- .../components/TextField/BaseTextField.tsx | 483 ++++++++++++++++ .../DefaultTextField/DefaultTextField.tsx | 21 + .../MaskedTextField/MaskedTextField.tsx | 325 +++++++++++ .../components/TextField/TextField.test.tsx | 59 +- .../src/components/TextField/TextField.tsx | 524 +----------------- .../components/TextField/TextField.types.ts | 1 + .../__snapshots__/TextField.test.tsx.snap | 1 - .../TextField/examples/NumberTextField.tsx | 4 +- .../TextField.AllErrorMessage.Example.tsx | 10 +- .../TextField.AutoComplete.Example.tsx | 4 +- .../examples/TextField.Basic.Example.tsx | 15 +- .../examples/TextField.Borderless.Example.tsx | 6 +- .../TextField.CustomRender.Example.tsx | 4 +- .../TextField.ErrorMessage.Example.tsx | 24 +- .../examples/TextField.Icon.Example.tsx | 4 +- .../examples/TextField.Multiline.Example.tsx | 14 +- .../TextField.Placeholder.Example.tsx | 10 +- .../examples/TextField.Prefix.Example.tsx | 4 +- .../TextField.PrefixAndSuffix.Example.tsx | 4 +- .../examples/TextField.Suffix.Example.tsx | 4 +- .../examples/TextField.Underlined.Example.tsx | 10 +- .../src/components/TextField/index.ts | 3 + packages/utilities/src/index.ts | 1 - packages/utilities/src/inputMask.test.ts | 154 +++-- packages/utilities/src/inputMask.ts | 300 ++++++---- 44 files changed, 1312 insertions(+), 812 deletions(-) create mode 100644 packages/office-ui-fabric-react/src/components/TextField/BaseTextField.tsx create mode 100644 packages/office-ui-fabric-react/src/components/TextField/DefaultTextField/DefaultTextField.tsx create mode 100644 packages/office-ui-fabric-react/src/components/TextField/MaskedTextField/MaskedTextField.tsx diff --git a/apps/todo-app/src/components/TodoForm.tsx b/apps/todo-app/src/components/TodoForm.tsx index 734130cb0c53e3..7ffae8be91a371 100644 --- a/apps/todo-app/src/components/TodoForm.tsx +++ b/apps/todo-app/src/components/TodoForm.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { autobind } from 'office-ui-fabric-react/lib/Utilities'; import { PrimaryButton } from 'office-ui-fabric-react/lib/Button'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField, ITextField } from 'office-ui-fabric-react/lib/TextField'; import { ITodoFormProps, ITodoFormState } from '../types/index'; import * as stylesImport from './Todo.scss'; const styles: any = stylesImport; @@ -15,7 +15,7 @@ import strings from './../strings'; * Button: https://fabricreact.azurewebsites.net/fabric-react/master/#/examples/button */ export default class TodoForm extends React.Component { - private _textField: TextField; + private _textField: ITextField; constructor(props: ITodoFormProps) { super(props); @@ -32,10 +32,12 @@ export default class TodoForm extends React.Component - this._textField = ref } + /* tslint:disable:jsx-no-lambda*/ + componentRef={ ref => this._textField = ref } + /* tslint:enable:jsx-no-lambda*/ placeholder={ strings.inputBoxPlaceholder } onBeforeChange={ this._onBeforeTextFieldChange } autoComplete='off' diff --git a/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.test.tsx b/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.test.tsx index 8beeb84a76a589..462d09b6d1fa53 100644 --- a/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.test.tsx +++ b/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.test.tsx @@ -10,7 +10,7 @@ import { IFormProps } from '../../Form.types'; import { DEFAULT_DEBOUNCE } from '../../FormBaseInput'; import { FormTextInput } from './FormTextInput'; import { IFormTextInputProps } from './FormTextInput.types'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { BaseTextField } from 'office-ui-fabric-react/lib/TextField'; // Utilities import { Validators } from '../../validators/Validators'; @@ -112,7 +112,7 @@ describe('FormTextInput Unit Tests', () => { ReactTestUtils.Simulate.submit(form); // Find the TextField component - let field = ReactTestUtils.findRenderedComponentWithType(renderedForm, TextField); + let field = ReactTestUtils.findRenderedComponentWithType(renderedForm, BaseTextField); expect(field.state.errorMessage).toBeTruthy(); expect(result).toBeFalsy(); }); diff --git a/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.tsx b/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.tsx index f9230e3cab7bf9..5f17b5c8d563ec 100644 --- a/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.tsx +++ b/packages/experiments/src/components/Form/inputs/textInput/FormTextInput.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { IFormTextInputProps } from './FormTextInput.types'; import { FormBaseInput, IFormBaseInputState } from '../../FormBaseInput'; import { IFormContext } from '../../Form'; -import { TextField, ITextFieldProps } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField, ITextFieldProps } from 'office-ui-fabric-react/lib/TextField'; // Utilities import { autobind } from 'office-ui-fabric-react/lib/Utilities'; @@ -37,7 +37,7 @@ export class FormTextInput extends FormBaseInput { } /> - - + + diff --git a/packages/office-ui-fabric-react/src/components/ColorPicker/ColorPicker.tsx b/packages/office-ui-fabric-react/src/components/ColorPicker/ColorPicker.tsx index 1eb8f5ca893a94..69493f77e9827d 100644 --- a/packages/office-ui-fabric-react/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/office-ui-fabric-react/src/components/ColorPicker/ColorPicker.tsx @@ -5,7 +5,7 @@ import { css } from '../../Utilities'; import { IColorPickerProps } from './ColorPicker.types'; -import { TextField } from '../../TextField'; +import { DefaultTextField, ITextField } from '../../TextField'; import { ColorRectangle } from './ColorRectangle'; import { ColorSlider } from './ColorSlider'; import { @@ -35,11 +35,11 @@ export class ColorPicker extends BaseComponent - this.hexText = ref! } + /* tslint:disable:jsx-no-lambda*/ + componentRef={ (ref) => this.hexText = ref! } + /* tslint:enable:jsx-no-lambda*/ onBlur={ this._onHexChanged } spellCheck={ false } /> - this.rText = ref! } + /* tslint:disable:jsx-no-lambda*/ + componentRef={ (ref) => this.rText = ref! } + /* tslint:enable:jsx-no-lambda*/ spellCheck={ false } /> - this.gText = ref! } + /* tslint:disable:jsx-no-lambda*/ + componentRef={ (ref) => this.gText = ref! } + /* tslint:enable:jsx-no-lambda*/ spellCheck={ false } /> - this.bText = ref! } + /* tslint:disable:jsx-no-lambda*/ + componentRef={ (ref) => this.bText = ref! } + /* tslint:enable:jsx-no-lambda*/ spellCheck={ false } /> { !this.props.alphaSliderHidden && ( - this.aText = ref! } + /* tslint:disable:jsx-no-lambda*/ + componentRef={ (ref) => this.aText = ref! } + /* tslint:enable:jsx-no-lambda*/ spellCheck={ false } /> diff --git a/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap index 0252da899a09f2..0776b8f363c6dc 100644 --- a/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/ColorPicker/__snapshots__/ColorPicker.test.tsx.snap @@ -131,7 +131,6 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} - onKeyDown={[Function]} spellCheck={false} type="text" value="ffffff" @@ -166,7 +165,6 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} - onKeyDown={[Function]} spellCheck={false} type="text" value="255" @@ -201,7 +199,6 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} - onKeyDown={[Function]} spellCheck={false} type="text" value="255" @@ -236,7 +233,6 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} - onKeyDown={[Function]} spellCheck={false} type="text" value="255" @@ -271,7 +267,6 @@ exports[`ColorPicker renders ColorPicker correctly 1`] = ` onChange={[Function]} onFocus={[Function]} onInput={[Function]} - onKeyDown={[Function]} spellCheck={false} type="text" value="100" diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/examples/ContextualMenu.Directional.Example.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/examples/ContextualMenu.Directional.Example.tsx index a591d48fd70b4b..3ef040eae513b2 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/examples/ContextualMenu.Directional.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/examples/ContextualMenu.Directional.Example.tsx @@ -3,7 +3,7 @@ import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox'; import { DirectionalHint, ContextualMenuItemType } from 'office-ui-fabric-react/lib/ContextualMenu'; import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { autobind, getRTL } from 'office-ui-fabric-react/lib/Utilities'; import './ContextualMenuExample.scss'; import * as exampleStylesImport from '../../../common/_exampleStyles.scss'; @@ -39,7 +39,6 @@ export class ContextualMenuDirectionalExample extends React.Component<{}, IConte public refs: { [key: string]: React.ReactInstance; menuButton: HTMLElement; - gapSize: TextField; }; public constructor(props: {}) { diff --git a/packages/office-ui-fabric-react/src/components/DatePicker/DatePicker.tsx b/packages/office-ui-fabric-react/src/components/DatePicker/DatePicker.tsx index 995bfedeeebbfd..e98cf17da5c771 100644 --- a/packages/office-ui-fabric-react/src/components/DatePicker/DatePicker.tsx +++ b/packages/office-ui-fabric-react/src/components/DatePicker/DatePicker.tsx @@ -10,7 +10,7 @@ import { import { FirstWeekOfYear } from '../../utilities/dateValues/DateValues'; import { Callout } from '../../Callout'; import { DirectionalHint } from '../../common/DirectionalHint'; -import { TextField } from '../../TextField'; +import { DefaultTextField, ITextField } from '../../TextField'; import { autobind, BaseComponent, @@ -122,7 +122,7 @@ export class DatePicker extends BaseComponent
-
diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Advanced.Example.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Advanced.Example.tsx index b350a86bfbac66..87cddd559d6dce 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Advanced.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Advanced.Example.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Link } from 'office-ui-fabric-react/lib/Link'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { CommandBar } from 'office-ui-fabric-react/lib/CommandBar'; import { IContextualMenuProps, @@ -119,7 +119,7 @@ export class DetailsListAdvancedExample extends React.Component<{}, IDetailsList { (isGrouped) ? - : + : (null) } diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Basic.Example.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Basic.Example.tsx index f121a24db19081..cb00e8a638f30d 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Basic.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Basic.Example.tsx @@ -1,7 +1,7 @@ /* tslint:disable:no-unused-variable */ import * as React from 'react'; /* tslint:enable:no-unused-variable */ -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { DetailsList, DetailsListLayoutMode, @@ -70,7 +70,7 @@ export class DetailsListBasicExample extends React.Component<{}, { return (
{ selectionDetails }
- diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Compact.Example.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Compact.Example.tsx index 26c9b2f1fe04e7..d3a3206f0874d9 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Compact.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Compact.Example.tsx @@ -1,7 +1,7 @@ /* tslint:disable:no-unused-variable */ import * as React from 'react'; /* tslint:enable:no-unused-variable */ -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { DetailsList, DetailsListLayoutMode, @@ -71,7 +71,7 @@ export class DetailsListCompactExample extends React.Component<{}, { return (
{ selectionDetails }
- diff --git a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Documents.Example.tsx b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Documents.Example.tsx index 174fcfb1abaf91..45edddfa9fae64 100644 --- a/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Documents.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/DetailsList/examples/DetailsList.Documents.Example.tsx @@ -1,7 +1,7 @@ /* tslint:disable:no-unused-variable */ import * as React from 'react'; /* tslint:enable:no-unused-variable */ -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; import { DetailsList, @@ -222,7 +222,7 @@ export class DetailsListDocumentsExample extends React.Component
{ selectionDetails }
- diff --git a/packages/office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.Box.Click.Example.tsx b/packages/office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.Box.Click.Example.tsx index 603cc4800d9ed1..81c9166420edea 100644 --- a/packages/office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.Box.Click.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/FocusTrapZone/examples/FocusTrapZone.Box.Click.Example.tsx @@ -6,7 +6,7 @@ import { autobind } from '../../../Utilities'; import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { FocusTrapZone } from 'office-ui-fabric-react/lib/FocusTrapZone'; import { Link } from 'office-ui-fabric-react/lib/Link'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { Toggle, IToggle } from 'office-ui-fabric-react/lib/Toggle'; import './FocusTrapZone.Box.Example.scss'; @@ -60,7 +60,7 @@ export default class BoxNoClickExample extends React.Component - + Hyperlink inside FocusTrapZone

- + Hyperlink inside FocusTrapZone

- + Hyperlink inside FocusTrapZone

( @@ -14,7 +14,7 @@ export const FocusZoneDisabledExample = () => ( Enabled FocusZone: Button 1 Button 2 - + Button 3
@@ -29,7 +29,7 @@ export const FocusZoneDisabledExample = () => (
- +
); diff --git a/packages/office-ui-fabric-react/src/components/List/examples/List.Basic.Example.tsx b/packages/office-ui-fabric-react/src/components/List/examples/List.Basic.Example.tsx index 1801030ef64a6f..081d22b891b803 100644 --- a/packages/office-ui-fabric-react/src/components/List/examples/List.Basic.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/List/examples/List.Basic.Example.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { getRTL } from 'office-ui-fabric-react/lib/Utilities'; import { FocusZone, FocusZoneDirection } from 'office-ui-fabric-react/lib/FocusZone'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { Image, ImageFit } from 'office-ui-fabric-react/lib/Image'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { List } from 'office-ui-fabric-react/lib/List'; @@ -35,7 +35,7 @@ export class ListBasicExample extends React.Component - + Scroll item index: - diff --git a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx index cf360d711d8aeb..0bf34a09bb1aae 100644 --- a/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx +++ b/packages/office-ui-fabric-react/src/components/ScrollablePane/examples/ScrollablePane.DetailsList.Example.tsx @@ -1,7 +1,7 @@ /* tslint:disable:no-unused-variable */ import * as React from 'react'; /* tslint:enable:no-unused-variable */ -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultTextField } from 'office-ui-fabric-react/lib/TextField'; import { DetailsList, DetailsListLayoutMode, @@ -87,7 +87,7 @@ export class ScrollablePaneDetailsListExample extends React.Component<{}, { return ( { selectionDetails } - this.setState({ items: text ? _items.filter(i => i.name.toLowerCase().indexOf(text) > -1) : _items }) } diff --git a/packages/office-ui-fabric-react/src/components/TextField/BaseTextField.tsx b/packages/office-ui-fabric-react/src/components/TextField/BaseTextField.tsx new file mode 100644 index 00000000000000..b78264955d612a --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/TextField/BaseTextField.tsx @@ -0,0 +1,483 @@ +import * as React from 'react'; +import { ITextField, ITextFieldProps } from './TextField.types'; +import { Label } from '../../Label'; +import { Icon } from '../../Icon'; +import { + DelayedRender, + BaseComponent, + getId, + css, + getNativeProps, + inputProperties, + textAreaProperties +} from '../../Utilities'; +import * as stylesImport from './TextField.scss'; +const styles: any = stylesImport; +import { AnimationClassNames } from '../../Styling'; + +export interface ITextFieldState { + value?: string | undefined; + + /** Is true when the control has focus. */ + isFocused?: boolean; + + /** + * The validation error message. + * + * - If there is no validation error or we have not validated the input value, errorMessage is an empty string. + * - If we have done the validation and there is validation error, errorMessage is the validation error message. + */ + errorMessage?: string; +} + +export const DEFAULT_MASK_CHAR = '_'; + +export class BaseTextField extends BaseComponent implements ITextField { + public static defaultProps: ITextFieldProps = { + multiline: false, + resizable: true, + autoAdjustHeight: false, + underlined: false, + borderless: false, + maskChar: DEFAULT_MASK_CHAR, + onChanged: () => { /* noop */ }, + onBeforeChange: () => { /* noop */ }, + onNotifyValidationResult: () => { /* noop */ }, + onGetErrorMessage: () => undefined, + deferredValidationTime: 200, + errorMessage: '', + validateOnFocusIn: false, + validateOnFocusOut: false, + validateOnLoad: true, + }; + + private _id: string; + private _descriptionId: string; + private _delayedValidate: (value: string | undefined) => void; + private _isMounted: boolean; + private _lastValidation: number; + private _latestValue: string | undefined; + private _latestValidateValue: string | undefined; + private _isDescriptionAvailable: boolean; + private _textElement: HTMLTextAreaElement; + + public constructor(props: ITextFieldProps) { + super(props); + + this._warnDeprecations({ + 'iconClass': 'iconProps', + 'addonString': 'prefix', + 'onRenderAddon': 'onRenderPrefix' + }); + + this._warnMutuallyExclusive({ + 'value': 'defaultValue' + }); + + this._id = getId('TextField'); + this._descriptionId = getId('TextFieldDescription'); + + this.state = { + value: props.value || props.defaultValue || '', + isFocused: false, + errorMessage: '' + }; + + this._onInputChange = this._onInputChange.bind(this); + this._onFocus = this._onFocus.bind(this); + this._onBlur = this._onBlur.bind(this); + + this._delayedValidate = this._async.debounce(this._validate, this.props.deferredValidationTime); + this._lastValidation = 0; + this._isDescriptionAvailable = false; + } + + /** + * Gets the current value of the text field. + */ + public get value(): string | undefined { + return this.state.value; + } + + /** + * Sets the current value of the text field. + */ + public set value(value: string | undefined) { + this.setState({ value }); + } + + public componentDidMount() { + this._isMounted = true; + this._adjustInputHeight(); + + if (this.props.validateOnLoad) { + this._validate(this.state.value); + } + } + + public componentWillReceiveProps(newProps: ITextFieldProps) { + const { onBeforeChange } = this.props; + + if (newProps.value !== undefined && newProps.value !== this.state.value) { + if (onBeforeChange) { + onBeforeChange(newProps.value); + } + + this._latestValue = newProps.value; + this.setState({ + value: newProps.value, + errorMessage: '' + } as ITextFieldState); + + this._delayedValidate(newProps.value); + } + } + + public componentWillUnmount() { + this._isMounted = false; + } + + public render() { + let { + className, + description, + disabled, + iconClass, + iconProps, + multiline, + required, + underlined, + borderless, + addonString, // @deprecated + prefix, + suffix, + onRenderAddon = this._onRenderAddon, // @deprecated + onRenderPrefix = this._onRenderPrefix, + onRenderSuffix = this._onRenderSuffix, + onRenderLabel = this._onRenderLabel + } = this.props; + let { isFocused } = this.state; + const errorMessage = this._errorMessage; + this._isDescriptionAvailable = Boolean(description || errorMessage); + const renderProps: ITextFieldProps = { ...this.props, componentId: this._id }; + + const textFieldClassName = css('ms-TextField', styles.root, className, { + ['is-required ' + styles.rootIsRequiredLabel]: this.props.label && required, + ['is-required ' + styles.rootIsRequiredPlaceholderOnly]: !this.props.label && required, + ['is-disabled ' + styles.rootIsDisabled]: disabled, + ['is-active ' + styles.rootIsActive]: isFocused, + ['ms-TextField--multiline ' + styles.rootIsMultiline]: multiline, + ['ms-TextField--underlined ' + styles.rootIsUnderlined]: underlined, + ['ms-TextField--borderless ' + styles.rootIsBorderless]: borderless + }); + + return ( +
+
+ { onRenderLabel(renderProps, this._onRenderLabel) } +
+ { (addonString !== undefined || this.props.onRenderAddon) && ( +
+ { onRenderAddon(this.props, this._onRenderAddon) } +
+ ) } + { (prefix !== undefined || this.props.onRenderPrefix) && ( +
+ { onRenderPrefix(this.props, this._onRenderPrefix) } +
+ ) } + { multiline ? this._renderTextArea() : this._renderInput() } + { (iconClass || iconProps) && } + { (suffix !== undefined || this.props.onRenderSuffix) && ( +
+ { onRenderSuffix(this.props, this._onRenderSuffix) } +
+ ) } +
+
+ { this._isDescriptionAvailable && + + { description && { description } } + { errorMessage && +
+ +

+ { errorMessage } +

+
+
+ } +
+ } +
+ ); + } + + /** + * Sets focus on the text field + */ + public focus() { + if (this._textElement) { + this._textElement.focus(); + } + } + + /** + * Selects the text field + */ + public select() { + if (this._textElement) { + this._textElement.select(); + } + } + + /** + * Sets the selection start of the text field to a specified value + */ + public setSelectionStart(value: number) { + if (this._textElement) { + this._textElement.selectionStart = value; + } + } + + /** + * Sets the selection end of the text field to a specified value + */ + public setSelectionEnd(value: number) { + if (this._textElement) { + this._textElement.selectionEnd = value; + } + } + + /** + * Gets the selection start of the text field + */ + public get selectionStart(): number { + return this._textElement ? this._textElement.selectionStart : -1; + } + + /** + * Gets the selection end of the text field + */ + public get selectionEnd(): number { + return this._textElement ? this._textElement.selectionEnd : -1; + } + + /** + * Sets the start and end positions of a selection in a text field. + * @param start Index of the start of the selection. + * @param end Index of the end of the selection. + */ + public setSelectionRange(start: number, end: number) { + if (this._textElement) { + this._textElement.setSelectionRange(start, end); + } + } + + private _onFocus(ev: React.FocusEvent) { + if (this.props.onFocus) { + this.props.onFocus(ev); + } + + this.setState({ isFocused: true }); + if (this.props.validateOnFocusIn) { + this._validate(this.state.value); + } + + } + + private _onBlur(ev: React.FocusEvent) { + if (this.props.onBlur) { + this.props.onBlur(ev); + } + + this.setState({ isFocused: false }); + if (this.props.validateOnFocusOut) { + this._validate(this.state.value); + } + } + + private _onRenderLabel(props: ITextFieldProps): JSX.Element | null { + const { + label, + componentId + } = props; + if (label) { + return (); + } + return null; + } + + // @deprecated + private _onRenderAddon(props: ITextFieldProps): JSX.Element { + let { addonString } = props; + return ( + { addonString } + ); + } + + private _onRenderPrefix(props: ITextFieldProps): JSX.Element { + let { prefix } = props; + return ( + { prefix } + ); + } + + private _onRenderSuffix(props: ITextFieldProps): JSX.Element { + let { suffix } = props; + return ( + { suffix } + ); + } + + private _getTextElementClassName(): string { + let textFieldClassName: string; + + if (this.props.multiline && !this.props.resizable) { + textFieldClassName = css('ms-TextField-field ms-TextField-field--unresizable', styles.field, styles.fieldIsUnresizable); + } else { + textFieldClassName = css('ms-TextField-field', styles.field); + } + + return css(textFieldClassName, this.props.inputClassName, { + [styles.hasIcon]: !!this.props.iconClass, + }); + } + + private get _errorMessage(): string | undefined { + let { errorMessage } = this.state; + if (!errorMessage) { + errorMessage = this.props.errorMessage; + } + + return errorMessage; + } + + private _renderTextArea(): React.ReactElement> { + let textAreaProps = getNativeProps(this.props, textAreaProperties, ['defaultValue']); + + return ( +