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