diff --git a/bestax-mcp/data/components/DateInput.json b/bestax-mcp/data/components/DateInput.json index abe84720b..690ada98e 100644 --- a/bestax-mcp/data/components/DateInput.json +++ b/bestax-mcp/data/components/DateInput.json @@ -394,7 +394,7 @@ "name": "min", "type": "Date", "default": null, - "description": "Earliest selectable date.", + "description": "Earliest selectable date. A `min` before year 1 is raised to 1 January of year 1, where the range starts without one too: HTML's date and month inputs hold no earlier year, so the calendar and typing stop there.", "required": false, "inherited": true, "deprecated": false diff --git a/bestax-mcp/data/components/DateTimeInput.json b/bestax-mcp/data/components/DateTimeInput.json index 2a262db6c..28dd2d28a 100644 --- a/bestax-mcp/data/components/DateTimeInput.json +++ b/bestax-mcp/data/components/DateTimeInput.json @@ -390,7 +390,7 @@ "name": "min", "type": "Date", "default": null, - "description": "Lower bound for the combined date-time.", + "description": "Lower bound for the combined date-time. A `min` before year 1 is raised to midnight on 1 January of year 1, where the range starts without one too: HTML's datetime-local input holds no earlier year, so the calendar, the time wheels and typing stop there.", "required": false, "inherited": true, "deprecated": false diff --git a/bestax-mcp/data/components/TimeInput.json b/bestax-mcp/data/components/TimeInput.json index 1279dfa7a..54e2c76c8 100644 --- a/bestax-mcp/data/components/TimeInput.json +++ b/bestax-mcp/data/components/TimeInput.json @@ -398,7 +398,7 @@ "name": "min", "type": "Date", "default": null, - "description": "Earliest selectable time.", + "description": "Earliest selectable time. A `min` before year 1 is raised to midnight on 1 January of year 1, and a `value` dated before then is out of range, so the wheels and typing change nothing on it, as in the date pickers.", "required": false, "inherited": true, "deprecated": false diff --git a/bulma-ui/src/form/DateInputBase.tsx b/bulma-ui/src/form/DateInputBase.tsx index 7cb7180dd..b2b3fb740 100644 --- a/bulma-ui/src/form/DateInputBase.tsx +++ b/bulma-ui/src/form/DateInputBase.tsx @@ -27,6 +27,8 @@ import { import { isWithin, clampDate, + FIRST_YEAR, + floorMin, isSameDay, isPeriodUnselectable, makeDate, @@ -48,9 +50,11 @@ const pad2 = (n: number): string => String(n).padStart(2, '0'); * The value as the native input and the hidden form input carry it: * `YYYY-MM-DD`, `YYYY-MM` or `YYYY`, the shapes `` and * `` use. The year is padded to four digits, as those - * inputs require and as the `YYYY` token displays it. + * inputs require and as the `YYYY` token displays it. HTML has no such shape + * for a year before 1, so a date then is empty, as those inputs would make it. */ const toIsoValue = (d: Date, granularity: DateGranularity): string => { + if (d.getFullYear() < FIRST_YEAR) return ''; const year = String(d.getFullYear()).padStart(4, '0'); if (granularity === 'year') return year; const month = `${year}-${pad2(d.getMonth() + 1)}`; @@ -116,7 +120,11 @@ export interface DateInputBaseProps onOpen?: () => void; /** Fired when the popover closes. */ onClose?: () => void; - /** Earliest selectable date. */ + /** + * Earliest selectable date. A `min` before year 1 is raised to 1 January of + * year 1, where the range starts without one too: HTML's date and month + * inputs hold no earlier year, so the calendar and typing stop there. + */ min?: Date; /** Latest selectable date. */ max?: Date; @@ -290,8 +298,11 @@ export const DateInputBase = forwardRef( ); const value = isControlled ? (controlledValue ?? null) : internalValue; + // Nothing before year 1 is in range, as in HTML's date inputs. + const lowerBound = useMemo(() => floorMin(min), [min]); + const initialFocused = useMemo( - () => clampDate(value ?? new Date(), min, max), + () => clampDate(value ?? new Date(), lowerBound, max), // intentionally only on mount: keep focusedDate stable until value/open change // eslint-disable-next-line react-hooks/exhaustive-deps [] @@ -300,8 +311,8 @@ export const DateInputBase = forwardRef( // Re-clamp focusedDate if min/max change after mount so the focused cell // never disappears outside the displayable range. useEffect(() => { - setFocusedDate(prev => clampDate(prev, min, max)); - }, [min, max]); + setFocusedDate(prev => clampDate(prev, lowerBound, max)); + }, [lowerBound, max]); const [open, setOpenState] = useState(false); const [text, setText] = useState( value ? formatDate(value, resolvedFormat, locale) : '' @@ -373,8 +384,9 @@ export const DateInputBase = forwardRef( // At month or year granularity the bounds widen to the periods holding // them, so the month or year containing `min` stays reachable. const periodMin = useMemo( - () => (min && !isDayGranularity ? startOfPeriod(min, granularity) : min), - [min, isDayGranularity, granularity] + () => + isDayGranularity ? lowerBound : startOfPeriod(lowerBound, granularity), + [lowerBound, isDayGranularity, granularity] ); const periodMax = useMemo( () => (max && !isDayGranularity ? endOfPeriod(max, granularity) : max), @@ -523,7 +535,7 @@ export const DateInputBase = forwardRef( : null; commitValue(parsed); }} - min={min ? toIsoValue(min, granularity) : undefined} + min={min ? toIsoValue(lowerBound, granularity) : undefined} max={max ? toIsoValue(max, granularity) : undefined} disabled={disabled} readOnly={readOnly} diff --git a/bulma-ui/src/form/DateTimeInputBase.tsx b/bulma-ui/src/form/DateTimeInputBase.tsx index 0f526f158..00e3ad7ce 100644 --- a/bulma-ui/src/form/DateTimeInputBase.tsx +++ b/bulma-ui/src/form/DateTimeInputBase.tsx @@ -28,6 +28,8 @@ import { isWithin, setTimeOfDay, clampDate, + FIRST_YEAR, + floorMin, isSameDay, makeDate, } from './_pickerInternals/dateUtils'; @@ -38,8 +40,11 @@ import { useNativeMobilePicker } from './_pickerInternals/useNativeMobilePicker' import { useSegmentedEntry } from './_pickerInternals/useSegmentedEntry'; import { Icon } from '../elements/Icon'; -// The year is padded to four digits, as `datetime-local` requires. +// The year is padded to four digits, as `datetime-local` requires. HTML has +// no such shape for a year before 1, so a date then is empty, as that input +// would make it. const toIsoDateTime = (d: Date, withSeconds: boolean): string => { + if (d.getFullYear() < FIRST_YEAR) return ''; const yyyy = String(d.getFullYear()).padStart(4, '0'); const mo = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); @@ -95,7 +100,12 @@ export interface DateTimeInputBaseProps onOpen?: () => void; /** Fired when the popover closes. */ onClose?: () => void; - /** Lower bound for the combined date-time. */ + /** + * Lower bound for the combined date-time. A `min` before year 1 is raised + * to midnight on 1 January of year 1, where the range starts without one + * too: HTML's datetime-local input holds no earlier year, so the calendar, + * the time wheels and typing stop there. + */ min?: Date; /** Upper bound for the combined date-time. */ max?: Date; @@ -275,16 +285,19 @@ export const DateTimeInputBase = forwardRef< ); const value = isControlled ? (controlledValue ?? null) : internalValue; + // Nothing before year 1 is in range, as in HTML's date inputs. + const lowerBound = useMemo(() => floorMin(min), [min]); + const initialFocused = useMemo( - () => clampDate(value ?? new Date(), min, max), + () => clampDate(value ?? new Date(), lowerBound, max), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); const [focusedDate, setFocusedDate] = useState(initialFocused); // Re-clamp focusedDate when min/max change so the focused cell stays valid. useEffect(() => { - setFocusedDate(prev => clampDate(prev, min, max)); - }, [min, max]); + setFocusedDate(prev => clampDate(prev, lowerBound, max)); + }, [lowerBound, max]); const defaultFormat: DateFormatOption = format ?? @@ -406,21 +419,21 @@ export const DateTimeInputBase = forwardRef< minutes: value?.getMinutes() ?? 0, seconds: enableSeconds ? (value?.getSeconds() ?? 0) : undefined, }); - if (!isWithin(merged, min, max)) return; + if (!isWithin(merged, lowerBound, max)) return; commitValue(merged); setFocusedDate(d); }, - [value, enableSeconds, min, max, commitValue] + [value, enableSeconds, lowerBound, max, commitValue] ); const handleTimeChange = useCallback( (parts: { hours: number; minutes: number; seconds?: number }) => { const base = value ?? focusedDate; const next = setTimeOfDay(base, parts); - if (!isWithin(next, min, max)) return; + if (!isWithin(next, lowerBound, max)) return; commitValue(next); }, - [value, focusedDate, min, max, commitValue] + [value, focusedDate, lowerBound, max, commitValue] ); const tryParse = useCallback( @@ -463,7 +476,7 @@ export const DateTimeInputBase = forwardRef< setText, makeBaseDate, locale, - min, + min: lowerBound, max, isBlocked, disabled, @@ -509,7 +522,7 @@ export const DateTimeInputBase = forwardRef< : null; commitValue(parsed); }} - min={min ? toIsoDateTime(min, enableSeconds) : undefined} + min={min ? toIsoDateTime(lowerBound, enableSeconds) : undefined} max={max ? toIsoDateTime(max, enableSeconds) : undefined} disabled={disabled} readOnly={readOnly} diff --git a/bulma-ui/src/form/TimeInputBase.tsx b/bulma-ui/src/form/TimeInputBase.tsx index 4c672cabb..6e3bfeab3 100644 --- a/bulma-ui/src/form/TimeInputBase.tsx +++ b/bulma-ui/src/form/TimeInputBase.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useId, + useMemo, useRef, useState, } from 'react'; @@ -24,6 +25,7 @@ import { } from './_pickerInternals/formatters'; import { setTimeOfDay, + floorMin, isWithin, snapTimeToIncrement, } from './_pickerInternals/dateUtils'; @@ -88,7 +90,11 @@ export interface TimeInputBaseProps onOpen?: () => void; /** Fired when the popover closes. */ onClose?: () => void; - /** Earliest selectable time. */ + /** + * Earliest selectable time. A `min` before year 1 is raised to midnight on + * 1 January of year 1, and a `value` dated before then is out of range, so + * the wheels and typing change nothing on it, as in the date pickers. + */ min?: Date; /** Latest selectable time. */ max?: Date; @@ -332,6 +338,9 @@ export const TimeInputBase = forwardRef( setText(value ? formatTime(value, defaultFormat, locale) : ''); }, [value, defaultFormat, locale]); + // A value's date before year 1 is out of range, as in the date pickers. + const lowerBound = useMemo(() => floorMin(min), [min]); + const commitValue = useCallback( (next: Date | null) => { if (!isControlled) setInternalValue(next); @@ -344,10 +353,10 @@ export const TimeInputBase = forwardRef( (parts: { hours: number; minutes: number; seconds?: number }) => { const base = value ?? new Date(); const next = setTimeOfDay(base, parts); - if (!isWithin(next, min, max)) return; + if (!isWithin(next, lowerBound, max)) return; commitValue(next); }, - [value, min, max, commitValue] + [value, lowerBound, max, commitValue] ); const tryParse = useCallback( @@ -389,7 +398,7 @@ export const TimeInputBase = forwardRef( setText, makeBaseDate, locale, - min, + min: lowerBound, max, isBlocked: unselectableTimes, disabled, diff --git a/bulma-ui/src/form/__tests__/Calendar.test.tsx b/bulma-ui/src/form/__tests__/Calendar.test.tsx index 50fa3ed5f..2e9f605f1 100644 --- a/bulma-ui/src/form/__tests__/Calendar.test.tsx +++ b/bulma-ui/src/form/__tests__/Calendar.test.tsx @@ -204,6 +204,86 @@ describe('Calendar', () => { ]); }); + describe('before year 1', () => { + // 1 January of year 1 is a Monday, so a Sunday-first grid opens on + // 31 December of year 0. + const yearZeroCell = (container: HTMLElement) => + Array.from(container.querySelectorAll('[role="gridcell"]')).find( + c => c.textContent === '31' && c.className.includes('is-other-month') + ) as HTMLButtonElement; + const firstOfYearOne = (container: HTMLElement) => + Array.from(container.querySelectorAll('[role="gridcell"]')).find( + c => c.textContent === '1' && !c.className.includes('is-other-month') + ) as HTMLButtonElement; + + it('selects a day in year 1 and disables the days before it', () => { + const onSelect = jest.fn(); + const { container, getByLabelText } = render( + + ); + expect(yearZeroCell(container)).toBeDisabled(); + expect(getByLabelText('Previous month')).toBeDisabled(); + fireEvent.click(firstOfYearOne(container)); + expect(onSelect).toHaveBeenCalledWith(makeDate(1, 0, 1)); + }); + + it('keeps keyboard focus from leaving year 1 backwards', () => { + const onFocusedDateChange = jest.fn(); + const { container } = render( + + ); + const grid = container.querySelector('[role="grid"]')!; + fireEvent.keyDown(grid, { key: 'ArrowLeft' }); + fireEvent.keyDown(grid, { key: 'PageUp' }); + expect(onFocusedDateChange).not.toHaveBeenCalled(); + }); + + it('reads a min in year 0 or a negative year as the start of year 1', () => { + for (const min of [makeDate(0, 6, 1), makeDate(-5, 0, 1)]) { + const { container, getByLabelText, unmount } = render( + + ); + expect(yearZeroCell(container)).toBeDisabled(); + expect(firstOfYearOne(container)).not.toBeDisabled(); + expect(getByLabelText('Previous month')).toBeDisabled(); + unmount(); + } + }); + + it('steps no further back from a month already before min', () => { + const cases: [Date, Date | undefined][] = [ + // No min, so the floor at year 1. + [makeDate(0, 11, 31), undefined], + // A day before min in an earlier month, as picking min's year from + // the year list can leave it. + [new Date(2024, 2, 15), new Date(2024, 5, 15)], + ]; + for (const [focusedDate, min] of cases) { + const { getByLabelText, unmount } = render( + + ); + expect(getByLabelText('Previous month')).toBeDisabled(); + expect(getByLabelText('Next month')).not.toBeDisabled(); + unmount(); + } + }); + }); + + it('steps no further on from a month already after max', () => { + // As picking max's year from the year list can leave it. + const { getByLabelText } = render( + + ); + expect(getByLabelText('Next month')).toBeDisabled(); + expect(getByLabelText('Previous month')).not.toBeDisabled(); + }); + it('shouldDisableDate predicate disables matching cells', () => { const onSelect = jest.fn(); const { container } = render( @@ -566,6 +646,29 @@ describe('Calendar', () => { expect(scrollIntoViewMock).not.toHaveBeenCalled(); }); + it('stops the default range at year 1', () => { + const { container } = render( + + ); + openYearView(container as HTMLElement); + const options = container.querySelectorAll('[role="option"]'); + expect(options[0].textContent).toBe('1'); + expect(options[options.length - 1].textContent).toBe('119'); + }); + + it('starts at year 1 for a min in year 0 or a negative year', () => { + for (const min of [makeDate(0, 6, 1), makeDate(-5, 0, 1)]) { + const { container, unmount } = render( + + ); + openYearView(container as HTMLElement); + expect(container.querySelector('[role="option"]')!.textContent).toBe( + '1' + ); + unmount(); + } + }); + it('disables month nav buttons while the year view is open', () => { const { container, getByLabelText } = render(); openYearView(container as HTMLElement); @@ -971,6 +1074,24 @@ describe('Calendar month granularity', () => { }); }); + it('stops stepping back at year 1', () => { + for (const min of [undefined, makeDate(0, 6, 1), makeDate(-5, 0, 1)]) { + const onSelect = jest.fn(); + const { container, getByLabelText, unmount } = render( + + ); + expect(getByLabelText('Previous year')).toBeDisabled(); + fireEvent.click(monthCell(container, 'January')); + expect(onSelect).toHaveBeenCalledWith(makeDate(1, 0, 1)); + unmount(); + } + }); + it('keeps a year below 100 when a month is picked', () => { const early = new Date(2024, 5, 15); early.setFullYear(19); @@ -1327,6 +1448,26 @@ describe('Calendar year granularity', () => { expect((onSelect.mock.calls[0][0] as Date).getFullYear()).toBe(22); }); + it('lists no year before 1', () => { + for (const min of [undefined, makeDate(0, 6, 1), makeDate(-5, 0, 1)]) { + const onSelect = jest.fn(); + const { container, unmount } = render( + + ); + expect(yearOptions(container)[0].textContent).toBe('1'); + press(container, 'Home'); + expect(focusedYearOption(container).textContent).toBe('1'); + fireEvent.click(yearOption(container, 1)); + expect(onSelect).toHaveBeenCalledWith(makeDate(1)); + unmount(); + } + }); + it('keeps the listed years still while focus moves', () => { const { container } = render(); const first = yearOptions(container)[0].textContent; diff --git a/bulma-ui/src/form/__tests__/DateInput.test.tsx b/bulma-ui/src/form/__tests__/DateInput.test.tsx index fe1f017bd..d1db6f5af 100644 --- a/bulma-ui/src/form/__tests__/DateInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateInput.test.tsx @@ -2228,3 +2228,204 @@ describe('DateInput onOpen and onClose', () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +// HTML's date and month inputs hold no year below 1, so the picker stops there +// on every path a value can arrive by. +describe('DateInput before year 1', () => { + const lastYear = (handler: jest.Mock) => + ( + handler.mock.calls[handler.mock.calls.length - 1][0] as Date + ).getFullYear(); + const focusInput = (input: HTMLElement) => + act(() => { + input.focus(); + }); + + it('picks a day in year 1 from the calendar and disables the days before', () => { + const handler = jest.fn(); + const { container, getByLabelText } = render( + + ); + expect(getByLabelText('Previous month')).toBeDisabled(); + const cells = Array.from(container.querySelectorAll('[role="gridcell"]')); + // 1 January of year 1 is a Monday, so the grid opens on 31 December of + // year 0. + const yearZero = cells.find( + c => c.textContent === '31' && c.className.includes('is-other-month') + ); + expect(yearZero).toBeDisabled(); + fireEvent.click( + cells.find( + c => c.textContent === '1' && !c.className.includes('is-other-month') + )! + ); + expect(handler).toHaveBeenCalledWith(makeDate(1, 0, 1)); + }); + + it('keeps the calendar at year 1 under a max before it', () => { + // StrictMode runs the focus re-clamp twice on mount, so a clamp that + // swaps between the crossed bounds would end on the max. + const { container, getByLabelText } = render( + + + + ); + expect(container.querySelector('.dateinput-month-label')!.textContent).toBe( + 'January 1' + ); + expect(getByLabelText('Previous month')).toBeDisabled(); + }); + + it.each([ + ['day', '0001-06-15'], + ['month', '0001-06'], + ['year', '0001'], + ] as const)( + 'submits a %s in year 1, and nothing for a value before it', + (granularity, expected) => { + const submitted = (defaultValue: Date) => { + const { container, unmount } = render( + + ); + const hidden = container.querySelector( + 'input[type="hidden"]' + ) as HTMLInputElement; + const value = hidden.value; + unmount(); + return value; + }; + expect(submitted(makeDate(1, 5, 15))).toBe(expected); + expect(submitted(makeDate(0, 5, 15))).toBe(''); + expect(submitted(makeDate(-1, 5, 15))).toBe(''); + } + ); + + describe('segmented typing', () => { + it('steps into year 1 and no further', () => { + const handler = jest.fn(); + const { getByRole } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + focusInput(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(lastYear(handler)).toBe(1); + expect(input.value).toBe('0001-06-15'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(handler).toHaveBeenCalledTimes(1); + expect(input.value).toBe('0001-06-15'); + }); + + it('commits nothing for a typed year 0 until it reaches year 1', () => { + const handler = jest.fn(); + const { getByRole } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + focusInput(input); + fireEvent.keyDown(input, { key: '0' }); + fireEvent.keyDown(input, { key: '0' }); + fireEvent.keyDown(input, { key: '0' }); + expect(handler).not.toHaveBeenCalled(); + expect(input.value).toBe('2024-06-15'); + fireEvent.keyDown(input, { key: '1' }); + expect(handler).toHaveBeenCalledTimes(1); + expect(input.value).toBe('0001-06-15'); + }); + + it('leaves a value in year 0 only towards year 1', () => { + const handler = jest.fn(); + const { getByRole } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + focusInput(input); + // Down to a negative year is out of range. + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(handler).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(lastYear(handler)).toBe(1); + }); + }); + + describe('parsing', () => { + const typeAndLeave = (input: HTMLInputElement, text: string) => { + fireEvent.change(input, { target: { value: text } }); + fireEvent.blur(input); + }; + + it('reads year 1 and rejects year 0 with the default parse', () => { + const handler = jest.fn(); + const { getByRole } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + typeAndLeave(input, '0000-06-16'); + expect(handler).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + typeAndLeave(input, '0001-06-16'); + expect(handler).toHaveBeenCalledWith(makeDate(1, 5, 16)); + }); + + it('rejects a negative year from a custom parse', () => { + const handler = jest.fn(); + const { getByRole } = render( + makeDate(-1, 5, 16)} + onChange={handler} + /> + ); + const input = getByRole('combobox') as HTMLInputElement; + typeAndLeave(input, '16 June 2 BC'); + expect(handler).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + }); + }); + + describe('native input', () => { + it.each([ + ['day', 'date', '0001-01-01'], + ['month', 'month', '0001-01'], + ] as const)( + 'gives the %s input a min of year 1 for one before it', + (granularity, type, expected) => { + for (const min of [makeDate(0, 6, 1), makeDate(-5, 0, 1)]) { + const { container, unmount } = render( + + ); + expect( + ( + container.querySelector( + `input[type="${type}"]` + ) as HTMLInputElement + ).min + ).toBe(expected); + unmount(); + } + } + ); + + it('reads year 1 back, while the input itself drops year 0 and below', () => { + const handler = jest.fn(); + const { container } = render( + + ); + const native = container.querySelector( + 'input[type="date"]' + ) as HTMLInputElement; + // HTML has no year 0 or negative year, so the input empties itself. + fireEvent.change(native, { target: { value: '0000-03-04' } }); + fireEvent.change(native, { target: { value: '-0001-03-04' } }); + expect(native.value).toBe(''); + expect(handler).not.toHaveBeenCalledWith(expect.any(Date)); + fireEvent.change(native, { target: { value: '0001-03-04' } }); + expect(handler).toHaveBeenLastCalledWith(makeDate(1, 2, 4)); + }); + }); +}); diff --git a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx index 6c6e0fa5a..49e53890a 100644 --- a/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/DateTimeInput.test.tsx @@ -3,6 +3,7 @@ import { render, fireEvent, act } from '@testing-library/react'; import { DateTimeInput } from '../DateTimeInput'; import { Field } from '../Field'; import { DateTimeInputBase } from '../DateTimeInputBase'; +import { makeDate } from '../_pickerInternals/dateUtils'; beforeAll(() => { if (!window.matchMedia) { @@ -1584,3 +1585,208 @@ describe('DateTimeInput onOpen and onClose', () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +// HTML's datetime-local input holds no year below 1, so the picker stops there +// on every path a value can arrive by. +describe('DateTimeInput before year 1', () => { + const at = (year: number, month: number, day: number, h = 0, m = 0) => { + const d = makeDate(year, month, day); + d.setHours(h, m); + return d; + }; + const lastYear = (handler: jest.Mock) => + ( + handler.mock.calls[handler.mock.calls.length - 1][0] as Date + ).getFullYear(); + const focusInput = (input: HTMLElement) => + act(() => { + input.focus(); + }); + + it('picks a day in year 1 from the calendar and disables the days before', () => { + const handler = jest.fn(); + const { container, getByLabelText } = render( + + ); + expect(getByLabelText('Previous month')).toBeDisabled(); + const cells = Array.from(container.querySelectorAll('[role="gridcell"]')); + // 1 January of year 1 is a Monday, so the grid opens on 31 December of + // year 0. + expect( + cells.find( + c => c.textContent === '31' && c.className.includes('is-other-month') + ) + ).toBeDisabled(); + fireEvent.click( + cells.find( + c => c.textContent === '1' && !c.className.includes('is-other-month') + )! + ); + expect(handler).toHaveBeenCalledWith(at(1, 0, 1, 9, 30)); + }); + + it('keeps the calendar at year 1 under a max before it', () => { + // StrictMode runs the focus re-clamp twice on mount, so a clamp that + // swaps between the crossed bounds would end on the max. + const { container, getByLabelText } = render( + + + + ); + expect(container.querySelector('.dateinput-month-label')!.textContent).toBe( + 'January 1' + ); + expect(getByLabelText('Previous month')).toBeDisabled(); + }); + + it('submits a value in year 1, and nothing for one before it', () => { + const submitted = (defaultValue: Date) => { + const { container, unmount } = render( + + ); + const hidden = container.querySelector( + 'input[type="hidden"]' + ) as HTMLInputElement; + const value = hidden.value; + unmount(); + return value; + }; + expect(submitted(at(1, 5, 15, 10, 30))).toBe('0001-06-15T10:30'); + expect(submitted(at(0, 5, 15, 10, 30))).toBe(''); + expect(submitted(at(-1, 5, 15, 10, 30))).toBe(''); + }); + + it('turns the time wheels on a value in year 1 but not before', () => { + for (const [year, allowed] of [ + [1, true], + [0, false], + [-1, false], + ] as const) { + const handler = jest.fn(); + const { getByRole, getAllByRole, unmount } = render( + + ); + fireEvent.click(getByRole('combobox')); + fireEvent.click(getByRole('button', { name: /Time/ })); + fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' }); + if (allowed) expect(handler).toHaveBeenCalledWith(at(year, 5, 15, 11, 0)); + else expect(handler).not.toHaveBeenCalled(); + unmount(); + } + }); + + describe('segmented typing', () => { + it('steps into year 1 and no further', () => { + const handler = jest.fn(); + const { getByRole } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + focusInput(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(lastYear(handler)).toBe(1); + expect(input.value).toBe('0001-06-15 10:00'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(handler).toHaveBeenCalledTimes(1); + expect(input.value).toBe('0001-06-15 10:00'); + }); + + it('leaves a value in year 0 only towards year 1', () => { + const handler = jest.fn(); + const { getByRole } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + focusInput(input); + // Down to a negative year is out of range. + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(handler).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(lastYear(handler)).toBe(1); + }); + }); + + describe('parsing', () => { + const typeAndLeave = (input: HTMLInputElement, text: string) => { + fireEvent.change(input, { target: { value: text } }); + fireEvent.blur(input); + }; + + it('reads year 1 and rejects year 0 with the default parse', () => { + const handler = jest.fn(); + const { getByRole } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + typeAndLeave(input, '0000-06-16 10:00'); + expect(handler).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + typeAndLeave(input, '0001-06-16 10:00'); + expect(handler).toHaveBeenCalledWith(at(1, 5, 16, 10, 0)); + }); + + it('rejects a negative year from a custom parse', () => { + const handler = jest.fn(); + const { getByRole } = render( + at(-1, 5, 16, 10, 0)} + onChange={handler} + /> + ); + const input = getByRole('combobox') as HTMLInputElement; + typeAndLeave(input, '16 June 2 BC, 10:00'); + expect(handler).not.toHaveBeenCalled(); + expect(input.value).toBe(''); + }); + }); + + describe('native input', () => { + const native = (container: HTMLElement) => + container.querySelector( + 'input[type="datetime-local"]' + ) as HTMLInputElement; + + it('gets a min of year 1 for one before it', () => { + for (const min of [at(0, 6, 1, 9, 0), at(-5, 0, 1, 9, 0)]) { + const { container, unmount } = render( + + ); + expect(native(container).min).toBe('0001-01-01T00:00'); + unmount(); + } + }); + + it('reads year 1 back, while the input itself drops year 0 and below', () => { + const handler = jest.fn(); + const { container } = render( + + ); + // HTML has no year 0 or negative year, so the input empties itself. + fireEvent.change(native(container), { + target: { value: '0000-03-04T14:30' }, + }); + fireEvent.change(native(container), { + target: { value: '-0001-03-04T14:30' }, + }); + expect(native(container).value).toBe(''); + expect(handler).not.toHaveBeenCalledWith(expect.any(Date)); + fireEvent.change(native(container), { + target: { value: '0001-03-04T14:30' }, + }); + expect(handler).toHaveBeenLastCalledWith(at(1, 2, 4, 14, 30)); + }); + }); +}); diff --git a/bulma-ui/src/form/__tests__/TimeInput.test.tsx b/bulma-ui/src/form/__tests__/TimeInput.test.tsx index 98299de72..f47e020fe 100644 --- a/bulma-ui/src/form/__tests__/TimeInput.test.tsx +++ b/bulma-ui/src/form/__tests__/TimeInput.test.tsx @@ -5,6 +5,7 @@ import { Field } from '../Field'; import { TimeInputBase } from '../TimeInputBase'; import { ConfigProvider } from '../../helpers/Config'; import { __resetAudioTickForTest } from '../_pickerInternals/audioTick'; +import { makeDate } from '../_pickerInternals/dateUtils'; beforeAll(() => { if (!window.matchMedia) { @@ -1793,3 +1794,66 @@ describe('TimeInput onOpen and onClose', () => { expect(onClose).toHaveBeenCalledTimes(1); }); }); + +// The date pickers hold no year below 1, and neither does the date a time +// value carries. +describe('TimeInput before year 1', () => { + const inYear = (year: number, h: number) => { + const d = makeDate(year, 5, 15); + d.setHours(h); + return d; + }; + const cases = [ + [1, true], + [0, false], + [-1, false], + ] as const; + + it('turns the wheels on a value in year 1 but not before', () => { + for (const [year, allowed] of cases) { + const handler = jest.fn(); + const { getByRole, getAllByRole, unmount } = render( + + ); + fireEvent.click(getByRole('combobox')); + fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' }); + if (allowed) expect(handler).toHaveBeenCalledWith(inYear(year, 11)); + else expect(handler).not.toHaveBeenCalled(); + unmount(); + } + }); + + it('types into a value in year 1 but not before', () => { + for (const [year, allowed] of cases) { + const handler = jest.fn(); + const { getByRole, unmount } = render( + + ); + const input = getByRole('combobox') as HTMLInputElement; + act(() => { + input.focus(); + }); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + if (allowed) expect(handler).toHaveBeenCalledWith(inYear(year, 11)); + else expect(handler).not.toHaveBeenCalled(); + unmount(); + } + }); + + it('reads a min in year 0 or a negative year as the start of year 1', () => { + for (const min of [makeDate(0, 6, 1), makeDate(-5, 0, 1)]) { + const handler = jest.fn(); + const { getByRole, getAllByRole, unmount } = render( + + ); + fireEvent.click(getByRole('combobox')); + fireEvent.keyDown(getAllByRole('spinbutton')[0], { key: 'ArrowDown' }); + expect(handler).not.toHaveBeenCalled(); + unmount(); + } + }); +}); diff --git a/bulma-ui/src/form/__tests__/dateUtils.test.ts b/bulma-ui/src/form/__tests__/dateUtils.test.ts index 0e6c98fec..b8dc9519c 100644 --- a/bulma-ui/src/form/__tests__/dateUtils.test.ts +++ b/bulma-ui/src/form/__tests__/dateUtils.test.ts @@ -24,6 +24,7 @@ import { isDayUnselectable, isPeriodUnselectable, makeDate, + floorMin, } from '../_pickerInternals/dateUtils'; describe('dateUtils', () => { @@ -205,6 +206,35 @@ describe('dateUtils', () => { expect(c).not.toBe(d); expect(c.getTime()).toBe(d.getTime()); }); + + it('lets min win when min is after max', () => { + // Nothing is in range then, and landing on min keeps a picker's focus + // off the years before 1 under a max before then. + const min = makeDate(1, 0, 1); + const max = makeDate(0, 11, 31); + expect(clampDate(new Date(2024, 0, 15), min, max)).toEqual(min); + expect(clampDate(makeDate(-5, 0, 1), min, max)).toEqual(min); + }); + }); + + describe('floorMin', () => { + const yearOne = makeDate(1, 0, 1); + + it('starts at midnight on 1 January of year 1 without a min', () => { + expect(floorMin()).toEqual(yearOne); + }); + + it('keeps a min in year 1 or later as the same object', () => { + const firstDay = makeDate(1, 0, 1); + const later = makeDate(1, 5, 15); + expect(floorMin(firstDay)).toBe(firstDay); + expect(floorMin(later)).toBe(later); + }); + + it('raises a min in year 0 or a negative year to year 1', () => { + expect(floorMin(makeDate(0, 11, 31))).toEqual(yearOne); + expect(floorMin(makeDate(-5, 5, 15))).toEqual(yearOne); + }); }); describe('buildMonthGrid', () => { diff --git a/bulma-ui/src/form/__tests__/formatters.test.ts b/bulma-ui/src/form/__tests__/formatters.test.ts index fb2b73124..b63545c97 100644 --- a/bulma-ui/src/form/__tests__/formatters.test.ts +++ b/bulma-ui/src/form/__tests__/formatters.test.ts @@ -11,6 +11,7 @@ import { DEFAULT_TIME_FORMAT_24, DEFAULT_TIME_FORMAT_12, } from '../_pickerInternals/formatters'; +import { makeDate } from '../_pickerInternals/dateUtils'; describe('formatters', () => { describe('formatDate (token format)', () => { @@ -28,6 +29,14 @@ describe('formatters', () => { expect(formatDate(new Date(2024, 0, 1), 'YY-MM-DD')).toBe('24-01-01'); }); + it('pads a year from 1 to 999 to four digits with YYYY', () => { + expect(formatDate(makeDate(1, 0, 1), 'YYYY-MM-DD')).toBe('0001-01-01'); + expect(formatDate(makeDate(19, 5, 7), 'YYYY')).toBe('0019'); + expect(formatDate(makeDate(999, 11, 31), 'YYYY-MM-DD')).toBe( + '0999-12-31' + ); + }); + it('uses default when format is undefined', () => { expect(formatDate(d, undefined)).toBe('2024-06-07'); }); diff --git a/bulma-ui/src/form/_pickerInternals/Calendar.tsx b/bulma-ui/src/form/_pickerInternals/Calendar.tsx index 8334f8fa0..5ef6b017a 100644 --- a/bulma-ui/src/form/_pickerInternals/Calendar.tsx +++ b/bulma-ui/src/form/_pickerInternals/Calendar.tsx @@ -24,6 +24,8 @@ import { addYears, buildMonthGrid, clampDate, + FIRST_YEAR, + floorMin, isDayUnselectable, isPeriodUnselectable, isSameDay, @@ -39,6 +41,10 @@ export interface CalendarProps { focusedDate: Date; onSelect: (d: Date) => void; onFocusedDateChange: (d: Date) => void; + /** + * Earliest selectable date. One before year 1, or none, counts as the start + * of year 1. + */ min?: Date; max?: Date; shouldDisableDate?: (d: Date) => boolean; @@ -62,7 +68,8 @@ export interface CalendarProps { labels?: PickerLabels; /** * Inclusive `[min, max]` year range shown in the year-dropdown view. - * Defaults to ±100 years around the focused year, clamped by `min`/`max`. + * Defaults to ±100 years around the focused year, clamped by `min`/`max` + * and never reaching below year 1. * At `'year'` granularity the window centres on the year focused when the * calendar mounted, so moving focus never reflows the list. */ @@ -193,7 +200,7 @@ export const Calendar: React.FC = ({ focusedDate, onSelect, onFocusedDateChange, - min, + min: minProp, max, shouldDisableDate, unselectableDates, @@ -220,6 +227,8 @@ export const Calendar: React.FC = ({ const isDayGranularity = granularity === 'day'; const isYearGranularity = granularity === 'year'; const [view, setView] = useState(baseView); + // Nothing before year 1 can be picked or reached, as in HTML's date inputs. + const min = useMemo(() => floorMin(minProp), [minProp]); const computedDayNames = useMemo(() => { if (dayNames && dayNames.length === 7) return dayNames; @@ -315,7 +324,7 @@ export const Calendar: React.FC = ({ (next: Date, direction: 1 | -1) => { let candidate = next; for (let i = 0; i < 366; i++) { - if (min && candidate.getTime() < startOfDay(min).getTime()) return; + if (candidate.getTime() < startOfDay(min).getTime()) return; if (max && candidate.getTime() > startOfDay(max).getTime()) return; if (!isDateUnselectable(candidate)) { onFocusedDateChange(candidate); @@ -392,7 +401,7 @@ export const Calendar: React.FC = ({ let candidate = next; for (let i = 0; i < 120; i++) { const month = startOfMonth(candidate).getTime(); - if (min && month < startOfMonth(min).getTime()) return; + if (month < startOfMonth(min).getTime()) return; if (max && month > startOfMonth(max).getTime()) return; if (!isMonthUnselectable(candidate)) { onFocusedDateChange(candidate); @@ -446,29 +455,22 @@ export const Calendar: React.FC = ({ const todayYear = today.getFullYear(); // The header steps a month at a time over the day grid and a year at a - // time over the month grid, kept inside min/max. - const prevMonthAnchor = addMonths(focusedDate, -1); - const nextMonthAnchor = addMonths(focusedDate, 1); + // time over the month grid, kept inside min/max. A step is off once the + // month or year on show holds the bound or lies past it, as picking a year + // from the list can leave it. const prevAnchor = isDayGranularity - ? prevMonthAnchor + ? addMonths(focusedDate, -1) : clampDate(addYears(focusedDate, -1), min, max); const nextAnchor = isDayGranularity - ? nextMonthAnchor + ? addMonths(focusedDate, 1) : clampDate(addYears(focusedDate, 1), min, max); + const focusedMonth = startOfMonth(focusedDate).getTime(); const prevDisabled = isDayGranularity - ? !!( - min && - prevMonthAnchor.getTime() < startOfDay(min).getTime() && - isSameMonth(focusedDate, min) - ) - : !!(min && focusedYear <= min.getFullYear()); + ? focusedMonth <= startOfMonth(min).getTime() + : focusedYear <= min.getFullYear(); const nextDisabled = isDayGranularity - ? !!( - max && - nextMonthAnchor.getTime() > startOfDay(max).getTime() && - isSameMonth(focusedDate, max) - ) - : !!(max && focusedYear >= max.getFullYear()); + ? !!max && focusedMonth >= startOfMonth(max).getTime() + : !!max && focusedYear >= max.getFullYear(); const labelId = id ? `${id}-label` : undefined; const monthLabel = isDayGranularity @@ -482,7 +484,9 @@ export const Calendar: React.FC = ({ const windowYear = isYearGranularity ? mountYear : focusedYear; const yearList = useMemo(() => { - const minYear = min ? min.getFullYear() : windowYear - 100; + const minYear = minProp + ? min.getFullYear() + : Math.max(FIRST_YEAR, windowYear - 100); const maxYear = max ? max.getFullYear() : windowYear + 100; const [lo, hi] = yearsRange ?? [minYear, maxYear]; const start = Math.max(lo, minYear); @@ -490,7 +494,7 @@ export const Calendar: React.FC = ({ const out: number[] = []; for (let y = start; y <= end; y++) out.push(y); return out; - }, [windowYear, min, max, yearsRange]); + }, [windowYear, minProp, min, max, yearsRange]); // Only the selection surface disables years; as navigation every listed // year can be visited. diff --git a/bulma-ui/src/form/_pickerInternals/dateUtils.ts b/bulma-ui/src/form/_pickerInternals/dateUtils.ts index 7bef2c6ce..1a5d644a0 100644 --- a/bulma-ui/src/form/_pickerInternals/dateUtils.ts +++ b/bulma-ui/src/form/_pickerInternals/dateUtils.ts @@ -67,6 +67,23 @@ export function makeDate(year: number, month = 0, day = 1): Date { return d; } +/** + * The earliest year the pickers accept. HTML's date, month and datetime-local + * inputs hold no year below 1, so the calendar, typing and parsing stop there + * too. + */ +export const FIRST_YEAR = 1; + +/** + * The lower bound a picker applies: `min`, raised to local midnight on + * 1 January of {@link FIRST_YEAR} when it is earlier or absent. A `min` in + * range comes back as the same object. + */ +export function floorMin(min?: Date): Date { + const floor = makeDate(FIRST_YEAR); + return min && min.getTime() >= floor.getTime() ? min : floor; +} + /** * First instant of the day, month or year containing `d`. Works by setters on * a copy rather than the `Date(y, m, d)` constructor, which reads years 0–99 @@ -200,10 +217,14 @@ export function getTimeOfDay(d: Date): { }; } +/** + * A copy of `d` moved into `[min, max]`. When `min` is after `max` nothing is + * in range and `min` wins, so clamping twice gives the same date and a + * picker's focus stays off the years before 1 under a `max` before then. + */ export function clampDate(d: Date, min?: Date, max?: Date): Date { - if (min && isBefore(d, min)) return new Date(min); - if (max && isAfter(d, max)) return new Date(max); - return new Date(d); + const capped = max && isAfter(d, max) ? max : d; + return new Date(min && isBefore(capped, min) ? min : capped); } /** diff --git a/bulma-ui/src/form/_pickerInternals/formatters.ts b/bulma-ui/src/form/_pickerInternals/formatters.ts index 6618b4815..3eda526f4 100644 --- a/bulma-ui/src/form/_pickerInternals/formatters.ts +++ b/bulma-ui/src/form/_pickerInternals/formatters.ts @@ -18,7 +18,9 @@ function formatToken(d: Date, token: string): string { case 'YYYY': // Zero-pad to a fixed 4 chars so the rendered width matches the // segment-map width during digit-by-digit keyboard entry (and to match - // ISO 8601). Years >9999 keep their natural width. + // ISO 8601), so year 19 shows as `0019`. Years >9999 keep their natural + // width. The pickers commit no year below 1, so a minus sign reaches + // this only from a `value` passed in. return pad(d.getFullYear(), 4); case 'YY': return pad(d.getFullYear() % 100); diff --git a/docs/docs/api/form/datetime/dateinput.md b/docs/docs/api/form/datetime/dateinput.md index 9b8e1c648..782922ae3 100644 --- a/docs/docs/api/form/datetime/dateinput.md +++ b/docs/docs/api/form/datetime/dateinput.md @@ -168,7 +168,7 @@ Rendered `inline`, the month grid shows without a popover. The header steps a ye ### Min and Max -Limit selectable dates to a range. +Limit selectable dates to a range. Nothing before year 1 is in range, as HTML's date inputs hold no earlier year: without a `min` the year list stops there, and a `min` before it counts as 1 January of year 1. ```tsx live function example() { @@ -969,7 +969,7 @@ Use `inline` instead of the popover when you have vertical room to spare — boo | `onChange` | `(d: Date \| null) => void` | — | Fired when the value changes. | | `onOpen` | `() => void` | — | Fired when the popover opens. | | `onClose` | `() => void` | — | Fired when the popover closes. | -| `min` | `Date` | — | Earliest selectable date. | +| `min` | `Date` | — | Earliest selectable date. A `min` before year 1 is raised to 1 January of year 1, where the range starts without one too: HTML's date and month inputs hold no earlier year, so the calendar and typing stop there. | | `max` | `Date` | — | Latest selectable date. | | `disabled` | `boolean` | `false` | Disable the input. | | `readOnly` | `boolean` | `false` | Make the input read-only. | diff --git a/docs/docs/api/form/datetime/datetimeinput.md b/docs/docs/api/form/datetime/datetimeinput.md index 5fbc66fc3..efcd40b8c 100644 --- a/docs/docs/api/form/datetime/datetimeinput.md +++ b/docs/docs/api/form/datetime/datetimeinput.md @@ -288,7 +288,7 @@ A clickable launcher sits on the **right** and toggles the popover — handy for ### Min and Max -Bounds apply to the combined date-time. +Bounds apply to the combined date-time. Nothing before year 1 is in range, as HTML's datetime-local input holds no earlier year: without a `min` the year list stops there, and a `min` before it counts as midnight on 1 January of year 1. ```tsx live function example() { @@ -864,7 +864,7 @@ The DateTimeInput prop set is the union of [DateInput](./dateinput.md) and [Time | `onChange` | `(d: Date \| null) => void` | — | Fired when either the date or time portion changes. | | `onOpen` | `() => void` | — | Fired when the popover opens. | | `onClose` | `() => void` | — | Fired when the popover closes. | -| `min` | `Date` | — | Lower bound for the combined date-time. | +| `min` | `Date` | — | Lower bound for the combined date-time. A `min` before year 1 is raised to midnight on 1 January of year 1, where the range starts without one too: HTML's datetime-local input holds no earlier year, so the calendar, the time wheels and typing stop there. | | `max` | `Date` | — | Upper bound for the combined date-time. | | `disabled` | `boolean` | `false` | Disable the input. | | `readOnly` | `boolean` | `false` | Read-only input. | diff --git a/docs/docs/api/form/datetime/timeinput.md b/docs/docs/api/form/datetime/timeinput.md index 3d4555bb4..1247e996a 100644 --- a/docs/docs/api/form/datetime/timeinput.md +++ b/docs/docs/api/form/datetime/timeinput.md @@ -1009,7 +1009,7 @@ Combine `incrementMinutes={5}` (or `15`/`30`) with `min` and `max` to build a ti | `onChange` | `(d: Date \| null) => void` | — | Fired when the value changes. | | `onOpen` | `() => void` | — | Fired when the popover opens. | | `onClose` | `() => void` | — | Fired when the popover closes. | -| `min` | `Date` | — | Earliest selectable time. | +| `min` | `Date` | — | Earliest selectable time. A `min` before year 1 is raised to midnight on 1 January of year 1, and a `value` dated before then is out of range, so the wheels and typing change nothing on it, as in the date pickers. | | `max` | `Date` | — | Latest selectable time. | | `disabled` | `boolean` | `false` | Disable the input. | | `readOnly` | `boolean` | `false` | Make the input read-only. |