Skip to content

Commit

Permalink
DateTime: Create TimeInput component and integrate into TimePicker (W…
Browse files Browse the repository at this point in the history
…ordPress#60613)

* Move reducer util func to the upper level of utils

* Move from12hTo24h util func to the upper level of utils

* Extract validation logic into separate function

* Add from24hTo12h util method

* Create initial version of TimeInput component

* Support two way data binding of the hours and minutes props

* Add pad start zero to the hours and minutes values

* Add TimeInput story

* Fix two way binding edge cases and optimize onChange triggers

* Remove unnecesarry Fieldset wrapper and label

* Add TimeInput change args type

* Integrate TimeInput into TimePicker component

* Fix edge case of handling day period

* Get proper hours format from the time picker component

With a new TimeInput component, the hours value is in 24 hours format.

* Add TimeInput unit tests

* Update default story to reflect the component defaults

* Simplify passing callback function

* Test: update element selectors

* Add todo comment

* Null-ing storybook value props

* Replace minutesStep with minutesProps prop

* Update time-input component entry props

* Don't trigger onChange event if the entry value is updated

* Simplify minutesProps passing

* Simplify controlled/uncontrolled logic

* Set to WIP status

* Add changelog

* Update test description

Co-authored-by: Lena Morita <[email protected]>

---------

Unlinked contributors: bogiii.

Co-authored-by: mirka <[email protected]>
  • Loading branch information
bogiii and mirka authored Jul 1, 2024
1 parent 12518a0 commit 67d7413
Show file tree
Hide file tree
Showing 8 changed files with 532 additions and 165 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- `CustomSelectControlV2`: fix popover styles. ([#62821](https://github.com/WordPress/gutenberg/pull/62821))
- `CustomSelectControlV2`: fix trigger text alignment in RTL languages ([#62869](https://github.com/WordPress/gutenberg/pull/62869)).
- `CustomSelectControlV2`: fix select popover content overflow. ([#62844](https://github.com/WordPress/gutenberg/pull/62844))
- Extract `TimeInput` component from `TimePicker` ([#60613](https://github.com/WordPress/gutenberg/pull/60613)).

## 28.2.0 (2024-06-26)

Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/date-time/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
*/
import { default as DatePicker } from './date';
import { default as TimePicker } from './time';
import { default as TimeInput } from './time-input';
import { default as DateTimePicker } from './date-time';

export { DatePicker, TimePicker };
export { DatePicker, TimePicker, TimeInput };
export default DateTimePicker;
33 changes: 33 additions & 0 deletions packages/components/src/date-time/stories/time-input.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* External dependencies
*/
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';

/**
* Internal dependencies
*/
import { TimeInput } from '../time-input';

const meta: Meta< typeof TimeInput > = {
title: 'Components/TimeInput',
component: TimeInput,
argTypes: {
onChange: { action: 'onChange', control: { type: null } },
},
tags: [ 'status-wip' ],
parameters: {
controls: { expanded: true },
docs: { canvas: { sourceState: 'shown' } },
},
args: {
onChange: action( 'onChange' ),
},
};
export default meta;

const Template: StoryFn< typeof TimeInput > = ( args ) => {
return <TimeInput { ...args } />;
};

export const Default: StoryFn< typeof TimeInput > = Template.bind( {} );
174 changes: 174 additions & 0 deletions packages/components/src/date-time/time-input/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* External dependencies
*/
import clsx from 'clsx';

/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import {
TimeWrapper,
TimeSeparator,
HoursInput,
MinutesInput,
} from '../time/styles';
import { HStack } from '../../h-stack';
import Button from '../../button';
import ButtonGroup from '../../button-group';
import {
from12hTo24h,
from24hTo12h,
buildPadInputStateReducer,
validateInputElementTarget,
} from '../utils';
import type { TimeInputProps } from '../types';
import type { InputChangeCallback } from '../../input-control/types';
import { useControlledValue } from '../../utils';

export function TimeInput( {
value: valueProp,
defaultValue,
is12Hour,
minutesProps,
onChange,
}: TimeInputProps ) {
const [
value = {
hours: new Date().getHours(),
minutes: new Date().getMinutes(),
},
setValue,
] = useControlledValue( {
value: valueProp,
onChange,
defaultValue,
} );
const dayPeriod = parseDayPeriod( value.hours );
const hours12Format = from24hTo12h( value.hours );

const buildNumberControlChangeCallback = (
method: 'hours' | 'minutes'
): InputChangeCallback => {
return ( _value, { event } ) => {
if ( ! validateInputElementTarget( event ) ) {
return;
}

// We can safely assume value is a number if target is valid.
const numberValue = Number( _value );

setValue( {
...value,
[ method ]:
method === 'hours' && is12Hour
? from12hTo24h( numberValue, dayPeriod === 'PM' )
: numberValue,
} );
};
};

const buildAmPmChangeCallback = ( _value: 'AM' | 'PM' ) => {
return () => {
if ( dayPeriod === _value ) {
return;
}

setValue( {
...value,
hours: from12hTo24h( hours12Format, _value === 'PM' ),
} );
};
};

function parseDayPeriod( _hours: number ) {
return _hours < 12 ? 'AM' : 'PM';
}

return (
<HStack alignment="left">
<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={ String(
is12Hour ? hours12Format : value.hours
).padStart( 2, '0' ) }
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={ clsx(
'components-datetime__time-field-minutes-input', // Unused, for backwards compatibility.
minutesProps?.className
) }
label={ __( 'Minutes' ) }
hideLabelFromVision
__next40pxDefaultSize
value={ String( value.minutes ).padStart( 2, '0' ) }
step={ 1 }
min={ 0 }
max={ 59 }
required
spinControls="none"
isPressEnterToChange
isDragEnabled={ false }
isShiftStepEnabled={ false }
onChange={ ( ...args ) => {
buildNumberControlChangeCallback( 'minutes' )(
...args
);
minutesProps?.onChange?.( ...args );
} }
__unstableStateReducer={ buildPadInputStateReducer( 2 ) }
{ ...minutesProps }
/>
</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={ dayPeriod === 'AM' ? 'primary' : 'secondary' }
__next40pxDefaultSize
onClick={ buildAmPmChangeCallback( 'AM' ) }
>
{ __( 'AM' ) }
</Button>
<Button
className="components-datetime__time-pm-button" // Unused, for backwards compatibility.
variant={ dayPeriod === 'PM' ? 'primary' : 'secondary' }
__next40pxDefaultSize
onClick={ buildAmPmChangeCallback( 'PM' ) }
>
{ __( 'PM' ) }
</Button>
</ButtonGroup>
) }
</HStack>
);
}
export default TimeInput;
171 changes: 171 additions & 0 deletions packages/components/src/date-time/time-input/test/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* External dependencies
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

/**
* Internal dependencies
*/
import TimeInput from '..';

describe( 'TimeInput', () => {
it( 'should call onChange with updated values | 24-hours format', async () => {
const user = userEvent.setup();

const timeInputValue = { hours: 0, minutes: 0 };
const onChangeSpy = jest.fn();

render(
<TimeInput
defaultValue={ timeInputValue }
onChange={ onChangeSpy }
/>
);

const hoursInput = screen.getByRole( 'spinbutton', { name: 'Hours' } );
const minutesInput = screen.getByRole( 'spinbutton', {
name: 'Minutes',
} );

await user.clear( minutesInput );
await user.type( minutesInput, '35' );
await user.keyboard( '{Tab}' );

expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 0, minutes: 35 } );
onChangeSpy.mockClear();

await user.clear( hoursInput );
await user.type( hoursInput, '12' );
await user.keyboard( '{Tab}' );

expect( onChangeSpy ).toHaveBeenCalledWith( {
hours: 12,
minutes: 35,
} );
onChangeSpy.mockClear();

await user.clear( hoursInput );
await user.type( hoursInput, '23' );
await user.keyboard( '{Tab}' );

expect( onChangeSpy ).toHaveBeenCalledWith( {
hours: 23,
minutes: 35,
} );
onChangeSpy.mockClear();

await user.clear( minutesInput );
await user.type( minutesInput, '0' );
await user.keyboard( '{Tab}' );

expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 23, minutes: 0 } );
} );

it( 'should call onChange with updated values | 12-hours format', async () => {
const user = userEvent.setup();

const timeInputValue = { hours: 0, minutes: 0 };
const onChangeSpy = jest.fn();

render(
<TimeInput
is12Hour
defaultValue={ timeInputValue }
onChange={ onChangeSpy }
/>
);

const hoursInput = screen.getByRole( 'spinbutton', { name: 'Hours' } );
const minutesInput = screen.getByRole( 'spinbutton', {
name: 'Minutes',
} );
const amButton = screen.getByRole( 'button', { name: 'AM' } );
const pmButton = screen.getByRole( 'button', { name: 'PM' } );

// TODO: Update assert these states through the accessibility tree rather than through styles, see: https://github.com/WordPress/gutenberg/issues/61163
expect( amButton ).toHaveClass( 'is-primary' );
expect( pmButton ).not.toHaveClass( 'is-primary' );
expect( hoursInput ).not.toHaveValue( 0 );
expect( hoursInput ).toHaveValue( 12 );

await user.clear( minutesInput );
await user.type( minutesInput, '35' );
await user.keyboard( '{Tab}' );

expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 0, minutes: 35 } );
expect( amButton ).toHaveClass( 'is-primary' );

await user.clear( hoursInput );
await user.type( hoursInput, '12' );
await user.keyboard( '{Tab}' );

expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 0, minutes: 35 } );

await user.click( pmButton );
expect( onChangeSpy ).toHaveBeenCalledWith( {
hours: 12,
minutes: 35,
} );
expect( pmButton ).toHaveClass( 'is-primary' );
} );

it( 'should call onChange with defined minutes steps', async () => {
const user = userEvent.setup();

const timeInputValue = { hours: 0, minutes: 0 };
const onChangeSpy = jest.fn();

render(
<TimeInput
defaultValue={ timeInputValue }
minutesProps={ { step: 5 } }
onChange={ onChangeSpy }
/>
);

const minutesInput = screen.getByRole( 'spinbutton', {
name: 'Minutes',
} );

await user.clear( minutesInput );
await user.keyboard( '{ArrowUp}' );

expect( minutesInput ).toHaveValue( 5 );

await user.keyboard( '{ArrowUp}' );
await user.keyboard( '{ArrowUp}' );

expect( minutesInput ).toHaveValue( 15 );

await user.keyboard( '{ArrowDown}' );

expect( minutesInput ).toHaveValue( 10 );

await user.clear( minutesInput );
await user.type( minutesInput, '44' );
await user.keyboard( '{Tab}' );

expect( minutesInput ).toHaveValue( 45 );

await user.clear( minutesInput );
await user.type( minutesInput, '51' );
await user.keyboard( '{Tab}' );

expect( minutesInput ).toHaveValue( 50 );
} );

it( 'should reflect changes to the value prop', () => {
const { rerender } = render(
<TimeInput value={ { hours: 0, minutes: 0 } } />
);
rerender( <TimeInput value={ { hours: 1, minutes: 2 } } /> );

expect(
screen.getByRole( 'spinbutton', { name: 'Hours' } )
).toHaveValue( 1 );
expect(
screen.getByRole( 'spinbutton', { name: 'Minutes' } )
).toHaveValue( 2 );
} );
} );
Loading

0 comments on commit 67d7413

Please sign in to comment.