UNPKG

@wix/design-system

Version:

@wix/design-system

429 lines • 18 kB
import React, { useContext, useMemo, useCallback, useRef, useImperativeHandle, useEffect, useReducer, } from 'react'; import PropTypes from 'prop-types'; import Input from '../Input'; import DropdownBase from '../DropdownBase'; import { ACTION, DATA_HOOK } from './constants'; import { st, classes } from './TimeInput.st.css.js'; import { SupportedWixLocales } from 'wix-design-systems-locale-utils'; import { WixStyleReactEnvironmentContext } from '../WixStyleReactEnvironmentProvider/context'; import { getTimeSlots, getTimeSlot, getClosestTimeSlot, getSuggestedOption, isInputInvalid, getAutoFilledValue, getCustomTimeSlot, getTimeFilter, } from './TimeInputUtils'; import { useEventCallback } from '../common/useEventCallback/useEventCallback'; export const DEFAULT_STEP = 15; export const DEFAULT_TIME_STYLE = 'short'; const ERROR_TYPES = { FORMAT: 'formatError', OUT_OF_BOUNDS: 'outOfBoundsError', }; const initialState = { isDropdownOpen: false, inputValue: '', selectedId: null, customInputId: null, highlightedOptionId: null, error: false, selectionRange: false, deleteEvent: false, scrollToOption: null, lastSavedDate: null, }; function reducer(state, action) { switch (action.type) { case ACTION.RESET: return { ...initialState }; case ACTION.SET_STATE: return { ...state, ...action.payload }; case ACTION.SET_ERROR: return { ...state, error: true, validationType: action.payload, }; case ACTION.OPEN_DROPDOWN: return { ...state, isDropdownOpen: true }; case ACTION.CLOSE_DROPDOWN: return { ...state, isDropdownOpen: false }; case ACTION.DELETE_EVENT: return { ...state, deleteEvent: true }; default: return state; } } 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, disableKeyboardType, hideStatusSuffix, value, ...rest }, ref) => { const inputRef = useRef(); const context = useContext(WixStyleReactEnvironmentContext); const [state, dispatch] = useReducer(reducer, initialState); const locale = rest.locale || context.locale || 'en'; const valueAsDate = value ? new Date(value).setSeconds(0, 0) : undefined; const showError = invalidMessage && state.error; const autofillValue = state.selectionRange ? state.inputValue.substring(state.selectionRange.selectionStart) : null; const resolvedStatusMessage = showError ? invalidMessage : statusMessage; const timeFilter = useMemo(() => getTimeFilter({ excludePastTimes, filterTime }), [excludePastTimes, filterTime]); const timeSlots = useMemo(() => { const slots = getTimeSlots({ value: valueAsDate, timeStyle, locale, step, }); return slots.filter(({ id }) => timeFilter(new Date(id))); }, [valueAsDate, timeStyle, locale, step, timeFilter]); const handleOnInvalid = useEventCallback(() => { onInvalid?.({ validationType: state.validationType, value: state.inputValue, }); }); const syncExternalValue = useCallback(() => { if (value === null) { dispatch({ type: ACTION.RESET }); return; } const timeSlot = valueAsDate ? getTimeSlot({ value: valueAsDate, timeStyle, locale }) : getClosestTimeSlot({ value: valueAsDate, timeSlots }); const selectedOption = timeSlot && timeSlots.find(slot => timeSlot.id === slot.id); dispatch({ type: ACTION.SET_STATE, payload: { inputValue: timeSlot && timeSlot.value, selectedId: selectedOption && selectedOption.id, }, }); }, [value, valueAsDate, locale, timeSlots, timeStyle]); const openDropdown = useCallback(() => { if (readOnly) { return; } if (state.selectedId || state.highlightedOptionId) { dispatch({ type: ACTION.OPEN_DROPDOWN }); return; } const closestTimeSlot = getClosestTimeSlot({ value: new Date(state.customInputId || valueAsDate), timeSlots, }); dispatch({ type: ACTION.OPEN_DROPDOWN }); dispatch({ type: ACTION.SET_STATE, payload: { scrollToOption: closestTimeSlot?.id, }, }); return; }, [ state.selectedId, state.highlightedOptionId, state.customInputId, timeSlots, readOnly, valueAsDate, ]); const findSelectedOption = 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 : findSelectedOption(); // Validate if (!option) { dispatch({ type: ACTION.SET_ERROR, payload: ERROR_TYPES.FORMAT, }); return; } else if (!timeFilter(new Date(option.id || option.customId))) { dispatch({ type: ACTION.SET_ERROR, payload: ERROR_TYPES.OUT_OF_BOUNDS, }); return; } // Set selected option const date = option ? new Date(option.customId || option.id) : null; const hasDateChanged = (state.lastSavedDate?.getTime?.() ?? null) !== (date?.getTime?.() ?? null); dispatch({ type: ACTION.SET_STATE, payload: { selectedId: option.id, customInputId: option.customId, inputValue: option.value, selectionRange: false, isDropdownOpen: false, highlightedOptionId: null, error: false, lastSavedDate: date, }, }); if (option && hasDateChanged) { onChange?.({ date }); } return option; }, [findSelectedOption, 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') { dispatch({ type: ACTION.DELETE_EVENT }); } if (state.isDropdownOpen) { switch (e.key) { case 'Escape': { dispatch({ type: ACTION.CLOSE_DROPDOWN }); return e.preventDefault(); } case 'Enter': { dispatch({ type: ACTION.CLOSE_DROPDOWN }); inputRef.current.setSelectionRange(state.inputValue.length, state.inputValue.length); if (!state.error) { return validateAndSetOption(); } } } } }, [ state.isDropdownOpen, state.inputValue, openDropdown, validateAndSetOption, state.error, ]); const onInputChange = 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/or value is typed again if (!isDropdownOpen) { isDropdownOpen = true; } // Validate current input value if (isInputInvalid(e.target.value)) { error = true; validationType = ERROR_TYPES.FORMAT; isDropdownOpen = false; } // Unselect dropdown option if input value does not match any available time slot 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; dispatch({ type: ACTION.SET_STATE, payload: { isDropdownOpen, error, validationType, selectedId, highlightedOptionId, selectionRange, inputValue, deleteEvent: false, }, }); }, [ state.deleteEvent, timeSlots, state.isDropdownOpen, state.selectedId, locale, ]); const onInputBlur = useCallback(e => { onBlur?.(e); if (state.isDropdownOpen) { dispatch({ type: ACTION.CLOSE_DROPDOWN }); } if (state.error) return; if (state.inputValue === '') { return onChange?.({ date: null }); } validateAndSetOption(); }, [ validateAndSetOption, onChange, onBlur, state.error, state.isDropdownOpen, state.inputValue, ]); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); openDropdown(); }, blur: () => { inputRef.current.blur(); }, clear: () => { dispatch({ type: ACTION.RESET }); onChange?.({ date: null }); }, }), [onChange, openDropdown]); useEffect(syncExternalValue, [syncExternalValue]); useEffect(() => { // Select range when suggestedInputValue is changed if (state.selectionRange) { inputRef.current.setSelectionRange(state.selectionRange.selectionStart, state.selectionRange.selectionEnd); } }, [state.selectionRange]); useEffect(() => { if (state.error) { handleOnInvalid(); } }, [handleOnInvalid, state.error, state.inputValue, state.validationType]); return (React.createElement("div", { className: st(classes.root, className), style: { width }, "data-hook": dataHook, "data-value": state.selectedId || valueAsDate, "data-locale": locale, "data-time-style": timeStyle, "data-autofill": autofillValue, "data-scroll-to-option": state.scrollToOption, "data-status-message": statusMessage }, React.createElement(DropdownBase, { dataHook: DATA_HOOK.TimeInputDropdown, open: state.isDropdownOpen, onClickOutside: () => dispatch({ type: ACTION.CLOSE_DROPDOWN }), 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 }, ({ isOpen, activeDescendantId, listboxId, delegateKeyDown }) => { return (React.createElement(Input, { dataHook: DATA_HOOK.TimeInputInput, size: size, status: showError ? 'error' : status, statusMessage: resolvedStatusMessage, 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: onInputChange, onInputClicked: openDropdown, onKeyDown: e => onKeyDown(e, delegateKeyDown), ref: inputRef, readOnly: readOnly, autoSelect: autoSelect, noLeftBorderRadius: noLeftBorderRadius, noRightBorderRadius: noRightBorderRadius, onFocus: onFocus, onBlur: onInputBlur, disableEditing: disableKeyboardType, hideStatusSuffix: hideStatusSuffix, ariaAutocomplete: "list", ariaExpanded: isOpen, ariaControls: isOpen ? listboxId : undefined, ariaActiveDescendant: isOpen ? activeDescendantId : undefined })); }))); }); 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. * @internal */ 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, /** Specifies whether status suffix is hidden. */ hideStatusSuffix: PropTypes.bool, /** 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