Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
### Bug Fixes

- `TimePicker`: Fix date parsing for years below 1000 by implementing ISO 8601 compliant year formatting with zero-padding ([#75343](https://github.com/WordPress/gutenberg/pull/75343)).
- `TimePicker`: Prevent Invalid Date from propagating via `onChange` when date fields are cleared ([#75285](https://github.com/WordPress/gutenberg/pull/75285)).
- `Text`: Remove `text-wrap: balance` fallback. Only `text-wrap: pretty` is now used ([#75089](https://github.com/WordPress/gutenberg/pull/75089)).
- `RangeControl`: support forced-colors mode ([#75165](https://github.com/WordPress/gutenberg/pull/75165)).
- `ToggleControl`: Prevent `__nextHasNoMarginBottom` from logging a console warning ([#75296](https://github.com/WordPress/gutenberg/pull/75296)).
- `createSlotFill`: fix ref forwarding ([#75274](https://github.com/WordPress/gutenberg/pull/75274)).

### Enhancements

- `TimePicker`, `DateTimePicker`: Add `minYear` prop to control minimum selectable year. Defaults to 1, enabling support for historical dates. Post scheduling continues to restrict dates to the current year and future via the `minYear` prop ([#75285](https://github.com/WordPress/gutenberg/pull/75285)).
- Improve visual emphasis of the selected item in the ToggleGroupControl component ([#75138](https://github.com/WordPress/gutenberg/pull/75138)).

### Internal
Expand Down
24 changes: 24 additions & 0 deletions packages/components/src/date-time/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,27 @@ The day that the week should start on. 0 for Sunday, 1 for Monday, etc.

- Required: No
- Default: 0

### `minYear`: `number`

The minimum year that can be selected in the year input field.

- Required: No
- Default: `1`

**Usage examples:**

```jsx
// Default behavior - allows years from 1 onwards
<DateTimePicker
currentDate={ date }
onChange={ setDate }
/>

// For post scheduling - restrict to current year and future only
<DateTimePicker
currentDate={ date }
onChange={ setDate }
minYear={ new Date().getFullYear() }
/>
```
2 changes: 2 additions & 0 deletions packages/components/src/date-time/date-time/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function UnforwardedDateTimePicker(
onChange,
events,
startOfWeek,
minYear = 1,
}: DateTimePickerProps,
ref: ForwardedRef< any >
) {
Expand All @@ -41,6 +42,7 @@ function UnforwardedDateTimePicker(
onChange={ onChange }
is12Hour={ is12Hour }
dateOrder={ dateOrder }
minYear={ minYear }
/>
<DatePicker
currentDate={ currentDate }
Expand Down
27 changes: 20 additions & 7 deletions packages/components/src/date-time/time/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function TimePicker( {
onChange,
dateOrder: dateOrderProp,
hideLabelFromVision = false,
minYear = 1,
}: TimePickerProps ) {
const [ date, setDate ] = useState( () =>
// Truncate the date at the minutes, see: #15495.
Expand Down Expand Up @@ -106,8 +107,23 @@ export function TimePicker( {
[ date ]
);

const updateDate = ( newDate: Date ) => {
// Guard against Invalid Date propagation.
if (
newDate instanceof Date &&
! Number.isFinite( newDate.getTime() )
) {
return;
}
Comment on lines +112 to +117

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this check necessary now? How can the user input an invalid date?


setDate( newDate );
onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) );
};

const buildNumberControlChangeCallback = ( method: 'date' | 'year' ) => {
const callback: InputChangeCallback = ( value, { event } ) => {
// validateInputElementTarget checks event.target.validity.valid,
// which catches empty values via the 'required' attribute
if ( ! validateInputElementTarget( event ) ) {
return;
}
Expand All @@ -120,8 +136,7 @@ export function TimePicker( {
const newDate = setInConfiguredTimezone( date, {
[ method ]: numberValue,
} );
setDate( newDate );
onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) );
updateDate( newDate );
};
return callback;
};
Expand All @@ -136,8 +151,7 @@ export function TimePicker( {
hours: newHours,
minutes: newMinutes,
} );
setDate( newDate );
onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) );
updateDate( newDate );
};

const dayField = (
Expand Down Expand Up @@ -175,8 +189,7 @@ export function TimePicker( {
const newDate = setInConfiguredTimezone( date, {
month: Number( value ) - 1,
} );
setDate( newDate );
onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) );
updateDate( newDate );
} }
/>
</MonthSelectWrapper>
Expand All @@ -191,7 +204,7 @@ export function TimePicker( {
__next40pxDefaultSize
value={ year }
step={ 1 }
min={ 1 }
min={ minYear }
max={ 9999 }
required
spinControls="none"
Expand Down
232 changes: 232 additions & 0 deletions packages/components/src/date-time/time/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -633,4 +633,236 @@ describe( 'TimePicker', () => {
expect( outputDate ).not.toContain( 'Invalid' );
} );
} );

describe( 'Invalid date handling', () => {
it( 'should not emit invalid dates when year is cleared and blurred', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this test necessary? It seems 90% like a repetition of the test introduced in #75343.

If we want to keep both tests, let's ensure we remove the repetition, no need to test the same things multiple times.

const user = userEvent.setup();
const onChangeSpy = jest.fn();

render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);

const yearInput = screen.getByLabelText( 'Year' );

// Clear the year field and blur
await user.clear( yearInput );
await user.keyboard( '{Tab}' );

// Verify the input field value after browser coercion
expect( yearInput ).toHaveValue( 1 );

// When the year is cleared, the browser may coerce to minYear.
// If onChange is called, it should only emit valid date strings.
onChangeSpy.mock.calls.forEach( ( call ) => {
const dateString = call[ 0 ];
expect( dateString ).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/
);
expect( dateString ).not.toContain( 'NaN' );
expect( dateString ).not.toContain( 'Invalid' );
} );
} );

it( 'should not call onChange with an invalid date when day is cleared and month is changed', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();

render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);

const dayInput = screen.getByLabelText( 'Day' );
const monthInput = screen.getByLabelText( 'Month' );

// Clear the day field
await user.clear( dayInput );
// Change the month (this would create an invalid date if day is 0)
await user.selectOptions( monthInput, '12' );

// Verify the input field value after browser coercion
expect( dayInput ).toHaveValue( 1 );

// Verify that if onChange was called, it only received valid date strings.
// Our guard should prevent invalid dates from being emitted, so any calls
// should have properly formatted dates without "NaN" or "Invalid".
onChangeSpy.mock.calls.forEach( ( call ) => {
const dateString = call[ 0 ];
expect( dateString ).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/
);
expect( dateString ).not.toContain( 'NaN' );
expect( dateString ).not.toContain( 'Invalid' );
} );
} );

it( 'should handle years below 1000 when minYear allows them', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();

render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
minYear={ 1 }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this test relevant? minYear is 1 by default, so isn't this test the same as the one above?

is12Hour
/>
);

const yearInput = screen.getByLabelText( 'Year' );

// Clear the year field and blur - browser will coerce to minYear (1)
await user.clear( yearInput );
await user.keyboard( '{Tab}' );

// Verify the input field value after browser coercion to minYear
expect( yearInput ).toHaveValue( 1 );

// Verify that if onChange was called, it only received valid date strings
onChangeSpy.mock.calls.forEach( ( call ) => {
const dateString = call[ 0 ];
expect( dateString ).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/
);
expect( dateString ).not.toContain( 'NaN' );
expect( dateString ).not.toContain( 'Invalid' );
} );
} );
} );

describe( 'Years below 1000 support', () => {
it( 'should handle year 999 with zero-padded output', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();

render(
<TimePicker
currentTime="0999-10-18T11:00:00"
onChange={ onChangeSpy }
minYear={ 1 }
/>
);

const yearInput = screen.getByLabelText( 'Year' );

// Verify input displays human-readable year (not zero-padded)
expect( yearInput ).toHaveValue( 999 );

// Change to year 500
await user.clear( yearInput );
await user.type( yearInput, '500' );
await user.keyboard( '{Tab}' );

// Verify input displays human-readable year
expect( yearInput ).toHaveValue( 500 );

// Verify onChange emits zero-padded ISO 8601 format
const lastCall =
onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ];
expect( lastCall[ 0 ] ).toMatch( /^0500-10-18T/ );
expect( lastCall[ 0 ] ).not.toContain( 'NaN' );
} );

it( 'should handle year 1 with zero-padded output', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();

render(
<TimePicker
currentTime="0001-01-01T12:00:00"
onChange={ onChangeSpy }
minYear={ 1 }
/>
);

const yearInput = screen.getByLabelText( 'Year' );

// Verify input displays human-readable year (not zero-padded)
expect( yearInput ).toHaveValue( 1 );

// Change to year 99
await user.clear( yearInput );
await user.type( yearInput, '99' );
await user.keyboard( '{Tab}' );

// Verify input displays human-readable year
expect( yearInput ).toHaveValue( 99 );

// Verify onChange emits zero-padded ISO 8601 format
const lastCall =
onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ];
expect( lastCall[ 0 ] ).toMatch( /^0099-01-01T/ );
} );

it( 'should allow years below 1000 when minYear permits', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();

render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
minYear={ 1 }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to specify minYear now that it's 1 by default?

/>
);

const yearInput = screen.getByLabelText( 'Year' );

// Verify initial input displays correctly
expect( yearInput ).toHaveValue( 1986 );

// Type year 500 - should be allowed
await user.clear( yearInput );
await user.type( yearInput, '500' );
await user.keyboard( '{Tab}' );

// Verify input displays human-readable year (not zero-padded)
expect( yearInput ).toHaveValue( 500 );

// Verify onChange was called with zero-padded ISO 8601 format
expect( onChangeSpy ).toHaveBeenCalled();
const lastCall =
onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ];
expect( lastCall[ 0 ] ).toMatch( /^0500-/ );
} );

it( 'should handle year 500 correctly', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What meaningful addition does this contribute in addition to the "should handle year 999 with zero-padded output" test?

const user = userEvent.setup();
const onChangeSpy = jest.fn();

render(
<TimePicker
currentTime="0500-06-15T09:30:00"
onChange={ onChangeSpy }
minYear={ 1 }
/>
);

const yearInput = screen.getByLabelText( 'Year' );

// Verify input displays human-readable year (not zero-padded)
expect( yearInput ).toHaveValue( 500 );

// Change to year 750
await user.clear( yearInput );
await user.type( yearInput, '750' );
await user.keyboard( '{Tab}' );

// Verify input displays human-readable year
expect( yearInput ).toHaveValue( 750 );

// Verify onChange emits zero-padded ISO 8601 format
const lastCall =
onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ];
expect( lastCall[ 0 ] ).toMatch( /^0750-06-15T/ );
} );
} );
} );
7 changes: 7 additions & 0 deletions packages/components/src/date-time/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export type TimePickerProps = {
* @default false
*/
hideLabelFromVision?: boolean;

/**
* The minimum year that can be selected in the year input field.
*
* @default 1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tyxla Shall we just default to 1900 or something realistic?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm totally fine with keeping it 1. It was 1 so far so 🤷‍♂️

*/
minYear?: number;
};

export type TimeInputValue = {
Expand Down
Loading
Loading