Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions bulma-ui/src/form/DateInputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,18 @@ const toIsoValue = (d: Date, granularity: DateGranularity): string => {
return granularity === 'month' ? month : `${month}-${pad2(d.getDate())}`;
};

/** Read a native `type="date"` or `type="month"` value back into a Date. */
/**
* Read a native `type="date"` or `type="month"` value back into a Date. HTML
* allows a year of four or more digits, as `toIsoValue` writes one past 9999.
*/
const fromIsoValue = (s: string, granularity: DateGranularity): Date | null => {
if (granularity === 'month') {
const m = /^(\d{4})-(\d{2})$/.exec(s);
const m = /^(\d{4,})-(\d{2})$/.exec(s);
return m ? makeDate(Number(m[1]), Number(m[2]) - 1) : null;
}
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
const m = /^(\d{4,})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return makeDate(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
Comment thread
claude[bot] marked this conversation as resolved.
};

const DEFAULT_FORMATS: Record<DateGranularity, string> = {
Expand Down
19 changes: 8 additions & 11 deletions bulma-ui/src/form/DateTimeInputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
setTimeOfDay,
clampDate,
isSameDay,
makeDate,
} from './_pickerInternals/dateUtils';
import { Calendar } from './_pickerInternals/Calendar';
import { TimeWheels } from './_pickerInternals/TimeWheels';
Expand All @@ -37,8 +38,9 @@ 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.
const toIsoDateTime = (d: Date, withSeconds: boolean): string => {
const yyyy = d.getFullYear();
const yyyy = String(d.getFullYear()).padStart(4, '0');
Comment thread
claude[bot] marked this conversation as resolved.
const mo = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
Expand All @@ -52,20 +54,15 @@ const toIsoDateTime = (d: Date, withSeconds: boolean): string => {
const fromIsoDateTime = (s: string): Date | null => {
// The HTML datetime-local value may carry fractional seconds (the spec
// allows them and some engines normalize to `:ss.sss`); accept and drop.
// Its year is four or more digits, as `toIsoDateTime` writes one past 9999.
const m =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/.exec(
/^(\d{4,})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/.exec(
s
);
if (!m) return null;
return new Date(
Number(m[1]),
Number(m[2]) - 1,
Number(m[3]),
Number(m[4]),
Number(m[5]),
m[6] ? Number(m[6]) : 0,
0
);
const d = makeDate(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
Comment thread
claude[bot] marked this conversation as resolved.
d.setHours(Number(m[4]), Number(m[5]), m[6] ? Number(m[6]) : 0, 0);
return d;
};

/**
Expand Down
29 changes: 29 additions & 0 deletions bulma-ui/src/form/__tests__/Calendar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { render, fireEvent, act } from '@testing-library/react';
import { Calendar, CalendarProps } from '../_pickerInternals/Calendar';
import { makeDate } from '../_pickerInternals/dateUtils';

const June15_2024 = new Date(2024, 5, 15);

Expand Down Expand Up @@ -187,6 +188,22 @@ describe('Calendar', () => {
expect(disabledCount).toBeGreaterThan(0);
});

it('reads a min and max in a year below 100 as given', () => {
const { container } = render(
<Harness
focusedDate={makeDate(19, 5, 15)}
min={makeDate(19, 5, 10)}
max={makeDate(19, 5, 20)}
/>
);
const enabled = Array.from(
container.querySelectorAll('[role="gridcell"]')
).filter(c => c.getAttribute('aria-disabled') === 'false');
expect(enabled.map(c => Number(c.textContent))).toEqual([
10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
]);
});

it('shouldDisableDate predicate disables matching cells', () => {
const onSelect = jest.fn();
const { container } = render(
Expand Down Expand Up @@ -971,6 +988,18 @@ describe('Calendar month granularity', () => {
expect([picked.getFullYear(), picked.getMonth()]).toEqual([19, 2]);
});

it('reads a min in a year below 100 as given', () => {
const { container } = render(
<PeriodHarness
granularity="month"
focusedDate={makeDate(19, 5, 15)}
min={makeDate(19, 5, 30)}
/>
);
expect(monthCell(container, 'June')).not.toBeDisabled();
expect(monthCell(container, 'May')).toBeDisabled();
});

it('shows caller-supplied month names on the cells', () => {
const names = [
'M1',
Expand Down
58 changes: 58 additions & 0 deletions bulma-ui/src/form/__tests__/DateInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { Field } from '../Field';
import { ConfigProvider } from '../../helpers/Config';
import * as nativeInputSupport from '../_pickerInternals/nativeInputSupport';
import { makeDate } from '../_pickerInternals/dateUtils';

beforeAll(() => {
if (!window.matchMedia) {
Expand Down Expand Up @@ -909,6 +910,51 @@ describe('DateInput native input value handling', () => {
expect(committed.getDate()).toBe(9);
});

it('keeps a year below 100 as given', () => {
const handler = jest.fn();
const { container } = render(
<DateInput
mobileNative={true}
min={makeDate(19, 0, 1)}
max={makeDate(19, 11, 31)}
onChange={handler}
/>
);
const native = container.querySelector(
'input[type="date"]'
) as HTMLInputElement;
expect(native.min).toBe('0019-01-01');
fireEvent.change(native, { target: { value: '0019-03-04' } });
const committed = handler.mock.calls[0][0] as Date;
expect([
committed.getFullYear(),
committed.getMonth(),
committed.getDate(),
]).toEqual([19, 2, 4]);
});

it('round-trips a year past 9999', () => {
const handler = jest.fn();
const { container } = render(
<DateInput
mobileNative={true}
defaultValue={makeDate(10000, 0, 1)}
onChange={handler}
/>
);
const native = container.querySelector(
'input[type="date"]'
) as HTMLInputElement;
expect(native.value).toBe('10000-01-01');
fireEvent.change(native, { target: { value: '10000-03-04' } });
const committed = handler.mock.calls[0][0] as Date;
expect([
committed.getFullYear(),
committed.getMonth(),
committed.getDate(),
]).toEqual([10000, 2, 4]);
});

it('clearing the native input commits null', () => {
const handler = jest.fn();
const { container } = render(
Expand Down Expand Up @@ -1518,6 +1564,18 @@ describe('DateInput month granularity', () => {
expect([committed.getFullYear(), committed.getMonth()]).toEqual([19, 2]);
});

it('reads a year past 9999 back', () => {
const handler = jest.fn();
const { container } = render(
<DateInput granularity="month" mobileNative onChange={handler} />
);
fireEvent.change(native(container), { target: { value: '10000-03' } });
const committed = handler.mock.calls[0][0] as Date;
expect([committed.getFullYear(), committed.getMonth()]).toEqual([
10000, 2,
]);
});

describe('where the browser has no month input', () => {
afterEach(() => jest.restoreAllMocks());

Expand Down
64 changes: 64 additions & 0 deletions bulma-ui/src/form/__tests__/DateTimeInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,70 @@ describe('DateTimeInput', () => {
expect(handler).toHaveBeenLastCalledWith(null);
});

it('native input pads a year below 1000 to four digits', () => {
const early = new Date(2024, 2, 4, 14, 30);
early.setFullYear(19);
const min = new Date(2024, 0, 1, 9, 0);
min.setFullYear(19);
const { container } = render(
<DateTimeInput mobileNative={true} defaultValue={early} min={min} />
);
const native = container.querySelector(
'input[type="datetime-local"]'
) as HTMLInputElement;
// Unpadded, `19-03-04T14:30` is not a valid value and the input shows
// nothing.
expect(native.value).toBe('0019-03-04T14:30');
expect(native.min).toBe('0019-01-01T09:00');
});

it('native input reads a year below 100 back as given', () => {
const handler = jest.fn();
const { container } = render(
<DateTimeInput mobileNative={true} enableSeconds onChange={handler} />
);
const native = container.querySelector(
'input[type="datetime-local"]'
) as HTMLInputElement;
fireEvent.change(native, { target: { value: '0019-03-04T14:30:55' } });
const committed = handler.mock.calls[0][0] as Date;
expect([
committed.getFullYear(),
committed.getMonth(),
committed.getDate(),
committed.getHours(),
committed.getMinutes(),
committed.getSeconds(),
committed.getMilliseconds(),
]).toEqual([19, 2, 4, 14, 30, 55, 0]);
});

it('native input round-trips a year past 9999', () => {
const handler = jest.fn();
const late = new Date(2024, 0, 1, 9, 0);
late.setFullYear(10000);
const { container } = render(
<DateTimeInput
mobileNative={true}
defaultValue={late}
onChange={handler}
/>
);
const native = container.querySelector(
'input[type="datetime-local"]'
) as HTMLInputElement;
expect(native.value).toBe('10000-01-01T09:00');
fireEvent.change(native, { target: { value: '10000-03-04T14:30' } });
const committed = handler.mock.calls[0][0] as Date;
expect([
committed.getFullYear(),
committed.getMonth(),
committed.getDate(),
committed.getHours(),
committed.getMinutes(),
]).toEqual([10000, 2, 4, 14, 30]);
});

it('native input round-trips seconds when enableSeconds', () => {
const handler = jest.fn();
const { container } = render(
Expand Down
39 changes: 39 additions & 0 deletions bulma-ui/src/form/__tests__/dateUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,24 @@ describe('dateUtils', () => {
const end2 = endOfMonth(new Date(2023, 1, 1));
expect(end2.getDate()).toBe(28);
});

it('keeps years below 100 rather than reading them as 19xx', () => {
const early = new Date(2024, 1, 20, 13, 45, 30);
early.setFullYear(19);
expect(startOfDay(early)).toEqual(makeDate(19, 1, 20));
expect(startOfMonth(early)).toEqual(makeDate(19, 1, 1));
const parts = (d: Date) => [
d.getFullYear(),
d.getMonth(),
d.getDate(),
d.getHours(),
d.getMinutes(),
d.getSeconds(),
d.getMilliseconds(),
];
expect(parts(endOfDay(early))).toEqual([19, 1, 20, 23, 59, 59, 999]);
expect(parts(endOfMonth(early))).toEqual([19, 1, 28, 23, 59, 59, 999]);
});
});

describe('arithmetic', () => {
Expand All @@ -114,6 +132,13 @@ describe('dateUtils', () => {
expect(r.getDate()).toBe(28);
});

it('addMonths clamps to the month length of a year below 100', () => {
// Year 0 is a leap year and 1900 is not, so reading it as 1900 would
// clamp to the 28th.
const r = addMonths(makeDate(0, 0, 31), 1);
expect([r.getFullYear(), r.getMonth(), r.getDate()]).toEqual([0, 1, 29]);
});

it('addYears wraps via addMonths', () => {
const r = addYears(new Date(2024, 1, 29), 1);
expect(r.getFullYear()).toBe(2025);
Expand Down Expand Up @@ -395,6 +420,20 @@ describe('dateUtils', () => {
).toBe(false);
});

it('reads a min or max in a year below 100 as given', () => {
const early = makeDate(19, 5, 20);
// A min partway through June 19 leaves the month selectable.
expect(
isPeriodUnselectable(early, 'month', { min: makeDate(19, 5, 10) })
).toBe(false);
// A max at the end of June 19 rules out July 19.
expect(
isPeriodUnselectable(makeDate(19, 6, 1), 'month', {
max: makeDate(19, 5, 30),
})
).toBe(true);
});

it('rules out a period wholly before min or after max without a walk', () => {
const shouldDisableDate = jest.fn(() => false);
expect(
Expand Down
15 changes: 15 additions & 0 deletions bulma-ui/src/form/__tests__/formatters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,21 @@ describe('formatters', () => {
expect(parseDate('')).toBeNull();
});

it('keeps a year below 100 as given', () => {
// The YYYY token displays year 19 as 0019, so the text the field shows
// has to parse back to the same year rather than to 1919 or null.
const d = parseDate('0019-03-04 14:30', 'YYYY-MM-DD HH:mm');
expect(d).not.toBeNull();
expect([
d!.getFullYear(),
d!.getMonth(),
d!.getDate(),
d!.getHours(),
d!.getMinutes(),
]).toEqual([19, 2, 4, 14, 30]);
expect(parseDate('0019-02-29')).toBeNull(); // year 19 is not leap
});

it('parses single-digit D and M tokens', () => {
const d = parseDate('7/6/2024', 'D/M/YYYY');
expect(d).not.toBeNull();
Expand Down
25 changes: 25 additions & 0 deletions bulma-ui/src/form/__tests__/segmentMap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
setAmPm,
segmentIndexAtCaret,
} from '../_pickerInternals/segmentMap';
import { makeDate } from '../_pickerInternals/dateUtils';

describe('buildSegmentMap', () => {
it('parses HH:mm into hours/literal/minutes', () => {
Expand Down Expand Up @@ -274,6 +275,16 @@ describe('incrementSegmentValue (date segments)', () => {
expect(next.getDate()).toBe(29);
});

it('wraps the day by the month length of a year below 100', () => {
// Year 0 is a leap year and 1900 is not, so reading it as 1900 would wrap
// 28 February to the 1st.
const feb28 = makeDate(0, 1, 28);
const next = incrementSegmentValue(daySeg, feb28, 1, false);
expect([next.getFullYear(), next.getMonth(), next.getDate()]).toEqual([
0, 1, 29,
]);
});

it('clamps Feb 29 → Feb 28 when the year increment lands on a non-leap year', () => {
const next = incrementSegmentValue(yearSeg, dmy(2024, 1, 29), 1, false);
expect(next.getFullYear()).toBe(2025);
Expand Down Expand Up @@ -471,6 +482,20 @@ describe('setSegmentValue (date segments)', () => {
expect(r.advance).toBe(true);
});

it('clamps by the month length of a year below 100', () => {
// Year 0 is a leap year and 1900 is not, so reading it as 1900 would
// clamp 29 February to the 28th.
const day = setSegmentValue(daySeg, makeDate(0, 1, 1), '29', false);
expect(day.date.getDate()).toBe(29);
// Typing a four-digit year passes through year 0 on the way.
const year = setSegmentValue(yearSeg, base(2024, 1, 29), '0', false);
expect([
year.date.getFullYear(),
year.date.getMonth(),
year.date.getDate(),
]).toEqual([0, 1, 29]);
});

it('re-clamps the day when a year write crosses a leap boundary', () => {
// Feb 29 2024 → type year 2023 → Feb 28.
const r = setSegmentValue(yearSeg, base(2024, 1, 29), '2023', false);
Expand Down
Loading
Loading