@wix/design-system
Version:
@wix/design-system
429 lines • 18 kB
JavaScript
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