UNPKG

@dnb/eufemia

Version:

DNB Eufemia Design System UI Library

655 lines (654 loc) 21.9 kB
"use client"; import _extends from "@babel/runtime/helpers/esm/extends"; var _span; import React, { useCallback, useContext, useMemo, useRef, useState } from 'react'; import { isValid, parseISO } from 'date-fns'; import classnames from 'classnames'; import TextMask from "../input-masked/TextMask.js"; import Button from "../button/Button.js"; import Input, { SubmitButton } from "../input/Input.js"; import { warn, validateDOMAttributes, toCapitalized } from "../../shared/component-helper.js"; import { IS_ANDROID, IS_IOS } from "../../shared/helpers.js"; import { convertStringToDate } from "./DatePickerCalc.js"; import DatePickerContext from "./DatePickerContext.js"; import { Context, useTranslation } from "../../shared/index.js"; import usePartialDates from "./hooks/usePartialDates.js"; import useInputDates from "./hooks/useInputDates.js"; import { formatDate } from "../date-format/DateFormatUtils.js"; const defaultProps = { separatorRegExp: /[-/ ]/g, statusState: 'error', opened: false }; function DatePickerInput(externalProps) { const props = { ...defaultProps, ...externalProps }; const { maskOrder: defaultMaskOrder, maskPlaceholder: defaultMaskPlaceholder } = useTranslation().DatePicker; const { isRange, maskOrder = defaultMaskOrder, separatorRegExp, id, title, submitAttributes, maskPlaceholder = defaultMaskPlaceholder, onFocus, onBlur, onChange, onSubmit, selectedDateTitle, showInput, inputElement, lang, disabled, skeleton, opened, size, status, statusState, statusProps, ...attributes } = props; const [focusState, setFocusState] = useState('virgin'); const { partialDatesRef, setPartialDates } = usePartialDates(); const invalidDatesRef = useRef({ invalidStartDate: null, invalidEndDate: null }); const isDateFullyFilledOutRef = useRef(false); const { updateDates, callOnChangeHandler, getReturnObject, startDate, endDate, props: { onType, label } } = useContext(DatePickerContext); const { inputDates, updateInputDates } = useInputDates({ startDate, endDate }); const translation = useTranslation().DatePicker; const { locale } = useContext(Context); const hasHadValidDate = isValid(startDate) || isValid(endDate); const modeDate = useMemo(() => ({ startDate, endDate }), [startDate, endDate]); const inputRefs = useRef({ startDayRef: { current: undefined }, startMonthRef: { current: undefined }, startYearRef: { current: undefined }, endDayRef: { current: undefined }, endMonthRef: { current: undefined }, endYearRef: { current: undefined } }); const dateRefs = useRef({ startDay: '', startMonth: '', startYear: '', endDay: '', endMonth: '', endYear: '' }); syncDateRefs(dateRefs, inputDates); const temporaryDates = useRef({ startDate: undefined, endDate: undefined }); const refList = useRef(); const focusMode = useRef(); const maskList = useMemo(() => { const separators = maskOrder.match(separatorRegExp); return maskOrder.split(separatorRegExp).reduce((acc, cur) => { if (!cur) { return acc; } acc.push(cur); if (separators.length > 0) { acc.push(separators.shift()); } return acc; }, []); }, [maskOrder, separatorRegExp]); const copyHandler = useCallback((event, mode) => { const date = mode === 'end' ? endDate : startDate; if (isValid(date)) { event.preventDefault(); const valueToCopy = formatDate(date, { locale }); event.clipboardData.setData('text/plain', valueToCopy); } }, [endDate, locale, startDate]); const pasteHandler = useCallback(async event => { if (!focusMode.current) { return; } const success = (event.clipboardData || typeof window !== 'undefined' && window['clipboardData']).getData('text/plain'); if (!success) { return; } event.preventDefault(); try { const separators = ['.', '/']; const possibleFormats = ['yyyy-MM-dd']; const baseFormats = [...possibleFormats]; baseFormats.forEach(date => { separators.forEach(sep => { const format = date.replace(/-/g, sep); possibleFormats.push(format); possibleFormats.push(format.split('').reverse().join('')); }); }); let date; let index = 0; for (index; index < possibleFormats.length; ++index) { date = convertStringToDate(success, { dateFormat: possibleFormats[index] }); if (date) { break; } } const mode = focusMode.current === 'start' ? 'startDate' : 'endDate'; if (date) { updateDates({ [mode]: date }); } } catch (error) { warn(error); } }, [updateDates]); const callOnChangeAsInvalid = useCallback(params => { if (isDateFullyFilledOutRef.current || hasHadValidDate) { const datesFromContext = { startDate, endDate }; const { startDate: derivedStartDate, endDate: derivedEndDate, event } = { ...datesFromContext, ...params }; callOnChangeHandler({ startDate: derivedStartDate, endDate: derivedEndDate, event, ...invalidDatesRef.current }); } }, [callOnChangeHandler, hasHadValidDate, startDate, endDate]); const callOnChange = useCallback(({ startDate, endDate, event }) => { const state = {}; if (typeof startDate !== 'undefined' && isValid(startDate)) { state['startDate'] = startDate; } if (!isRange) { endDate = startDate; } if (typeof endDate !== 'undefined' && isValid(endDate)) { state['endDate'] = endDate; } updateDates(state, dates => { if (typeof startDate !== 'undefined' && isValid(startDate) || typeof endDate !== 'undefined' && isValid(endDate)) { callOnChangeHandler({ event, ...dates, ...invalidDatesRef.current }); } }); }, [updateDates, callOnChangeHandler, isRange]); const callOnType = useCallback(({ event }) => { const getDates = () => ['start', 'end'].reduce((acc, mode) => { acc[`${mode}Date`] = [dateRefs.current[`${mode}Year`] || inputDates[`${mode}Year`] || 'yyyy', dateRefs.current[`${mode}Month`] || inputDates[`${mode}Month`] || 'mm', dateRefs.current[`${mode}Day`] || inputDates[`${mode}Day`] || 'dd'].join('-'); return acc; }, { startDate: undefined, endDate: undefined }); const { startDate, endDate } = getDates(); setPartialDates({ partialStartDate: startDate, ...(isRange && { partialEndDate: endDate }) }); const parsedStartDate = parseISO(startDate); const parsedEndDate = parseISO(endDate); const isStartDateValid = isValid(parsedStartDate); const isEndDateValid = isValid(parsedEndDate); const { is_valid, is_valid_start_date, is_valid_end_date, ...returnObject } = getReturnObject({ startDate: isStartDateValid ? parsedStartDate : null, endDate: isEndDateValid ? parsedEndDate : null, event, ...partialDatesRef.current, ...invalidDatesRef.current }); const typedDates = { ...(!isRange && is_valid === false && { date: startDate }), ...(isRange && is_valid_start_date === false && { start_date: startDate }), ...(isRange && is_valid_end_date === false && { end_date: endDate }) }; onType?.({ is_valid, is_valid_start_date, is_valid_end_date, ...returnObject, ...typedDates }); }, [setPartialDates, isRange, getReturnObject, partialDatesRef, onType, inputDates]); const setDate = useCallback((event, mode, type) => { event.persist(); const value = event.target.value; dateRefs.current[`${mode}${type}`] = value; if (modeDate[`${mode}Date`]) { temporaryDates.current[`${mode}Date`] = modeDate[`${mode}Date`]; } const fallback = temporaryDates.current[`${mode}Date`]; const year = dateRefs.current[`${mode}Year`] || fallback && fallback.getFullYear(); const month = dateRefs.current[`${mode}Month`] || fallback && fallback.getMonth() + 1; const day = dateRefs.current[`${mode}Day`] || fallback && fallback.getDate(); const date = new Date(parseFloat(String(year)), parseFloat(String(month)) - 1, parseFloat(String(day))); const isValidDate = !/[^0-9]/.test(String(day)) && !/[^0-9]/.test(String(month)) && !/[^0-9]/.test(String(year)) && isValid(date) && date.getDate() === parseFloat(String(day)) && date.getMonth() + 1 === parseFloat(String(month)) && date.getFullYear() === parseFloat(String(year)); const dateString = `${year}-${month}-${day}`; isDateFullyFilledOutRef.current = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(dateString); if (isValidDate) { invalidDatesRef.current = { ...invalidDatesRef.current, ...(mode === 'start' ? { invalidStartDate: null } : { invalidEndDate: null }) }; callOnChange({ [`${mode}Date`]: date, event }); } else { updateDates({ [`${mode}Date`]: null }); updateInputDates({ [`${mode}${type}`]: value }); invalidDatesRef.current = { ...invalidDatesRef.current, ...(mode === 'start' ? { invalidStartDate: dateString } : { invalidEndDate: dateString }) }; callOnChangeAsInvalid({ [`${mode}Date`]: null, event }); } callOnType({ event }); }, [updateDates, callOnChange, callOnChangeAsInvalid, callOnType, modeDate, dateRefs, temporaryDates, updateInputDates]); const dateSetters = useMemo(() => ({ set_startDay: event => { setDate(event, 'start', 'Day'); }, set_startMonth: event => { setDate(event, 'start', 'Month'); }, set_startYear: event => { setDate(event, 'start', 'Year'); }, set_endDay: event => { setDate(event, 'end', 'Day'); }, set_endMonth: event => { setDate(event, 'end', 'Month'); }, set_endYear: event => { setDate(event, 'end', 'Year'); } }), [setDate]); const onFocusHandler = useCallback(event => { setFocusState('focus'); onFocus?.({ ...event, ...getReturnObject({ event }) }); if (isNaN(parseFloat(event.target.value))) { setCursorPosition(event.target); } }, [getReturnObject, onFocus]); const onBlurHandler = useCallback(event => { focusMode.current = null; setFocusState('blur'); onBlur?.({ ...event, ...getReturnObject({ event, ...partialDatesRef.current }) }); }, [onBlur, getReturnObject, partialDatesRef]); const onKeyDownHandler = useCallback(async event => { const keyCode = event.key; const target = event.target; const isNumberKey = /[0-9]/g.test(keyCode); if (target.selectionStart !== target.selectionEnd && !isNumberKey) { setCursorPosition(target); } const size = parseFloat(target.getAttribute('size')); const firstSelectionStart = target.selectionStart; const firstSelectionEnd = target.selectionEnd; await wait(IS_IOS ? 10 : 1); const secondSelectionStart = target.selectionStart; const isValid = isNumberKey; const refListArray = refList.current; const index = refListArray.findIndex(({ current }) => current === target); const isLastChar = secondSelectionStart === size; const isFirstChar = firstSelectionStart === size; const isMovingForward = keyCode !== 'ArrowLeft' && keyCode !== 'Backspace' && isValid && isLastChar; const isExplicitForward = (keyCode === 'ArrowRight' || keyCode === 'Enter') && isFirstChar; const hasNextField = index < refListArray.length - 1; if (hasNextField && (isMovingForward || isExplicitForward)) { if (!refListArray[index + 1].current) { return; } const nextSibling = refListArray[index + 1]?.current; if (nextSibling) { setCursorPosition(nextSibling, 0, { withoutDelay: true }); } if (parseFloat(keyCode) <= 9 && firstSelectionStart === target.size) { const name = toCapitalized(nextSibling.getAttribute('class').match(/__input--(day|month|year)($|\s)/)[1]); const mode = nextSibling.getAttribute('id').match(/-(start|end)-(day|month|year)/)[1]; dateSetters[`set_${mode}${name}`]({ persist: () => null, ...event, target: { value: keyCode + nextSibling.value.slice(1) } }); setCursorPosition(nextSibling, 1); } } else if (index > 0 && firstSelectionStart === firstSelectionEnd) { const isMovingBackward = keyCode === 'ArrowLeft' && firstSelectionStart === 0; const isPressingBackspace = keyCode === 'Backspace' && firstSelectionStart <= 1; if (isMovingBackward || isPressingBackspace) { const prevSibling = refListArray[index - 1]?.current; if (prevSibling) { const endPos = prevSibling.value.length; setCursorPosition(prevSibling, endPos, { withoutDelay: true }); } } } }, [dateSetters]); const onInputHandler = useCallback(event => { const target = event.currentTarget; if (IS_ANDROID && event.nativeEvent.inputType === 'deleteContentBackward' && target.selectionStart === 0 && target.selectionEnd === 0) { onKeyDownHandler({ ...event, key: 'Backspace' }); } }, [onKeyDownHandler]); const getPlaceholderChar = useCallback(value => { const index = maskOrder.indexOf(value); return maskPlaceholder[index]; }, [maskOrder, maskPlaceholder]); const generateDateList = useCallback((element, mode) => { return maskList.map((value, i) => { const state = value.slice(0, 1); const placeholderChar = getPlaceholderChar(value); const { day, month, year } = translation; const isRangeLabel = isRange ? `${translation[mode]} ` : ''; if (!separatorRegExp.test(value)) { if (!inputElement) { element = { ...element, onInput: onInputHandler, onKeyDown: onKeyDownHandler, onFocus: e => { focusMode.current = mode; onFocusHandler(e); }, onBlur: onBlurHandler, onPaste: pasteHandler, onCopy: event => { copyHandler(event, mode); }, placeholderChar }; } const DateField = inputElement && React.isValidElement(inputElement) ? inputElement.type : InputElement; const inputSizeClassName = size && `dnb-date-picker__input--${size}`; switch (state) { case 'd': refList.current.push(inputRefs.current[`${mode}DayRef`]); return React.createElement(React.Fragment, { key: 'dd' + i }, React.createElement(DateField, _extends({}, element, { id: `${id}-${mode}-day`, key: 'di' + i, className: classnames("dnb-date-picker__input dnb-date-picker__input--day", element.className, inputSizeClassName), size: 2, mask: [/[0-9]/, /[0-9]/], inputRef: inputRefs.current[`${mode}DayRef`], onChange: dateSetters[`set_${mode}Day`], value: inputDates[`${mode}Day`] || '', "aria-labelledby": `${id}-${mode}-day-label` })), React.createElement("label", { key: 'dl' + i, hidden: true, id: `${id}-${mode}-day-label`, htmlFor: `${id}-${mode}-day` }, isRangeLabel + day)); case 'm': refList.current.push(inputRefs.current[`${mode}MonthRef`]); return React.createElement(React.Fragment, { key: 'mm' + i }, React.createElement(DateField, _extends({}, element, { id: `${id}-${mode}-month`, key: 'mi' + i, className: classnames("dnb-date-picker__input dnb-date-picker__input--month", element.className, inputSizeClassName), size: 2, mask: [/[0-9]/, /[0-9]/], inputRef: inputRefs.current[`${mode}MonthRef`], onChange: dateSetters[`set_${mode}Month`], value: inputDates[`${mode}Month`] || '', "aria-labelledby": `${id}-${mode}-month-label` })), React.createElement("label", { key: 'ml' + i, hidden: true, id: `${id}-${mode}-month-label`, htmlFor: `${id}-${mode}-month` }, isRangeLabel + month)); case 'y': refList.current.push(inputRefs.current[`${mode}YearRef`]); return React.createElement(React.Fragment, { key: 'yy' + i }, React.createElement(DateField, _extends({}, element, { id: `${id}-${mode}-year`, key: 'yi' + i, className: classnames("dnb-date-picker__input dnb-date-picker__input--year", element.className, inputSizeClassName), size: 4, mask: [/[0-9]/, /[0-9]/, /[0-9]/, /[0-9]/], inputRef: inputRefs.current[`${mode}YearRef`], onChange: dateSetters[`set_${mode}Year`], value: inputDates[`${mode}Year`] || '', "aria-labelledby": `${id}-${mode}-year-label` })), React.createElement("label", { key: 'yl' + i, hidden: true, id: `${id}-${mode}-year-label`, htmlFor: `${id}-${mode}-year` }, isRangeLabel + year)); } } return React.createElement("span", { key: 's' + i, className: "dnb-date-picker--separator", "aria-hidden": true }, placeholderChar); }); }, [maskList, getPlaceholderChar, translation, isRange, separatorRegExp, inputElement, size, onInputHandler, onKeyDownHandler, onBlurHandler, pasteHandler, onFocusHandler, copyHandler, id, dateSetters, inputDates]); const renderInputElement = useCallback(element => { refList.current = []; const startDateList = generateDateList(element, 'start'); const endDateList = generateDateList(element, 'end'); return React.createElement("span", { id: `${id}-input`, className: "dnb-date-picker__input__wrapper" }, startDateList, isRange && (_span || (_span = React.createElement("span", { className: "dnb-date-picker--separator", "aria-hidden": true }, ' – '))), isRange && endDateList); }, [id, isRange, generateDateList]); const ariaLabel = useMemo(() => selectedDateTitle ? `${selectedDateTitle}, ${translation.openPickerText}` : translation.openPickerText, [selectedDateTitle, translation]); validateDOMAttributes(props, attributes); validateDOMAttributes(null, submitAttributes); const SubmitElement = useMemo(() => showInput ? SubmitButton : Button, [showInput]); if (!showInput) { submitAttributes.innerRef = submitAttributes.ref; submitAttributes.ref = null; } return React.createElement("fieldset", { className: "dnb-date-picker__fieldset", lang: lang }, label && React.createElement("legend", { className: "dnb-sr-only" }, label), React.createElement(Input, _extends({ id: `${id}__input`, input_state: disabled ? 'disabled' : focusState, input_element: inputElement && typeof inputElement !== 'string' ? typeof inputElement === 'function' ? inputElement(props) : inputElement : renderInputElement, disabled: disabled || skeleton, skeleton: skeleton, size: size, status: !opened ? status : null, status_state: statusState }, statusProps, { submit_element: React.createElement(SubmitElement, _extends({ id: id, disabled: disabled, skeleton: skeleton, className: classnames(showInput && 'dnb-button--input-button', opened && 'dnb-button--active'), "aria-label": ariaLabel, title: title, size: size, status: status, status_state: statusState, type: "button", icon: "calendar", variant: "secondary", on_submit: onSubmit, on_click: onSubmit }, submitAttributes, statusProps)), lang: lang }, attributes))); } export default DatePickerInput; function setCursorPosition(target, position = 0, options) { target.focus(); const select = () => { target.setSelectionRange(position, position); }; if (!options?.withoutDelay && process.env.NODE_ENV !== 'test') { setTimeout(select, 0); } else { select(); } } function InputElement({ className, value, ...props }) { return React.createElement(TextMask, _extends({ guide: true, inputMode: "numeric", showMask: true, keepCharPositions: false, autoComplete: "off", autoCapitalize: "none", spellCheck: false, autoCorrect: "off", className: classnames(className, /\d+/.test(String(value)) && 'dnb-date-picker__input--highlight'), value: value }, props)); } function syncDateRefs(dateRefs, inputDates) { for (const date in dateRefs.current) { const dateRefValue = dateRefs.current[date]; const inputDateValue = inputDates[date]; if (dateRefValue !== inputDateValue) { dateRefs.current[date] = inputDateValue; } } } const wait = duration => new Promise(r => setTimeout(r, duration)); //# sourceMappingURL=DatePickerInput.js.map