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.tsx b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx index 581b374a32..8982385261 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimeInputDisplayContext.tsx @@ -41,21 +41,28 @@ 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, }); + /** + * Gets the time zone from the provider value or the browser's default + */ const timeZone = defaultTo( providerValue.timeZone, Intl.DateTimeFormat().resolvedOptions().timeZone, ); + // TODO: min, max helpers will be in a future PR + return ( ; @@ -40,15 +41,22 @@ export type TimeInputDisplayContextProps = Omit< setIsDirty: React.Dispatch>; /** - * Whether the AM/PM select should be shown + * Whether the time input is in 12-hour format. Helps determine if the AM/PM select should be shown. + * + * @default false */ - is12hFormat: boolean; + is12HourFormat: boolean; /** * An array of {@link Intl.DateTimeFormatPart}, * used to determine the order of segments in the input */ formatParts?: Array; + + /** + * LGIDs for the code snippet. + */ + lgIds: GetLgIdsReturnType; }; /** @@ -73,4 +81,9 @@ export type TimeInputDisplayProviderProps = Omit< * The aria-labelledby prop */ 'aria-labelledby'?: string; + + /** + * LGIDs for the code snippet. + */ + lgIds?: GetLgIdsReturnType; }; diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts b/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts index 9f178ddb6d..101a1049eb 100644 --- a/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts +++ b/packages/time-input/src/Context/TimeInputDisplayContext/TimePickerDisplayContext.utils.ts @@ -6,13 +6,17 @@ import { import { BaseFontSize } from '@leafygreen-ui/tokens'; import { Size } from '../../TimeInput/TimeInput.types'; +import { getLgIds } from '../../utils/getLgIds'; import { TimeInputDisplayContextProps, TimeInputDisplayProviderProps, } from './TimeInputDisplayContext.types'; -export type DisplayContextPropKeys = keyof TimeInputDisplayProviderProps; +export type DisplayContextPropKeys = Exclude< + keyof TimeInputDisplayProviderProps, + 'lgIds' +>; /** * Props names that that are added to the context and used to pick and omit props @@ -52,6 +56,7 @@ export const defaultTimeInputDisplayContext: TimeInputDisplayContextProps = { errorMessage: '', isDirty: false, setIsDirty: () => {}, - is12hFormat: false, + is12HourFormat: false, showSeconds: true, + lgIds: getLgIds(), }; diff --git a/packages/time-input/src/Context/TimeInputDisplayContext/Index.ts b/packages/time-input/src/Context/TimeInputDisplayContext/index.ts similarity index 100% rename from packages/time-input/src/Context/TimeInputDisplayContext/Index.ts rename to packages/time-input/src/Context/TimeInputDisplayContext/index.ts 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..e306f44f16 --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormField/TimeFormField.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { FormField } from '@leafygreen-ui/form-field'; + +import { useTimeInputDisplayContext } from '../../Context/TimeInputDisplayContext'; + +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, + // stateNotification: { state, message: errorMessage }, + 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/TimeFormFieldInputContainer/TimeFormFieldInputContainer.styles.ts b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.styles.ts new file mode 100644 index 0000000000..139e3ed68f --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.styles.ts @@ -0,0 +1,22 @@ +import { css, cx } from '@leafygreen-ui/emotion'; + +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..f96fb0d74e --- /dev/null +++ b/packages/time-input/src/TimeFormField/TimeFormFieldInputContainer/TimeFormFieldInputContainer.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { FormFieldInputContainer } from '@leafygreen-ui/form-field'; + +import { useTimeInputDisplayContext } from '../../Context/TimeInputDisplayContext'; + +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/index.ts b/packages/time-input/src/TimeFormField/index.ts new file mode 100644 index 0000000000..9d4badd451 --- /dev/null +++ b/packages/time-input/src/TimeFormField/index.ts @@ -0,0 +1,2 @@ +export { TimeFormField } from './TimeFormField/TimeFormField'; +export { TimeFormFieldInputContainer } from './TimeFormFieldInputContainer/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..5c38b24710 100644 --- a/packages/time-input/src/TimeInput/TimeInput.tsx +++ b/packages/time-input/src/TimeInput/TimeInput.tsx @@ -16,6 +16,7 @@ import { displayContextPropNames, } from '../Context/TimeInputDisplayContext/TimePickerDisplayContext.utils'; import { TimeInputContent } from '../TimeInputContent'; +import { getLgIds } from '../utils/getLgIds'; 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('Min/Max', () => { + describe('hour segment', () => { + describe('12 hour format', () => { + it('should have a min of 1 for the hour segment', () => { + const { hourInput } = renderTimeInputBox({ + displayProps: { locale: SupportedLocales.en_US }, + }); + expect(hourInput).toHaveAttribute('min', '1'); + }); + it('should have a max of 12 for the hour segment', () => { + const { hourInput } = renderTimeInputBox({ + displayProps: { locale: SupportedLocales.en_US }, + }); + expect(hourInput).toHaveAttribute('max', '12'); + }); + }); + + describe('24 hour format', () => { + it('should have a min of 0 for the hour segment', () => { + const { hourInput } = renderTimeInputBox({ + displayProps: { locale: SupportedLocales.ISO_8601 }, + }); + expect(hourInput).toHaveAttribute('min', '0'); + }); + it('should have a max of 23 for the hour segment', () => { + const { hourInput } = renderTimeInputBox({ + displayProps: { locale: SupportedLocales.ISO_8601 }, + }); + expect(hourInput).toHaveAttribute('max', '23'); + }); + }); + }); + + describe.each(['minute', 'second'])('%p segment', segment => { + test('should have a min of 0 for the %p segment', () => { + const result = renderTimeInputBox({ + displayProps: { locale: SupportedLocales.ISO_8601 }, + }); + const input = + segment === 'minute' ? result.minuteInput : result.secondInput; + expect(input).toHaveAttribute('min', '0'); + }); + test('should have a max of 59 for the %p segment', () => { + const result = renderTimeInputBox({ + displayProps: { locale: SupportedLocales.ISO_8601 }, + }); + const input = + segment === 'minute' ? result.minuteInput : result.secondInput; + expect(input).toHaveAttribute('max', '59'); + }); + }); + }); + + 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.styles.ts b/packages/time-input/src/TimeInputBox/TimeInputBox.styles.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/time-input/src/TimeInputBox/TimeInputBox.tsx b/packages/time-input/src/TimeInputBox/TimeInputBox.tsx new file mode 100644 index 0000000000..1ab56e62e7 --- /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/TimeInputDisplayContext/TimeInputDisplayContext'; +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 36a3377d48..71f30079a8 100644 --- a/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx +++ b/packages/time-input/src/TimeInputInputs/TimeInputInputs.tsx @@ -1,11 +1,12 @@ -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 { useTimeInputContext } from '../Context/TimeInputContext/TimeInputContext'; import { useTimeInputDisplayContext } from '../Context/TimeInputDisplayContext/TimeInputDisplayContext'; +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'; @@ -18,32 +19,58 @@ import { TimeInputInputsProps } from './TimeInputInputs.types'; */ export const TimeInputInputs = forwardRef( (_props: TimeInputInputsProps, forwardedRef) => { - const { is12hFormat, timeZone, locale } = useTimeInputDisplayContext(); - const [selectUnit, setSelectUnit] = useState(unitOptions[0]); - + const { is12HourFormat, timeZone, locale } = useTimeInputDisplayContext(); const { value } = useTimeInputContext(); const handleSelectChange = (unit: UnitOption) => { setSelectUnit(unit); }; + /** + * Gets the time parts from the value + */ const timeParts = getFormatPartsValues({ locale: locale, timeZone: timeZone, value: value, }); - // eslint-disable-next-line no-console - console.log('timeParts 🍎🍎🍎', timeParts); + 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, + }); - // TODO: break this out more 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 && ( { @@ -52,7 +79,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..b7a0cc5171 --- /dev/null +++ b/packages/time-input/src/TimeInputSegment/TimeInputSegment.spec.tsx @@ -0,0 +1,499 @@ +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 } from '../Context/TimeInputDisplayContext'; +import { TimeInputDisplayProvider } from '../Context/TimeInputDisplayContext'; +import { TimeSegment } from '../shared.types'; +import { getLgIds } from '../utils/getLgIds'; + +import { TimeInputSegment } from './TimeInputSegment'; +import { + TimeInputSegmentChangeEventHandler, + 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: 'minute', + 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..8d1e44fce3 --- /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/TimeInputDisplayContext/TimeInputDisplayContext'; + +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..73a27a1f88 --- /dev/null +++ b/packages/time-input/src/TimeInputSegment/TimeInputSegment.types.ts @@ -0,0 +1,24 @@ +import { + InputSegmentChangeEventHandler, + InputSegmentComponentProps, +} from '@leafygreen-ui/input-box'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { TimeSegment } from '../shared.types'; + +export interface TimeInputSegmentChangeEvent { + segment: TimeSegment; + value: string; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + [key: string]: any; + }; +} + +export type TimeInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< + TimeSegment, + string +>; + +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..f977ce780e 100644 --- a/packages/time-input/src/TimeInputSelect/TimeInputSelect.spec.tsx +++ b/packages/time-input/src/TimeInputSelect/TimeInputSelect.spec.tsx @@ -1,9 +1,82 @@ -/* 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/getLgIds'; + +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 2 options', () => { + const { getInput, getOptions } = renderTimeInputSelect({ + unit: 'AM', + onChange: () => {}, + }); + + userEvent.click(getInput()); + expect(getOptions()).toHaveLength(2); + }); + + test('has AM and PM options', () => { + const { getInput, getOptionByValue } = renderTimeInputSelect({ + unit: 'AM', + onChange: () => {}, + }); + + userEvent.click(getInput()); + 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..b2d036029d 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/TimeInputDisplayContext/TimeInputDisplayContext'; 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 => (