import * as React from 'react'; import { HistoryNavigator } from '../common'; import { formatWithPattern, getCountOfSymbolsInSelection, getCursorPositionAfterKeystroke, unformatWithPattern, getDistanceToPreviousSymbol, getDistanceToNextSymbol, } from '../common/textFormat'; import { InputProps } from '../inputs/Input'; import { TextAreaProps } from '../inputs/TextArea'; type HTMLTextElement = HTMLInputElement | HTMLTextAreaElement; type TextElementProps = InputProps | TextAreaProps; export type EventType = | 'KeyDown' | 'Paste' | 'Cut' | 'Undo' | 'Redo' | 'Backspace' | 'Delete' | 'Initial'; interface WithDisplayFormatState { value: string; historyNavigator: HistoryNavigator; prevDisplayPattern: string; triggerType: EventType; triggerEvent: React.KeyboardEvent | null; pastedLength: number; selectionStart: number; selectionEnd: number; } export interface WithDisplayFormatProps extends Pick< TextElementProps, | 'className' | 'disabled' | 'id' | 'maxLength' | 'minLength' | 'name' | 'placeholder' | 'readOnly' | 'required' | 'inputMode' > { /** @default '' */ value?: string; /** @default '' */ displayPattern: string; /** * autocomplete hides our form help so we need to disable it when help text * is present. Chrome ignores autocomplete=off, the only way to disable it is * to provide an 'invalid' value, for which 'disabled' serves. * @default 'off' */ autoComplete?: TextElementProps['autoComplete'] | 'disabled'; onChange?: (value: string) => void; onBlur?: (value: string) => void; onFocus?: (value: string) => void; render: (renderProps: T) => React.JSX.Element; } class WithDisplayFormat extends React.Component< WithDisplayFormatProps, WithDisplayFormatState > { declare props: WithDisplayFormatProps & Required, keyof typeof WithDisplayFormat.defaultProps>>; static defaultProps = { autoComplete: 'off', displayPattern: '', value: '', }; constructor(props: WithDisplayFormatProps) { super(props); const unformattedValue = unformatWithPattern(props.value ?? '', props.displayPattern); this.state = { value: formatWithPattern(unformattedValue, props.displayPattern), historyNavigator: new HistoryNavigator(), prevDisplayPattern: props.displayPattern, triggerType: 'Initial', triggerEvent: null, selectionStart: 0, selectionEnd: 0, pastedLength: 0, }; } static getDerivedStateFromProps( { displayPattern }: WithDisplayFormatProps, { prevDisplayPattern = displayPattern, value, historyNavigator }: WithDisplayFormatState, ) { if (prevDisplayPattern !== displayPattern) { const unFormattedValue = unformatWithPattern(value, prevDisplayPattern); historyNavigator.reset(); return { prevDisplayPattern: displayPattern, value: formatWithPattern(unFormattedValue, displayPattern), triggerType: null, triggerEvent: null, pastedLength: 0, }; } return null; } getUserAction = (unformattedValue: string): EventType | string => { const { triggerEvent, triggerType, value } = this.state; const { displayPattern } = this.props; if (triggerEvent) { if (triggerType === 'Paste' || triggerType === 'Cut') { return triggerType; } if ((triggerEvent.ctrlKey || triggerEvent.metaKey) && triggerEvent.key === 'z') { return triggerEvent.shiftKey ? 'Redo' : 'Undo'; } // Detect mouse event redo if (triggerEvent.ctrlKey && triggerEvent.key === 'd') { return 'Delete'; } // Android Fix. if ( typeof triggerEvent.key === 'undefined' && unformattedValue.length <= unformatWithPattern(value, displayPattern).length ) { return 'Backspace'; } return triggerEvent.key; } // triggerEvent can be null only in case of "autofilling" (via password manager extension or browser build-in one) events return 'Paste'; }; resetEvent = () => { this.setState({ triggerType: 'Initial', triggerEvent: null, pastedLength: 0, }); }; detectUndoRedo = (event: React.KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key === 'z') { return event.shiftKey ? 'Redo' : 'Undo'; } return null; }; handleOnKeyDown: React.KeyboardEventHandler = (event) => { event.persist(); const { selectionStart, selectionEnd } = event.currentTarget; const { historyNavigator } = this.state; const { displayPattern } = this.props; // Unfortunately Undo and Redo don't trigger OnChange event so we need to handle some value logic here. let newFormattedValue = ''; if (this.detectUndoRedo(event) === 'Undo') { newFormattedValue = formatWithPattern(historyNavigator.undo().toString(), displayPattern); this.setState({ value: newFormattedValue, triggerType: 'Undo' }); } else if (this.detectUndoRedo(event) === 'Redo') { newFormattedValue = formatWithPattern(historyNavigator.redo().toString(), displayPattern); this.setState({ value: newFormattedValue, triggerType: 'Redo' }); } else { this.setState({ triggerEvent: event, triggerType: 'KeyDown', selectionStart: selectionStart ?? 0, selectionEnd: selectionEnd ?? 0, }); } }; handleOnPaste: React.ClipboardEventHandler = (event) => { const { displayPattern } = this.props; const pastedLength = unformatWithPattern( event.clipboardData.getData('Text'), displayPattern, ).length; this.setState({ triggerType: 'Paste', pastedLength }); }; handleOnCut: React.ClipboardEventHandler = () => { this.setState({ triggerType: 'Cut' }); }; isKeyAllowed = (action: EventType | string) => { const { displayPattern } = this.props; const symbolsInPattern = displayPattern.split('').filter((character) => character !== '*'); return !symbolsInPattern.includes(action); }; handleOnChange: React.ChangeEventHandler = (event) => { const { historyNavigator, triggerType } = this.state; const { displayPattern, onChange } = this.props; const { value } = event.target; let unformattedValue = unformatWithPattern(value, displayPattern); const action = this.getUserAction(unformattedValue); if (!this.isKeyAllowed(action) || triggerType === 'Undo' || triggerType === 'Redo') { return; } if (action === 'Backspace' || action === 'Delete') { unformattedValue = this.handleDelete(unformattedValue, action); } const newFormattedValue = formatWithPattern(unformattedValue, displayPattern); historyNavigator.add(unformattedValue); this.handleCursorPositioning(action); this.setState({ value: newFormattedValue }, () => { this.resetEvent(); if (onChange) { const broadcastValue = unformatWithPattern(newFormattedValue, displayPattern); onChange(broadcastValue); } }); }; handleOnBlur: React.FocusEventHandler = (event) => { this.props.onBlur?.(unformatWithPattern(event.target.value, this.props.displayPattern)); }; handleOnFocus: React.FocusEventHandler = (event) => { const { displayPattern, onFocus } = this.props; if (onFocus) { onFocus(unformatWithPattern(event.target.value, displayPattern)); } }; handleDelete = (unformattedValue: string, action: EventType) => { const { displayPattern } = this.props; const { selectionStart, selectionEnd } = this.state; const newStack = [...unformattedValue]; if (selectionStart === selectionEnd) { let startPosition = selectionStart - getCountOfSymbolsInSelection(0, selectionStart, displayPattern); let toDelete = 0; let count = getDistanceToNextSymbol(selectionStart, displayPattern); if (action === 'Backspace') { startPosition -= 1; count = getDistanceToPreviousSymbol(selectionStart, displayPattern); } if (startPosition >= 0 && count) { toDelete = 1; } newStack.splice(startPosition, toDelete); } return newStack.join(''); }; handleCursorPositioning = (action: string) => { const { displayPattern } = this.props; const { triggerEvent, selectionStart, selectionEnd, pastedLength } = this.state; const cursorPosition = getCursorPositionAfterKeystroke( action, selectionStart, selectionEnd, displayPattern, pastedLength, ); setTimeout(() => { if (triggerEvent) { (triggerEvent.target as HTMLTextElement).setSelectionRange(cursorPosition, cursorPosition); } this.setState({ selectionStart: cursorPosition, selectionEnd: cursorPosition }); }, 0); }; render() { const { inputMode, className, id, name, placeholder, readOnly, required, minLength, maxLength, disabled, autoComplete, } = this.props; const { value } = this.state; const renderProps: TextElementProps = { inputMode, className, id, name, placeholder, readOnly, required, minLength, maxLength, disabled, autoComplete, value, onFocus: this.handleOnFocus, onBlur: this.handleOnBlur, onPaste: this.handleOnPaste, onKeyDown: this.handleOnKeyDown, onChange: this.handleOnChange, onCut: this.handleOnCut, }; return this.props.render(renderProps as T); } } export default WithDisplayFormat;