wix-style-react
Version:
282 lines (235 loc) • 8.16 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 './TimeInputNext.st.css';
import { supportedWixlocales } from 'wix-design-systems-locale-utils';
import { WixStyleReactEnvironmentContext } from '../WixStyleReactEnvironmentProvider/context';
import {
getTimeSlots,
getTimeSlot,
getClosestTimeSlot,
getSuggestedOption,
isInputValid,
getErorMessageByLocale,
} from './TimeInputNextUtils';
export const DEFAULT_STEP = 15;
export const DEFAULT_TIME_STYLE = 'short';
const TimeInputNext = React.forwardRef(
(
{
dataHook,
className,
size,
suffix,
prefix,
status,
statusMessage,
border,
disabled,
placeholder,
readOnly,
width,
value,
timeStyle,
onChange,
step,
noRightBorderRadius,
noLeftBorderRadius,
...rest
},
ref,
) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
}));
const context = useContext(WixStyleReactEnvironmentContext);
const locale = rest.locale || context.locale || 'en';
const timeSlots = useMemo(
() => getTimeSlots({ value, timeStyle, locale, step }),
[value, timeStyle, locale, step],
);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
const [selectedId, setSelectedId] = React.useState(null);
const [highlightedOptionId, setHighlightedOptionId] = React.useState(null);
const [error, setError] = React.useState(false);
const validationError = error ? 'error' : undefined;
const validationErrorMessage = error
? getErorMessageByLocale(locale)
: undefined;
useEffect(() => {
const timeSlot = value
? getTimeSlot({ value, timeStyle, locale })
: getClosestTimeSlot({ value, timeSlots });
setInputValue(timeSlot.value);
setSelectedId(timeSlot.id);
}, [value, timeStyle, locale, timeSlots]);
const onSelect = useCallback(
option => {
setInputValue(option.value);
setSelectedId(option.id);
setIsDropdownOpen(false);
onChange && onChange({ date: new Date(option.id) });
},
[onChange],
);
const onKeyDown = useCallback(
(e, delegateKeyDown) => {
if (e.key === ' ' || e.key === 'Spacebar') {
return;
}
if (!isDropdownOpen && e.key === 'ArrowDown') {
setIsDropdownOpen(true);
return e.preventDefault();
}
if (isDropdownOpen) {
delegateKeyDown(e);
if (e.key === 'Escape') {
setIsDropdownOpen(false);
return e.preventDefault();
}
// should be handled by error validation
if (!highlightedOptionId && e.key === 'Enter') {
setIsDropdownOpen(false);
return e.preventDefault();
}
}
},
[isDropdownOpen, highlightedOptionId],
);
const onInputChanged = e => {
setInputValue(e.target.value);
// validate input
if (isInputValid(e.target.value)) {
setError(false);
} else {
setError(true);
setIsDropdownOpen(false);
}
// open dropdown when value is selected with keyboard and value is typed again
if (!isDropdownOpen) {
setIsDropdownOpen(true);
}
// stop selecting option when input value changes and doesn't match anymore
if (e.target.value !== timeSlots[selectedId]) {
setSelectedId(null);
}
const suggestedOption = getSuggestedOption(e.target.value, timeSlots);
setHighlightedOptionId(suggestedOption ? suggestedOption.id : null);
};
const onBlur = () => {
const highlightedOption = timeSlots.find(
slot => slot.id === highlightedOptionId,
);
if (highlightedOption) {
setInputValue(highlightedOption.value);
setSelectedId(highlightedOption.id);
}
if (isDropdownOpen) {
setIsDropdownOpen(false);
}
};
return (
<div
className={st(classes.root, className)}
style={{ width }}
data-hook={dataHook}
data-value={selectedId}
data-locale={locale}
data-time-style={timeStyle}
>
<DropdownBase
dataHook={dataHooks.TimeInputNextDropdown}
open={isDropdownOpen}
onClickOutside={() => setIsDropdownOpen(false)}
options={timeSlots}
onSelect={onSelect}
selectedId={selectedId}
maxHeight="216px"
markedOptionId={highlightedOptionId}
focusOnOption={highlightedOptionId}
focusOnSelectedOption
onMouseDown={e => e.preventDefault()}
>
{({ delegateKeyDown }) => {
return (
<Input
dataHook={dataHooks.TimeInputNextInput}
size={size}
status={status || validationError}
statusMessage={statusMessage || validationErrorMessage}
suffix={suffix && <Input.Affix>{suffix}</Input.Affix>}
prefix={prefix && <Input.Affix>{prefix}</Input.Affix>}
border={border}
disabled={disabled}
placeholder={placeholder}
value={inputValue}
onChange={onInputChanged}
onInputClicked={() => !readOnly && setIsDropdownOpen(true)}
onKeyDown={e => onKeyDown(e, delegateKeyDown)}
ref={inputRef}
readOnly={readOnly}
noLeftBorderRadius={noLeftBorderRadius}
noRightBorderRadius={noRightBorderRadius}
onBlur={onBlur}
/>
);
}}
</DropdownBase>
</div>
);
},
);
TimeInputNext.displayName = 'TimeInputNext';
TimeInputNext.propTypes = {
/** Control the border style of input */
border: PropTypes.oneOf(['standard', 'round', 'bottomLine']),
/** 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 */
onChange: 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,
/** 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,
/** 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%']),
};
TimeInputNext.defaultProps = {
width: 'auto',
step: DEFAULT_STEP,
timeStyle: DEFAULT_TIME_STYLE,
};
export default TimeInputNext;