Skip to content

Commit

Permalink
DateTime: Create TimeInput component and integrate into TimePicker (#…
Browse files Browse the repository at this point in the history
…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 committed 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

1 comment on commit 67d7413

@github-actions
Copy link

Choose a reason for hiding this comment

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

Flaky tests detected in 67d7413.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/9750654436
📝 Reported issues:

Please sign in to comment.