UNPKG

wix-style-react

Version:
282 lines (235 loc) • 8.16 kB
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;