@wix/design-system
Version:
@wix/design-system
356 lines • 14.9 kB
JavaScript
import React, { useContext, useMemo, useCallback, useRef, useImperativeHandle, useEffect, useReducer, } from 'react';
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, isMaskPlaceholderOnly, stripMaskPlaceholders, } 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, disableAutoComplete, hideStatusSuffix, value, inputRef: externalInputRef, customInput, ...rest }, ref) => {
const inputRef = useRef(null);
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;
}
// External masks need a clean empty starting state.
if (!valueAsDate && disableAutoComplete) {
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, disableAutoComplete]);
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,
});
if (!state.isDropdownOpen) {
dispatch({ type: ACTION.OPEN_DROPDOWN });
}
// Always scroll to the most relevant option once the list is displayed
dispatch({
type: ACTION.SET_STATE,
payload: {
scrollToOption: state.selectedId ||
state.highlightedOptionId ||
closestTimeSlot?.id,
},
});
}, [
readOnly,
state.isDropdownOpen,
state.selectedId,
state.highlightedOptionId,
state.customInputId,
timeSlots,
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;
}
// Skip mid-typing format validation under `disableAutoComplete`
// (the mask owns validity); blur still validates. Treat
// all-placeholder values as not-yet-entered.
if (!disableAutoComplete &&
isInputInvalid(e.target.value) &&
!isMaskPlaceholderOnly(e.target.value)) {
error = true;
validationType = ERROR_TYPES.FORMAT;
isDropdownOpen = false;
}
const selectedSlot = timeSlots.find(s => s.id === state.selectedId);
if (!selectedSlot || inputValue !== selectedSlot.value) {
selectedId = null;
}
const suggestedOption = getSuggestedOption({
inputValue: stripMaskPlaceholders(inputValue),
timeSlots,
locale,
});
if (!state.deleteEvent && suggestedOption && !disableAutoComplete) {
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,
disableAutoComplete,
]);
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 });
}
if (isMaskPlaceholderOnly(state.inputValue)) {
dispatch({ type: ACTION.RESET });
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(), autoFocus: false, 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, inputRef: externalInputRef, customInput: customInput }));
})));
});
TimeInput.displayName = 'TimeInput';
export default TimeInput;
//# sourceMappingURL=TimeInput.js.map