wix-style-react
Version:
wix-style-react
370 lines • 15.9 kB
JavaScript
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