diff --git a/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.ts b/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.ts index 41ae0fbabe..df1212df87 100644 --- a/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.ts +++ b/packages/date-picker/src/shared/hooks/useDateSegments/useDateSegments.ts @@ -54,8 +54,12 @@ export const useDateSegments = ( (isNull(date) || isUndefined(date)) && isValidDate(prevDate); if (hasDateValueChanged || hasDateBeenCleared) { + // This returns a new state object with the updated segments from the new date const newSegments = getFormattedSegmentsFromDate(date); + // Pass the new state and a copy of the previous state to the callback onUpdate?.(newSegments, { ...segments }); + // This updates all segments in the internal state of the hook + // This internally invokes `dateSegmentsReducer` and passes `updateObject` as the second argument. `segments` is the first argument. This updates the internal state of the hook. dispatch(newSegments); } }, [date, onUpdate, prevDate, segments]); @@ -69,8 +73,11 @@ export const useDateSegments = ( // finally, commit the new state const updateObject = { [segment]: value }; + // This returns a new state object with the updated segment const nextState = dateSegmentsReducer(segments, updateObject); + // Pass the new state and a copy of the previous state to the callback onUpdate?.(nextState, { ...segments }, segment); + // This internally invokes `dateSegmentsReducer` and passes `updateObject` as the second argument. `segments` is the first argument. This updates the internal state of the hook. dispatch(updateObject); }; diff --git a/packages/time-input/package.json b/packages/time-input/package.json index 1865ee7b9f..66e5135576 100644 --- a/packages/time-input/package.json +++ b/packages/time-input/package.json @@ -34,6 +34,7 @@ "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/form-field": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", + "@leafygreen-ui/input-box": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/select": "workspace:^", diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.spec.tsx b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.spec.tsx index a41b855312..3ad5732feb 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.spec.tsx +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.spec.tsx @@ -6,13 +6,17 @@ import { renderHook } from '@leafygreen-ui/testing-lib'; import { BaseFontSize } from '@leafygreen-ui/tokens'; import { Size } from '../../TimeInput/TimeInput.types'; +import { getLgIds } from '../../utils'; import { TimeInputDisplayProvider, useTimeInputDisplayContext, } from './TimeInputDisplayContext'; import { type TimeInputDisplayProviderProps } from './TimeInputDisplayContext.types'; -import { defaultTimeInputDisplayContext } from './TimePickerDisplayContext.utils'; +import { defaultTimeInputDisplayContext } from './TimeInputDisplayContext.utils'; + +const lgIds = getLgIds(); +const overrideLgIds = getLgIds('lg-override-lgids'); const renderTimeInputDisplayProvider = ( props?: Partial, @@ -60,6 +64,7 @@ describe('packages/time-input-display-context', () => { defaultTimeInputDisplayContext.isDirty, ); expect(typeof result.current.setIsDirty).toBe('function'); + expect(result.current.lgIds).toEqual(lgIds); }); test('overrides default values with provided props', () => { @@ -73,6 +78,7 @@ describe('packages/time-input-display-context', () => { disabled: true, size: Size.Large, errorMessage: 'Custom error message', + lgIds: overrideLgIds, }; const { result } = renderTimeInputDisplayProvider(customProps); @@ -86,6 +92,7 @@ describe('packages/time-input-display-context', () => { expect(result.current.disabled).toBe(true); expect(result.current.size).toBe(Size.Large); expect(result.current.errorMessage).toBe('Custom error message'); + expect(result.current.lgIds).toEqual(overrideLgIds); }); describe('isDirty', () => { diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx index 830893676c..50eb70473b 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx @@ -12,7 +12,7 @@ import { TimeInputDisplayContextProps, TimeInputDisplayProviderProps, } from './TimeInputDisplayContext.types'; -import { defaultTimeInputDisplayContext } from './TimePickerDisplayContext.utils'; +import { defaultTimeInputDisplayContext } from './TimeInputDisplayContext.utils'; export const TimeInputDisplayContext = createContext(defaultTimeInputDisplayContext); @@ -39,12 +39,14 @@ export const TimeInputDisplayProvider = ({ ...defaults(rest, defaultTimeInputDisplayContext), }; - // TODO: min, max helpers - - // Determines if the input should show a select for the day period (AM/PM) - const is12hFormat = hasDayPeriod(providerValue.locale); + /** + * Determines if the input should show a select for the day period (AM/PM) + */ + const is12HourFormat = hasDayPeriod(providerValue.locale); - // Only used to track the presentation format of the segments, not the value itself + /** + * Only used to track the presentation format of the segments, not the value itself + */ const formatParts = getFormatParts({ showSeconds: providerValue.showSeconds, }); @@ -58,7 +60,7 @@ export const TimeInputDisplayProvider = ({ ariaLabelledbyProp, isDirty, setIsDirty, - is12hFormat, + is12HourFormat, formatParts, }} > diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts index 1a438e3729..575f5980bd 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.types.ts @@ -4,11 +4,19 @@ import { AriaLabelPropsWithLabel } from '@leafygreen-ui/a11y'; import { DarkModeProps } from '@leafygreen-ui/lib'; import { DisplayTimeInputProps } from '../../TimeInput/TimeInput.types'; +import { GetLgIdsReturnType } from '../../utils/getLgIds'; type AriaLabelKeys = keyof AriaLabelPropsWithLabel; type AriaLabelKeysWithoutLabel = Exclude; type DarkModeKeys = keyof DarkModeProps; +/** + * Dynamically generated LGIDs that will be used for the data-lgid and data-testid attributes in child components + */ +interface LgIds { + lgIds: GetLgIdsReturnType; +} + /** * The values in context that can be used in the component * Omits the aria-label and aria-labelledby props and replaces them with the ariaLabelProp and ariaLabelledbyProp @@ -18,38 +26,41 @@ type DarkModeKeys = keyof DarkModeProps; export type TimeInputDisplayContextProps = Omit< Required, AriaLabelKeysWithoutLabel | 'state' | DarkModeKeys -> & { - /** - * The aria-label prop - */ - ariaLabelProp: string; +> & + LgIds & { + /** + * The aria-label prop + */ + ariaLabelProp: string; - /** - * The aria-labelledby prop - */ - ariaLabelledbyProp: string; + /** + * The aria-labelledby prop + */ + ariaLabelledbyProp: string; - /** - * Whether the input has been interacted with - */ - isDirty: boolean; + /** + * Whether the input has been interacted with + */ + isDirty: boolean; - /** - * Setter for whether the input has been interacted with - */ - setIsDirty: React.Dispatch>; + /** + * Setter for whether the input has been interacted with + */ + setIsDirty: React.Dispatch>; - /** - * Whether the AM/PM select should be shown - */ - is12hFormat: boolean; + /** + * Whether the time input is in 12-hour format. Helps determine if the AM/PM select should be shown. + * + * @default false + */ + is12HourFormat: boolean; - /** - * An array of {@link Intl.DateTimeFormatPart}, - * used to determine the order of segments in the input - */ - formatParts?: Array; -}; + /** + * An array of {@link Intl.DateTimeFormatPart}, + * used to determine the order of segments in the input + */ + formatParts?: Array; + }; /** * The props expected to pass into the provider @@ -58,19 +69,20 @@ export type TimeInputDisplayContextProps = Omit< export type TimeInputDisplayProviderProps = Omit< DisplayTimeInputProps, AriaLabelKeys | DarkModeKeys -> & { - /** - * The label prop - */ - label?: ReactNode; +> & + Partial & { + /** + * The label prop + */ + label?: ReactNode; - /** - * The aria-label prop - */ - 'aria-label'?: string; + /** + * The aria-label prop + */ + 'aria-label'?: string; - /** - * The aria-labelledby prop - */ - 'aria-labelledby'?: string; -}; + /** + * The aria-labelledby prop + */ + 'aria-labelledby'?: string; + }; diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.utils.ts b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.utils.ts index e0a244977b..e1b7e582d4 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.utils.ts +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.utils.ts @@ -1 +1,62 @@ -// TODO: min, max helpers +import { + MAX_DATE, + MIN_DATE, + SupportedLocales, +} from '@leafygreen-ui/date-utils'; +import { BaseFontSize } from '@leafygreen-ui/tokens'; + +import { Size } from '../../TimeInput/TimeInput.types'; +import { getLgIds } from '../../utils'; + +import { + TimeInputDisplayContextProps, + TimeInputDisplayProviderProps, +} from './TimeInputDisplayContext.types'; + +export type DisplayContextPropKeys = Exclude< + keyof TimeInputDisplayProviderProps, + 'lgIds' +>; + +/** + * Props names that that are added to the context and used to pick and omit props + */ +export const displayContextPropNames: Array = [ + 'aria-label', + 'aria-labelledby', + 'label', + 'description', + 'locale', + 'timeZone', + 'min', + 'max', + 'baseFontSize', + 'disabled', + 'size', + 'errorMessage', + 'state', + 'showSeconds', +]; + +/** + * The default display context values + */ +export const defaultTimeInputDisplayContext: TimeInputDisplayContextProps = { + ariaLabelProp: '', + ariaLabelledbyProp: '', + label: '', + description: '', + locale: SupportedLocales.ISO_8601, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + min: MIN_DATE, + max: MAX_DATE, + baseFontSize: BaseFontSize.Body1, + disabled: false, + size: Size.Default, + errorMessage: '', + isDirty: false, + setIsDirty: () => {}, + is12HourFormat: false, + showSeconds: true, + lgIds: getLgIds(), +}; diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts b/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts deleted file mode 100644 index 9f178ddb6d..0000000000 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - MAX_DATE, - MIN_DATE, - SupportedLocales, -} from '@leafygreen-ui/date-utils'; -import { BaseFontSize } from '@leafygreen-ui/tokens'; - -import { Size } from '../../TimeInput/TimeInput.types'; - -import { - TimeInputDisplayContextProps, - TimeInputDisplayProviderProps, -} from './TimeInputDisplayContext.types'; - -export type DisplayContextPropKeys = keyof TimeInputDisplayProviderProps; - -/** - * Props names that that are added to the context and used to pick and omit props - */ -export const displayContextPropNames: Array = [ - 'aria-label', - 'aria-labelledby', - 'label', - 'description', - 'locale', - 'timeZone', - 'min', - 'max', - 'baseFontSize', - 'disabled', - 'size', - 'errorMessage', - 'state', - 'showSeconds', -]; - -/** - * The default display context values - */ -export const defaultTimeInputDisplayContext: TimeInputDisplayContextProps = { - ariaLabelProp: '', - ariaLabelledbyProp: '', - label: '', - description: '', - locale: SupportedLocales.ISO_8601, - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - min: MIN_DATE, - max: MAX_DATE, - baseFontSize: BaseFontSize.Body1, - disabled: false, - size: Size.Default, - errorMessage: '', - isDirty: false, - setIsDirty: () => {}, - is12hFormat: false, - showSeconds: true, -}; diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/Index.ts b/packages/time-input/src/Context/TimeInputDisplayContext/index.ts similarity index 64% rename from packages/time-input/src/Context/TimeInputDisplayContext/Index.ts rename to packages/time-input/src/Context/TimeInputDisplayContext/index.ts index 72aea2bac3..34a15ac828 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/Index.ts +++ b/packages/time-input/src/Context/TimeInputDisplayContext/index.ts @@ -7,4 +7,8 @@ export { type TimeInputDisplayContextProps, type TimeInputDisplayProviderProps, } from './TimeInputDisplayContext.types'; -export { defaultTimeInputDisplayContext } from './TimePickerDisplayContext.utils'; +export { + defaultTimeInputDisplayContext, + DisplayContextPropKeys, + displayContextPropNames, +} from './TimeInputDisplayContext.utils'; diff --git a/packages/time-input/src/Context/index.ts b/packages/time-input/src/Context/index.ts new file mode 100644 index 0000000000..4f3afe81dd --- /dev/null +++ b/packages/time-input/src/Context/index.ts @@ -0,0 +1,2 @@ +export * from './TimeInputContext'; +export * from './TimeInputDisplayContext'; diff --git a/packages/time-input/src/TimeFormField/TimeFormField/TimeFormField.tsx b/packages/time-input/src/TimeFormField/TimeFormField/TimeFormField.tsx new file mode 100644 index 0000000000..4ef8ef0bf0 --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormField/TimeFormField.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { FormField } from '@leafygreen-ui/form-field'; + +import { useTimeInputDisplayContext } from '../../Context'; + +import { TimeFormFieldProps } from './TimeFormField.types'; + +/** + * A wrapper around `FormField` that sets the relevant + * attributes, and styling + */ +export const TimeFormField = React.forwardRef< + HTMLDivElement, + TimeFormFieldProps +>(({ children, ...rest }: TimeFormFieldProps, fwdRef) => { + const { label, description, disabled, size } = useTimeInputDisplayContext(); + + return ( + + {children} + + ); +}); + +TimeFormField.displayName = 'TimeFormField'; diff --git a/packages/time-input/src/TimeFormField/TimeFormField/TimeFormField.types.ts b/packages/time-input/src/TimeFormField/TimeFormField/TimeFormField.types.ts new file mode 100644 index 0000000000..dd27e8d1a9 --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormField/TimeFormField.types.ts @@ -0,0 +1,5 @@ +import { FormFieldProps } from '@leafygreen-ui/form-field'; + +export type TimeFormFieldProps = React.ComponentPropsWithoutRef<'div'> & { + children: FormFieldProps['children']; +}; diff --git a/packages/time-input/src/TimeFormField/TimeFormField/index.ts b/packages/time-input/src/TimeFormField/TimeFormField/index.ts new file mode 100644 index 0000000000..f4e5e9ee77 --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormField/index.ts @@ -0,0 +1,2 @@ +export { TimeFormField } from './TimeFormField'; +export { TimeFormFieldProps } from './TimeFormField.types'; diff --git a/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.styles.ts b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.styles.ts new file mode 100644 index 0000000000..a163456ef7 --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.styles.ts @@ -0,0 +1,23 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +// When the time input is in 12 hour format, the input is rendered with a select input to the right of the input. To make these two elements appear seamlessly next to each other, we need to remove the right border radius of the input. +const selectStyles = css` + border-top-right-radius: 0; + border-bottom-right-radius: 0; +`; + +const baseStyles = css` + &:hover, + &:focus-within { + z-index: 1; + } +`; + +export const getContainerStyles = ({ + is12HourFormat, +}: { + is12HourFormat: boolean; +}) => + cx(baseStyles, { + [selectStyles]: is12HourFormat, + }); diff --git a/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.tsx b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.tsx new file mode 100644 index 0000000000..2e34ea8625 --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { FormFieldInputContainer } from '@leafygreen-ui/form-field'; + +import { useTimeInputDisplayContext } from '../../Context'; + +import { getContainerStyles } from './TimeFormFieldInputContainer.styles'; +import { TimeFormFieldInputContainerProps } from './TimeFormFieldInputContainer.types'; + +/** + * A wrapper around `FormField` that sets the relevant + * attributes, and styling + */ +export const TimeFormFieldInputContainer = React.forwardRef< + HTMLDivElement, + TimeFormFieldInputContainerProps +>(({ children, onInputClick }: TimeFormFieldInputContainerProps, fwdRef) => { + const { label, ariaLabelProp, ariaLabelledbyProp, is12HourFormat } = + useTimeInputDisplayContext(); + + return ( + + {children} + + ); +}); + +TimeFormFieldInputContainer.displayName = 'TimeFormFieldInputContainer'; diff --git a/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.types.ts b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.types.ts new file mode 100644 index 0000000000..48be749fa8 --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.types.ts @@ -0,0 +1,7 @@ +import { FormFieldProps } from '@leafygreen-ui/form-field'; + +export type TimeFormFieldInputContainerProps = + React.ComponentPropsWithoutRef<'div'> & { + children: FormFieldProps['children']; + onInputClick?: React.MouseEventHandler; + }; diff --git a/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/index.ts b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/index.ts new file mode 100644 index 0000000000..4c45a43dac --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/index.ts @@ -0,0 +1,2 @@ +export { TimeFormFieldInputContainer } from './TimeFormFieldInputContainer'; +export { TimeFormFieldInputContainerProps } from './TimeFormFieldInputContainer.types'; diff --git a/packages/time-input/src/TimeFormField/index.ts b/packages/time-input/src/TimeFormField/index.ts new file mode 100644 index 0000000000..12104d54e9 --- /dev/null +++ b/packages/time-input/src/TimeFormField/index.ts @@ -0,0 +1,2 @@ +export { TimeFormField } from './TimeFormField'; +export { TimeFormFieldInputContainer } from './TimeFormFieldInputContainer'; diff --git a/packages/time-input/src/TimeInput.stories.tsx b/packages/time-input/src/TimeInput.stories.tsx index 005a8751e0..e90ce7a0a5 100644 --- a/packages/time-input/src/TimeInput.stories.tsx +++ b/packages/time-input/src/TimeInput.stories.tsx @@ -1,9 +1,13 @@ import React, { useState } from 'react'; -import { type StoryMetaType } from '@lg-tools/storybook-utils'; +import { + storybookArgTypes, + type StoryMetaType, +} from '@lg-tools/storybook-utils'; import { StoryFn } from '@storybook/react'; import { DateType, SupportedLocales } from '@leafygreen-ui/date-utils'; +import { Size } from './TimeInput/TimeInput.types'; import { TimeInput } from '.'; const meta: StoryMetaType = { @@ -20,6 +24,8 @@ const meta: StoryMetaType = { 'onSegmentChange', 'value', 'onTimeChange', + 'data-lgid', + 'data-testid', ], }, }, @@ -27,6 +33,9 @@ const meta: StoryMetaType = { showSeconds: true, locale: SupportedLocales.ISO_8601, timeZone: 'UTC', + label: 'Time Input', + darkMode: false, + size: Size.Default, }, argTypes: { locale: { control: 'select', options: Object.values(SupportedLocales) }, @@ -34,6 +43,8 @@ const meta: StoryMetaType = { control: 'select', options: [undefined, 'UTC', 'America/New_York', 'Europe/London'], }, + darkMode: storybookArgTypes.darkMode, + size: { control: 'select', options: Object.values(Size) }, }, }; diff --git a/packages/time-input/src/TimeInput/TimeInput.tsx b/packages/time-input/src/TimeInput/TimeInput.tsx index b97481ed36..4c6a43e9e0 100644 --- a/packages/time-input/src/TimeInput/TimeInput.tsx +++ b/packages/time-input/src/TimeInput/TimeInput.tsx @@ -9,13 +9,14 @@ import { pickAndOmit } from '@leafygreen-ui/lib'; import { BaseFontSize } from '@leafygreen-ui/tokens'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; -import { TimeInputProvider } from '../Context/TimeInputContext/TimeInputContext'; -import { TimeInputDisplayProvider } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; import { DisplayContextPropKeys, displayContextPropNames, -} from '../Context/TimeInputDisplayContext/TimePickerDisplayContext.utils'; + TimeInputDisplayProvider, + TimeInputProvider, +} from '../Context'; import { TimeInputContent } from '../TimeInputContent'; +import { getLgIds } from '../utils'; import { TimeInputProps } from './TimeInput.types'; @@ -26,7 +27,7 @@ export const TimeInput = forwardRef( onTimeChange: onChangeProp, handleValidation, initialValue: initialValueProp, - 'data-lgid': _dataLgId, + 'data-lgid': dataLgId, darkMode: darkModeProp, baseFontSize: basefontSizeProp, ...props @@ -35,6 +36,7 @@ export const TimeInput = forwardRef( ) => { const { darkMode } = useDarkMode(darkModeProp); const baseFontSize = useUpdatedBaseFontSize(basefontSizeProp); + const lgIds = getLgIds(dataLgId); const { value, updateValue } = useControlled( valueProp, @@ -55,7 +57,7 @@ export const TimeInput = forwardRef( darkMode={darkMode} baseFontSize={baseFontSize === BaseFontSize.Body1 ? 14 : baseFontSize} > - + ; + displayProps?: Partial; +}) => { + const result = render( + + {}} + {...props} + /> + , + ); + + // TODO:: replace with test harnesses + const hourInput = result.container.querySelector( + 'input[aria-label="hour"]', + ) as HTMLInputElement; + const minuteInput = result.container.querySelector( + 'input[aria-label="minute"]', + ) as HTMLInputElement; + const secondInput = result.container.querySelector( + 'input[aria-label="second"]', + ) as HTMLInputElement; + + return { + ...result, + hourInput, + minuteInput, + secondInput, + }; +}; + +describe('packages/time-input/time-input-box', () => { + describe('Rendering', () => { + it('should render the segments', () => { + const { hourInput, minuteInput, secondInput } = renderTimeInputBox({}); + expect(hourInput).toBeInTheDocument(); + expect(minuteInput).toBeInTheDocument(); + expect(secondInput).toBeInTheDocument(); + }); + + it('should render the correct aria labels', () => { + const { hourInput, minuteInput, secondInput } = renderTimeInputBox({}); + expect(hourInput).toHaveAttribute('aria-label', 'hour'); + expect(minuteInput).toHaveAttribute('aria-label', 'minute'); + expect(secondInput).toHaveAttribute('aria-label', 'second'); + }); + + test('does not render seconds when showSeconds is false', () => { + const { secondInput } = renderTimeInputBox({ + displayProps: { showSeconds: false }, + }); + expect(secondInput).not.toBeInTheDocument(); + }); + }); + + describe('setSegment', () => { + test('should call setSegment with the segment name and the value', () => { + const setSegment = jest.fn(); + const { hourInput } = renderTimeInputBox({ props: { setSegment } }); + userEvent.type(hourInput, '1'); + expect(setSegment).toHaveBeenCalledWith('hour', '1'); + }); + }); + + describe('onSegmentChange', () => { + test.todo( + 'should call onSegmentChange with the segment name and the value', + ); + }); +}); diff --git a/packages/time-input/src/TimeInputBox/TimeInputBox.tsx b/packages/time-input/src/TimeInputBox/TimeInputBox.tsx new file mode 100644 index 0000000000..d44ae21ac2 --- /dev/null +++ b/packages/time-input/src/TimeInputBox/TimeInputBox.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { InputBox } from '@leafygreen-ui/input-box'; + +import { getTimeSegmentRules } from '../constants'; +import { useTimeInputDisplayContext } from '../Context'; +import { TimeSegment } from '../shared.types'; +import { TimeInputSegment } from '../TimeInputSegment/TimeInputSegment'; + +import { TimeInputBoxProps } from './TimeInputBox.types'; + +export const TimeInputBox = React.forwardRef( + ({ children, ...rest }: TimeInputBoxProps, fwdRef) => { + const { disabled, formatParts, size, is12HourFormat } = + useTimeInputDisplayContext(); + return ( + + ); + }, +); + +TimeInputBox.displayName = 'TimeInputBox'; diff --git a/packages/time-input/src/TimeInputBox/TimeInputBox.types.ts b/packages/time-input/src/TimeInputBox/TimeInputBox.types.ts new file mode 100644 index 0000000000..262baf0f1b --- /dev/null +++ b/packages/time-input/src/TimeInputBox/TimeInputBox.types.ts @@ -0,0 +1,7 @@ +import { TimeSegment, TimeSegmentsState } from '../shared.types'; + +export interface TimeInputBoxProps + extends React.ComponentPropsWithoutRef<'div'> { + segments: TimeSegmentsState; + setSegment: (segment: TimeSegment, value: string) => void; +} diff --git a/packages/time-input/src/TimeInputBox/index.ts b/packages/time-input/src/TimeInputBox/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx index 12e186c411..c00eadebef 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -1,12 +1,14 @@ -import React, { forwardRef, useState } from 'react'; - -import { cx } from '@leafygreen-ui/emotion'; -import { FormField, FormFieldInputContainer } from '@leafygreen-ui/form-field'; +import React, { forwardRef } from 'react'; import { unitOptions } from '../constants'; -import { useTimeInputDisplayContext } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; +import { useTimeInputContext, useTimeInputDisplayContext } from '../Context'; +import { useSelectUnit } from '../hooks'; +import { TimeSegmentsState } from '../shared.types'; +import { TimeFormField, TimeFormFieldInputContainer } from '../TimeFormField'; +import { TimeInputBox } from '../TimeInputBox/TimeInputBox'; import { TimeInputSelect } from '../TimeInputSelect/TimeInputSelect'; import { UnitOption } from '../TimeInputSelect/TimeInputSelect.types'; +import { getFormatPartsValues } from '../utils'; import { wrapperBaseStyles } from './TimeInputInputs.styles'; import { TimeInputInputsProps } from './TimeInputInputs.types'; @@ -16,21 +18,58 @@ import { TimeInputInputsProps } from './TimeInputInputs.types'; */ export const TimeInputInputs = forwardRef( (_props: TimeInputInputsProps, forwardedRef) => { - const { is12hFormat } = useTimeInputDisplayContext(); - const [selectUnit, setSelectUnit] = useState(unitOptions[0]); + const { is12HourFormat, timeZone, locale } = useTimeInputDisplayContext(); + const { value } = useTimeInputContext(); const handleSelectChange = (unit: UnitOption) => { setSelectUnit(unit); }; - // TODO: break this out more + /** + * Gets the time parts from the value + */ + const timeParts = getFormatPartsValues({ + locale: locale, + timeZone: timeZone, + value: value, + }); + + const { hour, minute, second } = timeParts; + + /** + * Creates time segments object + * // TODO: these are temp and will be replaced in the next PR + */ + const segmentObj: TimeSegmentsState = { + hour, + minute, + second, + }; + + /** + * Hook to manage the select unit + * // TODO: This is temp and will be replaced in the next PR + */ + const { selectUnit, setSelectUnit } = useSelectUnit({ + dayPeriod: timeParts.dayPeriod, + value, + unitOptions, + }); + return ( - -
- -
TODO: Input segments go here
-
- {is12hFormat && ( + +
+ + { + // TODO: This is temp and will be replaced in the next PR + // eslint-disable-next-line no-console + console.log({ segment, value }); + }} + /> + + {is12HourFormat && ( { @@ -39,7 +78,7 @@ export const TimeInputInputs = forwardRef( /> )}
- +
); }, ); diff --git a/packages/time-input/src/TimeInputSegment/TimeInputSegment.spec.tsx b/packages/time-input/src/TimeInputSegment/TimeInputSegment.spec.tsx new file mode 100644 index 0000000000..f202525d5e --- /dev/null +++ b/packages/time-input/src/TimeInputSegment/TimeInputSegment.spec.tsx @@ -0,0 +1,501 @@ +import React from 'react'; +import { jest } from '@jest/globals'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SupportedLocales } from '@leafygreen-ui/date-utils'; +import { getValueFormatter } from '@leafygreen-ui/input-box'; + +import { + defaultPlaceholder, + getDefaultMax, + getDefaultMin, + getTimeSegmentRules, +} from '../constants'; +import { + TimeInputDisplayContextProps, + TimeInputDisplayProvider, +} from '../Context'; +import { + TimeInputSegmentChangeEventHandler, + TimeSegment, +} from '../shared.types'; +import { getLgIds } from '../utils'; + +import { TimeInputSegment } from './TimeInputSegment'; +import { TimeInputSegmentProps } from './TimeInputSegment.types'; + +const lgIds = getLgIds(); + +const renderSegment = ( + props?: Partial, + ctx?: Partial, +) => { + const is12HourFormat = !!ctx?.is12HourFormat; + const defaultSegmentProps = { + value: '', + onChange: () => {}, + segment: 'hour' as TimeSegment, + disabled: false, + segmentEnum: TimeSegment, + charsCount: getTimeSegmentRules({ is12HourFormat })['hour'].maxChars, + minSegmentValue: getDefaultMin({ is12HourFormat })['hour'], + maxSegmentValue: getDefaultMax({ is12HourFormat })['hour'], + placeholder: defaultPlaceholder['hour'], + shouldWrap: true, + shouldValidate: true, + step: 1, + }; + + const result = render( + + + , + ); + + const rerenderSegment = (newProps: Partial) => + result.rerender( + + + , + ); + + // TODO:: replace with test harnesses + const getInput = () => + result.getByTestId(lgIds.inputSegment) as HTMLInputElement; + + return { + ...result, + rerenderSegment, + getInput, + input: getInput(), + }; +}; + +describe('packages/time-input/time-input-segment', () => { + describe('rendering', () => { + describe('segment', () => { + test('renders with an empty value sets the value to empty string', () => { + const { input } = renderSegment({ + value: '', + }); + expect(input.value).toBe(''); + }); + + test('renders with a value sets the value to the value', () => { + const { input } = renderSegment({ + value: '12', + }); + expect(input.value).toBe('12'); + }); + + test('rerendering updates the value', () => { + const { input, getInput, rerenderSegment } = renderSegment({ + value: '12', + }); + expect(input.value).toBe('12'); + rerenderSegment({ + value: '08', + }); + expect(getInput().value).toBe('08'); + }); + }); + }); + + describe('Keyboard', () => { + describe('Arrow Keys', () => { + describe('hour input', () => { + describe('Up arrow', () => { + const formatter = getValueFormatter({ + charsCount: getTimeSegmentRules({ is12HourFormat: true })['hour'] + .maxChars, + }); + test('calls handler with value +1 if value is less than max', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment: 'hour', + value: formatter(15), + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(16) }), + ); + }); + + describe('12 hour format', () => { + const formatter = getValueFormatter({ + charsCount: getTimeSegmentRules({ is12HourFormat: true })['hour'] + .maxChars, + }); + + test('calls handler with min if value is undefined', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: '', + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.en_US, + }, + ); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMin({ is12HourFormat: true })['hour'], + ), + }), + ); + }); + + test('rolls value over to min value if value exceeds `max`', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: formatter( + getDefaultMax({ is12HourFormat: true })['hour'], + ), + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.en_US, + }, + ); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMin({ is12HourFormat: true })['hour'], + ), + }), + ); + }); + }); + + describe('24 hour format', () => { + const formatter = getValueFormatter({ + charsCount: getTimeSegmentRules({ is12HourFormat: false })['hour'] + .maxChars, + allowZero: true, + }); + + test('calls handler with min if value is undefined', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: '', + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.ISO_8601, + }, + ); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMin({ is12HourFormat: false })['hour'], + ), + }), + ); + }); + + test('rolls value over to min value if value exceeds `max`', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: formatter( + getDefaultMax({ is12HourFormat: false })['hour'], + ), + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.ISO_8601, + }, + ); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMin({ is12HourFormat: false })['hour'], + ), + }), + ); + }); + }); + }); + + describe('Down arrow', () => { + const formatter = getValueFormatter({ + charsCount: getTimeSegmentRules({ is12HourFormat: true })['hour'] + .maxChars, + }); + test('calls handler with value -1 if value is greater than min', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment: 'hour', + value: formatter(12), + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(11) }), + ); + }); + + describe('12 hour format', () => { + test('calls handler with max if value is undefined', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: '', + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.en_US, + }, + ); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMax({ is12HourFormat: true })['hour'], + ), + }), + ); + }); + + test('rolls value over to max value if value exceeds `min`', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: formatter( + getDefaultMin({ is12HourFormat: true })['hour'], + ), + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.en_US, + }, + ); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMax({ is12HourFormat: true })['hour'], + ), + }), + ); + }); + }); + + describe('24 hour format', () => { + const formatter = getValueFormatter({ + charsCount: getTimeSegmentRules({ is12HourFormat: false })['hour'] + .maxChars, + allowZero: true, + }); + + test('calls handler with max if value is undefined', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: '', + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.ISO_8601, + }, + ); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMax({ is12HourFormat: false })['hour'], + ), + }), + ); + }); + + test('rolls value over to max value if value exceeds `min`', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment( + { + segment: 'hour', + value: formatter( + getDefaultMin({ is12HourFormat: false })['hour'], + ), + onChange: onChangeHandler, + }, + { + locale: SupportedLocales.ISO_8601, + }, + ); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMax({ is12HourFormat: false })['hour'], + ), + }), + ); + }); + }); + }); + }); + + describe.each(['minute', 'second'] as Array)( + '%p input', + segment => { + const formatter = getValueFormatter({ + charsCount: getTimeSegmentRules({ is12HourFormat: true })[segment] + .maxChars, + allowZero: true, + }); + + describe('Up arrow', () => { + test('calls handler with value +1 if value is less than max', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment, + value: formatter(15), + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(16) }), + ); + }); + + test('calls handler with min if value is undefined', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment, + value: '', + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMin({ is12HourFormat: true })[segment], + ), + }), + ); + }); + + test('rolls value over to min value if value exceeds `max`', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment, + value: formatter( + getDefaultMax({ is12HourFormat: true })[segment], + ), + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMin({ is12HourFormat: true })[segment], + ), + }), + ); + }); + }); + describe('Down arrow', () => { + test('calls handler with value -1 if value is greater than min', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment, + value: formatter(12), + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: formatter(11) }), + ); + }); + + test('calls handler with max if value is undefined', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment, + value: '', + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMax({ is12HourFormat: true })[segment], + ), + }), + ); + }); + + test('rolls value over to max value if value exceeds `min`', () => { + const onChangeHandler = + jest.fn(); + const { input } = renderSegment({ + segment, + value: formatter( + getDefaultMin({ is12HourFormat: true })[segment], + ), + onChange: onChangeHandler, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter( + getDefaultMax({ is12HourFormat: true })[segment], + ), + }), + ); + }); + }); + }, + ); + }); + }); +}); diff --git a/packages/time-input/src/TimeInputSegment/TimeInputSegment.styles.ts b/packages/time-input/src/TimeInputSegment/TimeInputSegment.styles.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/time-input/src/TimeInputSegment/TimeInputSegment.tsx b/packages/time-input/src/TimeInputSegment/TimeInputSegment.tsx new file mode 100644 index 0000000000..7ee632cf2a --- /dev/null +++ b/packages/time-input/src/TimeInputSegment/TimeInputSegment.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { InputSegment } from '@leafygreen-ui/input-box'; + +import { + defaultPlaceholder, + getDefaultMax, + getDefaultMin, + getTimeSegmentRules, +} from '../constants'; +import { useTimeInputDisplayContext } from '../Context'; + +import { TimeInputSegmentProps } from './TimeInputSegment.types'; + +export const TimeInputSegment = React.forwardRef< + HTMLInputElement, + TimeInputSegmentProps +>(({ children, segment, ...rest }: TimeInputSegmentProps, fwdRef) => { + const { is12HourFormat, lgIds } = useTimeInputDisplayContext(); + + return ( + + ); +}); + +TimeInputSegment.displayName = 'TimeInputSegment'; diff --git a/packages/time-input/src/TimeInputSegment/TimeInputSegment.types.ts b/packages/time-input/src/TimeInputSegment/TimeInputSegment.types.ts new file mode 100644 index 0000000000..e806f56f60 --- /dev/null +++ b/packages/time-input/src/TimeInputSegment/TimeInputSegment.types.ts @@ -0,0 +1,6 @@ +import { InputSegmentComponentProps } from '@leafygreen-ui/input-box'; + +import { TimeSegment } from '../shared.types'; + +export interface TimeInputSegmentProps + extends InputSegmentComponentProps {} diff --git a/packages/time-input/src/TimeInputSegment/index.ts b/packages/time-input/src/TimeInputSegment/index.ts new file mode 100644 index 0000000000..db95e5d49b --- /dev/null +++ b/packages/time-input/src/TimeInputSegment/index.ts @@ -0,0 +1 @@ +export { TimeInputSegment } from './TimeInputSegment'; diff --git a/packages/time-input/src/TimeInputSelect/TimeInputSelect.spec.tsx b/packages/time-input/src/TimeInputSelect/TimeInputSelect.spec.tsx index 43272da620..cc1d3e4fe4 100644 --- a/packages/time-input/src/TimeInputSelect/TimeInputSelect.spec.tsx +++ b/packages/time-input/src/TimeInputSelect/TimeInputSelect.spec.tsx @@ -1,9 +1,73 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import React from 'react'; import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; -import { TimeInputSelect } from '.'; +import { getTestUtils as getSelectTestUtils } from '@leafygreen-ui/select/testing'; + +import { getLgIds } from '../utils'; + +import { TimeInputSelect, TimeInputSelectProps } from '.'; + +const lgIds = getLgIds(); + +const renderTimeInputSelect = (props: TimeInputSelectProps) => { + const result = render(); + + const testUtils = getSelectTestUtils(lgIds.select); + + return { + ...result, + ...testUtils, + }; +}; describe('packages/time-input-select', () => { - test('condition', () => {}); + describe('Rendering', () => { + test('is in the document', () => { + const { getInput } = renderTimeInputSelect({ + unit: 'AM', + onChange: () => {}, + }); + expect(getInput()).toBeInTheDocument(); + }); + + test('shows the correct value', () => { + const { getInput } = renderTimeInputSelect({ + unit: 'AM', + onChange: () => {}, + }); + expect(getInput()).toHaveValue('AM'); + }); + + test('has AM and PM options', () => { + const { getInput, getOptionByValue, getOptions } = renderTimeInputSelect({ + unit: 'AM', + onChange: () => {}, + }); + + userEvent.click(getInput()); + expect(getOptions()).toHaveLength(2); + expect(getOptionByValue('AM')).toBeInTheDocument(); + expect(getOptionByValue('PM')).toBeInTheDocument(); + }); + }); + + describe('onChange', () => { + test('is called with the selected option', () => { + const onChange = jest.fn(); + const { getInput, getOptionByValue } = renderTimeInputSelect({ + unit: 'AM', + onChange, + }); + + userEvent.click(getInput()); + userEvent.click(getOptionByValue('PM')!); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + displayName: 'PM', + value: 'PM', + }), + ); + }); + }); }); diff --git a/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx b/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx index d65308e0c6..5305a70c88 100644 --- a/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx +++ b/packages/time-input/src/TimeInputSelect/TimeInputSelect.tsx @@ -9,6 +9,7 @@ import { } from '@leafygreen-ui/select'; import { unitOptions } from '../constants'; +import { useTimeInputDisplayContext } from '../Context'; import { selectStyles, wrapperBaseStyles } from './TimeInputSelect.styles'; import { TimeInputSelectProps, UnitOption } from './TimeInputSelect.types'; @@ -22,10 +23,9 @@ export const TimeInputSelect = ({ className, onChange, }: TimeInputSelectProps) => { + const { lgIds } = useTimeInputDisplayContext(); /** * Gets the current unit option using the unit string - * - * @internal */ const currentUnitOption = unitOptions.find( u => u.displayName === unit, @@ -50,6 +50,7 @@ export const TimeInputSelect = ({ allowDeselect={false} dropdownWidthBasis={DropdownWidthBasis.Option} renderMode={RenderMode.TopLayer} + data-lgid={lgIds.select} > {unitOptions.map(option => (