UNPKG

wix-style-react

Version:
370 lines • 15.9 kB
import React, { useContext, useMemo, useCallback, useRef, useImperativeHandle, useEffect, } from 'react'; import PropTypes from 'prop-types'; import Input from '../Input'; import DropdownBase from '../DropdownBase'; import { dataHooks } from './constants'; import { st, classes } from './TimeInput.st.css'; import { SupportedWixLocales } from 'wix-design-systems-locale-utils'; import { WixStyleReactEnvironmentContext } from '../WixStyleReactEnvironmentProvider/context'; import { getTimeSlots, getTimeSlot, getClosestTimeSlot, getSuggestedOption, isInputInvalid, getAutoFilledValue, getCustomTimeSlot, getTimeFilter, } from './TimeInputUtils'; export const DEFAULT_STEP = 15; export const DEFAULT_TIME_STYLE = 'short'; const initialState = { isDropdownOpen: false, inputValue: '', selectedId: null, customInputId: null, highlightedOptionId: null, error: false, selectionRange: false, deleteEvent: false, scrollToOption: null, lastSavedDate: null, }; const TimeInput = React.forwardRef(({ dataHook, className, size, suffix, prefix, status, statusMessage, invalidMessage, border, disabled, placeholder, readOnly, autoSelect, width = 'auto', timeStyle = DEFAULT_TIME_STYLE, onChange, onInvalid, step = DEFAULT_STEP, noRightBorderRadius, noLeftBorderRadius, popoverProps, onFocus, onBlur, excludePastTimes, filterTime, ...rest }, ref) => { const inputRef = useRef(); const context = useContext(WixStyleReactEnvironmentContext); const locale = rest.locale || context.locale || 'en'; // Resetting seconds so that one of timeSlots would be selected in case value with seconds is passed const value = rest.value ? rest.value.setSeconds(0, 0) : undefined; const timeFilter = useMemo(() => getTimeFilter({ excludePastTimes, filterTime }), [excludePastTimes, filterTime]); const timeSlots = useMemo(() => getTimeSlots({ value, timeStyle, locale, step }).filter(({ id }) => timeFilter(new Date(id))), [value, timeStyle, locale, step, timeFilter]); const [state, setState] = React.useReducer((currentState, newState) => ({ ...currentState, ...newState }), initialState); const shouldShowError = invalidMessage && state.error; const autofillValue = state.selectionRange ? state.inputValue.substring(state.selectionRange.selectionStart) : null; useEffect(() => { if (rest.value === null) { setState({ inputValue: '' }); return; } const timeSlot = value ? getTimeSlot({ value, timeStyle, locale }) : getClosestTimeSlot({ value, timeSlots }); const selectedOption = timeSlot && timeSlots.find(slot => timeSlot.id === slot.id); setState({ inputValue: timeSlot && timeSlot.value, selectedId: selectedOption && selectedOption.id, }); }, [value, rest.value, timeStyle, locale, timeSlots]); // select range when suggestedInputValue is changed useEffect(() => { if (state.selectionRange) { inputRef.current.setSelectionRange(state.selectionRange.selectionStart, state.selectionRange.selectionEnd); } }, [state.selectionRange]); useEffect(() => { if (state.error) { onInvalid?.({ validationType: state.validationType, value: state.inputValue, }); } }, [state.error, onInvalid, state.inputValue, state.validationType]); const openDropdown = useCallback(() => { if (readOnly) { return; } if (state.selectedId || state.highlightedOptionId) { setState({ isDropdownOpen: true }); return; } const closestTimeSlot = getClosestTimeSlot({ value: new Date(state.customInputId || value), timeSlots, }); setState({ isDropdownOpen: true, scrollToOption: closestTimeSlot ? closestTimeSlot.id : undefined, }); return; }, [ state.selectedId, state.highlightedOptionId, state.customInputId, timeSlots, readOnly, value, ]); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); openDropdown(); }, blur: () => { inputRef.current.blur(); }, }), [openDropdown]); const findCurrentOption = useCallback(() => { if (state.selectedId) { return { id: state.selectedId, value: state.inputValue }; } const highlightedOption = timeSlots.find(slot => slot.id === state.highlightedOptionId); if (highlightedOption) { return highlightedOption; } if (state.inputValue && !state.selectedId) { const customValue = getCustomTimeSlot({ value: state.inputValue, timeSlot: timeSlots[0].id, timeStyle, locale, }); if (customValue) { return { value: customValue.value, customId: customValue.id, }; } } }, [ locale, state.highlightedOptionId, state.inputValue, state.selectedId, timeSlots, timeStyle, ]); const validateAndSetOption = useCallback(opt => { const option = opt ? opt : findCurrentOption(); if (!option) { setState({ error: true, validationType: 'formatError' }); return; } else if (!timeFilter(new Date(option.id || option.customId))) { setState({ error: true, validationType: 'outOfBoundsError', }); return; } const date = option ? new Date(option.customId || option.id) : null; const valueChanged = (state.lastSavedDate?.getTime?.() ?? null) !== (date?.getTime?.() ?? null); setState({ selectedId: option.id, customInputId: option.customId, inputValue: option.value, selectionRange: false, isDropdownOpen: false, highlightedOptionId: null, error: false, lastSavedDate: date, }); if (option && valueChanged) { onChange && onChange({ date }); } return option; }, [findCurrentOption, timeFilter, onChange, state.lastSavedDate]); const onKeyDown = useCallback((e, delegateKeyDown) => { if (e.key === ' ' || e.key === 'Spacebar') { return; } if (!state.isDropdownOpen && e.key === 'ArrowDown') { openDropdown(); return e.preventDefault(); } delegateKeyDown(e); if (e.key === 'Backspace') { setState({ deleteEvent: true, }); } if (state.isDropdownOpen) { if (e.key === 'Escape') { setState({ isDropdownOpen: false }); return e.preventDefault(); } // should be handled by error validation if (e.key === 'Enter') { setState({ isDropdownOpen: false }); inputRef.current.setSelectionRange(state.inputValue.length, state.inputValue.length); if (!state.error) { return validateAndSetOption(); } } } }, [ state.isDropdownOpen, state.inputValue, openDropdown, validateAndSetOption, state.error, ]); const onInputChanged = useCallback(e => { let inputValue = e.target.value; let isDropdownOpen = state.isDropdownOpen; let selectedId = state.selectedId; let selectionRange = false; let error = false; let validationType; // open dropdown when value is selected with keyboard and value is typed again if (!isDropdownOpen) { isDropdownOpen = true; } // validate input if (isInputInvalid(e.target.value)) { error = true; validationType = 'formatError'; isDropdownOpen = false; } // unselect dropdown option if input value doesn not match any available timeslot if (inputValue !== timeSlots[state.selectedId]) { selectedId = null; } const suggestedOption = getSuggestedOption({ inputValue, timeSlots, locale, }); if (!state.deleteEvent && suggestedOption) { const autoFillValue = getAutoFilledValue({ inputValue, suggestedOption, locale, }); const inputWithAutoFill = inputValue + autoFillValue; inputValue = inputValue + autoFillValue; selectionRange = { selectionStart: inputWithAutoFill.length - autoFillValue.length, selectionEnd: inputWithAutoFill.length, }; } const highlightedOptionId = suggestedOption ? suggestedOption.id : null; setState({ isDropdownOpen, error, validationType, selectedId, highlightedOptionId, selectionRange, inputValue, deleteEvent: false, }); }, [ state.deleteEvent, timeSlots, state.isDropdownOpen, state.selectedId, locale, ]); const onInputBlur = useCallback(e => { onBlur && onBlur(e); if (state.isDropdownOpen) { setState({ isDropdownOpen: false }); } if (state.error) { return; } if (state.inputValue === '') { return onChange && onChange({ date: null }); } validateAndSetOption(); }, [ validateAndSetOption, onChange, onBlur, state.error, state.isDropdownOpen, state.inputValue, ]); const getStatusMessage = () => { if (shouldShowError) return invalidMessage; return statusMessage; }; return (React.createElement("div", { className: st(classes.root, className), style: { width }, "data-hook": dataHook, "data-value": state.selectedId || value, "data-locale": locale, "data-time-style": timeStyle, "data-autofill": autofillValue, "data-scroll-to-option": state.scrollToOption, "data-status-message": statusMessage }, React.createElement(DropdownBase, { dataHook: dataHooks.TimeInputDropdown, open: state.isDropdownOpen, onClickOutside: () => setState({ isDropdownOpen: false }), options: timeSlots, onSelect: validateAndSetOption, selectedId: state.selectedId, maxHeight: "216px", markedOptionId: state.highlightedOptionId, focusOnOption: state.highlightedOptionId, focusOnSelectedOption: true, scrollToOption: state.scrollToOption, onMouseDown: e => e.preventDefault(), dynamicWidth: true, ...popoverProps }, ({ delegateKeyDown }) => { return (React.createElement(Input, { dataHook: dataHooks.TimeInputInput, size: size, status: shouldShowError ? 'error' : status, statusMessage: getStatusMessage(), suffix: suffix && React.createElement(Input.Affix, null, suffix), prefix: prefix && React.createElement(Input.Affix, null, prefix), border: border, disabled: disabled, placeholder: placeholder, value: state.inputValue, onChange: onInputChanged, onInputClicked: openDropdown, onKeyDown: e => onKeyDown(e, delegateKeyDown), ref: inputRef, readOnly: readOnly, autoSelect: autoSelect, noLeftBorderRadius: noLeftBorderRadius, noRightBorderRadius: noRightBorderRadius, onFocus: onFocus, onBlur: onInputBlur })); }))); }); TimeInput.displayName = 'TimeInput'; TimeInput.propTypes = { /** Control the border style of input */ border: PropTypes.oneOf(['standard', 'round', 'bottomLine', 'none']), /** Specifies a CSS class name to be appended to the component’s root element */ className: PropTypes.string, /** Applies a data-hook HTML attribute that can be used in the tests */ dataHook: PropTypes.string, /** Specifies whether component is disabled */ disabled: PropTypes.bool, /** Sets locale and formats time according to it */ locale: PropTypes.oneOf(SupportedWixLocales), /** Defines a callback function which is called on every time value changes */ // * - return {{ date: Date | null }} onChange: PropTypes.func, /** Defines a callback function which is called on cases when invalid time is typed or confirmed with an action. * - return {{ validationType: 'formatError' | 'outOfBoundsError', value: string }} * - `validationType` - type 'formatError'is set when value is in the wrong time format * - type 'outOfBoundsError' is set when `excludePastTimes` or `filterTime` is used and value does not match the filters * - `value` - is set to current input value */ onInvalid: PropTypes.func, /** Sets a placeholder message to display */ placeholder: PropTypes.string, /** Pass a component you want to show as the prefix of the input, e.g., text, icon */ prefix: PropTypes.node, /** Specifies whether input is read only */ readOnly: PropTypes.bool, /** Specifies whether input is auto selected on focus */ autoSelect: PropTypes.bool, /** Controls the size of the input */ size: PropTypes.oneOf(['small', 'medium', 'large']), /** Specify the status of a field */ status: PropTypes.oneOf(['error', 'warning', 'loading']), /** Defines the message to display on status icon hover. If not given or empty there will be no tooltip. */ statusMessage: PropTypes.node, /** Enables internal validation and defines a message to display when user types invalid time value */ invalidMessage: PropTypes.string, /** Specifies the interval between time values shown in dropdown */ step: PropTypes.number, /** Pass a component you want to show as the suffix of the input, e.g., text, icon */ suffix: PropTypes.node, /** Specifies what time formatting style to use when calling `format()` */ timeStyle: PropTypes.string, /** Specifies the current value of the input */ value: PropTypes.object, /** Controls the width of the component. `auto` will resize the input to match width of its contents, while `100%` will take up the full parent container width. */ width: PropTypes.oneOf(['auto', '100%']), /** Defines a standard input `onFocus` callback */ onFocus: PropTypes.func, /** Defines a standard input `onBlur` callback */ onBlur: PropTypes.func, /** Specify whether past time slots should be shown or not */ excludePastTimes: PropTypes.bool, /** * Specify selectable time slots: * * `param` {Date} `value` - a time to check * * `return` {boolean} - true if `value` should be shown in time slots dropdown, false otherwise */ filterTime: PropTypes.func, /** Allows to pass all common popover props. */ popoverProps: PropTypes.shape({ appendTo: PropTypes.oneOf(['window', 'scrollParent', 'parent', 'viewport']), maxWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), minWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), flip: PropTypes.bool, fixed: PropTypes.bool, placement: PropTypes.oneOf([ 'auto-start', 'auto', 'auto-end', 'top-start', 'top', 'top-end', 'right-start', 'right', 'right-end', 'bottom-end', 'bottom', 'bottom-start', 'left-end', 'left', 'left-start', ]), dynamicWidth: PropTypes.bool, }), }; export default TimeInput; //# sourceMappingURL=TimeInput.js.map