diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts index 9ddd44b5e3..1bd8ac6279 100644 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -40,7 +40,7 @@ export const getSegmentThemeStyles = (theme: Theme) => { color: ${color[theme].text[Variant.Primary][InteractionState.Default]}; &::placeholder { - color: ${color[theme].text[Variant.Placeholder][ + color: ${color[theme].text[Variant.InverseSecondary][ InteractionState.Default ]}; } diff --git a/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx b/packages/time-input/src/Context/TimeInputContext/TimeInputContext.tsx index 56ad9d5616..c0a5e82b88 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,8 @@ export const TimeInputProvider = ({ setValue: _setValue, handleValidation: _handleValidation, }: PropsWithChildren) => { + const refs = useTimeInputComponentRefs(); + const setValue = (newVal?: DateType) => { _setValue(newVal ?? null); }; @@ -31,6 +34,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..290a8df0bb 100644 --- a/packages/time-input/src/TimeInput.stories.tsx +++ b/packages/time-input/src/TimeInput.stories.tsx @@ -29,6 +29,20 @@ const meta: StoryMetaType = { 'data-testid', ], }, + generate: { + storyNames: [ + 'TwelveHourFormat', + 'TwentyFourHourFormat', + 'WithoutSeconds', + ], + combineArgs: { + darkMode: [false, true], + value: [new Date('2026-02-20T04:00:00Z'), undefined], + disabled: [true, false], + size: Object.values(Size), + timeZone: ['UTC', 'America/New_York', 'Europe/London'], + }, + }, }, args: { showSeconds: true, @@ -37,6 +51,7 @@ const meta: StoryMetaType = { label: 'Time Input', darkMode: false, size: Size.Default, + disabled: false, }, argTypes: { locale: { control: 'select', options: Object.values(SupportedLocales) }, @@ -68,6 +83,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()}

@@ -75,4 +93,36 @@ const Template: StoryFn = props => { ); }; +export const TwelveHourFormat = Template.bind({}); +TwelveHourFormat.parameters = { + generate: { + args: { + locale: SupportedLocales.en_US, + }, + }, +}; + +export const TwentyFourHourFormat = Template.bind({}); +TwentyFourHourFormat.parameters = { + generate: { + args: { + locale: SupportedLocales.ISO_8601, + }, + }, +}; + +export const WithoutSeconds = Template.bind({}); +WithoutSeconds.parameters = { + generate: { + args: { + showSeconds: false, + }, + }, +}; + export const LiveExample = Template.bind({}); +LiveExample.parameters = { + chromatic: { + disableSnapshot: true, + }, +}; diff --git a/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx b/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx index 60700b1d18..f4be3e3f5d 100644 --- a/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx +++ b/packages/time-input/src/TimeInputBox/TimeInputBox.spec.tsx @@ -6,6 +6,7 @@ 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'; @@ -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.spec.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.spec.tsx index 3dea3943e5..0c6623f5ae 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.spec.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.spec.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { Month, newUTC, SupportedLocales } from '@leafygreen-ui/date-utils'; import { getTestUtils as getSelectTestUtils } from '@leafygreen-ui/select/testing'; +import { TWENTY_FOUR_HOURS_TEXT } from '../constants'; import { TimeInputProvider } from '../Context/TimeInputContext/TimeInputContext'; import { TimeInputProviderProps } from '../Context/TimeInputContext/TimeInputContext.types'; import { TimeInputDisplayProvider } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; @@ -256,16 +257,46 @@ describe('packages/time-input-inputs', () => { }); }); - test('does not render the select when the locale is 24h', () => { - const { queryByTestId } = renderTimeInputInputs({ - displayProps: { - locale: SupportedLocales.ISO_8601, - }, + describe('24 hour format', () => { + test('does not render the select', () => { + const { queryByTestId } = renderTimeInputInputs({ + displayProps: { + locale: SupportedLocales.ISO_8601, + }, + }); + expect(queryByTestId(lgIds.select)).not.toBeInTheDocument(); + }); + + test('renders 24 Hour label ', () => { + const { getByText } = renderTimeInputInputs({ + displayProps: { + locale: SupportedLocales.ISO_8601, + }, + }); + expect(getByText(TWENTY_FOUR_HOURS_TEXT)).toBeInTheDocument(); }); - expect(queryByTestId(lgIds.select)).not.toBeInTheDocument(); }); - test.todo('renders 24 Hour label when the locale is 24h'); + describe('12 hour format', () => { + test('renders the select', () => { + renderTimeInputInputs({ + displayProps: { + locale: SupportedLocales.en_US, + }, + }); + const selectTestUtils = getSelectTestUtils(lgIds.select); + expect(selectTestUtils.getInput()).toBeInTheDocument(); + }); + + test('does not render 24 Hour label', () => { + const { queryByText } = renderTimeInputInputs({ + displayProps: { + locale: SupportedLocales.en_US, + }, + }); + expect(queryByText(TWENTY_FOUR_HOURS_TEXT)).not.toBeInTheDocument(); + }); + }); }); describe('Re-rendering', () => { diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts b/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts index 653bc003e7..15f9535a39 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.styles.ts @@ -1,7 +1,45 @@ -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 twentyFourHourFormatStyles = 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 + `, + { + [twentyFourHourFormatStyles]: !is12HourFormat, + }, + ); + +const getDisabledThemeStyles = (theme: Theme) => css` + color: ${color[theme].text.disabled.default}; +`; + +export const getTwentyFourHourStyles = ({ + theme, + disabled, +}: { + theme: Theme; + disabled: boolean; +}) => + cx( + css` + color: ${color[theme].text.secondary.default}; + white-space: nowrap; + `, + { + [getDisabledThemeStyles(theme)]: disabled, + }, + ); diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index c66a38dac5..8e6a0df6ce 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -1,13 +1,24 @@ -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'; +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 { OnUpdateCallback, useTimeSegmentsAndSelectUnit } from '../hooks'; import { TimeFormField, TimeFormFieldInputContainer } from '../TimeFormField'; import { TimeInputBox } from '../TimeInputBox/TimeInputBox'; +import { TimeInputSegmentChangeEventHandler } from '../TimeInputSegment/TimeInputSegment.types'; import { TimeInputSelect } from '../TimeInputSelect/TimeInputSelect'; import { getFormatPartsValues, @@ -15,7 +26,10 @@ import { shouldSetValue, } from '../utils'; -import { wrapperBaseStyles } from './TimeInputInputs.styles'; +import { + getTwentyFourHourStyles, + getWrapperStyles, +} from './TimeInputInputs.styles'; import { TimeInputInputsProps } from './TimeInputInputs.types'; /** @@ -23,12 +37,31 @@ import { TimeInputInputsProps } from './TimeInputInputs.types'; * 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(); + const { theme } = useDarkMode(); + + const is24HourFormat = !is12HourFormat; - /** if the value is a `Date` the component is dirty, meaning the component has been interacted with */ + /** + * If the value is a `Date` the component is dirty, meaning the component has been interacted with + */ useEffect(() => { if (isDateObject(value) && !isDirty) { setIsDirty(true); @@ -88,6 +121,8 @@ export const TimeInputInputs = forwardRef( } }; + // TODO: need validation on blur + /** * Hook to manage the time segments and select unit */ @@ -101,15 +136,58 @@ 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 ( - -
+ +
{ setSegment(segment, value); }} + onSegmentChange={handleSegmentChange} + segmentRefs={segmentRefs} + onKeyDown={onKeyDown} /> {is12HourFormat && ( @@ -120,6 +198,11 @@ export const TimeInputInputs = forwardRef( }} /> )} + {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..41d7b246d5 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, disabled } = 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,10 @@ export const TimeInputSelect = ({ allowDeselect={false} dropdownWidthBasis={DropdownWidthBasis.Option} renderMode={RenderMode.TopLayer} + data-testid={lgIds.select} data-lgid={lgIds.select} + size={size} + disabled={disabled} > {unitOptions.map(option => (