From 79293d122d9d1a04e870e14cef208b353500d5e7 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sun, 14 Dec 2025 17:16:10 -0500 Subject: [PATCH 1/5] feat(time-input): enhance time input components with segment refs and change handling --- .../TimeInputContext/TimeInputContext.tsx | 3 + .../TimeInputContext.types.ts | 6 ++ .../useTimeInputComponentRefs.ts | 30 +++++++ packages/time-input/src/TimeInput.stories.tsx | 3 + .../src/TimeInputBox/TimeInputBox.spec.tsx | 13 ++- .../src/TimeInputBox/TimeInputBox.types.ts | 20 ++++- .../src/TimeInputInputs/TimeInputInputs.tsx | 84 +++++++++++++++++-- packages/time-input/src/shared.types.ts | 10 +++ packages/time-input/src/testing/testUtils.ts | 9 ++ 9 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 packages/time-input/src/Context/TimeInputContext/useTimeInputComponentRefs.ts create mode 100644 packages/time-input/src/testing/testUtils.ts diff --git a/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx b/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx index 56ad9d5616..76172c702a 100644 --- a/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx +++ b/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx @@ -6,6 +6,7 @@ import { TimeInputContextProps, TimeInputProviderProps, } from './TimeInputContext.types'; +import { useTimeInputComponentRefs } from './useTimeInputComponentRefs'; export const TimeInputContext = createContext( {} as TimeInputContextProps, @@ -20,6 +21,7 @@ export const TimeInputProvider = ({ setValue: _setValue, handleValidation: _handleValidation, }: PropsWithChildren) => { + const refs = useTimeInputComponentRefs(); const setValue = (newVal?: DateType) => { _setValue(newVal ?? null); }; @@ -31,6 +33,7 @@ export const TimeInputProvider = ({ return ( ['handleValidation']; + + /** + * Ref objects for time input segments + */ + refs: TimeInputComponentRefs; } /** diff --git a/packages/time-input/src/Context/TimeInputContext/useTimeInputComponentRefs.ts b/packages/time-input/src/Context/TimeInputContext/useTimeInputComponentRefs.ts new file mode 100644 index 0000000000..289c4c973f --- /dev/null +++ b/packages/time-input/src/Context/TimeInputContext/useTimeInputComponentRefs.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; + +import { useDynamicRefs } from '@leafygreen-ui/hooks'; + +import { SegmentRefs } from '../../shared.types'; + +export interface TimeInputComponentRefs { + segmentRefs: SegmentRefs; +} + +/** + * Creates `ref` objects for time input segments + * @returns A {@link TimeInputComponentRefs} object to keep track of each time input segment + */ +export const useTimeInputComponentRefs = (): TimeInputComponentRefs => { + const getSegmentRef = useDynamicRefs(); + + const segmentRefs: SegmentRefs = useMemo( + () => ({ + hour: getSegmentRef('hour') || undefined, + minute: getSegmentRef('minute') || undefined, + second: getSegmentRef('second') || undefined, + }), + [getSegmentRef], + ); + + return { + segmentRefs, + }; +}; diff --git a/packages/time-input/src/TimeInput.stories.tsx b/packages/time-input/src/TimeInput.stories.tsx index ab4a789796..e4f11902c0 100644 --- a/packages/time-input/src/TimeInput.stories.tsx +++ b/packages/time-input/src/TimeInput.stories.tsx @@ -68,6 +68,9 @@ const Template: StoryFn = props => { utcTime: time?.toUTCString(), }); }} + onChange={e => { + console.log('Storybook: onChange ⏰', { value: e.target.value }); + }} />

Time zone: {props.timeZone}

UTC value: {value?.toUTCString()}

diff --git a/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx b/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx index 60700b1d18..73eeddf435 100644 --- a/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx +++ b/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx @@ -9,6 +9,7 @@ import { TimeInputDisplayProviderProps } from '../Context/TimeInputDisplayContex import { TimeInputBox } from './TimeInputBox'; import { TimeInputBoxProps } from './TimeInputBox.types'; +import { timeSegmentRefsMock } from '../testing/testUtils'; const renderTimeInputBox = ({ props, @@ -22,6 +23,7 @@ const renderTimeInputBox = ({ {}} + segmentRefs={timeSegmentRefsMock} {...props} /> , @@ -133,8 +135,13 @@ describe('packages/time-input/time-input-box', () => { }); describe('onSegmentChange', () => { - test.todo( - 'should call onSegmentChange with the segment name and the value', - ); + test('should call onSegmentChange with the segment name and the value', () => { + const onSegmentChange = jest.fn(); + const { hourInput } = renderTimeInputBox({ props: { onSegmentChange } }); + userEvent.type(hourInput, '1'); + expect(onSegmentChange).toHaveBeenCalledWith( + expect.objectContaining({ value: '1' }), + ); + }); }); }); diff --git a/packages/time-input/src/TimeInputBox/TimeInputBox.types.ts b/packages/time-input/src/TimeInputBox/TimeInputBox.types.ts index 262baf0f1b..00945a3ed7 100644 --- a/packages/time-input/src/TimeInputBox/TimeInputBox.types.ts +++ b/packages/time-input/src/TimeInputBox/TimeInputBox.types.ts @@ -1,7 +1,25 @@ -import { TimeSegment, TimeSegmentsState } from '../shared.types'; +import { SegmentRefs, TimeSegment, TimeSegmentsState } from '../shared.types'; +import { TimeInputSegmentChangeEventHandler } from '../TimeInputSegment/TimeInputSegment.types'; export interface TimeInputBoxProps extends React.ComponentPropsWithoutRef<'div'> { + /** + * The segments of the time input + */ segments: TimeSegmentsState; + + /** + * The function to set a segment + */ setSegment: (segment: TimeSegment, value: string) => void; + + /** + * The function to handle a segment change, but not necessarily a full value + */ + onSegmentChange?: TimeInputSegmentChangeEventHandler; + + /** + * The refs for the segments + */ + segmentRefs: SegmentRefs; } diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index 9b9198a017..fe3e0dea7e 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -1,4 +1,9 @@ -import React, { forwardRef, useEffect } from 'react'; +import React, { + ChangeEvent, + forwardRef, + MouseEventHandler, + useEffect, +} from 'react'; import { isEqual } from 'lodash'; import { isDateObject } from '@leafygreen-ui/date-utils'; @@ -19,16 +24,33 @@ import { import { wrapperBaseStyles } from './TimeInputInputs.styles'; import { TimeInputInputsProps } from './TimeInputInputs.types'; +import { TimeInputSegmentChangeEventHandler } from '../TimeInputSegment/TimeInputSegment.types'; +import { createSyntheticEvent } from '@leafygreen-ui/lib'; +import { focusAndSelectSegment } from '@leafygreen-ui/input-box'; /** * @internal * This component renders and updates the time segments and select unit. */ export const TimeInputInputs = forwardRef( - (_props: TimeInputInputsProps, forwardedRef) => { - const { is12HourFormat, timeZone, locale, isDirty, setIsDirty } = - useTimeInputDisplayContext(); - const { value, setValue } = useTimeInputContext(); + ( + { onChange: onSegmentChange, onKeyDown, ...rest }: TimeInputInputsProps, + forwardedRef, + ) => { + const { + is12HourFormat, + timeZone, + locale, + isDirty, + setIsDirty, + disabled, + formatParts, + } = useTimeInputDisplayContext(); + const { + value, + setValue, + refs: { segmentRefs }, + } = useTimeInputContext(); /** if the value is a `Date` the component is dirty, meaning the component has been interacted with */ useEffect(() => { @@ -97,6 +119,8 @@ export const TimeInputInputs = forwardRef( } }; + // TODO: need validation on blur + /** * Hook to manage the time segments and select unit */ @@ -110,15 +134,62 @@ export const TimeInputInputs = forwardRef( }, }); + /** + * Called when the input, or any of its children, is clicked. + * Focuses the appropriate segment + */ + const handleInputClick: MouseEventHandler = e => { + if (!disabled) { + const { target } = e; + + /** + * Focus and select the appropriate segment. + * + * This is done here instead of `InputBox` because this component has padding that needs to be accounted for on click. + */ + focusAndSelectSegment({ + target, + formatParts, + segmentRefs, + }); + } + }; + + /** + * Called when any individual segment changes + */ + const handleSegmentChange: TimeInputSegmentChangeEventHandler = + segmentChangeEvent => { + const { segment, value } = segmentChangeEvent; + + //Fire a simulated `change` event + const target = segmentRefs[segment].current; + + if (target) { + // At this point, the target stored in segmentRefs has a stale value. + // To fix this we update the value of the target with the up-to-date value from `segmentChangeEvent`. + target.value = value; + const changeEvent = new Event('change'); + const reactEvent = createSyntheticEvent< + ChangeEvent + >(changeEvent, target); + onSegmentChange?.(reactEvent); + } + }; + return ( - +
+ {/* TODO: wrap this in a wrapper container */} { setSegment(segment, value); }} + onSegmentChange={handleSegmentChange} + segmentRefs={segmentRefs} + onKeyDown={onKeyDown} /> {is12HourFormat && ( @@ -129,6 +200,7 @@ export const TimeInputInputs = forwardRef( }} /> )} + {/* TODO: Add 24 hour label */}
); diff --git a/packages/time-input/src/shared.types.ts b/packages/time-input/src/shared.types.ts index 8bc5504704..78184e2d71 100644 --- a/packages/time-input/src/shared.types.ts +++ b/packages/time-input/src/shared.types.ts @@ -1,3 +1,5 @@ +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + /** * An enumerable object that maps the time part names to their values */ @@ -27,3 +29,11 @@ export const TimeSegment = { export type TimeSegment = (typeof TimeSegment)[keyof typeof TimeSegment]; export type TimeSegmentsState = Record; + +/** + * An object that maps the time segment names to their refs + */ +export type SegmentRefs = Record< + TimeSegment, + ReturnType> +>; diff --git a/packages/time-input/src/testing/testUtils.ts b/packages/time-input/src/testing/testUtils.ts new file mode 100644 index 0000000000..af007c3518 --- /dev/null +++ b/packages/time-input/src/testing/testUtils.ts @@ -0,0 +1,9 @@ +import { createRef } from 'react'; + +import { SegmentRefs } from '../shared.types'; + +export const timeSegmentRefsMock: SegmentRefs = { + hour: createRef(), + minute: createRef(), + second: createRef(), +}; From 6d5f282ece5acb0178222aeb16c8b720cf2bfb01 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sun, 14 Dec 2025 17:45:17 -0500 Subject: [PATCH 2/5] feat(time-input): add 24-hour format text constant and update styles for time input components --- .../TimeInputContext/TimeInputContext.tsx | 1 + .../TimeInputContext.types.ts | 1 + .../src/TimeInputBox/TimeInputBox.spec.tsx | 2 +- .../TimeInputInputs/TimeInputInputs.styles.ts | 32 +++++++++++++++--- .../src/TimeInputInputs/TimeInputInputs.tsx | 33 ++++++++++++------- .../src/TimeInputSelect/TimeInputSelect.tsx | 15 +++++++-- packages/time-input/src/constants.ts | 2 ++ 7 files changed, 65 insertions(+), 21 deletions(-) diff --git a/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx b/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx index 76172c702a..c0a5e82b88 100644 --- a/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx +++ b/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx @@ -22,6 +22,7 @@ export const TimeInputProvider = ({ handleValidation: _handleValidation, }: PropsWithChildren) => { const refs = useTimeInputComponentRefs(); + const setValue = (newVal?: DateType) => { _setValue(newVal ?? null); }; diff --git a/packages/time-input/src/Context/TimeInputContext/TimeInputContext.types.ts b/packages/time-input/src/Context/TimeInputContext/TimeInputContext.types.ts index d25fef27d8..fcd709d48c 100644 --- a/packages/time-input/src/Context/TimeInputContext/TimeInputContext.types.ts +++ b/packages/time-input/src/Context/TimeInputContext/TimeInputContext.types.ts @@ -1,6 +1,7 @@ import { DateType } from '@leafygreen-ui/date-utils'; import { TimeInputProps } from '../../TimeInput/TimeInput.types'; + import { TimeInputComponentRefs } from './useTimeInputComponentRefs'; /** diff --git a/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx b/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx index 73eeddf435..f4be3e3f5d 100644 --- a/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx +++ b/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx @@ -6,10 +6,10 @@ import { SupportedLocales } from '@leafygreen-ui/date-utils'; import { TimeInputDisplayProvider } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; import { TimeInputDisplayProviderProps } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext.types'; +import { timeSegmentRefsMock } from '../testing/testUtils'; import { TimeInputBox } from './TimeInputBox'; import { TimeInputBoxProps } from './TimeInputBox.types'; -import { timeSegmentRefsMock } from '../testing/testUtils'; const renderTimeInputBox = ({ props, diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts b/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts index 653bc003e7..e202e55242 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts @@ -1,7 +1,29 @@ -import { css } from '@leafygreen-ui/emotion'; +import { css, cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { color } from '@leafygreen-ui/tokens'; -export const wrapperBaseStyles = css` - display: flex; - position: relative; - z-index: 0; // Establish new stacking context +const twelveHourFormatStyles = css` + align-items: center; + gap: 12px; +`; + +export const getWrapperStyles = ({ + is12HourFormat, +}: { + is12HourFormat: boolean; +}) => + cx( + css` + display: flex; + position: relative; + z-index: 0; // Establish new stacking context + `, + { + [twelveHourFormatStyles]: !is12HourFormat, + }, + ); + +export const getTwentyFourHourStyles = ({ theme }: { theme: Theme }) => css` + color: ${color[theme].text.secondary.default}; + white-space: nowrap; `; diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index fe3e0dea7e..cfc41785fe 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -7,13 +7,19 @@ import React, { import { isEqual } from 'lodash'; import { isDateObject } from '@leafygreen-ui/date-utils'; +import { focusAndSelectSegment } from '@leafygreen-ui/input-box'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { createSyntheticEvent } from '@leafygreen-ui/lib'; +import { Overline } from '@leafygreen-ui/typography'; +import { TWENTY_FOUR_HOURS_TEXT } from '../constants'; import { useTimeInputContext } from '../Context/TimeInputContext/TimeInputContext'; import { useTimeInputDisplayContext } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; import { useTimeSegmentsAndSelectUnit } from '../hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit'; import { OnUpdateCallback } from '../hooks/useTimeSegmentsAndSelectUnit/useTimeSegmentsAndSelectUnit.types'; import { TimeFormField, TimeFormFieldInputContainer } from '../TimeFormField'; import { TimeInputBox } from '../TimeInputBox/TimeInputBox'; +import { TimeInputSegmentChangeEventHandler } from '../TimeInputSegment/TimeInputSegment.types'; import { TimeInputSelect } from '../TimeInputSelect/TimeInputSelect'; import { UnitOption } from '../TimeInputSelect/TimeInputSelect.types'; import { @@ -22,11 +28,11 @@ import { shouldSetValue, } from '../utils'; -import { wrapperBaseStyles } from './TimeInputInputs.styles'; +import { + getTwentyFourHourStyles, + getWrapperStyles, +} from './TimeInputInputs.styles'; import { TimeInputInputsProps } from './TimeInputInputs.types'; -import { TimeInputSegmentChangeEventHandler } from '../TimeInputSegment/TimeInputSegment.types'; -import { createSyntheticEvent } from '@leafygreen-ui/lib'; -import { focusAndSelectSegment } from '@leafygreen-ui/input-box'; /** * @internal @@ -51,6 +57,9 @@ export const TimeInputInputs = forwardRef( setValue, refs: { segmentRefs }, } = useTimeInputContext(); + const { theme } = useDarkMode(); + + const is24HourFormat = !is12HourFormat; /** if the value is a `Date` the component is dirty, meaning the component has been interacted with */ useEffect(() => { @@ -142,11 +151,8 @@ export const TimeInputInputs = forwardRef( if (!disabled) { const { target } = e; - /** - * Focus and select the appropriate segment. - * - * This is done here instead of `InputBox` because this component has padding that needs to be accounted for on click. - */ + // Focus and select the appropriate segment. + // This is done here instead of `InputBox` because this component has padding that needs to be accounted for on click. focusAndSelectSegment({ target, formatParts, @@ -179,8 +185,7 @@ export const TimeInputInputs = forwardRef( return ( -
- {/* TODO: wrap this in a wrapper container */} +
( }} /> )} - {/* TODO: Add 24 hour label */} + {is24HourFormat && ( + + {TWENTY_FOUR_HOURS_TEXT} + + )}
); diff --git a/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx b/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx index b2d036029d..4a0297ab4d 100644 --- a/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx +++ b/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx @@ -23,16 +23,23 @@ export const TimeInputSelect = ({ className, onChange, }: TimeInputSelectProps) => { - const { lgIds } = useTimeInputDisplayContext(); + const { lgIds, size } = useTimeInputDisplayContext(); + /** * Gets the current unit option using the unit string */ const currentUnitOption = unitOptions.find( - u => u.displayName === unit, + unitOption => unitOption.displayName === unit, ) as UnitOption; + /** + * Handles the change event for the select component + * @param val - The value of the selected unit + */ const handleChange = (val: string) => { - const selectedUnit = unitOptions.find(u => u.displayName === val); + const selectedUnit = unitOptions.find( + unitOption => unitOption.displayName === val, + ); if (selectedUnit !== undefined) { onChange(selectedUnit); @@ -50,7 +57,9 @@ export const TimeInputSelect = ({ allowDeselect={false} dropdownWidthBasis={DropdownWidthBasis.Option} renderMode={RenderMode.TopLayer} + data-testid={lgIds.select} data-lgid={lgIds.select} + size={size} > {unitOptions.map(option => (