diff --git a/src/DateTimeInput.spec.jsx b/src/DateTimeInput.spec.tsx similarity index 96% rename from src/DateTimeInput.spec.jsx rename to src/DateTimeInput.spec.tsx index a92ebf4d..9f85115d 100644 --- a/src/DateTimeInput.spec.jsx +++ b/src/DateTimeInput.spec.tsx @@ -26,7 +26,7 @@ describe('DateTimeInput', () => { className: 'react-datetime-picker__inputGroup', }; - let user; + let user: ReturnType; beforeEach(() => { user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi), @@ -494,7 +494,7 @@ describe('DateTimeInput', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; + const monthInput = customInputs[0] as HTMLInputElement; const dayInput = customInputs[1]; await user.type(monthInput, '{arrowright}'); @@ -506,14 +506,14 @@ describe('DateTimeInput', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; + const monthInput = customInputs[0] as HTMLInputElement; const dayInput = customInputs[1]; const separators = container.querySelectorAll('.react-datetime-picker__inputGroup__divider'); const separatorsTexts = Array.from(separators) - .map((el) => el.textContent) + .map((el) => el.textContent as string) .filter((el) => el.trim()); - const separatorKey = separatorsTexts[0]; + const separatorKey = separatorsTexts[0] as string; await user.type(monthInput, separatorKey); @@ -524,14 +524,14 @@ describe('DateTimeInput', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; + const monthInput = customInputs[0] as HTMLInputElement; const dayInput = customInputs[1]; const separators = container.querySelectorAll('.react-datetime-picker__inputGroup__divider'); const separatorsTexts = Array.from(separators) - .map((el) => el.textContent) + .map((el) => el.textContent as string) .filter((el) => el.trim()); - const separatorKey = separatorsTexts[separatorsTexts.length - 1]; + const separatorKey = separatorsTexts[separatorsTexts.length - 1] as string; await user.type(monthInput, separatorKey); @@ -541,7 +541,7 @@ describe('DateTimeInput', () => { it('does not jump to the next field when right arrow is pressed when the last input is focused', async () => { const { container } = render(); - const select = container.querySelector('select'); + const select = container.querySelector('select') as HTMLSelectElement; await user.type(select, '{arrowright}'); @@ -553,7 +553,7 @@ describe('DateTimeInput', () => { const customInputs = container.querySelectorAll('input[data-input]'); const monthInput = customInputs[0]; - const dayInput = customInputs[1]; + const dayInput = customInputs[1] as HTMLInputElement; await user.type(dayInput, '{arrowleft}'); @@ -564,7 +564,7 @@ describe('DateTimeInput', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; + const monthInput = customInputs[0] as HTMLInputElement; await user.type(monthInput, '{arrowleft}'); @@ -575,7 +575,7 @@ describe('DateTimeInput', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; + const monthInput = customInputs[0] as HTMLInputElement; const dayInput = customInputs[1]; await user.type(monthInput, '4'); @@ -587,7 +587,7 @@ describe('DateTimeInput', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; + const monthInput = customInputs[0] as HTMLInputElement; const dayInput = customInputs[1]; await user.type(monthInput, '03'); @@ -599,7 +599,7 @@ describe('DateTimeInput', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; + const monthInput = customInputs[0] as HTMLInputElement; await user.type(monthInput, '1'); @@ -615,7 +615,7 @@ describe('DateTimeInput', () => { ); const customInputs = container.querySelectorAll('input[data-input]'); - const hourInput = customInputs[3]; + const hourInput = customInputs[3] as HTMLInputElement; fireEvent.change(hourInput, { target: { value: '8' } }); @@ -634,7 +634,7 @@ describe('DateTimeInput', () => { ); const customInputs = container.querySelectorAll('input[data-input]'); - const hourInput = customInputs[3]; + const hourInput = customInputs[3] as HTMLInputElement; fireEvent.change(hourInput, { target: { value: '8' } }); @@ -655,7 +655,7 @@ describe('DateTimeInput', () => { ); const customInputs = container.querySelectorAll('input[data-input]'); - const hourInput = customInputs[2]; + const hourInput = customInputs[2] as HTMLInputElement; fireEvent.change(hourInput, { target: { value: '20' } }); @@ -691,7 +691,7 @@ describe('DateTimeInput', () => { , ); - const nativeInput = container.querySelector('input[type="datetime-local"]'); + const nativeInput = container.querySelector('input[type="datetime-local"]') as HTMLInputElement; fireEvent.change(nativeInput, { target: { value: '2017-09-30T20:17:00' } }); @@ -709,7 +709,7 @@ describe('DateTimeInput', () => { , ); - const nativeInput = container.querySelector('input[type="datetime-local"]'); + const nativeInput = container.querySelector('input[type="datetime-local"]') as HTMLInputElement; fireEvent.change(nativeInput, { target: { value: '0019-09-20T20:17:00' } }); @@ -729,7 +729,7 @@ describe('DateTimeInput', () => { , ); - const nativeInput = container.querySelector('input[type="datetime-local"]'); + const nativeInput = container.querySelector('input[type="datetime-local"]') as HTMLInputElement; fireEvent.change(nativeInput, { target: { value: '' } }); diff --git a/src/DateTimeInput.jsx b/src/DateTimeInput.tsx similarity index 76% rename from src/DateTimeInput.jsx rename to src/DateTimeInput.tsx index 130af187..c06efe2d 100644 --- a/src/DateTimeInput.jsx +++ b/src/DateTimeInput.tsx @@ -27,15 +27,17 @@ import { convert12to24, convert24to12 } from './shared/dates'; import { isMaxDate, isMinDate } from './shared/propTypes'; import { between, getAmPmLabels } from './shared/utils'; -const getFormatterOptionsCache = {}; +import type { AmPmType, Detail } from './shared/types'; + +const getFormatterOptionsCache: Record = {}; const defaultMinDate = new Date(); defaultMinDate.setFullYear(1, 0, 1); defaultMinDate.setHours(0, 0, 0, 0); const defaultMaxDate = new Date(8.64e15); -const allViews = ['hour', 'minute', 'second']; +const allViews = ['hour', 'minute', 'second'] as const; -function toDate(value) { +function toDate(value: Date | string): Date { if (value instanceof Date) { return value; } @@ -43,7 +45,7 @@ function toDate(value) { return new Date(value); } -function isSameDate(date, year, month, day) { +function isSameDate(date: Date, year: string | null, month: string | null, day: string | null) { return ( year === getYear(date).toString() && month === getMonthHuman(date).toString() && @@ -51,12 +53,11 @@ function isSameDate(date, year, month, day) { ); } -function getValue(value, index) { - if (!value) { - return null; - } - - const rawValue = Array.isArray(value) && value.length === 2 ? value[index] : value; +function getValue( + value: string | Date | null | undefined | (string | Date | null | undefined)[], + index: 0 | 1, +): Date | null { + const rawValue = Array.isArray(value) ? value[index] : value; if (!rawValue) { return null; @@ -71,7 +72,13 @@ function getValue(value, index) { return valueDate; } -function getDetailValue({ value, minDate, maxDate }, index) { +type DetailArgs = { + value?: string | Date | null; + minDate?: Date; + maxDate?: Date; +}; + +function getDetailValue({ value, minDate, maxDate }: DetailArgs, index: 0 | 1) { const valuePiece = getValue(value, index); if (!valuePiece) { @@ -81,28 +88,37 @@ function getDetailValue({ value, minDate, maxDate }, index) { return between(valuePiece, minDate, maxDate); } -const getDetailValueFrom = (args) => getDetailValue(args, 0); +const getDetailValueFrom = (args: DetailArgs) => getDetailValue(args, 0); -function isInternalInput(element) { +function isInternalInput(element: HTMLElement) { return element.dataset.input === 'true'; } -function findInput(element, property) { - let nextElement = element; +function findInput( + element: HTMLElement, + property: 'previousElementSibling' | 'nextElementSibling', +) { + let nextElement: HTMLElement | null = element; do { - nextElement = nextElement[property]; + nextElement = nextElement[property] as HTMLElement | null; } while (nextElement && !isInternalInput(nextElement)); return nextElement; } -function focus(element) { +function focus(element?: HTMLElement | null) { if (element) { element.focus(); } } -function renderCustomInputs(placeholder, elementFunctions, allowMultipleInstances) { - const usedFunctions = []; +type RenderFunction = (match: string, index: number) => React.ReactNode; + +function renderCustomInputs( + placeholder: string, + elementFunctions: Record, + allowMultipleInstances: boolean, +) { + const usedFunctions: RenderFunction[] = []; const pattern = new RegExp( Object.keys(elementFunctions) .map((el) => `${el}+`) @@ -111,7 +127,7 @@ function renderCustomInputs(placeholder, elementFunctions, allowMultipleInstance ); const matches = placeholder.match(pattern); - return placeholder.split(pattern).reduce((arr, element, index) => { + return placeholder.split(pattern).reduce((arr, element, index) => { const divider = element && ( // eslint-disable-next-line react/no-array-index-key {element} @@ -125,7 +141,7 @@ function renderCustomInputs(placeholder, elementFunctions, allowMultipleInstance elementFunctions[ Object.keys(elementFunctions).find((elementFunction) => currentMatch.match(elementFunction), - ) + ) as string ]; if (!renderFunction) { @@ -146,6 +162,37 @@ function renderCustomInputs(placeholder, elementFunctions, allowMultipleInstance const formatNumber = getNumberFormatter({ useGrouping: false }); +type DateTimeInputProps = { + amPmAriaLabel?: string; + autoFocus?: boolean; + className: string; + dayAriaLabel?: string; + dayPlaceholder?: string; + disabled?: boolean; + format?: string; + hourAriaLabel?: string; + hourPlaceholder?: string; + isWidgetOpen?: boolean | null; + locale?: string; + maxDate?: Date; + maxDetail?: Detail; + minDate?: Date; + minuteAriaLabel?: string; + minutePlaceholder?: string; + monthAriaLabel?: string; + monthPlaceholder?: string; + name?: string; + nativeInputAriaLabel?: string; + onChange?: (value: Date | null, shouldCloseWidgets: boolean) => void; + required?: boolean; + secondAriaLabel?: string; + secondPlaceholder?: string; + showLeadingZeros?: boolean; + value?: string | Date | null; + yearAriaLabel?: string; + yearPlaceholder?: string; +}; + export default function DateTimeInput({ amPmAriaLabel, autoFocus, @@ -158,9 +205,9 @@ export default function DateTimeInput({ hourPlaceholder, isWidgetOpen: isWidgetOpenProps, locale, - maxDate, + maxDate = defaultMaxDate, maxDetail = 'minute', - minDate, + minDate = defaultMinDate, minuteAriaLabel, minutePlaceholder, monthAriaLabel, @@ -175,24 +222,24 @@ export default function DateTimeInput({ value: valueProps, yearAriaLabel, yearPlaceholder, -}) { - const [amPm, setAmPm] = useState(null); - const [year, setYear] = useState(null); - const [month, setMonth] = useState(null); - const [day, setDay] = useState(null); - const [hour, setHour] = useState(null); - const [minute, setMinute] = useState(null); - const [second, setSecond] = useState(null); - const [value, setValue] = useState(null); - const amPmInput = useRef(); - const yearInput = useRef(); - const monthInput = useRef(); - const monthSelect = useRef(); - const dayInput = useRef(); - const hour12Input = useRef(); - const hour24Input = useRef(); - const minuteInput = useRef(); - const secondInput = useRef(); +}: DateTimeInputProps) { + const [amPm, setAmPm] = useState(); + const [year, setYear] = useState(null); + const [month, setMonth] = useState(null); + const [day, setDay] = useState(null); + const [hour, setHour] = useState(null); + const [minute, setMinute] = useState(null); + const [second, setSecond] = useState(null); + const [value, setValue] = useState(null); + const amPmInput = useRef(null); + const yearInput = useRef(null); + const monthInput = useRef(null); + const monthSelect = useRef(null); + const dayInput = useRef(null); + const hour12Input = useRef(null); + const hour24Input = useRef(null); + const minuteInput = useRef(null); + const secondInput = useRef(null); const [isWidgetOpen, setIsWidgetOpenOpen] = useState(isWidgetOpenProps); useEffect(() => { @@ -214,6 +261,7 @@ export default function DateTimeInput({ setHour(getHours(nextValue).toString()); setMinute(getMinutes(nextValue).toString()); setSecond(getSeconds(nextValue).toString()); + setValue(toDate(nextValue)); } else { setAmPm(null); setYear(null); @@ -222,8 +270,8 @@ export default function DateTimeInput({ setHour(null); setMinute(null); setSecond(null); + setValue(null); } - setValue(nextValue); }, [ valueProps, minDate, @@ -239,7 +287,7 @@ export default function DateTimeInput({ const formatterOptions = getFormatterOptionsCache[level] || (() => { - const options = { hour: 'numeric' }; + const options: Intl.DateTimeFormatOptions = { hour: 'numeric' }; if (level >= 1) { options.minute = 'numeric'; } @@ -263,10 +311,10 @@ export default function DateTimeInput({ const date = new Date(year, monthIndex, day); const formattedDate = formatDate(locale, date); - const datePieces = ['year', 'month', 'day']; + const datePieces = ['year', 'month', 'day'] as const; const datePieceReplacements = ['y', 'M', 'd']; - function formatDatePiece(name, dateToFormat) { + function formatDatePiece(name: keyof Intl.DateTimeFormatOptions, dateToFormat: Date) { const formatterOptions = getFormatterOptionsCache[name] || (() => { @@ -286,7 +334,7 @@ export default function DateTimeInput({ if (match) { const formattedDatePiece = match[0]; - const datePieceReplacement = datePieceReplacements[index]; + const datePieceReplacement = datePieceReplacements[index] as string; placeholder = placeholder.replace(formattedDatePiece, datePieceReplacement); } }); @@ -347,15 +395,19 @@ export default function DateTimeInput({ return getHoursMinutesSeconds(minDate || defaultMinDate); })(); - function onClick(event) { + function onClick(event: React.MouseEvent & { target: HTMLDivElement }) { if (event.target === event.currentTarget) { // Wrapper was directly clicked - const firstInput = event.target.children[1]; + const firstInput = event.target.children[1] as HTMLInputElement; focus(firstInput); } } - function onKeyDown(event) { + function onKeyDown( + event: + | (React.KeyboardEvent & { target: HTMLInputElement }) + | (React.KeyboardEvent & { target: HTMLSelectElement }), + ) { switch (event.key) { case 'ArrowLeft': case 'ArrowRight': @@ -374,7 +426,7 @@ export default function DateTimeInput({ } } - function onKeyUp(event) { + function onKeyUp(event: React.KeyboardEvent & { target: HTMLInputElement }) { const { key, target: input } = event; const isNumberKey = !isNaN(Number(key)); @@ -413,6 +465,12 @@ export default function DateTimeInput({ return; } + type NonFalsy = T extends false | 0 | '' | null | undefined | 0n ? never : T; + + function filterBoolean(value: T): value is NonFalsy { + return Boolean(value); + } + const formElements = [ amPmInput.current, dayInput.current, @@ -423,11 +481,13 @@ export default function DateTimeInput({ hour24Input.current, minuteInput.current, secondInput.current, - ].filter(Boolean); + ].filter(filterBoolean); const formElementsWithoutSelect = formElements.slice(1); - const values = {}; + const values: Record & { + amPm?: AmPmType; + } = {}; formElements.forEach((formElement) => { values[formElement.name] = formElement.type === 'number' @@ -445,7 +505,11 @@ export default function DateTimeInput({ const year = Number(values.year || new Date().getFullYear()); const monthIndex = Number(values.month || 1) - 1; const day = Number(values.day || 1); - const hour = Number(values.hour24 || convert12to24(values.hour12, values.amPm) || 0); + const hour = Number( + values.hour24 || + (values.hour12 && values.amPm && convert12to24(values.hour12, values.amPm)) || + 0, + ); const minute = Number(values.minute || 0); const second = Number(values.second || 0); @@ -460,12 +524,12 @@ export default function DateTimeInput({ /** * Called when non-native date input is changed. */ - function onChange(event) { + function onChange(event: React.ChangeEvent) { const { name, value } = event.target; switch (name) { case 'amPm': - setAmPm(value); + setAmPm(value as AmPmType); break; case 'year': setYear(value); @@ -477,7 +541,7 @@ export default function DateTimeInput({ setDay(value); break; case 'hour12': - setHour(value ? convert12to24(Number(value), amPm).toString() : ''); + setHour(value ? convert12to24(value, amPm || 'am').toString() : ''); break; case 'hour24': setHour(value); @@ -496,7 +560,7 @@ export default function DateTimeInput({ /** * Called when native date input is changed. */ - function onChangeNative(event) { + function onChangeNative(event: React.ChangeEvent) { const { value } = event.target; if (!onChangeProps) { @@ -508,14 +572,18 @@ export default function DateTimeInput({ return null; } - const [valueDate, valueTime] = value.split('T'); + const [valueDate, valueTime] = value.split('T') as [string, string]; - const [yearString, monthString, dayString] = valueDate.split('-'); + const [yearString, monthString, dayString] = valueDate.split('-') as [string, string, string]; const year = Number(yearString); const monthIndex = Number(monthString) - 1 || 0; const day = Number(dayString) || 1; - const [hourString, minuteString, secondString] = valueTime.split(':'); + const [hourString, minuteString, secondString] = valueTime.split(':') as [ + string, + string, + string, + ]; const hour = Number(hourString) || 0; const minute = Number(minuteString) || 0; const second = Number(secondString) || 0; @@ -547,7 +615,7 @@ export default function DateTimeInput({ minTime, }; - function renderDay(currentMatch, index) { + function renderDay(currentMatch: string, index: number) { if (currentMatch && currentMatch.length > 2) { throw new Error(`Unsupported token: ${currentMatch}`); } @@ -571,7 +639,7 @@ export default function DateTimeInput({ ); } - function renderMonth(currentMatch, index) { + function renderMonth(currentMatch: string, index: number) { if (currentMatch && currentMatch.length > 4) { throw new Error(`Unsupported token: ${currentMatch}`); } @@ -612,7 +680,7 @@ export default function DateTimeInput({ ); } - function renderYear(currentMatch, index) { + function renderYear(currentMatch: string, index: number) { return ( 2) { throw new Error(`Unsupported token: ${currentMatch}`); } - const showLeadingZeros = currentMatch && currentMatch.length === 2; + const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false; return ( 2) { throw new Error(`Unsupported token: ${currentMatch}`); } - const showLeadingZeros = currentMatch && currentMatch.length === 2; + const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false; return ( 2) { throw new Error(`Unsupported token: ${currentMatch}`); } - const showLeadingZeros = currentMatch && currentMatch.length === 2; + const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false; return ( 2) { throw new Error(`Unsupported token: ${currentMatch}`); } @@ -732,7 +800,7 @@ export default function DateTimeInput({ ); } - function renderAmPm(currentMatch, index) { + function renderAmPm(currentMatch: string, index: number) { return ( { // Intentionally empty }, valueType: 'second', - }; + } satisfies React.ComponentProps; it('renders an input', () => { const { container } = render(); diff --git a/src/DateTimeInput/NativeInput.jsx b/src/DateTimeInput/NativeInput.tsx similarity index 72% rename from src/DateTimeInput/NativeInput.jsx rename to src/DateTimeInput/NativeInput.tsx index 7bef6ab3..6d9ce3f4 100644 --- a/src/DateTimeInput/NativeInput.jsx +++ b/src/DateTimeInput/NativeInput.tsx @@ -9,6 +9,18 @@ import { import { isMaxDate, isMinDate, isValueType } from '../shared/propTypes'; +type NativeInputProps = { + ariaLabel?: string; + disabled?: boolean; + maxDate?: Date; + minDate?: Date; + name?: string; + onChange?: (event: React.ChangeEvent) => void; + required?: boolean; + value?: Date | null; + valueType: 'hour' | 'minute' | 'second'; +}; + export default function NativeInput({ ariaLabel, disabled, @@ -19,13 +31,14 @@ export default function NativeInput({ required, value, valueType, -}) { +}: NativeInputProps) { const nativeValueParser = (() => { switch (valueType) { case 'hour': - return (receivedValue) => `${getISOLocalDate(receivedValue)}T${getHours(receivedValue)}:00`; + return (receivedValue: Date) => + `${getISOLocalDate(receivedValue)}T${getHours(receivedValue)}:00`; case 'minute': - return (receivedValue) => + return (receivedValue: Date) => `${getISOLocalDate(receivedValue)}T${getHoursMinutes(receivedValue)}`; case 'second': return getISOLocalDateTime; @@ -47,7 +60,7 @@ export default function NativeInput({ } })(); - function stopPropagation(event) { + function stopPropagation(event: React.FocusEvent) { event.stopPropagation(); } @@ -56,8 +69,8 @@ export default function NativeInput({ aria-label={ariaLabel} disabled={disabled} hidden - max={maxDate ? nativeValueParser(maxDate) : null} - min={minDate ? nativeValueParser(minDate) : null} + max={maxDate ? nativeValueParser(maxDate) : undefined} + min={minDate ? nativeValueParser(minDate) : undefined} name={name} onChange={onChange} onFocus={stopPropagation} diff --git a/src/DateTimePicker.spec.jsx b/src/DateTimePicker.spec.tsx similarity index 94% rename from src/DateTimePicker.spec.jsx rename to src/DateTimePicker.spec.tsx index 2cd882de..12f9c82e 100644 --- a/src/DateTimePicker.spec.jsx +++ b/src/DateTimePicker.spec.tsx @@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event'; import DateTimePicker from './DateTimePicker'; -async function waitForElementToBeRemovedOrHidden(callback) { +async function waitForElementToBeRemovedOrHidden(callback: () => HTMLElement | null) { const element = callback(); if (element) { @@ -222,7 +222,9 @@ describe('DateTimePicker', () => { it('renders clear icon by default when clearIcon is not given', () => { const { container } = render(); - const clearButton = container.querySelector('button.react-datetime-picker__clear-button'); + const clearButton = container.querySelector( + 'button.react-datetime-picker__clear-button', + ) as HTMLButtonElement; const clearIcon = clearButton.querySelector('svg'); @@ -239,7 +241,7 @@ describe('DateTimePicker', () => { it('renders clear icon when given clearIcon as a React element', () => { function ClearIcon() { - return '❌'; + return <>❌; } const { container } = render(} />); @@ -251,7 +253,7 @@ describe('DateTimePicker', () => { it('renders clear icon when given clearIcon as a function', () => { function ClearIcon() { - return '❌'; + return <>❌; } const { container } = render(); @@ -278,7 +280,7 @@ describe('DateTimePicker', () => { const calendarButton = container.querySelector( 'button.react-datetime-picker__calendar-button', - ); + ) as HTMLButtonElement; const calendarIcon = calendarButton.querySelector('svg'); @@ -297,7 +299,7 @@ describe('DateTimePicker', () => { it('renders calendar icon when given calendarIcon as a React element', () => { function CalendarIcon() { - return '📅'; + return <>📅; } const { container } = render(} />); @@ -311,7 +313,7 @@ describe('DateTimePicker', () => { it('renders calendar icon when given calendarIcon as a function', () => { function CalendarIcon() { - return '📅'; + return <>📅; } const { container } = render(); @@ -388,7 +390,9 @@ describe('DateTimePicker', () => { const { container } = render(); const calendar = container.querySelector('.react-calendar'); - const button = container.querySelector('button.react-datetime-picker__calendar-button'); + const button = container.querySelector( + 'button.react-datetime-picker__calendar-button', + ) as HTMLButtonElement; expect(calendar).toBeFalsy(); @@ -404,7 +408,7 @@ describe('DateTimePicker', () => { const { container } = render(); const calendar = container.querySelector('.react-calendar'); - const input = container.querySelector('input[name="day"]'); + const input = container.querySelector('input[name="day"]') as HTMLInputElement; expect(calendar).toBeFalsy(); @@ -419,7 +423,7 @@ describe('DateTimePicker', () => { const { container } = render(); const calendar = container.querySelector('.react-calendar'); - const input = container.querySelector('input[name="day"]'); + const input = container.querySelector('input[name="day"]') as HTMLInputElement; expect(calendar).toBeFalsy(); @@ -434,7 +438,7 @@ describe('DateTimePicker', () => { const { container } = render(); const calendar = container.querySelector('.react-calendar'); - const input = container.querySelector('input[name="day"]'); + const input = container.querySelector('input[name="day"]') as HTMLInputElement; expect(calendar).toBeFalsy(); @@ -449,7 +453,7 @@ describe('DateTimePicker', () => { const { container } = render(); const calendar = container.querySelector('.react-calendar'); - const select = container.querySelector('select[name="month"]'); + const select = container.querySelector('select[name="month"]') as HTMLSelectElement; expect(calendar).toBeFalsy(); @@ -466,7 +470,7 @@ describe('DateTimePicker', () => { const { container } = render(); const clock = container.querySelector('.react-clock'); - const input = container.querySelector('input[name^="hour"]'); + const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; expect(clock).toBeFalsy(); @@ -481,7 +485,7 @@ describe('DateTimePicker', () => { const { container } = render(); const clock = container.querySelector('.react-clock'); - const input = container.querySelector('input[name^="hour"]'); + const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; expect(clock).toBeFalsy(); @@ -496,7 +500,7 @@ describe('DateTimePicker', () => { const { container } = render(); const clock = container.querySelector('.react-clock'); - const input = container.querySelector('input[name^="hour"]'); + const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; expect(clock).toBeFalsy(); @@ -511,7 +515,7 @@ describe('DateTimePicker', () => { const { container } = render(); const clock = container.querySelector('.react-clock'); - const select = container.querySelector('select[name="amPm"]'); + const select = container.querySelector('select[name="amPm"]') as HTMLSelectElement; expect(clock).toBeFalsy(); @@ -587,8 +591,8 @@ describe('DateTimePicker', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const monthInput = customInputs[0]; - const dayInput = customInputs[1]; + const monthInput = customInputs[0] as HTMLInputElement; + const dayInput = customInputs[1] as HTMLInputElement; fireEvent.blur(monthInput); fireEvent.focus(dayInput); @@ -602,8 +606,8 @@ describe('DateTimePicker', () => { const { container } = render(); const customInputs = container.querySelectorAll('input[data-input]'); - const hourInput = customInputs[3]; - const minuteInput = customInputs[4]; + const hourInput = customInputs[3] as HTMLInputElement; + const minuteInput = customInputs[4] as HTMLInputElement; fireEvent.blur(hourInput); fireEvent.focus(minuteInput); @@ -617,7 +621,9 @@ describe('DateTimePicker', () => { const { container } = render(); const clock = container.querySelector('.react-clock'); - const button = container.querySelector('button.react-datetime-picker__calendar-button'); + const button = container.querySelector( + 'button.react-datetime-picker__calendar-button', + ) as HTMLButtonElement; expect(clock).toBeInTheDocument(); @@ -631,7 +637,7 @@ describe('DateTimePicker', () => { it('closes Calendar when changing value by default', async () => { const { container } = render(); - const firstTile = container.querySelector('.react-calendar__tile'); + const firstTile = container.querySelector('.react-calendar__tile') as HTMLButtonElement; act(() => { fireEvent.click(firstTile); @@ -645,7 +651,7 @@ describe('DateTimePicker', () => { it('closes Calendar when changing value with prop closeWidgets = true', async () => { const { container } = render(); - const firstTile = container.querySelector('.react-calendar__tile'); + const firstTile = container.querySelector('.react-calendar__tile') as HTMLButtonElement; act(() => { fireEvent.click(firstTile); @@ -659,7 +665,7 @@ describe('DateTimePicker', () => { it('does not close Calendar when changing value with prop closeWidgets = false', () => { const { container } = render(); - const firstTile = container.querySelector('.react-calendar__tile'); + const firstTile = container.querySelector('.react-calendar__tile') as HTMLButtonElement; act(() => { fireEvent.click(firstTile); @@ -673,7 +679,7 @@ describe('DateTimePicker', () => { it('does not close Calendar when changing value using inputs', () => { const { container } = render(); - const dayInput = container.querySelector('input[name="day"]'); + const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; act(() => { fireEvent.change(dayInput, { target: { value: '1' } }); @@ -687,7 +693,7 @@ describe('DateTimePicker', () => { it('does not close Clock when changing value using inputs', () => { const { container } = render(); - const hourInput = container.querySelector('input[name="hour12"]'); + const hourInput = container.querySelector('input[name="hour12"]') as HTMLInputElement; act(() => { fireEvent.change(hourInput, { target: { value: '9' } }); @@ -706,7 +712,7 @@ describe('DateTimePicker', () => { , ); - const dayInput = container.querySelector('input[name="day"]'); + const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; act(() => { fireEvent.change(dayInput, { target: { value: '1' } }); @@ -730,7 +736,9 @@ describe('DateTimePicker', () => { ); // Navigate up the calendar - const drillUpButton = container.querySelector('.react-calendar__navigation__label'); + const drillUpButton = container.querySelector( + '.react-calendar__navigation__label', + ) as HTMLButtonElement; fireEvent.click(drillUpButton); // To year 2018 fireEvent.click(drillUpButton); // To 2011 – 2020 decade @@ -764,7 +772,9 @@ describe('DateTimePicker', () => { ); // Navigate up the calendar - const drillUpButton = container.querySelector('.react-calendar__navigation__label'); + const drillUpButton = container.querySelector( + '.react-calendar__navigation__label', + ) as HTMLButtonElement; fireEvent.click(drillUpButton); // To year 2018 fireEvent.click(drillUpButton); // To 2011 – 2020 decade @@ -789,7 +799,9 @@ describe('DateTimePicker', () => { const { container } = render(); const calendar = container.querySelector('.react-calendar'); - const button = container.querySelector('button.react-datetime-picker__clear-button'); + const button = container.querySelector( + 'button.react-datetime-picker__clear-button', + ) as HTMLButtonElement; expect(calendar).toBeFalsy(); diff --git a/src/DateTimePicker.jsx b/src/DateTimePicker.tsx similarity index 80% rename from src/DateTimePicker.jsx rename to src/DateTimePicker.tsx index d9e23af6..79165ca9 100644 --- a/src/DateTimePicker.jsx +++ b/src/DateTimePicker.tsx @@ -11,6 +11,8 @@ import DateTimeInput from './DateTimeInput'; import { isMaxDate, isMinDate } from './shared/propTypes'; +import type { ClassName, Detail, LooseValue } from './shared/types'; + const baseClassName = 'react-datetime-picker'; const outsideActionEvents = ['mousedown', 'focusin', 'touchstart']; const allViews = ['hour', 'minute', 'second']; @@ -45,7 +47,61 @@ const ClearIcon = ( ); -export default function DateTimePicker(props) { +type Icon = React.ReactElement | string; + +type IconOrRenderFunction = Icon | React.ComponentType | React.ReactElement; + +type DateTimePickerProps = { + amPmAriaLabel?: string; + autoFocus?: boolean; + calendarAriaLabel?: string; + calendarClassName?: ClassName; + calendarIcon?: IconOrRenderFunction; + className?: ClassName; + clearAriaLabel?: string; + clearIcon?: IconOrRenderFunction; + clockClassName?: ClassName; + closeWidgets?: boolean; + 'data-testid'?: string; + dayAriaLabel?: string; + dayPlaceholder?: string; + disableCalendar?: boolean; + disableClock?: boolean; + disabled?: boolean; + format?: string; + hourAriaLabel?: string; + hourPlaceholder?: string; + id?: string; + isCalendarOpen?: boolean; + isClockOpen?: boolean; + locale?: string; + maxDate?: Date; + maxDetail?: Detail; + minDate?: Date; + minuteAriaLabel?: string; + minutePlaceholder?: string; + monthAriaLabel?: string; + monthPlaceholder?: string; + name?: string; + nativeInputAriaLabel?: string; + onCalendarClose?: () => void; + onCalendarOpen?: () => void; + onChange?: (value: Date | null) => void; + onClockClose?: () => void; + onClockOpen?: () => void; + onFocus?: (event: React.FocusEvent) => void; + openWidgetsOnFocus?: boolean; + portalContainer?: HTMLElement; + required?: boolean; + secondAriaLabel?: string; + secondPlaceholder?: string; + showLeadingZeros?: boolean; + value?: LooseValue; + yearAriaLabel?: string; + yearPlaceholder?: string; +}; + +export default function DateTimePicker(props: DateTimePickerProps) { const { amPmAriaLabel, autoFocus, @@ -94,11 +150,11 @@ export default function DateTimePicker(props) { ...otherProps } = props; - const [isCalendarOpen, setIsCalendarOpen] = useState(isCalendarOpenProps); - const [isClockOpen, setIsClockOpen] = useState(isClockOpenProps); - const wrapper = useRef(); - const calendarWrapper = useRef(); - const clockWrapper = useRef(); + const [isCalendarOpen, setIsCalendarOpen] = useState(isCalendarOpenProps); + const [isClockOpen, setIsClockOpen] = useState(isClockOpenProps); + const wrapper = useRef(null); + const calendarWrapper = useRef(null); + const clockWrapper = useRef(null); useEffect(() => { setIsCalendarOpen(isCalendarOpenProps); @@ -155,7 +211,7 @@ export default function DateTimePicker(props) { closeClock(); }, [closeCalendar, closeClock]); - function onChange(value, shouldCloseWidgets = shouldCloseWidgetsProps) { + function onChange(value: Date | null, shouldCloseWidgets: boolean = shouldCloseWidgetsProps) { if (shouldCloseWidgets) { closeWidgets(); } @@ -165,9 +221,9 @@ export default function DateTimePicker(props) { } } - function onDateChange(nextValue, shouldCloseWidgets) { - const [nextValueFrom] = [].concat(nextValue); - const [valueFrom] = [].concat(value); + function onDateChange(nextValue: Date | null | (Date | null)[], shouldCloseWidgets?: boolean) { + const [nextValueFrom] = Array.isArray(nextValue) ? nextValue : [nextValue]; + const [valueFrom] = Array.isArray(value) ? value : [value]; if (valueFrom && nextValueFrom) { const valueFromDate = new Date(valueFrom); @@ -181,11 +237,11 @@ export default function DateTimePicker(props) { onChange(nextValueFromWithHour, shouldCloseWidgets); } else { - onChange(nextValueFrom, shouldCloseWidgets); + onChange(nextValueFrom || null, shouldCloseWidgets); } } - function onFocus(event) { + function onFocus(event: React.FocusEvent) { if (onFocusProps) { onFocusProps(event); } @@ -218,7 +274,7 @@ export default function DateTimePicker(props) { } const onKeyDown = useCallback( - (event) => { + (event: KeyboardEvent) => { if (event.key === 'Escape') { closeWidgets(); } @@ -230,18 +286,20 @@ export default function DateTimePicker(props) { onChange(null); } - function stopPropagation(event) { + function stopPropagation(event: React.FocusEvent) { event.stopPropagation(); } const onOutsideAction = useCallback( - (event) => { + (event: Event) => { const { current: wrapperEl } = wrapper; const { current: calendarWrapperEl } = calendarWrapper; const { current: clockWrapperEl } = clockWrapper; // Try event.composedPath first to handle clicks inside a Shadow DOM. - const target = 'composedPath' in event ? event.composedPath()[0] : event.target; + const target = ( + 'composedPath' in event ? event.composedPath()[0] : (event as Event).target + ) as HTMLElement; if ( target && @@ -258,13 +316,19 @@ export default function DateTimePicker(props) { const handleOutsideActionListeners = useCallback( (shouldListen = isCalendarOpen || isClockOpen) => { - const action = shouldListen ? 'addEventListener' : 'removeEventListener'; - outsideActionEvents.forEach((event) => { - document[action](event, onOutsideAction); + if (shouldListen) { + document.addEventListener(event, onOutsideAction); + } else { + document.removeEventListener(event, onOutsideAction); + } }); - document[action]('keydown', onKeyDown); + if (shouldListen) { + document.addEventListener('keydown', onKeyDown); + } else { + document.removeEventListener('keydown', onKeyDown); + } }, [isCalendarOpen, isClockOpen, onOutsideAction, onKeyDown], ); @@ -278,7 +342,7 @@ export default function DateTimePicker(props) { }, [handleOutsideActionListeners]); function renderInputs() { - const [valueFrom] = [].concat(value); + const [valueFrom] = Array.isArray(value) ? value : [value]; const ariaLabelProps = { amPmAriaLabel, @@ -371,7 +435,7 @@ export default function DateTimePicker(props) { onDateChange(value)} - value={value || null} + value={value} {...calendarProps} /> ); @@ -417,7 +481,7 @@ export default function DateTimePicker(props) { const className = `${baseClassName}__clock`; const classNames = clsx(className, `${className}--${isClockOpen ? 'open' : 'closed'}`); - const [valueFrom] = [].concat(value); + const [valueFrom] = Array.isArray(value) ? value : [value]; const maxDetailIndex = allViews.indexOf(maxDetail); diff --git a/src/Divider.jsx b/src/Divider.tsx similarity index 64% rename from src/Divider.jsx rename to src/Divider.tsx index f718eb18..18dde071 100644 --- a/src/Divider.jsx +++ b/src/Divider.tsx @@ -1,7 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -export default function Divider({ children }) { +type DividerProps = { + children: React.ReactNode; +}; + +export default function Divider({ children }: DividerProps) { return {children}; } diff --git a/src/index.js b/src/index.ts similarity index 100% rename from src/index.js rename to src/index.ts diff --git a/src/shared/dateFormatter.spec.js b/src/shared/dateFormatter.spec.ts similarity index 100% rename from src/shared/dateFormatter.spec.js rename to src/shared/dateFormatter.spec.ts diff --git a/src/shared/dateFormatter.js b/src/shared/dateFormatter.ts similarity index 59% rename from src/shared/dateFormatter.js rename to src/shared/dateFormatter.ts index 24eb3c70..63d28a9f 100644 --- a/src/shared/dateFormatter.js +++ b/src/shared/dateFormatter.ts @@ -2,8 +2,10 @@ import getUserLocale from 'get-user-locale'; const formatterCache = new Map(); -export function getFormatter(options) { - return (locale, date) => { +export function getFormatter( + options: Intl.DateTimeFormatOptions, +): (locale: string | undefined, date: Date) => string { + return function formatter(locale: string | undefined, date: Date): string { const localeWithDefault = locale || getUserLocale(); if (!formatterCache.has(localeWithDefault)) { @@ -13,7 +15,10 @@ export function getFormatter(options) { const formatterCacheLocale = formatterCache.get(localeWithDefault); if (!formatterCacheLocale.has(options)) { - formatterCacheLocale.set(options, new Intl.DateTimeFormat(localeWithDefault, options).format); + formatterCacheLocale.set( + options, + new Intl.DateTimeFormat(localeWithDefault || undefined, options).format, + ); } return formatterCacheLocale.get(options)(date); @@ -22,8 +27,8 @@ export function getFormatter(options) { const numberFormatterCache = new Map(); -export function getNumberFormatter(options) { - return (locale, number) => { +export function getNumberFormatter(options: Intl.NumberFormatOptions) { + return (locale: string | undefined, number: number) => { const localeWithDefault = locale || getUserLocale(); if (!numberFormatterCache.has(localeWithDefault)) { @@ -35,7 +40,7 @@ export function getNumberFormatter(options) { if (!numberFormatterCacheLocale.has(options)) { numberFormatterCacheLocale.set( options, - new Intl.NumberFormat(localeWithDefault, options).format, + new Intl.NumberFormat(localeWithDefault || undefined, options).format, ); } @@ -43,6 +48,10 @@ export function getNumberFormatter(options) { }; } -const formatDateOptions = { day: 'numeric', month: 'numeric', year: 'numeric' }; +const formatDateOptions = { + day: 'numeric', + month: 'numeric', + year: 'numeric', +} satisfies Intl.DateTimeFormatOptions; export const formatDate = getFormatter(formatDateOptions); diff --git a/src/shared/dates.js b/src/shared/dates.js deleted file mode 100644 index e06954c9..00000000 --- a/src/shared/dates.js +++ /dev/null @@ -1,17 +0,0 @@ -export function convert12to24(hour12, amPm) { - let hour24 = Number(hour12); - - if (amPm === 'am' && hour24 === 12) { - hour24 = 0; - } else if (amPm === 'pm' && hour24 < 12) { - hour24 += 12; - } - - return hour24; -} - -export function convert24to12(hour24) { - const hour12 = hour24 % 12 || 12; - - return [hour12, hour24 < 12 ? 'am' : 'pm']; -} diff --git a/src/shared/dates.spec.js b/src/shared/dates.spec.ts similarity index 100% rename from src/shared/dates.spec.js rename to src/shared/dates.spec.ts diff --git a/src/shared/dates.ts b/src/shared/dates.ts new file mode 100644 index 00000000..c8738740 --- /dev/null +++ b/src/shared/dates.ts @@ -0,0 +1,19 @@ +import type { AmPmType } from './types'; + +export function convert12to24(hour12: string | number, amPm: AmPmType): number { + let hour24 = Number(hour12); + + if (amPm === 'am' && hour24 === 12) { + hour24 = 0; + } else if (amPm === 'pm' && hour24 < 12) { + hour24 += 12; + } + + return hour24; +} + +export function convert24to12(hour24: string | number): [number, AmPmType] { + const hour12 = Number(hour24) % 12 || 12; + + return [hour12, Number(hour24) < 12 ? 'am' : 'pm']; +} diff --git a/src/shared/propTypes.js b/src/shared/propTypes.ts similarity index 86% rename from src/shared/propTypes.js rename to src/shared/propTypes.ts index 5aa0d4ae..9bce2d25 100644 --- a/src/shared/propTypes.js +++ b/src/shared/propTypes.ts @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; const allViews = ['hour', 'minute', 'second']; const allValueTypes = [...allViews]; -export function isMinDate(props, propName, componentName) { +export function isMinDate(props: Record, propName: string, componentName: string) { const { [propName]: minDate } = props; if (!minDate) { @@ -27,7 +27,7 @@ export function isMinDate(props, propName, componentName) { return null; } -export function isMaxDate(props, propName, componentName) { +export function isMaxDate(props: Record, propName: string, componentName: string) { const { [propName]: maxDate } = props; if (!maxDate) { diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 00000000..98880e14 --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,7 @@ +export type AmPmType = 'am' | 'pm'; + +export type ClassName = string | null | undefined | (string | null | undefined)[]; + +export type Detail = 'hour' | 'minute' | 'second'; + +export type LooseValue = string | Date | null | (Date | null)[]; diff --git a/src/shared/utils.spec.js b/src/shared/utils.spec.ts similarity index 100% rename from src/shared/utils.spec.js rename to src/shared/utils.spec.ts diff --git a/src/shared/utils.js b/src/shared/utils.ts similarity index 54% rename from src/shared/utils.js rename to src/shared/utils.ts index 022a2d20..9b5f20cf 100644 --- a/src/shared/utils.js +++ b/src/shared/utils.ts @@ -3,17 +3,20 @@ import { getFormatter } from './dateFormatter'; /** * Returns a value no smaller than min and no larger than max. * - * @param {*} value Value to return. - * @param {*} min Minimum return value. - * @param {*} max Maximum return value. + * @param {Date} value Value to return. + * @param {Date} min Minimum return value. + * @param {Date} max Maximum return value. + * @returns {Date} Value between min and max. */ -export function between(value, min, max) { +export function between(value: T, min?: T | null, max?: T | null): T { if (min && min > value) { return min; } + if (max && max < value) { return max; } + return value; } @@ -21,21 +24,21 @@ const nines = ['9', '٩']; const ninesRegExp = new RegExp(`[${nines.join('')}]`); const amPmFormatter = getFormatter({ hour: 'numeric' }); -export function getAmPmLabels(locale) { +export function getAmPmLabels(locale: string | undefined): [string, string] { const amString = amPmFormatter(locale, new Date(2017, 0, 1, 9)); const pmString = amPmFormatter(locale, new Date(2017, 0, 1, 21)); - const [am1, am2] = amString.split(ninesRegExp); - const [pm1, pm2] = pmString.split(ninesRegExp); + const [am1, am2] = amString.split(ninesRegExp) as [string, string]; + const [pm1, pm2] = pmString.split(ninesRegExp) as [string, string]; if (pm2 !== undefined) { // If pm2 is undefined, nine was not found in pmString - this locale is not using 12-hour time if (am1 !== pm1) { - return [am1, pm1].map((el) => el.trim()); + return [am1, pm1].map((el) => el.trim()) as [string, string]; } if (am2 !== pm2) { - return [am2, pm2].map((el) => el.trim()); + return [am2, pm2].map((el) => el.trim()) as [string, string]; } } diff --git a/test-utils.js b/test-utils.ts similarity index 100% rename from test-utils.js rename to test-utils.ts diff --git a/tsconfig.json b/tsconfig.json index 505941dd..7e30adc0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { - "allowJs": true, - "declaration": false, + "declaration": true, "esModuleInterop": true, "isolatedModules": true, "jsx": "react",