react-time-picker
Version:
A time picker for your React app.
355 lines (354 loc) • 14.9 kB
JavaScript
'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useRef, useState } from 'react';
import { getHours, getHoursMinutes, getHoursMinutesSeconds, getMinutes, getSeconds, } from '@wojtekmaj/date-utils';
import Divider from './Divider.js';
import AmPm from './TimeInput/AmPm.js';
import Hour12Input from './TimeInput/Hour12Input.js';
import Hour24Input from './TimeInput/Hour24Input.js';
import MinuteInput from './TimeInput/MinuteInput.js';
import NativeInput from './TimeInput/NativeInput.js';
import SecondInput from './TimeInput/SecondInput.js';
import { getFormatter, getNumberFormatter } from './shared/dateFormatter.js';
import { convert12to24, convert24to12 } from './shared/dates.js';
import { getAmPmLabels } from './shared/utils.js';
const getFormatterOptionsCache = {};
const allViews = ['hour', 'minute', 'second'];
function isInternalInput(element) {
return element.dataset.input === 'true';
}
function findInput(element, property) {
let nextElement = element;
do {
nextElement = nextElement[property];
} while (nextElement && !isInternalInput(nextElement));
return nextElement;
}
function focus(element) {
if (element) {
element.focus();
}
}
function renderCustomInputs(placeholder, elementFunctions, allowMultipleInstances) {
const usedFunctions = [];
const pattern = new RegExp(Object.keys(elementFunctions)
.map((el) => `${el}+`)
.join('|'), 'g');
const matches = placeholder.match(pattern);
return placeholder.split(pattern).reduce((arr, element, index) => {
const divider = element && (
// biome-ignore lint/suspicious/noArrayIndexKey: index is stable here
_jsx(Divider, { children: element }, `separator_${index}`));
arr.push(divider);
const currentMatch = matches === null || matches === void 0 ? void 0 : matches[index];
if (currentMatch) {
const renderFunction = elementFunctions[currentMatch] ||
elementFunctions[Object.keys(elementFunctions).find((elementFunction) => currentMatch.match(elementFunction))];
if (!renderFunction) {
return arr;
}
if (!allowMultipleInstances && usedFunctions.includes(renderFunction)) {
arr.push(currentMatch);
}
else {
arr.push(renderFunction(currentMatch, index));
usedFunctions.push(renderFunction);
}
}
return arr;
}, []);
}
const formatNumber = getNumberFormatter({ useGrouping: false });
export default function TimeInput({ amPmAriaLabel, autoFocus, className, disabled, format, hourAriaLabel, hourPlaceholder, isClockOpen: isClockOpenProps = null, locale, maxDetail = 'minute', maxTime, minTime, minuteAriaLabel, minutePlaceholder, name = 'time', nativeInputAriaLabel, onChange: onChangeProps, onInvalidChange, required, secondAriaLabel, secondPlaceholder, value: valueProps, }) {
const [amPm, setAmPm] = useState(null);
const [hour, setHour] = useState(null);
const [minute, setMinute] = useState(null);
const [second, setSecond] = useState(null);
const [value, setValue] = useState(null);
const amPmInput = useRef(null);
const hour12Input = useRef(null);
const hour24Input = useRef(null);
const minuteInput = useRef(null);
const secondInput = useRef(null);
const [isClockOpen, setIsClockOpen] = useState(isClockOpenProps);
const lastPressedKey = useRef(undefined);
useEffect(() => {
setIsClockOpen(isClockOpenProps);
}, [isClockOpenProps]);
// biome-ignore lint/correctness/useExhaustiveDependencies: useEffect intentionally triggered on props change
useEffect(() => {
const nextValue = valueProps;
if (nextValue) {
setAmPm(convert24to12(getHours(nextValue))[1]);
setHour(getHours(nextValue).toString());
setMinute(getMinutes(nextValue).toString());
setSecond(getSeconds(nextValue).toString());
setValue(nextValue);
}
else {
setAmPm(null);
setHour(null);
setMinute(null);
setSecond(null);
setValue(null);
}
}, [
valueProps,
minTime,
maxTime,
maxDetail,
// Toggling clock visibility resets values
isClockOpen,
]);
const valueType = maxDetail;
const formatTime = (() => {
const level = allViews.indexOf(maxDetail);
const formatterOptions = getFormatterOptionsCache[level] ||
(() => {
const options = { hour: 'numeric' };
if (level >= 1) {
options.minute = 'numeric';
}
if (level >= 2) {
options.second = 'numeric';
}
getFormatterOptionsCache[level] = options;
return options;
})();
return getFormatter(formatterOptions);
})();
/**
* Gets current value in a desired format.
*/
function getProcessedValue(value) {
const processFunction = (() => {
switch (valueType) {
case 'hour':
case 'minute':
return getHoursMinutes;
case 'second':
return getHoursMinutesSeconds;
default:
throw new Error('Invalid valueType');
}
})();
return processFunction(value);
}
const placeholder = format ||
(() => {
const hour24 = 21;
const hour12 = 9;
const minute = 13;
const second = 14;
const date = new Date(2017, 0, 1, hour24, minute, second);
return formatTime(locale, date)
.replace(formatNumber(locale, hour12), 'h')
.replace(formatNumber(locale, hour24), 'H')
.replace(formatNumber(locale, minute), 'mm')
.replace(formatNumber(locale, second), 'ss')
.replace(new RegExp(getAmPmLabels(locale).join('|')), 'a');
})();
const divider = (() => {
const dividers = placeholder.match(/[^0-9a-z]/i);
return dividers ? dividers[0] : null;
})();
function onClick(event) {
if (event.target === event.currentTarget) {
// Wrapper was directly clicked
const firstInput = event.target.children[1];
focus(firstInput);
}
}
function onKeyDown(event) {
lastPressedKey.current = event.key;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowRight':
case divider: {
event.preventDefault();
const { target: input } = event;
const property = event.key === 'ArrowLeft' ? 'previousElementSibling' : 'nextElementSibling';
const nextInput = findInput(input, property);
focus(nextInput);
break;
}
default:
}
}
function onKeyUp(event) {
const { key, target: input } = event;
const isLastPressedKey = lastPressedKey.current === key;
if (!isLastPressedKey) {
return;
}
const isNumberKey = !Number.isNaN(Number(key));
if (!isNumberKey) {
return;
}
const max = input.getAttribute('max');
if (!max) {
return;
}
const { value } = input;
/**
* Given 1, the smallest possible number the user could type by adding another digit is 10.
* 10 would be a valid value given max = 12, so we won't jump to the next input.
* However, given 2, smallers possible number would be 20, and thus keeping the focus in
* this field doesn't make sense.
*/
if (Number(value) * 10 > Number(max) || value.length >= max.length) {
const property = 'nextElementSibling';
const nextInput = findInput(input, property);
focus(nextInput);
}
}
/**
* Called after internal onChange. Checks input validity. If all fields are valid,
* calls props.onChange.
*/
function onChangeExternal() {
if (!onChangeProps) {
return;
}
function filterBoolean(value) {
return Boolean(value);
}
const formElements = [
amPmInput.current,
hour12Input.current,
hour24Input.current,
minuteInput.current,
secondInput.current,
].filter(filterBoolean);
const formElementsWithoutSelect = formElements.slice(1);
const values = {};
for (const formElement of formElements) {
values[formElement.name] =
formElement.type === 'number' ? formElement.valueAsNumber : formElement.value;
}
const isEveryValueEmpty = formElementsWithoutSelect.every((formElement) => !formElement.value);
if (isEveryValueEmpty) {
onChangeProps(null, false);
return;
}
const isEveryValueFilled = formElements.every((formElement) => formElement.value);
const isEveryValueValid = formElements.every((formElement) => formElement.validity.valid);
if (isEveryValueFilled && isEveryValueValid) {
const hour = Number(values.hour24 ||
(values.hour12 && values.amPm && convert12to24(values.hour12, values.amPm)) ||
0);
const minute = Number(values.minute || 0);
const second = Number(values.second || 0);
const padStart = (num) => `0${num}`.slice(-2);
const proposedValue = `${padStart(hour)}:${padStart(minute)}:${padStart(second)}`;
const processedValue = getProcessedValue(proposedValue);
onChangeProps(processedValue, false);
return;
}
if (!onInvalidChange) {
return;
}
onInvalidChange();
}
/**
* Called when non-native date input is changed.
*/
function onChange(event) {
const { name, value } = event.target;
switch (name) {
case 'amPm':
setAmPm(value);
break;
case 'hour12':
setHour(value ? convert12to24(value, amPm || 'am').toString() : '');
break;
case 'hour24':
setHour(value);
break;
case 'minute':
setMinute(value);
break;
case 'second':
setSecond(value);
break;
}
onChangeExternal();
}
/**
* Called when native date input is changed.
*/
function onChangeNative(event) {
const { value } = event.target;
if (!onChangeProps) {
return;
}
const processedValue = value || null;
onChangeProps(processedValue, false);
}
const commonInputProps = {
className,
disabled,
maxTime,
minTime,
onChange,
onKeyDown,
onKeyUp,
// This is only for showing validity when editing
required: Boolean(required || isClockOpen),
};
function renderHour12(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false;
return (_jsx(Hour12Input, { ...commonInputProps, amPm: amPm, ariaLabel: hourAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: hour12Input, placeholder: hourPlaceholder, showLeadingZeros: showLeadingZeros, value: hour }, "hour12"));
}
function renderHour24(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false;
return (_jsx(Hour24Input, { ...commonInputProps, ariaLabel: hourAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: hour24Input, placeholder: hourPlaceholder, showLeadingZeros: showLeadingZeros, value: hour }, "hour24"));
}
function renderHour(currentMatch, index) {
if (/h/.test(currentMatch)) {
return renderHour12(currentMatch, index);
}
return renderHour24(currentMatch, index);
}
function renderMinute(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false;
return (_jsx(MinuteInput, { ...commonInputProps, ariaLabel: minuteAriaLabel, autoFocus: index === 0 && autoFocus, hour: hour, inputRef: minuteInput, placeholder: minutePlaceholder, showLeadingZeros: showLeadingZeros, value: minute }, "minute"));
}
function renderSecond(currentMatch, index) {
if (currentMatch && currentMatch.length > 2) {
throw new Error(`Unsupported token: ${currentMatch}`);
}
const showLeadingZeros = currentMatch ? currentMatch.length === 2 : true;
return (_jsx(SecondInput, { ...commonInputProps, ariaLabel: secondAriaLabel, autoFocus: index === 0 && autoFocus, hour: hour, inputRef: secondInput, minute: minute, placeholder: secondPlaceholder, showLeadingZeros: showLeadingZeros, value: second }, "second"));
}
function renderAmPm(_currentMatch, index) {
return (_jsx(AmPm, { ...commonInputProps, ariaLabel: amPmAriaLabel, autoFocus: index === 0 && autoFocus, inputRef: amPmInput, locale: locale, onChange: onChange, value: amPm }, "ampm"));
}
function renderCustomInputsInternal() {
const elementFunctions = {
h: renderHour,
H: renderHour,
m: renderMinute,
s: renderSecond,
a: renderAmPm,
};
const allowMultipleInstances = typeof format !== 'undefined';
return renderCustomInputs(placeholder, elementFunctions, allowMultipleInstances);
}
function renderNativeInput() {
return (_jsx(NativeInput, { ariaLabel: nativeInputAriaLabel, disabled: disabled, maxTime: maxTime, minTime: minTime, name: name, onChange: onChangeNative, required: required, value: value, valueType: valueType }, "time"));
}
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: This interaction is designed for mouse users only
// biome-ignore lint/a11y/noStaticElementInteractions: This interaction is designed for mouse users only
_jsxs("div", { className: className, onClick: onClick, children: [renderNativeInput(), renderCustomInputsInternal()] }));
}