Skip to content

Commit 67d7413

Browse files
bogiiimirka
andauthored
DateTime: Create TimeInput component and integrate into TimePicker (#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]>
1 parent 12518a0 commit 67d7413

File tree

8 files changed

+532
-165
lines changed

8 files changed

+532
-165
lines changed

packages/components/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- `CustomSelectControlV2`: fix popover styles. ([#62821](https://github.com/WordPress/gutenberg/pull/62821))
1616
- `CustomSelectControlV2`: fix trigger text alignment in RTL languages ([#62869](https://github.com/WordPress/gutenberg/pull/62869)).
1717
- `CustomSelectControlV2`: fix select popover content overflow. ([#62844](https://github.com/WordPress/gutenberg/pull/62844))
18+
- Extract `TimeInput` component from `TimePicker` ([#60613](https://github.com/WordPress/gutenberg/pull/60613)).
1819

1920
## 28.2.0 (2024-06-26)
2021

packages/components/src/date-time/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
*/
44
import { default as DatePicker } from './date';
55
import { default as TimePicker } from './time';
6+
import { default as TimeInput } from './time-input';
67
import { default as DateTimePicker } from './date-time';
78

8-
export { DatePicker, TimePicker };
9+
export { DatePicker, TimePicker, TimeInput };
910
export default DateTimePicker;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import type { Meta, StoryFn } from '@storybook/react';
5+
import { action } from '@storybook/addon-actions';
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
import { TimeInput } from '../time-input';
11+
12+
const meta: Meta< typeof TimeInput > = {
13+
title: 'Components/TimeInput',
14+
component: TimeInput,
15+
argTypes: {
16+
onChange: { action: 'onChange', control: { type: null } },
17+
},
18+
tags: [ 'status-wip' ],
19+
parameters: {
20+
controls: { expanded: true },
21+
docs: { canvas: { sourceState: 'shown' } },
22+
},
23+
args: {
24+
onChange: action( 'onChange' ),
25+
},
26+
};
27+
export default meta;
28+
29+
const Template: StoryFn< typeof TimeInput > = ( args ) => {
30+
return <TimeInput { ...args } />;
31+
};
32+
33+
export const Default: StoryFn< typeof TimeInput > = Template.bind( {} );
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import clsx from 'clsx';
5+
6+
/**
7+
* WordPress dependencies
8+
*/
9+
import { __ } from '@wordpress/i18n';
10+
11+
/**
12+
* Internal dependencies
13+
*/
14+
import {
15+
TimeWrapper,
16+
TimeSeparator,
17+
HoursInput,
18+
MinutesInput,
19+
} from '../time/styles';
20+
import { HStack } from '../../h-stack';
21+
import Button from '../../button';
22+
import ButtonGroup from '../../button-group';
23+
import {
24+
from12hTo24h,
25+
from24hTo12h,
26+
buildPadInputStateReducer,
27+
validateInputElementTarget,
28+
} from '../utils';
29+
import type { TimeInputProps } from '../types';
30+
import type { InputChangeCallback } from '../../input-control/types';
31+
import { useControlledValue } from '../../utils';
32+
33+
export function TimeInput( {
34+
value: valueProp,
35+
defaultValue,
36+
is12Hour,
37+
minutesProps,
38+
onChange,
39+
}: TimeInputProps ) {
40+
const [
41+
value = {
42+
hours: new Date().getHours(),
43+
minutes: new Date().getMinutes(),
44+
},
45+
setValue,
46+
] = useControlledValue( {
47+
value: valueProp,
48+
onChange,
49+
defaultValue,
50+
} );
51+
const dayPeriod = parseDayPeriod( value.hours );
52+
const hours12Format = from24hTo12h( value.hours );
53+
54+
const buildNumberControlChangeCallback = (
55+
method: 'hours' | 'minutes'
56+
): InputChangeCallback => {
57+
return ( _value, { event } ) => {
58+
if ( ! validateInputElementTarget( event ) ) {
59+
return;
60+
}
61+
62+
// We can safely assume value is a number if target is valid.
63+
const numberValue = Number( _value );
64+
65+
setValue( {
66+
...value,
67+
[ method ]:
68+
method === 'hours' && is12Hour
69+
? from12hTo24h( numberValue, dayPeriod === 'PM' )
70+
: numberValue,
71+
} );
72+
};
73+
};
74+
75+
const buildAmPmChangeCallback = ( _value: 'AM' | 'PM' ) => {
76+
return () => {
77+
if ( dayPeriod === _value ) {
78+
return;
79+
}
80+
81+
setValue( {
82+
...value,
83+
hours: from12hTo24h( hours12Format, _value === 'PM' ),
84+
} );
85+
};
86+
};
87+
88+
function parseDayPeriod( _hours: number ) {
89+
return _hours < 12 ? 'AM' : 'PM';
90+
}
91+
92+
return (
93+
<HStack alignment="left">
94+
<TimeWrapper
95+
className="components-datetime__time-field components-datetime__time-field-time" // Unused, for backwards compatibility.
96+
>
97+
<HoursInput
98+
className="components-datetime__time-field-hours-input" // Unused, for backwards compatibility.
99+
label={ __( 'Hours' ) }
100+
hideLabelFromVision
101+
__next40pxDefaultSize
102+
value={ String(
103+
is12Hour ? hours12Format : value.hours
104+
).padStart( 2, '0' ) }
105+
step={ 1 }
106+
min={ is12Hour ? 1 : 0 }
107+
max={ is12Hour ? 12 : 23 }
108+
required
109+
spinControls="none"
110+
isPressEnterToChange
111+
isDragEnabled={ false }
112+
isShiftStepEnabled={ false }
113+
onChange={ buildNumberControlChangeCallback( 'hours' ) }
114+
__unstableStateReducer={ buildPadInputStateReducer( 2 ) }
115+
/>
116+
<TimeSeparator
117+
className="components-datetime__time-separator" // Unused, for backwards compatibility.
118+
aria-hidden="true"
119+
>
120+
:
121+
</TimeSeparator>
122+
<MinutesInput
123+
className={ clsx(
124+
'components-datetime__time-field-minutes-input', // Unused, for backwards compatibility.
125+
minutesProps?.className
126+
) }
127+
label={ __( 'Minutes' ) }
128+
hideLabelFromVision
129+
__next40pxDefaultSize
130+
value={ String( value.minutes ).padStart( 2, '0' ) }
131+
step={ 1 }
132+
min={ 0 }
133+
max={ 59 }
134+
required
135+
spinControls="none"
136+
isPressEnterToChange
137+
isDragEnabled={ false }
138+
isShiftStepEnabled={ false }
139+
onChange={ ( ...args ) => {
140+
buildNumberControlChangeCallback( 'minutes' )(
141+
...args
142+
);
143+
minutesProps?.onChange?.( ...args );
144+
} }
145+
__unstableStateReducer={ buildPadInputStateReducer( 2 ) }
146+
{ ...minutesProps }
147+
/>
148+
</TimeWrapper>
149+
{ is12Hour && (
150+
<ButtonGroup
151+
className="components-datetime__time-field components-datetime__time-field-am-pm" // Unused, for backwards compatibility.
152+
>
153+
<Button
154+
className="components-datetime__time-am-button" // Unused, for backwards compatibility.
155+
variant={ dayPeriod === 'AM' ? 'primary' : 'secondary' }
156+
__next40pxDefaultSize
157+
onClick={ buildAmPmChangeCallback( 'AM' ) }
158+
>
159+
{ __( 'AM' ) }
160+
</Button>
161+
<Button
162+
className="components-datetime__time-pm-button" // Unused, for backwards compatibility.
163+
variant={ dayPeriod === 'PM' ? 'primary' : 'secondary' }
164+
__next40pxDefaultSize
165+
onClick={ buildAmPmChangeCallback( 'PM' ) }
166+
>
167+
{ __( 'PM' ) }
168+
</Button>
169+
</ButtonGroup>
170+
) }
171+
</HStack>
172+
);
173+
}
174+
export default TimeInput;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { render, screen } from '@testing-library/react';
5+
import userEvent from '@testing-library/user-event';
6+
7+
/**
8+
* Internal dependencies
9+
*/
10+
import TimeInput from '..';
11+
12+
describe( 'TimeInput', () => {
13+
it( 'should call onChange with updated values | 24-hours format', async () => {
14+
const user = userEvent.setup();
15+
16+
const timeInputValue = { hours: 0, minutes: 0 };
17+
const onChangeSpy = jest.fn();
18+
19+
render(
20+
<TimeInput
21+
defaultValue={ timeInputValue }
22+
onChange={ onChangeSpy }
23+
/>
24+
);
25+
26+
const hoursInput = screen.getByRole( 'spinbutton', { name: 'Hours' } );
27+
const minutesInput = screen.getByRole( 'spinbutton', {
28+
name: 'Minutes',
29+
} );
30+
31+
await user.clear( minutesInput );
32+
await user.type( minutesInput, '35' );
33+
await user.keyboard( '{Tab}' );
34+
35+
expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 0, minutes: 35 } );
36+
onChangeSpy.mockClear();
37+
38+
await user.clear( hoursInput );
39+
await user.type( hoursInput, '12' );
40+
await user.keyboard( '{Tab}' );
41+
42+
expect( onChangeSpy ).toHaveBeenCalledWith( {
43+
hours: 12,
44+
minutes: 35,
45+
} );
46+
onChangeSpy.mockClear();
47+
48+
await user.clear( hoursInput );
49+
await user.type( hoursInput, '23' );
50+
await user.keyboard( '{Tab}' );
51+
52+
expect( onChangeSpy ).toHaveBeenCalledWith( {
53+
hours: 23,
54+
minutes: 35,
55+
} );
56+
onChangeSpy.mockClear();
57+
58+
await user.clear( minutesInput );
59+
await user.type( minutesInput, '0' );
60+
await user.keyboard( '{Tab}' );
61+
62+
expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 23, minutes: 0 } );
63+
} );
64+
65+
it( 'should call onChange with updated values | 12-hours format', async () => {
66+
const user = userEvent.setup();
67+
68+
const timeInputValue = { hours: 0, minutes: 0 };
69+
const onChangeSpy = jest.fn();
70+
71+
render(
72+
<TimeInput
73+
is12Hour
74+
defaultValue={ timeInputValue }
75+
onChange={ onChangeSpy }
76+
/>
77+
);
78+
79+
const hoursInput = screen.getByRole( 'spinbutton', { name: 'Hours' } );
80+
const minutesInput = screen.getByRole( 'spinbutton', {
81+
name: 'Minutes',
82+
} );
83+
const amButton = screen.getByRole( 'button', { name: 'AM' } );
84+
const pmButton = screen.getByRole( 'button', { name: 'PM' } );
85+
86+
// TODO: Update assert these states through the accessibility tree rather than through styles, see: https://github.com/WordPress/gutenberg/issues/61163
87+
expect( amButton ).toHaveClass( 'is-primary' );
88+
expect( pmButton ).not.toHaveClass( 'is-primary' );
89+
expect( hoursInput ).not.toHaveValue( 0 );
90+
expect( hoursInput ).toHaveValue( 12 );
91+
92+
await user.clear( minutesInput );
93+
await user.type( minutesInput, '35' );
94+
await user.keyboard( '{Tab}' );
95+
96+
expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 0, minutes: 35 } );
97+
expect( amButton ).toHaveClass( 'is-primary' );
98+
99+
await user.clear( hoursInput );
100+
await user.type( hoursInput, '12' );
101+
await user.keyboard( '{Tab}' );
102+
103+
expect( onChangeSpy ).toHaveBeenCalledWith( { hours: 0, minutes: 35 } );
104+
105+
await user.click( pmButton );
106+
expect( onChangeSpy ).toHaveBeenCalledWith( {
107+
hours: 12,
108+
minutes: 35,
109+
} );
110+
expect( pmButton ).toHaveClass( 'is-primary' );
111+
} );
112+
113+
it( 'should call onChange with defined minutes steps', async () => {
114+
const user = userEvent.setup();
115+
116+
const timeInputValue = { hours: 0, minutes: 0 };
117+
const onChangeSpy = jest.fn();
118+
119+
render(
120+
<TimeInput
121+
defaultValue={ timeInputValue }
122+
minutesProps={ { step: 5 } }
123+
onChange={ onChangeSpy }
124+
/>
125+
);
126+
127+
const minutesInput = screen.getByRole( 'spinbutton', {
128+
name: 'Minutes',
129+
} );
130+
131+
await user.clear( minutesInput );
132+
await user.keyboard( '{ArrowUp}' );
133+
134+
expect( minutesInput ).toHaveValue( 5 );
135+
136+
await user.keyboard( '{ArrowUp}' );
137+
await user.keyboard( '{ArrowUp}' );
138+
139+
expect( minutesInput ).toHaveValue( 15 );
140+
141+
await user.keyboard( '{ArrowDown}' );
142+
143+
expect( minutesInput ).toHaveValue( 10 );
144+
145+
await user.clear( minutesInput );
146+
await user.type( minutesInput, '44' );
147+
await user.keyboard( '{Tab}' );
148+
149+
expect( minutesInput ).toHaveValue( 45 );
150+
151+
await user.clear( minutesInput );
152+
await user.type( minutesInput, '51' );
153+
await user.keyboard( '{Tab}' );
154+
155+
expect( minutesInput ).toHaveValue( 50 );
156+
} );
157+
158+
it( 'should reflect changes to the value prop', () => {
159+
const { rerender } = render(
160+
<TimeInput value={ { hours: 0, minutes: 0 } } />
161+
);
162+
rerender( <TimeInput value={ { hours: 1, minutes: 2 } } /> );
163+
164+
expect(
165+
screen.getByRole( 'spinbutton', { name: 'Hours' } )
166+
).toHaveValue( 1 );
167+
expect(
168+
screen.getByRole( 'spinbutton', { name: 'Minutes' } )
169+
).toHaveValue( 2 );
170+
} );
171+
} );

0 commit comments

Comments
 (0)