fix: prevent asset conflicts between React and Grid.js versions

Add coexistence checks to all enqueue methods to prevent loading
both React and Grid.js assets simultaneously.

Changes:
- ReactAdmin.php: Only enqueue React assets when ?react=1
- Init.php: Skip Grid.js when React active on admin pages
- Form.php, Coupon.php, Access.php: Restore classic assets when ?react=0
- Customer.php, Product.php, License.php: Add coexistence checks

Now the toggle between Classic and React versions works correctly.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
dwindown
2026-04-18 17:02:14 +07:00
parent bd9cdac02e
commit e8fbfb14c1
74973 changed files with 6658406 additions and 71 deletions

View File

@@ -0,0 +1,83 @@
# DateTimePicker
DateTimePicker is a React component that renders a calendar and clock for date and time selection. The calendar and clock components can be accessed individually using the `DatePicker` and `TimePicker` components respectively.
![Date Time component](https://wordpress.org/gutenberg/files/2019/07/date-time-picker.png)
## Best practices
Date pickers should:
- Use smart defaults and highlight the current date.
## Usage
Render a DateTimePicker.
```jsx
import { useState } from 'react';
import { DateTimePicker } from '@wordpress/components';
const MyDateTimePicker = () => {
const [ date, setDate ] = useState( new Date() );
return (
<DateTimePicker
currentDate={ date }
onChange={ ( newDate ) => setDate( newDate ) }
is12Hour={ true }
/>
);
};
```
## Props
The component accepts the following props:
### `currentDate`: `Date | string | number | null`
The current date and time at initialization. Optionally pass in a `null` value to specify no date is currently selected.
- Required: No
- Default: today's date
### `onChange`: `( date: string | null ) => void`
The function called when a new date or time has been selected. It is passed the `currentDate` as an argument.
- Required: No
### `is12Hour`: `boolean`
Whether we use a 12-hour clock. With a 12-hour clock, an AM/PM widget is displayed and the time format is assumed to be `MM-DD-YYYY` (as opposed to the default format `DD-MM-YYYY`).
- Type: `bool`
- Required: No
- Default: false
### `isInvalidDate`: `( date: Date ) => boolean`
A callback function which receives a Date object representing a day as an argument, and should return a Boolean to signify if the day is valid or not.
- Required: No
### `onMonthPreviewed`: `( date: Date ) => void`
A callback invoked when selecting the previous/next month in the date picker. The callback receives the new month date in the ISO format as an argument.
- Required: No
### `events`: `{ date: Date }[]`
List of events to show in the date picker. Each event will appear as a dot on the day of the event.
- Type: `Array`
- Required: No
### `startOfWeek`: `number`
The day that the week should start on. 0 for Sunday, 1 for Monday, etc.
- Required: No
- Default: 0

View File

@@ -0,0 +1 @@
export const TIMEZONELESS_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";

View File

@@ -0,0 +1,81 @@
/**
* External dependencies
*/
import type { ForwardedRef } from 'react';
/**
* WordPress dependencies
*/
import { forwardRef } from '@wordpress/element';
import { __, _x } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { default as DatePicker } from '../date';
import { default as TimePicker } from '../time';
import type { DateTimePickerProps } from '../types';
import { Wrapper } from './styles';
export { DatePicker, TimePicker };
const noop = () => {};
function UnforwardedDateTimePicker(
{
currentDate,
is12Hour,
isInvalidDate,
onMonthPreviewed = noop,
onChange,
events,
startOfWeek,
}: DateTimePickerProps,
ref: ForwardedRef< any >
) {
return (
<Wrapper ref={ ref } className="components-datetime" spacing={ 4 }>
<>
<TimePicker
currentTime={ currentDate }
onChange={ onChange }
is12Hour={ is12Hour }
/>
<DatePicker
currentDate={ currentDate }
onChange={ onChange }
isInvalidDate={ isInvalidDate }
events={ events }
onMonthPreviewed={ onMonthPreviewed }
startOfWeek={ startOfWeek }
/>
</>
</Wrapper>
);
}
/**
* DateTimePicker is a React component that renders a calendar and clock for
* date and time selection. The calendar and clock components can be accessed
* individually using the `DatePicker` and `TimePicker` components respectively.
*
* ```jsx
* import { DateTimePicker } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const MyDateTimePicker = () => {
* const [ date, setDate ] = useState( new Date() );
*
* return (
* <DateTimePicker
* currentDate={ date }
* onChange={ ( newDate ) => setDate( newDate ) }
* is12Hour
* />
* );
* };
* ```
*/
export const DateTimePicker = forwardRef( UnforwardedDateTimePicker );
export default DateTimePicker;

View File

@@ -0,0 +1,13 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import { VStack } from '../../v-stack';
export const Wrapper = styled( VStack )`
box-sizing: border-box;
`;

View File

@@ -0,0 +1,364 @@
/**
* External dependencies
*/
import { useLilius } from 'use-lilius';
import {
format,
isSameDay,
subMonths,
addMonths,
startOfDay,
isEqual,
addDays,
subWeeks,
addWeeks,
isSameMonth,
startOfWeek,
endOfWeek,
} from 'date-fns';
import type { KeyboardEventHandler } from 'react';
/**
* WordPress dependencies
*/
import { __, _n, sprintf, isRTL } from '@wordpress/i18n';
import { arrowLeft, arrowRight } from '@wordpress/icons';
import { dateI18n, getSettings } from '@wordpress/date';
import { useState, useRef, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import type { DatePickerProps } from '../types';
import {
Wrapper,
Navigator,
NavigatorHeading,
Calendar,
DayOfWeek,
DayButton,
} from './styles';
import { inputToDate } from '../utils';
import Button from '../../button';
import { TIMEZONELESS_FORMAT } from '../constants';
/**
* DatePicker is a React component that renders a calendar for date selection.
*
* ```jsx
* import { DatePicker } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const MyDatePicker = () => {
* const [ date, setDate ] = useState( new Date() );
*
* return (
* <DatePicker
* currentDate={ date }
* onChange={ ( newDate ) => setDate( newDate ) }
* />
* );
* };
* ```
*/
export function DatePicker( {
currentDate,
onChange,
events = [],
isInvalidDate,
onMonthPreviewed,
startOfWeek: weekStartsOn = 0,
}: DatePickerProps ) {
const date = currentDate ? inputToDate( currentDate ) : new Date();
const {
calendar,
viewing,
setSelected,
setViewing,
isSelected,
viewPreviousMonth,
viewNextMonth,
} = useLilius( {
selected: [ startOfDay( date ) ],
viewing: startOfDay( date ),
weekStartsOn,
} );
// Used to implement a roving tab index. Tracks the day that receives focus
// when the user tabs into the calendar.
const [ focusable, setFocusable ] = useState( startOfDay( date ) );
// Allows us to only programmatically focus() a day when focus was already
// within the calendar. This stops us stealing focus from e.g. a TimePicker
// input.
const [ isFocusWithinCalendar, setIsFocusWithinCalendar ] =
useState( false );
// Update internal state when currentDate prop changes.
const [ prevCurrentDate, setPrevCurrentDate ] = useState( currentDate );
if ( currentDate !== prevCurrentDate ) {
setPrevCurrentDate( currentDate );
setSelected( [ startOfDay( date ) ] );
setViewing( startOfDay( date ) );
setFocusable( startOfDay( date ) );
}
return (
<Wrapper
className="components-datetime__date"
role="application"
aria-label={ __( 'Calendar' ) }
>
<Navigator>
<Button
icon={ isRTL() ? arrowRight : arrowLeft }
variant="tertiary"
aria-label={ __( 'View previous month' ) }
onClick={ () => {
viewPreviousMonth();
setFocusable( subMonths( focusable, 1 ) );
onMonthPreviewed?.(
format(
subMonths( viewing, 1 ),
TIMEZONELESS_FORMAT
)
);
} }
/>
<NavigatorHeading level={ 3 }>
<strong>
{ dateI18n(
'F',
viewing,
-viewing.getTimezoneOffset()
) }
</strong>{ ' ' }
{ dateI18n( 'Y', viewing, -viewing.getTimezoneOffset() ) }
</NavigatorHeading>
<Button
icon={ isRTL() ? arrowLeft : arrowRight }
variant="tertiary"
aria-label={ __( 'View next month' ) }
onClick={ () => {
viewNextMonth();
setFocusable( addMonths( focusable, 1 ) );
onMonthPreviewed?.(
format(
addMonths( viewing, 1 ),
TIMEZONELESS_FORMAT
)
);
} }
/>
</Navigator>
<Calendar
onFocus={ () => setIsFocusWithinCalendar( true ) }
onBlur={ () => setIsFocusWithinCalendar( false ) }
>
{ calendar[ 0 ][ 0 ].map( ( day ) => (
<DayOfWeek key={ day.toString() }>
{ dateI18n( 'D', day, -day.getTimezoneOffset() ) }
</DayOfWeek>
) ) }
{ calendar[ 0 ].map( ( week ) =>
week.map( ( day, index ) => {
if ( ! isSameMonth( day, viewing ) ) {
return null;
}
return (
<Day
key={ day.toString() }
day={ day }
column={ index + 1 }
isSelected={ isSelected( day ) }
isFocusable={ isEqual( day, focusable ) }
isFocusAllowed={ isFocusWithinCalendar }
isToday={ isSameDay( day, new Date() ) }
isInvalid={
isInvalidDate ? isInvalidDate( day ) : false
}
numEvents={
events.filter( ( event ) =>
isSameDay( event.date, day )
).length
}
onClick={ () => {
setSelected( [ day ] );
setFocusable( day );
onChange?.(
format(
// Don't change the selected date's time fields.
new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds()
),
TIMEZONELESS_FORMAT
)
);
} }
onKeyDown={ ( event ) => {
let nextFocusable;
if ( event.key === 'ArrowLeft' ) {
nextFocusable = addDays(
day,
isRTL() ? 1 : -1
);
}
if ( event.key === 'ArrowRight' ) {
nextFocusable = addDays(
day,
isRTL() ? -1 : 1
);
}
if ( event.key === 'ArrowUp' ) {
nextFocusable = subWeeks( day, 1 );
}
if ( event.key === 'ArrowDown' ) {
nextFocusable = addWeeks( day, 1 );
}
if ( event.key === 'PageUp' ) {
nextFocusable = subMonths( day, 1 );
}
if ( event.key === 'PageDown' ) {
nextFocusable = addMonths( day, 1 );
}
if ( event.key === 'Home' ) {
nextFocusable = startOfWeek( day );
}
if ( event.key === 'End' ) {
nextFocusable = startOfDay(
endOfWeek( day )
);
}
if ( nextFocusable ) {
event.preventDefault();
setFocusable( nextFocusable );
if (
! isSameMonth(
nextFocusable,
viewing
)
) {
setViewing( nextFocusable );
onMonthPreviewed?.(
format(
nextFocusable,
TIMEZONELESS_FORMAT
)
);
}
}
} }
/>
);
} )
) }
</Calendar>
</Wrapper>
);
}
type DayProps = {
day: Date;
column: number;
isSelected: boolean;
isFocusable: boolean;
isFocusAllowed: boolean;
isToday: boolean;
numEvents: number;
isInvalid: boolean;
onClick: () => void;
onKeyDown: KeyboardEventHandler;
};
function Day( {
day,
column,
isSelected,
isFocusable,
isFocusAllowed,
isToday,
isInvalid,
numEvents,
onClick,
onKeyDown,
}: DayProps ) {
const ref = useRef< HTMLButtonElement >();
// Focus the day when it becomes focusable, e.g. because an arrow key is
// pressed. Only do this if focus is allowed - this stops us stealing focus
// from e.g. a TimePicker input.
useEffect( () => {
if ( ref.current && isFocusable && isFocusAllowed ) {
ref.current.focus();
}
// isFocusAllowed is not a dep as there is no point calling focus() on
// an already focused element.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isFocusable ] );
return (
<DayButton
ref={ ref }
className="components-datetime__date__day" // Unused, for backwards compatibility.
disabled={ isInvalid }
tabIndex={ isFocusable ? 0 : -1 }
aria-label={ getDayLabel( day, isSelected, numEvents ) }
column={ column }
isSelected={ isSelected }
isToday={ isToday }
hasEvents={ numEvents > 0 }
onClick={ onClick }
onKeyDown={ onKeyDown }
>
{ dateI18n( 'j', day, -day.getTimezoneOffset() ) }
</DayButton>
);
}
function getDayLabel( date: Date, isSelected: boolean, numEvents: number ) {
const { formats } = getSettings();
const localizedDate = dateI18n(
formats.date,
date,
-date.getTimezoneOffset()
);
if ( isSelected && numEvents > 0 ) {
return sprintf(
// translators: 1: The calendar date. 2: Number of events on the calendar date.
_n(
'%1$s. Selected. There is %2$d event',
'%1$s. Selected. There are %2$d events',
numEvents
),
localizedDate,
numEvents
);
} else if ( isSelected ) {
return sprintf(
// translators: %s: The calendar date.
__( '%1$s. Selected' ),
localizedDate
);
} else if ( numEvents > 0 ) {
return sprintf(
// translators: 1: The calendar date. 2: Number of events on the calendar date.
_n(
'%1$s. There is %2$d event',
'%1$s. There are %2$d events',
numEvents
),
localizedDate,
numEvents
);
}
return localizedDate;
}
export default DatePicker;

View File

@@ -0,0 +1,120 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
/**
* Internal dependencies
*/
import Button from '../../button';
import { COLORS, CONFIG } from '../../utils';
import { HStack } from '../../h-stack';
import { Heading } from '../../heading';
import { space } from '../../utils/space';
export const Wrapper = styled.div`
box-sizing: border-box;
`;
export const Navigator = styled( HStack )`
margin-bottom: ${ space( 4 ) };
`;
export const NavigatorHeading = styled( Heading )`
font-size: ${ CONFIG.fontSize };
font-weight: ${ CONFIG.fontWeight };
strong {
font-weight: ${ CONFIG.fontWeightHeading };
}
`;
export const Calendar = styled.div`
column-gap: ${ space( 2 ) };
display: grid;
grid-template-columns: 0.5fr repeat( 5, 1fr ) 0.5fr;
justify-items: center;
row-gap: ${ space( 2 ) };
`;
export const DayOfWeek = styled.div`
color: ${ COLORS.gray[ 700 ] };
font-size: ${ CONFIG.fontSize };
line-height: ${ CONFIG.fontLineHeightBase };
&:nth-of-type( 1 ) {
justify-self: start;
}
&:nth-of-type( 7 ) {
justify-self: end;
}
`;
export const DayButton = styled( Button, {
shouldForwardProp: ( prop: string ) =>
! [ 'column', 'isSelected', 'isToday', 'hasEvents' ].includes( prop ),
} )< {
column: number;
isSelected: boolean;
isToday: boolean;
hasEvents: boolean;
} >`
grid-column: ${ ( props ) => props.column };
position: relative;
justify-content: center;
${ ( props ) =>
props.column === 1 &&
`
justify-self: start;
` }
${ ( props ) =>
props.column === 7 &&
`
justify-self: end;
` }
${ ( props ) =>
props.disabled &&
`
pointer-events: none;
` }
&&& {
border-radius: 100%;
height: ${ space( 8 ) };
width: ${ space( 8 ) };
${ ( props ) =>
props.isSelected &&
`
background: ${ COLORS.theme.accent };
color: ${ COLORS.white };
` }
${ ( props ) =>
! props.isSelected &&
props.isToday &&
`
background: ${ COLORS.gray[ 200 ] };
` }
}
${ ( props ) =>
props.hasEvents &&
`
::before {
background: ${ props.isSelected ? COLORS.white : COLORS.theme.accent };
border-radius: 2px;
bottom: 2px;
content: " ";
height: 4px;
left: 50%;
margin-left: -2px;
position: absolute;
width: 4px;
}
` }
`;

View File

@@ -0,0 +1,117 @@
/**
* External dependencies
*/
import { format } from 'date-fns';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import DatePicker from '..';
describe( 'DatePicker', () => {
it( 'should highlight the current date', () => {
render( <DatePicker currentDate="2022-05-02T11:00:00" /> );
expect(
screen.getByRole( 'button', { name: 'May 2, 2022. Selected' } )
).toBeInTheDocument();
} );
it( "should highlight today's date when not provided a currentDate", () => {
render( <DatePicker /> );
const todayDescription = format( new Date(), 'MMMM d, yyyy' );
expect(
screen.getByRole( 'button', {
name: `${ todayDescription }. Selected`,
} )
).toBeInTheDocument();
} );
it( 'should call onChange when a day is selected', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DatePicker
currentDate="2022-05-02T11:00:00"
onChange={ onChange }
/>
);
await user.click(
screen.getByRole( 'button', { name: 'May 20, 2022' } )
);
expect( onChange ).toHaveBeenCalledWith( '2022-05-20T11:00:00' );
} );
it( 'should call onMonthPreviewed and onChange when a day in a different month is selected', async () => {
const user = userEvent.setup();
const onMonthPreviewed = jest.fn();
const onChange = jest.fn();
render(
<DatePicker
currentDate="2022-05-02T11:00:00"
onMonthPreviewed={ onMonthPreviewed }
onChange={ onChange }
/>
);
await user.click(
screen.getByRole( 'button', {
name: 'View next month',
} )
);
expect( onMonthPreviewed ).toHaveBeenCalledWith(
expect.stringMatching( /^2022-06/ )
);
await user.click(
screen.getByRole( 'button', { name: 'June 20, 2022' } )
);
expect( onChange ).toHaveBeenCalledWith( '2022-06-20T11:00:00' );
} );
it( 'should highlight events on the calendar', () => {
render(
<DatePicker
currentDate="2022-05-02T11:00:00"
events={ [
{ date: new Date( '2022-05-04T00:00:00' ) },
{ date: new Date( '2022-05-19T00:00:00' ) },
] }
/>
);
expect(
screen
.getAllByLabelText( 'There is 1 event', { exact: false } )
.map( ( day ) => day.getAttribute( 'aria-label' ) )
).toEqual( [
'May 4, 2022. There is 1 event',
'May 19, 2022. There is 1 event',
] );
} );
it( 'should not allow invalid date to be selected', async () => {
render(
<DatePicker
currentDate="2022-05-02T11:00:00"
isInvalidDate={ ( date ) => date.getDate() === 20 }
/>
);
const button = screen.getByRole( 'button', {
name: 'May 20, 2022',
} ) as HTMLButtonElement;
expect( button ).toBeDisabled();
} );
} );

View File

@@ -0,0 +1,9 @@
/**
* Internal dependencies
*/
import { default as DatePicker } from './date';
import { default as TimePicker } from './time';
import { default as DateTimePicker } from './date-time';
export { DatePicker, TimePicker };
export default DateTimePicker;

View File

@@ -0,0 +1,71 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import DateTimePicker from '../date-time';
import { daysFromNow, isWeekend } from './utils';
const meta: Meta< typeof DateTimePicker > = {
title: 'Components/DateTimePicker',
component: DateTimePicker,
argTypes: {
currentDate: { control: 'date' },
onChange: { action: 'onChange', control: { type: null } },
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const Template: StoryFn< typeof DateTimePicker > = ( {
currentDate,
onChange,
...args
} ) => {
const [ date, setDate ] = useState( currentDate );
useEffect( () => {
setDate( currentDate );
}, [ currentDate ] );
return (
<DateTimePicker
{ ...args }
currentDate={ date }
onChange={ ( newDate ) => {
setDate( newDate );
onChange?.( newDate );
} }
/>
);
};
export const Default: StoryFn< typeof DateTimePicker > = Template.bind( {} );
export const WithEvents: StoryFn< typeof DateTimePicker > = Template.bind( {} );
WithEvents.args = {
currentDate: new Date(),
events: [
{ date: daysFromNow( 2 ) },
{ date: daysFromNow( 4 ) },
{ date: daysFromNow( 6 ) },
{ date: daysFromNow( 8 ) },
],
};
export const WithInvalidDates: StoryFn< typeof DateTimePicker > = Template.bind(
{}
);
WithInvalidDates.args = {
currentDate: new Date(),
isInvalidDate: isWeekend,
};

View File

@@ -0,0 +1,71 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import DatePicker from '../date';
import { daysFromNow, isWeekend } from './utils';
const meta: Meta< typeof DatePicker > = {
title: 'Components/DatePicker',
component: DatePicker,
argTypes: {
currentDate: { control: 'date' },
onChange: { action: 'onChange', control: { type: null } },
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const Template: StoryFn< typeof DatePicker > = ( {
currentDate,
onChange,
...args
} ) => {
const [ date, setDate ] = useState( currentDate );
useEffect( () => {
setDate( currentDate );
}, [ currentDate ] );
return (
<DatePicker
{ ...args }
currentDate={ date }
onChange={ ( newDate ) => {
setDate( newDate );
onChange?.( newDate );
} }
/>
);
};
export const Default: StoryFn< typeof DatePicker > = Template.bind( {} );
export const WithEvents: StoryFn< typeof DatePicker > = Template.bind( {} );
WithEvents.args = {
currentDate: new Date(),
events: [
{ date: daysFromNow( 2 ) },
{ date: daysFromNow( 4 ) },
{ date: daysFromNow( 6 ) },
{ date: daysFromNow( 8 ) },
],
};
export const WithInvalidDates: StoryFn< typeof DatePicker > = Template.bind(
{}
);
WithInvalidDates.args = {
currentDate: new Date(),
isInvalidDate: isWeekend,
};

View File

@@ -0,0 +1,51 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
/**
* WordPress dependencies
*/
import { useState, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import TimePicker from '../time';
const meta: Meta< typeof TimePicker > = {
title: 'Components/TimePicker',
component: TimePicker,
argTypes: {
currentTime: { control: 'date' },
onChange: { action: 'onChange', control: { type: null } },
},
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
};
export default meta;
const Template: StoryFn< typeof TimePicker > = ( {
currentTime,
onChange,
...args
} ) => {
const [ time, setTime ] = useState( currentTime );
useEffect( () => {
setTime( currentTime );
}, [ currentTime ] );
return (
<TimePicker
{ ...args }
currentTime={ time }
onChange={ ( newTime ) => {
setTime( newTime );
onChange?.( newTime );
} }
/>
);
};
export const Default: StoryFn< typeof TimePicker > = Template.bind( {} );

View File

@@ -0,0 +1,9 @@
export function daysFromNow( days: number ) {
const date = new Date();
date.setDate( date.getDate() + days );
return date;
}
export function isWeekend( date: Date ) {
return date.getDay() === 0 || date.getDay() === 6;
}

View File

@@ -0,0 +1,372 @@
/**
* External dependencies
*/
import { startOfMinute, format, set, setHours, setMonth } from 'date-fns';
/**
* WordPress dependencies
*/
import { useState, useMemo, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import BaseControl from '../../base-control';
import Button from '../../button';
import ButtonGroup from '../../button-group';
import SelectControl from '../../select-control';
import TimeZone from './timezone';
import type { TimePickerProps } from '../types';
import {
Wrapper,
Fieldset,
HoursInput,
TimeSeparator,
MinutesInput,
MonthSelectWrapper,
DayInput,
YearInput,
TimeWrapper,
} from './styles';
import { HStack } from '../../h-stack';
import { Spacer } from '../../spacer';
import type { InputChangeCallback } from '../../input-control/types';
import type { InputState } from '../../input-control/reducer/state';
import type { InputAction } from '../../input-control/reducer/actions';
import {
COMMIT,
PRESS_DOWN,
PRESS_UP,
} from '../../input-control/reducer/actions';
import { inputToDate } from '../utils';
import { TIMEZONELESS_FORMAT } from '../constants';
function from12hTo24h( hours: number, isPm: boolean ) {
return isPm ? ( ( hours % 12 ) + 12 ) % 24 : hours % 12;
}
/**
* Creates an InputControl reducer used to pad an input so that it is always a
* given width. For example, the hours and minutes inputs are padded to 2 so
* that '4' appears as '04'.
*
* @param pad How many digits the value should be.
*/
function buildPadInputStateReducer( pad: number ) {
return ( state: InputState, action: InputAction ) => {
const nextState = { ...state };
if (
action.type === COMMIT ||
action.type === PRESS_UP ||
action.type === PRESS_DOWN
) {
if ( nextState.value !== undefined ) {
nextState.value = nextState.value
.toString()
.padStart( pad, '0' );
}
}
return nextState;
};
}
/**
* TimePicker is a React component that renders a clock for time selection.
*
* ```jsx
* import { TimePicker } from '@wordpress/components';
* import { useState } from '@wordpress/element';
*
* const MyTimePicker = () => {
* const [ time, setTime ] = useState( new Date() );
*
* return (
* <TimePicker
* currentTime={ date }
* onChange={ ( newTime ) => setTime( newTime ) }
* is12Hour
* />
* );
* };
* ```
*/
export function TimePicker( {
is12Hour,
currentTime,
onChange,
}: TimePickerProps ) {
const [ date, setDate ] = useState( () =>
// Truncate the date at the minutes, see: #15495.
currentTime ? startOfMinute( inputToDate( currentTime ) ) : new Date()
);
// Reset the state when currentTime changed.
// TODO: useEffect() shouldn't be used like this, causes an unnecessary render
useEffect( () => {
setDate(
currentTime
? startOfMinute( inputToDate( currentTime ) )
: new Date()
);
}, [ currentTime ] );
const { day, month, year, minutes, hours, am } = useMemo(
() => ( {
day: format( date, 'dd' ),
month: format( date, 'MM' ),
year: format( date, 'yyyy' ),
minutes: format( date, 'mm' ),
hours: format( date, is12Hour ? 'hh' : 'HH' ),
am: format( date, 'a' ),
} ),
[ date, is12Hour ]
);
const buildNumberControlChangeCallback = (
method: 'hours' | 'minutes' | 'date' | 'year'
) => {
const callback: InputChangeCallback = ( value, { event } ) => {
// `instanceof` checks need to get the instance definition from the
// corresponding window object — therefore, the following logic makes
// the component work correctly even when rendered inside an iframe.
const HTMLInputElementInstance =
( event.target as HTMLInputElement )?.ownerDocument.defaultView
?.HTMLInputElement ?? HTMLInputElement;
if ( ! ( event.target instanceof HTMLInputElementInstance ) ) {
return;
}
if ( ! event.target.validity.valid ) {
return;
}
// We can safely assume value is a number if target is valid.
let numberValue = Number( value );
// If the 12-hour format is being used and the 'PM' period is
// selected, then the incoming value (which ranges 1-12) should be
// increased by 12 to match the expected 24-hour format.
if ( method === 'hours' && is12Hour ) {
numberValue = from12hTo24h( numberValue, am === 'PM' );
}
const newDate = set( date, { [ method ]: numberValue } );
setDate( newDate );
onChange?.( format( newDate, TIMEZONELESS_FORMAT ) );
};
return callback;
};
function buildAmPmChangeCallback( value: 'AM' | 'PM' ) {
return () => {
if ( am === value ) {
return;
}
const parsedHours = parseInt( hours, 10 );
const newDate = setHours(
date,
from12hTo24h( parsedHours, value === 'PM' )
);
setDate( newDate );
onChange?.( format( newDate, TIMEZONELESS_FORMAT ) );
};
}
const dayField = (
<DayInput
className="components-datetime__time-field components-datetime__time-field-day" // Unused, for backwards compatibility.
label={ __( 'Day' ) }
hideLabelFromVision
__next40pxDefaultSize
value={ day }
step={ 1 }
min={ 1 }
max={ 31 }
required
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
onChange={ buildNumberControlChangeCallback( 'date' ) }
/>
);
const monthField = (
<MonthSelectWrapper>
<SelectControl
className="components-datetime__time-field components-datetime__time-field-month" // Unused, for backwards compatibility.
label={ __( 'Month' ) }
hideLabelFromVision
__next40pxDefaultSize
__nextHasNoMarginBottom
value={ month }
options={ [
{ value: '01', label: __( 'January' ) },
{ value: '02', label: __( 'February' ) },
{ value: '03', label: __( 'March' ) },
{ value: '04', label: __( 'April' ) },
{ value: '05', label: __( 'May' ) },
{ value: '06', label: __( 'June' ) },
{ value: '07', label: __( 'July' ) },
{ value: '08', label: __( 'August' ) },
{ value: '09', label: __( 'September' ) },
{ value: '10', label: __( 'October' ) },
{ value: '11', label: __( 'November' ) },
{ value: '12', label: __( 'December' ) },
] }
onChange={ ( value ) => {
const newDate = setMonth( date, Number( value ) - 1 );
setDate( newDate );
onChange?.( format( newDate, TIMEZONELESS_FORMAT ) );
} }
/>
</MonthSelectWrapper>
);
return (
<Wrapper
className="components-datetime__time" // Unused, for backwards compatibility.
>
<Fieldset>
<BaseControl.VisualLabel
as="legend"
className="components-datetime__time-legend" // Unused, for backwards compatibility.
>
{ __( 'Time' ) }
</BaseControl.VisualLabel>
<HStack
className="components-datetime__time-wrapper" // Unused, for backwards compatibility.
>
<TimeWrapper
className="components-datetime__time-field components-datetime__time-field-time" // Unused, for backwards compatibility.
>
<HoursInput
className="components-datetime__time-field-hours-input" // Unused, for backwards compatibility.
label={ __( 'Hours' ) }
hideLabelFromVision
__next40pxDefaultSize
value={ hours }
step={ 1 }
min={ is12Hour ? 1 : 0 }
max={ is12Hour ? 12 : 23 }
required
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
onChange={ buildNumberControlChangeCallback(
'hours'
) }
__unstableStateReducer={ buildPadInputStateReducer(
2
) }
/>
<TimeSeparator
className="components-datetime__time-separator" // Unused, for backwards compatibility.
aria-hidden="true"
>
:
</TimeSeparator>
<MinutesInput
className="components-datetime__time-field-minutes-input" // Unused, for backwards compatibility.
label={ __( 'Minutes' ) }
hideLabelFromVision
__next40pxDefaultSize
value={ minutes }
step={ 1 }
min={ 0 }
max={ 59 }
required
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
onChange={ buildNumberControlChangeCallback(
'minutes'
) }
__unstableStateReducer={ buildPadInputStateReducer(
2
) }
/>
</TimeWrapper>
{ is12Hour && (
<ButtonGroup
className="components-datetime__time-field components-datetime__time-field-am-pm" // Unused, for backwards compatibility.
>
<Button
className="components-datetime__time-am-button" // Unused, for backwards compatibility.
variant={
am === 'AM' ? 'primary' : 'secondary'
}
__next40pxDefaultSize
onClick={ buildAmPmChangeCallback( 'AM' ) }
>
{ __( 'AM' ) }
</Button>
<Button
className="components-datetime__time-pm-button" // Unused, for backwards compatibility.
variant={
am === 'PM' ? 'primary' : 'secondary'
}
__next40pxDefaultSize
onClick={ buildAmPmChangeCallback( 'PM' ) }
>
{ __( 'PM' ) }
</Button>
</ButtonGroup>
) }
<Spacer />
<TimeZone />
</HStack>
</Fieldset>
<Fieldset>
<BaseControl.VisualLabel
as="legend"
className="components-datetime__time-legend" // Unused, for backwards compatibility.
>
{ __( 'Date' ) }
</BaseControl.VisualLabel>
<HStack
className="components-datetime__time-wrapper" // Unused, for backwards compatibility.
>
{ is12Hour ? (
<>
{ monthField }
{ dayField }
</>
) : (
<>
{ dayField }
{ monthField }
</>
) }
<YearInput
className="components-datetime__time-field components-datetime__time-field-year" // Unused, for backwards compatibility.
label={ __( 'Year' ) }
hideLabelFromVision
__next40pxDefaultSize
value={ year }
step={ 1 }
min={ 1 }
max={ 9999 }
required
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
onChange={ buildNumberControlChangeCallback( 'year' ) }
__unstableStateReducer={ buildPadInputStateReducer(
4
) }
/>
</HStack>
</Fieldset>
</Wrapper>
);
}
export default TimePicker;

View File

@@ -0,0 +1,107 @@
/**
* External dependencies
*/
import styled from '@emotion/styled';
import { css } from '@emotion/react';
/**
* Internal dependencies
*/
import { COLORS, CONFIG } from '../../utils';
import { space } from '../../utils/space';
import {
Input,
BackdropUI,
} from '../../input-control/styles/input-control-styles';
import NumberControl from '../../number-control';
export const Wrapper = styled.div`
box-sizing: border-box;
font-size: ${ CONFIG.fontSize };
`;
export const Fieldset = styled.fieldset`
border: 0;
margin: 0 0 ${ space( 2 * 2 ) } 0;
padding: 0;
&:last-child {
margin-bottom: 0;
}
`;
export const TimeWrapper = styled.div`
direction: ltr;
display: flex;
`;
const baseInput = css`
&&& ${ Input } {
padding-left: ${ space( 2 ) };
padding-right: ${ space( 2 ) };
text-align: center;
}
`;
export const HoursInput = styled( NumberControl )`
${ baseInput }
width: ${ space( 9 ) };
&&& ${ Input } {
padding-right: 0;
}
&&& ${ BackdropUI } {
border-right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
`;
export const TimeSeparator = styled.span`
border-top: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 700 ] };
border-bottom: ${ CONFIG.borderWidth } solid ${ COLORS.gray[ 700 ] };
line-height: calc(
${ CONFIG.controlHeight } - ${ CONFIG.borderWidth } * 2
);
display: inline-block;
`;
export const MinutesInput = styled( NumberControl )`
${ baseInput }
width: ${ space( 9 ) };
&&& ${ Input } {
padding-left: 0;
}
&&& ${ BackdropUI } {
border-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
`;
// Ideally we wouldn't need a wrapper, but can't otherwise target the
// <BaseControl> in <SelectControl>
export const MonthSelectWrapper = styled.div`
flex-grow: 1;
`;
export const DayInput = styled( NumberControl )`
${ baseInput }
width: ${ space( 9 ) };
`;
export const YearInput = styled( NumberControl )`
${ baseInput }
width: ${ space( 14 ) };
`;
export const TimeZone = styled.div`
text-decoration: underline dotted;
`;

View File

@@ -0,0 +1,368 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
/**
* Internal dependencies
*/
import TimePicker from '..';
describe( 'TimePicker', () => {
it( 'should call onChange with updated date values', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
const monthInput = screen.getByLabelText( 'Month' );
const dayInput = screen.getByLabelText( 'Day' );
const yearInput = screen.getByLabelText( 'Year' );
const hoursInput = screen.getByLabelText( 'Hours' );
const minutesInput = screen.getByLabelText( 'Minutes' );
await user.selectOptions( monthInput, '12' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-12-18T11:00:00' );
onChangeSpy.mockClear();
await user.clear( dayInput );
await user.type( dayInput, '22' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-12-22T11:00:00' );
onChangeSpy.mockClear();
await user.clear( yearInput );
await user.type( yearInput, '2018' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '2018-12-22T11:00:00' );
onChangeSpy.mockClear();
await user.clear( hoursInput );
await user.type( hoursInput, '12' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '2018-12-22T00:00:00' );
onChangeSpy.mockClear();
await user.clear( minutesInput );
await user.type( minutesInput, '35' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '2018-12-22T00:35:00' );
onChangeSpy.mockClear();
} );
it( 'should call onChange with an updated hour (12-hour clock)', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
const hoursInput = screen.getByLabelText( 'Hours' );
await user.clear( hoursInput );
await user.type( hoursInput, '10' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T10:00:00' );
} );
it( 'should call onChange with a bounded hour (12-hour clock) if the hour is out of bounds', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
const hoursInput = screen.getByLabelText( 'Hours' );
await user.clear( hoursInput );
await user.type( hoursInput, '22' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T00:00:00' );
} );
it( 'should call onChange with an updated hour (24-hour clock)', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour={ false }
/>
);
const hoursInput = screen.getByLabelText( 'Hours' );
await user.clear( hoursInput );
await user.type( hoursInput, '22' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T22:00:00' );
} );
it( 'should call onChange with a bounded minute if out of bounds', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
const minutesInput = screen.getByLabelText( 'Minutes' );
await user.clear( minutesInput );
await user.type( minutesInput, '99' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T11:59:00' );
} );
it( 'should switch to PM correctly', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
const pmButton = screen.getByText( 'PM' );
await user.click( pmButton );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T23:00:00' );
} );
it( 'should switch to AM correctly', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T23:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
const amButton = screen.getByText( 'AM' );
await user.click( amButton );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T11:00:00' );
} );
it( 'should allow to set the time correctly when the PM period is selected', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
const pmButton = screen.getByText( 'PM' );
await user.click( pmButton );
const hoursInput = screen.getByLabelText( 'Hours' );
await user.clear( hoursInput );
await user.type( hoursInput, '6' );
await user.keyboard( '{Tab}' );
// When clicking on 'PM', we expect the time to be 11pm
expect( onChangeSpy ).toHaveBeenNthCalledWith(
1,
'1986-10-18T23:00:00'
);
// When changing the hours to '6', we expect the time to be 6pm
expect( onChangeSpy ).toHaveBeenNthCalledWith(
2,
'1986-10-18T18:00:00'
);
} );
it( 'should truncate at the minutes on change', async () => {
const user = userEvent.setup();
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime="1986-10-18T23:12:35"
onChange={ onChangeSpy }
is12Hour
/>
);
const minutesInput = screen.getByLabelText( 'Minutes' );
await user.clear( minutesInput );
await user.type( minutesInput, '22' );
await user.keyboard( '{Tab}' );
expect( onChangeSpy ).toHaveBeenCalledWith( '1986-10-18T23:22:00' );
} );
it( 'should reset the date when currentTime changed', () => {
const onChangeSpy = jest.fn();
const { rerender } = render(
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
rerender(
<TimePicker
currentTime="2020-07-13T18:00:00"
onChange={ onChangeSpy }
is12Hour
/>
);
expect(
( screen.getByLabelText( 'Month' ) as HTMLInputElement ).value
).toBe( '07' );
expect(
( screen.getByLabelText( 'Day' ) as HTMLInputElement ).value
).toBe( '13' );
expect(
( screen.getByLabelText( 'Year' ) as HTMLInputElement ).value
).toBe( '2020' );
expect(
( screen.getByLabelText( 'Hours' ) as HTMLInputElement ).value
).toBe( '06' );
expect(
( screen.getByLabelText( 'Minutes' ) as HTMLInputElement ).value
).toBe( '00' );
/**
* This is not ideal, but best of we can do for now until we refactor
* AM/PM into accessible elements, like radio buttons.
*/
expect( screen.getByText( 'AM' ) ).not.toHaveClass( 'is-primary' );
expect( screen.getByText( 'PM' ) ).toHaveClass( 'is-primary' );
} );
it( 'should have different layouts/orders for 12/24 hour formats', () => {
const onChangeSpy = jest.fn();
const { rerender } = render(
<form aria-label="form">
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour={ false }
/>
</form>
);
const form = screen.getByRole( 'form' ) as HTMLFormElement;
let monthInputIndex = Array.from( form.elements ).indexOf(
screen.getByLabelText( 'Month' )
);
let dayInputIndex = Array.from( form.elements ).indexOf(
screen.getByLabelText( 'Day' )
);
expect( monthInputIndex > dayInputIndex ).toBe( true );
rerender(
<form aria-label="form">
<TimePicker
currentTime="1986-10-18T11:00:00"
onChange={ onChangeSpy }
is12Hour
/>
</form>
);
monthInputIndex = Array.from( form.elements ).indexOf(
screen.getByLabelText( 'Month' )
);
dayInputIndex = Array.from( form.elements ).indexOf(
screen.getByLabelText( 'Day' )
);
expect( monthInputIndex < dayInputIndex ).toBe( true );
} );
it( 'Should set a time when passed a null currentTime', () => {
const onChangeSpy = jest.fn();
render(
<TimePicker
currentTime={ null }
onChange={ onChangeSpy }
is12Hour
/>
);
const monthInput = (
screen.getByLabelText( 'Month' ) as HTMLInputElement
).value;
const dayInput = ( screen.getByLabelText( 'Day' ) as HTMLInputElement )
.value;
const yearInput = (
screen.getByLabelText( 'Year' ) as HTMLInputElement
).value;
const hoursInput = (
screen.getByLabelText( 'Hours' ) as HTMLInputElement
).value;
const minutesInput = (
screen.getByLabelText( 'Minutes' ) as HTMLInputElement
).value;
expect( Number.isNaN( parseInt( monthInput, 10 ) ) ).toBe( false );
expect( Number.isNaN( parseInt( dayInput, 10 ) ) ).toBe( false );
expect( Number.isNaN( parseInt( yearInput, 10 ) ) ).toBe( false );
expect( Number.isNaN( parseInt( hoursInput, 10 ) ) ).toBe( false );
expect( Number.isNaN( parseInt( minutesInput, 10 ) ) ).toBe( false );
} );
} );

View File

@@ -0,0 +1,61 @@
/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { getSettings as getDateSettings } from '@wordpress/date';
/**
* Internal dependencies
*/
import Tooltip from '../../tooltip';
import { TimeZone as StyledComponent } from './styles';
/**
* Displays timezone information when user timezone is different from site
* timezone.
*/
const TimeZone = () => {
const { timezone } = getDateSettings();
// Convert timezone offset to hours.
const userTimezoneOffset = -1 * ( new Date().getTimezoneOffset() / 60 );
// System timezone and user timezone match, nothing needed.
// Compare as numbers because it comes over as string.
if ( Number( timezone.offset ) === userTimezoneOffset ) {
return null;
}
const offsetSymbol = Number( timezone.offset ) >= 0 ? '+' : '';
const zoneAbbr =
'' !== timezone.abbr && isNaN( Number( timezone.abbr ) )
? timezone.abbr
: `UTC${ offsetSymbol }${ timezone.offset }`;
// Replace underscore with space in strings like `America/Costa_Rica`.
const prettyTimezoneString = timezone.string.replace( '_', ' ' );
const timezoneDetail =
'UTC' === timezone.string
? __( 'Coordinated Universal Time' )
: `(${ zoneAbbr }) ${ prettyTimezoneString }`;
// When the prettyTimezoneString is empty, there is no additional timezone
// detail information to show in a Tooltip.
const hasNoAdditionalTimezoneDetail =
prettyTimezoneString.trim().length === 0;
return hasNoAdditionalTimezoneDetail ? (
<StyledComponent className="components-datetime__timezone">
{ zoneAbbr }
</StyledComponent>
) : (
<Tooltip placement="top" text={ timezoneDetail }>
<StyledComponent className="components-datetime__timezone">
{ zoneAbbr }
</StyledComponent>
</Tooltip>
);
};
export default TimeZone;

View File

@@ -0,0 +1,76 @@
export type TimePickerProps = {
/**
* The initial current time the time picker should render.
*/
currentTime?: Date | string | number | null;
/**
* Whether we use a 12-hour clock. With a 12-hour clock, an AM/PM widget is
* displayed and the time format is assumed to be `MM-DD-YYYY` (as opposed
* to the default format `DD-MM-YYYY`).
*/
is12Hour?: boolean;
/**
* The function called when a new time has been selected. It is passed the
* time as an argument.
*/
onChange?: ( time: string ) => void;
};
export type DatePickerEvent = {
/**
* The date of the event.
*/
date: Date;
};
export type DatePickerProps = {
/**
* The current date and time at initialization. Optionally pass in a `null`
* value to specify no date is currently selected.
*/
currentDate?: Date | string | number | null;
/**
* The function called when a new date has been selected. It is passed the
* date as an argument.
*/
onChange?: ( date: string ) => void;
/**
* A callback function which receives a Date object representing a day as an
* argument, and should return a Boolean to signify if the day is valid or
* not.
*/
isInvalidDate?: ( date: Date ) => boolean;
/**
* A callback invoked when selecting the previous/next month in the date
* picker. The callback receives the new month date in the ISO format as an
* argument.
*/
onMonthPreviewed?: ( date: string ) => void;
/**
* List of events to show in the date picker. Each event will appear as a
* dot on the day of the event.
*/
events?: DatePickerEvent[];
/**
* The day that the week should start on. 0 for Sunday, 1 for Monday, etc.
*
* @default 0
*/
startOfWeek?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
};
export type DateTimePickerProps = Omit< DatePickerProps, 'onChange' > &
Omit< TimePickerProps, 'currentTime' | 'onChange' > & {
/**
* The function called when a new date or time has been selected. It is
* passed the date and time as an argument.
*/
onChange?: ( date: string | null ) => void;
};

View File

@@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { toDate } from 'date-fns';
/**
* Like date-fn's toDate, but tries to guess the format when a string is
* given.
*
* @param input Value to turn into a date.
*/
export function inputToDate( input: Date | string | number ): Date {
if ( typeof input === 'string' ) {
return new Date( input );
}
return toDate( input );
}