import type { ChangeEventHandler, KeyboardEventHandler, RefAttributes } from 'react'; import React, { useImperativeHandle, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { useTranslations } from '@shoptet/ui-core-web'; import type { Complete, Exact, SafeOmit } from '@shoptet/utils'; import { destruct, getControlMode, omit, pick, useStableCallback } from '@shoptet/utils'; import { InputLoader } from '../InputLoader'; import { getTextInputClassName } from '../TextInput/TextInput'; import type { CommonInputProps, PlaceholderProps } from '../types'; import { dictionary } from './dictionary'; function splitRangeValue(value: string | null | undefined, separator: string): [string | null, string | null] { if (separator.trim() === '') { throw new Error('RangeInput: Separator cannot be an empty string or only whitespace.'); } const trimmedValue = value?.trim() ?? ''; if (value) { const [start, end, ...rest] = trimmedValue.split(separator).map(part => part.trim()); if (process.env.NODE_ENV !== 'production' && rest.length > 0) { console.warn( `RangeInput: The provided value "${value}" contains multiple ("${separator}") separators. Only the first two parts will be used.` ); } return [start || null, end || null]; } else { return [null, null]; } } function joinRangeValue([start, end]: [string | null, string | null], separator: string): string | null { if (start === null && end === null) { return null; } return `${start ?? ''}${separator}${end ?? ''}`; } export interface RangeInputOwnProps { /** * The uncontrolled default value of the range input. */ defaultValue?: string; /** * The separator between the two inputs. * Separator is used to split and visually separate value into two inputs. */ separator: string; /** * The controlled value of the range input. */ value?: string; /** * Displays a loading state */ loading?: boolean; } function compareTuples(left: Array, right: Array): boolean { if (left.length !== right.length) { return false; } const leftEntries = left.entries(); for (const [i, element] of leftEntries) { if (element !== right[i]) { return false; } } return true; } /** @inheritdoc */ export interface InheritedCommonInputProps extends SafeOmit, PlaceholderProps {} interface RangeInputHandle { focus: () => void; blur: () => void; } /** @inheritdoc */ export interface RangeInputProps extends RangeInputOwnProps, InheritedCommonInputProps, RefAttributes {} // TODO: We should probably come up with a better way of handling these cases: const pickedContainerPropsList = [ 'data-testid', 'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', // 'aria-braillelabel', // Invalid aria prop on
tag. // 'aria-brailleroledescription', // Invalid aria prop on
tag. 'aria-busy', 'aria-checked', 'aria-colcount', 'aria-colindex', // 'aria-colindextext', // Invalid aria prop on
tag. 'aria-colspan', 'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded', 'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live', 'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount', 'aria-rowindex', // 'aria-rowindextext', // Invalid aria prop on
tag. 'aria-rowspan', 'aria-selected', 'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext', ] as const; export function RangeInput(props: RangeInputProps) { const [refAttributes, ownProps, inputProps] = destruct( props, ['ref', 'key'], ['defaultValue', 'separator', 'value', 'loading'] ); ownProps satisfies Exact, typeof ownProps>; refAttributes satisfies Exact>, typeof refAttributes>; inputProps satisfies Exact, typeof inputProps>; const translations = useTranslations(dictionary); const { ref } = refAttributes; const { defaultValue, separator, value, loading } = ownProps; const { error, warning, placeholder, ...restInputProps } = inputProps; const [placeholderStart, placeholderEnd] = splitRangeValue(placeholder, separator); const [mode] = useState(getControlMode(value)); if (process.env.NODE_ENV !== 'production' && mode !== getControlMode(value)) { throw new Error( `RangeInput is changing from ${mode} to ${getControlMode( value )}. RangeInput should not switch between controlled and uncontrolled (or vice versa).` ); } const [innerValue, setInnerValue] = useState<[string | null, string | null]>(() => splitRangeValue(value ?? defaultValue, separator) ); if (mode === 'controlled') { const nextValue = splitRangeValue(value, separator); if (!compareTuples(innerValue, nextValue)) { setInnerValue(nextValue); } } const [startValue, endValue] = innerValue; const startInputRef = useRef(null); const endInputRef = useRef(null); const [focusedInputs, setFocusedInputs] = useState>(new Set()); const isFocused = focusedInputs.size > 0; const handleApi = useMemo( () => ({ focus: () => { if (startValue === null || endValue === null) { startInputRef.current?.focus(); setFocusedInputs(state => new Set(state).add('start')); } else { endInputRef.current?.focus(); setFocusedInputs(state => new Set(state).add('end')); } }, blur: () => { startInputRef.current?.blur(); endInputRef.current?.blur(); setFocusedInputs(new Set()); }, }), [endValue, startValue] ); useImperativeHandle(ref, () => handleApi, [handleApi]); return (
{ if (event.relatedTarget !== startInputRef.current && event.relatedTarget !== endInputRef.current) { handleApi.focus(); } }} tabIndex={isFocused ? -1 : 0} > >(event => { const [, previousEnd] = innerValue; const [start, end] = splitRangeValue(event.target.value.trim() || null, separator); if (end) { setInnerValue([start, end]); restInputProps.onChange?.(joinRangeValue([start, end], separator) ?? '', { ...event, target: { ...event.target, value: joinRangeValue([start, end], separator) ?? '' }, }); endInputRef.current?.focus(); setFocusedInputs(state => { const next = new Set(state); next.delete('start'); next.add('end'); return next; }); } else { setInnerValue([start, previousEnd]); restInputProps.onChange?.(joinRangeValue([start, previousEnd], separator) ?? '', { ...event, target: { ...event.target, value: joinRangeValue([start, previousEnd], separator) ?? '' }, }); } })} onFocus={useStableCallback(event => { setFocusedInputs(state => new Set(state).add('start')); restInputProps.onFocus?.(joinRangeValue(innerValue, separator) ?? '', { ...event, target: { ...event.target, value: joinRangeValue(innerValue, separator) ?? '' }, }); })} onBlur={useStableCallback(event => { setFocusedInputs(state => { const next = new Set(state); next.delete('start'); return next; }); restInputProps.onBlur?.(joinRangeValue(innerValue, separator) ?? '', { ...event, target: { ...event.target, value: joinRangeValue(innerValue, separator) ?? '' }, }); })} onKeyDown={useStableCallback>(event => { if (event.key === separator) { event.preventDefault(); endInputRef.current?.focus(); } restInputProps.onKeyDown?.(event); })} placeholder={placeholderStart ?? undefined} tabIndex={isFocused ? 0 : -1} type='text' value={startValue ?? ''} /> {separator} >(event => { const [start] = innerValue; const [end] = splitRangeValue(event.target.value.trim() || null, separator); setInnerValue([start, end]); restInputProps.onChange?.(joinRangeValue([start, end], separator) ?? '', { ...event, target: { ...event.target, value: joinRangeValue([start, end], separator) ?? '' }, }); })} onFocus={useStableCallback(() => setFocusedInputs(state => new Set(state).add('end')))} onBlur={useStableCallback(() => setFocusedInputs(state => { const next = new Set(state); next.delete('end'); return next; }) )} onKeyDown={useStableCallback>(event => { if (event.key === 'Backspace' && !endValue) { startInputRef.current?.focus(); } })} placeholder={placeholderEnd ?? undefined} tabIndex={isFocused ? 0 : -1} type='text' value={endValue ?? ''} />
); }