diff --git a/src/components/StartEndDate/CalendarInput.jsx b/src/components/StartEndDate/CalendarInput.jsx new file mode 100644 index 000000000..6423efc2c --- /dev/null +++ b/src/components/StartEndDate/CalendarInput.jsx @@ -0,0 +1,131 @@ +import i18n from '@dhis2/d2-i18n' +import { Button, Card, InputField, Layer, Popper, Calendar } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import styles from './styles/CalendarInput.style.js' + +const offsetModifier = { + name: 'offset', + options: { + offset: [0, 2], + }, +} + +export const CalendarInput = ({ + onDateSelect, + calendar, + date, + dir, + locale, + numberingSystem, + weekDayFormat, + timeZone, + width, + cellSize, + clearable, + dataTest = 'calendar-inputfield', + ...rest +} = {}) => { + const ref = useRef() + const [open, setOpen] = useState(false) + + const calendarProps = React.useMemo(() => { + const onDateSelectWrapper = (selectedDate) => { + setOpen(false) + onDateSelect?.(selectedDate) + } + return { + onDateSelect: onDateSelectWrapper, + calendar, + date, + dir, + locale, + numberingSystem, + weekDayFormat, + timeZone, + width, + cellSize, + } + }, [ + calendar, + cellSize, + date, + dir, + locale, + numberingSystem, + onDateSelect, + timeZone, + weekDayFormat, + width, + ]) + + const onFocus = () => { + setOpen(true) + } + + const onChange = (e) => { + setOpen(false) + rest.onChange(e) + } + + return ( + <> +
+ + {clearable && ( +
+ +
+ )} +
+ {open && ( + { + setOpen(false) + }} + > + + + + + + + )} + + + ) +} + +CalendarInput.propTypes = { + calendar: PropTypes.object, + cellSize: PropTypes.number, + clearable: PropTypes.bool, + dataTest: PropTypes.string, + date: PropTypes.string, + dir: PropTypes.string, + locale: PropTypes.string, + numberingSystem: PropTypes.string, + timeZone: PropTypes.string, + weekDayFormat: PropTypes.string, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + onDateSelect: PropTypes.func, +} diff --git a/src/components/StartEndDate/StartEndDate.jsx b/src/components/StartEndDate/StartEndDate.jsx new file mode 100644 index 000000000..0cde30fce --- /dev/null +++ b/src/components/StartEndDate/StartEndDate.jsx @@ -0,0 +1,184 @@ +import i18n from '@dhis2/d2-i18n' +import { Field, IconArrowRight16, colors } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import { CalendarInput } from './CalendarInput.jsx' +import { + DEFAULT_CALENDAR, + DEFAULT_PLACEHOLDER, + formatDateInput, + formatDateOnBlur, + nextCharIsAutoHyphen, + nextCharIsManualHyphen, +} from './dateUtils.js' +import styles from './styles/StartEndDate.style.js' +import useKeyDown from './useKeyDown.js' + +const StartEndDate = ({ + startDate, + endDate, + onStartDateChange, + onEndDateChange, + errorText, + periodsSettings, +}) => { + const formattedStartDate = formatDateInput({ date: startDate }) + const formattedEndDate = formatDateInput({ date: endDate }) + + const [startDateChangeCount, setStartDateChangeCount] = useState(0) + const [endDateChangeCount, setEndDateChangeCount] = useState(0) + + const [startCaretPosition, setStartCaretPosition] = useState(null) + const [endCaretPosition, setEndCaretPosition] = useState(null) + + const onDateChange = (type, date) => { + const stateMap = { + start: { + prevDate: startDate, + inputSelector: '.start input', + onDateChange: onStartDateChange, + setDateChangeCount: setStartDateChangeCount, + setCaret: setStartCaretPosition, + }, + end: { + prevDate: endDate, + inputSelector: '.end input', + onDateChange: onEndDateChange, + setDateChangeCount: setEndDateChangeCount, + setCaret: setEndCaretPosition, + }, + } + if (!(type in stateMap)) { + console.error( + `Invalid type "${type}" passed to onDateChange. Expected "start" or "end".` + ) + return + } + + const { + prevDate: rawPrevDate, + inputSelector, + onDateChange, + setDateChangeCount, + setCaret, + } = stateMap[type] + const prevDate = rawPrevDate ? rawPrevDate.split('T')[0] : '' + + if (prevDate === date) { + return + } + + const caret = document.querySelector(inputSelector)?.selectionStart + const dateInfo = { date, prevDate, caret } + + let newCaretPosition = caret + if (nextCharIsAutoHyphen(dateInfo)) { + newCaretPosition = caret + 1 + } else if (nextCharIsManualHyphen(dateInfo)) { + // No change needed for caret position + } else if (/\D/.test(date?.[caret - 1] || '')) { + newCaretPosition = caret - 1 + } + setCaret(newCaretPosition) + + const formattedDate = formatDateInput(dateInfo) + onDateChange(formattedDate) + + setDateChangeCount((prevCount) => prevCount + 1) + } + + useEffect(() => { + if (startCaretPosition !== null) { + const input = document.querySelector('.start input') + input.setSelectionRange(startCaretPosition, startCaretPosition) + } + }, [startDateChangeCount, startCaretPosition]) + + useEffect(() => { + if (endCaretPosition !== null) { + const input = document.querySelector('.end input') + input.setSelectionRange(endCaretPosition, endCaretPosition) + } + }, [endDateChangeCount, endCaretPosition]) + + // Forces calendar to close when using Tab/Enter navigation + useKeyDown(['Tab', 'Enter'], () => { + const backdropElement = document.querySelectorAll('.backdrop') + if (backdropElement?.length === 3) { + backdropElement[2].click() + } + }) + + const hasDate = startDate !== undefined && endDate !== undefined + if (!hasDate) { + return null + } + + return ( + + {periodsSettings?.calendar !== DEFAULT_CALENDAR && ( +

+ {i18n.t( + 'Start and end dates must be entered using the ISO 8601 (Gregorian) date format.' + )} +

+ )} +
+ + onStartDateChange(e?.calendarDateString) + } + onChange={(e) => onDateChange('start', e?.value)} + onBlur={(e) => + onStartDateChange(formatDateOnBlur(e?.value)) + } + placeholder={DEFAULT_PLACEHOLDER} + width={styles.width} + dataTest="start-date-input" + clearable={true} + /> +
+ +
+ onEndDateChange(e?.calendarDateString)} + onChange={(e) => onDateChange('end', e?.value)} + onBlur={(e) => onEndDateChange(formatDateOnBlur(e?.value))} + placeholder={DEFAULT_PLACEHOLDER} + width={styles.width} + dataTest="end-date-input" + clearable={true} + /> +
+ {errorText &&
{errorText}
} +
+ ) +} +StartEndDate.propTypes = { + onEndDateChange: PropTypes.func.isRequired, + onStartDateChange: PropTypes.func.isRequired, + endDate: PropTypes.string, + errorText: PropTypes.string, + periodsSettings: PropTypes.shape({ + calendar: PropTypes.string, + locale: PropTypes.string, + }), + startDate: PropTypes.string, +} + +export default StartEndDate diff --git a/src/components/StartEndDate/dateUtils.js b/src/components/StartEndDate/dateUtils.js new file mode 100644 index 000000000..02edb4b4b --- /dev/null +++ b/src/components/StartEndDate/dateUtils.js @@ -0,0 +1,349 @@ +import { getNowInCalendar } from '@dhis2/multi-calendar-dates' +import { Temporal } from '@js-temporal/polyfill' // 13th months in etiopic calendar cannot be returned by getFixedPeriodByDate (@dhis2/multi-calendar-dates) + +export const DEFAULT_CALENDAR = 'iso8601' +export const DEFAULT_PLACEHOLDER = 'yyyy-mm-dd' + +const JULIAN_CALENDAR_NAME = 'julian' +const NEPALI_CALENDAR_NAME = 'nepali' +const GREGORIAN_CALENDAR_NAME = 'gregory' +const ETHIOPIAN_CALENDAR_NAME = 'ethiopic' +const THAI_CALENDAR_NAME = 'buddhist' + +// dhis2CalendarsMap and NEPALI_CALENDAR_DATA cannot be imported from @dhis2/multi-calendar-dates + +const dhis2CalendarsMap = { + iso8601: GREGORIAN_CALENDAR_NAME, // this is not supported by getNowInCalendar + ethiopian: ETHIOPIAN_CALENDAR_NAME, + gregorian: GREGORIAN_CALENDAR_NAME, + julian: GREGORIAN_CALENDAR_NAME, // this is not supported by Temporal + thai: THAI_CALENDAR_NAME, +} +const NEPALI_CALENDAR_DATA = { + // Used in @dhis2/multi-calendar-dates and @kbwood/world-calendars + // First value is used for 1st January calculation (not used here) + // This data are from http://www.ashesh.com.np + 1970: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1971: [18, 31, 31, 32, 31, 32, 30, 30, 29, 30, 29, 30, 30], + 1972: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30], + 1973: [19, 30, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 1974: [19, 31, 31, 32, 30, 31, 31, 30, 29, 30, 29, 30, 30], + 1975: [18, 31, 31, 32, 32, 30, 31, 30, 29, 30, 29, 30, 30], + 1976: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 1977: [18, 31, 32, 31, 32, 31, 31, 29, 30, 29, 30, 29, 31], + 1978: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1979: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 1980: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 1981: [18, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 30, 30], + 1982: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1983: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 1984: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 1985: [18, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 30, 30], + 1986: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1987: [18, 31, 32, 31, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 1988: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 1989: [18, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 1990: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1991: [18, 31, 32, 31, 32, 31, 30, 30, 29, 30, 29, 30, 30], + // These data are from http://nepalicalendar.rat32.com/index.php + 1992: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 1993: [18, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 1994: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1995: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30], + 1996: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 1997: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1998: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 1999: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2000: [17, 30, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2001: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2002: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2003: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2004: [17, 30, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2005: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2006: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2007: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2008: [17, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 29, 31], + 2009: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2010: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2011: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2012: [17, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 30, 30], + 2013: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2014: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2015: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2016: [17, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 30, 30], + 2017: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2018: [18, 31, 32, 31, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2019: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2020: [17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 2021: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2022: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30], + 2023: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2024: [17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 2025: [18, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2026: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2027: [17, 30, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2028: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2029: [18, 31, 31, 32, 31, 32, 30, 30, 29, 30, 29, 30, 30], + 2030: [17, 31, 32, 31, 32, 31, 30, 30, 30, 30, 30, 30, 31], + 2031: [17, 31, 32, 31, 32, 31, 31, 31, 31, 31, 31, 31, 31], + 2032: [17, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32], + 2033: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2034: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2035: [17, 30, 32, 31, 32, 31, 31, 29, 30, 30, 29, 29, 31], + 2036: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2037: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2038: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2039: [17, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 30, 30], + 2040: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2041: [18, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2042: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2043: [17, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 30, 30], + 2044: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2045: [18, 31, 32, 31, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2046: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2047: [17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 2048: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2049: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30], + 2050: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2051: [17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 2052: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2053: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30], + 2054: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2055: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 30, 29, 30], + 2056: [17, 31, 31, 32, 31, 32, 30, 30, 29, 30, 29, 30, 30], + 2057: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2058: [17, 30, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2059: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2060: [17, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2061: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2062: [17, 30, 32, 31, 32, 31, 31, 29, 30, 29, 30, 29, 31], + 2063: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2064: [17, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2065: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2066: [17, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 29, 31], + 2067: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2068: [17, 31, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2069: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2070: [17, 31, 31, 31, 32, 31, 31, 29, 30, 30, 29, 30, 30], + 2071: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2072: [17, 31, 32, 31, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2073: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 31], + 2074: [17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 2075: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2076: [16, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30], + 2077: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2078: [17, 31, 31, 31, 32, 31, 31, 30, 29, 30, 29, 30, 30], + 2079: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2080: [16, 31, 32, 31, 32, 31, 30, 30, 30, 29, 29, 30, 30], + // These data are from http://www.ashesh.com.np/nepali-calendar/ + 2081: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 29, 31], + 2082: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 29, 30, 30], + 2083: [17, 31, 31, 32, 31, 31, 30, 30, 30, 29, 30, 30, 30], + 2084: [17, 31, 31, 32, 31, 31, 30, 30, 30, 29, 30, 30, 30], + 2085: [17, 31, 32, 31, 32, 31, 31, 30, 30, 29, 30, 30, 30], + 2086: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 30, 30], + 2087: [16, 31, 31, 32, 31, 31, 31, 30, 30, 29, 30, 30, 30], + 2088: [16, 30, 31, 32, 32, 30, 31, 30, 30, 29, 30, 30, 30], + 2089: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 30, 30], + 2090: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 30, 30], + 2091: [16, 31, 31, 32, 31, 31, 31, 30, 30, 29, 30, 30, 30], + 2092: [16, 31, 31, 32, 32, 31, 30, 30, 30, 29, 30, 30, 30], + 2093: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 30, 30], + 2094: [17, 31, 31, 32, 31, 31, 30, 30, 30, 29, 30, 30, 30], + 2095: [17, 31, 31, 32, 31, 31, 31, 30, 29, 30, 30, 30, 30], + 2096: [17, 30, 31, 32, 32, 31, 30, 30, 29, 30, 29, 30, 30], + 2097: [17, 31, 32, 31, 32, 31, 30, 30, 30, 29, 30, 30, 30], + 2098: [17, 31, 31, 32, 31, 31, 31, 29, 30, 29, 30, 30, 31], + 2099: [17, 31, 31, 32, 31, 31, 31, 30, 29, 29, 30, 30, 30], + 2100: [17, 31, 32, 31, 32, 30, 31, 30, 29, 30, 29, 30, 30], +} + +export const getMaxDaysInMonth = (year, month, calendar) => { + if (calendar === JULIAN_CALENDAR_NAME && year % 4 === 0 && month === 2) { + return 29 + } + if (calendar === NEPALI_CALENDAR_NAME) { + return NEPALI_CALENDAR_DATA[year][month] + } + const calendarName = dhis2CalendarsMap[calendar] || calendar + const date = Temporal.PlainDate.from({ + year: year, + month: month, + day: 1, + calendar: calendarName, + }) + + return date.daysInMonth +} + +export const getMaxMonthsInYear = (year, calendar) => { + if (calendar === NEPALI_CALENDAR_NAME) { + return NEPALI_CALENDAR_DATA[year].length - 1 + } + const calendarName = dhis2CalendarsMap[calendar] || calendar + + const date = Temporal.PlainDate.from({ + year: year, + month: 1, + day: 1, + calendar: calendarName, + }) + + return date.monthsInYear +} + +export const getDefaultDatesInCalendar = (calendar = DEFAULT_CALENDAR) => { + const calendarName = dhis2CalendarsMap[calendar] || calendar + const { day, month, eraYear: year } = getNowInCalendar(calendarName) + const formatDateString = (y, m, d) => + `${y}-${m.toString().padStart(2, '0')}-${d.toString().padStart(2, '0')}` + return { + startDate: formatDateString(year - 1, month, day), + endDate: formatDateString(year, month, day), + } +} + +export const getCurrentYearInCalendar = (calendar) => { + const calendarName = dhis2CalendarsMap[calendar] || calendar + const today = getNowInCalendar(calendarName) + return today.eraYear +} + +export function replaceAt(str, index, replacement) { + const cleanReplacement = replacement.replace(/\D/g, '') + if (index >= str.length) { + return str + cleanReplacement + } + if (index < 0) { + index = 0 + } + return cleanReplacement + ? str.slice(0, index) + cleanReplacement + str.slice(index + 1) + : str +} + +export const formatDateInput = ({ + date, + prevDate, + caret, + calendar = DEFAULT_CALENDAR, +}) => { + if (!date) { + return '' + } + + if (prevDate && date.length > caret) { + if (date.length <= prevDate.length) { + date = prevDate + } else if (date[caret] === '-') { + date = replaceAt(prevDate, caret, date[caret - 1]) + } else { + date = replaceAt(prevDate, caret - 1, date[caret - 1]) + } + } + + if (date.length === 7 && date[6] === '-') { + date = date.slice(0, -2) + '0' + date.slice(-2) + } + + let finalHyphen = '' + if ( + (date.length === 5 && date[4] === '-') || + (date.length === 6 && date[4] === '-' && date[5] === '-') || + (date.length === 8 && date[7] === '-') || + (date.length === 9 && date[7] === '-' && date[8] === '-') + ) { + finalHyphen = '-' + } + + const numericDate = date.replace(/\D/g, '') + + const year = numericDate.slice(0, 4) + const month = numericDate.slice(4, 6) + const day = numericDate.slice(6, 8) + + if (numericDate.length < 4) { + return numericDate + } + + const formattedYear = + year === '0000' ? getCurrentYearInCalendar(calendar) : year + + if (numericDate.length === 4) { + return `${formattedYear}${finalHyphen}` + } + + if (numericDate.length < 6) { + return `${formattedYear}-${month}` + } + + const maxMonth = getMaxMonthsInYear(year, calendar) + + const formattedMonth = + month === '00' ? 1 : month > maxMonth ? maxMonth : month + + if (numericDate.length === 6) { + return `${formattedYear}-${formattedMonth + .toString() + .padStart(2, '0')}${finalHyphen}` + } + + if (numericDate.length < 8) { + return `${formattedYear}-${formattedMonth + .toString() + .padStart(2, '0')}-${day}` + } + + const maxDaysInMonth = getMaxDaysInMonth( + formattedYear, + formattedMonth, + calendar + ) + const formattedDay = + day === '00' ? 1 : day > maxDaysInMonth ? maxDaysInMonth : day + + return `${formattedYear}-${formattedMonth + .toString() + .padStart(2, '0')}-${formattedDay.toString().padStart(2, '0')}` +} + +export const formatDateOnBlur = (date) => { + // console.log('jj formatDateOnBlur', date) + if ( + (date.length === 5 && date[4] === '-') || + (date.length === 8 && date[7] === '-') + ) { + return date.slice(0, -1) + } else if (date?.length === 9) { + return date.slice(0, -1) + '0' + date.slice(-1) + } else { + return date + } +} + +export const nextCharIsAutoHyphen = ({ date, prevDate, caret }) => { + if (!date || !prevDate || !caret) { + return false + } + const isInsertingNewChar = date.length === prevDate.length + 1 + const insertedCharIsDigit = /\d/.test(date[caret - 1] || '') + const insertedCharIsHyphen = date[caret - 1] === '-' + const atHyphenPosition = caret === 5 || caret === 8 + const atEarlyHyphenPosition = caret === 7 && date.length === 7 + + return ( + isInsertingNewChar && + ((insertedCharIsDigit && atHyphenPosition) || + (insertedCharIsHyphen && atEarlyHyphenPosition)) + ) +} + +export const nextCharIsManualHyphen = ({ date, prevDate, caret }) => { + if (!date || !prevDate || !caret) { + return false + } + const atHyphenPosition = caret === 5 || caret === 8 + const insertedCharIsHyphen = date[caret - 1] === '-' + + return atHyphenPosition && insertedCharIsHyphen +} diff --git a/src/components/StartEndDate/styles/CalendarInput.style.js b/src/components/StartEndDate/styles/CalendarInput.style.js new file mode 100644 index 000000000..c570d1031 --- /dev/null +++ b/src/components/StartEndDate/styles/CalendarInput.style.js @@ -0,0 +1,13 @@ +import css from 'styled-jsx/css' + +export default css` + .calendarInputWrapper { + position: relative; + } + + .calendarClearButton { + position: absolute; + inset-inline-end: 6px; + inset-block-start: 27px; + } +` diff --git a/src/components/StartEndDate/styles/StartEndDate.style.js b/src/components/StartEndDate/styles/StartEndDate.style.js new file mode 100644 index 000000000..51a6145bf --- /dev/null +++ b/src/components/StartEndDate/styles/StartEndDate.style.js @@ -0,0 +1,29 @@ +import css from 'styled-jsx/css' + +export default css` + .row { + display: flex; + gap: var(--spacers-dp4); + align-items: flex-end; + } + + .width { + width: 248px; + } + + .icon { + flex-grow: 0; + height: 40px; + display: flex; + align-items: center; + } + + .field { + margin-bottom: var(--spacers-dp8); + } + + .error { + margin-top: var(--spacers-dp8); + color: var(--theme-error); + } +` diff --git a/src/components/StartEndDate/useKeyDown.js b/src/components/StartEndDate/useKeyDown.js new file mode 100644 index 000000000..90ffc849b --- /dev/null +++ b/src/components/StartEndDate/useKeyDown.js @@ -0,0 +1,40 @@ +import { useEffect, useRef, useMemo, useCallback } from 'react' + +const useKeyDown = (key, callback, longPress = false) => { + const timerRef = useRef(null) + const keys = useMemo(() => (Array.isArray(key) ? key : [key]), [key]) + + const handleKeyDown = useCallback( + (event) => { + if (keys.includes(event.key)) { + if (longPress) { + timerRef.current = setTimeout(() => callback(event), 250) + } else { + callback(event) + } + } + }, + [keys, callback, longPress] + ) + + const handleKeyUp = useCallback( + (event) => { + if (keys.includes(event.key) && longPress) { + clearTimeout(timerRef.current) + } + }, + [keys, longPress] + ) + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, [handleKeyDown, handleKeyUp]) +} + +export default useKeyDown