diff --git a/.changeset/input-box.md b/.changeset/input-box.md new file mode 100644 index 0000000000..204f152041 --- /dev/null +++ b/.changeset/input-box.md @@ -0,0 +1,5 @@ +--- +'@leafygreen-ui/input-box': minor +--- + +Initial release of `InputBox` diff --git a/packages/date-picker/package.json b/packages/date-picker/package.json index 87bf0a13cf..2dbe7e2693 100644 --- a/packages/date-picker/package.json +++ b/packages/date-picker/package.json @@ -22,6 +22,7 @@ "@leafygreen-ui/hooks": "workspace:^", "@leafygreen-ui/icon": "workspace:^", "@leafygreen-ui/icon-button": "workspace:^", + "@leafygreen-ui/input-box": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/popover": "workspace:^", diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx index 41226340d3..51d6105491 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx @@ -3,14 +3,14 @@ import userEvent from '@testing-library/user-event'; import { Month, newUTC } from '@leafygreen-ui/date-utils'; import { getLgIds as getLgFormFieldIds } from '@leafygreen-ui/form-field'; +import { getValueFormatter } from '@leafygreen-ui/input-box'; import { eventContainingTargetValue } from '@leafygreen-ui/testing-lib'; import { DateSegment } from '../shared'; -import { defaultMax, defaultMin } from '../shared/constants'; +import { charsPerSegment, defaultMax, defaultMin } from '../shared/constants'; import { getFormattedDateString, getFormattedSegmentsFromDate, - getValueFormatter, } from '../shared/utils'; import { @@ -79,7 +79,9 @@ describe('DatePicker keyboard interaction', () => { const segmentCases = ['year', 'month', 'day'] as Array; describe.each(segmentCases)('%p segment', segment => { - const formatter = getValueFormatter(segment); + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment[segment], + }); /** Utility only for this suite. Returns the day|month|year element from the render result */ const getRelevantInput = (renderResult: RenderDatePickerResult) => segment === 'year' diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx index 7954a8df4f..c68789da3d 100644 --- a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx @@ -8,6 +8,7 @@ import React, { import isNull from 'lodash/isNull'; import { isInvalidDateObject, isSameUTCDay } from '@leafygreen-ui/date-utils'; +import { isElementInputSegment } from '@leafygreen-ui/input-box'; import { createSyntheticEvent, keyMap } from '@leafygreen-ui/lib'; import { @@ -17,11 +18,7 @@ import { } from '../../shared/components/DateInput'; import { DateInputSegmentChangeEventHandler } from '../../shared/components/DateInput/DateInputSegment'; import { useSharedDatePickerContext } from '../../shared/context'; -import { - getFormattedDateStringFromSegments, - getRelativeSegmentRef, - isElementInputSegment, -} from '../../shared/utils'; +import { getFormattedDateStringFromSegments } from '../../shared/utils'; import { useDatePickerContext } from '../DatePickerContext'; import { getSegmentToFocus } from '../utils/getSegmentToFocus'; @@ -110,77 +107,11 @@ export const DatePickerInput = forwardRef( // if target is not a segment, do nothing if (!isSegment) return; - const isSegmentEmpty = !target.value; - switch (key) { - case keyMap.ArrowLeft: { - // Without this, the input ignores `.select()` - e.preventDefault(); - // if input is empty, - // set focus to prev input (if it exists) - const segmentToFocus = getRelativeSegmentRef('prev', { - segment: target, - formatParts, - segmentRefs, - }); - - segmentToFocus?.current?.focus(); - segmentToFocus?.current?.select(); - // otherwise, use default behavior - - break; - } - - case keyMap.ArrowRight: { - // Without this, the input ignores `.select()` - e.preventDefault(); - // if input is empty, - // set focus to next. input (if it exists) - const segmentToFocus = getRelativeSegmentRef('next', { - segment: target, - formatParts, - segmentRefs, - }); - - segmentToFocus?.current?.focus(); - segmentToFocus?.current?.select(); - // otherwise, use default behavior - - break; - } - - case keyMap.ArrowUp: - case keyMap.ArrowDown: { - // increment/decrement logic implemented by DateInputSegment - break; - } - - case keyMap.Backspace: { - if (isSegmentEmpty) { - // prevent the backspace in the previous segment - e.preventDefault(); - - const segmentToFocus = getRelativeSegmentRef('prev', { - segment: target, - formatParts, - segmentRefs, - }); - segmentToFocus?.current?.focus(); - segmentToFocus?.current?.select(); - } - break; - } - case keyMap.Space: { openMenu(); break; } - - case keyMap.Enter: - case keyMap.Escape: - case keyMap.Tab: - // Behavior handled by parent or menu - break; } // call any handler that was passed in @@ -232,10 +163,9 @@ export const DatePickerInput = forwardRef( ( setValue={handleInputValueChange} segmentRefs={segmentRefs} onSegmentChange={handleSegmentChange} + onKeyDown={handleInputKeyDown} /> ); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx index f0851e022b..8305e034d7 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -1,4 +1,4 @@ -import React, { FocusEventHandler, useEffect } from 'react'; +import React, { useEffect } from 'react'; import isEqual from 'lodash/isEqual'; import isNull from 'lodash/isNull'; @@ -7,37 +7,20 @@ import { isInvalidDateObject, isValidDate, } from '@leafygreen-ui/date-utils'; -import { cx } from '@leafygreen-ui/emotion'; -import { useForwardedRef } from '@leafygreen-ui/hooks'; -import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { keyMap } from '@leafygreen-ui/lib'; +import { InputBox } from '@leafygreen-ui/input-box'; +import { charsPerSegment, dateSegmentRules } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; import { useDateSegments } from '../../../hooks'; +import { DateSegment, DateSegmentsState } from '../../../types'; import { - DateSegment, - DateSegmentsState, - DateSegmentValue, - isDateSegment, -} from '../../../types'; -import { - getMaxSegmentValue, - getMinSegmentValue, - getRelativeSegment, - getValueFormatter, isEverySegmentFilled, isEverySegmentValueExplicit, - isExplicitSegmentValue, newDateFromSegments, } from '../../../utils'; +import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegment } from '../DateInputSegment'; -import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; -import { - segmentPartsWrapperStyles, - separatorLiteralDisabledStyles, - separatorLiteralStyles, -} from './DateInputBox.styles'; import { DateInputBoxProps } from './DateInputBox.types'; /** @@ -62,25 +45,13 @@ export const DateInputBox = React.forwardRef( labelledBy, segmentRefs, onSegmentChange, + onKeyDown, ...rest }: DateInputBoxProps, fwdRef, ) => { - const { isDirty, formatParts, disabled, min, max, setIsDirty } = + const { isDirty, formatParts, disabled, setIsDirty, size } = useSharedDatePickerContext(); - const { theme } = useDarkMode(); - - const containerRef = useForwardedRef(fwdRef, null); - - /** Formats and sets the segment value */ - const getFormattedSegmentValue = ( - segmentName: DateSegment, - segmentValue: DateSegmentValue, - ): DateSegmentValue => { - const formatter = getValueFormatter(segmentName); - const formattedValue = formatter(segmentValue); - return formattedValue; - }; /** if the value is a `Date` the component is dirty */ useEffect(() => { @@ -118,92 +89,32 @@ export const DateInputBox = React.forwardRef( } }; + /** State Management for segments using a useReducer instead of useState */ /** Keep track of each date segment */ const { segments, setSegment } = useDateSegments(value, { onUpdate: handleSegmentUpdate, }); - /** Fired when an individual segment value changes */ - const handleSegmentInputChange: DateInputSegmentChangeEventHandler = - segmentChangeEvent => { - let segmentValue = segmentChangeEvent.value; - const { segment: segmentName, meta } = segmentChangeEvent; - const changedViaArrowKeys = - meta?.key === keyMap.ArrowDown || meta?.key === keyMap.ArrowUp; - - // Auto-format the segment if it is explicit and was not changed via arrow-keys - if ( - !changedViaArrowKeys && - isExplicitSegmentValue(segmentName, segmentValue) - ) { - segmentValue = getFormattedSegmentValue(segmentName, segmentValue); - - // Auto-advance focus (if possible) - const nextSegmentName = getRelativeSegment('next', { - segment: segmentName, - formatParts, - }); - - if (nextSegmentName) { - const nextSegmentRef = segmentRefs[nextSegmentName]; - nextSegmentRef?.current?.focus(); - nextSegmentRef?.current?.select(); - } - } - - setSegment(segmentName, segmentValue); - onSegmentChange?.(segmentChangeEvent); - }; - - /** Triggered when a segment is blurred */ - const handleSegmentInputBlur: FocusEventHandler = e => { - const segmentName = e.target.getAttribute('id'); - const segmentValue = e.target.value; - - if (isDateSegment(segmentName)) { - const formattedValue = getFormattedSegmentValue( - segmentName, - segmentValue, - ); - setSegment(segmentName, formattedValue); - } - }; - return ( -
- {formatParts?.map((part, i) => { - if (part.type === 'literal') { - return ( - - {part.value} - - ); - } else if (isDateSegment(part.type)) { - return ( - - ); - } - })} -
+ + + ); }, ); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.spec.tsx new file mode 100644 index 0000000000..2e382cc102 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.spec.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; + +import { + DateInputBoxProvider, + useDateInputBoxContext, +} from './DateInputBoxContext'; + +describe('DateInputBoxContext', () => { + test('throws error when used outside of DateInputBoxProvider', () => { + /** + * The version of `renderHook` imported from "@testing-library/react-hooks", (used in React 17) + * has an error boundary, and doesn't throw errors as expected: + * https://github.com/testing-library/react-hooks-testing-library/blob/main/src/index.ts#L5 + * */ + if (isReact17()) { + const { result } = renderHook(() => useDateInputBoxContext()); + expect(result.error.message).toEqual( + 'useDateInputBoxContext must be used within a DateInputBoxProvider', + ); + } else { + expect(() => renderHook(() => useDateInputBoxContext())).toThrow( + 'useDateInputBoxContext must be used within a DateInputBoxProvider', + ); + } + }); + + test('provides context values that match the props passed to the provider', () => { + const value = new Date(); + const { result } = renderHook(() => useDateInputBoxContext(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current.value).toEqual(value); + }); +}); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx new file mode 100644 index 0000000000..50199b4158 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx @@ -0,0 +1,44 @@ +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +import { + DateInputBoxContextType, + DateInputBoxProviderProps, +} from './DateInputBoxContext.types'; + +export const DateInputBoxContext = + createContext(null); + +/** + * Provider to be used within the DateInputBox component. + * + * Depends on {@link DateInputBoxContextType} + * @param value - Date value in UTC time + * @returns + */ +export const DateInputBoxProvider = ({ + children, + value, +}: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +/** + * Hook to access the DateInputBoxContext + * + * Depends on {@link DateInputBoxContextType} + */ +export const useDateInputBoxContext = () => { + const context = useContext(DateInputBoxContext); + + if (!context) { + throw new Error( + 'useDateInputBoxContext must be used within a DateInputBoxProvider', + ); + } + + return context; +}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts new file mode 100644 index 0000000000..528f8fdfc2 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts @@ -0,0 +1,15 @@ +import { DateType } from '@leafygreen-ui/date-utils'; + +export interface DateInputBoxContextType { + /** + * Date value in UTC time + */ + value?: DateType; +} + +export interface DateInputBoxProviderProps { + /** + * Date value in UTC time + */ + value?: DateType; +} diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/index.ts b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/index.ts new file mode 100644 index 0000000000..352bd2a982 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/index.ts @@ -0,0 +1,5 @@ +export { + DateInputBoxContext, + DateInputBoxProvider, + useDateInputBoxContext, +} from './DateInputBoxContext'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx index 8f56fb113f..20406824db 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx @@ -1,39 +1,97 @@ import React from 'react'; import { jest } from '@jest/globals'; -import { render, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { defaultMax, defaultMin } from '../../../constants'; +import { getValueFormatter } from '@leafygreen-ui/input-box'; +import { + InputBoxProvider, + type InputBoxProviderProps, +} from '@leafygreen-ui/input-box'; +import { Size } from '@leafygreen-ui/tokens'; + +import { charsPerSegment, defaultMax, defaultMin } from '../../../constants'; import { SharedDatePickerProvider, SharedDatePickerProviderProps, } from '../../../context'; +import { segmentRefsMock } from '../../../testutils'; import { DateSegment } from '../../../types'; -import { getValueFormatter } from '../../../utils'; +import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; -const renderSegment = ( - props?: Partial, - ctx?: Partial, -) => { - const defaultProps = { +const renderSegment = ({ + props = {}, + sharedDatePickerProviderProps = {}, + inputBoxProviderProps = {}, +}: { + props?: Partial; + sharedDatePickerProviderProps?: Partial; + inputBoxProviderProps?: Partial>; +}) => { + const defaultSegmentProps = { value: '', - onChange: () => {}, + onChange: () => {}, //TODO: remove this segment: 'day' as DateSegment, }; + const defaultInputBoxProviderProps = { + onChange: () => {}, + onBlur: () => {}, + disabled: false, + size: Size.Default, + segmentRefs: segmentRefsMock, + segments: { + day: '', + month: '', + year: '', + }, + }; + const result = render( - - + + + + + + , ); - const rerenderSegment = (newProps: Partial) => + const rerenderSegment = ({ + newProps = {}, + newInputBoxProviderProps = {}, + }: { + newProps?: Partial; + newInputBoxProviderProps?: Partial>; + }) => result.rerender( - - , + + + + + + + , , ); @@ -56,217 +114,135 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('rendering', () => { - describe('aria attributes', () => { - test('has `spinbutton` role', () => { - const { input } = renderSegment({ segment: 'day' }); - expect(input).toHaveAttribute('role', 'spinbutton'); - }); - test('day segment has aria-label', () => { - const { input } = renderSegment({ segment: 'day' }); - expect(input).toHaveAttribute('aria-label', 'day'); - }); - test('month segment has aria-label', () => { - const { input } = renderSegment({ segment: 'month' }); - expect(input).toHaveAttribute('aria-label', 'month'); - }); - test('year segment has aria-label', () => { - const { input } = renderSegment({ segment: 'year' }); - expect(input).toHaveAttribute('aria-label', 'year'); - }); - }); - describe('day segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ segment: 'day' }); + const { input } = renderSegment({ props: { segment: 'day' } }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ segment: 'day', value: '12' }); + const { input } = renderSegment({ + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: '12', month: '', year: '' }, + }, + }); expect(input.value).toBe('12'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - segment: 'day', - value: '12', + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: '12', month: '', year: '' }, + }, }); - rerenderSegment({ value: '08' }); + rerenderSegment({ + newInputBoxProviderProps: { + segments: { day: '08', month: '', year: '' }, + }, + }); expect(getInput().value).toBe('08'); }); }); describe('month segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ segment: 'month' }); + const { input } = renderSegment({ props: { segment: 'month' } }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ segment: 'month', value: '26' }); + const { input } = renderSegment({ + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: '26', year: '' }, + }, + }); expect(input.value).toBe('26'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - segment: 'month', - value: '26', + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: '26', year: '' }, + }, }); - rerenderSegment({ value: '08' }); + rerenderSegment({ + newInputBoxProviderProps: { + segments: { day: '', month: '08', year: '' }, + }, + }); expect(getInput().value).toBe('08'); }); }); describe('year segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ segment: 'year' }); + const { input } = renderSegment({ props: { segment: 'year' } }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ segment: 'year', value: '2023' }); + const { input } = renderSegment({ + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '2023' }, + }, + }); expect(input.value).toBe('2023'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - segment: 'year', - value: '2023', - }); - rerenderSegment({ value: '1993' }); - expect(getInput().value).toBe('1993'); - }); - }); - }); - - describe('Typing', () => { - describe('into an empty segment', () => { - test('calls the change handler', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - }); - - userEvent.type(input, '8'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '8' }), - ); - }); - - test('allows zero character', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '2023' }, + }, }); - - userEvent.type(input, '0'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '0' }), - ); - }); - - test('allows typing leading zeroes', async () => { - const { input, rerenderSegment } = renderSegment({ - onChange: onChangeHandler, - }); - - userEvent.type(input, '0'); - rerenderSegment({ value: '0' }); - - userEvent.type(input, '2'); - await waitFor(() => { - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '02' }), - ); + rerenderSegment({ + newInputBoxProviderProps: { + segments: { day: '', month: '', year: '1993' }, + }, }); - }); - - test('does not allow non-number characters', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - }); - - userEvent.type(input, 'aB$/'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - }); - - describe('into a segment with a value', () => { - test('allows typing additional characters if the current value is incomplete', () => { - const { input } = renderSegment({ - value: '2', - onChange: onChangeHandler, - }); - - userEvent.type(input, '6'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '26' }), - ); - }); - - test('resets the value when the value is complete', () => { - const { input } = renderSegment({ - value: '26', - onChange: onChangeHandler, - }); - - userEvent.type(input, '4'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '4' }), - ); + expect(getInput().value).toBe('1993'); }); }); }); describe('Keyboard', () => { - describe('Backspace', () => { - test('does not call the onChangeHandler when the value is initially empty', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - }); - - userEvent.type(input, '{backspace}'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - - test('clears the input when there is a value', () => { - const { input } = renderSegment({ - value: '26', - onChange: onChangeHandler, - }); - - userEvent.type(input, '{backspace}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '' }), - ); - }); - }); - describe('Arrow Keys', () => { describe('day input', () => { - const formatter = getValueFormatter('day'); + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['day'], + }); describe('Up arrow', () => { - test('calls handler with value +1', () => { + test('calls handler with value +1 if value is less than max', () => { const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: formatter(15), month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(16), - }), + expect.objectContaining({ value: formatter(16) }), ); }); - test('calls handler with default `min` if initially undefined', () => { + test('calls handler with min if value is undefined', () => { const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); @@ -275,11 +251,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `min` value if value exceeds `max`', () => { + test('rolls value over to min value if value exceeds `max`', () => { const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMax['day']), + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { + day: formatter(defaultMax['day']), + month: '', + year: '', + }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); @@ -287,42 +269,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { expect.objectContaining({ value: formatter(defaultMin['day']) }), ); }); - - test('calls handler with provided `min` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - min: 5, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: formatter(5) }), - ); - }); - - test('rolls value over to provided `min` value if value exceeds `max`', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMax['day']), - min: 5, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: formatter(5) }), - ); - }); }); describe('Down arrow', () => { - test('calls handler with value -1', () => { + test('calls handler with value -1 if value is greater than min', () => { const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: formatter(15), month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -333,11 +289,13 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `max` if initially undefined', () => { + test('calls handler with max if value is undefined', () => { const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -346,11 +304,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `max` value if value exceeds `min`', () => { + test('rolls value over to max value if value is less than min', () => { const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMin['day']), + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { + day: formatter(defaultMin['day']), + month: '', + year: '', + }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -358,46 +322,22 @@ describe('packages/date-picker/shared/date-input-segment', () => { expect.objectContaining({ value: formatter(defaultMax['day']) }), ); }); - - test('calls handler with provided `max` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - max: 25, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: formatter(25) }), - ); - }); - - test('rolls value over to provided `max` value if value exceeds `min`', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMin['day']), - max: 25, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: formatter(25) }), - ); - }); }); }); describe('month input', () => { - const formatter = getValueFormatter('month'); + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['month'], + }); describe('Up arrow', () => { - test('calls handler with value +1', () => { + test('calls handler with value +1 if value is less than max', () => { const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(6), + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: formatter(6), year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); @@ -408,11 +348,13 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `min` if initially undefined', () => { + test('calls handler with min if value is undefined', () => { const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: '', + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); @@ -423,11 +365,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `min` value if value exceeds `max`', () => { + test('rolls value over to min value if value exceeds max', () => { const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(defaultMax['month']), + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { + day: '', + month: formatter(defaultMax['month']), + year: '', + }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); @@ -437,46 +385,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { }), ); }); - - test('calls handler with provided `min` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: '', - min: 5, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(5), - }), - ); - }); - - test('rolls value over to provided `min` value if value exceeds `max`', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(defaultMax['month']), - min: 5, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(5), - }), - ); - }); }); describe('Down arrow', () => { - test('calls handler with value -1', () => { + test('calls handler with value -1 if value is greater than min', () => { const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(6), + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: formatter(6), year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -487,11 +405,13 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `max` if initially undefined', () => { + test('calls handler with max if value is undefined', () => { const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: '', + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -502,11 +422,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `max` value if value exceeds `min`', () => { + test('rolls value over to max value if value is less than min', () => { const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(defaultMin['month']), + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { + day: '', + month: formatter(defaultMin['month']), + year: '', + }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -516,50 +442,22 @@ describe('packages/date-picker/shared/date-input-segment', () => { }), ); }); - - test('calls handler with provided `max` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: '', - max: 10, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(10), - }), - ); - }); - - test('rolls value over to provided `max` value if value exceeds `min`', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(defaultMin['month']), - max: 10, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(10), - }), - ); - }); }); }); describe('year input', () => { - const formatter = getValueFormatter('year'); + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['year'], + }); describe('Up arrow', () => { - test('calls handler with value +1', () => { + test('calls handler with value +1 if value is less than max', () => { const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: formatter(1993), + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: formatter(1993) }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -569,11 +467,13 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `min` if initially undefined', () => { + test('calls handler with min if value is undefined', () => { const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: '', + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); @@ -586,9 +486,15 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('does _not_ rollover if value exceeds max', () => { const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: formatter(defaultMax['year']), + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { + day: '', + month: '', + year: formatter(defaultMax['year']), + }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowup}'); @@ -598,30 +504,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { }), ); }); - - test('calls handler with provided `min` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: '', - min: 1969, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(1969), - }), - ); - }); }); describe('Down arrow', () => { - test('calls handler with value -1', () => { + test('calls handler with value -1 if value is greater than min', () => { const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: formatter(1993), + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: formatter(1993) }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -631,11 +523,13 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `max` if initially undefined', () => { + test('calls handler with max if value is undefined', () => { const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: '', + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -648,9 +542,15 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('does _not_ rollover if value exceeds min', () => { const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: formatter(defaultMin['year']), + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { + day: '', + month: '', + year: formatter(defaultMin['year']), + }, + onChange: onChangeHandler, + }, }); userEvent.type(input, '{arrowdown}'); @@ -660,80 +560,6 @@ describe('packages/date-picker/shared/date-input-segment', () => { }), ); }); - - test('calls handler with provided `max` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: '', - max: 2000, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(2000), - }), - ); - }); - }); - }); - }); - describe('Space Key', () => { - describe('on a single SPACE', () => { - describe('does not call the onChangeHandler ', () => { - test('when the input is initially empty', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - }); - - userEvent.type(input, '{space}'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - }); - describe('calls the onChangeHandler', () => { - test('when the input has a value', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - value: '12', - }); - - userEvent.type(input, '{space}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: '', - }), - ); - }); - }); - }); - - describe('on a double SPACE', () => { - describe('does not call the onChangeHandler ', () => { - test('when the input is initially empty', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - }); - - userEvent.type(input, '{space}{space}'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - }); - - describe('calls the onChangeHandler', () => { - test('when the input has a value', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - value: '12', - }); - - userEvent.type(input, '{space}{space}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: '', - }), - ); - }); }); }); }); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx index ba24823ca5..2b1875f14c 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx @@ -2,24 +2,53 @@ import React, { useState } from 'react'; import { StoryMetaType } from '@lg-tools/storybook-utils'; import { StoryFn } from '@storybook/react'; +import { + InputBoxProvider, + InputSegmentChangeEventHandler, +} from '@leafygreen-ui/input-box'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; +import { charsPerSegment } from '../../../constants'; import { SharedDatePickerContextProps, SharedDatePickerProvider, } from '../../../context'; -import { DateSegmentValue } from '../../../types'; +import { useSegmentRefs } from '../../../hooks'; +import { DateSegment } from '../../../types'; +import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegment } from './DateInputSegment'; -const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => ( - - - - - -); +const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => { + const { value, segment, size, darkMode } = ctx?.args ?? {}; + const segments = { + day: segment === 'day' ? value : '', + month: segment === 'month' ? value : '', + year: segment === 'year' ? value : '', + }; + + return ( + + + + {}} + onBlur={() => {}} + segmentRefs={useSegmentRefs()} + segments={segments} + size={size} + disabled={false} + > + + + + + + ); +}; const meta: StoryMetaType< typeof DateInputSegment, @@ -63,17 +92,35 @@ const meta: StoryMetaType< export default meta; const Template: StoryFn = props => { - const [value, setValue] = useState(''); + const [segments, setSegments] = useState({ + day: '', + month: '', + year: '', + }); + + const handleChange: InputSegmentChangeEventHandler = ({ + segment, + value, + }) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; return ( - { - setValue(value); - }} - /> + + {}} + segmentRefs={useSegmentRefs()} + segments={segments} + size={Size.Default} + disabled={false} + > + + + ); }; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.styles.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.styles.ts index 207fde92d3..68af1ce4cf 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.styles.ts +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.styles.ts @@ -1,88 +1,8 @@ import { css } from '@leafygreen-ui/emotion'; -import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; -import { - BaseFontSize, - fontFamilies, - Size, - typeScales, -} from '@leafygreen-ui/tokens'; import { characterWidth, charsPerSegment } from '../../../constants'; import { DateSegment } from '../../../types'; -export const baseStyles = css` - font-family: ${fontFamilies.default}; - font-size: ${BaseFontSize.Body1}px; - font-variant: tabular-nums; - text-align: center; - border: none; - border-radius: 0; - padding: 0; - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - -moz-appearance: textfield; /* Firefox */ - - &:focus { - outline: none; - } -`; - -export const segmentThemeStyles: Record = { - [Theme.Light]: css` - background-color: transparent; - color: ${palette.black}; - - &::placeholder { - color: ${palette.gray.light1}; - } - - &:focus { - background-color: ${palette.blue.light3}; - } - `, - [Theme.Dark]: css` - background-color: transparent; - color: ${palette.gray.light2}; - - &::placeholder { - color: ${palette.gray.dark1}; - } - - &:focus { - background-color: ${palette.blue.dark3}; - } - `, -}; - -export const fontSizeStyles: Record = { - [BaseFontSize.Body1]: css` - --base-font-size: ${BaseFontSize.Body1}px; - `, - [BaseFontSize.Body2]: css` - --base-font-size: ${BaseFontSize.Body2}px; - `, -}; - -export const segmentSizeStyles: Record = { - [Size.XSmall]: css` - font-size: ${typeScales.body1.fontSize}px; - `, - [Size.Small]: css` - font-size: ${typeScales.body1.fontSize}px; - `, - [Size.Default]: css` - font-size: var(--base-font-size, ${typeScales.body1.fontSize}px); - `, - [Size.Large]: css` - font-size: ${18}px; // Intentionally off-token - `, -}; - export const segmentWidthStyles: Record = { day: css` width: ${charsPerSegment.day * characterWidth.D}ch; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx index df30f5303f..9f4ae4e39b 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -1,31 +1,20 @@ -import React, { ChangeEventHandler, KeyboardEventHandler } from 'react'; +import React from 'react'; import { cx } from '@leafygreen-ui/emotion'; -import { useForwardedRef } from '@leafygreen-ui/hooks'; -import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; -import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; +import { InputSegment } from '@leafygreen-ui/input-box'; -import { - charsPerSegment, - defaultMax, - defaultMin, - defaultPlaceholder, -} from '../../../constants'; +import { defaultMax, defaultMin, defaultPlaceholder } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; -import { getAutoComplete, getValueFormatter } from '../../../utils'; - -import { getNewSegmentValueFromArrowKeyPress } from './utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +import { DateSegment } from '../../../types'; import { - baseStyles, - fontSizeStyles, - segmentSizeStyles, - segmentThemeStyles, - segmentWidthStyles, -} from './DateInputSegment.styles'; + getAutoComplete, + getMaxSegmentValue, + getMinSegmentValue, +} from '../../../utils'; +import { useDateInputBoxContext } from '../DateInputBoxContext'; + +import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; -import { getNewSegmentValueFromInputValue } from './utils'; /** * Controlled component @@ -39,179 +28,42 @@ import { getNewSegmentValueFromInputValue } from './utils'; export const DateInputSegment = React.forwardRef< HTMLInputElement, DateInputSegmentProps ->( - ( - { - segment, - value, - min: minProp, - max: maxProp, - onChange, - onBlur, - onKeyDown, - ...rest - }: DateInputSegmentProps, - fwdRef, - ) => { - const min = minProp ?? defaultMin[segment]; - const max = maxProp ?? defaultMax[segment]; - - const inputRef = useForwardedRef(fwdRef, null); - - const { theme } = useDarkMode(); - const baseFontSize = useUpdatedBaseFontSize(); - const { - size, - disabled, - autoComplete: autoCompleteProp, - } = useSharedDatePickerContext(); - const formatter = getValueFormatter(segment); - const autoComplete = getAutoComplete(autoCompleteProp, segment); - const pattern = `[0-9]{${charsPerSegment[segment]}}`; - - /** - * Receives native input events, - * determines whether the input value is valid and should change, - * and fires a custom `DateInputSegmentChangeEvent`. - */ - const handleChange: ChangeEventHandler = e => { - const { target } = e; - - const newValue = getNewSegmentValueFromInputValue( - segment, - value, - target.value, - ); - - const hasValueChanged = newValue !== value; - - if (hasValueChanged) { - onChange({ - segment, - value: newValue, - }); - } else { - // If the value has not changed, ensure the input value is reset - target.value = value; - } - }; - - /** Handle keydown presses that don't natively fire a change event */ - const handleKeyDown: KeyboardEventHandler = e => { - const { key, target } = e as React.KeyboardEvent & { - target: HTMLInputElement; - }; - - // A key press can be an `arrow`, `enter`, `space`, etc so we check for number presses - // We also check for `space` because Number(' ') returns true - const isNumber = Number(key) && key !== keyMap.Space; - - if (isNumber) { - // if the value length is equal to the charsPerSegment, reset the input - if (target.value.length === charsPerSegment[segment]) { - target.value = ''; - } - } - - switch (key) { - case keyMap.ArrowUp: - case keyMap.ArrowDown: { - e.preventDefault(); - - const newValue = getNewSegmentValueFromArrowKeyPress({ - key, - value, - min, - max, - segment, - }); - const valueString = formatter(newValue); - - /** Fire a custom change event when the up/down arrow keys are pressed */ - onChange({ - segment, - value: valueString, - meta: { key }, - }); - break; - } - - // On backspace the value is reset - case keyMap.Backspace: { - // Don't fire change event if the input is initially empty - if (value) { - // Prevent the onKeyDown handler inside `DatePickerInput` from firing. Because we reset the value on backspace, that will trigger the previous segment to focus but we want the focus to remain inside the current segment. - e.stopPropagation(); - - /** Fire a custom change event when the backspace key is pressed */ - onChange({ - segment, - value: '', - meta: { key }, - }); - } - - break; - } - - // On space the value is reset - case keyMap.Space: { - e.preventDefault(); - - // Don't fire change event if the input is initially empty - if (value) { - /** Fire a custom change event when the space key is pressed */ - onChange({ - segment, - value: '', - meta: { key }, - }); - } - - break; - } - - default: { - break; - } - } - - onKeyDown?.(e); - }; - - // Note: Using a text input with pattern attribute due to Firefox - // stripping leading zeros on number inputs - Thanks @matt-d-rat - // Number inputs also don't support the `selectionStart`/`End` API - return ( - - ); - }, -); +>(({ segment, ...rest }: DateInputSegmentProps, fwdRef) => { + const { + autoComplete: autoCompleteProp, + min: minContextProp, + max: maxContextProp, + } = useSharedDatePickerContext(); + + const { value } = useDateInputBoxContext(); + const min = + getMinSegmentValue(segment, { date: value, min: minContextProp }) ?? + defaultMin[segment]; + const max = + getMaxSegmentValue(segment, { date: value, max: maxContextProp }) ?? + defaultMax[segment]; + + const autoComplete = getAutoComplete(autoCompleteProp, segment); + + const shouldWrap = segment !== DateSegment.Year; + const shouldSkipValidation = segment === DateSegment.Year; + + return ( + + ); +}); DateInputSegment.displayName = 'DateInputSegment'; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.types.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.types.ts index c025f5ad11..003d287a95 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.types.ts +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.types.ts @@ -1,6 +1,8 @@ -import React from 'react'; - -import { DarkModeProps, keyMap } from '@leafygreen-ui/lib'; +import { + InputSegmentChangeEventHandler, + InputSegmentComponentProps, +} from '@leafygreen-ui/input-box'; +import { keyMap } from '@leafygreen-ui/lib'; import { DateSegment, DateSegmentValue } from '../../../types'; @@ -13,24 +15,10 @@ export interface DateInputSegmentChangeEvent { }; } -export type DateInputSegmentChangeEventHandler = ( - dateSegmentChangeEvent: DateInputSegmentChangeEvent, -) => void; +export type DateInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< + DateSegment, + DateSegmentValue +>; export interface DateInputSegmentProps - extends DarkModeProps, - Omit, 'onChange'> { - /** Which date segment this input represents. Determines the aria-label, and min/max values where relevant */ - segment: DateSegment; - - /** The value of the date segment */ - value: DateSegmentValue; - - /** Optional minimum value. Defaults to 0 for day/month segments, and 1970 for year segments */ - min?: number; - - /** Optional maximum value. Defaults to 31 for day, 12 for month, 2038 for year */ - max?: number; - - onChange: DateInputSegmentChangeEventHandler; -} + extends InputSegmentComponentProps {} diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts deleted file mode 100644 index 832c7c978a..0000000000 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { keyMap, rollover } from '@leafygreen-ui/lib'; - -import { DateSegment, DateSegmentValue } from '../../../../../types'; - -interface DateSegmentKeypressContext { - value: DateSegmentValue; - key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; - segment: DateSegment; - min: number; - max: number; -} - -/** - * Returns a new segment value given the current state - */ -export const getNewSegmentValueFromArrowKeyPress = ({ - value, - key, - segment, - min, - max, -}: DateSegmentKeypressContext): number => { - const valueDiff = key === keyMap.ArrowUp ? 1 : -1; - const defaultVal = key === keyMap.ArrowUp ? min : max; - - const incrementedValue: number = value - ? Number(value) + valueDiff - : defaultVal; - - const newValue = - segment === 'year' - ? incrementedValue - : rollover(incrementedValue, min, max); - - return newValue; -}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts deleted file mode 100644 index 095fe83b01..0000000000 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import range from 'lodash/range'; - -import { defaultMax, defaultMin } from '../../../../../constants'; -import { DateSegment } from '../../../../../types'; -import { getValueFormatter } from '../../../../../utils'; - -import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; - -describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromInputValue', () => { - describe.each(['day', 'month', 'year'])('For segment %p', _segment => { - const segment: DateSegment = _segment as DateSegment; - describe('when current value is empty', () => { - test.each(range(10))('accepts %i character as input', i => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `${i}`); - expect(newValue).toEqual(`${i}`); - }); - - const validValues = [defaultMin[segment], defaultMax[segment]]; - test.each(validValues)(`accepts value "%i" as input`, v => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `${v}`); - expect(newValue).toEqual(`${v}`); - }); - - test('does not accept non-numeric characters', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `b`); - expect(newValue).toEqual(''); - }); - - test('does not accept input with a period/decimal', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `2.`); - expect(newValue).toEqual(''); - }); - }); - - describe('when current value is 0', () => { - if (segment !== 'year') { - test('rejects additional 0 as input', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '0', `00`); - expect(newValue).toEqual(`0`); - }); - } - test.each(range(1, 10))('accepts 0%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - `0${i}`, - ); - expect(newValue).toEqual(`0${i}`); - }); - test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '0', ``); - expect(newValue).toEqual(``); - }); - }); - - describe('when current value is 1', () => { - test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '1', ``); - expect(newValue).toEqual(``); - }); - - if (segment === 'month') { - test.each(range(0, 3))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - ); - expect(newValue).toEqual(`1${i}`); - }); - describe.each(range(3, 10))('rejects 1%i', i => { - test(`and sets input "${i}"`, () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - ); - expect(newValue).toEqual(`${i}`); - }); - }); - } else { - test.each(range(10))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - ); - expect(newValue).toEqual(`1${i}`); - }); - } - }); - - describe('when current value is 3', () => { - test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '3', ``); - expect(newValue).toEqual(``); - }); - - switch (segment) { - case 'day': { - test.each(range(0, 2))('accepts 3%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - `3${i}`, - ); - expect(newValue).toEqual(`3${i}`); - }); - describe.each(range(3, 10))('rejects 3%i', i => { - test(`and sets input to ${i}`, () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - `3${i}`, - ); - expect(newValue).toEqual(`${i}`); - }); - }); - break; - } - - case 'month': { - describe.each(range(10))('rejects 3%i', i => { - test(`and sets input "${i}"`, () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - `3${i}`, - ); - expect(newValue).toEqual(`${i}`); - }); - }); - break; - } - - default: - break; - } - }); - - describe('when current value is a full formatted value', () => { - const formatter = getValueFormatter(segment); - const testValues = [defaultMin[segment], defaultMax[segment]].map( - formatter, - ); - test.each(testValues)( - 'when current value is %p, rejects additional input', - val => { - const newValue = getNewSegmentValueFromInputValue( - segment, - val, - `${val}1`, - ); - expect(newValue).toEqual(val); - }, - ); - }); - }); -}); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts deleted file mode 100644 index 1aff779713..0000000000 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ /dev/null @@ -1,56 +0,0 @@ -import last from 'lodash/last'; - -import { truncateStart } from '@leafygreen-ui/lib'; - -import { charsPerSegment } from '../../../../../constants'; -import { DateSegment, DateSegmentValue } from '../../../../../types'; -import { isValidValueForSegment } from '../../../../../utils'; - -/** - * Calculates the new value for the segment given an incoming change. - * - * Does not allow incoming values that - * - are not valid numbers - * - include a period - * - would cause the segment to overflow - */ -export const getNewSegmentValueFromInputValue = ( - segmentName: DateSegment, - currentValue: DateSegmentValue, - incomingValue: DateSegmentValue, -): DateSegmentValue => { - // If the incoming value is not a valid number - const isIncomingValueNumber = !isNaN(Number(incomingValue)); - // macOS adds a period when pressing SPACE twice inside a text input. - const doesIncomingValueContainPeriod = /\./.test(incomingValue); - - // if the current value is "full", do not allow any additional characters to be entered - const wouldCauseOverflow = - currentValue.length === charsPerSegment[segmentName] && - incomingValue.length > charsPerSegment[segmentName]; - - if ( - !isIncomingValueNumber || - doesIncomingValueContainPeriod || - wouldCauseOverflow - ) { - return currentValue; - } - - const isIncomingValueValid = isValidValueForSegment( - segmentName, - incomingValue, - ); - - if (isIncomingValueValid || segmentName === 'year') { - const newValue = truncateStart(incomingValue, { - length: charsPerSegment[segmentName], - }); - - return newValue; - } - - const typedChar = last(incomingValue.split('')); - const newValue = typedChar === '0' ? '0' : typedChar ?? ''; - return newValue; -}; diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts deleted file mode 100644 index f71520a27c..0000000000 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; diff --git a/packages/date-picker/src/shared/constants.ts b/packages/date-picker/src/shared/constants.ts index 3efdaaa8cc..8d46029865 100644 --- a/packages/date-picker/src/shared/constants.ts +++ b/packages/date-picker/src/shared/constants.ts @@ -2,6 +2,8 @@ import { MAX_DATE, MIN_DATE } from '@leafygreen-ui/date-utils'; import { RenderMode } from '@leafygreen-ui/popover'; import { DropdownWidthBasis } from '@leafygreen-ui/select'; +import { DateSegment } from './types'; + // TODO: Update how defaultMin & defaultMax are defined, // since day/month are constants, // but year is consumer-defined @@ -69,3 +71,17 @@ export const selectElementProps = { dropdownWidthBasis: DropdownWidthBasis.Option, renderMode: RenderMode.TopLayer, } as const; + +export const dateSegmentRules = { + [DateSegment.Day]: { + maxChars: charsPerSegment.day, + minExplicitValue: 4, + }, + [DateSegment.Month]: { + maxChars: charsPerSegment.month, + minExplicitValue: 2, + }, + [DateSegment.Year]: { + maxChars: charsPerSegment.year, + }, +}; diff --git a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts index 49cbaafded..cd48508669 100644 --- a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts +++ b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts @@ -1,6 +1,8 @@ +import { getValueFormatter } from '@leafygreen-ui/input-box'; + +import { charsPerSegment } from '../../../shared/constants'; import { DateSegment, DateSegmentsState } from '../../../shared/types'; import { getFormatParts } from '../getFormatParts'; -import { getValueFormatter } from '../getValueFormatter'; export const getFormattedDateStringFromSegments = ( segments: DateSegmentsState, @@ -16,7 +18,9 @@ export const getFormattedDateStringFromSegments = ( } const segment = part.type as DateSegment; - const formatter = getValueFormatter(segment); + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment[segment], + }); const formattedSegment = formatter(segments[segment]); return dateString + formattedSegment; }, ''); diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx b/packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx deleted file mode 100644 index 9c4370ca5c..0000000000 --- a/packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; - -import { SegmentRefs } from '../../../shared/hooks'; -import { segmentRefsMock } from '../../../shared/testutils'; - -import { getRelativeSegmentRef } from '.'; - -const renderTestComponent = () => { - const result = render( - <> - - - - , - ); - - const elements = { - day: result.getByTestId('day'), - month: result.getByTestId('month'), - year: result.getByTestId('year'), - } as { - day: HTMLInputElement; - month: HTMLInputElement; - year: HTMLInputElement; - }; - - return { - ...result, - segmentRefs: segmentRefsMock, - elements, - }; -}; - -describe('packages/date-picker/utils/getRelativeSegment', () => { - const formatParts: Array = [ - { type: 'year', value: '2023' }, - { type: 'literal', value: '-' }, - { type: 'month', value: '10' }, - { type: 'literal', value: '-' }, - { type: 'day', value: '31' }, - ]; - - describe('from ref', () => { - let segmentRefs: SegmentRefs; - beforeEach(() => { - segmentRefs = renderTestComponent().segmentRefs; - }); - test('next from year => month', () => { - expect( - getRelativeSegmentRef('next', { - segment: segmentRefs.year, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.month); - }); - test('next from month => day', () => { - expect( - getRelativeSegmentRef('next', { - segment: segmentRefs.month, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.day); - }); - - test('prev from day => month', () => { - expect( - getRelativeSegmentRef('prev', { - segment: segmentRefs.day, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.month); - }); - - test('prev from month => year', () => { - expect( - getRelativeSegmentRef('prev', { - segment: segmentRefs.month, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.year); - }); - - test('first = year', () => { - expect( - getRelativeSegmentRef('first', { - segment: segmentRefs.day, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.year); - }); - - test('last = day', () => { - expect( - getRelativeSegmentRef('last', { - segment: segmentRefs.year, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.day); - }); - }); - - describe('from element', () => { - let segmentRefs: SegmentRefs; - - let elements: { - day: HTMLInputElement; - month: HTMLInputElement; - year: HTMLInputElement; - }; - beforeEach(() => { - const result = renderTestComponent(); - segmentRefs = result.segmentRefs; - elements = result.elements; - }); - test('next from year => month', () => { - expect( - getRelativeSegmentRef('next', { - segment: elements.year, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.month); - }); - test('next from month => day', () => { - expect( - getRelativeSegmentRef('next', { - segment: elements.month, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.day); - }); - - test('prev from day => month', () => { - expect( - getRelativeSegmentRef('prev', { - segment: elements.day, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.month); - }); - - test('prev from month => year', () => { - expect( - getRelativeSegmentRef('prev', { - segment: elements.month, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.year); - }); - - test('first = year', () => { - expect( - getRelativeSegmentRef('first', { - segment: elements.day, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.year); - }); - - test('last = day', () => { - expect( - getRelativeSegmentRef('last', { - segment: elements.year, - formatParts, - segmentRefs, - }), - ).toBe(segmentRefs.day); - }); - }); -}); diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts deleted file mode 100644 index c298bddd5a..0000000000 --- a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -import isUndefined from 'lodash/isUndefined'; -import last from 'lodash/last'; - -import { SharedDatePickerContextProps } from '../../context'; -import { SegmentRefs } from '../../hooks'; -import { DateSegment } from '../../types'; - -type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; -interface GetRelativeSegmentContext { - segment: HTMLInputElement | React.RefObject; - formatParts: SharedDatePickerContextProps['formatParts']; - segmentRefs: SegmentRefs; -} - -/** - * Given a direction, starting segment name & format - * returns the segment name in the given direction - */ -export const getRelativeSegment = ( - direction: RelativeDirection, - { - segment, - formatParts, - }: { - segment: DateSegment; - formatParts: SharedDatePickerContextProps['formatParts']; - }, -): DateSegment | undefined => { - if ( - isUndefined(direction) || - isUndefined(segment) || - isUndefined(formatParts) - ) { - return; - } - - // only the relevant segments, not separators - const formatSegments: Array = formatParts - .filter(part => part.type !== 'literal') - .map(part => part.type as DateSegment); - - /** The index of the reference segment relative to formatParts */ - const currentSegmentIndex: number | undefined = - formatSegments.indexOf(segment); - - switch (direction) { - case 'first': { - return formatSegments[0]; - } - - case 'last': { - const lastSegmentName = last(formatSegments); - return lastSegmentName; - } - - case 'next': { - if ( - !isUndefined(currentSegmentIndex) && - currentSegmentIndex >= 0 && - currentSegmentIndex + 1 < formatSegments.length - ) { - return formatSegments[currentSegmentIndex + 1]; - } - - break; - } - - case 'prev': { - if (!isUndefined(currentSegmentIndex) && currentSegmentIndex > 0) { - return formatSegments[currentSegmentIndex - 1]; - } - - break; - } - - default: - break; - } -}; - -/** - * Given a direction, staring segment, and segment refs, - * returns the segment ref in the given direction - */ -export const getRelativeSegmentRef = ( - direction: RelativeDirection, - { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, -): React.RefObject | undefined => { - if ( - isUndefined(direction) || - isUndefined(segment) || - isUndefined(formatParts) || - isUndefined(segmentRefs) - ) { - return; - } - - // only the relevant segments, not separators - const formatSegments: Array = formatParts - .filter(part => part.type !== 'literal') - .map(part => part.type as DateSegment); - - const currentSegmentName: DateSegment | undefined = formatSegments.find( - segmentName => { - return ( - segmentRefs[segmentName] === segment || - segmentRefs[segmentName].current === segment - ); - }, - ); - - if (currentSegmentName) { - const relativeSegmentName = getRelativeSegment(direction, { - segment: currentSegmentName, - formatParts, - }); - - if (relativeSegmentName) { - return segmentRefs[relativeSegmentName]; - } - } -}; diff --git a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts index bcbf01f260..5b95cc563e 100644 --- a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts +++ b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts @@ -1,7 +1,8 @@ import { DateType } from '@leafygreen-ui/date-utils'; +import { getValueFormatter } from '@leafygreen-ui/input-box'; +import { charsPerSegment } from '../../constants'; import { DateSegmentsState } from '../../types'; -import { getValueFormatter } from '../getValueFormatter'; import { getSegmentsFromDate } from './getSegmentsFromDate'; @@ -12,8 +13,14 @@ export const getFormattedSegmentsFromDate = ( const segments = getSegmentsFromDate(date); return { - day: getValueFormatter('day')(segments['day']), - month: getValueFormatter('month')(segments['month']), - year: getValueFormatter('year')(segments['year']), + day: getValueFormatter({ charsPerSegment: charsPerSegment['day'] })( + segments['day'], + ), + month: getValueFormatter({ charsPerSegment: charsPerSegment['month'] })( + segments['month'], + ), + year: getValueFormatter({ charsPerSegment: charsPerSegment['year'] })( + segments['year'], + ), }; }; diff --git a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts deleted file mode 100644 index bf759d62bc..0000000000 --- a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import padStart from 'lodash/padStart'; - -import { isZeroLike } from '@leafygreen-ui/lib'; - -import { charsPerSegment } from '../../constants'; -import { DateSegment } from '../../types'; - -/** - * @returns a value formatter function for the provided date segment - */ -export const getValueFormatter = - (segment: DateSegment) => (val: string | number | undefined) => { - // If the value is any form of zero, we set it to an empty string - if (isZeroLike(val)) return ''; - - // otherwise, pad the string with 0s, or trim it to n chars - - const padded = padStart( - Number(val).toString(), - charsPerSegment[segment], - '0', - ); - const trimmed = padded.slice( - padded.length - charsPerSegment[segment], - padded.length, - ); - - return trimmed; - }; diff --git a/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts b/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts deleted file mode 100644 index 9b04b141ea..0000000000 --- a/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { DateSegment } from '../../types'; - -import { getValueFormatter } from '.'; - -describe('packages/date-picker/utils/valueFormatter', () => { - describe.each(['day', 'month'] as Array)('', segment => { - const formatter = getValueFormatter(segment); - - test('formats 2 digit values', () => { - expect(formatter('12')).toEqual('12'); - }); - - test('pads 1 digit value', () => { - expect(formatter('2')).toEqual('02'); - }); - - test('truncates 3+ digit values', () => { - expect(formatter('123')).toEqual('23'); - }); - - test('truncates 3+ digit padded values', () => { - expect(formatter('012')).toEqual('12'); - }); - - test('sets 0 to empty string', () => { - expect(formatter('0')).toEqual(''); - }); - - test('sets undefined to empty string', () => { - expect(formatter(undefined)).toEqual(''); - }); - }); - - describe('year', () => { - const formatter = getValueFormatter('year'); - - test('formats 4 digit values', () => { - expect(formatter('2023')).toEqual('2023'); - }); - - test('pads < 4 digit value', () => { - expect(formatter('123')).toEqual('0123'); - }); - - test('truncates 5+ digit values', () => { - expect(formatter('12345')).toEqual('2345'); - }); - - test('truncates 5+ digit padded values', () => { - expect(formatter('02345')).toEqual('2345'); - }); - - test('sets 0 to empty string', () => { - expect(formatter('0')).toEqual(''); - }); - - test('sets undefined to empty string', () => { - expect(formatter(undefined)).toEqual(''); - }); - }); -}); diff --git a/packages/date-picker/src/shared/utils/index.ts b/packages/date-picker/src/shared/utils/index.ts index 354af9cf99..cc082f9041 100644 --- a/packages/date-picker/src/shared/utils/index.ts +++ b/packages/date-picker/src/shared/utils/index.ts @@ -9,10 +9,6 @@ export { } from './getFormattedDateString'; export { getMaxSegmentValue } from './getMaxSegmentValue'; export { getMinSegmentValue } from './getMinSegmentValue'; -export { - getRelativeSegment, - getRelativeSegmentRef, -} from './getRelativeSegment'; export { getRemainingParts } from './getRemainingParts'; export { getSegmentMaxLength } from './getSegmentMaxLength'; export { @@ -20,12 +16,7 @@ export { getSegmentsFromDate, } from './getSegmentsFromDate'; export { getSegmentStateFromRefs } from './getSegmentStateFromRefs'; -export { getValueFormatter } from './getValueFormatter'; -export { isElementInputSegment } from './isElementInputSegment'; export { isEverySegmentFilled } from './isEverySegmentFilled'; export { isEverySegmentValid } from './isEverySegmentValid'; export { isEverySegmentValueExplicit } from './isEverySegmentValueExplicit'; -export { isExplicitSegmentValue } from './isExplicitSegmentValue'; -export { isValidSegmentName, isValidSegmentValue } from './isValidSegment'; -export { isValidValueForSegment } from './isValidValueForSegment'; export { newDateFromSegments } from './newDateFromSegments'; diff --git a/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts b/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts deleted file mode 100644 index 4bacd83464..0000000000 --- a/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SegmentRefs } from '../../hooks'; - -/** - * Returns whether the given element is a segment - */ -export const isElementInputSegment = ( - element: HTMLElement, - segmentRefs: SegmentRefs, -): element is HTMLInputElement => { - const segmentsArray = Object.values(segmentRefs).map( - ref => ref.current, - ) as Array; - const isSegment = segmentsArray.includes(element); - - return isSegment; -}; diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts index 6e338ec5b9..4ebf0db829 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -1,11 +1,25 @@ -import { DateSegment, DateSegmentsState } from '../../types'; -import { isValidValueForSegment } from '../isValidValueForSegment'; +import inRange from 'lodash/inRange'; + +import { isValidValueForSegment } from '@leafygreen-ui/input-box'; + +import { defaultMax, defaultMin } from '../../constants'; +import { DateSegment, DateSegmentsState, DateSegmentValue } from '../../types'; /** * Whether every segment in a {@link DateSegmentsState} object is valid */ export const isEverySegmentValid = (segments: DateSegmentsState): boolean => { return Object.entries(segments).every(([segment, value]) => - isValidValueForSegment(segment as DateSegment, value), + isValidValueForSegment({ + segment: segment as DateSegment, + value: value as DateSegmentValue, + defaultMin: defaultMin[segment as DateSegment], + defaultMax: defaultMax[segment as DateSegment], + segmentEnum: DateSegment, + customValidation: + segment === DateSegment.Year + ? (value: DateSegmentValue) => inRange(Number(value), 1000, 9999 + 1) + : undefined, + }), ); }; diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts b/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts index 10ec19bd54..b8478bbc23 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts @@ -1,5 +1,18 @@ +import { createExplicitSegmentValidator } from '@leafygreen-ui/input-box'; + +import { dateSegmentRules } from '../../constants'; import { DateSegment, DateSegmentsState } from '../../types'; -import { isExplicitSegmentValue } from '../isExplicitSegmentValue'; + +/** + * Returns whether the provided value is an explicit, unique value for a given segment. + * Contrast this with an ambiguous segment value: + * Explicit: Day = 5, 02 + * Ambiguous: Day = 2 (could be 20-29) + */ +export const isExplicitSegmentValue = createExplicitSegmentValidator({ + segmentEnum: DateSegment, + rules: dateSegmentRules, +}); /** * Returns whether every segment's value is explicit and unambiguous diff --git a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts deleted file mode 100644 index e357588425..0000000000 --- a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { charsPerSegment } from '../../constants'; -import { DateSegment, DateSegmentValue } from '../../types'; -import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; - -/** - * Returns whether the provided value is an explicit, unique value for a given segment. - * Contrast this with an ambiguous segment value: - * Explicit: Day = 5, 02 - * Ambiguous: Day = 2 (could be 20-29) - */ -export const isExplicitSegmentValue = ( - segment: DateSegment, - value: DateSegmentValue, -): boolean => { - if (!(isValidSegmentValue(value) && isValidSegmentName(segment))) - return false; - - switch (segment) { - case DateSegment.Day: - return value.length === charsPerSegment.day || Number(value) >= 4; - - case DateSegment.Month: - return value.length === charsPerSegment.month || Number(value) >= 2; - - case DateSegment.Year: - return value.length === charsPerSegment.year; - } -}; diff --git a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/isExplicitSegmentValue.spec.ts b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/isExplicitSegmentValue.spec.ts deleted file mode 100644 index 7011ecb6a4..0000000000 --- a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/isExplicitSegmentValue.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isExplicitSegmentValue } from '.'; - -describe('packages/date-picker/utils/isExplicitSegmentValue', () => { - test('day', () => { - expect(isExplicitSegmentValue('day', '1')).toBe(false); - expect(isExplicitSegmentValue('day', '01')).toBe(true); - expect(isExplicitSegmentValue('day', '4')).toBe(true); - expect(isExplicitSegmentValue('day', '10')).toBe(true); - expect(isExplicitSegmentValue('day', '22')).toBe(true); - expect(isExplicitSegmentValue('day', '31')).toBe(true); - }); - - test('month', () => { - expect(isExplicitSegmentValue('month', '1')).toBe(false); - expect(isExplicitSegmentValue('month', '01')).toBe(true); - expect(isExplicitSegmentValue('month', '2')).toBe(true); - expect(isExplicitSegmentValue('month', '12')).toBe(true); - }); - - test('year', () => { - expect(isExplicitSegmentValue('year', '1')).toBe(false); - expect(isExplicitSegmentValue('year', '200')).toBe(false); - expect(isExplicitSegmentValue('year', '1970')).toBe(true); - expect(isExplicitSegmentValue('year', '2000')).toBe(true); - expect(isExplicitSegmentValue('year', '0001')).toBe(true); - }); -}); diff --git a/packages/date-picker/src/shared/utils/isValidSegment/index.ts b/packages/date-picker/src/shared/utils/isValidSegment/index.ts deleted file mode 100644 index 861fbeca75..0000000000 --- a/packages/date-picker/src/shared/utils/isValidSegment/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import isUndefined from 'lodash/isUndefined'; - -import { DateSegment, DateSegmentValue } from '../../types'; - -/** - * Returns whether a given value is a valid segment value - */ -export const isValidSegmentValue = ( - segment?: DateSegmentValue, -): segment is DateSegmentValue => - !isUndefined(segment) && !isNaN(Number(segment)) && Number(segment) > 0; - -/** - * Returns whether a given string is a valid segment name (day, month, year) - */ -export const isValidSegmentName = (name?: string): name is DateSegment => { - return ( - !isUndefined(name) && - Object.values(DateSegment).includes(name as DateSegment) - ); -}; diff --git a/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts b/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts deleted file mode 100644 index 0993fec4be..0000000000 --- a/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { isValidSegmentName, isValidSegmentValue } from '.'; - -describe('packages/date-picker/utils/isValidSegment', () => { - describe('isValidSegment', () => { - test('undefined returns false', () => { - expect(isValidSegmentValue()).toBeFalsy(); - }); - - test('a string returns false', () => { - expect(isValidSegmentValue('')).toBeFalsy(); - }); - - test('NaN returns false', () => { - /// @ts-expect-error - expect(isValidSegmentValue(NaN)).toBeFalsy(); - }); - - test('0 returns false', () => { - expect(isValidSegmentValue('0')).toBeFalsy(); - }); - - test('negative returns false', () => { - expect(isValidSegmentValue('-1')).toBeFalsy(); - }); - - test('1970 returns true', () => { - expect(isValidSegmentValue('1970')).toBeTruthy(); - }); - - test('1 returns true', () => { - expect(isValidSegmentValue('1')).toBeTruthy(); - }); - - test('2038 returns true', () => { - expect(isValidSegmentValue('2038')).toBeTruthy(); - }); - }); - - describe('isValidSegmentName', () => { - test('undefined returns false', () => { - expect(isValidSegmentName()).toBeFalsy(); - }); - - test('random string returns false', () => { - expect(isValidSegmentName('123')).toBeFalsy(); - }); - - test('empty string returns false', () => { - expect(isValidSegmentName('')).toBeFalsy(); - }); - - test('day string returns true', () => { - expect(isValidSegmentName('day')).toBeTruthy(); - }); - - test('month string returns true', () => { - expect(isValidSegmentName('month')).toBeTruthy(); - }); - - test('year string returns true', () => { - expect(isValidSegmentName('year')).toBeTruthy(); - }); - }); -}); diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts deleted file mode 100644 index 802dd3baf1..0000000000 --- a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import inRange from 'lodash/inRange'; - -import { defaultMax, defaultMin } from '../../constants'; -import { DateSegment, DateSegmentValue } from '../../types'; -import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; - -/** - * Returns whether a value is valid for a given segment type - */ -export const isValidValueForSegment = ( - segment: DateSegment, - value: DateSegmentValue, -): boolean => { - const isValidSegmentAndValue = - isValidSegmentValue(value) && isValidSegmentName(segment); - - if (segment === 'year') { - // allow any 4-digit year value regardless of defined range - return isValidSegmentAndValue && inRange(Number(value), 1000, 9999 + 1); - } - - const isInRange = inRange( - Number(value), - defaultMin[segment], - defaultMax[segment] + 1, - ); - - return isValidSegmentAndValue && isInRange; -}; diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts deleted file mode 100644 index 4b29066629..0000000000 --- a/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { isValidValueForSegment } from '.'; - -describe('packages/date-picker/utils/isValidSegmentValue', () => { - test('day', () => { - expect(isValidValueForSegment('day', '1')).toBe(true); - expect(isValidValueForSegment('day', '15')).toBe(true); - expect(isValidValueForSegment('day', '31')).toBe(true); - - expect(isValidValueForSegment('day', '0')).toBe(false); - expect(isValidValueForSegment('day', '32')).toBe(false); - }); - - test('month', () => { - expect(isValidValueForSegment('month', '1')).toBe(true); - expect(isValidValueForSegment('month', '9')).toBe(true); - expect(isValidValueForSegment('month', '12')).toBe(true); - - expect(isValidValueForSegment('month', '0')).toBe(false); - expect(isValidValueForSegment('month', '28')).toBe(false); - }); - - test('year', () => { - expect(isValidValueForSegment('year', '1970')).toBe(true); - expect(isValidValueForSegment('year', '2000')).toBe(true); - expect(isValidValueForSegment('year', '2038')).toBe(true); - - // All positive numbers 4-digit are considered valid years by default - expect(isValidValueForSegment('year', '1000')).toBe(true); - expect(isValidValueForSegment('year', '1945')).toBe(true); - expect(isValidValueForSegment('year', '2048')).toBe(true); - expect(isValidValueForSegment('year', '9999')).toBe(true); - - expect(isValidValueForSegment('year', '0')).toBe(false); - expect(isValidValueForSegment('year', '20')).toBe(false); - expect(isValidValueForSegment('year', '200')).toBe(false); - expect(isValidValueForSegment('year', '999')).toBe(false); - expect(isValidValueForSegment('year', '10000')).toBe(false); - expect(isValidValueForSegment('year', '-2000')).toBe(false); - }); -}); diff --git a/packages/date-picker/tsconfig.json b/packages/date-picker/tsconfig.json index 48c679b834..b99731e7c9 100644 --- a/packages/date-picker/tsconfig.json +++ b/packages/date-picker/tsconfig.json @@ -41,6 +41,9 @@ { "path": "../icon-button" }, + { + "path": "../input-box" + }, { "path": "../leafygreen-provider" }, @@ -69,4 +72,4 @@ "path": "../typography" } ] -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb53b8e9d8..4b2249f6c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1660,6 +1660,9 @@ importers: '@leafygreen-ui/icon-button': specifier: workspace:^ version: link:../icon-button + '@leafygreen-ui/input-box': + specifier: workspace:^ + version: link:../input-box '@leafygreen-ui/leafygreen-provider': specifier: workspace:^ version: link:../leafygreen-provider diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index 67b943e5fe..2486ff963e 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -15,6 +15,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/code', '@leafygreen-ui/code-editor', '@leafygreen-ui/combobox', + '@leafygreen-ui/compound-component', '@leafygreen-ui/confirmation-modal', '@leafygreen-ui/context-drawer', '@leafygreen-ui/copyable', @@ -25,6 +26,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/emotion', '@leafygreen-ui/empty-state', '@leafygreen-ui/expandable-card', + '@leafygreen-ui/feature-walls', '@leafygreen-ui/form-field', '@leafygreen-ui/form-footer', '@leafygreen-ui/gallery-indicator', @@ -34,6 +36,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/icon-button', '@leafygreen-ui/info-sprinkle', '@leafygreen-ui/inline-definition', + '@leafygreen-ui/input-box', '@leafygreen-ui/input-option', '@leafygreen-ui/leafygreen-provider', '@leafygreen-ui/lib',