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. |