-
+
+
diff --git a/packages/ui/src/components/DateInput/__tests__/helper.test.ts b/packages/ui/src/components/DateInput/__tests__/helper.test.ts
new file mode 100644
index 0000000000..b842a41f15
--- /dev/null
+++ b/packages/ui/src/components/DateInput/__tests__/helper.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, test } from 'vitest'
+import {
+ addZero,
+ formatValue,
+ getMonthFirstDay,
+ getNextMonth,
+ getPreviousMonth,
+ isSameDay,
+ isSameMonth,
+} from '../helpers'
+
+const rangeDate = {
+ start: new Date('20 October 2000'),
+ end: new Date('31 October 2000'),
+}
+
+const date = new Date('20 November 2000')
+
+describe('Helper functions dateInput', () => {
+ test('getMonthFirstDay should work', () => {
+ expect(getMonthFirstDay(1, 2000)).toBe(5)
+ })
+
+ test('addZero should work', () => {
+ expect(addZero(1)).toBe('01')
+ })
+
+ test('getPreviousMonth should work', () => {
+ const beforeJan2000 = getPreviousMonth(1, 2000)
+ expect(beforeJan2000[0]).toBe(12)
+ expect(beforeJan2000[1]).toBe(1999)
+
+ const beforeDec2000 = getPreviousMonth(12, 2000)
+ expect(beforeDec2000[0]).toBe(11)
+ expect(beforeDec2000[1]).toBe(2000)
+ })
+
+ test('getNextMonth should work', () => {
+ const afterDec2000 = getNextMonth(12, 2000)
+ expect(afterDec2000[0]).toBe(1)
+ expect(afterDec2000[1]).toBe(2001)
+
+ const afterNov2000 = getNextMonth(11, 2000)
+ expect(afterNov2000[0]).toBe(12)
+ expect(afterNov2000[1]).toBe(2000)
+ })
+
+ test('isSameMonth should work', () => {
+ expect(isSameMonth(new Date('23 Dec 2023'), new Date('22 Dec 2023'))).toBe(
+ true,
+ )
+ expect(isSameMonth(new Date('23 Dec 2023'), new Date('23 Oct 2023'))).toBe(
+ false,
+ )
+ })
+
+ test('isSameDay should work', () => {
+ expect(isSameDay(new Date(), new Date('22 Dec 1999'))).toBe(false)
+ expect(isSameDay(new Date('23 Dec 2023'), new Date('23 Dec 2023'))).toBe(
+ true,
+ )
+ })
+
+ test('formatValue should work with default formatting', () => {
+ expect(formatValue(date, null, false, false)).toBe('11/20/2000')
+ expect(formatValue(date, null, true, false)).toBe('11/2000')
+ expect(formatValue(date, null, false, true)).toBe('11/20/2000')
+
+ expect(formatValue(null, rangeDate, false, false)).toBe(undefined)
+ expect(formatValue(null, rangeDate, true, true)).toBe('10/2000 - 10/2000')
+ expect(formatValue(null, rangeDate, false, true)).toBe(
+ '10/20/2000 - 10/31/2000',
+ )
+ })
+
+ test('formatValue should work with custom formatting', () => {
+ const format = (value?: Date) =>
+ value ? String(value.getFullYear()) : '1999'
+
+ expect(formatValue(date, null, false, false, format)).toBe('2000')
+ expect(formatValue(date, null, true, false, format)).toBe('2000')
+ expect(formatValue(date, null, false, true, format)).toBe('2000')
+
+ expect(formatValue(null, rangeDate, false, false, format)).toBe(undefined)
+ expect(formatValue(null, rangeDate, true, true, format)).toBe('2000 - 2000')
+ expect(formatValue(null, rangeDate, false, true, format)).toBe(
+ '2000 - 2000',
+ )
+ })
+})
diff --git a/packages/ui/src/components/DateInput/__tests__/index.test.tsx b/packages/ui/src/components/DateInput/__tests__/index.test.tsx
index 9213d331e9..ca195f9aca 100644
--- a/packages/ui/src/components/DateInput/__tests__/index.test.tsx
+++ b/packages/ui/src/components/DateInput/__tests__/index.test.tsx
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
import { renderWithTheme } from '@utils/test'
import { es, fr, ru } from 'date-fns/locale'
import tk from 'timekeeper'
-import { describe, expect, test } from 'vitest'
+import { describe, expect, test, vi } from 'vitest'
import { DateInput } from '..'
tk.freeze(new Date(1609503120000))
@@ -17,12 +17,10 @@ describe('DateInput', () => {
onBlur={() => {}}
onFocus={() => {}}
locale={fr}
- value={new Date('1995-12-17T03:24:00.000+00:00').toISOString()}
+ value={new Date('1995-12-17T03:24:00.000+00:00')}
name="test"
autoFocus={false}
- format={value =>
- value instanceof Date ? value.toISOString() : value?.toString()
- }
+ format={value => (value instanceof Date ? value.toISOString() : value)}
/>,
)
expect(asFragment()).toMatchSnapshot()
@@ -113,6 +111,21 @@ describe('DateInput', () => {
)
expect(asFragment()).toMatchSnapshot()
})
+ test('render correctly with showMonthYearPicker with default date', async () => {
+ const { asFragment } = renderWithTheme(
+
{}}
+ value={new Date('1995-02-11T03:24:00.000+00:00')}
+ placeholder="YYYY-MM-DD"
+ />,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ expect(asFragment()).toMatchSnapshot()
+ })
test('render correctly with a range of date', () => {
const { asFragment } = renderWithTheme(
@@ -127,7 +140,7 @@ describe('DateInput', () => {
expect(asFragment()).toMatchSnapshot()
})
- test('render correctly with a array of dates to exclude', () => {
+ test('render correctly with a array of dates to exclude', async () => {
const { asFragment } = renderWithTheme(
{
new Date('1995-12-13T03:24:00.000+00:00'),
new Date('1995-12-14T03:24:00.000+00:00'),
]}
+ placeholder="YYYY-MM-DD"
onChange={() => {}}
/>,
)
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+
expect(asFragment()).toMatchSnapshot()
})
+
+ test('handle correctly click outside', async () => {
+ renderWithTheme(
+ <>
+ Outside
+ {}} placeholder="YYYY-MM-DD" />
+ >,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ const calendar = screen.getByRole('dialog')
+ expect(calendar).toBeVisible()
+ await userEvent.click(screen.getByText('Outside'))
+ await userEvent.click(screen.getByText('Outside'))
+ expect(calendar).not.toBeVisible()
+ })
+
+ test('handle correctly click to change month', async () => {
+ renderWithTheme(
+ <>
+ Outside
+ {}}
+ placeholder="YYYY-MM-DD"
+ value={new Date('1995-12-11T03:24:00.000+00:00')}
+ />
+ >,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ const calendar = screen.getByRole('dialog')
+ expect(calendar).toBeVisible()
+
+ const visibleMonth = screen.getByText(/December/i)
+ await userEvent.click(screen.getByTestId('previous-month'))
+ expect(visibleMonth.textContent).toContain('November')
+
+ await userEvent.click(screen.getByTestId('next-month'))
+ expect(visibleMonth.textContent).toContain('December')
+ })
+
+ test('handle correctly click to change year', async () => {
+ renderWithTheme(
+ <>
+ Outside
+ {}}
+ placeholder="YYYY-MM-DD"
+ value={new Date('1995-12-11T03:24:00.000+00:00')}
+ showMonthYearPicker
+ />
+ >,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ const calendar = screen.getByRole('dialog')
+ expect(calendar).toBeVisible()
+
+ const visibleMonth = screen.getByText(/1995/i)
+ await userEvent.click(screen.getByTestId('previous-month'))
+ expect(visibleMonth.textContent).toContain('1994')
+
+ await userEvent.click(screen.getByTestId('next-month'))
+ expect(visibleMonth.textContent).toContain('1995')
+ })
+
+ test('handle correctly click on date', async () => {
+ renderWithTheme(
+ {}}
+ placeholder="YYYY-MM-DD"
+ value={new Date('1995-12-11T03:24:00.000+00:00')}
+ />,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+
+ const calendar = screen.getByRole('dialog')
+ expect(calendar).toBeVisible()
+
+ await userEvent.click(input)
+
+ await userEvent.click(screen.getByText('15'))
+ expect(input.value).toBe('12/15/1995')
+
+ await userEvent.click(input)
+
+ const dayFromLastMonth = screen.getAllByText('30')[0] // the first element in this array is from previous month
+ await userEvent.click(dayFromLastMonth)
+
+ await userEvent.click(input)
+ expect(input.value).toBe('11/30/1995')
+
+ await userEvent.click(input)
+ const dayFromNextMonth = screen.getAllByText('1')[1]
+ await userEvent.click(dayFromNextMonth)
+ expect(input.value).toBe('12/01/1995')
+ })
+
+ test('handle correctly click on date range', async () => {
+ renderWithTheme(
+ {}}
+ placeholder="YYYY-MM-DD"
+ selectsRange
+ value={new Date('1995-02-11T03:24:00.000+00:00')}
+ />,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ const calendar = screen.getByRole('dialog')
+ expect(calendar).toBeVisible()
+
+ await userEvent.click(screen.getByText('15'))
+ expect(input.value).toBe('02/15/1995 - ')
+ const day = screen.getByText('27')
+
+ await userEvent.hover(day)
+ await userEvent.unhover(day)
+
+ await userEvent.click(day)
+ expect(input.value).toBe('02/15/1995 - 02/27/1995')
+
+ await userEvent.click(input)
+ await userEvent.click(screen.getByText('31'))
+ expect(input.value).toBe('01/31/1995 - ')
+ })
+
+ test('render correctly with showMonthYearPicker with excluded months', async () => {
+ const { asFragment } = renderWithTheme(
+ {}}
+ value={new Date('1995-02-11T03:24:00.000+00:00')}
+ placeholder="YYYY-MM-DD"
+ excludeDates={[new Date('1995-10-01'), new Date('1995-02-01')]}
+ />,
+ )
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ expect(asFragment()).toMatchSnapshot()
+ })
+
+ test('handle correctly click on date with showmonthYearPicker', async () => {
+ renderWithTheme(
+ {}}
+ placeholder="YYYY-MM-DD"
+ value={new Date('1995-12-11T03:24:00.000+00:00')}
+ showMonthYearPicker
+ />,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ const calendar = screen.getByRole('dialog')
+ expect(calendar).toBeVisible()
+
+ await userEvent.click(screen.getByText('Jan'))
+ expect(input.value).toBe('01/1995')
+ })
+
+ test('handle correctly click on date range with showMonthYearPicker', async () => {
+ renderWithTheme(
+ {}}
+ placeholder="YYYY-MM-DD"
+ selectsRange
+ showMonthYearPicker
+ value={new Date('1995-12-11T03:24:00.000+00:00')}
+ />,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+ const calendar = screen.getByRole('dialog')
+ expect(calendar).toBeVisible()
+
+ await userEvent.click(screen.getByText('Aug'))
+ expect(input.value).toBe('08/1995 - ')
+ const month = screen.getByText('Feb')
+
+ await userEvent.hover(month)
+ await userEvent.unhover(month)
+
+ await userEvent.click(month)
+ expect(input.value).toBe('02/1995 - ')
+
+ await userEvent.click(screen.getByText('Sep'))
+ expect(input.value).toBe('02/1995 - 09/1995')
+ })
+
+ test('renders correctly custom format with range', () => {
+ const { asFragment } = renderWithTheme(
+ {}}
+ onBlur={() => {}}
+ onFocus={() => {}}
+ selectsRange
+ startDate={new Date('1995-12-11T03:24:00.000+00:00')}
+ endDate={new Date('1995-12-11T03:24:00.000+00:00')}
+ name="test"
+ autoFocus={false}
+ format={value => (value instanceof Date ? value.toISOString() : value)}
+ />,
+ )
+ expect(asFragment()).toMatchSnapshot()
+ })
+
+ test('handle correctly type in input', async () => {
+ const mockOnChange = vi.fn()
+ renderWithTheme(
+ ,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+
+ await userEvent.type(input, '08/21/1995')
+ expect(mockOnChange).toBeCalled()
+ expect(screen.getByText('August', { exact: false })).toBeInTheDocument()
+ })
+
+ test('handle correctly type in input with select range', async () => {
+ const mockOnChange = vi.fn()
+ renderWithTheme(
+ ,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+
+ await userEvent.type(input, '08/21/1995')
+ expect(mockOnChange).toBeCalled()
+ expect(screen.getByText('August', { exact: false })).toBeInTheDocument()
+ })
+
+ test('handle correctly type in input with showMonthYearPicker', async () => {
+ const mockOnChange = vi.fn()
+ renderWithTheme(
+ ,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+
+ await userEvent.type(input, '2000/08')
+ expect(mockOnChange).toBeCalled()
+ expect(screen.getByText('2000', { exact: false })).toBeInTheDocument()
+ })
+
+ test('handle correctly type in input with select range and showMonthYearPicker', async () => {
+ const mockOnChange = vi.fn()
+ renderWithTheme(
+ ,
+ )
+
+ const input = screen.getByPlaceholderText('YYYY-MM-DD')
+ await userEvent.click(input)
+
+ await userEvent.type(input, '2000/08')
+ expect(mockOnChange).toBeCalled()
+ expect(screen.getByText('2000', { exact: false })).toBeInTheDocument()
+ })
})
diff --git a/packages/ui/src/components/DateInput/components/CalendarDaily.tsx b/packages/ui/src/components/DateInput/components/CalendarDaily.tsx
new file mode 100644
index 0000000000..84334c5c46
--- /dev/null
+++ b/packages/ui/src/components/DateInput/components/CalendarDaily.tsx
@@ -0,0 +1,283 @@
+import styled from '@emotion/styled'
+import type { MouseEvent as MouseEventReact } from 'react'
+import { useContext, useState } from 'react'
+import { Button } from '../../Button'
+import { Row } from '../../Row'
+import { Text } from '../../Text'
+import { DateInputContext } from '../Context'
+import { CALENDAR_WEEKS } from '../constants'
+import {
+ formatValue,
+ getMonthFirstDay,
+ getNextMonth,
+ getPreviousMonth,
+ isSameDay,
+} from '../helpers'
+
+const ButtonDate = styled(Button)`
+ height: ${({ theme }) => theme.sizing['312']};
+ width: 100%;
+ padding: 0;
+`
+
+const RangeButton = styled(Button)`
+ background-color: ${({ theme }) => theme.colors.primary.background};
+ height: ${({ theme }) => theme.sizing['312']};
+ width: 100%;
+ padding: 0;
+`
+
+const CapitalizedText = styled(Text)`
+ display: inline-block;
+ text-transform: lowercase;
+
+ &::first-letter {
+ text-transform: uppercase;
+ }
+`
+
+export const Daily = ({ disabled }: { disabled: boolean }) => {
+ const {
+ value,
+ yearToShow,
+ monthToShow,
+ setValue,
+ setMonthToShow,
+ onChange,
+ setYearToShow,
+ excludeDates,
+ minDate,
+ maxDate,
+ DAYS,
+ selectsRange,
+ range,
+ setRange,
+ setInputValue,
+ format,
+ setVisible,
+ } = useContext(DateInputContext)
+
+ const [rangeState, setRangeState] = useState<'start' | 'none' | 'done'>(
+ range?.start ? 'start' : 'none',
+ ) // Used when selectsRange is True. Kow the current state of the range: none when start date not selected, start when start date is selected, done when start & end date selected
+
+ const [hoveredDate, setHoveredDate] = useState(null)
+
+ const monthDays = new Date(yearToShow, monthToShow, 0).getDate() // Number of days in the month
+
+ const daysFromPreviousMonth = getMonthFirstDay(monthToShow, yearToShow) // Number of days from the previous month to show.
+
+ const daysFromNextMonth =
+ CALENDAR_WEEKS * 7 - (daysFromPreviousMonth + monthDays) // We want to display 6 CALENDAR_WEEKS lines, so we show days from the next month
+
+ const [previousMonth, prevMonthYear] = getPreviousMonth(
+ monthToShow,
+ yearToShow,
+ )
+
+ const [nextMonth, nextMonthYear] = getNextMonth(monthToShow, yearToShow)
+ const previousMonthDays = new Date(prevMonthYear, previousMonth, 0).getDate() // Number of days in the previous month
+
+ // Get the dates to be displayed from the previous month
+ const prevMonthDates = Array.from(
+ { length: daysFromPreviousMonth },
+ (_, index) => ({
+ day: index + 1 + (previousMonthDays - daysFromPreviousMonth),
+ month: -1,
+ }),
+ )
+
+ // Get the dates to be displayed from the current month
+ const currentMonthDates = Array.from({ length: monthDays }, (_, index) => ({
+ day: index + 1,
+ month: 0,
+ }))
+
+ // Get the dates to be displayed from the next month
+ const nextMonthDates = Array.from(
+ { length: daysFromNextMonth },
+ (_, index) => ({ day: index + 1, month: 1 }),
+ )
+
+ const allDaysToShow = [
+ ...prevMonthDates,
+ ...currentMonthDates,
+ ...nextMonthDates,
+ ] // Array of the days to display { day : day n°, isCurrentMonth: if it is the current day}
+
+ return (
+
+ {Object.entries(DAYS).map(day => (
+
+ {day[1]}
+
+ ))}
+ {allDaysToShow.map(data => {
+ const constructedDate = new Date(
+ yearToShow,
+ monthToShow - 1 + data.month,
+ data.day,
+ )
+ const isExcluded = excludeDates
+ ? excludeDates
+ .map(date => isSameDay(constructedDate, date))
+ .includes(true)
+ : false
+
+ // Whether the date < minDate or date > maxDate
+ const isOutsideRange =
+ !!(minDate && constructedDate < minDate) ||
+ !!(maxDate && constructedDate > maxDate)
+
+ // Whether the date is selected
+ const isSelected =
+ (value && isSameDay(constructedDate, new Date(value))) ||
+ (range?.end && isSameDay(constructedDate, range.end)) ||
+ (range?.start && isSameDay(constructedDate, range.start))
+
+ // Whether the date is after the start date - useful when selectsRange is set to true
+ const isAfterStartDate =
+ selectsRange && range?.start && constructedDate > range.start
+
+ const isInHoveredRange =
+ (selectsRange &&
+ range?.start &&
+ constructedDate > range.start &&
+ hoveredDate &&
+ constructedDate < hoveredDate) ||
+ (range?.start &&
+ range.end &&
+ constructedDate < range.end &&
+ constructedDate > range.start)
+
+ const getNewDate = () => {
+ // Clicked on a day from the previous month
+ if (data.month !== 0 && data.day > 15) {
+ setMonthToShow(previousMonth)
+ setYearToShow(prevMonthYear)
+
+ return new Date(prevMonthYear, previousMonth - 1, data.day)
+ }
+
+ // Clicked on a day from the next month
+ if (data.month !== 0 && data.day < 15) {
+ setMonthToShow(nextMonth)
+ setYearToShow(nextMonthYear)
+
+ return new Date(nextMonthYear, nextMonth - 1, data.day)
+ }
+
+ return new Date(yearToShow, monthToShow - 1, data.day)
+ }
+
+ const onClickRange = (event: MouseEventReact, newDate: Date) => {
+ if (selectsRange) {
+ // Selecting start date
+ if (rangeState === 'none') {
+ setRange?.({ start: newDate, end: null })
+ onChange?.([newDate, null], event)
+ setInputValue(
+ formatValue(
+ null,
+ { start: newDate, end: null },
+ false,
+ true,
+ format,
+ ),
+ )
+ setRangeState('start')
+ }
+
+ // Selecting end date
+ else if (isAfterStartDate) {
+ setRange?.({ start: range.start, end: newDate })
+ onChange?.([range.start, newDate], event)
+ setInputValue(
+ formatValue(
+ null,
+ { start: range.start, end: newDate },
+ false,
+ true,
+ format,
+ ),
+ )
+ setVisible(false)
+ setRangeState('done')
+ } else {
+ // End date before start
+ setRange?.({ start: newDate, end: null })
+ setInputValue(
+ formatValue(
+ null,
+ { start: newDate, end: null },
+ false,
+ true,
+ format,
+ ),
+ )
+ onChange?.([newDate, null], event)
+ }
+ }
+ }
+ const createTestId = () => {
+ if (isInHoveredRange) return 'rangeButton'
+ if (data.month === -1) return 'dayLastMonth'
+ if (data.month === 1) return 'dayNextMonth'
+
+ return undefined
+ }
+
+ const Day = isInHoveredRange ? RangeButton : ButtonDate
+
+ return (
+ {
+ if (!isExcluded && !isOutsideRange) {
+ const newDate = getNewDate()
+
+ if (selectsRange) {
+ onClickRange(event, newDate)
+ } else {
+ setValue(newDate)
+ onChange?.(newDate, event)
+ setInputValue(
+ formatValue(newDate, null, false, false, format),
+ )
+ setVisible(false)
+ }
+ }
+ }}
+ onMouseEnter={() => {
+ if (selectsRange && range?.start) setHoveredDate(constructedDate)
+ }}
+ onMouseLeave={() => {
+ if (selectsRange && range?.start) setHoveredDate(null)
+ }}
+ >
+
+ {data.day}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/packages/ui/src/components/DateInput/components/CalendarMonthly.tsx b/packages/ui/src/components/DateInput/components/CalendarMonthly.tsx
new file mode 100644
index 0000000000..ba7540f455
--- /dev/null
+++ b/packages/ui/src/components/DateInput/components/CalendarMonthly.tsx
@@ -0,0 +1,179 @@
+import styled from '@emotion/styled'
+import type { MouseEvent as MouseEventReact } from 'react'
+import { useContext, useState } from 'react'
+import { Button } from '../../Button'
+import { Row } from '../../Row'
+import { Text } from '../../Text'
+import { DateInputContext } from '../Context'
+import { formatValue, isSameMonth } from '../helpers'
+
+const ButtonDate = styled(Button)`
+ height: ${({ theme }) => theme.sizing['312']};
+ width: 100%;
+ padding: 0;
+`
+
+const RangeButton = styled(Button)`
+ background-color: ${({ theme }) => theme.colors.primary.background};
+ height: ${({ theme }) => theme.sizing['312']};
+ width: 100%;
+ padding: 0;
+`
+
+const CapitalizedText = styled(Text)`
+display: inline-block;
+text-transform: lowercase;
+
+&::first-letter {
+ text-transform: uppercase;
+}
+`
+
+export const Monthly = ({ disabled }: { disabled: boolean }) => {
+ const {
+ yearToShow,
+ setValue,
+ setMonthToShow,
+ MONTHS,
+ onChange,
+ selectsRange,
+ setRange,
+ range,
+ minDate,
+ maxDate,
+ excludeDates,
+ value,
+ setInputValue,
+ format,
+ setVisible,
+ } = useContext(DateInputContext)
+ const [rangeState, setRangeState] = useState<'start' | 'none' | 'done'>(
+ range?.start ? 'start' : 'none',
+ ) // Used when selectsRange is True. It is used to know the current state of the range: none when start date not selected, start when start date is selected, done when start & end date selected
+ const [hoveredDate, setHoveredDate] = useState(null)
+
+ return (
+
+ {Object.entries(MONTHS).map((month, index) => {
+ const constructedDate = new Date(yearToShow, index, 1)
+
+ const isExcluded = excludeDates
+ ? excludeDates
+ .map(date => isSameMonth(constructedDate, date))
+ .includes(true)
+ : false
+
+ const isOutsideRange =
+ !!(minDate && constructedDate < minDate) ||
+ !!(maxDate && constructedDate > maxDate)
+
+ const isAfterStartDate =
+ selectsRange && range?.start && constructedDate > range.start
+
+ const isInHoveredRange =
+ (selectsRange &&
+ range?.start &&
+ constructedDate > range.start &&
+ hoveredDate &&
+ constructedDate < hoveredDate) ||
+ (range?.start &&
+ range.end &&
+ constructedDate < range.end &&
+ constructedDate > range.start)
+
+ const isSelected =
+ (value && isSameMonth(constructedDate, value)) ||
+ (range?.start && isSameMonth(constructedDate, range.start)) ||
+ (range?.end && isSameMonth(constructedDate, range.end))
+
+ const onClickRange = (event: MouseEventReact, newDate: Date) => {
+ // Must wrap inside this if otherwise TypeScript doesn't get the right type for onChange
+ if (selectsRange) {
+ // Selecting start date
+ if (rangeState === 'none') {
+ setRange?.({ start: newDate, end: null })
+ onChange?.([newDate, null], event)
+ setInputValue(
+ formatValue(
+ null,
+ { start: newDate, end: null },
+ true,
+ true,
+ format,
+ ),
+ )
+ setRangeState('start')
+ }
+
+ // Selecting end date
+ else if (isAfterStartDate) {
+ setRange?.({ start: range.start, end: newDate })
+ onChange?.([range.start, newDate], event)
+ setInputValue(
+ formatValue(
+ null,
+ { start: range.start, end: newDate },
+ true,
+ true,
+ format,
+ ),
+ )
+ setVisible(false)
+ setRangeState('done')
+ } else {
+ // End date before start
+ setRange?.({ start: newDate, end: null })
+ setInputValue(
+ formatValue(
+ null,
+ { start: newDate, end: null },
+ true,
+ true,
+ format,
+ ),
+ )
+ onChange?.([newDate, null], event)
+ }
+ }
+ }
+ const Month = isInHoveredRange ? RangeButton : ButtonDate
+
+ return (
+ {
+ if (!isExcluded && !isOutsideRange) {
+ if (selectsRange) {
+ onClickRange(event, constructedDate)
+ } else {
+ setMonthToShow(index + 1)
+ setValue(constructedDate)
+ onChange?.(constructedDate, event)
+ setInputValue(
+ formatValue(constructedDate, null, true, false, format),
+ )
+ setVisible(false)
+ }
+ }
+ }}
+ onMouseEnter={() => setHoveredDate(constructedDate)}
+ onMouseLeave={() => setHoveredDate(null)}
+ >
+
+ {month[1]}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/packages/ui/src/components/DateInput/components/Popup.tsx b/packages/ui/src/components/DateInput/components/Popup.tsx
new file mode 100644
index 0000000000..c972a096a3
--- /dev/null
+++ b/packages/ui/src/components/DateInput/components/Popup.tsx
@@ -0,0 +1,170 @@
+import styled from '@emotion/styled'
+import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react'
+import { useContext, useEffect, useRef } from 'react'
+import { Button } from '../../Button'
+import { Popup } from '../../Popup'
+import { Stack } from '../../Stack'
+import { Text } from '../../Text'
+import { DateInputContext } from '../Context'
+import { POPUP_WIDTH } from '../constants'
+import { getNextMonth, getPreviousMonth } from '../helpers'
+import { Daily } from './CalendarDaily'
+import { Monthly } from './CalendarMonthly'
+
+const CapitalizedText = styled(Text)`
+ display: inline-block;
+ text-transform: lowercase;
+
+ &::first-letter {
+ text-transform: uppercase;
+ }
+`
+type PopupProps = {
+ children: ReactNode
+ visible: boolean
+ setVisible: Dispatch>
+ refInput: RefObject
+}
+
+const StyledPopup = styled(Popup)`
+ width: 100%;
+ background-color: ${({ theme }) => theme.colors.other.elevation.background.raised};
+ color: ${({ theme }) => theme.colors.neutral.text};
+ box-shadow: ${({ theme }) => `${theme.shadows.raised[0]}, ${theme.shadows.raised[1]}`};
+ padding: ${({ theme }) => theme.space[2]};
+ border-radius: ${({ theme }) => theme.radii.default};
+`
+
+const handleClickOutside = (
+ event: MouseEvent,
+ ref: RefObject,
+ setVisible: Dispatch>,
+ refInput: RefObject,
+) => {
+ if (
+ ref.current &&
+ !ref.current.contains(event.target as Node) &&
+ !refInput.current?.contains(event.target as Node)
+ ) {
+ setVisible(false)
+ }
+}
+
+const PopupContent = () => {
+ const {
+ showMonthYearPicker,
+ disabled,
+ monthToShow,
+ yearToShow,
+ setMonthToShow,
+ setYearToShow,
+ maxDate,
+ minDate,
+ MONTHS_ARR,
+ } = useContext(DateInputContext)
+
+ return (
+
+
+
+ {showMonthYearPicker ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export const CalendarPopup = ({
+ children,
+ visible,
+ setVisible,
+ refInput,
+}: PopupProps) => {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ document.addEventListener('mousedown', event =>
+ handleClickOutside(event, ref, setVisible, refInput),
+ )
+
+ return () =>
+ document.removeEventListener('mousedown', event =>
+ handleClickOutside(event, ref, setVisible, refInput),
+ )
+ }, [ref, setVisible, refInput])
+
+ return (
+ }
+ placement="bottom"
+ ref={ref}
+ hasArrow={false}
+ tabIndex={0}
+ role="dialog"
+ debounceDelay={0}
+ maxWidth={POPUP_WIDTH}
+ disableAnimation
+ align="start"
+ >
+ {children}
+
+ )
+}
diff --git a/packages/ui/src/components/DateInput/constants.ts b/packages/ui/src/components/DateInput/constants.ts
new file mode 100644
index 0000000000..8774c3ce1a
--- /dev/null
+++ b/packages/ui/src/components/DateInput/constants.ts
@@ -0,0 +1,7 @@
+export const POPUP_WIDTH = '16.5rem'
+
+export const CURRENT_YEAR = new Date().getFullYear()
+export const CURRENT_MONTH = new Date().getMonth() + 1
+
+// Weeks displayed on calendar
+export const CALENDAR_WEEKS = 6
diff --git a/packages/ui/src/components/DateInput/datepicker.css b/packages/ui/src/components/DateInput/datepicker.css
deleted file mode 100644
index bea364a0d9..0000000000
--- a/packages/ui/src/components/DateInput/datepicker.css
+++ /dev/null
@@ -1 +0,0 @@
-@import 'react-datepicker/dist/react-datepicker.min.css';
diff --git a/packages/ui/src/components/DateInput/helpers.ts b/packages/ui/src/components/DateInput/helpers.ts
new file mode 100644
index 0000000000..eb14cd7392
--- /dev/null
+++ b/packages/ui/src/components/DateInput/helpers.ts
@@ -0,0 +1,84 @@
+import { CURRENT_MONTH, CURRENT_YEAR } from './constants'
+
+// First day of the month for a given year
+export const getMonthFirstDay = (
+ month = CURRENT_MONTH,
+ year = CURRENT_YEAR,
+) => {
+ const firstDay = new Date(year, month - 1, 1).getDay()
+
+ // Change so that a week starts on monday
+ return firstDay === 0 ? 6 : firstDay - 1
+}
+
+export const addZero = (value: number) => `${value}`.padStart(2, '0')
+
+export const getPreviousMonth = (month: number, year: number) => {
+ if (month === 1) {
+ return [12, year - 1]
+ }
+
+ return [month - 1, year]
+}
+
+export const getNextMonth = (month: number, year: number) => {
+ if (month === 12) {
+ return [1, year + 1]
+ }
+
+ return [month + 1, year]
+}
+
+// Checks if two date values are of the same month and year
+export const isSameMonth = (date: Date, basedate = new Date()) =>
+ basedate.getMonth() === date.getMonth() &&
+ basedate.getFullYear() === date.getFullYear()
+
+// (bool) Checks if two date values are the same day
+export const isSameDay = (date: Date, basedate = new Date()) =>
+ basedate.getDate() === date.getDate() &&
+ basedate.getMonth() + 1 === date.getMonth() + 1 &&
+ basedate.getFullYear() === date.getFullYear()
+
+// Default format if none is provided
+const getDateISO = (showMonthYearPicker: boolean, date?: Date) => {
+ if (date) {
+ if (showMonthYearPicker) {
+ return [addZero(date.getMonth() + 1), date.getFullYear()].join('/')
+ }
+
+ return [
+ addZero(date.getMonth() + 1),
+ addZero(date.getDate()),
+ date.getFullYear(),
+ ].join('/')
+ }
+
+ return ''
+}
+
+export const formatValue = (
+ computedValue: Date | null,
+ computedRange: {
+ start: Date | null
+ end: Date | null
+ } | null,
+ showMonthYearPicker: boolean,
+ selectsRange: boolean,
+ format?: (value?: Date) => string | undefined,
+) => {
+ if (selectsRange && computedRange) {
+ return format
+ ? `${format(computedRange.start ?? undefined) ? `${format(computedRange.start ?? undefined)} - ` : ''}${format(computedRange.end ?? undefined) ?? ''}`
+ : `${getDateISO(showMonthYearPicker, computedRange.start ?? undefined)}${computedRange.start ? ' - ' : ''}${getDateISO(showMonthYearPicker, computedRange.end ?? undefined)}`
+ }
+
+ if (computedValue && format) {
+ return format(computedValue)
+ }
+ if (computedValue) {
+ return getDateISO(showMonthYearPicker, computedValue)
+ }
+
+ return undefined
+}
diff --git a/packages/ui/src/components/DateInput/helpersLocale.ts b/packages/ui/src/components/DateInput/helpersLocale.ts
new file mode 100644
index 0000000000..1995188a50
--- /dev/null
+++ b/packages/ui/src/components/DateInput/helpersLocale.ts
@@ -0,0 +1,72 @@
+import type { Locale } from 'date-fns'
+
+const getLocalizedDays = (locale: string) => {
+ const formatter = new Intl.DateTimeFormat(locale, { weekday: 'long' })
+ const days = []
+ for (let i = 0; i < 7; i += 1) {
+ const date = new Date(1970, 0, i + 4)
+ days.push(formatter.format(date))
+ }
+
+ return days
+}
+
+export const getLocalizedMonths = (locale: Locale | string) => {
+ const computedLocale = typeof locale === 'string' ? locale : locale.code
+
+ const formatter = new Intl.DateTimeFormat(computedLocale, { month: 'long' })
+ const months = []
+ for (let i = 0; i < 12; i += 1) {
+ const date = new Date(1970, i)
+ months.push(formatter.format(date))
+ }
+
+ return months
+}
+
+const getLocalizedShortMonths = (locale: string) => {
+ const formatter = new Intl.DateTimeFormat(locale, { month: 'short' })
+ const months = []
+ for (let i = 0; i < 12; i += 1) {
+ const date = new Date(1970, i)
+ months.push(formatter.format(date))
+ }
+
+ return months
+}
+
+export const getDays = (locale: Locale | string) => {
+ const computedLocale = typeof locale === 'string' ? locale : locale.code
+ const longDays = getLocalizedDays(computedLocale) // Get long names
+ const days: Record = {}
+
+ // Map long day names (localized) to short day names (localized)
+ longDays.forEach(longDay => {
+ days[longDay] = longDay.slice(0, 2)
+ })
+
+ const dayKeys = Object.keys(days)
+ dayKeys.push(dayKeys.shift()!)
+
+ const orderedDaysMap: Record = {}
+ dayKeys.forEach(key => {
+ orderedDaysMap[key] = days[key]
+ })
+
+ return orderedDaysMap
+}
+
+export const getMonths = (locale: Locale | string) => {
+ const computedLocale = typeof locale === 'string' ? locale : locale.code
+
+ const longMonths = getLocalizedMonths(locale)
+ const shortMonths = getLocalizedShortMonths(computedLocale)
+ const months: Record = {}
+
+ // Map long month names (localized) to short month names (localized)
+ longMonths.forEach((longMonth, index) => {
+ months[longMonth] = shortMonths[index]
+ })
+
+ return months
+}
diff --git a/packages/ui/src/components/DateInput/index.tsx b/packages/ui/src/components/DateInput/index.tsx
index 52fb7ce360..5363992076 100644
--- a/packages/ui/src/components/DateInput/index.tsx
+++ b/packages/ui/src/components/DateInput/index.tsx
@@ -1,198 +1,20 @@
-import { Global } from '@emotion/react'
import styled from '@emotion/styled'
import { CalendarRangeIcon } from '@ultraviolet/icons'
-import type { FocusEvent, ReactNode } from 'react'
-import { useId } from 'react'
-import type { ReactDatePickerProps } from 'react-datepicker'
-import DatePicker, { registerLocale } from 'react-datepicker'
-import { Button } from '../Button'
-import { Stack } from '../Stack'
-import { Text } from '../Text'
+import type { Locale } from 'date-fns'
+import type { ChangeEvent, FocusEvent } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { TextInputV2 } from '../TextInputV2'
-import style from './datepicker.css?inline'
+import { type ContextProps, DateInputContext } from './Context'
+import { CalendarPopup } from './components/Popup'
+import { formatValue } from './helpers'
+import { getDays, getLocalizedMonths, getMonths } from './helpersLocale'
-const PREFIX = '.react-datepicker'
+const Container = styled.div`
+width: 100%;`
-const StyledWrapper = styled.div`
- width: 100%;
-
- div${PREFIX}-wrapper {
- display: block;
- }
- div${PREFIX}__input-container {
- display: block;
- }
- div${PREFIX}__triangle {
- display: none;
- }
-
- div${PREFIX}__month-container {
- padding: ${({ theme }) => theme.space['2']};
- width: 20rem;
- }
-
- ${PREFIX}-popper {
- z-index: 1000;
- }
- .calendar {
- font-family: ${({ theme }) => theme.typography.body.fontFamily};
- border-color: ${({ theme }) => theme.colors.neutral.borderWeak};
- background-color: ${({ theme }) =>
- theme.colors.other.elevation.background.raised};
- box-shadow: ${({ theme }) => theme.shadows.raised};
-
-
- ${PREFIX}__header {
- color: ${({ theme }) => theme.colors.neutral.text};
- background-color: ${({ theme }) =>
- theme.colors.other.elevation.background.raised};
- border-bottom: none;
- text-align: inherit;
- display: block;
- padding-top: 0;
- position: inherit;
- }
-
- ${PREFIX}__triangle {
- border-bottom-color: ${({ theme }) => theme.colors.neutral.backgroundWeak};
- }
- ${PREFIX}__month {
- margin: 0;
- }
-
- ${PREFIX}__day-names {
- margin-top: ${({ theme }) => theme.space['1']};
- display: flex;
- justify-content: center;
- }
-
- ${PREFIX}__day-name {
- font-family: ${({ theme }) => theme.typography.bodySmallStrong.fontFamily};
- color: ${({ theme }) => theme.colors.neutral.text};
- font-weight: ${({ theme }) => theme.typography.bodySmallStrong.weight};
- font-size: ${({ theme }) => theme.typography.bodySmallStrong.fontSize};
- line-height: ${({ theme }) => theme.typography.bodySmallStrong.lineHeight};
- text-align: center;
- margin: ${({ theme }) => theme.space['0.5']};
- text-transform: capitalize;
- }
-
- ${PREFIX}__day, ${PREFIX}__month {
- color: ${({ theme }) => theme.colors.neutral.textWeak};
- font-weight: ${({ theme }) => theme.typography.bodyStrong.weight};
- font-size: ${({ theme }) => theme.typography.bodyStrong.fontSize};
- margin-left: ${({ theme }) => theme.space['0.5']};
- margin-right: ${({ theme }) => theme.space['0.5']};
- }
-
- ${PREFIX}__day {
- width: ${({ theme }) => theme.sizing['400']};
- height: ${({ theme }) => theme.sizing['400']};
- }
-
- ${PREFIX}__month-text {
- height: ${({ theme }) => theme.sizing['400']};
- display: inline-flex;
- justify-content: center;
- align-items: center;
- }
-
- ${PREFIX}__day--outside-month {
- color: ${({ theme }) => theme.colors.neutral.textDisabled};
- font-weight: ${({ theme }) => theme.typography.bodyStrong.weight};
- font-size: ${({ theme }) => theme.typography.bodyStrong.fontSize};
- }
-
- ${PREFIX}__day--selected, ${PREFIX}__month-text--selected {
- color: ${({ theme }) => theme.colors.primary.textStrong};
- background-color: ${({ theme }) => theme.colors.primary.backgroundStrong};
-
- &[aria-disabled="true"],
- &:disabled {
- color: ${({ theme }) => theme.colors.primary.textStrongDisabled};
- background-color: ${({ theme }) =>
- theme.colors.primary.backgroundStrongDisabled};
- }
- }
-
- ${PREFIX}__day--in-selecting-range, ${PREFIX}__month-text--in-selecting-range {
- color: ${({ theme }) => theme.colors.primary.text};
- background-color: ${({ theme }) => theme.colors.primary.background};
-
- &[aria-disabled="true"],
- &:disabled {
- color: ${({ theme }) => theme.colors.primary.textDisabled};
- background-color: ${({ theme }) =>
- theme.colors.primary.backgroundDisabled};
- }
- }
-
- ${PREFIX}__day--in-range, ${PREFIX}__month-text--in-range {
- color: ${({ theme }) => theme.colors.primary.text};
- background-color: ${({ theme }) => theme.colors.primary.background};
-
- &[aria-disabled="true"],
- &:disabled {
- color: ${({ theme }) => theme.colors.primary.textDisabled};
- background-color: ${({ theme }) =>
- theme.colors.primary.backgroundDisabled};
- }
- }
-
- ${PREFIX}__day--range-start, ${PREFIX}__month-text--range-start {
- color: ${({ theme }) => theme.colors.primary.textStrong};
- background-color: ${({ theme }) => theme.colors.primary.backgroundStrong};
-
- &[aria-disabled="true"],
- &:disabled {
- color: ${({ theme }) => theme.colors.primary.textStrongDisabled};
- background-color: ${({ theme }) =>
- theme.colors.primary.backgroundStrongDisabled};
- }
- }
-
- ${PREFIX}__day--range-end, ${PREFIX}__month-text--range-end {
- color: ${({ theme }) => theme.colors.primary.textStrong};
- background-color: ${({ theme }) => theme.colors.primary.backgroundStrong};
-
- &[aria-disabled="true"],
- &:disabled {
- color: ${({ theme }) => theme.colors.primary.textStrongDisabled};
- background-color: ${({ theme }) =>
- theme.colors.primary.backgroundStrongDisabled};
- }
- }
-
- ${PREFIX}__day--keyboard-selected, ${PREFIX}__month-text--keyboard-selected {
- color: ${({ theme }) => theme.colors.primary.textStrong};
- background-color: ${({ theme }) => theme.colors.primary.backgroundStrong};
- }
-
- ${PREFIX}__day:hover, ${PREFIX}__month-text:hover {
- color: ${({ theme }) => theme.colors.neutral.textHover};
- background-color: ${({ theme }) => theme.colors.neutral.backgroundHover};
- }
-
- ${PREFIX}__day--disabled, ${PREFIX}__month-text--disabled {
- color: ${({ theme }) => theme.colors.neutral.textDisabled};
- }
-
- ${PREFIX}__day--disabled:hover, ${PREFIX}__month-text--disabled:hover {
- color: ${({ theme }) => theme.colors.neutral.textDisabled};
- background-color: transparent;
- }
- }
-`
-
-const StyledText = styled(Text)`
- text-transform: capitalize;
-`
-
-type DateInputProps = Pick<
- ReactDatePickerProps,
- 'locale' | 'onChange'
-> & {
+type DateInputProps = {
autoFocus?: boolean
+ locale?: string | Locale
disabled?: boolean
maxDate?: Date | null
minDate?: Date | null
@@ -201,43 +23,46 @@ type DateInputProps = Pick<
onFocus?: (event: FocusEvent) => void
error?: string
required?: boolean
- format?: (value?: Date | string) => string | undefined
+ format?: (value?: Date) => string | undefined
/**
* Label of the field
*/
label?: string
- value?: Date | string | [Date | null, Date | null]
+ value?: Date | string | null
className?: string
'data-testid'?: string
- selectsRange?: boolean
- startDate?: Date | null
- endDate?: Date | null
excludeDates?: Date[]
id?: string
- labelDescription?: ReactNode
+ labelDescription?: string
success?: string | boolean
helper?: string
size?: 'small' | 'medium' | 'large'
readOnly?: boolean
tooltip?: string
showMonthYearPicker?: boolean
+ placeholder?: string
+ startDate?: Date | null
+ endDate?: Date | null
+ selectsRange?: IsRange
+ onChange?: IsRange extends true
+ ? (
+ date: Date[] | [Date | null, Date | null],
+ event?: React.SyntheticEvent,
+ ) => void
+ : (date: Date | null, event?: React.SyntheticEvent) => void
}
-const DEFAULT_FORMAT: DateInputProps['format'] = value =>
- value instanceof Date ? value.toISOString() : value
-
/**
- * DateInput is a wrapper around react-datepicker that provides a consistent look and feel with the rest of the Ultraviolet UI.
- * See https://reactdatepicker.com/ for more information.
+ * DateInput can be used to select a specific date
*/
-export const DateInput = ({
+export const DateInput = ({
autoFocus = false,
disabled = false,
error,
- format = DEFAULT_FORMAT,
+ format,
label,
labelDescription,
- locale,
+ locale = 'en-US',
maxDate,
minDate,
startDate,
@@ -245,11 +70,11 @@ export const DateInput = ({
name,
onBlur,
onChange,
+ placeholder,
onFocus,
required = false,
excludeDates,
value,
- selectsRange,
className,
id,
success,
@@ -257,125 +82,219 @@ export const DateInput = ({
size = 'large',
readOnly = false,
tooltip,
- showMonthYearPicker,
+ selectsRange = false,
+ showMonthYearPicker = false,
'data-testid': dataTestId,
-}: DateInputProps) => {
- const uniqueId = useId()
- const localId = id ?? uniqueId
+}: DateInputProps) => {
+ const defaultMonthToShow = useMemo(() => {
+ if (value) return new Date(value).getMonth() + 1
+ if (startDate && selectsRange) return startDate.getMonth() + 1
+ if (endDate && selectsRange) return endDate.getMonth() + 1
- // Linked to: https://github.com/Hacker0x01/react-datepicker/issues/3834
- const ReactDatePicker =
- (DatePicker as unknown as { default: typeof DatePicker }).default ??
- DatePicker
+ return new Date().getMonth() + 1
+ }, [endDate, selectsRange, startDate, value])
- const localeCode =
- (typeof locale === 'string' ? locale : locale?.code) ?? 'en-GB'
+ const defaultYearToShow = useMemo(() => {
+ if (value) return new Date(value).getFullYear()
+ if (startDate && selectsRange) return startDate.getFullYear()
+ if (endDate && selectsRange) return endDate.getFullYear()
- if (typeof locale === 'object') {
- registerLocale(localeCode, locale)
- }
+ return new Date().getFullYear()
+ }, [endDate, selectsRange, startDate, value])
- const valueStart = `${
- startDate !== undefined && startDate !== null
- ? `${format(startDate)} -`
- : ''
- }`
- const valueEnd = `${
- endDate !== undefined && endDate !== null ? format(endDate) : ''
- }`
+ const [computedValue, setValue] = useState(
+ value && !selectsRange ? new Date(value) : null,
+ )
+ const [computedRange, setRange] = useState({
+ start: startDate ?? null,
+ end: endDate ?? null,
+ })
+ const [isPopupVisible, setVisible] = useState(false)
+ const [monthToShow, setMonthToShow] = useState(defaultMonthToShow)
+ const [yearToShow, setYearToShow] = useState(defaultYearToShow)
+ const [inputValue, setInputValue] = useState(
+ formatValue(
+ computedValue,
+ computedRange,
+ showMonthYearPicker,
+ selectsRange,
+ format,
+ ),
+ )
+ const refInput = useRef(null)
+ const MONTHS = getMonths(locale)
+ const DAYS = getDays(locale)
+ const MONTHS_ARR = getLocalizedMonths(locale)
+
+ const valueContext = useMemo(
+ () =>
+ ({
+ showMonthYearPicker,
+ disabled,
+ value: computedValue,
+ range: computedRange,
+ setRange,
+ setValue,
+ monthToShow,
+ yearToShow,
+ setMonthToShow,
+ setYearToShow,
+ excludeDates,
+ maxDate,
+ minDate,
+ MONTHS,
+ MONTHS_ARR,
+ DAYS,
+ onChange,
+ selectsRange,
+ format,
+ setInputValue,
+ setVisible,
+ }) as ContextProps,
+ [
+ showMonthYearPicker,
+ disabled,
+ selectsRange,
+ computedValue,
+ computedRange,
+ monthToShow,
+ yearToShow,
+ excludeDates,
+ maxDate,
+ minDate,
+ MONTHS,
+ MONTHS_ARR,
+ DAYS,
+ onChange,
+ format,
+ setInputValue,
+ setVisible,
+ ],
+ )
- const valueFormat = selectsRange
- ? `${valueStart} ${valueEnd}`
- : format(value as Date)
+ useEffect(() => {
+ if (value && !selectsRange) {
+ setValue(new Date(value))
+ setInputValue(
+ formatValue(
+ new Date(value),
+ null,
+ showMonthYearPicker,
+ selectsRange,
+ format,
+ ),
+ )
+ }
+ if (selectsRange) {
+ setRange({
+ start: startDate ?? computedRange.start,
+ end: endDate ?? computedRange.end,
+ })
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [endDate, startDate, value])
+
+ const manageOnChange = (event: ChangeEvent) => {
+ const newValue = event.currentTarget.value
+
+ if (selectsRange) {
+ const [startDateInput, endDateInput] = newValue.split(' - ').map(val => {
+ if (showMonthYearPicker) {
+ // Force YYYY/MM (since MM/YYYY not recognised as a date in typescript)
+ const res = val.split(/\D+/).map(aa => parseInt(aa, 10))
+
+ return new Date(Math.max(...res), Math.min(...res) - 1)
+ }
+
+ return new Date(val)
+ })
+
+ const computedNewRange: [Date | null, Date | null] = [
+ startDateInput instanceof Date &&
+ !Number.isNaN(startDateInput.getTime())
+ ? startDateInput
+ : null,
+ endDateInput instanceof Date && !Number.isNaN(endDateInput.getTime())
+ ? endDateInput
+ : null,
+ ]
+
+ setRange({ start: computedNewRange[0], end: computedNewRange[1] })
+ setInputValue(newValue)
+
+ if (computedNewRange[0]) {
+ setMonthToShow(computedNewRange[0].getMonth() + 1)
+ setYearToShow(computedNewRange[0].getFullYear())
+ }
+ // TypeScript fails to automatically get the correct type of onChange here
+ ;(
+ onChange as (
+ date: Date[] | [Date | null, Date | null],
+ event: React.SyntheticEvent | undefined,
+ ) => void
+ )?.(computedNewRange, event)
+ } else {
+ const computedDate = new Date(newValue)
+ setInputValue(newValue)
+
+ if (Date.parse(newValue)) {
+ setValue(computedDate)
+ setMonthToShow(computedDate.getMonth() + 1)
+ setYearToShow(computedDate.getFullYear())
+
+ // TypeScript fails to automatically get the correct type of onChange here
+ ;(
+ onChange as (date: Date | null, event?: React.SyntheticEvent) => void
+ )?.(computedDate, event)
+ }
+ }
+ }
return (
- <>
-
-
-
- }
- readOnly={readOnly}
- tooltip={tooltip}
- />
- }
- disabled={disabled}
- calendarClassName="calendar"
- minDate={minDate}
- maxDate={maxDate}
- startDate={startDate}
- endDate={endDate}
- showMonthYearPicker={showMonthYearPicker}
- dateFormat={showMonthYearPicker ? 'MM/yyyy' : undefined}
- renderCustomHeader={({
- date,
- /* eslint-disable-next-line @typescript-eslint/unbound-method */
- decreaseMonth,
- /* eslint-disable-next-line @typescript-eslint/unbound-method */
- increaseMonth,
- prevMonthButtonDisabled,
- nextMonthButtonDisabled,
- }) => (
-
-
-
- {new Date(date).toLocaleString(localeCode, {
- month: 'long',
- year: 'numeric',
- })}
-
-
+ {
+ if (!isPopupVisible) setVisible(true)
+ }}
+ >
+
+
-
- )}
- />
-
- >
+ }
+ ref={refInput}
+ tooltip={tooltip}
+ autoComplete="false"
+ onChange={manageOnChange}
+ />
+
+
+
)
}