diff --git a/src/components/datepicker/html/DatepickerField.tsx b/src/components/datepicker/html/DatepickerField.tsx new file mode 100644 index 000000000..eef92ab34 --- /dev/null +++ b/src/components/datepicker/html/DatepickerField.tsx @@ -0,0 +1,58 @@ +import classNames from 'classnames'; +import type { ChangeEventHandler } from 'react'; + +type Props = { + id: string; + label: string; + description: string; + max?: number; + onChange: (value: number) => void; + value?: number; + readOnly?: boolean; + disabled?: boolean; +}; + +export function DatepickerField({ + label, + id, + description, + onChange, + value, + max, + readOnly, + disabled, +}: Props) { + const handleChange: ChangeEventHandler = (e) => { + if ( + !Number.isNaN(e.target.valueAsNumber) && + ((max && e.target.valueAsNumber > max) || e.target.valueAsNumber < 1) + ) { + return; + } + onChange(e.target.valueAsNumber); + }; + return ( +
+ + e.target.select()} + /> +
+ ); +} diff --git a/src/components/datepicker/html/__snapshots__/datepicker.spec.tsx.snap b/src/components/datepicker/html/__snapshots__/datepicker.spec.tsx.snap index c77f3d3bf..cffc653a9 100644 --- a/src/components/datepicker/html/__snapshots__/datepicker.spec.tsx.snap +++ b/src/components/datepicker/html/__snapshots__/datepicker.spec.tsx.snap @@ -1,34 +1,226 @@ // Vitest Snapshot v1 -exports[`Datepicker > renders without crashing 1`] = ` +exports[`Datepicker > should render properly with format YYYY 1`] = `
- +
+
+ + +
+
+ + +
+
+ + +
+
`; -exports[`Datepicker > should handle readOnly 1`] = ` +exports[`Datepicker > should render properly with format YYYY-MM 1`] = `
- +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+`; + +exports[`Datepicker > should render properly with format YYYY-MM-DD 1`] = ` +
+
+
+
+ + +
+
+ + +
+
+ + +
+
`; diff --git a/src/components/datepicker/html/datepicker-container.tsx b/src/components/datepicker/html/datepicker-container.tsx deleted file mode 100644 index b037d6ee9..000000000 --- a/src/components/datepicker/html/datepicker-container.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import React, { type PropsWithChildren } from 'react'; - -function DatepickerContainer({ children }: PropsWithChildren) { - return
{children}
; -} - -export default DatepickerContainer; diff --git a/src/components/datepicker/html/datepicker-input.tsx b/src/components/datepicker/html/datepicker-input.tsx deleted file mode 100644 index 64400c1b4..000000000 --- a/src/components/datepicker/html/datepicker-input.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { type ChangeEventHandler } from 'react'; -import classnames from 'classnames'; - -export type Props = { - labelId: string; - id?: string; - disabled?: boolean; - readOnly?: boolean; - value?: string; - onChange?: ChangeEventHandler; - min?: string; - max?: string; -}; - -function DatepickerInput({ - id, - disabled, - readOnly, - labelId, - value, - onChange, - min, - max, -}: Props) { - return ( - - ); -} - -export default DatepickerInput; diff --git a/src/components/datepicker/html/datepicker.scss b/src/components/datepicker/html/datepicker.scss index 440d7ca14..39881bd8c 100644 --- a/src/components/datepicker/html/datepicker.scss +++ b/src/components/datepicker/html/datepicker.scss @@ -1 +1,19 @@ -// see input.scss +.lunaticDatepickerFields { + display: flex; + align-items: flex-end; + gap: 1rem; +} + +.lunaticDatepickerField input { + width: 2.5em; +} + +.lunaticDatepickerFieldLarge input { + width: 4.5em; +} + +.lunaticDatepickerHint { + display: block; + font-weight: 400; + font-size: .9em; +} diff --git a/src/components/datepicker/html/datepicker.spec.tsx b/src/components/datepicker/html/datepicker.spec.tsx index 5d852ddc6..8121c907d 100644 --- a/src/components/datepicker/html/datepicker.spec.tsx +++ b/src/components/datepicker/html/datepicker.spec.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import Datepicker from './datepicker'; @@ -9,8 +9,22 @@ describe('Datepicker', () => { mockOnChange.mockClear(); }); - it('renders without crashing', () => { - const { container } = render( + ['YYYY-MM-DD', 'YYYY-MM', 'YYYY'].forEach((format) => { + it('should render properly with format ' + format, () => { + const { container } = render( + + ); + expect(container).toMatchSnapshot(); + }); + }); + + it('handle change correctly for format YYYY-MM-DD', () => { + render( { onChange={mockOnChange} /> ); - expect(container).toMatchSnapshot(); + fireEvent.change(screen.getByLabelText(/Année/), { + target: { valueAsNumber: 2023 }, + }); + expect(mockOnChange).toHaveBeenLastCalledWith('2023-01-01'); + fireEvent.change(screen.getByLabelText(/Mois/), { + target: { valueAsNumber: 2 }, + }); + fireEvent.change(screen.getByLabelText(/Jour/), { + target: { valueAsNumber: 30 }, + }); + expect(mockOnChange).toHaveBeenLastCalledWith(null); }); - it('should handle readOnly', () => { - const { container } = render( + it('handle change correctly for format YYYY-MM', () => { + render( ); - expect(container).toMatchSnapshot(); + fireEvent.change(screen.getByLabelText(/Année/), { + target: { valueAsNumber: 2023 }, + }); + expect(mockOnChange).toHaveBeenLastCalledWith('2023-01'); + fireEvent.change(screen.getByLabelText(/Mois/), { + target: { valueAsNumber: 10 }, + }); + expect(mockOnChange).toHaveBeenLastCalledWith('2023-10'); + }); - const input = container.querySelector('input[type="date"]'); - expect(input).toHaveAttribute('readonly'); - (input as HTMLElement).focus(); - expect(input).toHaveFocus(); - expect(input).toHaveValue('1980-01-19'); + it('handle change correctly for year YYYY', () => { + render( + + ); + fireEvent.change(screen.getByLabelText(/Année/), { + target: { valueAsNumber: 2023 }, + }); + expect(mockOnChange).toHaveBeenLastCalledWith('2023'); }); }); diff --git a/src/components/datepicker/html/datepicker.tsx b/src/components/datepicker/html/datepicker.tsx index 5914ed767..42a62483c 100644 --- a/src/components/datepicker/html/datepicker.tsx +++ b/src/components/datepicker/html/datepicker.tsx @@ -1,9 +1,13 @@ -import { type ChangeEventHandler, type ReactNode, useCallback } from 'react'; +import { + type ChangeEventHandler, + type ReactNode, + useCallback, + useState, +} from 'react'; import { createCustomizableLunaticField, Errors, Label } from '../../commons'; -import DatepickerInput from './datepicker-input'; -import DatepickerContainer from './datepicker-container'; import './datepicker.scss'; import type { LunaticError } from '../../../use-lunatic/type'; +import { DatepickerField } from './DatepickerField'; type Props = { label?: ReactNode; @@ -15,13 +19,15 @@ type Props = { max?: string; id?: string; value?: string; - onChange: (s: string) => void; + onChange: (s: string | null) => void; + format?: string; }; function Datepicker({ disabled, readOnly, value = '', + format = 'YYYY-MM-DD', onChange, id, min, @@ -31,19 +37,83 @@ function Datepicker({ description, }: Props) { const labelId = `lunatic-datepicker-${id}`; - const handleChange = useCallback>( - function (e) { - const value = e.target.value; - onChange(value); - }, - [onChange] - ); + const showDay = format.includes('DD'); + const showMonth = format.includes('MM'); + + // Raw state, we allow invalid dates to be typed + const [numbers, setNumbers] = useState(() => numbersFromDateString(value)); + const setNumber = (index: number) => (value: number) => { + const newNumbers = [...numbers] as typeof numbers; + newNumbers[index] = value; + setNumbers(newNumbers); + handleChange(newNumbers); + }; + + const handleChange = (numbers: [number, number, number]) => { + const formatParts = format.split('-'); + const hasNaNIndex = numbers.findIndex((v) => Number.isNaN(v)); + + // Date has a missing part + if (hasNaNIndex > -1 && hasNaNIndex <= formatParts.length - 1) { + onChange(null); + return; + } + + // Date is not valid + if (format === 'YYYY-MM-DD' && !isDateValid(numbers)) { + onChange(null); + return; + } + + const result = formatParts + .map((v, k) => numbers[k].toString().padStart(v.length, '0')) + .join('-'); + onChange(result); + }; + + const extraProps = { + readOnly, + disabled, + }; return ( - +
+
+ {showDay && ( + + )} + {showMonth && ( + + )} + +
+ {/* + */} - +
+ ); +} + +function numbersFromDateString(s?: string): [number, number, number] { + if (!s) { + return [NaN, NaN, NaN]; + } + const parts = s.split('-'); + return [ + parseInt(parts[0], 10), + parseInt(parts[1], 10), + parseInt(parts[2], 10), + ]; +} + +function isDateValid(dateArray: [number, number, number]) { + const [year, month, day] = dateArray; + const date = new Date(year, month - 1, day); + + return ( + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day ); } diff --git a/src/components/datepicker/lunatic-datepicker.tsx b/src/components/datepicker/lunatic-datepicker.tsx index 7f96951c4..e24342adb 100644 --- a/src/components/datepicker/lunatic-datepicker.tsx +++ b/src/components/datepicker/lunatic-datepicker.tsx @@ -22,6 +22,7 @@ const LunaticDatepicker = (props: LunaticComponentProps<'Datepicker'>) => { missing, missingResponse, management, + format, } = props; const onChange = useOnHandleChange({ handleChange, response, value }); @@ -39,6 +40,7 @@ const LunaticDatepicker = (props: LunaticComponentProps<'Datepicker'>) => { handleChange={handleChange} > & { + format: 'YYYY-MM-DD' | 'YYYY-MM' | 'YYYY'; min?: string; max?: string; response: { name: string };