From 4f0dc24a51ef7128f19350b9d0898492483f86fc Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 7 Oct 2025 18:31:48 -0400 Subject: [PATCH 01/56] refactor(date-picker): extract reusable logic from DateInputSegment, wip --- .../getNewSegmentValueFromArrowKeyPress.ts | 0 .../getNewSegmentValueFromInputValue.spec.ts | 0 .../getNewSegmentValueFromInputValue.ts | 0 .../{DateInput/DateInputSegment => InputSegment}/utils/index.ts | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename packages/date-picker/src/shared/components/{DateInput/DateInputSegment => InputSegment}/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts (100%) rename packages/date-picker/src/shared/components/{DateInput/DateInputSegment => InputSegment}/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts (100%) rename packages/date-picker/src/shared/components/{DateInput/DateInputSegment => InputSegment}/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts (100%) rename packages/date-picker/src/shared/components/{DateInput/DateInputSegment => InputSegment}/utils/index.ts (100%) diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts similarity index 100% rename from packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts rename to packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts similarity index 100% rename from packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts rename to packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts similarity index 100% rename from packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts rename to packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts b/packages/date-picker/src/shared/components/InputSegment/utils/index.ts similarity index 100% rename from packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts rename to packages/date-picker/src/shared/components/InputSegment/utils/index.ts From 27712354181344786d8b7f584f219c704a621813 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 7 Oct 2025 18:32:49 -0400 Subject: [PATCH 02/56] refactor(date-picker): extract reusable logic from DateInputSegment, wip --- .../DatePicker/DatePicker.keyboard3.spec.tsx | 4 +- .../DatePickerInput/DatePickerInput.tsx | 73 +---- .../DateInput/DateInputBox/DateInputBox.tsx | 97 +++++- .../DateInputSegment.spec.tsx | 8 +- .../DateInputSegment.styles.ts | 80 ----- .../DateInputSegment/DateInputSegment.tsx | 310 +++++++++--------- .../shared/components/InputSegment/Index.ts | 6 + .../InputSegment/InputSegment.spec.tsx | 0 .../InputSegment/InputSegment.styles.ts | 83 +++++ .../components/InputSegment/InputSegment.tsx | 196 +++++++++++ .../InputSegment/InputSegment.types.ts | 45 +++ .../getNewSegmentValueFromArrowKeyPress.ts | 2 +- .../getNewSegmentValueFromInputValue.spec.ts | 8 +- .../getNewSegmentValueFromInputValue.ts | 6 +- .../components/InputSegment/utils/index.ts | 1 + .../getFormattedDateStringFromSegments.ts | 3 +- .../getFormattedSegmentsFromDate.ts | 7 +- .../shared/utils/getValueFormatter/index.ts | 6 +- .../getValueFormatter/valueFormatter.spec.ts | 5 +- .../utils/isExplicitSegmentValue/index.ts | 2 +- .../src/shared/utils/isValidSegment/index.ts | 38 ++- .../isValidSegment/isValidSegment.spec.ts | 29 +- .../utils/isValidValueForSegment/index.ts | 2 +- 23 files changed, 664 insertions(+), 347 deletions(-) create mode 100644 packages/date-picker/src/shared/components/InputSegment/Index.ts create mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.spec.tsx create mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts create mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx create mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx index 41226340d3..a20f253d27 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx @@ -6,7 +6,7 @@ import { getLgIds as getLgFormFieldIds } from '@leafygreen-ui/form-field'; 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, @@ -79,7 +79,7 @@ describe('DatePicker keyboard interaction', () => { const segmentCases = ['year', 'month', 'day'] as Array; describe.each(segmentCases)('%p segment', segment => { - const formatter = getValueFormatter(segment); + const formatter = getValueFormatter(segment, charsPerSegment); /** 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..dd1bab297d 100644 --- a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx @@ -19,7 +19,6 @@ import { DateInputSegmentChangeEventHandler } from '../../shared/components/Date import { useSharedDatePickerContext } from '../../shared/context'; import { getFormattedDateStringFromSegments, - getRelativeSegmentRef, isElementInputSegment, } from '../../shared/utils'; import { useDatePickerContext } from '../DatePickerContext'; @@ -66,6 +65,8 @@ export const DatePickerInput = forwardRef( setValue(newVal); } + console.log('😈handleInputValueChange', { newVal, segments }); + if (!isNull(newVal) && isInvalidDateObject(newVal)) { const dateString = getFormattedDateStringFromSegments(segments, locale); setInternalErrorMessage(`${dateString} is not a valid date`); @@ -110,77 +111,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 +167,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..75e37bdca7 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,8 @@ -import React, { FocusEventHandler, useEffect } from 'react'; +import React, { + FocusEventHandler, + KeyboardEventHandler, + useEffect, +} from 'react'; import isEqual from 'lodash/isEqual'; import isNull from 'lodash/isNull'; @@ -29,6 +33,8 @@ import { isEverySegmentValueExplicit, isExplicitSegmentValue, newDateFromSegments, + getRelativeSegmentRef, + isElementInputSegment, } from '../../../utils'; import { DateInputSegment } from '../DateInputSegment'; import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; @@ -39,6 +45,7 @@ import { separatorLiteralStyles, } from './DateInputBox.styles'; import { DateInputBoxProps } from './DateInputBox.types'; +import { charsPerSegment } from '../../../constants'; /** * Renders a styled date input with appropriate segment order & separator characters. @@ -62,6 +69,7 @@ export const DateInputBox = React.forwardRef( labelledBy, segmentRefs, onSegmentChange, + onKeyDown, ...rest }: DateInputBoxProps, fwdRef, @@ -77,7 +85,7 @@ export const DateInputBox = React.forwardRef( segmentName: DateSegment, segmentValue: DateSegmentValue, ): DateSegmentValue => { - const formatter = getValueFormatter(segmentName); + const formatter = getValueFormatter(segmentName, charsPerSegment); const formattedValue = formatter(segmentValue); return formattedValue; }; @@ -118,6 +126,7 @@ 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, @@ -153,6 +162,7 @@ export const DateInputBox = React.forwardRef( setSegment(segmentName, segmentValue); onSegmentChange?.(segmentChangeEvent); + // TODO: onInputChange callback here }; /** Triggered when a segment is blurred */ @@ -169,9 +179,92 @@ export const DateInputBox = React.forwardRef( } }; + /** Called on any keydown within the input element */ + const handleInputKeyDown: KeyboardEventHandler = e => { + const { target: _target, key } = e; + const target = _target as HTMLElement; + const isSegment = isElementInputSegment(target, segmentRefs); + + // 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: + case keyMap.Enter: + case keyMap.Escape: + case keyMap.Tab: + // Behavior handled by parent or menu + break; + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + return (
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..92c927fcb2 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 @@ -3,7 +3,7 @@ import { jest } from '@jest/globals'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { defaultMax, defaultMin } from '../../../constants'; +import { charsPerSegment, defaultMax, defaultMin } from '../../../constants'; import { SharedDatePickerProvider, SharedDatePickerProviderProps, @@ -244,7 +244,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Arrow Keys', () => { describe('day input', () => { - const formatter = getValueFormatter('day'); + const formatter = getValueFormatter('day', charsPerSegment); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -390,7 +390,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('month input', () => { - const formatter = getValueFormatter('month'); + const formatter = getValueFormatter('month', charsPerSegment); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -552,7 +552,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('year input', () => { - const formatter = getValueFormatter('year'); + const formatter = getValueFormatter('year', charsPerSegment); describe('Up arrow', () => { test('calls handler with value +1', () => { 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..a67f504699 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -1,11 +1,6 @@ import React, { ChangeEventHandler, KeyboardEventHandler } 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 { charsPerSegment, @@ -14,18 +9,11 @@ import { defaultPlaceholder, } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; -import { getAutoComplete, getValueFormatter } from '../../../utils'; +import { getAutoComplete } from '../../../utils'; -import { getNewSegmentValueFromArrowKeyPress } from './utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; -import { - baseStyles, - fontSizeStyles, - segmentSizeStyles, - segmentThemeStyles, - segmentWidthStyles, -} from './DateInputSegment.styles'; +import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; -import { getNewSegmentValueFromInputValue } from './utils'; +import { InputSegment } from '../../InputSegment/InputSegment'; /** * Controlled component @@ -56,159 +44,181 @@ export const DateInputSegment = React.forwardRef< const min = minProp ?? defaultMin[segment]; const max = maxProp ?? defaultMax[segment]; - const inputRef = useForwardedRef(fwdRef, null); + // const inputRef = useForwardedRef(fwdRef, null); // TODO: do we need this? - const { theme } = useDarkMode(); - const baseFontSize = useUpdatedBaseFontSize(); + // const { theme } = useDarkMode(); + // const baseFontSize = useUpdatedBaseFontSize(); const { size, disabled, autoComplete: autoCompleteProp, } = useSharedDatePickerContext(); - const formatter = getValueFormatter(segment); + // const formatter = getValueFormatter(segment, charsPerSegment); 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); - }; + // 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, + // // TODO: pass pattern here + // ); + + // 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 ( + // + // ); + return ( - ); }, diff --git a/packages/date-picker/src/shared/components/InputSegment/Index.ts b/packages/date-picker/src/shared/components/InputSegment/Index.ts new file mode 100644 index 0000000000..11d6b7db8c --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/Index.ts @@ -0,0 +1,6 @@ +export { InputSegment } from './InputSegment'; +export type { + InputSegmentChangeEvent, + InputSegmentChangeEventHandler, + InputSegmentProps, +} from './InputSegment.types'; diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.spec.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.spec.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts new file mode 100644 index 0000000000..73fd8d176d --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts @@ -0,0 +1,83 @@ +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'; + +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; + appearance: none; + margin: 0; + } + -moz-appearance: textfield; /* Firefox */ + appearance: textfield; + + &: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 + `, +}; diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx new file mode 100644 index 0000000000..4bf028798c --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx @@ -0,0 +1,196 @@ +import React, { ChangeEventHandler, KeyboardEventHandler } from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; +import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; + +import { + baseStyles, + fontSizeStyles, + segmentSizeStyles, + segmentThemeStyles, +} from './InputSegment.styles'; +import { InputSegmentProps } from './InputSegment.types'; +import { getValueFormatter } from '../../utils'; +import { + getNewSegmentValueFromInputValue, + getNewSegmentValueFromArrowKeyPress, +} from './utils'; + +/** + * Generic controlled input segment component + * + * Renders a single input segment with configurable + * character padding, validation, and formatting. + * + * @internal + */ +export const InputSegment = React.forwardRef< + HTMLInputElement, + InputSegmentProps +>( + ( + { + segment, + value, + onChange, + onBlur, + onKeyDown, + size: sizeProp, + charsPerSegment, + min, + max, + size, + className, + ...rest + }: InputSegmentProps, + fwdRef, + ) => { + const { theme } = useDarkMode(); + const baseFontSize = useUpdatedBaseFontSize(); + const formatter = getValueFormatter(segment, charsPerSegment); + const pattern = `[0-9]{${charsPerSegment[segment]}}`; + + /** + * Receives native input events, + * determines whether the input value is valid and should change, + * and fires a custom `InputSegmentChangeEvent`. + */ + 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 maxLength, 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) { + // Stop propagation to prevent parent handlers from firing + 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 ( + + ); + }, +); + +InputSegment.displayName = 'InputSegment'; diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts new file mode 100644 index 0000000000..10782e0303 --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts @@ -0,0 +1,45 @@ +import React from 'react'; + +import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; + +export interface InputSegmentChangeEvent< + T extends string = string, + V extends string = string, +> { + segment: T; + value: V; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + [key: string]: any; + }; +} + +export type InputSegmentChangeEventHandler< + T extends string = string, + V extends string = string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +export interface InputSegmentProps< + T extends string = string, + V extends string = string, +> extends Omit, 'onChange' | 'size'> { + /** Which segment this input represents */ + segment: T; + + /** The value of the segment */ + value: V; + + /** Custom onChange handler */ + onChange: InputSegmentChangeEventHandler; + + charsPerSegment: Record; + + /** Minimum value. */ + min: number; + + /** Maximum value. */ + max: number; + + size: Size; +} diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts index 832c7c978a..54d102dd8d 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -1,6 +1,6 @@ import { keyMap, rollover } from '@leafygreen-ui/lib'; -import { DateSegment, DateSegmentValue } from '../../../../../types'; +import { DateSegment, DateSegmentValue } from '../../../../types'; interface DateSegmentKeypressContext { value: DateSegmentValue; diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index 095fe83b01..8ee336af6b 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -1,8 +1,8 @@ import range from 'lodash/range'; -import { defaultMax, defaultMin } from '../../../../../constants'; -import { DateSegment } from '../../../../../types'; -import { getValueFormatter } from '../../../../../utils'; +import { charsPerSegment, defaultMax, defaultMin } from '../../../../constants'; +import { DateSegment } from '../../../../types'; +import { getValueFormatter } from '../../../../utils'; import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; @@ -139,7 +139,7 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI }); describe('when current value is a full formatted value', () => { - const formatter = getValueFormatter(segment); + const formatter = getValueFormatter(segment, charsPerSegment); const testValues = [defaultMin[segment], defaultMax[segment]].map( formatter, ); diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 1aff779713..8c696cbc8f 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -2,9 +2,9 @@ import last from 'lodash/last'; import { truncateStart } from '@leafygreen-ui/lib'; -import { charsPerSegment } from '../../../../../constants'; -import { DateSegment, DateSegmentValue } from '../../../../../types'; -import { isValidValueForSegment } from '../../../../../utils'; +import { charsPerSegment } from '../../../../constants'; +import { DateSegment, DateSegmentValue } from '../../../../types'; +import { isValidValueForSegment } from '../../../../utils'; /** * Calculates the new value for the segment given an incoming change. diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/index.ts b/packages/date-picker/src/shared/components/InputSegment/utils/index.ts index f71520a27c..8326610773 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/index.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/index.ts @@ -1 +1,2 @@ export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; +export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; diff --git a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts index 49cbaafded..e7793f4825 100644 --- a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts +++ b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts @@ -1,4 +1,5 @@ import { DateSegment, DateSegmentsState } from '../../../shared/types'; +import { charsPerSegment } from '../../../shared/constants'; import { getFormatParts } from '../getFormatParts'; import { getValueFormatter } from '../getValueFormatter'; @@ -16,7 +17,7 @@ export const getFormattedDateStringFromSegments = ( } const segment = part.type as DateSegment; - const formatter = getValueFormatter(segment); + const formatter = getValueFormatter(segment, charsPerSegment); const formattedSegment = formatter(segments[segment]); return dateString + formattedSegment; }, ''); diff --git a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts index bcbf01f260..304076041a 100644 --- a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts +++ b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts @@ -1,5 +1,6 @@ import { DateType } from '@leafygreen-ui/date-utils'; +import { charsPerSegment } from '../../constants'; import { DateSegmentsState } from '../../types'; import { getValueFormatter } from '../getValueFormatter'; @@ -12,8 +13,8 @@ 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('day', charsPerSegment)(segments['day']), + month: getValueFormatter('month', charsPerSegment)(segments['month']), + year: getValueFormatter('year', charsPerSegment)(segments['year']), }; }; diff --git a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts index bf759d62bc..dbe7b575a0 100644 --- a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts +++ b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts @@ -2,14 +2,12 @@ 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) => { + (segment: T, charsPerSegment: Record) => + (val: string | number | undefined) => { // If the value is any form of zero, we set it to an empty string if (isZeroLike(val)) return ''; diff --git a/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts b/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts index 9b04b141ea..05c2916639 100644 --- a/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts +++ b/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts @@ -1,10 +1,11 @@ import { DateSegment } from '../../types'; import { getValueFormatter } from '.'; +import { charsPerSegment } from '../../constants'; describe('packages/date-picker/utils/valueFormatter', () => { describe.each(['day', 'month'] as Array)('', segment => { - const formatter = getValueFormatter(segment); + const formatter = getValueFormatter(segment, charsPerSegment); test('formats 2 digit values', () => { expect(formatter('12')).toEqual('12'); @@ -32,7 +33,7 @@ describe('packages/date-picker/utils/valueFormatter', () => { }); describe('year', () => { - const formatter = getValueFormatter('year'); + const formatter = getValueFormatter('year', charsPerSegment); test('formats 4 digit values', () => { expect(formatter('2023')).toEqual('2023'); diff --git a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts index e357588425..74e87d1932 100644 --- a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts +++ b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts @@ -12,7 +12,7 @@ export const isExplicitSegmentValue = ( segment: DateSegment, value: DateSegmentValue, ): boolean => { - if (!(isValidSegmentValue(value) && isValidSegmentName(segment))) + if (!(isValidSegmentValue(value) && isValidSegmentName(DateSegment, segment))) return false; switch (segment) { diff --git a/packages/date-picker/src/shared/utils/isValidSegment/index.ts b/packages/date-picker/src/shared/utils/isValidSegment/index.ts index 861fbeca75..0c6be85d83 100644 --- a/packages/date-picker/src/shared/utils/isValidSegment/index.ts +++ b/packages/date-picker/src/shared/utils/isValidSegment/index.ts @@ -5,17 +5,43 @@ import { DateSegment, DateSegmentValue } from '../../types'; /** * Returns whether a given value is a valid segment value */ -export const isValidSegmentValue = ( - segment?: DateSegmentValue, -): segment is DateSegmentValue => +// export const isValidSegmentValue = ( +// segment?: DateSegmentValue, +// ): segment is DateSegmentValue => +// !isUndefined(segment) && !isNaN(Number(segment)) && Number(segment) > 0; + +export const isValidSegmentValue = (segment?: T): segment is T => !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) +// ); +// }; + +// 1. Define a type helper for the segment object structure +type SegmentObject = Readonly>; + /** - * Returns whether a given string is a valid segment name (day, month, year) + * A generic type predicate function that checks if a given string is one + * of the values in the provided segment object. + * + * @param segmentObj The runtime object containing the valid string segments (must be 'as const') + * @param name The string to validate + * @returns A boolean and a type predicate (name is T[keyof T]) */ -export const isValidSegmentName = (name?: string): name is DateSegment => { +export const isValidSegmentName = ( + segmentObj: T, + name?: string, +): name is T[keyof T] => { return ( !isUndefined(name) && - Object.values(DateSegment).includes(name as DateSegment) + Object.values(segmentObj).includes( + name as (typeof segmentObj)[keyof typeof segmentObj], + ) ); }; diff --git a/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts b/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts index 0993fec4be..50520de8e9 100644 --- a/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts +++ b/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts @@ -1,64 +1,65 @@ import { isValidSegmentName, isValidSegmentValue } from '.'; +import { DateSegment, DateSegmentValue } from '../../types'; describe('packages/date-picker/utils/isValidSegment', () => { describe('isValidSegment', () => { test('undefined returns false', () => { - expect(isValidSegmentValue()).toBeFalsy(); + expect(isValidSegmentValue()).toBeFalsy(); }); test('a string returns false', () => { - expect(isValidSegmentValue('')).toBeFalsy(); + expect(isValidSegmentValue('')).toBeFalsy(); }); test('NaN returns false', () => { /// @ts-expect-error - expect(isValidSegmentValue(NaN)).toBeFalsy(); + expect(isValidSegmentValue(NaN)).toBeFalsy(); }); test('0 returns false', () => { - expect(isValidSegmentValue('0')).toBeFalsy(); + expect(isValidSegmentValue('0')).toBeFalsy(); }); test('negative returns false', () => { - expect(isValidSegmentValue('-1')).toBeFalsy(); + expect(isValidSegmentValue('-1')).toBeFalsy(); }); test('1970 returns true', () => { - expect(isValidSegmentValue('1970')).toBeTruthy(); + expect(isValidSegmentValue('1970')).toBeTruthy(); }); test('1 returns true', () => { - expect(isValidSegmentValue('1')).toBeTruthy(); + expect(isValidSegmentValue('1')).toBeTruthy(); }); test('2038 returns true', () => { - expect(isValidSegmentValue('2038')).toBeTruthy(); + expect(isValidSegmentValue('2038')).toBeTruthy(); }); }); describe('isValidSegmentName', () => { test('undefined returns false', () => { - expect(isValidSegmentName()).toBeFalsy(); + expect(isValidSegmentName(DateSegment)).toBeFalsy(); }); test('random string returns false', () => { - expect(isValidSegmentName('123')).toBeFalsy(); + expect(isValidSegmentName(DateSegment, '123')).toBeFalsy(); }); test('empty string returns false', () => { - expect(isValidSegmentName('')).toBeFalsy(); + expect(isValidSegmentName(DateSegment, '')).toBeFalsy(); }); test('day string returns true', () => { - expect(isValidSegmentName('day')).toBeTruthy(); + expect(isValidSegmentName(DateSegment, 'day')).toBeTruthy(); }); test('month string returns true', () => { - expect(isValidSegmentName('month')).toBeTruthy(); + expect(isValidSegmentName(DateSegment, 'month')).toBeTruthy(); }); test('year string returns true', () => { - expect(isValidSegmentName('year')).toBeTruthy(); + expect(isValidSegmentName(DateSegment, 'year')).toBeTruthy(); }); }); }); diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts index 802dd3baf1..549ec8880f 100644 --- a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts +++ b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts @@ -12,7 +12,7 @@ export const isValidValueForSegment = ( value: DateSegmentValue, ): boolean => { const isValidSegmentAndValue = - isValidSegmentValue(value) && isValidSegmentName(segment); + isValidSegmentValue(value) && isValidSegmentName(DateSegment, segment); if (segment === 'year') { // allow any 4-digit year value regardless of defined range From 4ebb946b97eb49caacd6fa8b861c841475e6af3e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 8 Oct 2025 11:12:12 -0400 Subject: [PATCH 03/56] refactor(date-picker): enhance InputSegment and DateInputSegment with improved type handling and event management --- .../DateInputSegment/DateInputSegment.tsx | 15 +- .../DateInputSegment.types.ts | 12 +- .../components/InputSegment/InputSegment.tsx | 464 ++++++++++++------ .../InputSegment/InputSegment.types.ts | 21 +- .../getNewSegmentValueFromArrowKeyPress.ts | 15 +- .../getNewSegmentValueFromInputValue.ts | 27 +- .../isEverySegmentValid.ts | 11 +- .../utils/isValidValueForSegment/index.ts | 15 +- .../isValidValueForSegment.spec.ts | 79 ++- 9 files changed, 461 insertions(+), 198 deletions(-) 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 a67f504699..cc9b420b85 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -14,6 +14,8 @@ import { getAutoComplete } from '../../../utils'; import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; import { InputSegment } from '../../InputSegment/InputSegment'; +import { InputSegmentChangeEvent } from '../../InputSegment/InputSegment.types'; +import { DateSegment, DateSegmentValue } from '../../../types'; /** * Controlled component @@ -57,6 +59,15 @@ export const DateInputSegment = React.forwardRef< const autoComplete = getAutoComplete(autoCompleteProp, segment); // const pattern = `[0-9]{${charsPerSegment[segment]}}`; + const handleChange = ( + inputSegmentChangeEvent: InputSegmentChangeEvent< + DateSegment, + DateSegmentValue + >, + ) => { + onChange(inputSegmentChangeEvent); + }; + // /** // * Receives native input events, // * determines whether the input value is valid and should change, @@ -202,10 +213,10 @@ export const DateInputSegment = React.forwardRef< // ); return ( - segment={segment} value={value} - onChange={onChange} + onChange={handleChange} onBlur={onBlur} onKeyDown={onKeyDown} min={min} 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..258365543a 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 @@ -3,6 +3,7 @@ import React from 'react'; import { DarkModeProps, keyMap } from '@leafygreen-ui/lib'; import { DateSegment, DateSegmentValue } from '../../../types'; +import { InputSegmentChangeEventHandler } from '../../InputSegment/InputSegment.types'; export interface DateInputSegmentChangeEvent { segment: DateSegment; @@ -13,9 +14,14 @@ export interface DateInputSegmentChangeEvent { }; } -export type DateInputSegmentChangeEventHandler = ( - dateSegmentChangeEvent: DateInputSegmentChangeEvent, -) => void; +// export type DateInputSegmentChangeEventHandler = ( +// dateSegmentChangeEvent: DateInputSegmentChangeEvent, +// ) => void; + +export type DateInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< + DateSegment, + DateSegmentValue +>; export interface DateInputSegmentProps extends DarkModeProps, diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx index 4bf028798c..d865a239cc 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx @@ -1,4 +1,8 @@ -import React, { ChangeEventHandler, KeyboardEventHandler } from 'react'; +import React, { + ChangeEventHandler, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; import { cx } from '@leafygreen-ui/emotion'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; @@ -11,7 +15,10 @@ import { segmentSizeStyles, segmentThemeStyles, } from './InputSegment.styles'; -import { InputSegmentProps } from './InputSegment.types'; +import { + InputSegmentComponentType, + InputSegmentProps, +} from './InputSegment.types'; import { getValueFormatter } from '../../utils'; import { getNewSegmentValueFromInputValue, @@ -26,171 +33,344 @@ import { * * @internal */ -export const InputSegment = React.forwardRef< - HTMLInputElement, - InputSegmentProps ->( - ( - { +// export const InputSegment = React.forwardRef< +// HTMLInputElement, +// InputSegmentProps //TODO: fix this . This is a generic forwardRef +// >( +// ( +// { +// segment, +// value, +// onChange, +// onBlur, +// onKeyDown, +// size: sizeProp, +// charsPerSegment, +// min, +// max, +// size, +// className, +// ...rest +// }: InputSegmentProps, +// fwdRef, +// ) => { +// const { theme } = useDarkMode(); +// const baseFontSize = useUpdatedBaseFontSize(); +// const formatter = getValueFormatter(segment, charsPerSegment); +// const pattern = `[0-9]{${charsPerSegment[segment]}}`; + +// /** +// * Receives native input events, +// * determines whether the input value is valid and should change, +// * and fires a custom `InputSegmentChangeEvent`. +// */ +// 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 maxLength, 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) { +// // Stop propagation to prevent parent handlers from firing +// 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 ( +// +// ); +// }, +// ); + +const InputSegmentWithRef = ( + { + segment, + value, + onChange, + onBlur, + onKeyDown, + size: sizeProp, + charsPerSegment, + min, + max, + size, + className, + segmentObj, + defaultMin, + defaultMax, + ...rest + }: InputSegmentProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + const baseFontSize = useUpdatedBaseFontSize(); + const formatter = getValueFormatter(segment, charsPerSegment); + const pattern = `[0-9]{${charsPerSegment[segment]}}`; + + /** + * Receives native input events, + * determines whether the input value is valid and should change, + * and fires a custom `InputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue( segment, value, - onChange, - onBlur, - onKeyDown, - size: sizeProp, + target.value, charsPerSegment, - min, - max, - size, - className, - ...rest - }: InputSegmentProps, - fwdRef, - ) => { - const { theme } = useDarkMode(); - const baseFontSize = useUpdatedBaseFontSize(); - const formatter = getValueFormatter(segment, charsPerSegment); - const pattern = `[0-9]{${charsPerSegment[segment]}}`; - - /** - * Receives native input events, - * determines whether the input value is valid and should change, - * and fires a custom `InputSegmentChangeEvent`. - */ - const handleChange: ChangeEventHandler = e => { - const { target } = e; - - const newValue = getNewSegmentValueFromInputValue( + defaultMin, + defaultMax, + segmentObj, + ); + + const hasValueChanged = newValue !== value; + + if (hasValueChanged) { + onChange({ segment, - value, - target.value, - ); + value: newValue as V, + }); + } else { + // If the value has not changed, ensure the input value is reset + target.value = value; + } + }; - const hasValueChanged = newValue !== 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; + }; - if (hasValueChanged) { - onChange({ - segment, - value: newValue, - }); - } else { - // If the value has not changed, ensure the input value is reset - target.value = value; + // 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 maxLength, reset the input + if (target.value.length === charsPerSegment[segment]) { + target.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; - }; + switch (key) { + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + e.preventDefault(); - // 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; + const newValue = getNewSegmentValueFromArrowKeyPress({ + key, + value, + min, + max, + segment, + }); + const valueString = formatter(newValue); - if (isNumber) { - // if the value length is equal to the maxLength, reset the input - if (target.value.length === charsPerSegment[segment]) { - target.value = ''; - } + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString as V, + meta: { key }, + }); + break; } - switch (key) { - case keyMap.ArrowUp: - case keyMap.ArrowDown: { - e.preventDefault(); + // On backspace the value is reset + case keyMap.Backspace: { + // Don't fire change event if the input is initially empty + if (value) { + // Stop propagation to prevent parent handlers from firing + e.stopPropagation(); - const newValue = getNewSegmentValueFromArrowKeyPress({ - key, - value, - min, - max, + /** Fire a custom change event when the backspace key is pressed */ + onChange({ segment, + value: '' as V, + meta: { key }, }); - const valueString = formatter(newValue); + } + + break; + } - /** Fire a custom change event when the up/down arrow keys are pressed */ + // 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: valueString, + value: '' as V, 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) { - // Stop propagation to prevent parent handlers from firing - 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; - } + break; + } - default: { - break; - } + default: { + break; } + } - onKeyDown?.(e); - }; + 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 ( - - ); - }, -); + // 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 ( + + ); +}; + +export const InputSegment = React.forwardRef( + InputSegmentWithRef, +) as InputSegmentComponentType; InputSegment.displayName = 'InputSegment'; diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts index 10782e0303..cdf52bbe9e 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ForwardedRef, ReactElement } from 'react'; import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; @@ -31,7 +31,7 @@ export interface InputSegmentProps< value: V; /** Custom onChange handler */ - onChange: InputSegmentChangeEventHandler; + onChange: InputSegmentChangeEventHandler; charsPerSegment: Record; @@ -41,5 +41,22 @@ export interface InputSegmentProps< /** Maximum value. */ max: number; + /** Segment object */ + segmentObj: Readonly>; + + /** Default minimum value */ + defaultMin: Record; + + /** Default maximum value */ + defaultMax: Record; + size: Size; } + +export interface InputSegmentComponentType { + ( + props: InputSegmentProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; +} diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts index 54d102dd8d..5a743d51fb 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -1,11 +1,9 @@ import { keyMap, rollover } from '@leafygreen-ui/lib'; -import { DateSegment, DateSegmentValue } from '../../../../types'; - -interface DateSegmentKeypressContext { - value: DateSegmentValue; +interface DateSegmentKeypressContext { + value: V; key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; - segment: DateSegment; + segment: T; min: number; max: number; } @@ -13,13 +11,16 @@ interface DateSegmentKeypressContext { /** * Returns a new segment value given the current state */ -export const getNewSegmentValueFromArrowKeyPress = ({ +export const getNewSegmentValueFromArrowKeyPress = < + T extends string, + V extends string, +>({ value, key, segment, min, max, -}: DateSegmentKeypressContext): number => { +}: DateSegmentKeypressContext): number => { const valueDiff = key === keyMap.ArrowUp ? 1 : -1; const defaultVal = key === keyMap.ArrowUp ? min : max; diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 8c696cbc8f..207fa0f575 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -1,9 +1,6 @@ import last from 'lodash/last'; import { truncateStart } from '@leafygreen-ui/lib'; - -import { charsPerSegment } from '../../../../constants'; -import { DateSegment, DateSegmentValue } from '../../../../types'; import { isValidValueForSegment } from '../../../../utils'; /** @@ -14,11 +11,18 @@ import { isValidValueForSegment } from '../../../../utils'; * - include a period * - would cause the segment to overflow */ -export const getNewSegmentValueFromInputValue = ( - segmentName: DateSegment, - currentValue: DateSegmentValue, - incomingValue: DateSegmentValue, -): DateSegmentValue => { +export const getNewSegmentValueFromInputValue = < + T extends string, + V extends string, +>( + segmentName: T, + currentValue: V, + incomingValue: V, + charsPerSegment: Record, + defaultMin: Record, + defaultMax: Record, + segmentObj: Readonly>, +): V => { // 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. @@ -40,6 +44,9 @@ export const getNewSegmentValueFromInputValue = ( const isIncomingValueValid = isValidValueForSegment( segmentName, incomingValue, + defaultMin, + defaultMax, + segmentObj, ); if (isIncomingValueValid || segmentName === 'year') { @@ -47,10 +54,10 @@ export const getNewSegmentValueFromInputValue = ( length: charsPerSegment[segmentName], }); - return newValue; + return newValue as V; } const typedChar = last(incomingValue.split('')); const newValue = typedChar === '0' ? '0' : typedChar ?? ''; - return newValue; + return newValue as V; }; diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts index 6e338ec5b9..e4e3119cfe 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -1,4 +1,5 @@ -import { DateSegment, DateSegmentsState } from '../../types'; +import { defaultMax, defaultMin } from '../../constants'; +import { DateSegment, DateSegmentValue, DateSegmentsState } from '../../types'; import { isValidValueForSegment } from '../isValidValueForSegment'; /** @@ -6,6 +7,12 @@ import { isValidValueForSegment } from '../isValidValueForSegment'; */ export const isEverySegmentValid = (segments: DateSegmentsState): boolean => { return Object.entries(segments).every(([segment, value]) => - isValidValueForSegment(segment as DateSegment, value), + isValidValueForSegment( + segment as DateSegment, + value as DateSegmentValue, + defaultMin, + defaultMax, + DateSegment, + ), ); }; diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts index 549ec8880f..5691ebff0f 100644 --- a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts +++ b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts @@ -1,18 +1,21 @@ import inRange from 'lodash/inRange'; -import { defaultMax, defaultMin } from '../../constants'; -import { DateSegment, DateSegmentValue } from '../../types'; import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; +// TODO: move to generic utils + /** * Returns whether a value is valid for a given segment type */ -export const isValidValueForSegment = ( - segment: DateSegment, - value: DateSegmentValue, +export const isValidValueForSegment = ( + segment: T, + value: V, + defaultMin: Record, + defaultMax: Record, + segmentObj: Readonly>, ): boolean => { const isValidSegmentAndValue = - isValidSegmentValue(value) && isValidSegmentName(DateSegment, segment); + isValidSegmentValue(value) && isValidSegmentName(segmentObj, segment); if (segment === 'year') { // allow any 4-digit year value regardless of defined range diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts index 4b29066629..f4d5b86d6c 100644 --- a/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts +++ b/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -1,40 +1,71 @@ +import { MAX_DATE, MIN_DATE } from '@leafygreen-ui/date-utils'; import { isValidValueForSegment } from '.'; +const SegmentObj = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; + +type SegmentObj = (typeof SegmentObj)[keyof typeof SegmentObj]; + +const defaultMin = { + day: 1, + month: 1, + year: MIN_DATE.getUTCFullYear(), +} as const; + +const defaultMax = { + day: 31, + month: 12, + year: MAX_DATE.getUTCFullYear(), +} as const; + +const isValidValueForSegmentWrapper = (segment: SegmentObj, value: string) => { + return isValidValueForSegment( + segment, + value, + defaultMin, + defaultMax, + SegmentObj, + ); +}; + 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(isValidValueForSegmentWrapper('day', '1')).toBe(true); + expect(isValidValueForSegmentWrapper('day', '15')).toBe(true); + expect(isValidValueForSegmentWrapper('day', '31')).toBe(true); - expect(isValidValueForSegment('day', '0')).toBe(false); - expect(isValidValueForSegment('day', '32')).toBe(false); + expect(isValidValueForSegmentWrapper('day', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('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(isValidValueForSegmentWrapper('month', '1')).toBe(true); + expect(isValidValueForSegmentWrapper('month', '9')).toBe(true); + expect(isValidValueForSegmentWrapper('month', '12')).toBe(true); - expect(isValidValueForSegment('month', '0')).toBe(false); - expect(isValidValueForSegment('month', '28')).toBe(false); + expect(isValidValueForSegmentWrapper('month', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('month', '28')).toBe(false); }); test('year', () => { - expect(isValidValueForSegment('year', '1970')).toBe(true); - expect(isValidValueForSegment('year', '2000')).toBe(true); - expect(isValidValueForSegment('year', '2038')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '1970')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2000')).toBe(true); + expect(isValidValueForSegmentWrapper('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); + expect(isValidValueForSegmentWrapper('year', '1000')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '1945')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2048')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '9999')).toBe(true); + + expect(isValidValueForSegmentWrapper('year', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '20')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '200')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '999')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '10000')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '-2000')).toBe(false); }); }); From 9f40cffc0b2c7b4c5d8d9e37033a8d63748fa142 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 8 Oct 2025 16:20:05 -0400 Subject: [PATCH 04/56] refactor(date-picker): update InputSegment types and enhance DateInputSegment with additional props --- .../DateInputSegment/DateInputSegment.tsx | 6 +- .../components/InputBox/InputBox.specs.tsx | 0 .../components/InputBox/InputBox.styles.ts | 0 .../shared/components/InputBox/InputBox.tsx | 278 ++++++++++++++++++ .../components/InputBox/InputBox.types.ts | 0 .../InputSegment/InputSegment.types.ts | 2 +- 6 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.specs.tsx create mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.styles.ts create mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.tsx create mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.types.ts 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 cc9b420b85..a3090ad9f5 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -16,6 +16,7 @@ import { DateInputSegmentProps } from './DateInputSegment.types'; import { InputSegment } from '../../InputSegment/InputSegment'; import { InputSegmentChangeEvent } from '../../InputSegment/InputSegment.types'; import { DateSegment, DateSegmentValue } from '../../../types'; +import { Size } from '@leafygreen-ui/tokens'; /** * Controlled component @@ -214,6 +215,7 @@ export const DateInputSegment = React.forwardRef< return ( + ref={fwdRef} segment={segment} value={value} onChange={handleChange} @@ -225,10 +227,12 @@ export const DateInputSegment = React.forwardRef< size={size} charsPerSegment={charsPerSegment} autoComplete={autoComplete} - ref={fwdRef} className={cx(segmentWidthStyles[segment])} disabled={disabled} data-testid="lg-date_picker_input-segment" + defaultMin={defaultMin} + defaultMax={defaultMax} + segmentObj={DateSegment} {...rest} /> ); diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.specs.tsx b/packages/date-picker/src/shared/components/InputBox/InputBox.specs.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.styles.ts b/packages/date-picker/src/shared/components/InputBox/InputBox.styles.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.tsx b/packages/date-picker/src/shared/components/InputBox/InputBox.tsx new file mode 100644 index 0000000000..0e9b609229 --- /dev/null +++ b/packages/date-picker/src/shared/components/InputBox/InputBox.tsx @@ -0,0 +1,278 @@ +// @ts-nocheck + +import React, { + FocusEventHandler, + KeyboardEventHandler, + useEffect, +} from 'react'; +import isEqual from 'lodash/isEqual'; +import isNull from 'lodash/isNull'; + +import { + isDateObject, + 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 { useSharedDatePickerContext } from '../../../context'; +import { useDateSegments } from '../../../hooks'; +import { + DateSegment, + DateSegmentsState, + DateSegmentValue, + isDateSegment, +} from '../../../types'; +import { + getMaxSegmentValue, + getMinSegmentValue, + getRelativeSegment, + getValueFormatter, + isEverySegmentFilled, + isEverySegmentValueExplicit, + isExplicitSegmentValue, + newDateFromSegments, + getRelativeSegmentRef, + isElementInputSegment, +} from '../../../utils'; +import { DateInputSegment } from '../DateInputSegment'; +import { InputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; + +import { + segmentPartsWrapperStyles, + separatorLiteralDisabledStyles, + separatorLiteralStyles, +} from './InputBox.styles'; +import { InputBoxProps } from './InputBox.types'; +import { charsPerSegment } from '../../../constants'; + +/** + * Renders a styled date input with appropriate segment order & separator characters. + * + * Depends on {@link DateInputSegment} + * + * Uses parameters `value` & `locale` along with {@link Intl.DateTimeFormat.prototype.formatToParts} + * to determine the segment order and separator characters. + * + * Provided value is assumed to be UTC. + * + * Argument passed into `setValue` callback is also in UTC + * @internal + */ +export const InputBox = React.forwardRef( + ( + { + value, + setValue, + className, + labelledBy, + segmentRefs, + onSegmentChange, + onKeyDown, + handleSegmentUpdate, + ...rest + }: InputBoxProps, + fwdRef, + ) => { + const { isDirty, formatParts, disabled, min, max, setIsDirty } = + 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, charsPerSegment); + const formattedValue = formatter(segmentValue); + return formattedValue; + }; + + /** if the value is a `Date` the component is dirty */ + useEffect(() => { + if (isDateObject(value) && !isDirty) { + setIsDirty(true); + } + }, [isDirty, setIsDirty, value]); + + /** 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: InputSegmentChangeEventHandler = + 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); + // TODO: onInputChange callback here + }; + + /** 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); + } + }; + + /** Called on any keydown within the input element */ + const handleInputKeyDown: KeyboardEventHandler = e => { + const { target: _target, key } = e; + const target = _target as HTMLElement; + const isSegment = isElementInputSegment(target, segmentRefs); + + // 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: + case keyMap.Enter: + case keyMap.Escape: + case keyMap.Tab: + // Behavior handled by parent or menu + break; + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + + return ( +
+ {formatParts?.map((part, i) => { + if (part.type === 'literal') { + return ( + + {part.value} + + ); + } else if (isDateSegment(part.type)) { + return ( + + ); + } + })} +
+ ); + }, +); + +InputBox.displayName = 'InputBox'; diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts index cdf52bbe9e..dbd9dd6235 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts @@ -23,7 +23,7 @@ export type InputSegmentChangeEventHandler< export interface InputSegmentProps< T extends string = string, V extends string = string, -> extends Omit, 'onChange' | 'size'> { +> extends Omit, 'onChange' | 'size'> { /** Which segment this input represents */ segment: T; From 85351a3f0ac915bbe333d3bf5fc1aa5e6e93cee2 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 21 Oct 2025 12:46:27 -0400 Subject: [PATCH 05/56] refactor(date-picker): WIP enhance InputBox and DateInput components with improved type handling and segment management --- .../DateInput/DateInputBox/DateInputBox.tsx | 11 ++ .../DateInputSegment/DateInputSegment.tsx | 2 + .../components/InputBox/InputBox.styles.ts | 22 +++ .../shared/components/InputBox/InputBox.tsx | 129 ++++++-------- .../components/InputBox/InputBox.types.ts | 65 +++++++ .../components/InputSegment/InputSegment.tsx | 167 ------------------ .../getNewSegmentValueFromInputValue.spec.ts | 2 + .../src/shared/types/DateSegment.types.ts | 8 + .../shared/utils/getRelativeSegment/index.ts | 162 ++++++++++++++--- .../utils/isExplicitSegmentValue/index.ts | 37 ++++ 10 files changed, 335 insertions(+), 270 deletions(-) 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 75e37bdca7..a58ef159aa 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -80,6 +80,7 @@ export const DateInputBox = React.forwardRef( const containerRef = useForwardedRef(fwdRef, null); + // TODO: MOVE to generic component /** Formats and sets the segment value */ const getFormattedSegmentValue = ( segmentName: DateSegment, @@ -90,6 +91,7 @@ export const DateInputBox = React.forwardRef( return formattedValue; }; + // TODO: MOVE to generic component /** if the value is a `Date` the component is dirty */ useEffect(() => { if (isDateObject(value) && !isDirty) { @@ -97,6 +99,7 @@ export const DateInputBox = React.forwardRef( } }, [isDirty, setIsDirty, value]); + // TODO: keep. This is specific to DatePicker /** * When a segment is updated, * trigger a `change` event for the segment, and @@ -126,12 +129,14 @@ export const DateInputBox = React.forwardRef( } }; + // TODO: keep. This is specific to DatePicker /** State Management for segments using a useReducer instead of useState */ /** Keep track of each date segment */ const { segments, setSegment } = useDateSegments(value, { onUpdate: handleSegmentUpdate, }); + // TODO: MOVE to generic component /** Fired when an individual segment value changes */ const handleSegmentInputChange: DateInputSegmentChangeEventHandler = segmentChangeEvent => { @@ -143,6 +148,7 @@ export const DateInputBox = React.forwardRef( // Auto-format the segment if it is explicit and was not changed via arrow-keys if ( !changedViaArrowKeys && + // TODO: consider making this a factory function since this will be different depending on the component. isExplicitSegmentValue(segmentName, segmentValue) ) { segmentValue = getFormattedSegmentValue(segmentName, segmentValue); @@ -165,6 +171,7 @@ export const DateInputBox = React.forwardRef( // TODO: onInputChange callback here }; + // TODO: MOVE to generic component /** Triggered when a segment is blurred */ const handleSegmentInputBlur: FocusEventHandler = e => { const segmentName = e.target.getAttribute('id'); @@ -179,6 +186,7 @@ export const DateInputBox = React.forwardRef( } }; + // TODO: MOVE to generic component /** Called on any keydown within the input element */ const handleInputKeyDown: KeyboardEventHandler = e => { const { target: _target, key } = e; @@ -261,6 +269,9 @@ export const DateInputBox = React.forwardRef( onKeyDown?.(e); }; + // TODO: This will return the generic InputBox component + // We will pass in the formatParts, segmentRefs, onSegmentChange, onKeyDown, the segments and setSegment functions, and getMinSegmentValue and getMaxSegmentValue functions as props + return (
= { + [Theme.Dark]: css` + color: ${palette.gray.dark2}; + `, + [Theme.Light]: css` + color: ${palette.gray.base}; + `, +}; diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.tsx b/packages/date-picker/src/shared/components/InputBox/InputBox.tsx index 0e9b609229..70d73b06e3 100644 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.tsx +++ b/packages/date-picker/src/shared/components/InputBox/InputBox.tsx @@ -1,45 +1,15 @@ -// @ts-nocheck +import React, { FocusEventHandler, KeyboardEventHandler } from 'react'; -import React, { - FocusEventHandler, - KeyboardEventHandler, - useEffect, -} from 'react'; -import isEqual from 'lodash/isEqual'; -import isNull from 'lodash/isNull'; - -import { - isDateObject, - 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 { useSharedDatePickerContext } from '../../../context'; -import { useDateSegments } from '../../../hooks'; import { - DateSegment, - DateSegmentsState, - DateSegmentValue, - isDateSegment, -} from '../../../types'; -import { - getMaxSegmentValue, - getMinSegmentValue, getRelativeSegment, getValueFormatter, - isEverySegmentFilled, - isEverySegmentValueExplicit, - isExplicitSegmentValue, - newDateFromSegments, getRelativeSegmentRef, - isElementInputSegment, -} from '../../../utils'; -import { DateInputSegment } from '../DateInputSegment'; -import { InputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; +} from '../../utils'; +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; import { segmentPartsWrapperStyles, @@ -47,19 +17,32 @@ import { separatorLiteralStyles, } from './InputBox.styles'; import { InputBoxProps } from './InputBox.types'; -import { charsPerSegment } from '../../../constants'; +import { createExplicitSegmentValidator } from '../../utils/isExplicitSegmentValue'; + +export function isInputSegment( + str: any, + segmentObj: Record, +): str is keyof typeof segmentObj { + if (typeof str !== 'string') return false; + return Object.values(segmentObj).includes(str); +} + +export const isElementInputSegment = < + T extends Record>, +>( + element: HTMLElement, + segmentRefs: T, +): element is HTMLInputElement => { + const segmentsArray = Object.values(segmentRefs).map( + ref => ref.current, + ) as Array; + const isSegment = segmentsArray.includes(element); + return isSegment; +}; /** * Renders a styled date input with appropriate segment order & separator characters. * - * Depends on {@link DateInputSegment} - * - * Uses parameters `value` & `locale` along with {@link Intl.DateTimeFormat.prototype.formatToParts} - * to determine the segment order and separator characters. - * - * Provided value is assumed to be UTC. - * - * Argument passed into `setValue` callback is also in UTC * @internal */ export const InputBox = React.forwardRef( @@ -72,40 +55,35 @@ export const InputBox = React.forwardRef( segmentRefs, onSegmentChange, onKeyDown, - handleSegmentUpdate, + segments, + setSegment, + disabled, + charsPerSegment, + formatParts, + children, + segmentObj, + segmentRules, ...rest }: InputBoxProps, fwdRef, ) => { - const { isDirty, formatParts, disabled, min, max, setIsDirty } = - useSharedDatePickerContext(); const { theme } = useDarkMode(); - const containerRef = useForwardedRef(fwdRef, null); + const isExplicitSegmentValue = createExplicitSegmentValidator( + segmentObj, + segmentRules, + ); /** Formats and sets the segment value */ const getFormattedSegmentValue = ( - segmentName: DateSegment, - segmentValue: DateSegmentValue, - ): DateSegmentValue => { + segmentName: (typeof segmentObj)[keyof typeof segmentObj], + segmentValue: string, + ): string => { const formatter = getValueFormatter(segmentName, charsPerSegment); const formattedValue = formatter(segmentValue); return formattedValue; }; - /** if the value is a `Date` the component is dirty */ - useEffect(() => { - if (isDateObject(value) && !isDirty) { - setIsDirty(true); - } - }, [isDirty, setIsDirty, value]); - - /** 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: InputSegmentChangeEventHandler = segmentChangeEvent => { @@ -144,7 +122,7 @@ export const InputBox = React.forwardRef( const segmentName = e.target.getAttribute('id'); const segmentValue = e.target.value; - if (isDateSegment(segmentName)) { + if (isInputSegment(segmentName, segmentObj)) { const formattedValue = getFormattedSegmentValue( segmentName, segmentValue, @@ -235,11 +213,18 @@ export const InputBox = React.forwardRef( onKeyDown?.(e); }; + // TODO: consider render prop + const renderedChildren = React.cloneElement(children, { + ...children.props, + onChange: handleSegmentInputChange, + onBlur: handleSegmentInputBlur, + }); + return (
{formatParts?.map((part, i) => { @@ -254,20 +239,8 @@ export const InputBox = React.forwardRef( {part.value} ); - } else if (isDateSegment(part.type)) { - return ( - - ); + } else if (isInputSegment(part.type, segmentObj)) { + return renderedChildren; } })}
diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts index e69de29bb2..aa604b7fec 100644 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts +++ b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts @@ -0,0 +1,65 @@ +import React from 'react'; + +import { DateType } from '@leafygreen-ui/date-utils'; + +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { ExplicitSegmentRule } from '../../utils/isExplicitSegmentValue'; + +export interface InputChangeEvent { + value: DateType; + segments: Record; +} + +export type InputChangeEventHandler = ( + changeEvent: InputChangeEvent, +) => void; + +export interface InputBoxProps + extends Omit, 'onChange' | 'children'> { + /** + * Date value passed into the component + */ + value?: DateType; + + /** + * Value setter callback. + */ + setValue?: InputChangeEventHandler; + + /** + * Callback fired when any segment changes, but not necessarily a full value + */ + onSegmentChange?: InputSegmentChangeEventHandler; + + /** + * id of the labelling element + */ + labelledBy?: string; + + /** Refs */ + segmentRefs: Record>>; + + /** Segment object */ + segmentObj: Readonly>; + + /** Default minimum value */ + defaultMin: Record; + + /** Default maximum value */ + defaultMax: Record; + + segments: Record; + + setSegment: (segment: T, value: string) => void; + + formatParts: Intl.DateTimeFormatPart[]; + + charsPerSegment: Record; + + disabled: boolean; + + children: React.ReactElement; + + segmentRules: Record; +} diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx index d865a239cc..13ca2da92b 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx @@ -33,173 +33,6 @@ import { * * @internal */ -// export const InputSegment = React.forwardRef< -// HTMLInputElement, -// InputSegmentProps //TODO: fix this . This is a generic forwardRef -// >( -// ( -// { -// segment, -// value, -// onChange, -// onBlur, -// onKeyDown, -// size: sizeProp, -// charsPerSegment, -// min, -// max, -// size, -// className, -// ...rest -// }: InputSegmentProps, -// fwdRef, -// ) => { -// const { theme } = useDarkMode(); -// const baseFontSize = useUpdatedBaseFontSize(); -// const formatter = getValueFormatter(segment, charsPerSegment); -// const pattern = `[0-9]{${charsPerSegment[segment]}}`; - -// /** -// * Receives native input events, -// * determines whether the input value is valid and should change, -// * and fires a custom `InputSegmentChangeEvent`. -// */ -// 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 maxLength, 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) { -// // Stop propagation to prevent parent handlers from firing -// 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 ( -// -// ); -// }, -// ); - const InputSegmentWithRef = ( { segment, diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index 8ee336af6b..d254077b10 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -1,3 +1,5 @@ +// @ts-nocheck + import range from 'lodash/range'; import { charsPerSegment, defaultMax, defaultMin } from '../../../../constants'; diff --git a/packages/date-picker/src/shared/types/DateSegment.types.ts b/packages/date-picker/src/shared/types/DateSegment.types.ts index 1ee8cdf6c8..32c77236f7 100644 --- a/packages/date-picker/src/shared/types/DateSegment.types.ts +++ b/packages/date-picker/src/shared/types/DateSegment.types.ts @@ -13,3 +13,11 @@ export function isDateSegment(str: any): str is DateSegment { if (typeof str !== 'string') return false; return ['day', 'month', 'year'].includes(str); } + +export function isInputSegment( + str: any, + segmentObj: Record, +): str is keyof typeof segmentObj { + if (typeof str !== 'string') return false; + return Object.values(segmentObj).includes(str); +} diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts index c298bddd5a..cd8cebc6b9 100644 --- a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts +++ b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts @@ -6,26 +6,90 @@ 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; -} - +// interface GetRelativeSegmentContext { +// segment: HTMLInputElement | React.RefObject; +// formatParts: SharedDatePickerContextProps['formatParts']; +// segmentRefs: SegmentRefs; +// } + +// TODO: needs to be updated so it is generic. +// needs: /** * Given a direction, starting segment name & format * returns the segment name in the given direction */ -export const getRelativeSegment = ( +// 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; +// } +// }; + +export const getRelativeSegment = ( direction: RelativeDirection, { segment, formatParts, }: { - segment: DateSegment; - formatParts: SharedDatePickerContextProps['formatParts']; + segment: V; + formatParts?: Array; }, -): DateSegment | undefined => { +): V | undefined => { if ( isUndefined(direction) || isUndefined(segment) || @@ -35,9 +99,9 @@ export const getRelativeSegment = ( } // only the relevant segments, not separators - const formatSegments: Array = formatParts + const formatSegments: Array = formatParts .filter(part => part.type !== 'literal') - .map(part => part.type as DateSegment); + .map(part => part.type as V); /** The index of the reference segment relative to formatParts */ const currentSegmentIndex: number | undefined = @@ -82,9 +146,59 @@ export const getRelativeSegment = ( * Given a direction, staring segment, and segment refs, * returns the segment ref in the given direction */ -export const getRelativeSegmentRef = ( +// 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]; +// } +// } +// }; + +interface GetRelativeSegmentContext< + T extends Record>, +> { + segment: HTMLInputElement | React.RefObject; + formatParts: Array; + segmentRefs: T; +} + +export const getRelativeSegmentRef = < + T extends Record>, + V extends string = string, +>( direction: RelativeDirection, - { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, + { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, ): React.RefObject | undefined => { if ( isUndefined(direction) || @@ -96,18 +210,16 @@ export const getRelativeSegmentRef = ( } // only the relevant segments, not separators - const formatSegments: Array = formatParts + 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 - ); - }, - ); + .map(part => part.type as V); + + const currentSegmentName: V | undefined = formatSegments.find(segmentName => { + return ( + segmentRefs[segmentName] === segment || + segmentRefs[segmentName].current === segment + ); + }); if (currentSegmentName) { const relativeSegmentName = getRelativeSegment(direction, { diff --git a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts index 74e87d1932..7fea14c6aa 100644 --- a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts +++ b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts @@ -26,3 +26,40 @@ export const isExplicitSegmentValue = ( return value.length === charsPerSegment.year; } }; + +/** + * Configuration for determining if a segment value is explicit + */ +export type ExplicitSegmentRule = { + /** Maximum characters for this segment */ + maxChars: number; + /** Minimum numeric value that makes the input explicit (optional) */ + minExplicitValue?: number; +}; + +/** + * Factory function that creates a segment value validator + * @param segmentEnum - The segment enum/object to validate against + * @param rules - Rules for each segment type + * @returns A function that checks if a segment value is explicit + */ +export function createExplicitSegmentValidator< + T extends Record, +>(segmentEnum: T, rules: Record) { + return (segment: T[keyof T], value: string): boolean => { + if ( + !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) + ) + return false; + + const rule = rules[segment]; + if (!rule) return false; + + const isMaxLength = value.length === rule.maxChars; + const meetsMinValue = rule.minExplicitValue + ? Number(value) >= rule.minExplicitValue + : false; + + return isMaxLength || meetsMinValue; + }; +} From ebdde67e5c86f9fbde7195eed51b66369baa4d2a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 22 Oct 2025 16:20:47 -0400 Subject: [PATCH 06/56] refactor(date-picker): integrate InputBox into DateInputBox for improved segment management and type handling --- .../DateInput/DateInputBox/DateInputBox.tsx | 382 +++++++++-------- .../shared/components/InputBox/InputBox.tsx | 386 +++++++++--------- .../components/InputBox/InputBox.types.ts | 60 +-- .../getNewSegmentValueFromInputValue.spec.ts | 142 ++++++- .../shared/utils/getRelativeSegment/index.ts | 2 +- 5 files changed, 571 insertions(+), 401 deletions(-) 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 a58ef159aa..6fa3c6d15b 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -46,6 +46,7 @@ import { } from './DateInputBox.styles'; import { DateInputBoxProps } from './DateInputBox.types'; import { charsPerSegment } from '../../../constants'; +import { InputBox } from '../../InputBox/InputBox'; /** * Renders a styled date input with appropriate segment order & separator characters. @@ -82,14 +83,14 @@ export const DateInputBox = React.forwardRef( // TODO: MOVE to generic component /** Formats and sets the segment value */ - const getFormattedSegmentValue = ( - segmentName: DateSegment, - segmentValue: DateSegmentValue, - ): DateSegmentValue => { - const formatter = getValueFormatter(segmentName, charsPerSegment); - const formattedValue = formatter(segmentValue); - return formattedValue; - }; + // const getFormattedSegmentValue = ( + // segmentName: DateSegment, + // segmentValue: DateSegmentValue, + // ): DateSegmentValue => { + // const formatter = getValueFormatter(segmentName, charsPerSegment); + // const formattedValue = formatter(segmentValue); + // return formattedValue; + // }; // TODO: MOVE to generic component /** if the value is a `Date` the component is dirty */ @@ -138,178 +139,223 @@ export const DateInputBox = React.forwardRef( // TODO: MOVE to generic component /** 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 && - // TODO: consider making this a factory function since this will be different depending on the component. - 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); - // TODO: onInputChange callback here - }; + // 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 && + // // TODO: consider making this a factory function since this will be different depending on the component. + // 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); + // // TODO: onInputChange callback here + // }; // TODO: MOVE to generic component /** 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); - } - }; + // 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); + // } + // }; // TODO: MOVE to generic component /** Called on any keydown within the input element */ - const handleInputKeyDown: KeyboardEventHandler = e => { - const { target: _target, key } = e; - const target = _target as HTMLElement; - const isSegment = isElementInputSegment(target, segmentRefs); - - // 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: - case keyMap.Enter: - case keyMap.Escape: - case keyMap.Tab: - // Behavior handled by parent or menu - break; - } - - // call any handler that was passed in - onKeyDown?.(e); + // const handleInputKeyDown: KeyboardEventHandler = e => { + // const { target: _target, key } = e; + // const target = _target as HTMLElement; + // const isSegment = isElementInputSegment(target, segmentRefs); + + // // 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: + // case keyMap.Enter: + // case keyMap.Escape: + // case keyMap.Tab: + // // Behavior handled by parent or menu + // break; + // } + + // // call any handler that was passed in + // onKeyDown?.(e); + // }; + + // TODO: MOVE to constants + const segmentRules = { + [DateSegment.Day]: { + maxChars: charsPerSegment.day, + minExplicitValue: 4, + }, + [DateSegment.Month]: { + maxChars: charsPerSegment.month, + minExplicitValue: 2, + }, + [DateSegment.Year]: { + maxChars: charsPerSegment.year, + }, }; - // TODO: This will return the generic InputBox component - // We will pass in the formatParts, segmentRefs, onSegmentChange, onKeyDown, the segments and setSegment functions, and getMinSegmentValue and getMaxSegmentValue functions as props - return ( -
+ // {formatParts?.map((part, i) => { + // if (part.type === 'literal') { + // return ( + // + // {part.value} + // + // ); + // } else if (isDateSegment(part.type)) { + // return ( + // + // ); + // } + // })} + //
+ + ( + + )} {...rest} - > - {formatParts?.map((part, i) => { - if (part.type === 'literal') { - return ( - - {part.value} - - ); - } else if (isDateSegment(part.type)) { - return ( - - ); - } - })} -
+ > ); }, ); DateInputBox.displayName = 'DateInputBox'; + +// return ( +// // contains keyboard management and auto-formatting +// // contains the input and the label, will be cloned for each segment so it gets the correct props +// +// ) diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.tsx b/packages/date-picker/src/shared/components/InputBox/InputBox.tsx index 70d73b06e3..8ae9a821c4 100644 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.tsx +++ b/packages/date-picker/src/shared/components/InputBox/InputBox.tsx @@ -1,4 +1,8 @@ -import React, { FocusEventHandler, KeyboardEventHandler } from 'react'; +import React, { + FocusEventHandler, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; import { cx } from '@leafygreen-ui/emotion'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; @@ -16,13 +20,13 @@ import { separatorLiteralDisabledStyles, separatorLiteralStyles, } from './InputBox.styles'; -import { InputBoxProps } from './InputBox.types'; +import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; import { createExplicitSegmentValidator } from '../../utils/isExplicitSegmentValue'; -export function isInputSegment( +export function isInputSegment>( str: any, - segmentObj: Record, -): str is keyof typeof segmentObj { + segmentObj: T, +): str is T[keyof T] { if (typeof str !== 'string') return false; return Object.values(segmentObj).includes(str); } @@ -45,207 +49,207 @@ export const isElementInputSegment = < * * @internal */ -export const InputBox = React.forwardRef( - ( - { - value, - setValue, - className, - labelledBy, - segmentRefs, - onSegmentChange, - onKeyDown, - segments, - setSegment, - disabled, - charsPerSegment, - formatParts, - children, - segmentObj, - segmentRules, - ...rest - }: InputBoxProps, - fwdRef, - ) => { - const { theme } = useDarkMode(); - - const isExplicitSegmentValue = createExplicitSegmentValidator( - segmentObj, - segmentRules, - ); - - /** Formats and sets the segment value */ - const getFormattedSegmentValue = ( - segmentName: (typeof segmentObj)[keyof typeof segmentObj], - segmentValue: string, - ): string => { - const formatter = getValueFormatter(segmentName, charsPerSegment); - const formattedValue = formatter(segmentValue); - return formattedValue; - }; - - /** Fired when an individual segment value changes */ - const handleSegmentInputChange: InputSegmentChangeEventHandler = - 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); - // TODO: onInputChange callback here - }; - - /** Triggered when a segment is blurred */ - const handleSegmentInputBlur: FocusEventHandler = e => { - const segmentName = e.target.getAttribute('id'); - const segmentValue = e.target.value; - - if (isInputSegment(segmentName, segmentObj)) { - const formattedValue = getFormattedSegmentValue( - segmentName, - segmentValue, - ); - setSegment(segmentName, formattedValue); +export const InputBoxWithRef = >( + { + className, + labelledBy, + segmentRefs, + onSegmentChange, + onKeyDown, + segments, + setSegment, + disabled, + charsPerSegment, + formatParts, + segmentObj, + segmentRules, + renderSegment, + ...rest + }: InputBoxProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + + const isExplicitSegmentValue = createExplicitSegmentValidator( + segmentObj, + segmentRules, + ); + + /** Formats and sets the segment value */ + const getFormattedSegmentValue = ( + segmentName: (typeof segmentObj)[keyof typeof segmentObj], + segmentValue: string, + ): string => { + const formatter = getValueFormatter(segmentName, charsPerSegment); + const formattedValue = formatter(segmentValue); + return formattedValue; + }; + + /** Fired when an individual segment value changes */ + const handleSegmentInputChange: InputSegmentChangeEventHandler< + T[keyof T], + string + > = 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); + // TODO: onInputChange callback here + }; + + /** Triggered when a segment is blurred */ + const handleSegmentInputBlur: FocusEventHandler = e => { + const segmentName = e.target.getAttribute('id'); + const segmentValue = e.target.value; + + if (isInputSegment(segmentName, segmentObj)) { + const formattedValue = getFormattedSegmentValue( + segmentName, + segmentValue, + ); + setSegment(segmentName, formattedValue); + } + }; + + /** Called on any keydown within the input element */ + const handleInputKeyDown: KeyboardEventHandler = e => { + const { target: _target, key } = e; + const target = _target as HTMLElement; + const isSegment = isElementInputSegment(target, segmentRefs); + + // 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; } - }; - - /** Called on any keydown within the input element */ - const handleInputKeyDown: KeyboardEventHandler = e => { - const { target: _target, key } = e; - const target = _target as HTMLElement; - const isSegment = isElementInputSegment(target, segmentRefs); - // if target is not a segment, do nothing - if (!isSegment) return; + 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; + } - const isSegmentEmpty = !target.value; + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + // increment/decrement logic implemented by DateInputSegment + break; + } - switch (key) { - case keyMap.ArrowLeft: { - // Without this, the input ignores `.select()` + case keyMap.Backspace: { + if (isSegmentEmpty) { + // prevent the backspace in the previous segment 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', { + const segmentToFocus = getRelativeSegmentRef('prev', { 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; } + 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: + case keyMap.Enter: + case keyMap.Escape: + case keyMap.Tab: + // Behavior handled by parent or menu + break; + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + + return ( +
+ {formatParts?.map((part, i) => { + if (part.type === 'literal') { + return ( + + {part.value} + + ); + } else if (isInputSegment(part.type, segmentObj)) { + const segmentProps = { + onChange: handleSegmentInputChange, + onBlur: handleSegmentInputBlur, + partType: part.type, + }; + return renderSegment(segmentProps); } + })} +
+ ); +}; - case keyMap.Space: - case keyMap.Enter: - case keyMap.Escape: - case keyMap.Tab: - // Behavior handled by parent or menu - break; - } - - // call any handler that was passed in - onKeyDown?.(e); - }; - - // TODO: consider render prop - const renderedChildren = React.cloneElement(children, { - ...children.props, - onChange: handleSegmentInputChange, - onBlur: handleSegmentInputBlur, - }); - - return ( -
- {formatParts?.map((part, i) => { - if (part.type === 'literal') { - return ( - - {part.value} - - ); - } else if (isInputSegment(part.type, segmentObj)) { - return renderedChildren; - } - })} -
- ); - }, -); +export const InputBox = React.forwardRef( + InputBoxWithRef, +) as InputBoxComponentType; InputBox.displayName = 'InputBox'; diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts index aa604b7fec..7b3e45a771 100644 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts +++ b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { FocusEventHandler, ForwardedRef, ReactElement } from 'react'; import { DateType } from '@leafygreen-ui/date-utils'; @@ -6,6 +6,12 @@ import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.typ import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { ExplicitSegmentRule } from '../../utils/isExplicitSegmentValue'; +export interface RenderSegmentProps { + onChange: InputSegmentChangeEventHandler; + onBlur: FocusEventHandler; + partType: T; +} + export interface InputChangeEvent { value: DateType; segments: Record; @@ -15,22 +21,12 @@ export type InputChangeEventHandler = ( changeEvent: InputChangeEvent, ) => void; -export interface InputBoxProps - extends Omit, 'onChange' | 'children'> { - /** - * Date value passed into the component - */ - value?: DateType; - - /** - * Value setter callback. - */ - setValue?: InputChangeEventHandler; - +export interface InputBoxProps> + extends Omit, 'onChange' | 'children'> { /** * Callback fired when any segment changes, but not necessarily a full value */ - onSegmentChange?: InputSegmentChangeEventHandler; + onSegmentChange?: InputSegmentChangeEventHandler; /** * id of the labelling element @@ -38,28 +34,36 @@ export interface InputBoxProps labelledBy?: string; /** Refs */ - segmentRefs: Record>>; + // instead of T, this should be a key from the Record + segmentRefs: Record< + T[keyof T], + ReturnType> + >; /** Segment object */ - segmentObj: Readonly>; - - /** Default minimum value */ - defaultMin: Record; - - /** Default maximum value */ - defaultMax: Record; + // { Day: 'day', Month: 'month', Year: 'year' } + segmentObj: T; - segments: Record; + // This should be a Record where the key is the value of the segmentObj and the value is a string + segments: Record; - setSegment: (segment: T, value: string) => void; + setSegment: (segment: T[keyof T], value: string) => void; - formatParts: Intl.DateTimeFormatPart[]; + formatParts?: Intl.DateTimeFormatPart[]; - charsPerSegment: Record; + charsPerSegment: Record; disabled: boolean; - children: React.ReactElement; + segmentRules: Record; + + renderSegment: (props: RenderSegmentProps) => React.ReactElement; +} - segmentRules: Record; +export interface InputBoxComponentType { + >( + props: InputBoxProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; } diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index d254077b10..132c4363ec 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -1,35 +1,87 @@ -// @ts-nocheck - import range from 'lodash/range'; -import { charsPerSegment, defaultMax, defaultMin } from '../../../../constants'; -import { DateSegment } from '../../../../types'; import { getValueFormatter } from '../../../../utils'; import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; +const charsPerSegment = { + day: 2, + month: 2, + year: 4, +}; + +const defaultMin = { + day: 1, + month: 1, + year: 1970, +}; + +const defaultMax = { + day: 31, + month: 12, + year: new Date().getFullYear(), +}; + +const segmentObj = { + day: 'day', + month: 'month', + year: 'year', +}; + describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromInputValue', () => { describe.each(['day', 'month', 'year'])('For segment %p', _segment => { - const segment: DateSegment = _segment as DateSegment; + const segment = _segment as 'day' | 'month' | 'year'; describe('when current value is empty', () => { test.each(range(10))('accepts %i character as input', i => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `${i}`); + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(`${i}`); }); const validValues = [defaultMin[segment], defaultMax[segment]]; test.each(validValues)(`accepts value "%i" as input`, v => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `${v}`); + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${v}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(`${v}`); }); test('does not accept non-numeric characters', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `b`); + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `b`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(''); }); test('does not accept input with a period/decimal', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '', `2.`); + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `2.`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(''); }); }); @@ -37,7 +89,15 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI describe('when current value is 0', () => { if (segment !== 'year') { test('rejects additional 0 as input', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '0', `00`); + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `00`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(`0`); }); } @@ -46,18 +106,38 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, '0', `0${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(`0${i}`); }); test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '0', ``); + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + ``, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(``); }); }); describe('when current value is 1', () => { test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '1', ``); + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + ``, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(``); }); @@ -67,6 +147,10 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, '1', `1${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(`1${i}`); }); @@ -76,6 +160,10 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, '1', `1${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(`${i}`); }); @@ -86,6 +174,10 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, '1', `1${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(`1${i}`); }); @@ -94,7 +186,15 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI describe('when current value is 3', () => { test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue(segment, '3', ``); + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + ``, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); expect(newValue).toEqual(``); }); @@ -105,6 +205,10 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, '3', `3${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(`3${i}`); }); @@ -114,6 +218,10 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, '3', `3${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(`${i}`); }); @@ -128,6 +236,10 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, '3', `3${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(`${i}`); }); @@ -152,6 +264,10 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI segment, val, `${val}1`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, ); expect(newValue).toEqual(val); }, diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts index cd8cebc6b9..4eb4d01660 100644 --- a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts +++ b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts @@ -189,7 +189,7 @@ interface GetRelativeSegmentContext< T extends Record>, > { segment: HTMLInputElement | React.RefObject; - formatParts: Array; + formatParts?: Array; segmentRefs: T; } From e19c9267e5423bb8f44d82edda8d500758fd447c Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 22 Oct 2025 16:32:22 -0400 Subject: [PATCH 07/56] refactor(date-picker): clean up DateInputBox and enhance InputBox types for better segment management --- .../DateInput/DateInputBox/DateInputBox.tsx | 156 ------------------ .../DateInputSegment/DateInputSegment.tsx | 2 +- .../components/InputBox/InputBox.types.ts | 43 ++++- .../InputSegment/InputSegment.types.ts | 15 +- 4 files changed, 44 insertions(+), 172 deletions(-) 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 6fa3c6d15b..686a28e7e0 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -77,22 +77,7 @@ export const DateInputBox = React.forwardRef( ) => { const { isDirty, formatParts, disabled, min, max, setIsDirty } = useSharedDatePickerContext(); - const { theme } = useDarkMode(); - const containerRef = useForwardedRef(fwdRef, null); - - // TODO: MOVE to generic component - /** Formats and sets the segment value */ - // const getFormattedSegmentValue = ( - // segmentName: DateSegment, - // segmentValue: DateSegmentValue, - // ): DateSegmentValue => { - // const formatter = getValueFormatter(segmentName, charsPerSegment); - // const formattedValue = formatter(segmentValue); - // return formattedValue; - // }; - - // TODO: MOVE to generic component /** if the value is a `Date` the component is dirty */ useEffect(() => { if (isDateObject(value) && !isDirty) { @@ -100,7 +85,6 @@ export const DateInputBox = React.forwardRef( } }, [isDirty, setIsDirty, value]); - // TODO: keep. This is specific to DatePicker /** * When a segment is updated, * trigger a `change` event for the segment, and @@ -130,146 +114,12 @@ export const DateInputBox = React.forwardRef( } }; - // TODO: keep. This is specific to DatePicker /** State Management for segments using a useReducer instead of useState */ /** Keep track of each date segment */ const { segments, setSegment } = useDateSegments(value, { onUpdate: handleSegmentUpdate, }); - // TODO: MOVE to generic component - /** 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 && - // // TODO: consider making this a factory function since this will be different depending on the component. - // 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); - // // TODO: onInputChange callback here - // }; - - // TODO: MOVE to generic component - /** 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); - // } - // }; - - // TODO: MOVE to generic component - /** Called on any keydown within the input element */ - // const handleInputKeyDown: KeyboardEventHandler = e => { - // const { target: _target, key } = e; - // const target = _target as HTMLElement; - // const isSegment = isElementInputSegment(target, segmentRefs); - - // // 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: - // case keyMap.Enter: - // case keyMap.Escape: - // case keyMap.Tab: - // // Behavior handled by parent or menu - // break; - // } - - // // call any handler that was passed in - // onKeyDown?.(e); - // }; - // TODO: MOVE to constants const segmentRules = { [DateSegment.Day]: { @@ -353,9 +203,3 @@ export const DateInputBox = React.forwardRef( ); DateInputBox.displayName = 'DateInputBox'; - -// return ( -// // contains keyboard management and auto-formatting -// // contains the input and the label, will be cloned for each segment so it gets the correct props -// -// ) 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 7e98eb6266..9d64406426 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -214,7 +214,7 @@ export const DateInputSegment = React.forwardRef< // ); return ( - + > */ labelledBy?: string; - /** Refs */ - // instead of T, this should be a key from the Record + /** + * Segment Refs + * e.g. { day: ref, month: ref, year: ref } + */ segmentRefs: Record< T[keyof T], ReturnType> >; - /** Segment object */ - // { Day: 'day', Month: 'month', Year: 'year' } + /** + * Segment object + * e.g. { Day: 'day', Month: 'month', Year: 'year' } + */ segmentObj: T; - // This should be a Record where the key is the value of the segmentObj and the value is a string + /** + * An object containing the values of the segments + * e.g. { day: '1', month: '2', year: '2025' } + */ segments: Record; + /** + * A function that sets the value of a segment + * e.g. (segment: 'day', value: '1') => void; + */ setSegment: (segment: T[keyof T], value: string) => void; + /** + * The format parts of the date + */ formatParts?: Intl.DateTimeFormatPart[]; + /** + * The number of characters per segment + * e.g. { day: 2, month: 2, year: 4 } + */ charsPerSegment: Record; + /** + * Whether the input box is disabled + */ disabled: boolean; + /** + * The rules for the segments + * e.g. { day: { maxChars: 2, minExplicitValue: 1 }, month: { maxChars: 2, minExplicitValue: 1 }, year: { maxChars: 4, minExplicitValue: 1970 } } + */ segmentRules: Record; + /** + * A function that renders a segment + * e.g. (props: { onChange: (event: React.ChangeEvent) => void, onBlur: (event: React.FocusEvent) => void, partType: 'day' | 'month' | 'year' }) => React.ReactElement; + */ renderSegment: (props: RenderSegmentProps) => React.ReactElement; } +/** + * The component type for the InputBox + * TODO: add why we need this + */ export interface InputBoxComponentType { >( props: InputBoxProps, diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts index dbd9dd6235..d161fe5cbb 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts @@ -3,10 +3,7 @@ import React, { ForwardedRef, ReactElement } from 'react'; import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; -export interface InputSegmentChangeEvent< - T extends string = string, - V extends string = string, -> { +export interface InputSegmentChangeEvent { segment: T; value: V; meta?: { @@ -16,14 +13,12 @@ export interface InputSegmentChangeEvent< } export type InputSegmentChangeEventHandler< - T extends string = string, - V extends string = string, + T extends string, + V extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; -export interface InputSegmentProps< - T extends string = string, - V extends string = string, -> extends Omit, 'onChange' | 'size'> { +export interface InputSegmentProps + extends Omit, 'onChange' | 'size'> { /** Which segment this input represents */ segment: T; From edce7ccffcd7905ae194a8cb8e85b50bbed30c10 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 22 Oct 2025 17:23:54 -0400 Subject: [PATCH 08/56] refactor(date-picker): enhance DatePicker components with improved type handling and segment management --- .../DatePickerContent/DatePickerContent.tsx | 1 + .../DateInput/DateInputBox/DateInputBox.tsx | 84 +-------- .../DateInputSegment/DateInputSegment.tsx | 168 +----------------- .../shared/components/InputBox/InputBox.tsx | 4 +- .../components/InputBox/InputBox.types.ts | 2 +- .../components/InputSegment/InputSegment.tsx | 2 +- .../InputSegment/InputSegment.types.ts | 84 +++++++-- packages/date-picker/src/shared/constants.ts | 15 ++ 8 files changed, 96 insertions(+), 264 deletions(-) diff --git a/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx index e4b2b77b21..6616bcb731 100644 --- a/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx @@ -67,6 +67,7 @@ export const DatePickerContent = forwardRef< */ const handleDatePickerKeyDown: KeyboardEventHandler = e => { const { key } = e; + console.log('😈handleDatePickerKeyDown', { key }); switch (key) { case keyMap.Escape: 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 686a28e7e0..7c74748b03 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -1,8 +1,4 @@ -import React, { - FocusEventHandler, - KeyboardEventHandler, - useEffect, -} from 'react'; +import React, { useEffect } from 'react'; import isEqual from 'lodash/isEqual'; import isNull from 'lodash/isNull'; @@ -11,41 +7,21 @@ 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 { useSharedDatePickerContext } from '../../../context'; import { useDateSegments } from '../../../hooks'; -import { - DateSegment, - DateSegmentsState, - DateSegmentValue, - isDateSegment, -} from '../../../types'; +import { DateSegment, DateSegmentsState } from '../../../types'; import { getMaxSegmentValue, getMinSegmentValue, - getRelativeSegment, - getValueFormatter, isEverySegmentFilled, isEverySegmentValueExplicit, - isExplicitSegmentValue, newDateFromSegments, - getRelativeSegmentRef, - isElementInputSegment, } from '../../../utils'; import { DateInputSegment } from '../DateInputSegment'; -import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; -import { - segmentPartsWrapperStyles, - separatorLiteralDisabledStyles, - separatorLiteralStyles, -} from './DateInputBox.styles'; import { DateInputBoxProps } from './DateInputBox.types'; -import { charsPerSegment } from '../../../constants'; +import { charsPerSegment, dateSegmentRules } from '../../../constants'; import { InputBox } from '../../InputBox/InputBox'; /** @@ -120,60 +96,10 @@ export const DateInputBox = React.forwardRef( onUpdate: handleSegmentUpdate, }); - // TODO: MOVE to constants - const segmentRules = { - [DateSegment.Day]: { - maxChars: charsPerSegment.day, - minExplicitValue: 4, - }, - [DateSegment.Month]: { - maxChars: charsPerSegment.month, - minExplicitValue: 2, - }, - [DateSegment.Year]: { - maxChars: charsPerSegment.year, - }, - }; - return ( - //
- // {formatParts?.map((part, i) => { - // if (part.type === 'literal') { - // return ( - // - // {part.value} - // - // ); - // } else if (isDateSegment(part.type)) { - // return ( - // - // ); - // } - // })} - //
- ( segments={segments} setSegment={setSegment} disabled={disabled} - segmentRules={segmentRules} + segmentRules={dateSegmentRules} onSegmentChange={onSegmentChange} renderSegment={({ onChange, onBlur, partType }) => ( , - ) => { - onChange(inputSegmentChangeEvent); - }; - - // /** - // * 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, - // // TODO: pass pattern here - // ); - - // 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 ( - // - // ); + const autoComplete = getAutoComplete(autoCompleteProp, segment); return ( >( setSegment(segmentName, segmentValue); onSegmentChange?.(segmentChangeEvent); - // TODO: onInputChange callback here }; /** Triggered when a segment is blurred */ diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts index bfb05d3b16..866ab13b03 100644 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts +++ b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts @@ -21,7 +21,7 @@ export type InputChangeEventHandler = ( changeEvent: InputChangeEvent, ) => void; -export interface InputBoxProps> +export interface InputBoxProps> extends Omit, 'onChange' | 'children'> { /** * Callback fired when any segment changes, but not necessarily a full value diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx index 13ca2da92b..34267a66bd 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx @@ -33,7 +33,7 @@ import { * * @internal */ -const InputSegmentWithRef = ( +const InputSegmentWithRef = , V extends string>( { segment, value, diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts index d161fe5cbb..12af98dfc4 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts @@ -12,44 +12,94 @@ export interface InputSegmentChangeEvent { }; } +/** + * The type for the onChange handler + */ export type InputSegmentChangeEventHandler< T extends string, V extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; -export interface InputSegmentProps - extends Omit, 'onChange' | 'size'> { - /** Which segment this input represents */ - segment: T; +export interface InputSegmentProps< + T extends Record, + V extends string, +> extends Omit, 'onChange' | 'size'> { + /** + * Which segment this input represents + * e.g. 'day' + * e.g. 'month' + * e.g. 'year' + */ + segment: T[keyof T]; - /** The value of the segment */ + /** + * The value of the segment + * e.g. '1' + * e.g. '2' + * e.g. '2025' + */ value: V; - /** Custom onChange handler */ - onChange: InputSegmentChangeEventHandler; + /** + * Custom onChange handler + */ + onChange: InputSegmentChangeEventHandler; - charsPerSegment: Record; + /** + * The number of characters per segment + * e.g. { day: 2, month: 2, year: 4 } + */ + charsPerSegment: Record; - /** Minimum value. */ + /** + * Minimum value. + * e.g. 1 + * e.g. 1 + * e.g. 1970 + */ min: number; - /** Maximum value. */ + /** + * Maximum value. + * e.g. 31 + * e.g. 12 + * e.g. 2038 + */ max: number; - /** Segment object */ - segmentObj: Readonly>; + /** + * Segment object + * e.g. { Day: 'day', Month: 'month', Year: 'year' } + */ + segmentObj: T; - /** Default minimum value */ - defaultMin: Record; + /** + * Default minimum value + * e.g. { day: 1, month: 1, year: 1970 } + */ + defaultMin: Record; - /** Default maximum value */ - defaultMax: Record; + /** + * Default maximum value + * e.g. { day: 31, month: 12, year: 2038 } + */ + defaultMax: Record; + /** + * Size of the segment + * e.g. Size.Default + * e.g. Size.Small + * e.g. Size.Large + */ size: Size; } +/** + * The component type for the InputSegment + * TODO: add why we need this + */ export interface InputSegmentComponentType { - ( + , V extends string>( props: InputSegmentProps, ref: ForwardedRef, ): ReactElement | null; diff --git a/packages/date-picker/src/shared/constants.ts b/packages/date-picker/src/shared/constants.ts index 3efdaaa8cc..36e27ab674 100644 --- a/packages/date-picker/src/shared/constants.ts +++ b/packages/date-picker/src/shared/constants.ts @@ -1,6 +1,7 @@ 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, @@ -69,3 +70,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, + }, +}; From ad12567ada0444e516990d2e4f1c88ba5672f29e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 23 Oct 2025 11:17:44 -0400 Subject: [PATCH 09/56] refactor(date-picker): enhance InputSegment and DateInputSegment with new props for step handling and rollover management --- .../DateInputSegment/DateInputSegment.tsx | 1 + .../components/InputSegment/InputSegment.tsx | 4 +++ .../InputSegment/InputSegment.types.ts | 23 +++++++++++++- .../getNewSegmentValueFromArrowKeyPress.ts | 31 ++++++++++++++----- .../getNewSegmentValueFromInputValue.ts | 2 ++ .../shared/utils/getRelativeSegment/index.ts | 9 ++---- .../shared/utils/getValueFormatter/index.ts | 10 +++++- .../utils/isExplicitSegmentValue/index.ts | 27 +++++----------- .../src/shared/utils/isValidSegment/index.ts | 8 ++--- 9 files changed, 73 insertions(+), 42 deletions(-) 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 3982b01d2b..666efd8b32 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -75,6 +75,7 @@ export const DateInputSegment = React.forwardRef< defaultMin={defaultMin} defaultMax={defaultMax} segmentObj={DateSegment} + shouldNotRollover={DateSegment.Year} {...rest} /> ); diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx index 34267a66bd..28e50cdb43 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx @@ -49,6 +49,8 @@ const InputSegmentWithRef = , V extends string>( segmentObj, defaultMin, defaultMax, + step = 1, + shouldNotRollover, ...rest }: InputSegmentProps, fwdRef: ForwardedRef, @@ -117,6 +119,8 @@ const InputSegmentWithRef = , V extends string>( min, max, segment, + step, + shouldNotRollover, }); const valueString = formatter(newValue); diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts index 12af98dfc4..bf2dd3624f 100644 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts +++ b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts @@ -23,7 +23,10 @@ export type InputSegmentChangeEventHandler< export interface InputSegmentProps< T extends Record, V extends string, -> extends Omit, 'onChange' | 'size'> { +> extends Omit< + React.ComponentPropsWithRef<'input'>, + 'onChange' | 'size' | 'step' + > { /** * Which segment this input represents * e.g. 'day' @@ -92,6 +95,24 @@ export interface InputSegmentProps< * e.g. Size.Large */ size: Size; + + /** + * The step value for the arrow keys + * e.g. 1 + * e.g. { day: 1, month: 1, year: 1 } + * + * @default 1 + */ + step?: number | Partial>; + + /** + * The segments that should not rollover + * e.g. 'year' + * e.g. ['year', 'month'] + * + * @default undefined + */ + shouldNotRollover?: T[keyof T] | Array; } /** diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts index 5a743d51fb..21c9b153bd 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -1,11 +1,16 @@ import { keyMap, rollover } from '@leafygreen-ui/lib'; -interface DateSegmentKeypressContext { +interface GetNewSegmentValueFromArrowKeyPress< + T extends string, + V extends string, +> { value: V; key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; segment: T; min: number; max: number; + step?: number | Partial>; + shouldNotRollover?: T | Array; } /** @@ -20,18 +25,30 @@ export const getNewSegmentValueFromArrowKeyPress = < segment, min, max, -}: DateSegmentKeypressContext): number => { - const valueDiff = key === keyMap.ArrowUp ? 1 : -1; + shouldNotRollover, + step = 1, +}: GetNewSegmentValueFromArrowKeyPress): number => { + const stepValue = typeof step === 'number' ? step : step[segment] ?? 1; + + const valueDiff = key === keyMap.ArrowUp ? stepValue : -stepValue; const defaultVal = key === keyMap.ArrowUp ? min : max; const incrementedValue: number = value ? Number(value) + valueDiff : defaultVal; - const newValue = - segment === 'year' - ? incrementedValue - : rollover(incrementedValue, min, max); + let shouldSkipRollover = false; + if (shouldNotRollover !== undefined) { + if (typeof shouldNotRollover === 'string') { + shouldSkipRollover = segment === shouldNotRollover; + } else if (Array.isArray(shouldNotRollover)) { + shouldSkipRollover = shouldNotRollover.includes(segment); + } + } + + const newValue = shouldSkipRollover + ? incrementedValue + : rollover(incrementedValue, min, max); return newValue; }; diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 207fa0f575..a240603117 100644 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -3,6 +3,8 @@ import last from 'lodash/last'; import { truncateStart } from '@leafygreen-ui/lib'; import { isValidValueForSegment } from '../../../../utils'; +// TODO: MOVE TO the new input box component + /** * Calculates the new value for the segment given an incoming change. * diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts index 4eb4d01660..c11f1611ea 100644 --- a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts +++ b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts @@ -1,10 +1,6 @@ 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; @@ -12,8 +8,7 @@ type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; // segmentRefs: SegmentRefs; // } -// TODO: needs to be updated so it is generic. -// needs: +// TODO: MOVE TO the new input box component /** * Given a direction, starting segment name & format * returns the segment name in the given direction @@ -195,7 +190,7 @@ interface GetRelativeSegmentContext< export const getRelativeSegmentRef = < T extends Record>, - V extends string = string, + V extends string, >( direction: RelativeDirection, { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, diff --git a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts index dbe7b575a0..11ae0ac68a 100644 --- a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts +++ b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts @@ -2,8 +2,16 @@ import padStart from 'lodash/padStart'; import { isZeroLike } from '@leafygreen-ui/lib'; +// TODO: MOVE TO the new input box component + /** - * @returns a value formatter function for the provided date segment + * If the value is any form of zero, we set it to an empty string + * otherwise, pad the string with 0s, or trim it to n chars + * + * @param segment - the segment to format + * @param charsPerSegment - the number of characters per segment + * @param val - the value to format + * @returns a value formatter function for the provided segment */ export const getValueFormatter = (segment: T, charsPerSegment: Record) => diff --git a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts index 7fea14c6aa..61db85df0b 100644 --- a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts +++ b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts @@ -1,5 +1,5 @@ -import { charsPerSegment } from '../../constants'; -import { DateSegment, DateSegmentValue } from '../../types'; +import { dateSegmentRules } from '../../constants'; +import { DateSegment } from '../../types'; import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; /** @@ -8,25 +8,12 @@ import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; * Explicit: Day = 5, 02 * Ambiguous: Day = 2 (could be 20-29) */ -export const isExplicitSegmentValue = ( - segment: DateSegment, - value: DateSegmentValue, -): boolean => { - if (!(isValidSegmentValue(value) && isValidSegmentName(DateSegment, 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; - } -}; +export const isExplicitSegmentValue = createExplicitSegmentValidator( + DateSegment, + dateSegmentRules, +); +// TODO: MOVE TO the new input box component /** * Configuration for determining if a segment value is explicit */ diff --git a/packages/date-picker/src/shared/utils/isValidSegment/index.ts b/packages/date-picker/src/shared/utils/isValidSegment/index.ts index 0c6be85d83..28a061fb32 100644 --- a/packages/date-picker/src/shared/utils/isValidSegment/index.ts +++ b/packages/date-picker/src/shared/utils/isValidSegment/index.ts @@ -1,7 +1,6 @@ import isUndefined from 'lodash/isUndefined'; -import { DateSegment, DateSegmentValue } from '../../types'; - +// TODO: MOVE TO the new input box component /** * Returns whether a given value is a valid segment value */ @@ -23,9 +22,6 @@ export const isValidSegmentValue = (segment?: T): segment is T => // ); // }; -// 1. Define a type helper for the segment object structure -type SegmentObject = Readonly>; - /** * A generic type predicate function that checks if a given string is one * of the values in the provided segment object. @@ -34,7 +30,7 @@ type SegmentObject = Readonly>; * @param name The string to validate * @returns A boolean and a type predicate (name is T[keyof T]) */ -export const isValidSegmentName = ( +export const isValidSegmentName = >>( segmentObj: T, name?: string, ): name is T[keyof T] => { From 6be0c010d12ce69a0b4c12b05b590a1797bce5e6 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 23 Oct 2025 13:52:49 -0400 Subject: [PATCH 10/56] refactor(input-box): move utils into input-box --- .../utils/getRelativeSegment/getRelativeSegment.spec.tsx | 0 .../src/InputBox}/utils/getRelativeSegment/index.ts | 0 .../src/InputBox}/utils/getValueFormatter/index.ts | 0 .../src/InputBox}/utils/getValueFormatter/valueFormatter.spec.ts | 0 .../src/InputBox}/utils/isValidSegment/index.ts | 0 .../src/InputBox}/utils/isValidSegment/isValidSegment.spec.ts | 0 .../getNewSegmentValueFromArrowKeyPress.ts | 0 .../getNewSegmentValueFromInputValue.spec.ts | 0 .../getNewSegmentValueFromInputValue.ts | 0 .../components => input-box/src}/InputSegment/utils/index.ts | 0 .../src/InputSegment}/utils/isElementInputSegment/index.ts | 0 .../src}/utils/isValidValueForSegment/index.ts | 0 .../utils/isValidValueForSegment/isValidValueForSegment.spec.ts | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename packages/{date-picker/src/shared => input-box/src/InputBox}/utils/getRelativeSegment/getRelativeSegment.spec.tsx (100%) rename packages/{date-picker/src/shared => input-box/src/InputBox}/utils/getRelativeSegment/index.ts (100%) rename packages/{date-picker/src/shared => input-box/src/InputBox}/utils/getValueFormatter/index.ts (100%) rename packages/{date-picker/src/shared => input-box/src/InputBox}/utils/getValueFormatter/valueFormatter.spec.ts (100%) rename packages/{date-picker/src/shared => input-box/src/InputBox}/utils/isValidSegment/index.ts (100%) rename packages/{date-picker/src/shared => input-box/src/InputBox}/utils/isValidSegment/isValidSegment.spec.ts (100%) rename packages/{date-picker/src/shared/components => input-box/src}/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts (100%) rename packages/{date-picker/src/shared/components => input-box/src}/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts (100%) rename packages/{date-picker/src/shared/components => input-box/src}/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts (100%) rename packages/{date-picker/src/shared/components => input-box/src}/InputSegment/utils/index.ts (100%) rename packages/{date-picker/src/shared => input-box/src/InputSegment}/utils/isElementInputSegment/index.ts (100%) rename packages/{date-picker/src/shared => input-box/src}/utils/isValidValueForSegment/index.ts (100%) rename packages/{date-picker/src/shared => input-box/src}/utils/isValidValueForSegment/isValidValueForSegment.spec.ts (100%) diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx b/packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx similarity index 100% rename from packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx rename to packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts b/packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts similarity index 100% rename from packages/date-picker/src/shared/utils/getRelativeSegment/index.ts rename to packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts diff --git a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts b/packages/input-box/src/InputBox/utils/getValueFormatter/index.ts similarity index 100% rename from packages/date-picker/src/shared/utils/getValueFormatter/index.ts rename to packages/input-box/src/InputBox/utils/getValueFormatter/index.ts diff --git a/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts similarity index 100% rename from packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts rename to packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts diff --git a/packages/date-picker/src/shared/utils/isValidSegment/index.ts b/packages/input-box/src/InputBox/utils/isValidSegment/index.ts similarity index 100% rename from packages/date-picker/src/shared/utils/isValidSegment/index.ts rename to packages/input-box/src/InputBox/utils/isValidSegment/index.ts diff --git a/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts similarity index 100% rename from packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts rename to packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts similarity index 100% rename from packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts rename to packages/input-box/src/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts similarity index 100% rename from packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts rename to packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts similarity index 100% rename from packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts rename to packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/index.ts b/packages/input-box/src/InputSegment/utils/index.ts similarity index 100% rename from packages/date-picker/src/shared/components/InputSegment/utils/index.ts rename to packages/input-box/src/InputSegment/utils/index.ts diff --git a/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts b/packages/input-box/src/InputSegment/utils/isElementInputSegment/index.ts similarity index 100% rename from packages/date-picker/src/shared/utils/isElementInputSegment/index.ts rename to packages/input-box/src/InputSegment/utils/isElementInputSegment/index.ts diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts b/packages/input-box/src/utils/isValidValueForSegment/index.ts similarity index 100% rename from packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts rename to packages/input-box/src/utils/isValidValueForSegment/index.ts diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts similarity index 100% rename from packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts rename to packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts From 97d84d458808674f77a63c378d02735ec0280cd3 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 23 Oct 2025 13:53:24 -0400 Subject: [PATCH 11/56] refactor(input-box): move utils into input-box --- .changeset/input-box.md | 5 + .../getNewSegmentValueFromArrowKeyPress.ts | 54 ++++ .../getNewSegmentValueFromInputValue.spec.ts | 277 ++++++++++++++++++ .../getNewSegmentValueFromInputValue.ts | 65 ++++ .../components/InputSegment/utils/index.ts | 2 + .../src/shared/types/DateSegment.types.ts | 8 - .../shared/utils/getRelativeSegment/index.ts | 229 +++++++++++++++ .../shared/utils/getValueFormatter/index.ts | 35 +++ .../utils/isElementInputSegment/index.ts | 34 +++ .../src/shared/utils/isValidSegment/index.ts | 43 +++ .../utils/isValidValueForSegment/index.ts | 33 +++ packages/input-box/README.md | 26 ++ packages/input-box/package.json | 50 ++++ packages/input-box/src/InputBox.stories.tsx | 17 ++ .../input-box/src/InputBox/InputBox.spec.tsx | 11 + .../input-box/src/InputBox/InputBox.styles.ts | 22 ++ packages/input-box/src/InputBox/InputBox.tsx | 240 +++++++++++++++ .../input-box/src/InputBox/InputBox.types.ts | 102 +++++++ packages/input-box/src/InputBox/index.ts | 3 + .../getRelativeSegment.spec.tsx | 17 +- .../utils/getRelativeSegment/index.ts | 115 +------- .../InputBox/utils/getValueFormatter/index.ts | 2 - .../getValueFormatter/valueFormatter.spec.ts | 11 +- .../input-box/src/InputBox/utils/index.ts | 6 + .../utils/isElementInputSegment/index.ts | 0 .../InputBox/utils/isInputSegment/index.ts | 0 .../InputBox/utils/isValidSegment/index.ts | 16 - .../isValidSegment/isValidSegment.spec.ts | 36 ++- .../src/InputSegment/InputSegment.spec.tsx | 11 + .../src/InputSegment/InputSegment.styles.ts | 83 ++++++ .../src/InputSegment/InputSegment.tsx | 213 ++++++++++++++ .../src/InputSegment/InputSegment.types.ts | 138 +++++++++ packages/input-box/src/InputSegment/index.ts | 3 + .../getNewSegmentValueFromInputValue.spec.ts | 2 +- .../getNewSegmentValueFromInputValue.ts | 4 +- packages/input-box/src/index.ts | 1 + .../src/testing/getTestUtils.spec.tsx | 10 + .../input-box/src/testing/getTestUtils.tsx | 15 + .../src/testing/getTestUtils.types.ts | 1 + packages/input-box/src/testing/index.ts | 2 + .../createExplicitSegmentValidator/index.ts | 41 +++ packages/input-box/src/utils/getLgIds.ts | 12 + packages/input-box/src/utils/index.ts | 7 + .../utils/isElementInputSegment/index.ts | 9 +- .../src/utils/isValidValueForSegment/index.ts | 5 +- packages/input-box/tsconfig.json | 43 +++ 46 files changed, 1888 insertions(+), 171 deletions(-) create mode 100644 .changeset/input-box.md create mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts create mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts create mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts create mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/index.ts create mode 100644 packages/date-picker/src/shared/utils/getRelativeSegment/index.ts create mode 100644 packages/date-picker/src/shared/utils/getValueFormatter/index.ts create mode 100644 packages/date-picker/src/shared/utils/isElementInputSegment/index.ts create mode 100644 packages/date-picker/src/shared/utils/isValidSegment/index.ts create mode 100644 packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts create mode 100644 packages/input-box/README.md create mode 100644 packages/input-box/package.json create mode 100644 packages/input-box/src/InputBox.stories.tsx create mode 100644 packages/input-box/src/InputBox/InputBox.spec.tsx create mode 100644 packages/input-box/src/InputBox/InputBox.styles.ts create mode 100644 packages/input-box/src/InputBox/InputBox.tsx create mode 100644 packages/input-box/src/InputBox/InputBox.types.ts create mode 100644 packages/input-box/src/InputBox/index.ts create mode 100644 packages/input-box/src/InputBox/utils/index.ts create mode 100644 packages/input-box/src/InputBox/utils/isElementInputSegment/index.ts create mode 100644 packages/input-box/src/InputBox/utils/isInputSegment/index.ts create mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.styles.ts create mode 100644 packages/input-box/src/InputSegment/InputSegment.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.types.ts create mode 100644 packages/input-box/src/InputSegment/index.ts create mode 100644 packages/input-box/src/index.ts create mode 100644 packages/input-box/src/testing/getTestUtils.spec.tsx create mode 100644 packages/input-box/src/testing/getTestUtils.tsx create mode 100644 packages/input-box/src/testing/getTestUtils.types.ts create mode 100644 packages/input-box/src/testing/index.ts create mode 100644 packages/input-box/src/utils/createExplicitSegmentValidator/index.ts create mode 100644 packages/input-box/src/utils/getLgIds.ts create mode 100644 packages/input-box/src/utils/index.ts rename packages/input-box/src/{InputSegment => }/utils/isElementInputSegment/index.ts (71%) create mode 100644 packages/input-box/tsconfig.json 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/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts new file mode 100644 index 0000000000..21c9b153bd --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -0,0 +1,54 @@ +import { keyMap, rollover } from '@leafygreen-ui/lib'; + +interface GetNewSegmentValueFromArrowKeyPress< + T extends string, + V extends string, +> { + value: V; + key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; + segment: T; + min: number; + max: number; + step?: number | Partial>; + shouldNotRollover?: T | Array; +} + +/** + * Returns a new segment value given the current state + */ +export const getNewSegmentValueFromArrowKeyPress = < + T extends string, + V extends string, +>({ + value, + key, + segment, + min, + max, + shouldNotRollover, + step = 1, +}: GetNewSegmentValueFromArrowKeyPress): number => { + const stepValue = typeof step === 'number' ? step : step[segment] ?? 1; + + const valueDiff = key === keyMap.ArrowUp ? stepValue : -stepValue; + const defaultVal = key === keyMap.ArrowUp ? min : max; + + const incrementedValue: number = value + ? Number(value) + valueDiff + : defaultVal; + + let shouldSkipRollover = false; + if (shouldNotRollover !== undefined) { + if (typeof shouldNotRollover === 'string') { + shouldSkipRollover = segment === shouldNotRollover; + } else if (Array.isArray(shouldNotRollover)) { + shouldSkipRollover = shouldNotRollover.includes(segment); + } + } + + const newValue = shouldSkipRollover + ? incrementedValue + : rollover(incrementedValue, min, max); + + return newValue; +}; diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts new file mode 100644 index 0000000000..132c4363ec --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -0,0 +1,277 @@ +import range from 'lodash/range'; + +import { getValueFormatter } from '../../../../utils'; + +import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; + +const charsPerSegment = { + day: 2, + month: 2, + year: 4, +}; + +const defaultMin = { + day: 1, + month: 1, + year: 1970, +}; + +const defaultMax = { + day: 31, + month: 12, + year: new Date().getFullYear(), +}; + +const segmentObj = { + day: 'day', + month: 'month', + year: 'year', +}; + +describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromInputValue', () => { + describe.each(['day', 'month', 'year'])('For segment %p', _segment => { + const segment = _segment as 'day' | 'month' | 'year'; + describe('when current value is empty', () => { + test.each(range(10))('accepts %i character as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + + const validValues = [defaultMin[segment], defaultMax[segment]]; + test.each(validValues)(`accepts value "%i" as input`, v => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${v}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(`${v}`); + }); + + test('does not accept non-numeric characters', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `b`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(''); + }); + + test('does not accept input with a period/decimal', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `2.`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(''); + }); + }); + + describe('when current value is 0', () => { + if (segment !== 'year') { + test('rejects additional 0 as input', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `00`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(`0`); + }); + } + test.each(range(1, 10))('accepts 0%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `0${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(`0${i}`); + }); + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + ``, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(``); + }); + }); + + describe('when current value is 1', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + ``, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(``); + }); + + if (segment === 'month') { + test.each(range(0, 3))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + 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}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + } else { + test.each(range(10))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(`1${i}`); + }); + } + }); + + describe('when current value is 3', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + ``, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + 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}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + 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}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + 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}`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + break; + } + + default: + break; + } + }); + + describe('when current value is a full formatted value', () => { + const formatter = getValueFormatter(segment, charsPerSegment); + 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`, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + expect(newValue).toEqual(val); + }, + ); + }); + }); +}); diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts new file mode 100644 index 0000000000..a240603117 --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -0,0 +1,65 @@ +import last from 'lodash/last'; + +import { truncateStart } from '@leafygreen-ui/lib'; +import { isValidValueForSegment } from '../../../../utils'; + +// TODO: MOVE TO the new input box component + +/** + * 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 = < + T extends string, + V extends string, +>( + segmentName: T, + currentValue: V, + incomingValue: V, + charsPerSegment: Record, + defaultMin: Record, + defaultMax: Record, + segmentObj: Readonly>, +): V => { + // 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, + defaultMin, + defaultMax, + segmentObj, + ); + + if (isIncomingValueValid || segmentName === 'year') { + const newValue = truncateStart(incomingValue, { + length: charsPerSegment[segmentName], + }); + + return newValue as V; + } + + const typedChar = last(incomingValue.split('')); + const newValue = typedChar === '0' ? '0' : typedChar ?? ''; + return newValue as V; +}; diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/index.ts b/packages/date-picker/src/shared/components/InputSegment/utils/index.ts new file mode 100644 index 0000000000..8326610773 --- /dev/null +++ b/packages/date-picker/src/shared/components/InputSegment/utils/index.ts @@ -0,0 +1,2 @@ +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; +export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; diff --git a/packages/date-picker/src/shared/types/DateSegment.types.ts b/packages/date-picker/src/shared/types/DateSegment.types.ts index 32c77236f7..1ee8cdf6c8 100644 --- a/packages/date-picker/src/shared/types/DateSegment.types.ts +++ b/packages/date-picker/src/shared/types/DateSegment.types.ts @@ -13,11 +13,3 @@ export function isDateSegment(str: any): str is DateSegment { if (typeof str !== 'string') return false; return ['day', 'month', 'year'].includes(str); } - -export function isInputSegment( - str: any, - segmentObj: Record, -): str is keyof typeof segmentObj { - if (typeof str !== 'string') return false; - return Object.values(segmentObj).includes(str); -} diff --git a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts new file mode 100644 index 0000000000..c11f1611ea --- /dev/null +++ b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts @@ -0,0 +1,229 @@ +import isUndefined from 'lodash/isUndefined'; +import last from 'lodash/last'; + +type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; +// interface GetRelativeSegmentContext { +// segment: HTMLInputElement | React.RefObject; +// formatParts: SharedDatePickerContextProps['formatParts']; +// segmentRefs: SegmentRefs; +// } + +// TODO: MOVE TO the new input box component +/** + * 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; +// } +// }; + +export const getRelativeSegment = ( + direction: RelativeDirection, + { + segment, + formatParts, + }: { + segment: V; + formatParts?: Array; + }, +): V | 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 V); + + /** 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]; +// } +// } +// }; + +interface GetRelativeSegmentContext< + T extends Record>, +> { + segment: HTMLInputElement | React.RefObject; + formatParts?: Array; + segmentRefs: T; +} + +export const getRelativeSegmentRef = < + T extends Record>, + V extends string, +>( + 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 V); + + const currentSegmentName: V | 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/getValueFormatter/index.ts b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts new file mode 100644 index 0000000000..11ae0ac68a --- /dev/null +++ b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts @@ -0,0 +1,35 @@ +import padStart from 'lodash/padStart'; + +import { isZeroLike } from '@leafygreen-ui/lib'; + +// TODO: MOVE TO the new input box component + +/** + * If the value is any form of zero, we set it to an empty string + * otherwise, pad the string with 0s, or trim it to n chars + * + * @param segment - the segment to format + * @param charsPerSegment - the number of characters per segment + * @param val - the value to format + * @returns a value formatter function for the provided segment + */ +export const getValueFormatter = + (segment: T, charsPerSegment: Record) => + (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/isElementInputSegment/index.ts b/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts new file mode 100644 index 0000000000..4db93f11e8 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts @@ -0,0 +1,34 @@ +import { SegmentRefs } from '../../hooks'; + +// TODO: git mv to input box utils and then export this in DatePickerInput + +/** + * 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; +// }; + +/** + * Returns whether the given element is a segment + */ +export const isElementInputSegment = < + T extends Record>, +>( + element: HTMLElement, + segmentRefs: T, +): 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/isValidSegment/index.ts b/packages/date-picker/src/shared/utils/isValidSegment/index.ts new file mode 100644 index 0000000000..c7ebd45ace --- /dev/null +++ b/packages/date-picker/src/shared/utils/isValidSegment/index.ts @@ -0,0 +1,43 @@ +import isUndefined from 'lodash/isUndefined'; + +// TODO: MOVE TO the new input box component ok +/** + * 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; + +export const isValidSegmentValue = (segment?: T): segment is T => + !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) +// ); +// }; + +/** + * A generic type predicate function that checks if a given string is one + * of the values in the provided segment object. + * + * @param segmentObj The runtime object containing the valid string segments (must be 'as const') + * @param name The string to validate + * @returns A boolean and a type predicate (name is T[keyof T]) + */ +export const isValidSegmentName = >>( + segmentObj: T, + name?: string, +): name is T[keyof T] => { + return ( + !isUndefined(name) && + Object.values(segmentObj).includes( + name as (typeof segmentObj)[keyof typeof segmentObj], + ) + ); +}; diff --git a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts new file mode 100644 index 0000000000..6872809801 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts @@ -0,0 +1,33 @@ +import inRange from 'lodash/inRange'; + +import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; + +// TODO: move to generic utils and export inside isEverySegmentValid + +/** + * Returns whether a value is valid for a given segment type + */ +export const isValidValueForSegment = ( + segment: T, + value: V, + defaultMin: Record, + defaultMax: Record, + segmentObj: Readonly>, +): boolean => { + const isValidSegmentAndValue = + isValidSegmentValue(value) && isValidSegmentName(segmentObj, segment); + + // TODO: should this be custom? + 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/input-box/README.md b/packages/input-box/README.md new file mode 100644 index 0000000000..8f8e34ad8a --- /dev/null +++ b/packages/input-box/README.md @@ -0,0 +1,26 @@ + +# Input Box + +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/input-box.svg) +#### [View on MongoDB.design](https://www.mongodb.design/component/input-box/live-example/) + +## Installation + +### PNPM + +```shell +pnpm add @leafygreen-ui/input-box +``` + +### Yarn + +```shell +yarn add @leafygreen-ui/input-box +``` + +### NPM + +```shell +npm install @leafygreen-ui/input-box +``` + diff --git a/packages/input-box/package.json b/packages/input-box/package.json new file mode 100644 index 0000000000..a293319a60 --- /dev/null +++ b/packages/input-box/package.json @@ -0,0 +1,50 @@ + +{ + "name": "@leafygreen-ui/input-box", + "version": "0.0.1", + "description": "LeafyGreen UI Kit Input Box", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "license": "Apache-2.0", + "exports": { + ".": { + "require": "./dist/umd/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts" + }, + "./testing": { + "require": "./dist/umd/testing/index.js", + "import": "./dist/esm/testing/index.js", + "types": "./dist/types/testing/index.d.ts" + } + }, + "scripts": { + "build": "lg-build bundle", + "tsc": "lg-build tsc", + "docs": "lg-build docs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@leafygreen-ui/emotion": "workspace:^", + "@leafygreen-ui/lib": "workspace:^", + "@leafygreen-ui/hooks": "workspace:^", + "@leafygreen-ui/date-utils": "workspace:^", + "@leafygreen-ui/tokens": "workspace:^", + "@leafygreen-ui/typography": "workspace:^", + "@lg-tools/test-harnesses": "workspace:^" + }, + "peerDependencies": { + "@leafygreen-ui/leafygreen-provider": "workspace:^" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/input-box", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/LG/summary" + } +} diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx new file mode 100644 index 0000000000..1531dfa9d6 --- /dev/null +++ b/packages/input-box/src/InputBox.stories.tsx @@ -0,0 +1,17 @@ + +import React from 'react'; +import { StoryFn } from '@storybook/react'; + +import { InputBox } from '.'; + +export default { + title: 'Components/InputBox', + component: InputBox, +} + +const Template: StoryFn = (props) => ( + +); + +export const Basic = Template.bind({}); + diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx new file mode 100644 index 0000000000..ada6b50fe4 --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -0,0 +1,11 @@ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { InputBox } from '.'; + +describe('packages/input-box', () => { + test('condition', () => { + + }) +}) diff --git a/packages/input-box/src/InputBox/InputBox.styles.ts b/packages/input-box/src/InputBox/InputBox.styles.ts new file mode 100644 index 0000000000..00cdcea518 --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.styles.ts @@ -0,0 +1,22 @@ +import { css } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; + +export const segmentPartsWrapperStyles = css` + display: flex; + align-items: center; + gap: 1px; +`; + +export const separatorLiteralStyles = css` + user-select: none; +`; + +export const separatorLiteralDisabledStyles: Record = { + [Theme.Dark]: css` + color: ${palette.gray.dark2}; + `, + [Theme.Light]: css` + color: ${palette.gray.base}; + `, +}; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx new file mode 100644 index 0000000000..488cff4d92 --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -0,0 +1,240 @@ +import React, { + FocusEventHandler, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { + getRelativeSegment, + getValueFormatter, + getRelativeSegmentRef, +} from './utils'; +import { + InputSegmentChangeEventHandler, + isInputSegment, +} from '../InputSegment/InputSegment.types'; + +import { + segmentPartsWrapperStyles, + separatorLiteralDisabledStyles, + separatorLiteralStyles, +} from './InputBox.styles'; +import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; +import { + createExplicitSegmentValidator, + isElementInputSegment, +} from '../utils'; + +/** + * Generic controlled input box component + * Renders a styled input box with appropriate segment order & separator characters. + * + * @internal + */ +export const InputBoxWithRef = >( + { + className, + labelledBy, + segmentRefs, + onSegmentChange, + onKeyDown, + segments, + setSegment, + disabled, + charsPerSegment, + formatParts, + segmentObj, + segmentRules, + renderSegment, + ...rest + }: InputBoxProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + + const isExplicitSegmentValue = createExplicitSegmentValidator( + segmentObj, + segmentRules, + ); + + /** Formats and sets the segment value */ + const getFormattedSegmentValue = ( + segmentName: (typeof segmentObj)[keyof typeof segmentObj], + segmentValue: string, + ): string => { + const formatter = getValueFormatter(segmentName, charsPerSegment); + const formattedValue = formatter(segmentValue); + return formattedValue; + }; + + /** Fired when an individual segment value changes */ + const handleSegmentInputChange: InputSegmentChangeEventHandler< + T[keyof T], + string + > = 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 (isInputSegment(segmentName, segmentObj)) { + const formattedValue = getFormattedSegmentValue( + segmentName, + segmentValue, + ); + setSegment(segmentName, formattedValue); + } + }; + + /** Called on any keydown within the input element */ + const handleInputKeyDown: KeyboardEventHandler = e => { + const { target: _target, key } = e; + const target = _target as HTMLElement; + const isSegment = isElementInputSegment(target, segmentRefs); + + // 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: + case keyMap.Enter: + case keyMap.Escape: + case keyMap.Tab: + // Behavior handled by parent or menu + break; + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + + return ( +
+ {formatParts?.map((part, i) => { + if (part.type === 'literal') { + return ( + + {part.value} + + ); + } else if (isInputSegment(part.type, segmentObj)) { + const segmentProps = { + onChange: handleSegmentInputChange, + onBlur: handleSegmentInputBlur, + partType: part.type, + }; + return renderSegment(segmentProps); + } + })} +
+ ); +}; + +export const InputBox = React.forwardRef( + InputBoxWithRef, +) as InputBoxComponentType; + +InputBox.displayName = 'InputBox'; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts new file mode 100644 index 0000000000..1120761159 --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -0,0 +1,102 @@ +import React, { FocusEventHandler, ForwardedRef, ReactElement } from 'react'; + +import { DateType } from '@leafygreen-ui/date-utils'; + +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { ExplicitSegmentRule } from '../utils/createExplicitSegmentValidator'; + +export interface RenderSegmentProps { + onChange: InputSegmentChangeEventHandler; + onBlur: FocusEventHandler; + partType: T; +} + +export interface InputChangeEvent { + value: DateType; + segments: Record; +} + +export type InputChangeEventHandler = ( + changeEvent: InputChangeEvent, +) => void; + +export interface InputBoxProps> + extends Omit, 'onChange' | 'children'> { + /** + * Callback fired when any segment changes, but not necessarily a full value + */ + onSegmentChange?: InputSegmentChangeEventHandler; + + /** + * id of the labelling element + */ + labelledBy?: string; + + /** + * Segment Refs + * e.g. { day: ref, month: ref, year: ref } + */ + segmentRefs: Record< + T[keyof T], + ReturnType> + >; + + /** + * Segment object + * e.g. { Day: 'day', Month: 'month', Year: 'year' } + */ + segmentObj: T; + + /** + * An object containing the values of the segments + * e.g. { day: '1', month: '2', year: '2025' } + */ + segments: Record; + + /** + * A function that sets the value of a segment + * e.g. (segment: 'day', value: '1') => void; + */ + setSegment: (segment: T[keyof T], value: string) => void; + + /** + * The format parts of the date + */ + formatParts?: Intl.DateTimeFormatPart[]; + + /** + * The number of characters per segment + * e.g. { day: 2, month: 2, year: 4 } + */ + charsPerSegment: Record; + + /** + * Whether the input box is disabled + */ + disabled: boolean; + + /** + * The rules for the segments + * e.g. { day: { maxChars: 2, minExplicitValue: 1 }, month: { maxChars: 2, minExplicitValue: 1 }, year: { maxChars: 4, minExplicitValue: 1970 } } + */ + segmentRules: Record; + + /** + * A function that renders a segment + * e.g. (props: { onChange: (event: React.ChangeEvent) => void, onBlur: (event: React.FocusEvent) => void, partType: 'day' | 'month' | 'year' }) => React.ReactElement; + */ + renderSegment: (props: RenderSegmentProps) => React.ReactElement; +} + +/** + * The component type for the InputBox + * TODO: add why we need this + */ +export interface InputBoxComponentType { + >( + props: InputBoxProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; +} diff --git a/packages/input-box/src/InputBox/index.ts b/packages/input-box/src/InputBox/index.ts new file mode 100644 index 0000000000..ad481cad1c --- /dev/null +++ b/packages/input-box/src/InputBox/index.ts @@ -0,0 +1,3 @@ + +export { InputBox } from './InputBox'; +export { type InputBoxProps } from './InputBox.types'; diff --git a/packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx b/packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx index 9c4370ca5c..5dbd7f95e0 100644 --- a/packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx +++ b/packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx @@ -1,8 +1,19 @@ -import React from 'react'; +import React, { createRef } from 'react'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { render } from '@testing-library/react'; -import { SegmentRefs } from '../../../shared/hooks'; -import { segmentRefsMock } from '../../../shared/testutils'; +type Segment = 'day' | 'month' | 'year'; + +export type SegmentRefs = Record< + Segment, + ReturnType> +>; + +export const segmentRefsMock: SegmentRefs = { + day: createRef(), + month: createRef(), + year: createRef(), +}; import { getRelativeSegmentRef } from '.'; diff --git a/packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts b/packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts index c11f1611ea..3544ff1ea5 100644 --- a/packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts +++ b/packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts @@ -2,79 +2,11 @@ import isUndefined from 'lodash/isUndefined'; import last from 'lodash/last'; type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; -// interface GetRelativeSegmentContext { -// segment: HTMLInputElement | React.RefObject; -// formatParts: SharedDatePickerContextProps['formatParts']; -// segmentRefs: SegmentRefs; -// } -// TODO: MOVE TO the new input box component /** * 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; -// } -// }; - export const getRelativeSegment = ( direction: RelativeDirection, { @@ -137,49 +69,6 @@ export const getRelativeSegment = ( } }; -/** - * 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]; -// } -// } -// }; - interface GetRelativeSegmentContext< T extends Record>, > { @@ -188,6 +77,10 @@ interface GetRelativeSegmentContext< segmentRefs: T; } +/** + * Given a direction, staring segment, and segment refs, + * returns the segment ref in the given direction + */ export const getRelativeSegmentRef = < T extends Record>, V extends string, diff --git a/packages/input-box/src/InputBox/utils/getValueFormatter/index.ts b/packages/input-box/src/InputBox/utils/getValueFormatter/index.ts index 11ae0ac68a..3ce6c53cdb 100644 --- a/packages/input-box/src/InputBox/utils/getValueFormatter/index.ts +++ b/packages/input-box/src/InputBox/utils/getValueFormatter/index.ts @@ -2,8 +2,6 @@ import padStart from 'lodash/padStart'; import { isZeroLike } from '@leafygreen-ui/lib'; -// TODO: MOVE TO the new input box component - /** * If the value is any form of zero, we set it to an empty string * otherwise, pad the string with 0s, or trim it to n chars diff --git a/packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts index 05c2916639..67ba6ac3a2 100644 --- a/packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts +++ b/packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts @@ -1,10 +1,13 @@ -import { DateSegment } from '../../types'; - import { getValueFormatter } from '.'; -import { charsPerSegment } from '../../constants'; +type Segment = 'day' | 'month' | 'year'; +const charsPerSegment: Record = { + day: 2, + month: 2, + year: 4, +}; describe('packages/date-picker/utils/valueFormatter', () => { - describe.each(['day', 'month'] as Array)('', segment => { + describe.each(['day', 'month'] as Array)('', segment => { const formatter = getValueFormatter(segment, charsPerSegment); test('formats 2 digit values', () => { diff --git a/packages/input-box/src/InputBox/utils/index.ts b/packages/input-box/src/InputBox/utils/index.ts new file mode 100644 index 0000000000..d59798a662 --- /dev/null +++ b/packages/input-box/src/InputBox/utils/index.ts @@ -0,0 +1,6 @@ +export { + getRelativeSegment, + getRelativeSegmentRef, +} from './getRelativeSegment'; +export { getValueFormatter } from './getValueFormatter'; +export { isValidSegmentValue, isValidSegmentName } from './isValidSegment'; diff --git a/packages/input-box/src/InputBox/utils/isElementInputSegment/index.ts b/packages/input-box/src/InputBox/utils/isElementInputSegment/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/input-box/src/InputBox/utils/isInputSegment/index.ts b/packages/input-box/src/InputBox/utils/isInputSegment/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/input-box/src/InputBox/utils/isValidSegment/index.ts b/packages/input-box/src/InputBox/utils/isValidSegment/index.ts index 28a061fb32..4ab45be909 100644 --- a/packages/input-box/src/InputBox/utils/isValidSegment/index.ts +++ b/packages/input-box/src/InputBox/utils/isValidSegment/index.ts @@ -1,27 +1,11 @@ import isUndefined from 'lodash/isUndefined'; -// TODO: MOVE TO the new input box component /** * 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; - export const isValidSegmentValue = (segment?: T): segment is T => !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) -// ); -// }; - /** * A generic type predicate function that checks if a given string is one * of the values in the provided segment object. diff --git a/packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts index 50520de8e9..36e0f65ec3 100644 --- a/packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts +++ b/packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts @@ -1,65 +1,71 @@ import { isValidSegmentName, isValidSegmentValue } from '.'; -import { DateSegment, DateSegmentValue } from '../../types'; + +export const Segment = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; +type SegmentValue = string; describe('packages/date-picker/utils/isValidSegment', () => { describe('isValidSegment', () => { test('undefined returns false', () => { - expect(isValidSegmentValue()).toBeFalsy(); + expect(isValidSegmentValue()).toBeFalsy(); }); test('a string returns false', () => { - expect(isValidSegmentValue('')).toBeFalsy(); + expect(isValidSegmentValue('')).toBeFalsy(); }); test('NaN returns false', () => { /// @ts-expect-error - expect(isValidSegmentValue(NaN)).toBeFalsy(); + expect(isValidSegmentValue(NaN)).toBeFalsy(); }); test('0 returns false', () => { - expect(isValidSegmentValue('0')).toBeFalsy(); + expect(isValidSegmentValue('0')).toBeFalsy(); }); test('negative returns false', () => { - expect(isValidSegmentValue('-1')).toBeFalsy(); + expect(isValidSegmentValue('-1')).toBeFalsy(); }); test('1970 returns true', () => { - expect(isValidSegmentValue('1970')).toBeTruthy(); + expect(isValidSegmentValue('1970')).toBeTruthy(); }); test('1 returns true', () => { - expect(isValidSegmentValue('1')).toBeTruthy(); + expect(isValidSegmentValue('1')).toBeTruthy(); }); test('2038 returns true', () => { - expect(isValidSegmentValue('2038')).toBeTruthy(); + expect(isValidSegmentValue('2038')).toBeTruthy(); }); }); describe('isValidSegmentName', () => { test('undefined returns false', () => { - expect(isValidSegmentName(DateSegment)).toBeFalsy(); + expect(isValidSegmentName(Segment)).toBeFalsy(); }); test('random string returns false', () => { - expect(isValidSegmentName(DateSegment, '123')).toBeFalsy(); + expect(isValidSegmentName(Segment, '123')).toBeFalsy(); }); test('empty string returns false', () => { - expect(isValidSegmentName(DateSegment, '')).toBeFalsy(); + expect(isValidSegmentName(Segment, '')).toBeFalsy(); }); test('day string returns true', () => { - expect(isValidSegmentName(DateSegment, 'day')).toBeTruthy(); + expect(isValidSegmentName(Segment, 'day')).toBeTruthy(); }); test('month string returns true', () => { - expect(isValidSegmentName(DateSegment, 'month')).toBeTruthy(); + expect(isValidSegmentName(Segment, 'month')).toBeTruthy(); }); test('year string returns true', () => { - expect(isValidSegmentName(DateSegment, 'year')).toBeTruthy(); + expect(isValidSegmentName(Segment, 'year')).toBeTruthy(); }); }); }); diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx new file mode 100644 index 0000000000..7627dab1fb --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -0,0 +1,11 @@ + +import React from 'react'; +import { render } from '@testing-library/react'; + +import { InputSegment } from '.'; + +describe('packages/input-segment', () => { + test('condition', () => { + + }) +}) diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts new file mode 100644 index 0000000000..73fd8d176d --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -0,0 +1,83 @@ +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'; + +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; + appearance: none; + margin: 0; + } + -moz-appearance: textfield; /* Firefox */ + appearance: textfield; + + &: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 + `, +}; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx new file mode 100644 index 0000000000..91252951e6 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -0,0 +1,213 @@ +import React, { + ChangeEventHandler, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; + +import { cx } from '@leafygreen-ui/emotion'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; +import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; + +import { + baseStyles, + fontSizeStyles, + segmentSizeStyles, + segmentThemeStyles, +} from './InputSegment.styles'; +import { + InputSegmentComponentType, + InputSegmentProps, +} from './InputSegment.types'; +import { getValueFormatter } from '../InputBox/utils'; // TODO: moved to shared utils +import { + getNewSegmentValueFromInputValue, + getNewSegmentValueFromArrowKeyPress, +} from './utils'; + +/** + * Generic controlled input segment component + * + * Renders a single input segment with configurable + * character padding, validation, and formatting. + * + * @internal + */ +const InputSegmentWithRef = , V extends string>( + { + segment, + value, + onChange, + onBlur, + onKeyDown, + size: sizeProp, + charsPerSegment, + min, + max, + size, + className, + segmentObj, + defaultMin, + defaultMax, + step = 1, + shouldNotRollover, + ...rest + }: InputSegmentProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + const baseFontSize = useUpdatedBaseFontSize(); + const formatter = getValueFormatter(segment, charsPerSegment); + const pattern = `[0-9]{${charsPerSegment[segment]}}`; + + /** + * Receives native input events, + * determines whether the input value is valid and should change, + * and fires a custom `InputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue( + segment, + value, + target.value, + charsPerSegment, + defaultMin, + defaultMax, + segmentObj, + ); + + const hasValueChanged = newValue !== value; + + if (hasValueChanged) { + onChange({ + segment, + value: newValue as V, + }); + } 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 maxLength, 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, + step, + shouldNotRollover, + }); + const valueString = formatter(newValue); + + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString as V, + 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) { + // Stop propagation to prevent parent handlers from firing + e.stopPropagation(); + + /** Fire a custom change event when the backspace key is pressed */ + onChange({ + segment, + value: '' as V, + 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: '' as V, + 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 ( + + ); +}; + +export const InputSegment = React.forwardRef( + InputSegmentWithRef, +) as InputSegmentComponentType; + +InputSegment.displayName = 'InputSegment'; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts new file mode 100644 index 0000000000..01e4ed4463 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -0,0 +1,138 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + +import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; + +export interface InputSegmentChangeEvent { + segment: T; + value: V; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + [key: string]: any; + }; +} + +/** + * The type for the onChange handler + */ +export type InputSegmentChangeEventHandler< + T extends string, + V extends string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +export interface InputSegmentProps< + T extends Record, + V extends string, +> extends Omit< + React.ComponentPropsWithRef<'input'>, + 'onChange' | 'size' | 'step' + > { + /** + * Which segment this input represents + * e.g. 'day' + * e.g. 'month' + * e.g. 'year' + */ + segment: T[keyof T]; + + /** + * The value of the segment + * e.g. '1' + * e.g. '2' + * e.g. '2025' + */ + value: V; + + /** + * Custom onChange handler + */ + onChange: InputSegmentChangeEventHandler; + + /** + * The number of characters per segment + * e.g. { day: 2, month: 2, year: 4 } + */ + charsPerSegment: Record; + + /** + * Minimum value. + * e.g. 1 + * e.g. 1 + * e.g. 1970 + */ + min: number; + + /** + * Maximum value. + * e.g. 31 + * e.g. 12 + * e.g. 2038 + */ + max: number; + + /** + * Segment object + * e.g. { Day: 'day', Month: 'month', Year: 'year' } + */ + segmentObj: T; + + /** + * Default minimum value + * e.g. { day: 1, month: 1, year: 1970 } + */ + defaultMin: Record; + + /** + * Default maximum value + * e.g. { day: 31, month: 12, year: 2038 } + */ + defaultMax: Record; + + /** + * Size of the segment + * e.g. Size.Default + * e.g. Size.Small + * e.g. Size.Large + */ + size: Size; + + /** + * The step value for the arrow keys + * e.g. 1 + * e.g. { day: 1, month: 1, year: 1 } + * + * @default 1 + */ + step?: number | Partial>; + + /** + * The segments that should not rollover + * e.g. 'year' + * e.g. ['year', 'month'] + * + * @default undefined + */ + shouldNotRollover?: T[keyof T] | Array; +} + +/** + * The component type for the InputSegment + * TODO: add why we need this + */ +export interface InputSegmentComponentType { + , V extends string>( + props: InputSegmentProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; +} +/** + * Returns whether the given string is a valid segment + */ +export function isInputSegment>( + str: any, + segmentObj: T, +): str is T[keyof T] { + if (typeof str !== 'string') return false; + return Object.values(segmentObj).includes(str); +} diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts new file mode 100644 index 0000000000..e698c9edba --- /dev/null +++ b/packages/input-box/src/InputSegment/index.ts @@ -0,0 +1,3 @@ + +export { InputSegment } from './InputSegment'; +export { type InputSegmentProps } from './InputSegment.types'; diff --git a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index 132c4363ec..daa289e406 100644 --- a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -1,6 +1,6 @@ import range from 'lodash/range'; -import { getValueFormatter } from '../../../../utils'; +import { getValueFormatter } from '../../../InputBox/utils'; import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; diff --git a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index a240603117..e935ec7723 100644 --- a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -1,9 +1,7 @@ import last from 'lodash/last'; import { truncateStart } from '@leafygreen-ui/lib'; -import { isValidValueForSegment } from '../../../../utils'; - -// TODO: MOVE TO the new input box component +import { isValidValueForSegment } from '../../../utils'; /** * Calculates the new value for the segment given an incoming change. diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts new file mode 100644 index 0000000000..58e526338a --- /dev/null +++ b/packages/input-box/src/index.ts @@ -0,0 +1 @@ +export { InputBox, type InputBoxProps } from './InputBox'; \ No newline at end of file diff --git a/packages/input-box/src/testing/getTestUtils.spec.tsx b/packages/input-box/src/testing/getTestUtils.spec.tsx new file mode 100644 index 0000000000..9c823ded0d --- /dev/null +++ b/packages/input-box/src/testing/getTestUtils.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { InputBox } from '.'; + +describe('packages/input-box/getTestUtils', () => { + test('condition', () => { + + }) +}) diff --git a/packages/input-box/src/testing/getTestUtils.tsx b/packages/input-box/src/testing/getTestUtils.tsx new file mode 100644 index 0000000000..ad89a6e99d --- /dev/null +++ b/packages/input-box/src/testing/getTestUtils.tsx @@ -0,0 +1,15 @@ +import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; + +import { LgIdString } from '@leafygreen-ui/lib'; + +import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; + +import { TestUtilsReturnType } from './getTestUtils.types'; + +export const getTestUtils = ( + lgId: LgIdString = DEFAULT_LGID_ROOT, +): TestUtilsReturnType => { + const lgIds = getLgIds(lgId); + + return {}; +}; diff --git a/packages/input-box/src/testing/getTestUtils.types.ts b/packages/input-box/src/testing/getTestUtils.types.ts new file mode 100644 index 0000000000..50d2fb417a --- /dev/null +++ b/packages/input-box/src/testing/getTestUtils.types.ts @@ -0,0 +1 @@ +export interface TestUtilsReturnType {} \ No newline at end of file diff --git a/packages/input-box/src/testing/index.ts b/packages/input-box/src/testing/index.ts new file mode 100644 index 0000000000..4c102995fa --- /dev/null +++ b/packages/input-box/src/testing/index.ts @@ -0,0 +1,2 @@ +export { getTestUtils } from './getTestUtils'; +export { type TestUtilsReturnType } from './getTestUtils.types'; diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts new file mode 100644 index 0000000000..dcc3d27be3 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts @@ -0,0 +1,41 @@ +import { + isValidSegmentName, + isValidSegmentValue, +} from '../../InputBox/utils/isValidSegment'; + +/** + * Configuration for determining if a segment value is explicit + */ +export type ExplicitSegmentRule = { + /** Maximum characters for this segment */ + maxChars: number; + /** Minimum numeric value that makes the input explicit (optional) */ + minExplicitValue?: number; +}; + +/** + * Factory function that creates a segment value validator + * @param segmentEnum - The segment enum/object to validate against + * @param rules - Rules for each segment type + * @returns A function that checks if a segment value is explicit + */ +export function createExplicitSegmentValidator< + T extends Record, +>(segmentEnum: T, rules: Record) { + return (segment: T[keyof T], value: string): boolean => { + if ( + !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) + ) + return false; + + const rule = rules[segment]; + if (!rule) return false; + + const isMaxLength = value.length === rule.maxChars; + const meetsMinValue = rule.minExplicitValue + ? Number(value) >= rule.minExplicitValue + : false; + + return isMaxLength || meetsMinValue; + }; +} diff --git a/packages/input-box/src/utils/getLgIds.ts b/packages/input-box/src/utils/getLgIds.ts new file mode 100644 index 0000000000..08b841e0a5 --- /dev/null +++ b/packages/input-box/src/utils/getLgIds.ts @@ -0,0 +1,12 @@ +import { LgIdString } from '@leafygreen-ui/lib'; + +export const DEFAULT_LGID_ROOT = 'lg-input_box'; + +export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => { + const ids = { + root, + } as const; + return ids; +}; + +export type GetLgIdsReturnType = ReturnType; diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts new file mode 100644 index 0000000000..6efd3a0bb6 --- /dev/null +++ b/packages/input-box/src/utils/index.ts @@ -0,0 +1,7 @@ +export { isValidValueForSegment } from './isValidValueForSegment'; +export { + createExplicitSegmentValidator, + ExplicitSegmentRule, +} from './createExplicitSegmentValidator'; + +export { isElementInputSegment } from './isElementInputSegment'; diff --git a/packages/input-box/src/InputSegment/utils/isElementInputSegment/index.ts b/packages/input-box/src/utils/isElementInputSegment/index.ts similarity index 71% rename from packages/input-box/src/InputSegment/utils/isElementInputSegment/index.ts rename to packages/input-box/src/utils/isElementInputSegment/index.ts index 4bacd83464..4f59087128 100644 --- a/packages/input-box/src/InputSegment/utils/isElementInputSegment/index.ts +++ b/packages/input-box/src/utils/isElementInputSegment/index.ts @@ -1,16 +1,15 @@ -import { SegmentRefs } from '../../hooks'; - /** * Returns whether the given element is a segment */ -export const isElementInputSegment = ( +export const isElementInputSegment = < + T extends Record>, +>( element: HTMLElement, - segmentRefs: SegmentRefs, + segmentRefs: T, ): 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/input-box/src/utils/isValidValueForSegment/index.ts b/packages/input-box/src/utils/isValidValueForSegment/index.ts index 5691ebff0f..fd556adaf0 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/index.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/index.ts @@ -1,8 +1,6 @@ import inRange from 'lodash/inRange'; -import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; - -// TODO: move to generic utils +import { isValidSegmentName, isValidSegmentValue } from '../../InputBox/utils'; /** * Returns whether a value is valid for a given segment type @@ -17,6 +15,7 @@ export const isValidValueForSegment = ( const isValidSegmentAndValue = isValidSegmentValue(value) && isValidSegmentName(segmentObj, segment); + // TODO: should this be custom? if (segment === 'year') { // allow any 4-digit year value regardless of defined range return isValidSegmentAndValue && inRange(Number(value), 1000, 9999 + 1); diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json new file mode 100644 index 0000000000..353961b7b7 --- /dev/null +++ b/packages/input-box/tsconfig.json @@ -0,0 +1,43 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": [ + "../icon/src/generated/*" + ], + "@leafygreen-ui/*": [ + "../*/src" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/*.spec.*", + "**/*.stories.*" + ], + "references": [ + { + "path": "../emotion" + }, + { + "path": "../lib" + }, + { + "path": "../hooks" + }, + { + "path": "../date-utils" + }, + { + "path": "../tokens" + }, + { + "path": "../typography" + }, + { + "path": "../leafygreen-provider" + } + ] +} \ No newline at end of file From cf44545e8578fddcd638eac7f2443089fdcf9892 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sun, 26 Oct 2025 12:10:54 -0400 Subject: [PATCH 12/56] refactor(date-picker): integrate InputBox and InputSegment into DatePicker components for improved segment management and type handling --- packages/date-picker/package.json | 1 + .../DateInput/DateInputBox/DateInputBox.tsx | 2 +- .../DateInputSegment/DateInputSegment.tsx | 6 +- .../components/InputBox/InputBox.specs.tsx | 0 .../components/InputBox/InputBox.styles.ts | 22 -- .../shared/components/InputBox/InputBox.tsx | 255 ---------------- .../components/InputBox/InputBox.types.ts | 102 ------- .../shared/components/InputSegment/Index.ts | 6 - .../InputSegment/InputSegment.spec.tsx | 0 .../InputSegment/InputSegment.styles.ts | 83 ------ .../components/InputSegment/InputSegment.tsx | 213 -------------- .../InputSegment/InputSegment.types.ts | 128 -------- .../getNewSegmentValueFromArrowKeyPress.ts | 54 ---- .../getNewSegmentValueFromInputValue.spec.ts | 277 ------------------ .../getNewSegmentValueFromInputValue.ts | 65 ---- .../components/InputSegment/utils/index.ts | 2 - packages/date-picker/tsconfig.json | 5 +- packages/input-box/src/InputBox/InputBox.tsx | 3 +- .../getValueFormatter/valueFormatter.spec.ts | 3 +- .../src/InputSegment/InputSegment.tsx | 4 +- packages/input-box/src/index.ts | 3 +- .../src/testing/getTestUtils.spec.tsx | 10 - .../input-box/src/testing/getTestUtils.tsx | 15 - .../src/testing/getTestUtils.types.ts | 1 - packages/input-box/src/testing/index.ts | 2 - packages/input-box/src/testutils/index.ts | 15 + pnpm-lock.yaml | 30 ++ 27 files changed, 63 insertions(+), 1244 deletions(-) delete mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.specs.tsx delete mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.styles.ts delete mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.tsx delete mode 100644 packages/date-picker/src/shared/components/InputBox/InputBox.types.ts delete mode 100644 packages/date-picker/src/shared/components/InputSegment/Index.ts delete mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.spec.tsx delete mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts delete mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx delete mode 100644 packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts delete mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts delete mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts delete mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts delete mode 100644 packages/date-picker/src/shared/components/InputSegment/utils/index.ts delete mode 100644 packages/input-box/src/testing/getTestUtils.spec.tsx delete mode 100644 packages/input-box/src/testing/getTestUtils.tsx delete mode 100644 packages/input-box/src/testing/getTestUtils.types.ts delete mode 100644 packages/input-box/src/testing/index.ts create mode 100644 packages/input-box/src/testutils/index.ts 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/shared/components/DateInput/DateInputBox/DateInputBox.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx index 7c74748b03..41892d841a 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -22,7 +22,7 @@ import { DateInputSegment } from '../DateInputSegment'; import { DateInputBoxProps } from './DateInputBox.types'; import { charsPerSegment, dateSegmentRules } from '../../../constants'; -import { InputBox } from '../../InputBox/InputBox'; +import { InputBox } from '@leafygreen-ui/input-box'; /** * Renders a styled date input with appropriate segment order & separator characters. 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 666efd8b32..616c9fce0f 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -13,7 +13,7 @@ import { getAutoComplete } from '../../../utils'; import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; -import { InputSegment } from '../../InputSegment/InputSegment'; +import { InputSegment } from '@leafygreen-ui/input-box'; import { DateSegment } from '../../../types'; /** @@ -72,8 +72,8 @@ export const DateInputSegment = React.forwardRef< className={cx(segmentWidthStyles[segment])} disabled={disabled} data-testid="lg-date_picker_input-segment" - defaultMin={defaultMin} - defaultMax={defaultMax} + defaultMin={defaultMin} // TODO: remove this + defaultMax={defaultMax} // TODO: remove this segmentObj={DateSegment} shouldNotRollover={DateSegment.Year} {...rest} diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.specs.tsx b/packages/date-picker/src/shared/components/InputBox/InputBox.specs.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.styles.ts b/packages/date-picker/src/shared/components/InputBox/InputBox.styles.ts deleted file mode 100644 index 00cdcea518..0000000000 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.styles.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { css } from '@leafygreen-ui/emotion'; -import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; - -export const segmentPartsWrapperStyles = css` - display: flex; - align-items: center; - gap: 1px; -`; - -export const separatorLiteralStyles = css` - user-select: none; -`; - -export const separatorLiteralDisabledStyles: Record = { - [Theme.Dark]: css` - color: ${palette.gray.dark2}; - `, - [Theme.Light]: css` - color: ${palette.gray.base}; - `, -}; diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.tsx b/packages/date-picker/src/shared/components/InputBox/InputBox.tsx deleted file mode 100644 index 7f0f6bc53c..0000000000 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import React, { - FocusEventHandler, - ForwardedRef, - KeyboardEventHandler, -} from 'react'; - -import { cx } from '@leafygreen-ui/emotion'; -import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { keyMap } from '@leafygreen-ui/lib'; - -import { - getRelativeSegment, - getValueFormatter, - getRelativeSegmentRef, -} from '../../utils'; -import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; - -import { - segmentPartsWrapperStyles, - separatorLiteralDisabledStyles, - separatorLiteralStyles, -} from './InputBox.styles'; -import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; -import { createExplicitSegmentValidator } from '../../utils/isExplicitSegmentValue'; - -export function isInputSegment>( - str: any, - segmentObj: T, -): str is T[keyof T] { - if (typeof str !== 'string') return false; - return Object.values(segmentObj).includes(str); -} - -export const isElementInputSegment = < - T extends Record>, ->( - element: HTMLElement, - segmentRefs: T, -): element is HTMLInputElement => { - const segmentsArray = Object.values(segmentRefs).map( - ref => ref.current, - ) as Array; - const isSegment = segmentsArray.includes(element); - return isSegment; -}; - -/** - * Generic controlled input box component - * Renders a styled input box with appropriate segment order & separator characters. - * - * @internal - */ -export const InputBoxWithRef = >( - { - className, - labelledBy, - segmentRefs, - onSegmentChange, - onKeyDown, - segments, - setSegment, - disabled, - charsPerSegment, - formatParts, - segmentObj, - segmentRules, - renderSegment, - ...rest - }: InputBoxProps, - fwdRef: ForwardedRef, -) => { - const { theme } = useDarkMode(); - - const isExplicitSegmentValue = createExplicitSegmentValidator( - segmentObj, - segmentRules, - ); - - /** Formats and sets the segment value */ - const getFormattedSegmentValue = ( - segmentName: (typeof segmentObj)[keyof typeof segmentObj], - segmentValue: string, - ): string => { - const formatter = getValueFormatter(segmentName, charsPerSegment); - const formattedValue = formatter(segmentValue); - return formattedValue; - }; - - /** Fired when an individual segment value changes */ - const handleSegmentInputChange: InputSegmentChangeEventHandler< - T[keyof T], - string - > = 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 (isInputSegment(segmentName, segmentObj)) { - const formattedValue = getFormattedSegmentValue( - segmentName, - segmentValue, - ); - setSegment(segmentName, formattedValue); - } - }; - - /** Called on any keydown within the input element */ - const handleInputKeyDown: KeyboardEventHandler = e => { - const { target: _target, key } = e; - const target = _target as HTMLElement; - const isSegment = isElementInputSegment(target, segmentRefs); - - // 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: - case keyMap.Enter: - case keyMap.Escape: - case keyMap.Tab: - // Behavior handled by parent or menu - break; - } - - // call any handler that was passed in - onKeyDown?.(e); - }; - - return ( -
- {formatParts?.map((part, i) => { - if (part.type === 'literal') { - return ( - - {part.value} - - ); - } else if (isInputSegment(part.type, segmentObj)) { - const segmentProps = { - onChange: handleSegmentInputChange, - onBlur: handleSegmentInputBlur, - partType: part.type, - }; - return renderSegment(segmentProps); - } - })} -
- ); -}; - -export const InputBox = React.forwardRef( - InputBoxWithRef, -) as InputBoxComponentType; - -InputBox.displayName = 'InputBox'; diff --git a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts b/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts deleted file mode 100644 index 866ab13b03..0000000000 --- a/packages/date-picker/src/shared/components/InputBox/InputBox.types.ts +++ /dev/null @@ -1,102 +0,0 @@ -import React, { FocusEventHandler, ForwardedRef, ReactElement } from 'react'; - -import { DateType } from '@leafygreen-ui/date-utils'; - -import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; -import { ExplicitSegmentRule } from '../../utils/isExplicitSegmentValue'; - -export interface RenderSegmentProps { - onChange: InputSegmentChangeEventHandler; - onBlur: FocusEventHandler; - partType: T; -} - -export interface InputChangeEvent { - value: DateType; - segments: Record; -} - -export type InputChangeEventHandler = ( - changeEvent: InputChangeEvent, -) => void; - -export interface InputBoxProps> - extends Omit, 'onChange' | 'children'> { - /** - * Callback fired when any segment changes, but not necessarily a full value - */ - onSegmentChange?: InputSegmentChangeEventHandler; - - /** - * id of the labelling element - */ - labelledBy?: string; - - /** - * Segment Refs - * e.g. { day: ref, month: ref, year: ref } - */ - segmentRefs: Record< - T[keyof T], - ReturnType> - >; - - /** - * Segment object - * e.g. { Day: 'day', Month: 'month', Year: 'year' } - */ - segmentObj: T; - - /** - * An object containing the values of the segments - * e.g. { day: '1', month: '2', year: '2025' } - */ - segments: Record; - - /** - * A function that sets the value of a segment - * e.g. (segment: 'day', value: '1') => void; - */ - setSegment: (segment: T[keyof T], value: string) => void; - - /** - * The format parts of the date - */ - formatParts?: Intl.DateTimeFormatPart[]; - - /** - * The number of characters per segment - * e.g. { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - - /** - * Whether the input box is disabled - */ - disabled: boolean; - - /** - * The rules for the segments - * e.g. { day: { maxChars: 2, minExplicitValue: 1 }, month: { maxChars: 2, minExplicitValue: 1 }, year: { maxChars: 4, minExplicitValue: 1970 } } - */ - segmentRules: Record; - - /** - * A function that renders a segment - * e.g. (props: { onChange: (event: React.ChangeEvent) => void, onBlur: (event: React.FocusEvent) => void, partType: 'day' | 'month' | 'year' }) => React.ReactElement; - */ - renderSegment: (props: RenderSegmentProps) => React.ReactElement; -} - -/** - * The component type for the InputBox - * TODO: add why we need this - */ -export interface InputBoxComponentType { - >( - props: InputBoxProps, - ref: ForwardedRef, - ): ReactElement | null; - displayName?: string; -} diff --git a/packages/date-picker/src/shared/components/InputSegment/Index.ts b/packages/date-picker/src/shared/components/InputSegment/Index.ts deleted file mode 100644 index 11d6b7db8c..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/Index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { InputSegment } from './InputSegment'; -export type { - InputSegmentChangeEvent, - InputSegmentChangeEventHandler, - InputSegmentProps, -} from './InputSegment.types'; diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.spec.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.spec.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts deleted file mode 100644 index 73fd8d176d..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.styles.ts +++ /dev/null @@ -1,83 +0,0 @@ -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'; - -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; - appearance: none; - margin: 0; - } - -moz-appearance: textfield; /* Firefox */ - appearance: textfield; - - &: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 - `, -}; diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx b/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx deleted file mode 100644 index 28e50cdb43..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { - ChangeEventHandler, - ForwardedRef, - KeyboardEventHandler, -} from 'react'; - -import { cx } from '@leafygreen-ui/emotion'; -import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { keyMap } from '@leafygreen-ui/lib'; -import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; - -import { - baseStyles, - fontSizeStyles, - segmentSizeStyles, - segmentThemeStyles, -} from './InputSegment.styles'; -import { - InputSegmentComponentType, - InputSegmentProps, -} from './InputSegment.types'; -import { getValueFormatter } from '../../utils'; -import { - getNewSegmentValueFromInputValue, - getNewSegmentValueFromArrowKeyPress, -} from './utils'; - -/** - * Generic controlled input segment component - * - * Renders a single input segment with configurable - * character padding, validation, and formatting. - * - * @internal - */ -const InputSegmentWithRef = , V extends string>( - { - segment, - value, - onChange, - onBlur, - onKeyDown, - size: sizeProp, - charsPerSegment, - min, - max, - size, - className, - segmentObj, - defaultMin, - defaultMax, - step = 1, - shouldNotRollover, - ...rest - }: InputSegmentProps, - fwdRef: ForwardedRef, -) => { - const { theme } = useDarkMode(); - const baseFontSize = useUpdatedBaseFontSize(); - const formatter = getValueFormatter(segment, charsPerSegment); - const pattern = `[0-9]{${charsPerSegment[segment]}}`; - - /** - * Receives native input events, - * determines whether the input value is valid and should change, - * and fires a custom `InputSegmentChangeEvent`. - */ - const handleChange: ChangeEventHandler = e => { - const { target } = e; - - const newValue = getNewSegmentValueFromInputValue( - segment, - value, - target.value, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - - const hasValueChanged = newValue !== value; - - if (hasValueChanged) { - onChange({ - segment, - value: newValue as V, - }); - } 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 maxLength, 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, - step, - shouldNotRollover, - }); - const valueString = formatter(newValue); - - /** Fire a custom change event when the up/down arrow keys are pressed */ - onChange({ - segment, - value: valueString as V, - 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) { - // Stop propagation to prevent parent handlers from firing - e.stopPropagation(); - - /** Fire a custom change event when the backspace key is pressed */ - onChange({ - segment, - value: '' as V, - 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: '' as V, - 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 ( - - ); -}; - -export const InputSegment = React.forwardRef( - InputSegmentWithRef, -) as InputSegmentComponentType; - -InputSegment.displayName = 'InputSegment'; diff --git a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts b/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts deleted file mode 100644 index bf2dd3624f..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/InputSegment.types.ts +++ /dev/null @@ -1,128 +0,0 @@ -import React, { ForwardedRef, ReactElement } from 'react'; - -import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; - -export interface InputSegmentChangeEvent { - segment: T; - value: V; - meta?: { - key?: (typeof keyMap)[keyof typeof keyMap]; - [key: string]: any; - }; -} - -/** - * The type for the onChange handler - */ -export type InputSegmentChangeEventHandler< - T extends string, - V extends string, -> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; - -export interface InputSegmentProps< - T extends Record, - V extends string, -> extends Omit< - React.ComponentPropsWithRef<'input'>, - 'onChange' | 'size' | 'step' - > { - /** - * Which segment this input represents - * e.g. 'day' - * e.g. 'month' - * e.g. 'year' - */ - segment: T[keyof T]; - - /** - * The value of the segment - * e.g. '1' - * e.g. '2' - * e.g. '2025' - */ - value: V; - - /** - * Custom onChange handler - */ - onChange: InputSegmentChangeEventHandler; - - /** - * The number of characters per segment - * e.g. { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - - /** - * Minimum value. - * e.g. 1 - * e.g. 1 - * e.g. 1970 - */ - min: number; - - /** - * Maximum value. - * e.g. 31 - * e.g. 12 - * e.g. 2038 - */ - max: number; - - /** - * Segment object - * e.g. { Day: 'day', Month: 'month', Year: 'year' } - */ - segmentObj: T; - - /** - * Default minimum value - * e.g. { day: 1, month: 1, year: 1970 } - */ - defaultMin: Record; - - /** - * Default maximum value - * e.g. { day: 31, month: 12, year: 2038 } - */ - defaultMax: Record; - - /** - * Size of the segment - * e.g. Size.Default - * e.g. Size.Small - * e.g. Size.Large - */ - size: Size; - - /** - * The step value for the arrow keys - * e.g. 1 - * e.g. { day: 1, month: 1, year: 1 } - * - * @default 1 - */ - step?: number | Partial>; - - /** - * The segments that should not rollover - * e.g. 'year' - * e.g. ['year', 'month'] - * - * @default undefined - */ - shouldNotRollover?: T[keyof T] | Array; -} - -/** - * The component type for the InputSegment - * TODO: add why we need this - */ -export interface InputSegmentComponentType { - , V extends string>( - props: InputSegmentProps, - ref: ForwardedRef, - ): ReactElement | null; - displayName?: string; -} diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts deleted file mode 100644 index 21c9b153bd..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { keyMap, rollover } from '@leafygreen-ui/lib'; - -interface GetNewSegmentValueFromArrowKeyPress< - T extends string, - V extends string, -> { - value: V; - key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; - segment: T; - min: number; - max: number; - step?: number | Partial>; - shouldNotRollover?: T | Array; -} - -/** - * Returns a new segment value given the current state - */ -export const getNewSegmentValueFromArrowKeyPress = < - T extends string, - V extends string, ->({ - value, - key, - segment, - min, - max, - shouldNotRollover, - step = 1, -}: GetNewSegmentValueFromArrowKeyPress): number => { - const stepValue = typeof step === 'number' ? step : step[segment] ?? 1; - - const valueDiff = key === keyMap.ArrowUp ? stepValue : -stepValue; - const defaultVal = key === keyMap.ArrowUp ? min : max; - - const incrementedValue: number = value - ? Number(value) + valueDiff - : defaultVal; - - let shouldSkipRollover = false; - if (shouldNotRollover !== undefined) { - if (typeof shouldNotRollover === 'string') { - shouldSkipRollover = segment === shouldNotRollover; - } else if (Array.isArray(shouldNotRollover)) { - shouldSkipRollover = shouldNotRollover.includes(segment); - } - } - - const newValue = shouldSkipRollover - ? incrementedValue - : rollover(incrementedValue, min, max); - - return newValue; -}; diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts deleted file mode 100644 index 132c4363ec..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ /dev/null @@ -1,277 +0,0 @@ -import range from 'lodash/range'; - -import { getValueFormatter } from '../../../../utils'; - -import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; - -const charsPerSegment = { - day: 2, - month: 2, - year: 4, -}; - -const defaultMin = { - day: 1, - month: 1, - year: 1970, -}; - -const defaultMax = { - day: 31, - month: 12, - year: new Date().getFullYear(), -}; - -const segmentObj = { - day: 'day', - month: 'month', - year: 'year', -}; - -describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromInputValue', () => { - describe.each(['day', 'month', 'year'])('For segment %p', _segment => { - const segment = _segment as 'day' | 'month' | 'year'; - describe('when current value is empty', () => { - test.each(range(10))('accepts %i character as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `${i}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(`${i}`); - }); - - const validValues = [defaultMin[segment], defaultMax[segment]]; - test.each(validValues)(`accepts value "%i" as input`, v => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `${v}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(`${v}`); - }); - - test('does not accept non-numeric characters', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `b`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(''); - }); - - test('does not accept input with a period/decimal', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `2.`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(''); - }); - }); - - describe('when current value is 0', () => { - if (segment !== 'year') { - test('rejects additional 0 as input', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - `00`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(`0`); - }); - } - test.each(range(1, 10))('accepts 0%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - `0${i}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(`0${i}`); - }); - test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - ``, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(``); - }); - }); - - describe('when current value is 1', () => { - test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - ``, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(``); - }); - - if (segment === 'month') { - test.each(range(0, 3))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - 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}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(`${i}`); - }); - }); - } else { - test.each(range(10))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(`1${i}`); - }); - } - }); - - describe('when current value is 3', () => { - test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - ``, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - 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}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - 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}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - 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}`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(`${i}`); - }); - }); - break; - } - - default: - break; - } - }); - - describe('when current value is a full formatted value', () => { - const formatter = getValueFormatter(segment, charsPerSegment); - 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`, - charsPerSegment, - defaultMin, - defaultMax, - segmentObj, - ); - expect(newValue).toEqual(val); - }, - ); - }); - }); -}); diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts deleted file mode 100644 index a240603117..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ /dev/null @@ -1,65 +0,0 @@ -import last from 'lodash/last'; - -import { truncateStart } from '@leafygreen-ui/lib'; -import { isValidValueForSegment } from '../../../../utils'; - -// TODO: MOVE TO the new input box component - -/** - * 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 = < - T extends string, - V extends string, ->( - segmentName: T, - currentValue: V, - incomingValue: V, - charsPerSegment: Record, - defaultMin: Record, - defaultMax: Record, - segmentObj: Readonly>, -): V => { - // 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, - defaultMin, - defaultMax, - segmentObj, - ); - - if (isIncomingValueValid || segmentName === 'year') { - const newValue = truncateStart(incomingValue, { - length: charsPerSegment[segmentName], - }); - - return newValue as V; - } - - const typedChar = last(incomingValue.split('')); - const newValue = typedChar === '0' ? '0' : typedChar ?? ''; - return newValue as V; -}; diff --git a/packages/date-picker/src/shared/components/InputSegment/utils/index.ts b/packages/date-picker/src/shared/components/InputSegment/utils/index.ts deleted file mode 100644 index 8326610773..0000000000 --- a/packages/date-picker/src/shared/components/InputSegment/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; -export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; 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/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 488cff4d92..0413ede387 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -81,7 +81,7 @@ export const InputBoxWithRef = >( 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 + // Auto-format the segment if it is explicit and was not changed via arrow-keys e.g. up/down arrows if ( !changedViaArrowKeys && isExplicitSegmentValue(segmentName, segmentValue) @@ -203,6 +203,7 @@ export const InputBoxWithRef = >( return (
= { month: 2, year: 4, }; -describe('packages/date-picker/utils/valueFormatter', () => { + +describe('packages/input-box/utils/valueFormatter', () => { describe.each(['day', 'month'] as Array)('', segment => { const formatter = getValueFormatter(segment, charsPerSegment); diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 91252951e6..0cdf20e11a 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -102,7 +102,8 @@ const InputSegmentWithRef = , V extends string>( const isNumber = Number(key) && key !== keyMap.Space; if (isNumber) { - // if the value length is equal to the maxLength, reset the input + // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called. + if (target.value.length === charsPerSegment[segment]) { target.value = ''; } @@ -195,6 +196,7 @@ const InputSegmentWithRef = , V extends string>( onBlur={onBlur} onKeyDown={handleKeyDown} data-segment={String(segment)} + // TODO: use getInputSegmentStyles className={cx( baseStyles, fontSizeStyles[baseFontSize], diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index 58e526338a..d0771b32c0 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1 +1,2 @@ -export { InputBox, type InputBoxProps } from './InputBox'; \ No newline at end of file +export { InputBox, type InputBoxProps } from './InputBox'; +export { InputSegment, type InputSegmentProps } from './InputSegment'; diff --git a/packages/input-box/src/testing/getTestUtils.spec.tsx b/packages/input-box/src/testing/getTestUtils.spec.tsx deleted file mode 100644 index 9c823ded0d..0000000000 --- a/packages/input-box/src/testing/getTestUtils.spec.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; - -import { InputBox } from '.'; - -describe('packages/input-box/getTestUtils', () => { - test('condition', () => { - - }) -}) diff --git a/packages/input-box/src/testing/getTestUtils.tsx b/packages/input-box/src/testing/getTestUtils.tsx deleted file mode 100644 index ad89a6e99d..0000000000 --- a/packages/input-box/src/testing/getTestUtils.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { findByLgId, getByLgId, queryByLgId } from '@lg-tools/test-harnesses'; - -import { LgIdString } from '@leafygreen-ui/lib'; - -import { DEFAULT_LGID_ROOT, getLgIds } from '../utils/getLgIds'; - -import { TestUtilsReturnType } from './getTestUtils.types'; - -export const getTestUtils = ( - lgId: LgIdString = DEFAULT_LGID_ROOT, -): TestUtilsReturnType => { - const lgIds = getLgIds(lgId); - - return {}; -}; diff --git a/packages/input-box/src/testing/getTestUtils.types.ts b/packages/input-box/src/testing/getTestUtils.types.ts deleted file mode 100644 index 50d2fb417a..0000000000 --- a/packages/input-box/src/testing/getTestUtils.types.ts +++ /dev/null @@ -1 +0,0 @@ -export interface TestUtilsReturnType {} \ No newline at end of file diff --git a/packages/input-box/src/testing/index.ts b/packages/input-box/src/testing/index.ts deleted file mode 100644 index 4c102995fa..0000000000 --- a/packages/input-box/src/testing/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getTestUtils } from './getTestUtils'; -export { type TestUtilsReturnType } from './getTestUtils.types'; diff --git a/packages/input-box/src/testutils/index.ts b/packages/input-box/src/testutils/index.ts new file mode 100644 index 0000000000..b4e795daad --- /dev/null +++ b/packages/input-box/src/testutils/index.ts @@ -0,0 +1,15 @@ +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { createRef } from 'react'; + +type Segment = 'day' | 'month' | 'year'; + +export type SegmentRefs = Record< + Segment, + ReturnType> +>; + +export const segmentRefsMock: SegmentRefs = { + day: createRef(), + month: createRef(), + year: createRef(), +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1297838ca..90c6bc92af 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 @@ -2253,6 +2256,33 @@ importers: specifier: workspace:^ version: link:../../tools/build + packages/input-box: + dependencies: + '@leafygreen-ui/date-utils': + specifier: workspace:^ + version: link:../date-utils + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/hooks': + specifier: workspace:^ + version: link:../hooks + '@leafygreen-ui/leafygreen-provider': + specifier: workspace:^ + version: link:../leafygreen-provider + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/tokens': + specifier: workspace:^ + version: link:../tokens + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography + '@lg-tools/test-harnesses': + specifier: workspace:^ + version: link:../../tools/test-harnesses + packages/input-option: dependencies: '@leafygreen-ui/a11y': From 01a407244643960b7838ae0ae8880af3a4ec9534 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 27 Oct 2025 19:10:09 -0400 Subject: [PATCH 13/56] refactor(date-picker): migrate utility functions to InputBox and enhance segment validation logic for improved date handling --- .../DatePicker/DatePicker.keyboard3.spec.tsx | 2 +- .../DatePickerContent/DatePickerContent.tsx | 1 - .../DatePickerInput/DatePickerInput.tsx | 8 +- .../DateInput/DateInputBox/DateInputBox.tsx | 4 +- .../DateInputSegment.spec.tsx | 3 +- .../DateInputSegment/DateInputSegment.tsx | 10 +- .../DateInputSegment.types.ts | 6 +- packages/date-picker/src/shared/constants.ts | 1 + .../getFormattedDateStringFromSegments.ts | 5 +- .../shared/utils/getRelativeSegment/index.ts | 229 ------------ .../getFormattedSegmentsFromDate.ts | 2 +- .../shared/utils/getValueFormatter/index.ts | 35 -- .../date-picker/src/shared/utils/index.ts | 9 - .../utils/isElementInputSegment/index.ts | 34 -- .../isEverySegmentValid.ts | 9 +- .../isEverySegmentValueExplicit.ts | 15 +- .../utils/isExplicitSegmentValue/index.ts | 52 --- .../isExplicitSegmentValue.spec.ts | 27 -- .../src/shared/utils/isValidSegment/index.ts | 43 --- .../utils/isValidValueForSegment/index.ts | 33 -- packages/input-box/README.md | 3 +- packages/input-box/src/InputBox.stories.tsx | 8 +- .../input-box/src/InputBox/InputBox.spec.tsx | 7 +- packages/input-box/src/InputBox/InputBox.tsx | 26 +- .../input-box/src/InputBox/InputBox.types.ts | 6 +- packages/input-box/src/InputBox/index.ts | 3 +- .../input-box/src/InputBox/utils/index.ts | 6 - .../utils/isElementInputSegment/index.ts | 0 .../InputBox/utils/isInputSegment/index.ts | 0 .../src/InputSegment/InputSegment.spec.tsx | 7 +- .../src/InputSegment/InputSegment.tsx | 20 +- .../src/InputSegment/InputSegment.types.ts | 12 +- packages/input-box/src/InputSegment/index.ts | 8 +- .../getNewSegmentValueFromArrowKeyPress.ts | 54 --- .../input-box/src/InputSegment/utils/index.ts | 2 - packages/input-box/src/index.ts | 17 +- packages/input-box/src/testutils/index.ts | 3 +- .../createExplicitSegmentValidator.spec.ts | 97 ++++++ .../createExplicitSegmentValidator/index.ts | 19 +- ...etNewSegmentValueFromArrowKeyPress.spec.ts | 328 ++++++++++++++++++ .../getNewSegmentValueFromArrowKeyPress.ts | 50 +++ .../getNewSegmentValueFromInputValue.spec.ts | 68 ++-- .../getNewSegmentValueFromInputValue.ts | 29 +- .../getRelativeSegment.spec.tsx | 9 +- .../utils/getRelativeSegment/index.ts | 42 +++ .../utils/getValueFormatter/index.ts | 12 + .../getValueFormatter/valueFormatter.spec.ts | 0 packages/input-box/src/utils/index.ts | 5 +- .../src/utils/isElementInputSegment/index.ts | 13 + .../isElementInputSegment.spec.ts | 95 +++++ .../utils/isValidSegment/index.ts | 22 +- .../isValidSegment/isValidSegment.spec.ts | 4 +- .../src/utils/isValidValueForSegment/index.ts | 31 +- .../isValidValueForSegment.spec.ts | 11 +- 54 files changed, 861 insertions(+), 684 deletions(-) delete mode 100644 packages/date-picker/src/shared/utils/getRelativeSegment/index.ts delete mode 100644 packages/date-picker/src/shared/utils/getValueFormatter/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isElementInputSegment/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isExplicitSegmentValue/isExplicitSegmentValue.spec.ts delete mode 100644 packages/date-picker/src/shared/utils/isValidSegment/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts delete mode 100644 packages/input-box/src/InputBox/utils/index.ts delete mode 100644 packages/input-box/src/InputBox/utils/isElementInputSegment/index.ts delete mode 100644 packages/input-box/src/InputBox/utils/isInputSegment/index.ts delete mode 100644 packages/input-box/src/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts delete mode 100644 packages/input-box/src/InputSegment/utils/index.ts create mode 100644 packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts rename packages/input-box/src/{InputSegment => }/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts (84%) rename packages/input-box/src/{InputSegment => }/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts (56%) rename packages/input-box/src/{InputBox => }/utils/getRelativeSegment/getRelativeSegment.spec.tsx (96%) rename packages/input-box/src/{InputBox => }/utils/getRelativeSegment/index.ts (61%) rename packages/input-box/src/{InputBox => }/utils/getValueFormatter/index.ts (79%) rename packages/input-box/src/{InputBox => }/utils/getValueFormatter/valueFormatter.spec.ts (100%) create mode 100644 packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts rename packages/input-box/src/{InputBox => }/utils/isValidSegment/index.ts (56%) rename packages/input-box/src/{InputBox => }/utils/isValidSegment/isValidSegment.spec.ts (95%) diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx index a20f253d27..1897bf624f 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx @@ -3,6 +3,7 @@ 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'; @@ -10,7 +11,6 @@ import { charsPerSegment, defaultMax, defaultMin } from '../shared/constants'; import { getFormattedDateString, getFormattedSegmentsFromDate, - getValueFormatter, } from '../shared/utils'; import { diff --git a/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx index 6616bcb731..e4b2b77b21 100644 --- a/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerContent/DatePickerContent.tsx @@ -67,7 +67,6 @@ export const DatePickerContent = forwardRef< */ const handleDatePickerKeyDown: KeyboardEventHandler = e => { const { key } = e; - console.log('😈handleDatePickerKeyDown', { key }); switch (key) { case keyMap.Escape: diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx index dd1bab297d..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,10 +18,7 @@ import { } from '../../shared/components/DateInput'; import { DateInputSegmentChangeEventHandler } from '../../shared/components/DateInput/DateInputSegment'; import { useSharedDatePickerContext } from '../../shared/context'; -import { - getFormattedDateStringFromSegments, - isElementInputSegment, -} from '../../shared/utils'; +import { getFormattedDateStringFromSegments } from '../../shared/utils'; import { useDatePickerContext } from '../DatePickerContext'; import { getSegmentToFocus } from '../utils/getSegmentToFocus'; @@ -65,8 +63,6 @@ export const DatePickerInput = forwardRef( setValue(newVal); } - console.log('😈handleInputValueChange', { newVal, segments }); - if (!isNull(newVal) && isInvalidDateObject(newVal)) { const dateString = getFormattedDateStringFromSegments(segments, locale); setInternalErrorMessage(`${dateString} is not a valid date`); 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 41892d841a..64506f6644 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -7,7 +7,9 @@ import { isInvalidDateObject, isValidDate, } from '@leafygreen-ui/date-utils'; +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'; @@ -21,8 +23,6 @@ import { import { DateInputSegment } from '../DateInputSegment'; import { DateInputBoxProps } from './DateInputBox.types'; -import { charsPerSegment, dateSegmentRules } from '../../../constants'; -import { InputBox } from '@leafygreen-ui/input-box'; /** * Renders a styled date input with appropriate segment order & separator characters. 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 92c927fcb2..9682f70886 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 @@ -3,13 +3,14 @@ import { jest } from '@jest/globals'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { getValueFormatter } from '@leafygreen-ui/input-box'; + import { charsPerSegment, defaultMax, defaultMin } from '../../../constants'; import { SharedDatePickerProvider, SharedDatePickerProviderProps, } from '../../../context'; import { DateSegment } from '../../../types'; -import { getValueFormatter } from '../../../utils'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; 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 616c9fce0f..0a4d179e57 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { cx } from '@leafygreen-ui/emotion'; +import { InputSegment } from '@leafygreen-ui/input-box'; import { charsPerSegment, @@ -9,12 +10,11 @@ import { defaultPlaceholder, } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; +import { DateSegment } from '../../../types'; import { getAutoComplete } from '../../../utils'; import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; -import { InputSegment } from '@leafygreen-ui/input-box'; -import { DateSegment } from '../../../types'; /** * Controlled component @@ -53,6 +53,8 @@ export const DateInputSegment = React.forwardRef< const autoComplete = getAutoComplete(autoCompleteProp, segment); + const shouldNotRollover = [DateSegment.Year].includes(segment); + return ( ); 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 258365543a..53d916292d 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,9 +1,9 @@ import React from 'react'; +import { InputSegmentChangeEventHandler } from '@leafygreen-ui/input-box'; import { DarkModeProps, keyMap } from '@leafygreen-ui/lib'; import { DateSegment, DateSegmentValue } from '../../../types'; -import { InputSegmentChangeEventHandler } from '../../InputSegment/InputSegment.types'; export interface DateInputSegmentChangeEvent { segment: DateSegment; @@ -14,10 +14,6 @@ export interface DateInputSegmentChangeEvent { }; } -// export type DateInputSegmentChangeEventHandler = ( -// dateSegmentChangeEvent: DateInputSegmentChangeEvent, -// ) => void; - export type DateInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< DateSegment, DateSegmentValue diff --git a/packages/date-picker/src/shared/constants.ts b/packages/date-picker/src/shared/constants.ts index 36e27ab674..8d46029865 100644 --- a/packages/date-picker/src/shared/constants.ts +++ b/packages/date-picker/src/shared/constants.ts @@ -1,6 +1,7 @@ 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, diff --git a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts index e7793f4825..d366faeef8 100644 --- a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts +++ b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts @@ -1,7 +1,8 @@ -import { DateSegment, DateSegmentsState } from '../../../shared/types'; +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, 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 c11f1611ea..0000000000 --- a/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts +++ /dev/null @@ -1,229 +0,0 @@ -import isUndefined from 'lodash/isUndefined'; -import last from 'lodash/last'; - -type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; -// interface GetRelativeSegmentContext { -// segment: HTMLInputElement | React.RefObject; -// formatParts: SharedDatePickerContextProps['formatParts']; -// segmentRefs: SegmentRefs; -// } - -// TODO: MOVE TO the new input box component -/** - * 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; -// } -// }; - -export const getRelativeSegment = ( - direction: RelativeDirection, - { - segment, - formatParts, - }: { - segment: V; - formatParts?: Array; - }, -): V | 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 V); - - /** 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]; -// } -// } -// }; - -interface GetRelativeSegmentContext< - T extends Record>, -> { - segment: HTMLInputElement | React.RefObject; - formatParts?: Array; - segmentRefs: T; -} - -export const getRelativeSegmentRef = < - T extends Record>, - V extends string, ->( - 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 V); - - const currentSegmentName: V | 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 304076041a..48cd5971fb 100644 --- a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts +++ b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts @@ -1,8 +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'; 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 11ae0ac68a..0000000000 --- a/packages/date-picker/src/shared/utils/getValueFormatter/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import padStart from 'lodash/padStart'; - -import { isZeroLike } from '@leafygreen-ui/lib'; - -// TODO: MOVE TO the new input box component - -/** - * If the value is any form of zero, we set it to an empty string - * otherwise, pad the string with 0s, or trim it to n chars - * - * @param segment - the segment to format - * @param charsPerSegment - the number of characters per segment - * @param val - the value to format - * @returns a value formatter function for the provided segment - */ -export const getValueFormatter = - (segment: T, charsPerSegment: Record) => - (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/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 4db93f11e8..0000000000 --- a/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { SegmentRefs } from '../../hooks'; - -// TODO: git mv to input box utils and then export this in DatePickerInput - -/** - * 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; -// }; - -/** - * Returns whether the given element is a segment - */ -export const isElementInputSegment = < - T extends Record>, ->( - element: HTMLElement, - segmentRefs: T, -): 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 e4e3119cfe..7e8e640e16 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -1,6 +1,7 @@ +import { isValidValueForSegment } from '@leafygreen-ui/input-box'; + import { defaultMax, defaultMin } from '../../constants'; -import { DateSegment, DateSegmentValue, DateSegmentsState } from '../../types'; -import { isValidValueForSegment } from '../isValidValueForSegment'; +import { DateSegment, DateSegmentsState, DateSegmentValue } from '../../types'; /** * Whether every segment in a {@link DateSegmentsState} object is valid @@ -10,8 +11,8 @@ export const isEverySegmentValid = (segments: DateSegmentsState): boolean => { isValidValueForSegment( segment as DateSegment, value as DateSegmentValue, - defaultMin, - defaultMax, + defaultMin[segment as DateSegment], + defaultMax[segment as DateSegment], DateSegment, ), ); diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts b/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts index 10ec19bd54..894f0237b2 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( + DateSegment, + 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 61db85df0b..0000000000 --- a/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { dateSegmentRules } from '../../constants'; -import { DateSegment } 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 = createExplicitSegmentValidator( - DateSegment, - dateSegmentRules, -); - -// TODO: MOVE TO the new input box component -/** - * Configuration for determining if a segment value is explicit - */ -export type ExplicitSegmentRule = { - /** Maximum characters for this segment */ - maxChars: number; - /** Minimum numeric value that makes the input explicit (optional) */ - minExplicitValue?: number; -}; - -/** - * Factory function that creates a segment value validator - * @param segmentEnum - The segment enum/object to validate against - * @param rules - Rules for each segment type - * @returns A function that checks if a segment value is explicit - */ -export function createExplicitSegmentValidator< - T extends Record, ->(segmentEnum: T, rules: Record) { - return (segment: T[keyof T], value: string): boolean => { - if ( - !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) - ) - return false; - - const rule = rules[segment]; - if (!rule) return false; - - const isMaxLength = value.length === rule.maxChars; - const meetsMinValue = rule.minExplicitValue - ? Number(value) >= rule.minExplicitValue - : false; - - return isMaxLength || meetsMinValue; - }; -} 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 c7ebd45ace..0000000000 --- a/packages/date-picker/src/shared/utils/isValidSegment/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -import isUndefined from 'lodash/isUndefined'; - -// TODO: MOVE TO the new input box component ok -/** - * 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; - -export const isValidSegmentValue = (segment?: T): segment is T => - !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) -// ); -// }; - -/** - * A generic type predicate function that checks if a given string is one - * of the values in the provided segment object. - * - * @param segmentObj The runtime object containing the valid string segments (must be 'as const') - * @param name The string to validate - * @returns A boolean and a type predicate (name is T[keyof T]) - */ -export const isValidSegmentName = >>( - segmentObj: T, - name?: string, -): name is T[keyof T] => { - return ( - !isUndefined(name) && - Object.values(segmentObj).includes( - name as (typeof segmentObj)[keyof typeof segmentObj], - ) - ); -}; 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 6872809801..0000000000 --- a/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import inRange from 'lodash/inRange'; - -import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; - -// TODO: move to generic utils and export inside isEverySegmentValid - -/** - * Returns whether a value is valid for a given segment type - */ -export const isValidValueForSegment = ( - segment: T, - value: V, - defaultMin: Record, - defaultMax: Record, - segmentObj: Readonly>, -): boolean => { - const isValidSegmentAndValue = - isValidSegmentValue(value) && isValidSegmentName(segmentObj, segment); - - // TODO: should this be custom? - 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/input-box/README.md b/packages/input-box/README.md index 8f8e34ad8a..793c40f565 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -1,7 +1,7 @@ - # Input Box ![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/input-box.svg) + #### [View on MongoDB.design](https://www.mongodb.design/component/input-box/live-example/) ## Installation @@ -23,4 +23,3 @@ yarn add @leafygreen-ui/input-box ```shell npm install @leafygreen-ui/input-box ``` - diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 1531dfa9d6..df42d2c69d 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -1,4 +1,3 @@ - import React from 'react'; import { StoryFn } from '@storybook/react'; @@ -7,11 +6,8 @@ import { InputBox } from '.'; export default { title: 'Components/InputBox', component: InputBox, -} +}; -const Template: StoryFn = (props) => ( - -); +const Template: StoryFn = props => ; export const Basic = Template.bind({}); - diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index ada6b50fe4..64a43ccf87 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -1,11 +1,8 @@ - import React from 'react'; import { render } from '@testing-library/react'; import { InputBox } from '.'; describe('packages/input-box', () => { - test('condition', () => { - - }) -}) + test('condition', () => {}); +}); diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 0413ede387..acf3525ccb 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -8,15 +8,17 @@ import { cx } from '@leafygreen-ui/emotion'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; -import { - getRelativeSegment, - getValueFormatter, - getRelativeSegmentRef, -} from './utils'; import { InputSegmentChangeEventHandler, isInputSegment, } from '../InputSegment/InputSegment.types'; +import { + createExplicitSegmentValidator, + getRelativeSegment, + getRelativeSegmentRef, + getValueFormatter, + isElementInputSegment, +} from '../utils'; import { segmentPartsWrapperStyles, @@ -24,10 +26,6 @@ import { separatorLiteralStyles, } from './InputBox.styles'; import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; -import { - createExplicitSegmentValidator, - isElementInputSegment, -} from '../utils'; /** * Generic controlled input box component @@ -61,7 +59,7 @@ export const InputBoxWithRef = >( segmentRules, ); - /** Formats and sets the segment value */ + /** Formats and sets the segment value. */ const getFormattedSegmentValue = ( segmentName: (typeof segmentObj)[keyof typeof segmentObj], segmentValue: string, @@ -81,7 +79,7 @@ export const InputBoxWithRef = >( 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 e.g. up/down arrows + // Auto-format the segment if it is explicit and was not changed via arrow-keys e.g. up/down arrows. if ( !changedViaArrowKeys && isExplicitSegmentValue(segmentName, segmentValue) @@ -105,7 +103,7 @@ export const InputBoxWithRef = >( onSegmentChange?.(segmentChangeEvent); }; - /** Triggered when a segment is blurred */ + /** Triggered when a segment is blurred. Formats the segment value and sets it. */ const handleSegmentInputBlur: FocusEventHandler = e => { const segmentName = e.target.getAttribute('id'); const segmentValue = e.target.value; @@ -119,7 +117,7 @@ export const InputBoxWithRef = >( } }; - /** Called on any keydown within the input element */ + /** Called on any keydown within the input element. Manages arrow key navigation. */ const handleInputKeyDown: KeyboardEventHandler = e => { const { target: _target, key } = e; const target = _target as HTMLElement; @@ -202,6 +200,8 @@ export const InputBoxWithRef = >( }; return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions + // We want to allow keydown events to be captured by the parent so that the parent can handle the event.
{ @@ -63,7 +63,7 @@ export interface InputBoxProps> /** * The format parts of the date */ - formatParts?: Intl.DateTimeFormatPart[]; + formatParts?: Array; /** * The number of characters per segment @@ -74,7 +74,7 @@ export interface InputBoxProps> /** * Whether the input box is disabled */ - disabled: boolean; + disabled?: boolean; /** * The rules for the segments diff --git a/packages/input-box/src/InputBox/index.ts b/packages/input-box/src/InputBox/index.ts index ad481cad1c..5b2e30901f 100644 --- a/packages/input-box/src/InputBox/index.ts +++ b/packages/input-box/src/InputBox/index.ts @@ -1,3 +1,2 @@ - -export { InputBox } from './InputBox'; +export { InputBox } from './InputBox'; export { type InputBoxProps } from './InputBox.types'; diff --git a/packages/input-box/src/InputBox/utils/index.ts b/packages/input-box/src/InputBox/utils/index.ts deleted file mode 100644 index d59798a662..0000000000 --- a/packages/input-box/src/InputBox/utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - getRelativeSegment, - getRelativeSegmentRef, -} from './getRelativeSegment'; -export { getValueFormatter } from './getValueFormatter'; -export { isValidSegmentValue, isValidSegmentName } from './isValidSegment'; diff --git a/packages/input-box/src/InputBox/utils/isElementInputSegment/index.ts b/packages/input-box/src/InputBox/utils/isElementInputSegment/index.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/input-box/src/InputBox/utils/isInputSegment/index.ts b/packages/input-box/src/InputBox/utils/isInputSegment/index.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 7627dab1fb..d1224a9dc6 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,11 +1,8 @@ - import React from 'react'; import { render } from '@testing-library/react'; import { InputSegment } from '.'; describe('packages/input-segment', () => { - test('condition', () => { - - }) -}) + test('condition', () => {}); +}); diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 0cdf20e11a..054696f0dd 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -9,6 +9,12 @@ import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; +import { + getNewSegmentValueFromArrowKeyPress, + getNewSegmentValueFromInputValue, + getValueFormatter, +} from '../utils'; + import { baseStyles, fontSizeStyles, @@ -19,11 +25,6 @@ import { InputSegmentComponentType, InputSegmentProps, } from './InputSegment.types'; -import { getValueFormatter } from '../InputBox/utils'; // TODO: moved to shared utils -import { - getNewSegmentValueFromInputValue, - getNewSegmentValueFromArrowKeyPress, -} from './utils'; /** * Generic controlled input segment component @@ -47,10 +48,8 @@ const InputSegmentWithRef = , V extends string>( size, className, segmentObj, - defaultMin, - defaultMax, step = 1, - shouldNotRollover, + shouldNotRollover = false, ...rest }: InputSegmentProps, fwdRef: ForwardedRef, @@ -73,8 +72,8 @@ const InputSegmentWithRef = , V extends string>( value, target.value, charsPerSegment, - defaultMin, - defaultMax, + min, + max, segmentObj, ); @@ -119,7 +118,6 @@ const InputSegmentWithRef = , V extends string>( value, min, max, - segment, step, shouldNotRollover, }); diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 01e4ed4463..acae154a72 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -98,21 +98,17 @@ export interface InputSegmentProps< /** * The step value for the arrow keys - * e.g. 1 - * e.g. { day: 1, month: 1, year: 1 } * * @default 1 */ - step?: number | Partial>; + step?: number; /** - * The segments that should not rollover - * e.g. 'year' - * e.g. ['year', 'month'] + * Whether the segment should not rollover * - * @default undefined + * @default false */ - shouldNotRollover?: T[keyof T] | Array; + shouldNotRollover?: boolean; } /** diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts index e698c9edba..283810ebcb 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1,3 +1,5 @@ - -export { InputSegment } from './InputSegment'; -export { type InputSegmentProps } from './InputSegment.types'; +export { InputSegment } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + type InputSegmentProps, +} from './InputSegment.types'; diff --git a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts deleted file mode 100644 index 21c9b153bd..0000000000 --- a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { keyMap, rollover } from '@leafygreen-ui/lib'; - -interface GetNewSegmentValueFromArrowKeyPress< - T extends string, - V extends string, -> { - value: V; - key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; - segment: T; - min: number; - max: number; - step?: number | Partial>; - shouldNotRollover?: T | Array; -} - -/** - * Returns a new segment value given the current state - */ -export const getNewSegmentValueFromArrowKeyPress = < - T extends string, - V extends string, ->({ - value, - key, - segment, - min, - max, - shouldNotRollover, - step = 1, -}: GetNewSegmentValueFromArrowKeyPress): number => { - const stepValue = typeof step === 'number' ? step : step[segment] ?? 1; - - const valueDiff = key === keyMap.ArrowUp ? stepValue : -stepValue; - const defaultVal = key === keyMap.ArrowUp ? min : max; - - const incrementedValue: number = value - ? Number(value) + valueDiff - : defaultVal; - - let shouldSkipRollover = false; - if (shouldNotRollover !== undefined) { - if (typeof shouldNotRollover === 'string') { - shouldSkipRollover = segment === shouldNotRollover; - } else if (Array.isArray(shouldNotRollover)) { - shouldSkipRollover = shouldNotRollover.includes(segment); - } - } - - const newValue = shouldSkipRollover - ? incrementedValue - : rollover(incrementedValue, min, max); - - return newValue; -}; diff --git a/packages/input-box/src/InputSegment/utils/index.ts b/packages/input-box/src/InputSegment/utils/index.ts deleted file mode 100644 index 8326610773..0000000000 --- a/packages/input-box/src/InputSegment/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; -export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index d0771b32c0..08c6ba1c78 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,2 +1,17 @@ export { InputBox, type InputBoxProps } from './InputBox'; -export { InputSegment, type InputSegmentProps } from './InputSegment'; +export { + InputSegment, + type InputSegmentChangeEventHandler, + type InputSegmentProps, +} from './InputSegment'; +export { + createExplicitSegmentValidator, + type ExplicitSegmentRule, + isElementInputSegment, + isValidValueForSegment, +} from './utils'; +export { getValueFormatter } from './utils/getValueFormatter'; +export { + isValidSegmentName, + isValidSegmentValue, +} from './utils/isValidSegment'; diff --git a/packages/input-box/src/testutils/index.ts b/packages/input-box/src/testutils/index.ts index b4e795daad..bd2cb0744f 100644 --- a/packages/input-box/src/testutils/index.ts +++ b/packages/input-box/src/testutils/index.ts @@ -1,6 +1,7 @@ -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { createRef } from 'react'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + type Segment = 'day' | 'month' | 'year'; export type SegmentRefs = Record< diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts new file mode 100644 index 0000000000..1278085cd8 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -0,0 +1,97 @@ +import { createExplicitSegmentValidator } from '.'; + +const segmentObj = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; + +const rules = { + day: { maxChars: 2, minExplicitValue: 4 }, + month: { maxChars: 2, minExplicitValue: 2 }, + year: { maxChars: 4 }, +}; + +const isExplicitSegmentValue = createExplicitSegmentValidator( + segmentObj, + rules, +); + +describe('packages/input-box/utils/createExplicitSegmentValidator', () => { + describe('day segment', () => { + test('returns false for single digit below minExplicitValue', () => { + expect(isExplicitSegmentValue('day', '1')).toBe(false); + expect(isExplicitSegmentValue('day', '2')).toBe(false); + expect(isExplicitSegmentValue('day', '3')).toBe(false); + }); + + test('returns true for single digit at or above minExplicitValue', () => { + expect(isExplicitSegmentValue('day', '4')).toBe(true); + expect(isExplicitSegmentValue('day', '5')).toBe(true); + expect(isExplicitSegmentValue('day', '9')).toBe(true); + }); + + test('returns true for two-digit values (maxChars)', () => { + expect(isExplicitSegmentValue('day', '01')).toBe(true); + expect(isExplicitSegmentValue('day', '10')).toBe(true); + expect(isExplicitSegmentValue('day', '22')).toBe(true); + expect(isExplicitSegmentValue('day', '31')).toBe(true); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('day', '0')).toBe(false); + expect(isExplicitSegmentValue('day', '')).toBe(false); + }); + }); + + describe('month segment', () => { + test('returns false for single digit below minExplicitValue', () => { + expect(isExplicitSegmentValue('month', '1')).toBe(false); + }); + + test('returns true for single digit at or above minExplicitValue', () => { + expect(isExplicitSegmentValue('month', '2')).toBe(true); + expect(isExplicitSegmentValue('month', '3')).toBe(true); + expect(isExplicitSegmentValue('month', '9')).toBe(true); + }); + + test('returns true for two-digit values (maxChars)', () => { + expect(isExplicitSegmentValue('month', '01')).toBe(true); + expect(isExplicitSegmentValue('month', '12')).toBe(true); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('month', '0')).toBe(false); + expect(isExplicitSegmentValue('month', '')).toBe(false); + }); + }); + + describe('year segment', () => { + test('returns false for values shorter than maxChars', () => { + expect(isExplicitSegmentValue('year', '1')).toBe(false); + expect(isExplicitSegmentValue('year', '20')).toBe(false); + expect(isExplicitSegmentValue('year', '200')).toBe(false); + }); + + test('returns true for four-digit values (maxChars)', () => { + expect(isExplicitSegmentValue('year', '1970')).toBe(true); + expect(isExplicitSegmentValue('year', '2000')).toBe(true); + expect(isExplicitSegmentValue('year', '2023')).toBe(true); + expect(isExplicitSegmentValue('year', '0001')).toBe(true); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('year', '0')).toBe(false); + expect(isExplicitSegmentValue('year', '')).toBe(false); + }); + }); + + describe('invalid segment names', () => { + test('returns false for unknown segment names', () => { + // @ts-expect-error Testing invalid segment + expect(isExplicitSegmentValue('invalid', '10')).toBe(false); + // @ts-expect-error Testing invalid segment + expect(isExplicitSegmentValue('hour', '12')).toBe(false); + }); + }); +}); diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts index dcc3d27be3..a10cbf2b2b 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts @@ -1,23 +1,30 @@ -import { - isValidSegmentName, - isValidSegmentValue, -} from '../../InputBox/utils/isValidSegment'; +import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; /** * Configuration for determining if a segment value is explicit */ -export type ExplicitSegmentRule = { +export interface ExplicitSegmentRule { /** Maximum characters for this segment */ maxChars: number; /** Minimum numeric value that makes the input explicit (optional) */ minExplicitValue?: number; -}; +} /** * Factory function that creates a segment value validator * @param segmentEnum - The segment enum/object to validate against * @param rules - Rules for each segment type * @returns A function that checks if a segment value is explicit + * + * @example + * const segmentObj = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * const rules = { + * day: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 1 }, */ export function createExplicitSegmentValidator< T extends Record, diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts new file mode 100644 index 0000000000..331dcf7561 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts @@ -0,0 +1,328 @@ +import { keyMap } from '@leafygreen-ui/lib'; + +import { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress'; + +describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { + describe('ArrowUp key', () => { + test('increments value by 1 when step is not provided', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(6); + }); + + test('increments value by custom step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(10); + }); + + test('rolls over from max to min', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('does not rollover when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(2039); + }); + + test('rolls over when shouldNotRollover is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '12', + key: keyMap.ArrowUp, + min: 1, + max: 12, + shouldNotRollover: false, + }); + expect(result).toBe(1); + }); + + test('defaults to min when value is empty', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('handles value at min boundary', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(2); + }); + + test('handles mid-range value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '15', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(16); + }); + + test('handles value at max boundary with rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('handles large step increments', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 10, + }); + expect(result).toBe(15); + }); + }); + + describe('ArrowDown key', () => { + test('decrements value by 1 when step is not provided', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(4); + }); + + test('decrements value by custom step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '10', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(5); + }); + + test('rolls over from min to max', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(31); + }); + + test('rolls over from min to max for month range', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 12, + }); + expect(result).toBe(12); + }); + + test('does not rollover when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(1969); + }); + + test('rolls over when shouldNotRollover is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 31, + shouldNotRollover: false, + }); + expect(result).toBe(31); + }); + + test('defaults to max when value is empty', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(31); + }); + + test('handles value at max boundary', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(30); + }); + + test('handles mid-range value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '15', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(14); + }); + + test('handles large step decrements', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '20', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 10, + }); + expect(result).toBe(10); + }); + }); + + describe('edge cases', () => { + test('handles step larger than range with rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 12, + step: 20, + }); + expect(result).toBe(2); // 25 rolls over to 2 + }); + + test('handles step larger than range without rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 12, + step: 20, + shouldNotRollover: true, + }); + expect(result).toBe(25); + }); + + test('handles negative values when not rolling over', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '-5', + key: keyMap.ArrowDown, + min: -10, + max: 10, + }); + expect(result).toBe(-6); + }); + + test('handles rollover with negative range', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '-10', + key: keyMap.ArrowDown, + min: -10, + max: 10, + }); + expect(result).toBe(10); + }); + + test('handles zero as min value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '0', + key: keyMap.ArrowDown, + min: 0, + max: 23, + }); + expect(result).toBe(23); + }); + + test('handles rollover at boundary with step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '30', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(4); // 35 rolls to 4 + }); + + test('handles going below min with step and rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '3', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(29); // -2 rolls to 29 + }); + }); + + describe('shouldNotRollover behavior', () => { + test('allows exceeding max when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(2039); + }); + + test('allows going below min when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(1969); + }); + + test('respects rollover by default', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + }); +}); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts new file mode 100644 index 0000000000..6d2e2e9dc7 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -0,0 +1,50 @@ +import { keyMap, rollover } from '@leafygreen-ui/lib'; + +interface GetNewSegmentValueFromArrowKeyPress { + value: V; + key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; + min: number; + max: number; + step?: number; + shouldNotRollover?: boolean; +} + +/** + * Returns a new segment value given the current state + * + * @param value - The current value of the segment + * @param key - The key pressed + * @param min - The minimum value for the segment + * @param max - The maximum value for the segment + * @param step - The step value for the arrow keys + * @param shouldNotRollover - The segments that should not rollover + * @returns The new value for the segment + * @example + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 31, step: 1}); // 2 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 31, step: 1}); // 31 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 12, step: 1}); // 2 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 12, step: 1}); // 12 + * getNewSegmentValueFromArrowKeyPress({ value: '1970', key: 'ArrowUp', min: 1970, max: 2038, step: 1 }); // 1971 + * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldNotRollover: true }); // 2039 + */ +export const getNewSegmentValueFromArrowKeyPress = ({ + value, + key, + min, + max, + shouldNotRollover, + step = 1, +}: GetNewSegmentValueFromArrowKeyPress): number => { + const valueDiff = key === keyMap.ArrowUp ? step : -step; + const defaultVal = key === keyMap.ArrowUp ? min : max; + + const incrementedValue: number = value + ? Number(value) + valueDiff + : defaultVal; + + const newValue = shouldNotRollover + ? incrementedValue + : rollover(incrementedValue, min, max); + + return newValue; +}; diff --git a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts similarity index 84% rename from packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts rename to packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index daa289e406..11c7e0282a 100644 --- a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -1,6 +1,6 @@ import range from 'lodash/range'; -import { getValueFormatter } from '../../../InputBox/utils'; +import { getValueFormatter } from '../getValueFormatter'; import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; @@ -28,7 +28,7 @@ const segmentObj = { year: 'year', }; -describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromInputValue', () => { +describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { describe.each(['day', 'month', 'year'])('For segment %p', _segment => { const segment = _segment as 'day' | 'month' | 'year'; describe('when current value is empty', () => { @@ -38,8 +38,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '', `${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`${i}`); @@ -52,8 +52,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '', `${v}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`${v}`); @@ -65,8 +65,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '', `b`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(''); @@ -78,8 +78,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '', `2.`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(''); @@ -94,8 +94,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '0', `00`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`0`); @@ -107,8 +107,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '0', `0${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`0${i}`); @@ -119,8 +119,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '0', ``, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(``); @@ -134,8 +134,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '1', ``, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(``); @@ -148,8 +148,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '1', `1${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`1${i}`); @@ -161,8 +161,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '1', `1${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`${i}`); @@ -175,8 +175,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '1', `1${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`1${i}`); @@ -191,8 +191,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '3', ``, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(``); @@ -206,8 +206,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '3', `3${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`3${i}`); @@ -219,8 +219,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '3', `3${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`${i}`); @@ -237,8 +237,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI '3', `3${i}`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(`${i}`); @@ -265,8 +265,8 @@ describe('packages/date-picker/shared/date-input-segment/getNewSegmentValueFromI val, `${val}1`, charsPerSegment, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], segmentObj, ); expect(newValue).toEqual(val); diff --git a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts similarity index 56% rename from packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts rename to packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index e935ec7723..a44971a185 100644 --- a/packages/input-box/src/InputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -1,7 +1,10 @@ import last from 'lodash/last'; import { truncateStart } from '@leafygreen-ui/lib'; -import { isValidValueForSegment } from '../../../utils'; + +import { isValidValueForSegment } from '..'; + +// TODO: make props an object with all the necessary properties /** * Calculates the new value for the segment given an incoming change. @@ -10,6 +13,26 @@ import { isValidValueForSegment } from '../../../utils'; * - are not valid numbers * - include a period * - would cause the segment to overflow + * + * @param segmentName - The name of the segment + * @param currentValue - The current value of the segment + * @param incomingValue - The incoming value to set + * @param charsPerSegment - The number of characters per segment + * @param defaultMin - The default minimum value for the segment + * @param defaultMax - The default maximum value for the segment + * @param segmentObj - The segment object + * @returns The new value for the segment + * @example + * // The segmentObj is the object that contains the segment names and their corresponding values + * const segmentObj = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * getNewSegmentValueFromInputValue('day', '1', '2', { day: 2, month: 2, year: 4 }, 1, 31, segmentObj); // '2' + * getNewSegmentValueFromInputValue('month', '1', '2', { day: 2, month: 2, year: 4 }, 1, 12, segmentObj); // '2' + * getNewSegmentValueFromInputValue('year', '1', '2', { day: 2, month: 2, year: 4 }, 1970, 2038, segmentObj); // '2' + * getNewSegmentValueFromInputValue('day', '1', '.', { day: 2, month: 2, year: 4 }, 1, 31, segmentObj); // '1' */ export const getNewSegmentValueFromInputValue = < T extends string, @@ -19,8 +42,8 @@ export const getNewSegmentValueFromInputValue = < currentValue: V, incomingValue: V, charsPerSegment: Record, - defaultMin: Record, - defaultMax: Record, + defaultMin: number, + defaultMax: number, segmentObj: Readonly>, ): V => { // If the incoming value is not a valid number diff --git a/packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx similarity index 96% rename from packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx rename to packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx index 5dbd7f95e0..b5331a53d7 100644 --- a/packages/input-box/src/InputBox/utils/getRelativeSegment/getRelativeSegment.spec.tsx +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx @@ -1,15 +1,16 @@ import React, { createRef } from 'react'; -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { render } from '@testing-library/react'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + type Segment = 'day' | 'month' | 'year'; -export type SegmentRefs = Record< +type SegmentRefs = Record< Segment, ReturnType> >; -export const segmentRefsMock: SegmentRefs = { +const segmentRefsMock: SegmentRefs = { day: createRef(), month: createRef(), year: createRef(), @@ -43,7 +44,7 @@ const renderTestComponent = () => { }; }; -describe('packages/date-picker/utils/getRelativeSegment', () => { +describe('packages/input-box/utils/getRelativeSegment', () => { const formatParts: Array = [ { type: 'year', value: '2023' }, { type: 'literal', value: '-' }, diff --git a/packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts b/packages/input-box/src/utils/getRelativeSegment/index.ts similarity index 61% rename from packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts rename to packages/input-box/src/utils/getRelativeSegment/index.ts index 3544ff1ea5..578bf6ddb4 100644 --- a/packages/input-box/src/InputBox/utils/getRelativeSegment/index.ts +++ b/packages/input-box/src/utils/getRelativeSegment/index.ts @@ -6,6 +6,25 @@ type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; /** * Given a direction, starting segment name & format * returns the segment name in the given direction + * + * @param direction - The direction to get the relative segment from + * @param segment - The starting segment name + * @param formatParts - The format parts of the date + * @returns The segment name in the given direction + * @example + * const formatParts = [ + * { type: 'year', value: '2023' }, + * { type: 'literal', value: '-' }, + * { type: 'month', value: '10' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '31' }, + * ]; + * getRelativeSegment('next', { segment: 'year', formatParts }); // 'month' + * getRelativeSegment('next', { segment: 'month', formatParts }); // 'day' + * getRelativeSegment('prev', { segment: 'day', formatParts }); // 'month' + * getRelativeSegment('prev', { segment: 'month', formatParts }); // 'year' + * getRelativeSegment('first', { segment: 'day', formatParts }); // 'year' + * getRelativeSegment('last', { segment: 'year', formatParts }); // 'day' */ export const getRelativeSegment = ( direction: RelativeDirection, @@ -80,6 +99,29 @@ interface GetRelativeSegmentContext< /** * Given a direction, staring segment, and segment refs, * returns the segment ref in the given direction + * + * @param direction - The direction to get the relative segment from + * @param segment - The starting segment ref + * @param formatParts - The format parts of the date + * @param segmentRefs - The segment refs + * @returns The segment ref in the given direction + * @example + * const formatParts = [ + * { type: 'year', value: '2023' }, + * { type: 'literal', value: '-' }, + * { type: 'month', value: '10' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '31' }, + * ]; + * const segmentRefs = { + * year: yearRef, + * month: monthRef, + * day: dayRef, + * }; + * getRelativeSegmentRef('next', { segment: yearRef, formatParts, segmentRefs }); // monthRef + * getRelativeSegmentRef('prev', { segment: dayRef, formatParts, segmentRefs }); // monthRef + * getRelativeSegmentRef('first', { segment: monthRef, formatParts, segmentRefs }); // yearRef + * getRelativeSegmentRef('last', { segment: monthRef, formatParts, segmentRefs }); // dayRef */ export const getRelativeSegmentRef = < T extends Record>, diff --git a/packages/input-box/src/InputBox/utils/getValueFormatter/index.ts b/packages/input-box/src/utils/getValueFormatter/index.ts similarity index 79% rename from packages/input-box/src/InputBox/utils/getValueFormatter/index.ts rename to packages/input-box/src/utils/getValueFormatter/index.ts index 3ce6c53cdb..6f421bd5d9 100644 --- a/packages/input-box/src/InputBox/utils/getValueFormatter/index.ts +++ b/packages/input-box/src/utils/getValueFormatter/index.ts @@ -10,6 +10,18 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * @param charsPerSegment - the number of characters per segment * @param val - the value to format * @returns a value formatter function for the provided segment + * + * @example + * const charsPerSegment = { + * day: 2, + * month: 2, + * year: 4, + * }; + * const formatter = getValueFormatter('day', charsPerSegment); + * formatter('0'); // '' + * formatter('1'); // '01' + * formatter('12'); // '12' + * formatter('123'); // '23' */ export const getValueFormatter = (segment: T, charsPerSegment: Record) => diff --git a/packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts similarity index 100% rename from packages/input-box/src/InputBox/utils/getValueFormatter/valueFormatter.spec.ts rename to packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index 6efd3a0bb6..c1e0eea3c1 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -1,7 +1,8 @@ -export { isValidValueForSegment } from './isValidValueForSegment'; export { createExplicitSegmentValidator, ExplicitSegmentRule, } from './createExplicitSegmentValidator'; - +export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; export { isElementInputSegment } from './isElementInputSegment'; +export { isValidValueForSegment } from './isValidValueForSegment'; diff --git a/packages/input-box/src/utils/isElementInputSegment/index.ts b/packages/input-box/src/utils/isElementInputSegment/index.ts index 4f59087128..411237f8cb 100644 --- a/packages/input-box/src/utils/isElementInputSegment/index.ts +++ b/packages/input-box/src/utils/isElementInputSegment/index.ts @@ -1,5 +1,18 @@ /** * Returns whether the given element is a segment + * @param element - The element to check + * @param segmentObj - The segment object + * @returns Whether the element is a segment + * @example + * // In the segmentRefs object, the key is the segment name and the value is the ref object + * const segmentRefs = { + * day: { current: document.querySelector('input[data-segment="day"]') }, + * month: { current: document.querySelector('input[data-segment="month"]') }, + * year: { current: document.querySelector('input[data-segment="year"]') }, + * }; + * isElementInputSegment(document.querySelector('input[data-segment="day"]'), segmentRefs); // true + * isElementInputSegment(document.querySelector('input[data-segment="month"]'), segmentRefs); // true + * isElementInputSegment(document.querySelector('input[data-segment="year"]'), segmentRefs); // true */ export const isElementInputSegment = < T extends Record>, diff --git a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts new file mode 100644 index 0000000000..eff2da34cb --- /dev/null +++ b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts @@ -0,0 +1,95 @@ +import React from 'react'; + +import { isElementInputSegment } from '.'; + +describe('packages/input-box/utils/isElementInputSegment', () => { + describe('isElementInputSegment', () => { + let dayInput: HTMLInputElement; + let monthInput: HTMLInputElement; + let yearInput: HTMLInputElement; + let unrelatedInput: HTMLInputElement; + let segmentRefs: Record>; + + beforeEach(() => { + // Create input elements + dayInput = document.createElement('input'); + dayInput.setAttribute('data-segment', 'day'); + + monthInput = document.createElement('input'); + monthInput.setAttribute('data-segment', 'month'); + + yearInput = document.createElement('input'); + yearInput.setAttribute('data-segment', 'year'); + + unrelatedInput = document.createElement('input'); + unrelatedInput.setAttribute('data-testid', 'unrelated'); + + // Create segment refs + segmentRefs = { + day: { current: dayInput }, + month: { current: monthInput }, + year: { current: yearInput }, + }; + }); + + test('returns true when element is the day segment', () => { + expect(isElementInputSegment(dayInput, segmentRefs)).toBe(true); + }); + + test('returns true when element is the month segment', () => { + expect(isElementInputSegment(monthInput, segmentRefs)).toBe(true); + }); + + test('returns true when element is the year segment', () => { + expect(isElementInputSegment(yearInput, segmentRefs)).toBe(true); + }); + + test('returns false when element is not in segment refs', () => { + expect(isElementInputSegment(unrelatedInput, segmentRefs)).toBe(false); + }); + + test('returns false when segmentRefs is empty', () => { + const emptySegmentRefs = {}; + expect(isElementInputSegment(dayInput, emptySegmentRefs)).toBe(false); + }); + + test('returns false when all segment refs are null', () => { + const nullSegmentRefs = { + day: { current: null }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(dayInput, nullSegmentRefs)).toBe(false); + }); + + test('returns true when element matches one of the non-null refs', () => { + const partialSegmentRefs = { + day: { current: dayInput }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(dayInput, partialSegmentRefs)).toBe(true); + }); + + test('returns false when element does not match the only non-null ref', () => { + const partialSegmentRefs = { + day: { current: dayInput }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(monthInput, partialSegmentRefs)).toBe(false); + }); + + test('returns false when checking a div element not in segment refs', () => { + const divElement = document.createElement('div'); + expect(isElementInputSegment(divElement, segmentRefs)).toBe(false); + }); + + test('returns true when segment has a single input', () => { + const singleSegmentRefs = { + hour: { current: dayInput }, + }; + expect(isElementInputSegment(dayInput, singleSegmentRefs)).toBe(true); + }); + }); +}); diff --git a/packages/input-box/src/InputBox/utils/isValidSegment/index.ts b/packages/input-box/src/utils/isValidSegment/index.ts similarity index 56% rename from packages/input-box/src/InputBox/utils/isValidSegment/index.ts rename to packages/input-box/src/utils/isValidSegment/index.ts index 4ab45be909..c25fb69379 100644 --- a/packages/input-box/src/InputBox/utils/isValidSegment/index.ts +++ b/packages/input-box/src/utils/isValidSegment/index.ts @@ -3,25 +3,35 @@ import isUndefined from 'lodash/isUndefined'; /** * Returns whether a given value is a valid segment value */ -export const isValidSegmentValue = (segment?: T): segment is T => +export const isValidSegmentValue = ( + segment?: T, +): segment is T => !isUndefined(segment) && !isNaN(Number(segment)) && Number(segment) > 0; /** * A generic type predicate function that checks if a given string is one * of the values in the provided segment object. * - * @param segmentObj The runtime object containing the valid string segments (must be 'as const') + * @param segmentObj The runtime object containing the valid string segments * @param name The string to validate * @returns A boolean and a type predicate (name is T[keyof T]) + * + * @example + * const segmentObj = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * isValidSegmentName(segmentObj, 'day'); // true + * isValidSegmentName(segmentObj, 'month'); // true + * isValidSegmentName(segmentObj, 'year'); // true + * isValidSegmentName(segmentObj, 'seconds'); // false */ export const isValidSegmentName = >>( segmentObj: T, name?: string, ): name is T[keyof T] => { return ( - !isUndefined(name) && - Object.values(segmentObj).includes( - name as (typeof segmentObj)[keyof typeof segmentObj], - ) + !isUndefined(name) && Object.values(segmentObj).includes(name as T[keyof T]) ); }; diff --git a/packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts similarity index 95% rename from packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts rename to packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts index 36e0f65ec3..f27081839d 100644 --- a/packages/input-box/src/InputBox/utils/isValidSegment/isValidSegment.spec.ts +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts @@ -1,13 +1,13 @@ import { isValidSegmentName, isValidSegmentValue } from '.'; -export const Segment = { +const Segment = { Day: 'day', Month: 'month', Year: 'year', } as const; type SegmentValue = string; -describe('packages/date-picker/utils/isValidSegment', () => { +describe('packages/input-box/utils/isValidSegment', () => { describe('isValidSegment', () => { test('undefined returns false', () => { expect(isValidSegmentValue()).toBeFalsy(); diff --git a/packages/input-box/src/utils/isValidValueForSegment/index.ts b/packages/input-box/src/utils/isValidValueForSegment/index.ts index fd556adaf0..55251ef8f6 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/index.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/index.ts @@ -1,15 +1,34 @@ import inRange from 'lodash/inRange'; -import { isValidSegmentName, isValidSegmentValue } from '../../InputBox/utils'; +import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; /** * Returns whether a value is valid for a given segment type + * @param segment - The segment type + * @param value - The value to check + * @param defaultMin - The default minimum value for the segment + * @param defaultMax - The default maximum value for the segment + * @param segmentObj - The segment object + * @returns Whether the value is valid for the segment + * @example + * // The segmentObj is the object that contains the segment names and their corresponding values + * const segmentObj = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * isValidValueForSegment('day', '1', 1, 31, segmentObj); // true + * isValidValueForSegment('day', '32', 1, 31, segmentObj); // false + * isValidValueForSegment('month', '1', 1, 12, segmentObj); // true + * isValidValueForSegment('month', '13', 1, 12, segmentObj); // false + * isValidValueForSegment('year', '1970', 1000, 9999, segmentObj); // true + * isValidValueForSegment('year', '10000', 1000, 9999, segmentObj); // false */ export const isValidValueForSegment = ( segment: T, value: V, - defaultMin: Record, - defaultMax: Record, + defaultMin: number, + defaultMax: number, segmentObj: Readonly>, ): boolean => { const isValidSegmentAndValue = @@ -21,11 +40,7 @@ export const isValidValueForSegment = ( return isValidSegmentAndValue && inRange(Number(value), 1000, 9999 + 1); } - const isInRange = inRange( - Number(value), - defaultMin[segment], - defaultMax[segment] + 1, - ); + const isInRange = inRange(Number(value), defaultMin, defaultMax + 1); return isValidSegmentAndValue && isInRange; }; diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts index f4d5b86d6c..23619d12b9 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -1,4 +1,3 @@ -import { MAX_DATE, MIN_DATE } from '@leafygreen-ui/date-utils'; import { isValidValueForSegment } from '.'; const SegmentObj = { @@ -12,26 +11,26 @@ type SegmentObj = (typeof SegmentObj)[keyof typeof SegmentObj]; const defaultMin = { day: 1, month: 1, - year: MIN_DATE.getUTCFullYear(), + year: 1970, } as const; const defaultMax = { day: 31, month: 12, - year: MAX_DATE.getUTCFullYear(), + year: 2038, } as const; const isValidValueForSegmentWrapper = (segment: SegmentObj, value: string) => { return isValidValueForSegment( segment, value, - defaultMin, - defaultMax, + defaultMin[segment], + defaultMax[segment], SegmentObj, ); }; -describe('packages/date-picker/utils/isValidSegmentValue', () => { +describe('packages/input-box/utils/isValidSegmentValue', () => { test('day', () => { expect(isValidValueForSegmentWrapper('day', '1')).toBe(true); expect(isValidValueForSegmentWrapper('day', '15')).toBe(true); From 0d8a8270759f7e5d0647b297ac417c145130c77d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 27 Oct 2025 20:15:56 -0400 Subject: [PATCH 14/56] refactor(date-picker): improve type safety in DateInputSegment and clean up InputBox component --- .../DateInput/DateInputSegment/DateInputSegment.tsx | 4 +++- packages/input-box/src/InputBox/InputBox.tsx | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) 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 0a4d179e57..4c5b14bb81 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -53,7 +53,9 @@ export const DateInputSegment = React.forwardRef< const autoComplete = getAutoComplete(autoCompleteProp, segment); - const shouldNotRollover = [DateSegment.Year].includes(segment); + const shouldNotRollover = ([DateSegment.Year] as DateSegment[]).includes( + segment, + ); return ( >( // eslint-disable-next-line jsx-a11y/no-static-element-interactions // We want to allow keydown events to be captured by the parent so that the parent can handle the event.
Date: Mon, 27 Oct 2025 20:26:37 -0400 Subject: [PATCH 15/56] refactor(input-box): update exports in utils to include new segment validation and formatting functions --- packages/input-box/src/utils/index.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index c1e0eea3c1..3846e7a3d9 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -1,8 +1,15 @@ +export { isValidValueForSegment } from './isValidValueForSegment'; export { createExplicitSegmentValidator, ExplicitSegmentRule, } from './createExplicitSegmentValidator'; -export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; -export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; + export { isElementInputSegment } from './isElementInputSegment'; -export { isValidValueForSegment } from './isValidValueForSegment'; +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; +export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +export { + getRelativeSegment, + getRelativeSegmentRef, +} from './getRelativeSegment'; +export { getValueFormatter } from './getValueFormatter'; +export { isValidSegmentValue, isValidSegmentName } from './isValidSegment'; From 9e63f3b9d3a0d09a9917ca4c8b884317bfd7af98 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 28 Oct 2025 17:02:29 -0400 Subject: [PATCH 16/56] refactor(input-box): add devDependencies for palette and enhance InputSegment and InputBox tests for better coverage --- .../DateInputSegment/DateInputSegment.tsx | 6 +- packages/input-box/package.json | 3 + .../input-box/src/InputBox/InputBox.spec.tsx | 307 +++++++++- .../src/InputBox/InputBox.stories.tsx | 52 ++ packages/input-box/src/InputBox/InputBox.tsx | 2 +- .../src/InputSegment/InputSegment.spec.tsx | 528 +++++++++++++++++- .../src/InputSegment/InputSegment.stories.tsx | 103 ++++ .../src/InputSegment/InputSegment.types.ts | 14 +- packages/input-box/src/testutils/index.ts | 16 - packages/input-box/src/testutils/index.tsx | 275 +++++++++ packages/input-box/src/utils/index.ts | 9 +- packages/input-box/tsconfig.json | 3 + pnpm-lock.yaml | 4 + 13 files changed, 1278 insertions(+), 44 deletions(-) create mode 100644 packages/input-box/src/InputBox/InputBox.stories.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.tsx delete mode 100644 packages/input-box/src/testutils/index.ts create mode 100644 packages/input-box/src/testutils/index.tsx 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 4c5b14bb81..34efde1ee3 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -53,9 +53,9 @@ export const DateInputSegment = React.forwardRef< const autoComplete = getAutoComplete(autoCompleteProp, segment); - const shouldNotRollover = ([DateSegment.Year] as DateSegment[]).includes( - segment, - ); + const shouldNotRollover = ( + [DateSegment.Year] as Array + ).includes(segment); return ( { - test('condition', () => {}); + describe('Rendering', () => { + describe.each(['day', 'month', 'year'])('%p', segment => { + test('renders the correct aria attributes', () => { + const { getByLabelText } = renderInputBox({}); + const input = getByLabelText(segment); + + // each segment has appropriate aria label + expect(input).toHaveAttribute('aria-label', segment); + }); + }); + + test('renders segments in the correct order', () => { + const { getAllByRole } = renderInputBox({}); + const segments = getAllByRole('spinbutton'); + expect(segments[0]).toHaveAttribute('aria-label', 'month'); + expect(segments[1]).toHaveAttribute('aria-label', 'day'); + expect(segments[2]).toHaveAttribute('aria-label', 'year'); + }); + + test('renders filled segments when a value is passed', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({}); + + expect(dayInput.value).toBe('02'); + expect(monthInput.value).toBe('02'); + expect(yearInput.value).toBe('2025'); + }); + + test.todo('does not render non-segment parts as inputs'); + }); + + describe('rerendering', () => { + test('with new value updates the segments', () => { + const { rerenderInputBox, dayInput, monthInput, yearInput } = + renderInputBox({}); + expect(dayInput.value).toBe('02'); + expect(monthInput.value).toBe('02'); + expect(yearInput.value).toBe('2025'); + + rerenderInputBox({ segments: { day: '26', month: '09', year: '1993' } }); + expect(dayInput.value).toBe('26'); + expect(monthInput.value).toBe('09'); + expect(yearInput.value).toBe('1993'); + }); + }); + + describe('onSegmentChange', () => { + test('is called when a segment value changes', () => { + const onSegmentChange = + jest.fn>(); + const { dayInput } = renderInputBox({ + onSegmentChange, + segments: { day: '', month: '', year: '' }, + }); + expect(dayInput.value).toBe(''); + userEvent.type(dayInput, '2'); + expect(onSegmentChange).toHaveBeenCalledWith( + expect.objectContaining({ value: '2' }), + ); + }); + + test('is called when deleting from a single segment', () => { + const onSegmentChange = + jest.fn>(); + const { dayInput } = renderInputBox({ + onSegmentChange, + segments: { day: '21', month: '', year: '' }, + }); + + userEvent.type(dayInput, '{backspace}'); + expect(onSegmentChange).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + + describe('setSegment', () => { + test('is called when a segment value changes', () => { + const setSegment = jest.fn(); + const { dayInput } = renderInputBox({ + setSegment, + segments: { day: '', month: '', year: '' }, + }); + userEvent.type(dayInput, '2'); + expect(setSegment).toHaveBeenCalledWith('day', '2'); + }); + + test('is called when deleting from a single segment', () => { + const setSegment = jest.fn(); + const { dayInput } = renderInputBox({ + setSegment, + segments: { day: '21', month: '', year: '' }, + }); + + userEvent.type(dayInput, '{backspace}'); + expect(setSegment).toHaveBeenCalledWith('day', ''); + }); + }); + + describe('renderSegment', () => { + test('calls renderSegment for each segment with correct props', () => { + const mockRenderSegment = jest.fn(({ partType, onChange, onBlur }) => ( + // @ts-expect-error - we are not passing all the props to the InputSegment component + + )); + renderInputBox({ + renderSegment: mockRenderSegment, + formatParts: [ + { type: 'year', value: '' }, + { type: 'literal', value: '-' }, + { type: 'month', value: '' }, + { type: 'literal', value: '-' }, + { type: 'day', value: '' }, + ], + }); + // Verify renderSegment was called 3 times (once per segment) + expect(mockRenderSegment).toHaveBeenCalledTimes(3); + // Check first call (year) + expect(mockRenderSegment).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + partType: 'year', + onChange: expect.any(Function), + onBlur: expect.any(Function), + }), + ); + // Check second call (month) + expect(mockRenderSegment).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + partType: 'month', + onChange: expect.any(Function), + onBlur: expect.any(Function), + }), + ); + // Check third call (day) + expect(mockRenderSegment).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + partType: 'day', + onChange: expect.any(Function), + onBlur: expect.any(Function), + }), + ); + }); + }); + + describe('auto-focus', () => { + test('focuses the next segment when an explicit value is entered', () => { + const { dayInput, monthInput } = renderInputBoxWithState({}); + + userEvent.type(monthInput, '02'); + expect(dayInput).toHaveFocus(); + }); + + test('focus remains in the current segment when an ambiguous value is entered', () => { + const { dayInput } = renderInputBoxWithState({}); + + userEvent.type(dayInput, '2'); + expect(dayInput).toHaveFocus(); + }); + + test('focuses the previous segment when a backspace is pressed and the current segment is empty', () => { + const { dayInput, monthInput } = renderInputBoxWithState({}); + + userEvent.type(dayInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + + test('focus remains in the current segment when a backspace is pressed and the current segment is not empty', () => { + const { monthInput } = renderInputBoxWithState({}); + + userEvent.type(monthInput, '2'); + userEvent.type(monthInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + }); + + describe('Mouse interaction', () => { + test('click on segment focuses it', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.click(dayInput); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Keyboard interaction', () => { + test('Tab moves focus to next segment', () => { + const { dayInput, monthInput, yearInput } = renderInputBoxWithState({}); + userEvent.click(monthInput); + userEvent.tab(); + expect(dayInput).toHaveFocus(); + userEvent.tab(); + expect(yearInput).toHaveFocus(); + }); + + test('Right arrow key moves focus to next segment', () => { + const { dayInput, monthInput, yearInput } = renderInputBoxWithState({}); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowright}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('Left arrow key moves focus to previous segment', () => { + const { dayInput, monthInput, yearInput } = renderInputBoxWithState({}); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + }); + + describe('typing', () => { + describe('explicit value', () => { + test('updates the rendered segment value', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '26'); + expect(dayInput.value).toBe('26'); + }); + + test('segment value is immediately formatted', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '5'); + expect(dayInput.value).toBe('05'); + }); + + test('allows leading zeros', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '02'); + expect(dayInput.value).toBe('02'); + }); + }); + + describe('ambiguous value', () => { + test('segment value is not immediately formatted', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '2'); + expect(dayInput.value).toBe('2'); + }); + + test('value is formatted on segment blur', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '2'); + userEvent.tab(); + expect(dayInput.value).toBe('02'); + }); + + test('allows leading zeros', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '0'); + expect(dayInput.value).toBe('0'); + }); + + test('allows backspace to delete the value', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '2'); + userEvent.type(dayInput, '{backspace}'); + expect(dayInput.value).toBe(''); + }); + }); + + test('returns no value with leading zero on blur', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '0'); + userEvent.tab(); + expect(dayInput.value).toBe(''); + }); + + test('does not allow non-number characters', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, 'aB$/'); + expect(dayInput.value).toBe(''); + }); + + test('backspace resets the input', () => { + const { dayInput, yearInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '21'); + userEvent.type(dayInput, '{backspace}'); + expect(dayInput.value).toBe(''); + + userEvent.type(yearInput, '1993'); + userEvent.type(yearInput, '{backspace}'); + expect(yearInput.value).toBe(''); + }); + }); }); diff --git a/packages/input-box/src/InputBox/InputBox.stories.tsx b/packages/input-box/src/InputBox/InputBox.stories.tsx new file mode 100644 index 0000000000..3b5e503f3d --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.stories.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; +import { StoryFn } from '@storybook/react'; + +import { css } from '@leafygreen-ui/emotion'; +import { palette } from '@leafygreen-ui/palette'; + +import { InputBoxWithState } from '../testutils'; + +import { InputBox } from '.'; + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox', + component: InputBox, + decorators: [ + StoryFn => ( +
+ +
+ ), + ], + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segments', + 'segmentObj', + 'segmentRefs', + 'setSegment', + 'charsPerSegment', + 'formatParts', + 'segmentRules', + 'labelledBy', + 'onSegmentChange', + 'renderSegment', + ], + }, + }, +}; +export default meta; + +export const LiveExample: StoryFn = props => { + return ; +}; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 4feb53f7c5..30f7e494bf 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -200,8 +200,8 @@ export const InputBoxWithRef = >( }; return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions // We want to allow keydown events to be captured by the parent so that the parent can handle the event. + // eslint-disable-next-line jsx-a11y/no-static-element-interactions
>, +): RenderResult & { + getInput: () => HTMLInputElement; + input: HTMLInputElement; + rerenderSegment: ( + newProps: Partial>, + ) => void; +} => { + const defaultProps: InputSegmentProps = { + value: '', + onChange: () => {}, + segment: 'day', + charsPerSegment: charsPerSegmentMock, + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + segmentObj: SegmentObjMock, + size: Size.Default, + shouldNotRollover: false, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', + }; + + const mergedProps = { + ...defaultProps, + ...props, + }; + + const utils = render(); + + const rerenderSegment = ( + newProps: Partial>, + ) => { + utils.rerender(); + }; + + const getInput = () => + utils.getByTestId('lg-input-segment') as HTMLInputElement; + return { ...utils, getInput, input: getInput(), rerenderSegment }; +}; describe('packages/input-segment', () => { - test('condition', () => {}); + describe('aria attributes', () => { + describe.each(['day', 'month', 'year'])('%p', segment => { + test(`${segment} segment has aria-label`, () => { + const { input } = renderSegment({ segment: segment as SegmentObjMock }); + expect(input).toHaveAttribute('aria-label', segment); + }); + }); + }); + + describe('rendering', () => { + describe('day segment', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({ segment: 'day' }); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ segment: 'day', value: '12' }); + expect(input.value).toBe('12'); + }); + + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + segment: 'day', + value: '12', + }); + + rerenderSegment({ value: '08' }); + expect(getInput().value).toBe('08'); + }); + }); + + describe('month segment', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({ segment: 'month' }); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ segment: 'month', value: '26' }); + expect(input.value).toBe('26'); + }); + + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + segment: 'month', + value: '26', + }); + + rerenderSegment({ value: '08' }); + expect(getInput().value).toBe('08'); + }); + }); + + describe('year segment', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({ segment: 'year' }); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ segment: 'year', value: '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 onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, '8'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '8' }), + ); + }); + + test('allows zero character', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + onChange: onChangeHandler, + }); + + userEvent.type(input, '0'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '0' }), + ); + }); + + test('does not allow non-number characters', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + 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 onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + 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 onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + value: '26', + onChange: onChangeHandler, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); + }); + + describe('keyboard events', () => { + describe('Arrow keys', () => { + const formatter = getValueFormatter('day', charsPerSegmentMock); + + describe('Up arrow', () => { + test('calls handler with value default +1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(16), + }), + ); + }); + + test('calls handler with custom `step`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), + step: 2, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(17), + }), + ); + }); + + test('calls handler with `min`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('rolls value over to `min` value if value exceeds `max`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMaxMock['day']), + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('does not rollover if `shouldNotRollover` is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMaxMock['day']), + shouldNotRollover: true, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day'] + 1), + }), + ); + }); + }); + + describe('Down arrow', () => { + test('calls handler with value default -1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(14), + }), + ); + }); + + test('calls handler with custom `step`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), + step: 2, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(13), + }), + ); + }); + + test('calls handler with `max`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '', + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('rolls value over to `max` value if value exceeds `min`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMinMock['day']), + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('does not rollover if `shouldNotRollover` is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: formatter(defaultMinMock['day']), + shouldNotRollover: true, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day'] - 1), + }), + ); + }); + }); + + describe('Backspace', () => { + test('clears the input when there is a value', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '12', + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('Space', () => { + describe('on a single SPACE', () => { + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('calls the onChangeHandler when the value is present', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '12', + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + + describe('on a double SPACE', () => { + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('calls the onChangeHandler when the value is present', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + onChange: onChangeHandler, + value: '12', + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + }); + }); + }); + }); }); diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx new file mode 100644 index 0000000000..a3f2cb0266 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -0,0 +1,103 @@ +/* eslint-disable no-console */ +import React, { useState } from 'react'; +import { + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; +import { StoryFn } from '@storybook/react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, +} from '../testutils'; + +import { InputSegment } from '.'; + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox/InputSegment', + component: InputSegment, + decorators: [ + (StoryFn, context) => ( + + + + ), + ], + args: { + segment: SegmentObjMock.Day, + value: '', + charsPerSegment: charsPerSegmentMock, + segmentObj: SegmentObjMock, + min: defaultMinMock[SegmentObjMock.Day], + max: defaultMaxMock[SegmentObjMock.Day], + size: Size.Default, + placeholder: defaultPlaceholderMock[SegmentObjMock.Day], + shouldNotRollover: false, + step: 1, + darkMode: false, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + shouldNotRollover: { + control: 'boolean', + }, + step: { + control: 'number', + }, + darkMode: { + control: 'boolean', + }, + }, + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segment', + 'value', + 'onChange', + 'charsPerSegment', + 'segmentObj', + ], + }, + generate: { + combineArgs: { + darkMode: [false, true], + value: ['', '6', '06'], + segment: ['day'], + size: Object.values(Size), + }, + decorator: (StoryFn, context) => ( + + + + ), + }, + }, +}; +export default meta; + +export const LiveExample: StoryFn = props => { + const [value, setValue] = useState(''); + return ( + { + setValue(value); + console.log('🌻Storybook: onChange', { value }); + }} + /> + ); +}; + +export const Generated = () => {}; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index acae154a72..f68e5f707d 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -52,7 +52,7 @@ export interface InputSegmentProps< * The number of characters per segment * e.g. { day: 2, month: 2, year: 4 } */ - charsPerSegment: Record; + charsPerSegment: Record; // TODO: make this a number? /** * Minimum value. @@ -76,18 +76,6 @@ export interface InputSegmentProps< */ segmentObj: T; - /** - * Default minimum value - * e.g. { day: 1, month: 1, year: 1970 } - */ - defaultMin: Record; - - /** - * Default maximum value - * e.g. { day: 31, month: 12, year: 2038 } - */ - defaultMax: Record; - /** * Size of the segment * e.g. Size.Default diff --git a/packages/input-box/src/testutils/index.ts b/packages/input-box/src/testutils/index.ts deleted file mode 100644 index bd2cb0744f..0000000000 --- a/packages/input-box/src/testutils/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createRef } from 'react'; - -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; - -type Segment = 'day' | 'month' | 'year'; - -export type SegmentRefs = Record< - Segment, - ReturnType> ->; - -export const segmentRefsMock: SegmentRefs = { - day: createRef(), - month: createRef(), - year: createRef(), -}; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx new file mode 100644 index 0000000000..57cd7433e6 --- /dev/null +++ b/packages/input-box/src/testutils/index.tsx @@ -0,0 +1,275 @@ +import { createRef } from 'react'; +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; + +import { css } from '@leafygreen-ui/emotion'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputBox, InputBoxProps } from '../InputBox'; +import { RenderSegmentProps } from '../InputBox/InputBox.types'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { ExplicitSegmentRule } from '../utils'; + +export const SegmentObjMock = { + Month: 'month', + Day: 'day', + Year: 'year', +} as const; +export type SegmentObjMock = + (typeof SegmentObjMock)[keyof typeof SegmentObjMock]; + +export type SegmentRefsMock = Record< + SegmentObjMock, + ReturnType> +>; + +export const segmentRefsMock: SegmentRefsMock = { + month: createRef(), + day: createRef(), + year: createRef(), +}; + +export const segmentsMock: Record = { + month: '02', + day: '02', + year: '2025', +}; +export const charsPerSegmentMock: Record = { + month: 2, + day: 2, + year: 4, +}; +export const segmentRulesMock: Record = { + month: { maxChars: 2, minExplicitValue: 2 }, + day: { maxChars: 2, minExplicitValue: 4 }, + year: { maxChars: 4, minExplicitValue: 1970 }, +}; +export const defaultMinMock: Record = { + month: 1, + day: 1, + year: 1970, +}; +export const defaultMaxMock: Record = { + month: 12, + day: 31, + year: 2038, +}; + +export const defaultPlaceholderMock: Record = { + day: 'DD', + month: 'MM', + year: 'YYYY', +} as const; + +export const defaultFormatPartsMock: Array = [ + { type: 'month', value: '' }, + { type: 'literal', value: '-' }, + { type: 'day', value: '' }, + { type: 'literal', value: '-' }, + { type: 'year', value: '' }, +]; + +/** The percentage of 1ch these specific characters take up */ +export const characterWidth = { + // // Standard font + D: 46 / 40, + M: 55 / 40, + Y: 50 / 40, +} as const; + +export const segmentWidthStyles: Record = { + day: css` + width: ${charsPerSegmentMock.day * characterWidth.D}ch; + `, + month: css` + width: ${charsPerSegmentMock.month * characterWidth.M}ch; + `, + year: css` + width: ${charsPerSegmentMock.year * characterWidth.Y}ch; + `, +}; + +export const defaultProps: Partial> = { + segments: segmentsMock, + segmentObj: SegmentObjMock, + segmentRefs: segmentRefsMock, + setSegment: () => {}, + charsPerSegment: charsPerSegmentMock, + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, +}; + +/** + * This component is used to render the InputBox component for testing purposes. + * Includes segment state management and a default renderSegment function. + */ +export const InputBoxWithState = ({ + onSegmentChange, + disabled = false, + segments: segmentsProp = { + day: '', + month: '', + year: '', + }, +}: { + onSegmentChange?: InputSegmentChangeEventHandler; + disabled?: boolean; + segments?: Record; +}) => { + const dayRef = React.useRef(null); + const monthRef = React.useRef(null); + const yearRef = React.useRef(null); + + const segmentRefs = { + day: dayRef, + month: monthRef, + year: yearRef, + }; + + const [segments, setSegments] = React.useState(segmentsProp); + + const setSegment = (segment: SegmentObjMock, value: string) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; + + return ( + ( + + )} + /> + ); +}; + +interface RenderInputBoxWithStateReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; +} + +export const renderInputBoxWithState = ({ + onSegmentChange, +}: { + onSegmentChange?: InputSegmentChangeEventHandler; +}): RenderResult & RenderInputBoxWithStateReturnType => { + const utils = render(); + + const dayInput = utils.getByTestId('input-segment-day') as HTMLInputElement; + const monthInput = utils.getByTestId( + 'input-segment-month', + ) as HTMLInputElement; + const yearInput = utils.getByTestId('input-segment-year') as HTMLInputElement; + + return { ...utils, dayInput, monthInput, yearInput }; +}; + +const createRenderSegment = ( + mergedProps: InputBoxProps, +) => { + const RenderSegment = ({ + onChange, + onBlur, + partType, + }: RenderSegmentProps) => ( + + ); + + return RenderSegment; +}; + +interface RenderInputBoxReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + rerenderInputBox: ( + props: Partial>, + ) => void; +} + +export const renderInputBox = ({ + ...props +}: Partial>): RenderResult & + RenderInputBoxReturnType => { + const mergedProps = { + ...defaultProps, + ...props, + } as InputBoxProps; + + const finalMergedProps = { + ...mergedProps, + renderSegment: + mergedProps.renderSegment ?? createRenderSegment(mergedProps), + } as InputBoxProps; + + const result = render(); + + const rerenderInputBox = ({ + ...props + }: Partial>) => { + const mergedProps = { + ...defaultProps, + ...props, + } as InputBoxProps; + + const finalMergedProps = { + ...mergedProps, + renderSegment: + mergedProps.renderSegment ?? createRenderSegment(mergedProps), + } as InputBoxProps; + + result.rerender(); + }; + + const dayInput = result.getByTestId('input-segment-day') as HTMLInputElement; + const monthInput = result.getByTestId( + 'input-segment-month', + ) as HTMLInputElement; + const yearInput = result.getByTestId( + 'input-segment-year', + ) as HTMLInputElement; + + return { ...result, rerenderInputBox, dayInput, monthInput, yearInput }; +}; + +// InputSegment Utils diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index 3846e7a3d9..6a742a2825 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -1,15 +1,14 @@ -export { isValidValueForSegment } from './isValidValueForSegment'; export { createExplicitSegmentValidator, ExplicitSegmentRule, } from './createExplicitSegmentValidator'; - -export { isElementInputSegment } from './isElementInputSegment'; -export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; export { getRelativeSegment, getRelativeSegmentRef, } from './getRelativeSegment'; export { getValueFormatter } from './getValueFormatter'; -export { isValidSegmentValue, isValidSegmentName } from './isValidSegment'; +export { isElementInputSegment } from './isElementInputSegment'; +export { isValidSegmentName, isValidSegmentValue } from './isValidSegment'; +export { isValidValueForSegment } from './isValidValueForSegment'; diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json index 353961b7b7..cba2152d8f 100644 --- a/packages/input-box/tsconfig.json +++ b/packages/input-box/tsconfig.json @@ -30,6 +30,9 @@ { "path": "../date-utils" }, + { + "path": "../palette" + }, { "path": "../tokens" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90c6bc92af..d74d95b289 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2282,6 +2282,10 @@ importers: '@lg-tools/test-harnesses': specifier: workspace:^ version: link:../../tools/test-harnesses + devDependencies: + '@leafygreen-ui/palette': + specifier: workspace:^ + version: link:../palette packages/input-option: dependencies: From 0a3390715b8a8c6f7ce1cdae854da1f4020340b6 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 28 Oct 2025 17:08:48 -0400 Subject: [PATCH 17/56] refactor(input-box): simplify styling logic in InputBox and InputSegment components by introducing utility functions --- .../input-box/src/InputBox/InputBox.styles.ts | 22 ++++++++++++++++++- packages/input-box/src/InputBox/InputBox.tsx | 10 ++++----- .../src/InputSegment/InputSegment.styles.ts | 20 +++++++++++++++++ .../src/InputSegment/InputSegment.tsx | 19 +++++----------- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.styles.ts b/packages/input-box/src/InputBox/InputBox.styles.ts index 00cdcea518..53e3de972e 100644 --- a/packages/input-box/src/InputBox/InputBox.styles.ts +++ b/packages/input-box/src/InputBox/InputBox.styles.ts @@ -1,4 +1,4 @@ -import { css } from '@leafygreen-ui/emotion'; +import { css, cx } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; import { palette } from '@leafygreen-ui/palette'; @@ -20,3 +20,23 @@ export const separatorLiteralDisabledStyles: Record = { color: ${palette.gray.base}; `, }; + +export const getSeparatorLiteralStyles = ({ + theme, + disabled = false, +}: { + theme: Theme; + disabled?: boolean; +}) => { + return cx(separatorLiteralStyles, { + [separatorLiteralDisabledStyles[theme]]: disabled, + }); +}; + +export const getSegmentPartsWrapperStyles = ({ + className, +}: { + className?: string; +}) => { + return cx(segmentPartsWrapperStyles, className); +}; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 30f7e494bf..1bca789f47 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -21,9 +21,9 @@ import { } from '../utils'; import { + getSegmentPartsWrapperStyles, + getSeparatorLiteralStyles, segmentPartsWrapperStyles, - separatorLiteralDisabledStyles, - separatorLiteralStyles, } from './InputBox.styles'; import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; @@ -203,7 +203,7 @@ export const InputBoxWithRef = >( // We want to allow keydown events to be captured by the parent so that the parent can handle the event. // eslint-disable-next-line jsx-a11y/no-static-element-interactions
>( if (part.type === 'literal') { return ( {part.value} diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts index 73fd8d176d..430cb6efe4 100644 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -81,3 +81,23 @@ export const segmentSizeStyles: Record = { font-size: ${18}px; // Intentionally off-token `, }; + +export const getInputSegmentStyles = ({ + className, + baseFontSize, + theme, + size, +}: { + className?: string; + baseFontSize: BaseFontSize; + theme: Theme; + size: Size; +}) => { + return css` + ${baseStyles} + ${fontSizeStyles[baseFontSize]} + ${segmentThemeStyles[theme]} + ${segmentSizeStyles[size]} + ${className} + `; +}; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 054696f0dd..636d9cb92a 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -15,12 +15,7 @@ import { getValueFormatter, } from '../utils'; -import { - baseStyles, - fontSizeStyles, - segmentSizeStyles, - segmentThemeStyles, -} from './InputSegment.styles'; +import { getInputSegmentStyles } from './InputSegment.styles'; import { InputSegmentComponentType, InputSegmentProps, @@ -194,14 +189,12 @@ const InputSegmentWithRef = , V extends string>( onBlur={onBlur} onKeyDown={handleKeyDown} data-segment={String(segment)} - // TODO: use getInputSegmentStyles - className={cx( - baseStyles, - fontSizeStyles[baseFontSize], - segmentThemeStyles[theme], - segmentSizeStyles[size], + className={getInputSegmentStyles({ className, - )} + baseFontSize, + theme, + size, + })} /> ); }; From d4bc35672b5419609f6285e9bf6830cb1e4729d7 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 28 Oct 2025 17:39:34 -0400 Subject: [PATCH 18/56] refactor(input-box): enhance type definitions in InputBox and InputSegment components for improved clarity and documentation --- .../input-box/src/InputBox/InputBox.types.ts | 55 ++++++++++++++++--- .../src/InputSegment/InputSegment.types.ts | 9 ++- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 216a7239b6..0c9e38bbdd 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -35,7 +35,9 @@ export interface InputBoxProps> /** * Segment Refs - * e.g. { day: ref, month: ref, year: ref } + * + * @example + * { day: ref, month: ref, year: ref } */ segmentRefs: Record< T[keyof T], @@ -44,54 +46,89 @@ export interface InputBoxProps> /** * Segment object - * e.g. { Day: 'day', Month: 'month', Year: 'year' } + * + * @example + * { Day: 'day', Month: 'month', Year: 'year' } */ segmentObj: T; /** * An object containing the values of the segments - * e.g. { day: '1', month: '2', year: '2025' } + * + * @example + * { day: '1', month: '2', year: '2025' } */ segments: Record; /** * A function that sets the value of a segment - * e.g. (segment: 'day', value: '1') => void; + * + * @example + * (segment: 'day', value: '1') => void; */ setSegment: (segment: T[keyof T], value: string) => void; /** * The format parts of the date + * + * @example + * [ + * { type: 'month', value: '02' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '02' }, + * { type: 'literal', value: '-' }, + * { type: 'year', value: '2025' }, + * ] */ formatParts?: Array; /** * The number of characters per segment - * e.g. { day: 2, month: 2, year: 4 } + * + * @example + * { day: 2, month: 2, year: 4 } */ charsPerSegment: Record; /** * Whether the input box is disabled + * + * @default false */ disabled?: boolean; /** * The rules for the segments - * e.g. { day: { maxChars: 2, minExplicitValue: 1 }, month: { maxChars: 2, minExplicitValue: 1 }, year: { maxChars: 4, minExplicitValue: 1970 } } + * + * @example + * { + * day: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 1 }, + * year: { maxChars: 4, minExplicitValue: 1970 }, + * } */ segmentRules: Record; /** * A function that renders a segment - * e.g. (props: { onChange: (event: React.ChangeEvent) => void, onBlur: (event: React.FocusEvent) => void, partType: 'day' | 'month' | 'year' }) => React.ReactElement; + * + * @example + * (props: { + * onChange: (event: React.ChangeEvent) => void, + * onBlur: (event: React.FocusEvent) => void, + * partType: 'day' | 'month' | 'year', + * }) => React.ReactElement; */ renderSegment: (props: RenderSegmentProps) => React.ReactElement; } /** - * The component type for the InputBox - * TODO: add why we need this + * Type definition for the InputBox component that maintains generic type safety with forwardRef. + * + * Interface with a generic call signature that preserves type parameters() when using forwardRef. + * React.forwardRef loses type parameters, so this interface is used to restore them. + * + * @see https://stackoverflow.com/a/58473012 */ export interface InputBoxComponentType { >( diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index f68e5f707d..2ccc58addc 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -100,8 +100,12 @@ export interface InputSegmentProps< } /** - * The component type for the InputSegment - * TODO: add why we need this + * Type definition for the InputSegment component that maintains generic type safety with forwardRef. + * + * Interface with a generic call signature that preserves type parameters() when using forwardRef. + * React.forwardRef loses type parameters, so this interface is used to restore them. + * + * @see https://stackoverflow.com/a/58473012 */ export interface InputSegmentComponentType { , V extends string>( @@ -110,6 +114,7 @@ export interface InputSegmentComponentType { ): ReactElement | null; displayName?: string; } + /** * Returns whether the given string is a valid segment */ From d36b02a4358f404a2dcda5fac1025934c4308278 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 28 Oct 2025 17:44:01 -0400 Subject: [PATCH 19/56] refactor(input-box): improve documentation for InputBox and InputSegment types with clearer examples --- .../input-box/src/InputBox/InputBox.types.ts | 2 +- .../src/InputSegment/InputSegment.types.ts | 50 ++++++++++++------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 0c9e38bbdd..455a07fb73 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -45,7 +45,7 @@ export interface InputBoxProps> >; /** - * Segment object + * An enumerable object that maps the segment names to their values * * @example * { Day: 'day', Month: 'month', Year: 'year' } diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 2ccc58addc..6bf55398cf 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -29,17 +29,21 @@ export interface InputSegmentProps< > { /** * Which segment this input represents - * e.g. 'day' - * e.g. 'month' - * e.g. 'year' + * + * @example + * 'day' + * 'month' + * 'year' */ segment: T[keyof T]; /** * The value of the segment - * e.g. '1' - * e.g. '2' - * e.g. '2025' + * + * @example + * '1' + * '2' + * '2025' */ value: V; @@ -50,37 +54,47 @@ export interface InputSegmentProps< /** * The number of characters per segment - * e.g. { day: 2, month: 2, year: 4 } + * + * @example + * { day: 2, month: 2, year: 4 } */ charsPerSegment: Record; // TODO: make this a number? /** * Minimum value. - * e.g. 1 - * e.g. 1 - * e.g. 1970 + * + * @example + * 1 + * 1 + * 1970 */ min: number; /** * Maximum value. - * e.g. 31 - * e.g. 12 - * e.g. 2038 + * + * @example + * 31 + * 12 + * 2038 */ max: number; /** - * Segment object - * e.g. { Day: 'day', Month: 'month', Year: 'year' } + * An enumerable object that maps the segment names to their values + * + * @example + * { Day: 'day', Month: 'month', Year: 'year' } */ segmentObj: T; /** * Size of the segment - * e.g. Size.Default - * e.g. Size.Small - * e.g. Size.Large + * + * @example + * Size.Default + * Size.Small + * Size.Large */ size: Size; From dfd04ff274c8e90c4aaf029d1c36360ff0a3e2cb Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 28 Oct 2025 17:52:03 -0400 Subject: [PATCH 20/56] refactor(input-box): update documentation for segmentRefs and segmentRules in InputBox types for better clarity --- packages/input-box/src/InputBox/InputBox.types.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 455a07fb73..1aed4fd502 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -34,7 +34,7 @@ export interface InputBoxProps> labelledBy?: string; /** - * Segment Refs + * An object that maps the segment names to their refs * * @example * { day: ref, month: ref, year: ref } @@ -98,14 +98,21 @@ export interface InputBoxProps> disabled?: boolean; /** - * The rules for the segments + * An object that maps the segment names to their rules. + * + * maxChars: the maximum number of characters for the segment + * minExplicitValue: the minimum explicit value for the segment * * @example * { * day: { maxChars: 2, minExplicitValue: 1 }, - * month: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 4 }, * year: { maxChars: 4, minExplicitValue: 1970 }, * } + * + * Explicit: Day = 5, 02 + * Ambiguous: Day = 2 (could be 20-29) + * */ segmentRules: Record; From 853eea450ef475967041d7f5ed5c4dc05a6ae032 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 28 Oct 2025 18:20:24 -0400 Subject: [PATCH 21/56] refactor(input-box, date-picker): streamline value formatting by updating getValueFormatter to accept segment-specific character counts --- .../DatePicker/DatePicker.keyboard3.spec.tsx | 2 +- .../DateInputSegment.spec.tsx | 6 ++-- .../DateInputSegment/DateInputSegment.tsx | 2 +- .../getFormattedDateStringFromSegments.ts | 2 +- .../getFormattedSegmentsFromDate.ts | 6 ++-- .../input-box/src/InputBox/InputBox.spec.tsx | 32 +++++++++++------ packages/input-box/src/InputBox/InputBox.tsx | 2 +- .../src/InputSegment/InputSegment.spec.tsx | 4 +-- .../src/InputSegment/InputSegment.tsx | 6 ++-- .../src/InputSegment/InputSegment.types.ts | 4 +-- packages/input-box/src/testutils/index.tsx | 4 +-- .../getNewSegmentValueFromInputValue.spec.ts | 34 +++++++++---------- .../getNewSegmentValueFromInputValue.ts | 16 ++++----- .../src/utils/getValueFormatter/index.ts | 14 +++----- .../getValueFormatter/valueFormatter.spec.ts | 4 +-- 15 files changed, 71 insertions(+), 67 deletions(-) diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx index 1897bf624f..b9076df507 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx @@ -79,7 +79,7 @@ describe('DatePicker keyboard interaction', () => { const segmentCases = ['year', 'month', 'day'] as Array; describe.each(segmentCases)('%p segment', segment => { - const formatter = getValueFormatter(segment, charsPerSegment); + const formatter = getValueFormatter(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/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx index 9682f70886..06ce3c37e4 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 @@ -245,7 +245,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Arrow Keys', () => { describe('day input', () => { - const formatter = getValueFormatter('day', charsPerSegment); + const formatter = getValueFormatter(charsPerSegment['day']); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -391,7 +391,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('month input', () => { - const formatter = getValueFormatter('month', charsPerSegment); + const formatter = getValueFormatter(charsPerSegment['month']); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -553,7 +553,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('year input', () => { - const formatter = getValueFormatter('year', charsPerSegment); + const formatter = getValueFormatter(charsPerSegment['year']); describe('Up arrow', () => { test('calls handler with value +1', () => { 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 34efde1ee3..dc338c3271 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -71,7 +71,7 @@ export const DateInputSegment = React.forwardRef< // TODO: // @ts-expect-error size={size} - charsPerSegment={charsPerSegment} + charsPerSegment={charsPerSegment[segment]} autoComplete={autoComplete} className={cx(segmentWidthStyles[segment])} disabled={disabled} diff --git a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts index d366faeef8..94a467ad02 100644 --- a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts +++ b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts @@ -18,7 +18,7 @@ export const getFormattedDateStringFromSegments = ( } const segment = part.type as DateSegment; - const formatter = getValueFormatter(segment, charsPerSegment); + const formatter = getValueFormatter(charsPerSegment[segment]); const formattedSegment = formatter(segments[segment]); return dateString + formattedSegment; }, ''); diff --git a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts index 48cd5971fb..dbb8ae65bc 100644 --- a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts +++ b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts @@ -13,8 +13,8 @@ export const getFormattedSegmentsFromDate = ( const segments = getSegmentsFromDate(date); return { - day: getValueFormatter('day', charsPerSegment)(segments['day']), - month: getValueFormatter('month', charsPerSegment)(segments['month']), - year: getValueFormatter('year', charsPerSegment)(segments['year']), + day: getValueFormatter(charsPerSegment['day'])(segments['day']), + month: getValueFormatter(charsPerSegment['month'])(segments['month']), + year: getValueFormatter(charsPerSegment['year'])(segments['year']), }; }; diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 053534f113..8f2bfb2c86 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -112,17 +112,27 @@ describe('packages/input-box', () => { describe('renderSegment', () => { test('calls renderSegment for each segment with correct props', () => { - const mockRenderSegment = jest.fn(({ partType, onChange, onBlur }) => ( - // @ts-expect-error - we are not passing all the props to the InputSegment component - - )); + const mockRenderSegment = jest.fn( + ({ + partType, + onChange, + onBlur, + }: { + partType: SegmentObjMock; + onChange: any; + onBlur: any; + }) => ( + // @ts-expect-error - we are not passing all the props to the InputSegment component + + ), + ); renderInputBox({ renderSegment: mockRenderSegment, formatParts: [ diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 1bca789f47..9ff3bab6b4 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -64,7 +64,7 @@ export const InputBoxWithRef = >( segmentName: (typeof segmentObj)[keyof typeof segmentObj], segmentValue: string, ): string => { - const formatter = getValueFormatter(segmentName, charsPerSegment); + const formatter = getValueFormatter(charsPerSegment[segmentName]); const formattedValue = formatter(segmentValue); return formattedValue; }; diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 8e78ec237c..ab59245a00 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -32,7 +32,7 @@ const renderSegment = ( value: '', onChange: () => {}, segment: 'day', - charsPerSegment: charsPerSegmentMock, + charsPerSegment: charsPerSegmentMock['day'], min: defaultMinMock['day'], max: defaultMaxMock['day'], segmentObj: SegmentObjMock, @@ -219,7 +219,7 @@ describe('packages/input-segment', () => { describe('keyboard events', () => { describe('Arrow keys', () => { - const formatter = getValueFormatter('day', charsPerSegmentMock); + const formatter = getValueFormatter(charsPerSegmentMock['day']); describe('Up arrow', () => { test('calls handler with value default +1 step', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 636d9cb92a..5449870e42 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -51,8 +51,8 @@ const InputSegmentWithRef = , V extends string>( ) => { const { theme } = useDarkMode(); const baseFontSize = useUpdatedBaseFontSize(); - const formatter = getValueFormatter(segment, charsPerSegment); - const pattern = `[0-9]{${charsPerSegment[segment]}}`; + const formatter = getValueFormatter(charsPerSegment); + const pattern = `[0-9]{${charsPerSegment}}`; /** * Receives native input events, @@ -98,7 +98,7 @@ const InputSegmentWithRef = , V extends string>( if (isNumber) { // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called. - if (target.value.length === charsPerSegment[segment]) { + if (target.value.length === charsPerSegment) { target.value = ''; } } diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 6bf55398cf..8722417e6b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -56,9 +56,9 @@ export interface InputSegmentProps< * The number of characters per segment * * @example - * { day: 2, month: 2, year: 4 } + * 4 */ - charsPerSegment: Record; // TODO: make this a number? + charsPerSegment: number; /** * Minimum value. diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 57cd7433e6..47ea5062b2 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -154,7 +154,7 @@ export const InputBoxWithState = ({ value={segments[partType]} onChange={onChange} onBlur={onBlur} - charsPerSegment={charsPerSegmentMock} + charsPerSegment={charsPerSegmentMock[partType]} min={defaultMinMock[partType]} max={defaultMaxMock[partType]} segmentObj={SegmentObjMock} @@ -206,7 +206,7 @@ const createRenderSegment = ( value={mergedProps.segments[partType]} onChange={onChange} onBlur={onBlur} - charsPerSegment={charsPerSegmentMock} + charsPerSegment={charsPerSegmentMock[partType]} min={defaultMinMock[partType]} max={defaultMaxMock[partType]} segmentObj={SegmentObjMock} diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index 11c7e0282a..f8ae8f4332 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -37,7 +37,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '', `${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -51,7 +51,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '', `${v}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -64,7 +64,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '', `b`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -77,7 +77,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '', `2.`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -93,7 +93,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '0', `00`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -106,7 +106,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '0', `0${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -118,7 +118,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '0', ``, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -133,7 +133,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '1', ``, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -147,7 +147,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '1', `1${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -160,7 +160,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '1', `1${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -174,7 +174,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '1', `1${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -190,7 +190,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '3', ``, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -205,7 +205,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '3', `3${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -218,7 +218,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '3', `3${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -236,7 +236,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, '3', `3${i}`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, @@ -253,7 +253,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { }); describe('when current value is a full formatted value', () => { - const formatter = getValueFormatter(segment, charsPerSegment); + const formatter = getValueFormatter(charsPerSegment[segment]); const testValues = [defaultMin[segment], defaultMax[segment]].map( formatter, ); @@ -264,7 +264,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { segment, val, `${val}1`, - charsPerSegment, + charsPerSegment[segment], defaultMin[segment], defaultMax[segment], segmentObj, diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index a44971a185..f47fa56131 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -29,10 +29,10 @@ import { isValidValueForSegment } from '..'; * Month: 'month', * Year: 'year', * }; - * getNewSegmentValueFromInputValue('day', '1', '2', { day: 2, month: 2, year: 4 }, 1, 31, segmentObj); // '2' - * getNewSegmentValueFromInputValue('month', '1', '2', { day: 2, month: 2, year: 4 }, 1, 12, segmentObj); // '2' - * getNewSegmentValueFromInputValue('year', '1', '2', { day: 2, month: 2, year: 4 }, 1970, 2038, segmentObj); // '2' - * getNewSegmentValueFromInputValue('day', '1', '.', { day: 2, month: 2, year: 4 }, 1, 31, segmentObj); // '1' + * getNewSegmentValueFromInputValue('day', '1', '2', segmentObj['day'], 1, 31, segmentObj); // '2' + * getNewSegmentValueFromInputValue('month', '1', '2', segmentObj['month'], 1, 12, segmentObj); // '2' + * getNewSegmentValueFromInputValue('year', '1', '2', segmentObj['year'], 1970, 2038, segmentObj); // '2' + * getNewSegmentValueFromInputValue('day', '1', '.', segmentObj['day'], 1, 31, segmentObj); // '1' */ export const getNewSegmentValueFromInputValue = < T extends string, @@ -41,7 +41,7 @@ export const getNewSegmentValueFromInputValue = < segmentName: T, currentValue: V, incomingValue: V, - charsPerSegment: Record, + charsPerSegment: number, defaultMin: number, defaultMax: number, segmentObj: Readonly>, @@ -53,8 +53,8 @@ export const getNewSegmentValueFromInputValue = < // 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]; + currentValue.length === charsPerSegment && + incomingValue.length > charsPerSegment; if ( !isIncomingValueNumber || @@ -74,7 +74,7 @@ export const getNewSegmentValueFromInputValue = < if (isIncomingValueValid || segmentName === 'year') { const newValue = truncateStart(incomingValue, { - length: charsPerSegment[segmentName], + length: charsPerSegment, }); return newValue as V; diff --git a/packages/input-box/src/utils/getValueFormatter/index.ts b/packages/input-box/src/utils/getValueFormatter/index.ts index 6f421bd5d9..7620a97963 100644 --- a/packages/input-box/src/utils/getValueFormatter/index.ts +++ b/packages/input-box/src/utils/getValueFormatter/index.ts @@ -6,7 +6,6 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * If the value is any form of zero, we set it to an empty string * otherwise, pad the string with 0s, or trim it to n chars * - * @param segment - the segment to format * @param charsPerSegment - the number of characters per segment * @param val - the value to format * @returns a value formatter function for the provided segment @@ -17,27 +16,22 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * month: 2, * year: 4, * }; - * const formatter = getValueFormatter('day', charsPerSegment); + * const formatter = getValueFormatter(charsPerSegment['day']); * formatter('0'); // '' * formatter('1'); // '01' * formatter('12'); // '12' * formatter('123'); // '23' */ export const getValueFormatter = - (segment: T, charsPerSegment: Record) => - (val: string | number | undefined) => { + (charsPerSegment: number) => (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 padded = padStart(Number(val).toString(), charsPerSegment, '0'); const trimmed = padded.slice( - padded.length - charsPerSegment[segment], + padded.length - charsPerSegment, padded.length, ); diff --git a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts index e20ba953c7..031808e536 100644 --- a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts +++ b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts @@ -9,7 +9,7 @@ const charsPerSegment: Record = { describe('packages/input-box/utils/valueFormatter', () => { describe.each(['day', 'month'] as Array)('', segment => { - const formatter = getValueFormatter(segment, charsPerSegment); + const formatter = getValueFormatter(charsPerSegment[segment]); test('formats 2 digit values', () => { expect(formatter('12')).toEqual('12'); @@ -37,7 +37,7 @@ describe('packages/input-box/utils/valueFormatter', () => { }); describe('year', () => { - const formatter = getValueFormatter('year', charsPerSegment); + const formatter = getValueFormatter(charsPerSegment['year']); test('formats 4 digit values', () => { expect(formatter('2023')).toEqual('2023'); From 93d2c09abb821f6791a0868a39362ded8fa7b30e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 28 Oct 2025 22:07:56 -0400 Subject: [PATCH 22/56] refactor(input-box, date-picker): update utils to allow zero values --- .../DateInput/DateInputBox/DateInputBox.tsx | 7 +- .../input-box/src/InputBox/InputBox.spec.tsx | 25 ++++-- packages/input-box/src/InputBox/InputBox.tsx | 9 +- .../input-box/src/InputBox/InputBox.types.ts | 7 ++ .../src/InputSegment/InputSegment.spec.tsx | 83 +++++-------------- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.tsx | 2 +- packages/input-box/src/testutils/index.tsx | 59 ++++++++++++- .../src/utils/getValueFormatter/index.ts | 11 ++- .../src/utils/isValidSegment/index.ts | 15 +++- .../isValidSegment/isValidSegment.spec.ts | 4 + .../src/utils/isValidValueForSegment/index.ts | 11 ++- 12 files changed, 156 insertions(+), 79 deletions(-) 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 64506f6644..1fc80e00da 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -9,7 +9,11 @@ import { } from '@leafygreen-ui/date-utils'; import { InputBox } from '@leafygreen-ui/input-box'; -import { charsPerSegment, dateSegmentRules } from '../../../constants'; +import { + charsPerSegment, + dateSegmentRules, + defaultMin, +} from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; import { useDateSegments } from '../../../hooks'; import { DateSegment, DateSegmentsState } from '../../../types'; @@ -109,6 +113,7 @@ export const DateInputBox = React.forwardRef( disabled={disabled} segmentRules={dateSegmentRules} onSegmentChange={onSegmentChange} + minValues={defaultMin} renderSegment={({ onChange, onBlur, partType }) => ( { userEvent.type(dayInput, '02'); expect(dayInput.value).toBe('02'); }); + + test('allows 00 as minimum value', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '00'); + expect(dayInput.value).toBe('00'); + }); }); describe('ambiguous value', () => { @@ -292,11 +298,20 @@ describe('packages/input-box', () => { }); }); - test('returns no value with leading zero on blur', () => { - const { dayInput } = renderInputBoxWithState({}); - userEvent.type(dayInput, '0'); - userEvent.tab(); - expect(dayInput.value).toBe(''); + describe('onBlur', () => { + test('returns no value with leading zero on blur', () => { + const { monthInput } = renderInputBoxWithState({}); + userEvent.type(monthInput, '0'); + userEvent.tab(); + expect(monthInput.value).toBe(''); + }); + + test('returns value with leading zero on blur', () => { + const { dayInput } = renderInputBoxWithState({}); + userEvent.type(dayInput, '0'); + userEvent.tab(); + expect(dayInput.value).toBe('00'); + }); }); test('does not allow non-number characters', () => { diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 9ff3bab6b4..f700cc700a 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -4,7 +4,6 @@ import React, { KeyboardEventHandler, } from 'react'; -import { cx } from '@leafygreen-ui/emotion'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; @@ -48,6 +47,7 @@ export const InputBoxWithRef = >( segmentObj, segmentRules, renderSegment, + minValues, ...rest }: InputBoxProps, fwdRef: ForwardedRef, @@ -64,7 +64,10 @@ export const InputBoxWithRef = >( segmentName: (typeof segmentObj)[keyof typeof segmentObj], segmentValue: string, ): string => { - const formatter = getValueFormatter(charsPerSegment[segmentName]); + const formatter = getValueFormatter( + charsPerSegment[segmentName], + minValues[segmentName] === 0, + ); const formattedValue = formatter(segmentValue); return formattedValue; }; @@ -108,6 +111,8 @@ export const InputBoxWithRef = >( const segmentName = e.target.getAttribute('id'); const segmentValue = e.target.value; + console.log('🪼🪼🪼', { segmentName, segmentValue }); + if (isInputSegment(segmentName, segmentObj)) { const formattedValue = getFormattedSegmentValue( segmentName, diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 1aed4fd502..47bd1d2e48 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -115,6 +115,13 @@ export interface InputBoxProps> * */ segmentRules: Record; + /** + * An object that maps the segment names to their minimum values + * + * @example + * { day: 0, month: 1, year: 1970 } + */ + minValues: Record; /** * A function that renders a segment diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index ab59245a00..4dad7c9305 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,65 +1,16 @@ -import React from 'react'; -import { render, RenderResult } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Size } from '@leafygreen-ui/tokens'; - import { charsPerSegmentMock, defaultMaxMock, defaultMinMock, - defaultPlaceholderMock, + renderSegment, SegmentObjMock, + setSegmentProps, } from '../testutils'; import { getValueFormatter } from '../utils'; -import { - InputSegment, - InputSegmentChangeEventHandler, - InputSegmentProps, -} from '.'; - -const renderSegment = ( - props?: Partial>, -): RenderResult & { - getInput: () => HTMLInputElement; - input: HTMLInputElement; - rerenderSegment: ( - newProps: Partial>, - ) => void; -} => { - const defaultProps: InputSegmentProps = { - value: '', - onChange: () => {}, - segment: 'day', - charsPerSegment: charsPerSegmentMock['day'], - min: defaultMinMock['day'], - max: defaultMaxMock['day'], - segmentObj: SegmentObjMock, - size: Size.Default, - shouldNotRollover: false, - placeholder: defaultPlaceholderMock['day'], - // @ts-expect-error - data-testid - ['data-testid']: 'lg-input-segment', - }; - - const mergedProps = { - ...defaultProps, - ...props, - }; - - const utils = render(); - - const rerenderSegment = ( - newProps: Partial>, - ) => { - utils.rerender(); - }; - - const getInput = () => - utils.getByTestId('lg-input-segment') as HTMLInputElement; - return { ...utils, getInput, input: getInput(), rerenderSegment }; -}; +import { InputSegmentChangeEventHandler } from '.'; describe('packages/input-segment', () => { describe('aria attributes', () => { @@ -74,18 +25,17 @@ describe('packages/input-segment', () => { describe('rendering', () => { describe('day segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ segment: 'day' }); + const { input } = renderSegment({}); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ segment: 'day', value: '12' }); + const { input } = renderSegment({ value: '12' }); expect(input.value).toBe('12'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - segment: 'day', value: '12', }); @@ -96,18 +46,21 @@ describe('packages/input-segment', () => { describe('month segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ segment: 'month' }); + const { input } = renderSegment({ ...setSegmentProps('month') }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ segment: 'month', value: '26' }); + const { input } = renderSegment({ + ...setSegmentProps('month'), + value: '26', + }); expect(input.value).toBe('26'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - segment: 'month', + ...setSegmentProps('month'), value: '26', }); @@ -118,18 +71,21 @@ describe('packages/input-segment', () => { describe('year segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ segment: 'year' }); + const { input } = renderSegment({ ...setSegmentProps('year') }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ segment: 'year', value: '2023' }); + const { input } = renderSegment({ + ...setSegmentProps('year'), + value: '2023', + }); expect(input.value).toBe('2023'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - segment: 'year', + ...setSegmentProps('year'), value: '2023', }); rerenderSegment({ value: '1993' }); @@ -219,7 +175,10 @@ describe('packages/input-segment', () => { describe('keyboard events', () => { describe('Arrow keys', () => { - const formatter = getValueFormatter(charsPerSegmentMock['day']); + const formatter = getValueFormatter( + charsPerSegmentMock['day'], + defaultMinMock['day'] === 0, + ); describe('Up arrow', () => { test('calls handler with value default +1 step', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index a3f2cb0266..9cbe1d9145 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -32,7 +32,7 @@ const meta: StoryMetaType = { args: { segment: SegmentObjMock.Day, value: '', - charsPerSegment: charsPerSegmentMock, + charsPerSegment: charsPerSegmentMock[SegmentObjMock.Day], segmentObj: SegmentObjMock, min: defaultMinMock[SegmentObjMock.Day], max: defaultMaxMock[SegmentObjMock.Day], diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 5449870e42..f1effe47a0 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -51,7 +51,7 @@ const InputSegmentWithRef = , V extends string>( ) => { const { theme } = useDarkMode(); const baseFontSize = useUpdatedBaseFontSize(); - const formatter = getValueFormatter(charsPerSegment); + const formatter = getValueFormatter(charsPerSegment, min === 0); const pattern = `[0-9]{${charsPerSegment}}`; /** diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 47ea5062b2..8f6546df46 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -9,7 +9,10 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputBox, InputBoxProps } from '../InputBox'; import { RenderSegmentProps } from '../InputBox/InputBox.types'; import { InputSegment } from '../InputSegment'; -import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { + InputSegmentChangeEventHandler, + InputSegmentProps, +} from '../InputSegment/InputSegment.types'; import { ExplicitSegmentRule } from '../utils'; export const SegmentObjMock = { @@ -48,7 +51,7 @@ export const segmentRulesMock: Record = { }; export const defaultMinMock: Record = { month: 1, - day: 1, + day: 0, year: 1970, }; export const defaultMaxMock: Record = { @@ -145,6 +148,7 @@ export const InputBoxWithState = ({ formatParts={defaultFormatPartsMock} segmentRules={segmentRulesMock} onSegmentChange={onSegmentChange} + minValues={defaultMinMock} renderSegment={({ onChange, onBlur, partType }) => ( { + return { + segment: segment, + charsPerSegment: charsPerSegmentMock[segment], + min: defaultMinMock[segment], + max: defaultMaxMock[segment], + placeholder: defaultPlaceholderMock[segment], + }; +}; + +export const renderSegment = ( + props?: Partial>, +): RenderResult & { + getInput: () => HTMLInputElement; + input: HTMLInputElement; + rerenderSegment: ( + newProps: Partial>, + ) => void; +} => { + const defaultProps: InputSegmentProps = { + value: '', + onChange: () => {}, + segment: 'day', + charsPerSegment: charsPerSegmentMock['day'], + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + segmentObj: SegmentObjMock, + size: Size.Default, + shouldNotRollover: false, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', + }; + + const mergedProps = { + ...defaultProps, + ...props, + }; + + const utils = render(); + + const rerenderSegment = ( + newProps: Partial>, + ) => { + utils.rerender(); + }; + + const getInput = () => + utils.getByTestId('lg-input-segment') as HTMLInputElement; + return { ...utils, getInput, input: getInput(), rerenderSegment }; +}; diff --git a/packages/input-box/src/utils/getValueFormatter/index.ts b/packages/input-box/src/utils/getValueFormatter/index.ts index 7620a97963..4fc6b79072 100644 --- a/packages/input-box/src/utils/getValueFormatter/index.ts +++ b/packages/input-box/src/utils/getValueFormatter/index.ts @@ -7,6 +7,7 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * otherwise, pad the string with 0s, or trim it to n chars * * @param charsPerSegment - the number of characters per segment + * @param allowsZero - * @param val - the value to format * @returns a value formatter function for the provided segment * @@ -23,9 +24,13 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * formatter('123'); // '23' */ export const getValueFormatter = - (charsPerSegment: number) => (val: string | number | undefined) => { - // If the value is any form of zero, we set it to an empty string - if (isZeroLike(val)) return ''; + (charsPerSegment: number, allowZero = false) => + (val: string | number | undefined) => { + // If the value is empty, do not format it + if (val === '') return ''; + + // If we don't allow zero and the value is any form of zero, we set it to an empty string + if (!allowZero && isZeroLike(val)) return ''; // otherwise, pad the string with 0s, or trim it to n chars diff --git a/packages/input-box/src/utils/isValidSegment/index.ts b/packages/input-box/src/utils/isValidSegment/index.ts index c25fb69379..692a13177f 100644 --- a/packages/input-box/src/utils/isValidSegment/index.ts +++ b/packages/input-box/src/utils/isValidSegment/index.ts @@ -2,11 +2,24 @@ import isUndefined from 'lodash/isUndefined'; /** * Returns whether a given value is a valid segment value + * + * @param segment - The segment value to validate + * @param allowZero - Whether to allow zero as a valid segment value + * @returns Whether the segment value is valid + * + * @example + * isValidSegmentValue('1'); // true + * isValidSegmentValue('0'); // false + * isValidSegmentValue('0', true); // true + * isValidSegmentValue('00', true); // true */ export const isValidSegmentValue = ( segment?: T, + allowZero = false, ): segment is T => - !isUndefined(segment) && !isNaN(Number(segment)) && Number(segment) > 0; + !isUndefined(segment) && + !isNaN(Number(segment)) && + (Number(segment) > 0 || allowZero); /** * A generic type predicate function that checks if a given string is one diff --git a/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts index f27081839d..9f46171e25 100644 --- a/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts @@ -26,6 +26,10 @@ describe('packages/input-box/utils/isValidSegment', () => { expect(isValidSegmentValue('0')).toBeFalsy(); }); + test('0 with allowZero returns true', () => { + expect(isValidSegmentValue('0', true)).toBeTruthy(); + }); + test('negative returns false', () => { expect(isValidSegmentValue('-1')).toBeFalsy(); }); diff --git a/packages/input-box/src/utils/isValidValueForSegment/index.ts b/packages/input-box/src/utils/isValidValueForSegment/index.ts index 55251ef8f6..d7afbfbf0b 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/index.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/index.ts @@ -32,7 +32,16 @@ export const isValidValueForSegment = ( segmentObj: Readonly>, ): boolean => { const isValidSegmentAndValue = - isValidSegmentValue(value) && isValidSegmentName(segmentObj, segment); + isValidSegmentValue(value, defaultMin === 0) && + isValidSegmentName(segmentObj, segment); + + console.log('✅', { + isValidSegmentAndValue, + segment, + value, + defaultMin, + defaultMax, + }); // TODO: should this be custom? if (segment === 'year') { From d2aa6ff45e04468c795f5ee824c34b8b75bed1dc Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 09:43:22 -0400 Subject: [PATCH 23/56] refactor(input-box, date-picker): introduce shouldSkipValidation flag for year segment and enhance validation logic --- .../DateInputSegment/DateInputSegment.tsx | 5 ++++ .../isEverySegmentValid.ts | 5 ++++ packages/input-box/src/InputBox/InputBox.tsx | 3 --- .../src/InputSegment/InputSegment.tsx | 3 ++- .../src/InputSegment/InputSegment.types.ts | 7 ++++++ .../getNewSegmentValueFromInputValue.spec.ts | 23 +++++++++++++++++++ .../getNewSegmentValueFromInputValue.ts | 6 ++--- .../src/utils/getValueFormatter/index.ts | 3 +-- .../src/utils/isValidValueForSegment/index.ts | 17 ++++---------- .../isValidValueForSegment.spec.ts | 7 +++++- 10 files changed, 56 insertions(+), 23 deletions(-) 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 dc338c3271..b6abac6623 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -57,6 +57,10 @@ export const DateInputSegment = React.forwardRef< [DateSegment.Year] as Array ).includes(segment); + const shouldSkipValidation = ( + [DateSegment.Year] as Array + ).includes(segment); + return ( ); diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts index 7e8e640e16..049a3b9b30 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -1,3 +1,5 @@ +import inRange from 'lodash/inRange'; + import { isValidValueForSegment } from '@leafygreen-ui/input-box'; import { defaultMax, defaultMin } from '../../constants'; @@ -14,6 +16,9 @@ export const isEverySegmentValid = (segments: DateSegmentsState): boolean => { defaultMin[segment as DateSegment], defaultMax[segment as DateSegment], DateSegment, + segment === DateSegment.Year + ? (value: DateSegmentValue) => inRange(Number(value), 1000, 9999 + 1) + : undefined, ), ); }; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index f700cc700a..6496bc7e36 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -22,7 +22,6 @@ import { import { getSegmentPartsWrapperStyles, getSeparatorLiteralStyles, - segmentPartsWrapperStyles, } from './InputBox.styles'; import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; @@ -111,8 +110,6 @@ export const InputBoxWithRef = >( const segmentName = e.target.getAttribute('id'); const segmentValue = e.target.value; - console.log('🪼🪼🪼', { segmentName, segmentValue }); - if (isInputSegment(segmentName, segmentObj)) { const formattedValue = getFormattedSegmentValue( segmentName, diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index f1effe47a0..d85790fc15 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -4,7 +4,6 @@ import React, { KeyboardEventHandler, } from 'react'; -import { cx } from '@leafygreen-ui/emotion'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; @@ -45,6 +44,7 @@ const InputSegmentWithRef = , V extends string>( segmentObj, step = 1, shouldNotRollover = false, + shouldSkipValidation = false, ...rest }: InputSegmentProps, fwdRef: ForwardedRef, @@ -70,6 +70,7 @@ const InputSegmentWithRef = , V extends string>( min, max, segmentObj, + shouldSkipValidation, ); const hasValueChanged = newValue !== value; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 8722417e6b..6eb53f1133 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -111,6 +111,13 @@ export interface InputSegmentProps< * @default false */ shouldNotRollover?: boolean; + + /** + * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * + * @default false + */ + shouldSkipValidation?: boolean; } /** diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index f8ae8f4332..eb3ad175ce 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -41,6 +41,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin[segment], defaultMax[segment], segmentObj, + segment === 'year', ); expect(newValue).toEqual(`${i}`); }); @@ -55,6 +56,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin[segment], defaultMax[segment], segmentObj, + segment === 'year', ); expect(newValue).toEqual(`${v}`); }); @@ -68,6 +70,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin[segment], defaultMax[segment], segmentObj, + segment === 'year', ); expect(newValue).toEqual(''); }); @@ -81,6 +84,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin[segment], defaultMax[segment], segmentObj, + segment === 'year', ); expect(newValue).toEqual(''); }); @@ -101,6 +105,22 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { expect(newValue).toEqual(`0`); }); } + + if (segment === 'year') { + test('accepts 0000 as input', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `0000`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + true, + ); + expect(newValue).toEqual(`0000`); + }); + } test.each(range(1, 10))('accepts 0%i as input', i => { const newValue = getNewSegmentValueFromInputValue( segment, @@ -110,6 +130,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin[segment], defaultMax[segment], segmentObj, + segment === 'year', ); expect(newValue).toEqual(`0${i}`); }); @@ -122,6 +143,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin[segment], defaultMax[segment], segmentObj, + segment === 'year', ); expect(newValue).toEqual(``); }); @@ -178,6 +200,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin[segment], defaultMax[segment], segmentObj, + segment === 'year', ); expect(newValue).toEqual(`1${i}`); }); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index f47fa56131..902bd5c712 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -4,8 +4,6 @@ import { truncateStart } from '@leafygreen-ui/lib'; import { isValidValueForSegment } from '..'; -// TODO: make props an object with all the necessary properties - /** * Calculates the new value for the segment given an incoming change. * @@ -21,6 +19,7 @@ import { isValidValueForSegment } from '..'; * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment * @param segmentObj - The segment object + * @param shouldSkipValidation - Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. * @returns The new value for the segment * @example * // The segmentObj is the object that contains the segment names and their corresponding values @@ -45,6 +44,7 @@ export const getNewSegmentValueFromInputValue = < defaultMin: number, defaultMax: number, segmentObj: Readonly>, + shouldSkipValidation = false, ): V => { // If the incoming value is not a valid number const isIncomingValueNumber = !isNaN(Number(incomingValue)); @@ -72,7 +72,7 @@ export const getNewSegmentValueFromInputValue = < segmentObj, ); - if (isIncomingValueValid || segmentName === 'year') { + if (isIncomingValueValid || shouldSkipValidation) { const newValue = truncateStart(incomingValue, { length: charsPerSegment, }); diff --git a/packages/input-box/src/utils/getValueFormatter/index.ts b/packages/input-box/src/utils/getValueFormatter/index.ts index 4fc6b79072..f2c6d822e6 100644 --- a/packages/input-box/src/utils/getValueFormatter/index.ts +++ b/packages/input-box/src/utils/getValueFormatter/index.ts @@ -29,11 +29,10 @@ export const getValueFormatter = // If the value is empty, do not format it if (val === '') return ''; - // If we don't allow zero and the value is any form of zero, we set it to an empty string + // Return empty string for zero-like values when disallowed (e.g., '00') if (!allowZero && isZeroLike(val)) return ''; // otherwise, pad the string with 0s, or trim it to n chars - const padded = padStart(Number(val).toString(), charsPerSegment, '0'); const trimmed = padded.slice( padded.length - charsPerSegment, diff --git a/packages/input-box/src/utils/isValidValueForSegment/index.ts b/packages/input-box/src/utils/isValidValueForSegment/index.ts index d7afbfbf0b..62cc9a637f 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/index.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/index.ts @@ -9,6 +9,7 @@ import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment * @param segmentObj - The segment object + * @param customValidation - A custom validation function for the segment. This is useful for segments that allow values outside of the default range. * @returns Whether the value is valid for the segment * @example * // The segmentObj is the object that contains the segment names and their corresponding values @@ -22,7 +23,6 @@ import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; * isValidValueForSegment('month', '1', 1, 12, segmentObj); // true * isValidValueForSegment('month', '13', 1, 12, segmentObj); // false * isValidValueForSegment('year', '1970', 1000, 9999, segmentObj); // true - * isValidValueForSegment('year', '10000', 1000, 9999, segmentObj); // false */ export const isValidValueForSegment = ( segment: T, @@ -30,23 +30,14 @@ export const isValidValueForSegment = ( defaultMin: number, defaultMax: number, segmentObj: Readonly>, + customValidation?: (value: V) => boolean, ): boolean => { const isValidSegmentAndValue = isValidSegmentValue(value, defaultMin === 0) && isValidSegmentName(segmentObj, segment); - console.log('✅', { - isValidSegmentAndValue, - segment, - value, - defaultMin, - defaultMax, - }); - - // TODO: should this be custom? - if (segment === 'year') { - // allow any 4-digit year value regardless of defined range - return isValidSegmentAndValue && inRange(Number(value), 1000, 9999 + 1); + if (customValidation) { + return isValidSegmentAndValue && customValidation(value); } const isInRange = inRange(Number(value), defaultMin, defaultMax + 1); diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts index 23619d12b9..248f1773c0 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -1,3 +1,5 @@ +import inRange from 'lodash/inRange'; + import { isValidValueForSegment } from '.'; const SegmentObj = { @@ -27,6 +29,9 @@ const isValidValueForSegmentWrapper = (segment: SegmentObj, value: string) => { defaultMin[segment], defaultMax[segment], SegmentObj, + segment === 'year' + ? (value: string) => inRange(Number(value), 1000, 9999 + 1) + : undefined, ); }; @@ -49,7 +54,7 @@ describe('packages/input-box/utils/isValidSegmentValue', () => { expect(isValidValueForSegmentWrapper('month', '28')).toBe(false); }); - test('year', () => { + test('year with custom validation', () => { expect(isValidValueForSegmentWrapper('year', '1970')).toBe(true); expect(isValidValueForSegmentWrapper('year', '2000')).toBe(true); expect(isValidValueForSegmentWrapper('year', '2038')).toBe(true); From e3066f9fbf2eb64ce3b9c02e9b6f4a86eebcb472 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 10:27:45 -0400 Subject: [PATCH 24/56] refactor(input-box): enhance renderSegment return type for improved type safety and clarity --- packages/input-box/src/testutils/index.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 8f6546df46..5dc3a9761a 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -287,15 +287,17 @@ export const setSegmentProps = (segment: SegmentObjMock) => { }; }; -export const renderSegment = ( - props?: Partial>, -): RenderResult & { +interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; rerenderSegment: ( newProps: Partial>, ) => void; -} => { +} + +export const renderSegment = ( + props?: Partial>, +): RenderResult & RenderSegmentReturnType => { const defaultProps: InputSegmentProps = { value: '', onChange: () => {}, From ee819d68c761c0cbd9bd135f49ba41782627f9c0 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 11:43:07 -0400 Subject: [PATCH 25/56] refactor(input-box): improve InputBox tests to verify segment rendering and props validation --- .../input-box/src/InputBox/InputBox.spec.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 65cdb6e72a..45ed15c68e 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -143,29 +143,35 @@ describe('packages/input-box', () => { { type: 'day', value: '' }, ], }); - // Verify renderSegment was called 3 times (once per segment) - expect(mockRenderSegment).toHaveBeenCalledTimes(3); - // Check first call (year) - expect(mockRenderSegment).toHaveBeenNthCalledWith( - 1, + // Verify renderSegment was called (may be called multiple times in dev mode) + expect(mockRenderSegment).toHaveBeenCalled(); + + // Collect all unique partTypes that were called + const calledPartTypes = mockRenderSegment.mock.calls.map( + call => call[0].partType, + ); + // Verify all three segment types were rendered + expect(calledPartTypes).toHaveLength(3); + expect(calledPartTypes).toContain('year'); + expect(calledPartTypes).toContain('month'); + expect(calledPartTypes).toContain('day'); + + // Verify each segment type was called with correct props + expect(mockRenderSegment).toHaveBeenCalledWith( expect.objectContaining({ partType: 'year', onChange: expect.any(Function), onBlur: expect.any(Function), }), ); - // Check second call (month) - expect(mockRenderSegment).toHaveBeenNthCalledWith( - 2, + expect(mockRenderSegment).toHaveBeenCalledWith( expect.objectContaining({ partType: 'month', onChange: expect.any(Function), onBlur: expect.any(Function), }), ); - // Check third call (day) - expect(mockRenderSegment).toHaveBeenNthCalledWith( - 3, + expect(mockRenderSegment).toHaveBeenCalledWith( expect.objectContaining({ partType: 'day', onChange: expect.any(Function), From 3eb786cd3dee5a8104a2e0f73ff93c20a991b569 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 11:52:52 -0400 Subject: [PATCH 26/56] refactor(input-box): remove unused getLgIds utility and clean up InputSegment props --- packages/input-box/package.json | 7 ++----- packages/input-box/src/InputSegment/InputSegment.tsx | 3 +-- packages/input-box/src/utils/getLgIds.ts | 12 ------------ 3 files changed, 3 insertions(+), 19 deletions(-) delete mode 100644 packages/input-box/src/utils/getLgIds.ts diff --git a/packages/input-box/package.json b/packages/input-box/package.json index 6b03b606f0..3030c6e71e 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -32,12 +32,9 @@ "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", "@leafygreen-ui/date-utils": "workspace:^", + "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", - "@leafygreen-ui/typography": "workspace:^", - "@lg-tools/test-harnesses": "workspace:^" - }, - "devDependencies": { - "@leafygreen-ui/palette": "workspace:^" + "@leafygreen-ui/typography": "workspace:^" }, "peerDependencies": { "@leafygreen-ui/leafygreen-provider": "workspace:^" diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index d85790fc15..adc5435079 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -35,11 +35,10 @@ const InputSegmentWithRef = , V extends string>( onChange, onBlur, onKeyDown, - size: sizeProp, + size, charsPerSegment, min, max, - size, className, segmentObj, step = 1, diff --git a/packages/input-box/src/utils/getLgIds.ts b/packages/input-box/src/utils/getLgIds.ts deleted file mode 100644 index 08b841e0a5..0000000000 --- a/packages/input-box/src/utils/getLgIds.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { LgIdString } from '@leafygreen-ui/lib'; - -export const DEFAULT_LGID_ROOT = 'lg-input_box'; - -export const getLgIds = (root: LgIdString = DEFAULT_LGID_ROOT) => { - const ids = { - root, - } as const; - return ids; -}; - -export type GetLgIdsReturnType = ReturnType; From ec626586a326649ccdfe4ea0a140f93fd280e00e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 12:16:32 -0400 Subject: [PATCH 27/56] refactor(input-box, date-picker): rename segmentObj to segmentEnum for consistency and clarity across components --- .../DateInput/DateInputBox/DateInputBox.tsx | 2 +- .../DateInputSegment/DateInputSegment.tsx | 2 +- packages/input-box/src/InputBox/InputBox.tsx | 10 ++++---- .../input-box/src/InputBox/InputBox.types.ts | 4 +-- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.tsx | 4 +-- .../src/InputSegment/InputSegment.types.ts | 2 +- packages/input-box/src/index.ts | 4 +-- packages/input-box/src/testutils/index.tsx | 10 ++++---- .../createExplicitSegmentValidator.spec.ts | 2 +- ...x.ts => createExplicitSegmentValidator.ts} | 5 +++- .../getNewSegmentValueFromInputValue.spec.ts | 2 +- .../getNewSegmentValueFromInputValue.ts | 18 ++++++------- .../getRelativeSegment.spec.tsx | 2 +- .../{index.ts => getRelativeSegment.ts} | 0 .../{index.ts => getValueFormatter.ts} | 0 .../getValueFormatter/valueFormatter.spec.ts | 2 +- packages/input-box/src/utils/index.ts | 15 ++++++----- .../isElementInputSegment.spec.ts | 2 +- .../{index.ts => isElementInputSegment.ts} | 0 .../isValidSegment/isValidSegment.spec.ts | 2 +- .../{index.ts => isValidSegment.ts} | 17 +++++++------ .../isValidValueForSegment.spec.ts | 2 +- .../{index.ts => isValidValueForSegment.ts} | 25 +++++++++++-------- pnpm-lock.yaml | 10 +++----- 25 files changed, 75 insertions(+), 69 deletions(-) rename packages/input-box/src/utils/createExplicitSegmentValidator/{index.ts => createExplicitSegmentValidator.ts} (93%) rename packages/input-box/src/utils/getRelativeSegment/{index.ts => getRelativeSegment.ts} (100%) rename packages/input-box/src/utils/getValueFormatter/{index.ts => getValueFormatter.ts} (100%) rename packages/input-box/src/utils/isElementInputSegment/{index.ts => isElementInputSegment.ts} (100%) rename packages/input-box/src/utils/isValidSegment/{index.ts => isValidSegment.ts} (72%) rename packages/input-box/src/utils/isValidValueForSegment/{index.ts => isValidValueForSegment.ts} (60%) 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 1fc80e00da..69386ef015 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -105,7 +105,7 @@ export const DateInputBox = React.forwardRef( ref={fwdRef} onKeyDown={onKeyDown} segmentRefs={segmentRefs} - segmentObj={DateSegment} + segmentEnum={DateSegment} charsPerSegment={charsPerSegment} formatParts={formatParts} segments={segments} 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 b6abac6623..c91e472028 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -80,7 +80,7 @@ export const DateInputSegment = React.forwardRef< className={cx(segmentWidthStyles[segment])} disabled={disabled} data-testid="lg-date_picker_input-segment" - segmentObj={DateSegment} + segmentEnum={DateSegment} shouldNotRollover={shouldNotRollover} shouldSkipValidation={shouldSkipValidation} {...rest} diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 6496bc7e36..779585b2be 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -43,7 +43,7 @@ export const InputBoxWithRef = >( disabled, charsPerSegment, formatParts, - segmentObj, + segmentEnum, segmentRules, renderSegment, minValues, @@ -54,13 +54,13 @@ export const InputBoxWithRef = >( const { theme } = useDarkMode(); const isExplicitSegmentValue = createExplicitSegmentValidator( - segmentObj, + segmentEnum, segmentRules, ); /** Formats and sets the segment value. */ const getFormattedSegmentValue = ( - segmentName: (typeof segmentObj)[keyof typeof segmentObj], + segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], segmentValue: string, ): string => { const formatter = getValueFormatter( @@ -110,7 +110,7 @@ export const InputBoxWithRef = >( const segmentName = e.target.getAttribute('id'); const segmentValue = e.target.value; - if (isInputSegment(segmentName, segmentObj)) { + if (isInputSegment(segmentName, segmentEnum)) { const formattedValue = getFormattedSegmentValue( segmentName, segmentValue, @@ -220,7 +220,7 @@ export const InputBoxWithRef = >( {part.value} ); - } else if (isInputSegment(part.type, segmentObj)) { + } else if (isInputSegment(part.type, segmentEnum)) { const segmentProps = { onChange: handleSegmentInputChange, onBlur: handleSegmentInputBlur, diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 47bd1d2e48..dc61d7b9d0 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -4,7 +4,7 @@ import { DateType } from '@leafygreen-ui/date-utils'; import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; -import { ExplicitSegmentRule } from '../utils/createExplicitSegmentValidator'; +import { ExplicitSegmentRule } from '../utils'; export interface RenderSegmentProps { onChange: InputSegmentChangeEventHandler; @@ -50,7 +50,7 @@ export interface InputBoxProps> * @example * { Day: 'day', Month: 'month', Year: 'year' } */ - segmentObj: T; + segmentEnum: T; /** * An object containing the values of the segments diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 9cbe1d9145..12598e2440 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -66,7 +66,7 @@ const meta: StoryMetaType = { 'value', 'onChange', 'charsPerSegment', - 'segmentObj', + 'segmentEnum', ], }, generate: { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index adc5435079..988e26d046 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -40,7 +40,7 @@ const InputSegmentWithRef = , V extends string>( min, max, className, - segmentObj, + segmentEnum, step = 1, shouldNotRollover = false, shouldSkipValidation = false, @@ -68,7 +68,7 @@ const InputSegmentWithRef = , V extends string>( charsPerSegment, min, max, - segmentObj, + segmentEnum, shouldSkipValidation, ); diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 6eb53f1133..9cb70f76f7 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -86,7 +86,7 @@ export interface InputSegmentProps< * @example * { Day: 'day', Month: 'month', Year: 'year' } */ - segmentObj: T; + segmentEnum: T; /** * Size of the segment diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index 08c6ba1c78..34d65de6af 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -10,8 +10,8 @@ export { isElementInputSegment, isValidValueForSegment, } from './utils'; -export { getValueFormatter } from './utils/getValueFormatter'; +export { getValueFormatter } from './utils/getValueFormatter/getValueFormatter'; export { isValidSegmentName, isValidSegmentValue, -} from './utils/isValidSegment'; +} from './utils/isValidSegment/isValidSegment'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 5dc3a9761a..88f132463d 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -96,7 +96,7 @@ export const segmentWidthStyles: Record = { export const defaultProps: Partial> = { segments: segmentsMock, - segmentObj: SegmentObjMock, + segmentEnum: SegmentObjMock, segmentRefs: segmentRefsMock, setSegment: () => {}, charsPerSegment: charsPerSegmentMock, @@ -140,7 +140,7 @@ export const InputBoxWithState = ({ return ( @@ -305,7 +305,7 @@ export const renderSegment = ( charsPerSegment: charsPerSegmentMock['day'], min: defaultMinMock['day'], max: defaultMaxMock['day'], - segmentObj: SegmentObjMock, + segmentEnum: SegmentObjMock, size: Size.Default, shouldNotRollover: false, placeholder: defaultPlaceholderMock['day'], diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts index 1278085cd8..9acad385b9 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -1,4 +1,4 @@ -import { createExplicitSegmentValidator } from '.'; +import { createExplicitSegmentValidator } from './createExplicitSegmentValidator'; const segmentObj = { Day: 'day', diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts similarity index 93% rename from packages/input-box/src/utils/createExplicitSegmentValidator/index.ts rename to packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index a10cbf2b2b..200d832632 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/index.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -1,4 +1,7 @@ -import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; +import { + isValidSegmentName, + isValidSegmentValue, +} from '../isValidSegment/isValidSegment'; /** * Configuration for determining if a segment value is explicit diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index eb3ad175ce..3eaba47e20 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -1,6 +1,6 @@ import range from 'lodash/range'; -import { getValueFormatter } from '../getValueFormatter'; +import { getValueFormatter } from '../getValueFormatter/getValueFormatter'; import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 902bd5c712..0c1644a73e 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -18,20 +18,20 @@ import { isValidValueForSegment } from '..'; * @param charsPerSegment - The number of characters per segment * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment - * @param segmentObj - The segment object + * @param segmentEnum - The segment object * @param shouldSkipValidation - Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. * @returns The new value for the segment * @example - * // The segmentObj is the object that contains the segment names and their corresponding values - * const segmentObj = { + * // The segmentEnum is the object that contains the segment names and their corresponding values + * const segmentEnum = { * Day: 'day', * Month: 'month', * Year: 'year', * }; - * getNewSegmentValueFromInputValue('day', '1', '2', segmentObj['day'], 1, 31, segmentObj); // '2' - * getNewSegmentValueFromInputValue('month', '1', '2', segmentObj['month'], 1, 12, segmentObj); // '2' - * getNewSegmentValueFromInputValue('year', '1', '2', segmentObj['year'], 1970, 2038, segmentObj); // '2' - * getNewSegmentValueFromInputValue('day', '1', '.', segmentObj['day'], 1, 31, segmentObj); // '1' + * getNewSegmentValueFromInputValue('day', '1', '2', segmentEnum['day'], 1, 31, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('month', '1', '2', segmentEnum['month'], 1, 12, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('year', '1', '2', segmentEnum['year'], 1970, 2038, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('day', '1', '.', segmentEnum['day'], 1, 31, segmentEnum); // '1' */ export const getNewSegmentValueFromInputValue = < T extends string, @@ -43,7 +43,7 @@ export const getNewSegmentValueFromInputValue = < charsPerSegment: number, defaultMin: number, defaultMax: number, - segmentObj: Readonly>, + segmentEnum: Readonly>, shouldSkipValidation = false, ): V => { // If the incoming value is not a valid number @@ -69,7 +69,7 @@ export const getNewSegmentValueFromInputValue = < incomingValue, defaultMin, defaultMax, - segmentObj, + segmentEnum, ); if (isIncomingValueValid || shouldSkipValidation) { diff --git a/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx index b5331a53d7..872820347b 100644 --- a/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx @@ -16,7 +16,7 @@ const segmentRefsMock: SegmentRefs = { year: createRef(), }; -import { getRelativeSegmentRef } from '.'; +import { getRelativeSegmentRef } from './getRelativeSegment'; const renderTestComponent = () => { const result = render( diff --git a/packages/input-box/src/utils/getRelativeSegment/index.ts b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts similarity index 100% rename from packages/input-box/src/utils/getRelativeSegment/index.ts rename to packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts diff --git a/packages/input-box/src/utils/getValueFormatter/index.ts b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts similarity index 100% rename from packages/input-box/src/utils/getValueFormatter/index.ts rename to packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts diff --git a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts index 031808e536..7e5436fe01 100644 --- a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts +++ b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts @@ -1,4 +1,4 @@ -import { getValueFormatter } from '.'; +import { getValueFormatter } from './getValueFormatter'; type Segment = 'day' | 'month' | 'year'; const charsPerSegment: Record = { diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index 6a742a2825..9754f2fa90 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -1,14 +1,17 @@ export { createExplicitSegmentValidator, ExplicitSegmentRule, -} from './createExplicitSegmentValidator'; +} from './createExplicitSegmentValidator/createExplicitSegmentValidator'; export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; export { getRelativeSegment, getRelativeSegmentRef, -} from './getRelativeSegment'; -export { getValueFormatter } from './getValueFormatter'; -export { isElementInputSegment } from './isElementInputSegment'; -export { isValidSegmentName, isValidSegmentValue } from './isValidSegment'; -export { isValidValueForSegment } from './isValidValueForSegment'; +} from './getRelativeSegment/getRelativeSegment'; +export { getValueFormatter } from './getValueFormatter/getValueFormatter'; +export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment'; +export { + isValidSegmentName, + isValidSegmentValue, +} from './isValidSegment/isValidSegment'; +export { isValidValueForSegment } from './isValidValueForSegment/isValidValueForSegment'; diff --git a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts index eff2da34cb..9dbc50deda 100644 --- a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts +++ b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts @@ -1,6 +1,6 @@ import React from 'react'; -import { isElementInputSegment } from '.'; +import { isElementInputSegment } from './isElementInputSegment'; describe('packages/input-box/utils/isElementInputSegment', () => { describe('isElementInputSegment', () => { diff --git a/packages/input-box/src/utils/isElementInputSegment/index.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts similarity index 100% rename from packages/input-box/src/utils/isElementInputSegment/index.ts rename to packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts diff --git a/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts index 9f46171e25..64929a3f56 100644 --- a/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts @@ -1,4 +1,4 @@ -import { isValidSegmentName, isValidSegmentValue } from '.'; +import { isValidSegmentName, isValidSegmentValue } from './isValidSegment'; const Segment = { Day: 'day', diff --git a/packages/input-box/src/utils/isValidSegment/index.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts similarity index 72% rename from packages/input-box/src/utils/isValidSegment/index.ts rename to packages/input-box/src/utils/isValidSegment/isValidSegment.ts index 692a13177f..3cae5afb58 100644 --- a/packages/input-box/src/utils/isValidSegment/index.ts +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts @@ -25,26 +25,27 @@ export const isValidSegmentValue = ( * A generic type predicate function that checks if a given string is one * of the values in the provided segment object. * - * @param segmentObj The runtime object containing the valid string segments + * @param segmentEnum The runtime object containing the valid string segments * @param name The string to validate * @returns A boolean and a type predicate (name is T[keyof T]) * * @example - * const segmentObj = { + * const segmentEnum = { * Day: 'day', * Month: 'month', * Year: 'year', * }; - * isValidSegmentName(segmentObj, 'day'); // true - * isValidSegmentName(segmentObj, 'month'); // true - * isValidSegmentName(segmentObj, 'year'); // true - * isValidSegmentName(segmentObj, 'seconds'); // false + * isValidSegmentName(segmentEnum, 'day'); // true + * isValidSegmentName(segmentEnum, 'month'); // true + * isValidSegmentName(segmentEnum, 'year'); // true + * isValidSegmentName(segmentEnum, 'seconds'); // false */ export const isValidSegmentName = >>( - segmentObj: T, + segmentEnum: T, name?: string, ): name is T[keyof T] => { return ( - !isUndefined(name) && Object.values(segmentObj).includes(name as T[keyof T]) + !isUndefined(name) && + Object.values(segmentEnum).includes(name as T[keyof T]) ); }; diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts index 248f1773c0..5d7d72dd8a 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -1,6 +1,6 @@ import inRange from 'lodash/inRange'; -import { isValidValueForSegment } from '.'; +import { isValidValueForSegment } from './isValidValueForSegment'; const SegmentObj = { Day: 'day', diff --git a/packages/input-box/src/utils/isValidValueForSegment/index.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts similarity index 60% rename from packages/input-box/src/utils/isValidValueForSegment/index.ts rename to packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts index 62cc9a637f..7a8df1593e 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/index.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts @@ -1,6 +1,9 @@ import inRange from 'lodash/inRange'; -import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; +import { + isValidSegmentName, + isValidSegmentValue, +} from '../isValidSegment/isValidSegment'; /** * Returns whether a value is valid for a given segment type @@ -8,33 +11,33 @@ import { isValidSegmentName, isValidSegmentValue } from '../isValidSegment'; * @param value - The value to check * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment - * @param segmentObj - The segment object + * @param segmentEnum - The segment object * @param customValidation - A custom validation function for the segment. This is useful for segments that allow values outside of the default range. * @returns Whether the value is valid for the segment * @example - * // The segmentObj is the object that contains the segment names and their corresponding values - * const segmentObj = { + * // The segmentEnum is the object that contains the segment names and their corresponding values + * const segmentEnum = { * Day: 'day', * Month: 'month', * Year: 'year', * }; - * isValidValueForSegment('day', '1', 1, 31, segmentObj); // true - * isValidValueForSegment('day', '32', 1, 31, segmentObj); // false - * isValidValueForSegment('month', '1', 1, 12, segmentObj); // true - * isValidValueForSegment('month', '13', 1, 12, segmentObj); // false - * isValidValueForSegment('year', '1970', 1000, 9999, segmentObj); // true + * isValidValueForSegment('day', '1', 1, 31, segmentEnum); // true + * isValidValueForSegment('day', '32', 1, 31, segmentEnum); // false + * isValidValueForSegment('month', '1', 1, 12, segmentEnum); // true + * isValidValueForSegment('month', '13', 1, 12, segmentEnum); // false + * isValidValueForSegment('year', '1970', 1000, 9999, segmentEnum); // true */ export const isValidValueForSegment = ( segment: T, value: V, defaultMin: number, defaultMax: number, - segmentObj: Readonly>, + segmentEnum: Readonly>, customValidation?: (value: V) => boolean, ): boolean => { const isValidSegmentAndValue = isValidSegmentValue(value, defaultMin === 0) && - isValidSegmentName(segmentObj, segment); + isValidSegmentName(segmentEnum, segment); if (customValidation) { return isValidSegmentAndValue && customValidation(value); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d74d95b289..2de0639a3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2273,19 +2273,15 @@ importers: '@leafygreen-ui/lib': specifier: workspace:^ version: link:../lib + '@leafygreen-ui/palette': + specifier: workspace:^ + version: link:../palette '@leafygreen-ui/tokens': specifier: workspace:^ version: link:../tokens '@leafygreen-ui/typography': specifier: workspace:^ version: link:../typography - '@lg-tools/test-harnesses': - specifier: workspace:^ - version: link:../../tools/test-harnesses - devDependencies: - '@leafygreen-ui/palette': - specifier: workspace:^ - version: link:../palette packages/input-option: dependencies: From d19245643199fe70e57f3f1de56ab1fc79736c79 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 13:55:20 -0400 Subject: [PATCH 28/56] refactor(input-box, date-picker): update type annotations and enhance tests for InputBox and InputSegment components --- .../DateInputSegment/DateInputSegment.tsx | 2 +- .../input-box/src/InputBox/InputBox.spec.tsx | 55 +++++++++++++++++-- .../src/InputSegment/InputSegment.spec.tsx | 49 ++++++++++++++++- 3 files changed, 99 insertions(+), 7 deletions(-) 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 c91e472028..b219a989d0 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -72,7 +72,7 @@ export const DateInputSegment = React.forwardRef< min={min} max={max} placeholder={defaultPlaceholder[segment]} - // TODO: + // TODO: Type 'number | Size' is not assignable to type 'Size'. Unsure why the size is a number. // @ts-expect-error size={size} charsPerSegment={charsPerSegment[segment]} diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 45ed15c68e..ef52f326a1 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -6,10 +6,17 @@ import { InputSegment } from '../InputSegment'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; import { charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, renderInputBox, renderInputBoxWithState, SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, } from '../testutils'; +import { InputBox } from './InputBox'; +import { Size } from '@leafygreen-ui/tokens'; describe('packages/input-box', () => { describe('Rendering', () => { @@ -143,18 +150,22 @@ describe('packages/input-box', () => { { type: 'day', value: '' }, ], }); - // Verify renderSegment was called (may be called multiple times in dev mode) + // Verify renderSegment was called (may be called multiple times in dev mode in R17) expect(mockRenderSegment).toHaveBeenCalled(); // Collect all unique partTypes that were called const calledPartTypes = mockRenderSegment.mock.calls.map( call => call[0].partType, ); + + // Remove duplicate partTypes + const uniqueCalledPartTypes = [...new Set(calledPartTypes)]; + // Verify all three segment types were rendered - expect(calledPartTypes).toHaveLength(3); - expect(calledPartTypes).toContain('year'); - expect(calledPartTypes).toContain('month'); - expect(calledPartTypes).toContain('day'); + expect(uniqueCalledPartTypes).toHaveLength(3); + expect(uniqueCalledPartTypes).toContain('year'); + expect(uniqueCalledPartTypes).toContain('month'); + expect(uniqueCalledPartTypes).toContain('day'); // Verify each segment type was called with correct props expect(mockRenderSegment).toHaveBeenCalledWith( @@ -337,4 +348,38 @@ describe('packages/input-box', () => { expect(yearInput.value).toBe(''); }); }); + + /* eslint-disable jest/no-disabled-tests */ + describe.skip('types behave as expected', () => { + test('InputBox throws error when no required props are provided', () => { + // @ts-expect-error - missing required props + ; + }); + }); + + test('With required props', () => { + {}} + charsPerSegment={charsPerSegmentMock} + segmentRules={segmentRulesMock} + minValues={defaultMinMock} + renderSegment={({ onChange, onBlur, partType }) => ( + + )} + />; + }); }); diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 4dad7c9305..c5b765c957 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -10,7 +10,9 @@ import { } from '../testutils'; import { getValueFormatter } from '../utils'; -import { InputSegmentChangeEventHandler } from '.'; +import { InputSegment, InputSegmentChangeEventHandler } from '.'; +import { Size } from '@leafygreen-ui/tokens'; +import React from 'react'; describe('packages/input-segment', () => { describe('aria attributes', () => { @@ -486,4 +488,49 @@ describe('packages/input-segment', () => { }); }); }); + + /* eslint-disable jest/no-disabled-tests */ + describe.skip('types behave as expected', () => { + test('InputSegment throws error when no required props are provided', () => { + // @ts-expect-error - missing required props + ; + }); + + test('With required props', () => { + {}} + value="12" + charsPerSegment={2} + min={1} + max={31} + segmentEnum={SegmentObjMock} + size={Size.Default} + />; + }); + + test('With all props', () => { + {}} + value="12" + charsPerSegment={2} + min={1} + max={31} + segmentEnum={SegmentObjMock} + size={Size.Default} + step={1} + shouldNotRollover={false} + shouldSkipValidation={false} + placeholder="12" + className="test" + onBlur={() => {}} + onKeyDown={() => {}} + disabled={false} + data-testid="test-id" + id="day" + ref={React.createRef()} + />; + }); + }); }); From fb7837aa20d9b772b63ea81fcbfa7fdc487154ff Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 14:11:59 -0400 Subject: [PATCH 29/56] refactor(input-box): update README and improve InputBox documentation for clarity --- packages/input-box/README.md | 27 +++---------------- .../input-box/src/InputBox/InputBox.spec.tsx | 4 ++- packages/input-box/src/InputBox/InputBox.tsx | 2 +- .../src/InputSegment/InputSegment.spec.tsx | 5 ++-- 4 files changed, 10 insertions(+), 28 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 793c40f565..67bcec1d73 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -1,25 +1,4 @@ -# Input Box +# Internal Input Box -![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/input-box.svg) - -#### [View on MongoDB.design](https://www.mongodb.design/component/input-box/live-example/) - -## Installation - -### PNPM - -```shell -pnpm add @leafygreen-ui/input-box -``` - -### Yarn - -```shell -yarn add @leafygreen-ui/input-box -``` - -### NPM - -```shell -npm install @leafygreen-ui/input-box -``` +An internal component intended to be used by any date or time component. +I.e. `DatePicker`, `TimeInput` etc. diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index ef52f326a1..34f894d924 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { jest } from '@jest/globals'; import userEvent from '@testing-library/user-event'; +import { Size } from '@leafygreen-ui/tokens'; + import { InputSegment } from '../InputSegment'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; import { @@ -15,8 +17,8 @@ import { segmentRulesMock, segmentsMock, } from '../testutils'; + import { InputBox } from './InputBox'; -import { Size } from '@leafygreen-ui/tokens'; describe('packages/input-box', () => { describe('Rendering', () => { diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 779585b2be..464f09ee7e 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -27,7 +27,7 @@ import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; /** * Generic controlled input box component - * Renders a styled input box with appropriate segment order & separator characters. + * Renders an input box with appropriate segment order & separator characters. * * @internal */ diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index c5b765c957..2aca0dd10f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,5 +1,8 @@ +import React from 'react'; import userEvent from '@testing-library/user-event'; +import { Size } from '@leafygreen-ui/tokens'; + import { charsPerSegmentMock, defaultMaxMock, @@ -11,8 +14,6 @@ import { import { getValueFormatter } from '../utils'; import { InputSegment, InputSegmentChangeEventHandler } from '.'; -import { Size } from '@leafygreen-ui/tokens'; -import React from 'react'; describe('packages/input-segment', () => { describe('aria attributes', () => { From 40a106d98e31582d077f99484f5927d0ac580179 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 15:28:51 -0400 Subject: [PATCH 30/56] feat(input-box): adds input-box package and utils --- packages/input-box/README.md | 4 + packages/input-box/package.json | 50 +++ packages/input-box/src/index.ts | 11 + .../createExplicitSegmentValidator.spec.ts | 97 ++++++ .../createExplicitSegmentValidator.ts | 51 +++ ...etNewSegmentValueFromArrowKeyPress.spec.ts | 328 ++++++++++++++++++ .../getNewSegmentValueFromArrowKeyPress.ts | 50 +++ .../getNewSegmentValueFromInputValue.spec.ts | 300 ++++++++++++++++ .../getNewSegmentValueFromInputValue.ts | 86 +++++ .../getRelativeSegment.spec.tsx | 193 +++++++++++ .../getRelativeSegment/getRelativeSegment.ts | 164 +++++++++ .../getValueFormatter/getValueFormatter.ts | 43 +++ .../getValueFormatter/valueFormatter.spec.ts | 66 ++++ packages/input-box/src/utils/index.ts | 17 + .../isElementInputSegment.spec.ts | 95 +++++ .../isElementInputSegment.ts | 28 ++ .../isValidSegment/isValidSegment.spec.ts | 75 ++++ .../utils/isValidSegment/isValidSegment.ts | 51 +++ .../isValidValueForSegment.spec.ts | 75 ++++ .../isValidValueForSegment.ts | 49 +++ packages/input-box/tsconfig.json | 46 +++ pnpm-lock.yaml | 27 ++ 22 files changed, 1906 insertions(+) create mode 100644 packages/input-box/README.md create mode 100644 packages/input-box/package.json create mode 100644 packages/input-box/src/index.ts create mode 100644 packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts create mode 100644 packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts create mode 100644 packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx create mode 100644 packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts create mode 100644 packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts create mode 100644 packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts create mode 100644 packages/input-box/src/utils/index.ts create mode 100644 packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts create mode 100644 packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts create mode 100644 packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts create mode 100644 packages/input-box/src/utils/isValidSegment/isValidSegment.ts create mode 100644 packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts create mode 100644 packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts create mode 100644 packages/input-box/tsconfig.json diff --git a/packages/input-box/README.md b/packages/input-box/README.md new file mode 100644 index 0000000000..67bcec1d73 --- /dev/null +++ b/packages/input-box/README.md @@ -0,0 +1,4 @@ +# Internal Input Box + +An internal component intended to be used by any date or time component. +I.e. `DatePicker`, `TimeInput` etc. diff --git a/packages/input-box/package.json b/packages/input-box/package.json new file mode 100644 index 0000000000..3030c6e71e --- /dev/null +++ b/packages/input-box/package.json @@ -0,0 +1,50 @@ + +{ + "name": "@leafygreen-ui/input-box", + "version": "0.0.1", + "description": "LeafyGreen UI Kit Input Box", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "license": "Apache-2.0", + "exports": { + ".": { + "require": "./dist/umd/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts" + }, + "./testing": { + "require": "./dist/umd/testing/index.js", + "import": "./dist/esm/testing/index.js", + "types": "./dist/types/testing/index.d.ts" + } + }, + "scripts": { + "build": "lg-build bundle", + "tsc": "lg-build tsc", + "docs": "lg-build docs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@leafygreen-ui/emotion": "workspace:^", + "@leafygreen-ui/lib": "workspace:^", + "@leafygreen-ui/hooks": "workspace:^", + "@leafygreen-ui/date-utils": "workspace:^", + "@leafygreen-ui/palette": "workspace:^", + "@leafygreen-ui/tokens": "workspace:^", + "@leafygreen-ui/typography": "workspace:^" + }, + "peerDependencies": { + "@leafygreen-ui/leafygreen-provider": "workspace:^" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/input-box", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/LG/summary" + } +} diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts new file mode 100644 index 0000000000..f70976968b --- /dev/null +++ b/packages/input-box/src/index.ts @@ -0,0 +1,11 @@ +export { + createExplicitSegmentValidator, + type ExplicitSegmentRule, + isElementInputSegment, + isValidValueForSegment, +} from './utils'; +export { getValueFormatter } from './utils/getValueFormatter/getValueFormatter'; +export { + isValidSegmentName, + isValidSegmentValue, +} from './utils/isValidSegment/isValidSegment'; diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts new file mode 100644 index 0000000000..9acad385b9 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -0,0 +1,97 @@ +import { createExplicitSegmentValidator } from './createExplicitSegmentValidator'; + +const segmentObj = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; + +const rules = { + day: { maxChars: 2, minExplicitValue: 4 }, + month: { maxChars: 2, minExplicitValue: 2 }, + year: { maxChars: 4 }, +}; + +const isExplicitSegmentValue = createExplicitSegmentValidator( + segmentObj, + rules, +); + +describe('packages/input-box/utils/createExplicitSegmentValidator', () => { + describe('day segment', () => { + test('returns false for single digit below minExplicitValue', () => { + expect(isExplicitSegmentValue('day', '1')).toBe(false); + expect(isExplicitSegmentValue('day', '2')).toBe(false); + expect(isExplicitSegmentValue('day', '3')).toBe(false); + }); + + test('returns true for single digit at or above minExplicitValue', () => { + expect(isExplicitSegmentValue('day', '4')).toBe(true); + expect(isExplicitSegmentValue('day', '5')).toBe(true); + expect(isExplicitSegmentValue('day', '9')).toBe(true); + }); + + test('returns true for two-digit values (maxChars)', () => { + expect(isExplicitSegmentValue('day', '01')).toBe(true); + expect(isExplicitSegmentValue('day', '10')).toBe(true); + expect(isExplicitSegmentValue('day', '22')).toBe(true); + expect(isExplicitSegmentValue('day', '31')).toBe(true); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('day', '0')).toBe(false); + expect(isExplicitSegmentValue('day', '')).toBe(false); + }); + }); + + describe('month segment', () => { + test('returns false for single digit below minExplicitValue', () => { + expect(isExplicitSegmentValue('month', '1')).toBe(false); + }); + + test('returns true for single digit at or above minExplicitValue', () => { + expect(isExplicitSegmentValue('month', '2')).toBe(true); + expect(isExplicitSegmentValue('month', '3')).toBe(true); + expect(isExplicitSegmentValue('month', '9')).toBe(true); + }); + + test('returns true for two-digit values (maxChars)', () => { + expect(isExplicitSegmentValue('month', '01')).toBe(true); + expect(isExplicitSegmentValue('month', '12')).toBe(true); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('month', '0')).toBe(false); + expect(isExplicitSegmentValue('month', '')).toBe(false); + }); + }); + + describe('year segment', () => { + test('returns false for values shorter than maxChars', () => { + expect(isExplicitSegmentValue('year', '1')).toBe(false); + expect(isExplicitSegmentValue('year', '20')).toBe(false); + expect(isExplicitSegmentValue('year', '200')).toBe(false); + }); + + test('returns true for four-digit values (maxChars)', () => { + expect(isExplicitSegmentValue('year', '1970')).toBe(true); + expect(isExplicitSegmentValue('year', '2000')).toBe(true); + expect(isExplicitSegmentValue('year', '2023')).toBe(true); + expect(isExplicitSegmentValue('year', '0001')).toBe(true); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('year', '0')).toBe(false); + expect(isExplicitSegmentValue('year', '')).toBe(false); + }); + }); + + describe('invalid segment names', () => { + test('returns false for unknown segment names', () => { + // @ts-expect-error Testing invalid segment + expect(isExplicitSegmentValue('invalid', '10')).toBe(false); + // @ts-expect-error Testing invalid segment + expect(isExplicitSegmentValue('hour', '12')).toBe(false); + }); + }); +}); diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts new file mode 100644 index 0000000000..200d832632 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -0,0 +1,51 @@ +import { + isValidSegmentName, + isValidSegmentValue, +} from '../isValidSegment/isValidSegment'; + +/** + * Configuration for determining if a segment value is explicit + */ +export interface ExplicitSegmentRule { + /** Maximum characters for this segment */ + maxChars: number; + /** Minimum numeric value that makes the input explicit (optional) */ + minExplicitValue?: number; +} + +/** + * Factory function that creates a segment value validator + * @param segmentEnum - The segment enum/object to validate against + * @param rules - Rules for each segment type + * @returns A function that checks if a segment value is explicit + * + * @example + * const segmentObj = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * const rules = { + * day: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 1 }, + */ +export function createExplicitSegmentValidator< + T extends Record, +>(segmentEnum: T, rules: Record) { + return (segment: T[keyof T], value: string): boolean => { + if ( + !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) + ) + return false; + + const rule = rules[segment]; + if (!rule) return false; + + const isMaxLength = value.length === rule.maxChars; + const meetsMinValue = rule.minExplicitValue + ? Number(value) >= rule.minExplicitValue + : false; + + return isMaxLength || meetsMinValue; + }; +} diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts new file mode 100644 index 0000000000..331dcf7561 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts @@ -0,0 +1,328 @@ +import { keyMap } from '@leafygreen-ui/lib'; + +import { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress'; + +describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { + describe('ArrowUp key', () => { + test('increments value by 1 when step is not provided', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(6); + }); + + test('increments value by custom step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(10); + }); + + test('rolls over from max to min', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('does not rollover when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(2039); + }); + + test('rolls over when shouldNotRollover is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '12', + key: keyMap.ArrowUp, + min: 1, + max: 12, + shouldNotRollover: false, + }); + expect(result).toBe(1); + }); + + test('defaults to min when value is empty', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('handles value at min boundary', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(2); + }); + + test('handles mid-range value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '15', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(16); + }); + + test('handles value at max boundary with rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('handles large step increments', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 10, + }); + expect(result).toBe(15); + }); + }); + + describe('ArrowDown key', () => { + test('decrements value by 1 when step is not provided', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(4); + }); + + test('decrements value by custom step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '10', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(5); + }); + + test('rolls over from min to max', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(31); + }); + + test('rolls over from min to max for month range', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 12, + }); + expect(result).toBe(12); + }); + + test('does not rollover when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(1969); + }); + + test('rolls over when shouldNotRollover is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 31, + shouldNotRollover: false, + }); + expect(result).toBe(31); + }); + + test('defaults to max when value is empty', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(31); + }); + + test('handles value at max boundary', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(30); + }); + + test('handles mid-range value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '15', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(14); + }); + + test('handles large step decrements', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '20', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 10, + }); + expect(result).toBe(10); + }); + }); + + describe('edge cases', () => { + test('handles step larger than range with rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 12, + step: 20, + }); + expect(result).toBe(2); // 25 rolls over to 2 + }); + + test('handles step larger than range without rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 12, + step: 20, + shouldNotRollover: true, + }); + expect(result).toBe(25); + }); + + test('handles negative values when not rolling over', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '-5', + key: keyMap.ArrowDown, + min: -10, + max: 10, + }); + expect(result).toBe(-6); + }); + + test('handles rollover with negative range', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '-10', + key: keyMap.ArrowDown, + min: -10, + max: 10, + }); + expect(result).toBe(10); + }); + + test('handles zero as min value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '0', + key: keyMap.ArrowDown, + min: 0, + max: 23, + }); + expect(result).toBe(23); + }); + + test('handles rollover at boundary with step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '30', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(4); // 35 rolls to 4 + }); + + test('handles going below min with step and rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '3', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(29); // -2 rolls to 29 + }); + }); + + describe('shouldNotRollover behavior', () => { + test('allows exceeding max when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(2039); + }); + + test('allows going below min when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(1969); + }); + + test('respects rollover by default', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + }); +}); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts new file mode 100644 index 0000000000..6d2e2e9dc7 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -0,0 +1,50 @@ +import { keyMap, rollover } from '@leafygreen-ui/lib'; + +interface GetNewSegmentValueFromArrowKeyPress { + value: V; + key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; + min: number; + max: number; + step?: number; + shouldNotRollover?: boolean; +} + +/** + * Returns a new segment value given the current state + * + * @param value - The current value of the segment + * @param key - The key pressed + * @param min - The minimum value for the segment + * @param max - The maximum value for the segment + * @param step - The step value for the arrow keys + * @param shouldNotRollover - The segments that should not rollover + * @returns The new value for the segment + * @example + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 31, step: 1}); // 2 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 31, step: 1}); // 31 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 12, step: 1}); // 2 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 12, step: 1}); // 12 + * getNewSegmentValueFromArrowKeyPress({ value: '1970', key: 'ArrowUp', min: 1970, max: 2038, step: 1 }); // 1971 + * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldNotRollover: true }); // 2039 + */ +export const getNewSegmentValueFromArrowKeyPress = ({ + value, + key, + min, + max, + shouldNotRollover, + step = 1, +}: GetNewSegmentValueFromArrowKeyPress): number => { + const valueDiff = key === keyMap.ArrowUp ? step : -step; + const defaultVal = key === keyMap.ArrowUp ? min : max; + + const incrementedValue: number = value + ? Number(value) + valueDiff + : defaultVal; + + const newValue = shouldNotRollover + ? incrementedValue + : rollover(incrementedValue, min, max); + + return newValue; +}; diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts new file mode 100644 index 0000000000..3eaba47e20 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -0,0 +1,300 @@ +import range from 'lodash/range'; + +import { getValueFormatter } from '../getValueFormatter/getValueFormatter'; + +import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; + +const charsPerSegment = { + day: 2, + month: 2, + year: 4, +}; + +const defaultMin = { + day: 1, + month: 1, + year: 1970, +}; + +const defaultMax = { + day: 31, + month: 12, + year: new Date().getFullYear(), +}; + +const segmentObj = { + day: 'day', + month: 'month', + year: 'year', +}; + +describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { + describe.each(['day', 'month', 'year'])('For segment %p', _segment => { + const segment = _segment as 'day' | 'month' | 'year'; + describe('when current value is empty', () => { + test.each(range(10))('accepts %i character as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`${i}`); + }); + + const validValues = [defaultMin[segment], defaultMax[segment]]; + test.each(validValues)(`accepts value "%i" as input`, v => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${v}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`${v}`); + }); + + test('does not accept non-numeric characters', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `b`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(''); + }); + + test('does not accept input with a period/decimal', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `2.`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(''); + }); + }); + + describe('when current value is 0', () => { + if (segment !== 'year') { + test('rejects additional 0 as input', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `00`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`0`); + }); + } + + if (segment === 'year') { + test('accepts 0000 as input', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `0000`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + true, + ); + expect(newValue).toEqual(`0000`); + }); + } + test.each(range(1, 10))('accepts 0%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `0${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`0${i}`); + }); + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + ``, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(``); + }); + }); + + describe('when current value is 1', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + ``, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(``); + }); + + if (segment === 'month') { + test.each(range(0, 3))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + 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}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + } else { + test.each(range(10))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`1${i}`); + }); + } + }); + + describe('when current value is 3', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + ``, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + 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}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + 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}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + 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}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + break; + } + + default: + break; + } + }); + + describe('when current value is a full formatted value', () => { + const formatter = getValueFormatter(charsPerSegment[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`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(val); + }, + ); + }); + }); +}); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts new file mode 100644 index 0000000000..0c1644a73e --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -0,0 +1,86 @@ +import last from 'lodash/last'; + +import { truncateStart } from '@leafygreen-ui/lib'; + +import { isValidValueForSegment } from '..'; + +/** + * 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 + * + * @param segmentName - The name of the segment + * @param currentValue - The current value of the segment + * @param incomingValue - The incoming value to set + * @param charsPerSegment - The number of characters per segment + * @param defaultMin - The default minimum value for the segment + * @param defaultMax - The default maximum value for the segment + * @param segmentEnum - The segment object + * @param shouldSkipValidation - Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * @returns The new value for the segment + * @example + * // The segmentEnum is the object that contains the segment names and their corresponding values + * const segmentEnum = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * getNewSegmentValueFromInputValue('day', '1', '2', segmentEnum['day'], 1, 31, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('month', '1', '2', segmentEnum['month'], 1, 12, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('year', '1', '2', segmentEnum['year'], 1970, 2038, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('day', '1', '.', segmentEnum['day'], 1, 31, segmentEnum); // '1' + */ +export const getNewSegmentValueFromInputValue = < + T extends string, + V extends string, +>( + segmentName: T, + currentValue: V, + incomingValue: V, + charsPerSegment: number, + defaultMin: number, + defaultMax: number, + segmentEnum: Readonly>, + shouldSkipValidation = false, +): V => { + // 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 && + incomingValue.length > charsPerSegment; + + if ( + !isIncomingValueNumber || + doesIncomingValueContainPeriod || + wouldCauseOverflow + ) { + return currentValue; + } + + const isIncomingValueValid = isValidValueForSegment( + segmentName, + incomingValue, + defaultMin, + defaultMax, + segmentEnum, + ); + + if (isIncomingValueValid || shouldSkipValidation) { + const newValue = truncateStart(incomingValue, { + length: charsPerSegment, + }); + + return newValue as V; + } + + const typedChar = last(incomingValue.split('')); + const newValue = typedChar === '0' ? '0' : typedChar ?? ''; + return newValue as V; +}; diff --git a/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx new file mode 100644 index 0000000000..872820347b --- /dev/null +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx @@ -0,0 +1,193 @@ +import React, { createRef } from 'react'; +import { render } from '@testing-library/react'; + +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +type Segment = 'day' | 'month' | 'year'; + +type SegmentRefs = Record< + Segment, + ReturnType> +>; + +const segmentRefsMock: SegmentRefs = { + day: createRef(), + month: createRef(), + year: createRef(), +}; + +import { getRelativeSegmentRef } from './getRelativeSegment'; + +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/input-box/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/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts new file mode 100644 index 0000000000..578bf6ddb4 --- /dev/null +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts @@ -0,0 +1,164 @@ +import isUndefined from 'lodash/isUndefined'; +import last from 'lodash/last'; + +type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; + +/** + * Given a direction, starting segment name & format + * returns the segment name in the given direction + * + * @param direction - The direction to get the relative segment from + * @param segment - The starting segment name + * @param formatParts - The format parts of the date + * @returns The segment name in the given direction + * @example + * const formatParts = [ + * { type: 'year', value: '2023' }, + * { type: 'literal', value: '-' }, + * { type: 'month', value: '10' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '31' }, + * ]; + * getRelativeSegment('next', { segment: 'year', formatParts }); // 'month' + * getRelativeSegment('next', { segment: 'month', formatParts }); // 'day' + * getRelativeSegment('prev', { segment: 'day', formatParts }); // 'month' + * getRelativeSegment('prev', { segment: 'month', formatParts }); // 'year' + * getRelativeSegment('first', { segment: 'day', formatParts }); // 'year' + * getRelativeSegment('last', { segment: 'year', formatParts }); // 'day' + */ +export const getRelativeSegment = ( + direction: RelativeDirection, + { + segment, + formatParts, + }: { + segment: V; + formatParts?: Array; + }, +): V | 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 V); + + /** 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; + } +}; + +interface GetRelativeSegmentContext< + T extends Record>, +> { + segment: HTMLInputElement | React.RefObject; + formatParts?: Array; + segmentRefs: T; +} + +/** + * Given a direction, staring segment, and segment refs, + * returns the segment ref in the given direction + * + * @param direction - The direction to get the relative segment from + * @param segment - The starting segment ref + * @param formatParts - The format parts of the date + * @param segmentRefs - The segment refs + * @returns The segment ref in the given direction + * @example + * const formatParts = [ + * { type: 'year', value: '2023' }, + * { type: 'literal', value: '-' }, + * { type: 'month', value: '10' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '31' }, + * ]; + * const segmentRefs = { + * year: yearRef, + * month: monthRef, + * day: dayRef, + * }; + * getRelativeSegmentRef('next', { segment: yearRef, formatParts, segmentRefs }); // monthRef + * getRelativeSegmentRef('prev', { segment: dayRef, formatParts, segmentRefs }); // monthRef + * getRelativeSegmentRef('first', { segment: monthRef, formatParts, segmentRefs }); // yearRef + * getRelativeSegmentRef('last', { segment: monthRef, formatParts, segmentRefs }); // dayRef + */ +export const getRelativeSegmentRef = < + T extends Record>, + V extends string, +>( + 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 V); + + const currentSegmentName: V | 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/input-box/src/utils/getValueFormatter/getValueFormatter.ts b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts new file mode 100644 index 0000000000..f2c6d822e6 --- /dev/null +++ b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts @@ -0,0 +1,43 @@ +import padStart from 'lodash/padStart'; + +import { isZeroLike } from '@leafygreen-ui/lib'; + +/** + * If the value is any form of zero, we set it to an empty string + * otherwise, pad the string with 0s, or trim it to n chars + * + * @param charsPerSegment - the number of characters per segment + * @param allowsZero - + * @param val - the value to format + * @returns a value formatter function for the provided segment + * + * @example + * const charsPerSegment = { + * day: 2, + * month: 2, + * year: 4, + * }; + * const formatter = getValueFormatter(charsPerSegment['day']); + * formatter('0'); // '' + * formatter('1'); // '01' + * formatter('12'); // '12' + * formatter('123'); // '23' + */ +export const getValueFormatter = + (charsPerSegment: number, allowZero = false) => + (val: string | number | undefined) => { + // If the value is empty, do not format it + if (val === '') return ''; + + // Return empty string for zero-like values when disallowed (e.g., '00') + if (!allowZero && isZeroLike(val)) return ''; + + // otherwise, pad the string with 0s, or trim it to n chars + const padded = padStart(Number(val).toString(), charsPerSegment, '0'); + const trimmed = padded.slice( + padded.length - charsPerSegment, + padded.length, + ); + + return trimmed; + }; diff --git a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts new file mode 100644 index 0000000000..7e5436fe01 --- /dev/null +++ b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts @@ -0,0 +1,66 @@ +import { getValueFormatter } from './getValueFormatter'; + +type Segment = 'day' | 'month' | 'year'; +const charsPerSegment: Record = { + day: 2, + month: 2, + year: 4, +}; + +describe('packages/input-box/utils/valueFormatter', () => { + describe.each(['day', 'month'] as Array)('', segment => { + const formatter = getValueFormatter(charsPerSegment[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(charsPerSegment['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/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts new file mode 100644 index 0000000000..9754f2fa90 --- /dev/null +++ b/packages/input-box/src/utils/index.ts @@ -0,0 +1,17 @@ +export { + createExplicitSegmentValidator, + ExplicitSegmentRule, +} from './createExplicitSegmentValidator/createExplicitSegmentValidator'; +export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; +export { + getRelativeSegment, + getRelativeSegmentRef, +} from './getRelativeSegment/getRelativeSegment'; +export { getValueFormatter } from './getValueFormatter/getValueFormatter'; +export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment'; +export { + isValidSegmentName, + isValidSegmentValue, +} from './isValidSegment/isValidSegment'; +export { isValidValueForSegment } from './isValidValueForSegment/isValidValueForSegment'; diff --git a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts new file mode 100644 index 0000000000..9dbc50deda --- /dev/null +++ b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts @@ -0,0 +1,95 @@ +import React from 'react'; + +import { isElementInputSegment } from './isElementInputSegment'; + +describe('packages/input-box/utils/isElementInputSegment', () => { + describe('isElementInputSegment', () => { + let dayInput: HTMLInputElement; + let monthInput: HTMLInputElement; + let yearInput: HTMLInputElement; + let unrelatedInput: HTMLInputElement; + let segmentRefs: Record>; + + beforeEach(() => { + // Create input elements + dayInput = document.createElement('input'); + dayInput.setAttribute('data-segment', 'day'); + + monthInput = document.createElement('input'); + monthInput.setAttribute('data-segment', 'month'); + + yearInput = document.createElement('input'); + yearInput.setAttribute('data-segment', 'year'); + + unrelatedInput = document.createElement('input'); + unrelatedInput.setAttribute('data-testid', 'unrelated'); + + // Create segment refs + segmentRefs = { + day: { current: dayInput }, + month: { current: monthInput }, + year: { current: yearInput }, + }; + }); + + test('returns true when element is the day segment', () => { + expect(isElementInputSegment(dayInput, segmentRefs)).toBe(true); + }); + + test('returns true when element is the month segment', () => { + expect(isElementInputSegment(monthInput, segmentRefs)).toBe(true); + }); + + test('returns true when element is the year segment', () => { + expect(isElementInputSegment(yearInput, segmentRefs)).toBe(true); + }); + + test('returns false when element is not in segment refs', () => { + expect(isElementInputSegment(unrelatedInput, segmentRefs)).toBe(false); + }); + + test('returns false when segmentRefs is empty', () => { + const emptySegmentRefs = {}; + expect(isElementInputSegment(dayInput, emptySegmentRefs)).toBe(false); + }); + + test('returns false when all segment refs are null', () => { + const nullSegmentRefs = { + day: { current: null }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(dayInput, nullSegmentRefs)).toBe(false); + }); + + test('returns true when element matches one of the non-null refs', () => { + const partialSegmentRefs = { + day: { current: dayInput }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(dayInput, partialSegmentRefs)).toBe(true); + }); + + test('returns false when element does not match the only non-null ref', () => { + const partialSegmentRefs = { + day: { current: dayInput }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(monthInput, partialSegmentRefs)).toBe(false); + }); + + test('returns false when checking a div element not in segment refs', () => { + const divElement = document.createElement('div'); + expect(isElementInputSegment(divElement, segmentRefs)).toBe(false); + }); + + test('returns true when segment has a single input', () => { + const singleSegmentRefs = { + hour: { current: dayInput }, + }; + expect(isElementInputSegment(dayInput, singleSegmentRefs)).toBe(true); + }); + }); +}); diff --git a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts new file mode 100644 index 0000000000..411237f8cb --- /dev/null +++ b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts @@ -0,0 +1,28 @@ +/** + * Returns whether the given element is a segment + * @param element - The element to check + * @param segmentObj - The segment object + * @returns Whether the element is a segment + * @example + * // In the segmentRefs object, the key is the segment name and the value is the ref object + * const segmentRefs = { + * day: { current: document.querySelector('input[data-segment="day"]') }, + * month: { current: document.querySelector('input[data-segment="month"]') }, + * year: { current: document.querySelector('input[data-segment="year"]') }, + * }; + * isElementInputSegment(document.querySelector('input[data-segment="day"]'), segmentRefs); // true + * isElementInputSegment(document.querySelector('input[data-segment="month"]'), segmentRefs); // true + * isElementInputSegment(document.querySelector('input[data-segment="year"]'), segmentRefs); // true + */ +export const isElementInputSegment = < + T extends Record>, +>( + element: HTMLElement, + segmentRefs: T, +): 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/input-box/src/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts new file mode 100644 index 0000000000..64929a3f56 --- /dev/null +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts @@ -0,0 +1,75 @@ +import { isValidSegmentName, isValidSegmentValue } from './isValidSegment'; + +const Segment = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; +type SegmentValue = string; + +describe('packages/input-box/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('0 with allowZero returns true', () => { + expect(isValidSegmentValue('0', true)).toBeTruthy(); + }); + + 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(Segment)).toBeFalsy(); + }); + + test('random string returns false', () => { + expect(isValidSegmentName(Segment, '123')).toBeFalsy(); + }); + + test('empty string returns false', () => { + expect(isValidSegmentName(Segment, '')).toBeFalsy(); + }); + + test('day string returns true', () => { + expect(isValidSegmentName(Segment, 'day')).toBeTruthy(); + }); + + test('month string returns true', () => { + expect(isValidSegmentName(Segment, 'month')).toBeTruthy(); + }); + + test('year string returns true', () => { + expect(isValidSegmentName(Segment, 'year')).toBeTruthy(); + }); + }); +}); diff --git a/packages/input-box/src/utils/isValidSegment/isValidSegment.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts new file mode 100644 index 0000000000..3cae5afb58 --- /dev/null +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts @@ -0,0 +1,51 @@ +import isUndefined from 'lodash/isUndefined'; + +/** + * Returns whether a given value is a valid segment value + * + * @param segment - The segment value to validate + * @param allowZero - Whether to allow zero as a valid segment value + * @returns Whether the segment value is valid + * + * @example + * isValidSegmentValue('1'); // true + * isValidSegmentValue('0'); // false + * isValidSegmentValue('0', true); // true + * isValidSegmentValue('00', true); // true + */ +export const isValidSegmentValue = ( + segment?: T, + allowZero = false, +): segment is T => + !isUndefined(segment) && + !isNaN(Number(segment)) && + (Number(segment) > 0 || allowZero); + +/** + * A generic type predicate function that checks if a given string is one + * of the values in the provided segment object. + * + * @param segmentEnum The runtime object containing the valid string segments + * @param name The string to validate + * @returns A boolean and a type predicate (name is T[keyof T]) + * + * @example + * const segmentEnum = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * isValidSegmentName(segmentEnum, 'day'); // true + * isValidSegmentName(segmentEnum, 'month'); // true + * isValidSegmentName(segmentEnum, 'year'); // true + * isValidSegmentName(segmentEnum, 'seconds'); // false + */ +export const isValidSegmentName = >>( + segmentEnum: T, + name?: string, +): name is T[keyof T] => { + return ( + !isUndefined(name) && + Object.values(segmentEnum).includes(name as T[keyof T]) + ); +}; diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts new file mode 100644 index 0000000000..5d7d72dd8a --- /dev/null +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -0,0 +1,75 @@ +import inRange from 'lodash/inRange'; + +import { isValidValueForSegment } from './isValidValueForSegment'; + +const SegmentObj = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; + +type SegmentObj = (typeof SegmentObj)[keyof typeof SegmentObj]; + +const defaultMin = { + day: 1, + month: 1, + year: 1970, +} as const; + +const defaultMax = { + day: 31, + month: 12, + year: 2038, +} as const; + +const isValidValueForSegmentWrapper = (segment: SegmentObj, value: string) => { + return isValidValueForSegment( + segment, + value, + defaultMin[segment], + defaultMax[segment], + SegmentObj, + segment === 'year' + ? (value: string) => inRange(Number(value), 1000, 9999 + 1) + : undefined, + ); +}; + +describe('packages/input-box/utils/isValidSegmentValue', () => { + test('day', () => { + expect(isValidValueForSegmentWrapper('day', '1')).toBe(true); + expect(isValidValueForSegmentWrapper('day', '15')).toBe(true); + expect(isValidValueForSegmentWrapper('day', '31')).toBe(true); + + expect(isValidValueForSegmentWrapper('day', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('day', '32')).toBe(false); + }); + + test('month', () => { + expect(isValidValueForSegmentWrapper('month', '1')).toBe(true); + expect(isValidValueForSegmentWrapper('month', '9')).toBe(true); + expect(isValidValueForSegmentWrapper('month', '12')).toBe(true); + + expect(isValidValueForSegmentWrapper('month', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('month', '28')).toBe(false); + }); + + test('year with custom validation', () => { + expect(isValidValueForSegmentWrapper('year', '1970')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2000')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2038')).toBe(true); + + // All positive numbers 4-digit are considered valid years by default + expect(isValidValueForSegmentWrapper('year', '1000')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '1945')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2048')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '9999')).toBe(true); + + expect(isValidValueForSegmentWrapper('year', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '20')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '200')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '999')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '10000')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '-2000')).toBe(false); + }); +}); diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts new file mode 100644 index 0000000000..7a8df1593e --- /dev/null +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts @@ -0,0 +1,49 @@ +import inRange from 'lodash/inRange'; + +import { + isValidSegmentName, + isValidSegmentValue, +} from '../isValidSegment/isValidSegment'; + +/** + * Returns whether a value is valid for a given segment type + * @param segment - The segment type + * @param value - The value to check + * @param defaultMin - The default minimum value for the segment + * @param defaultMax - The default maximum value for the segment + * @param segmentEnum - The segment object + * @param customValidation - A custom validation function for the segment. This is useful for segments that allow values outside of the default range. + * @returns Whether the value is valid for the segment + * @example + * // The segmentEnum is the object that contains the segment names and their corresponding values + * const segmentEnum = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * isValidValueForSegment('day', '1', 1, 31, segmentEnum); // true + * isValidValueForSegment('day', '32', 1, 31, segmentEnum); // false + * isValidValueForSegment('month', '1', 1, 12, segmentEnum); // true + * isValidValueForSegment('month', '13', 1, 12, segmentEnum); // false + * isValidValueForSegment('year', '1970', 1000, 9999, segmentEnum); // true + */ +export const isValidValueForSegment = ( + segment: T, + value: V, + defaultMin: number, + defaultMax: number, + segmentEnum: Readonly>, + customValidation?: (value: V) => boolean, +): boolean => { + const isValidSegmentAndValue = + isValidSegmentValue(value, defaultMin === 0) && + isValidSegmentName(segmentEnum, segment); + + if (customValidation) { + return isValidSegmentAndValue && customValidation(value); + } + + const isInRange = inRange(Number(value), defaultMin, defaultMax + 1); + + return isValidSegmentAndValue && isInRange; +}; diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json new file mode 100644 index 0000000000..cba2152d8f --- /dev/null +++ b/packages/input-box/tsconfig.json @@ -0,0 +1,46 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": [ + "../icon/src/generated/*" + ], + "@leafygreen-ui/*": [ + "../*/src" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/*.spec.*", + "**/*.stories.*" + ], + "references": [ + { + "path": "../emotion" + }, + { + "path": "../lib" + }, + { + "path": "../hooks" + }, + { + "path": "../date-utils" + }, + { + "path": "../palette" + }, + { + "path": "../tokens" + }, + { + "path": "../typography" + }, + { + "path": "../leafygreen-provider" + } + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1297838ca..3de735ab2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2253,6 +2253,33 @@ importers: specifier: workspace:^ version: link:../../tools/build + packages/input-box: + dependencies: + '@leafygreen-ui/date-utils': + specifier: workspace:^ + version: link:../date-utils + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/hooks': + specifier: workspace:^ + version: link:../hooks + '@leafygreen-ui/leafygreen-provider': + specifier: workspace:^ + version: link:../leafygreen-provider + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/palette': + specifier: workspace:^ + version: link:../palette + '@leafygreen-ui/tokens': + specifier: workspace:^ + version: link:../tokens + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography + packages/input-option: dependencies: '@leafygreen-ui/a11y': From 2f600d94d4b5202ce7503920c8e44fadad05d05e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 16:02:18 -0400 Subject: [PATCH 31/56] refactor(input-box): consolidate InputBox stories into a single file and enhance control parameters --- packages/input-box/src/InputBox.stories.tsx | 51 +++++++++++++++--- .../src/InputBox/InputBox.stories.tsx | 52 ------------------- 2 files changed, 45 insertions(+), 58 deletions(-) delete mode 100644 packages/input-box/src/InputBox/InputBox.stories.tsx diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index df42d2c69d..05a065f2e8 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -1,13 +1,52 @@ import React from 'react'; +import { + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; import { StoryFn } from '@storybook/react'; -import { InputBox } from '.'; +import { css } from '@leafygreen-ui/emotion'; +import { palette } from '@leafygreen-ui/palette'; -export default { - title: 'Components/InputBox', +import { InputBoxWithState } from './testutils'; + +import { InputBox } from './InputBox'; + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox', component: InputBox, + decorators: [ + StoryFn => ( +
+ +
+ ), + ], + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segments', + 'segmentObj', + 'segmentRefs', + 'setSegment', + 'charsPerSegment', + 'formatParts', + 'segmentRules', + 'labelledBy', + 'onSegmentChange', + 'renderSegment', + ], + }, + }, }; +export default meta; -const Template: StoryFn = props => ; - -export const Basic = Template.bind({}); +export const LiveExample: StoryFn = props => { + return ; +}; diff --git a/packages/input-box/src/InputBox/InputBox.stories.tsx b/packages/input-box/src/InputBox/InputBox.stories.tsx deleted file mode 100644 index 3b5e503f3d..0000000000 --- a/packages/input-box/src/InputBox/InputBox.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import { - storybookExcludedControlParams, - StoryMetaType, -} from '@lg-tools/storybook-utils'; -import { StoryFn } from '@storybook/react'; - -import { css } from '@leafygreen-ui/emotion'; -import { palette } from '@leafygreen-ui/palette'; - -import { InputBoxWithState } from '../testutils'; - -import { InputBox } from '.'; - -const meta: StoryMetaType = { - title: 'Components/Inputs/InputBox', - component: InputBox, - decorators: [ - StoryFn => ( -
- -
- ), - ], - parameters: { - default: 'LiveExample', - controls: { - exclude: [ - ...storybookExcludedControlParams, - 'segments', - 'segmentObj', - 'segmentRefs', - 'setSegment', - 'charsPerSegment', - 'formatParts', - 'segmentRules', - 'labelledBy', - 'onSegmentChange', - 'renderSegment', - ], - }, - }, -}; -export default meta; - -export const LiveExample: StoryFn = props => { - return ; -}; From b8d410a1ff9a134c3574cca1a3d452b42ac6b950 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 16:23:13 -0400 Subject: [PATCH 32/56] refactor(date-picker): remove input-box dependency and streamline date segment handling --- packages/date-picker/package.json | 1 - .../DatePicker/DatePicker.keyboard3.spec.tsx | 6 +- .../DatePickerInput/DatePickerInput.tsx | 77 +++++++- .../DateInput/DateInputBox/DateInputBox.tsx | 149 ++++++++++---- .../DateInputSegment.spec.tsx | 11 +- .../DateInputSegment.styles.ts | 80 ++++++++ .../DateInputSegment/DateInputSegment.tsx | 181 +++++++++++++++--- .../DateInputSegment.types.ts | 8 +- .../getNewSegmentValueFromArrowKeyPress.ts | 36 ++++ .../getNewSegmentValueFromInputValue.spec.ts | 159 +++++++++++++++ .../getNewSegmentValueFromInputValue.ts | 56 ++++++ .../DateInput/DateInputSegment/utils/index.ts | 1 + packages/date-picker/src/shared/constants.ts | 16 -- .../getFormattedDateStringFromSegments.ts | 6 +- .../getRelativeSegment.spec.tsx | 181 ++++++++++++++++++ .../shared/utils/getRelativeSegment/index.ts | 122 ++++++++++++ .../getFormattedSegmentsFromDate.ts | 9 +- .../shared/utils/getValueFormatter/index.ts | 29 +++ .../getValueFormatter/valueFormatter.spec.ts | 61 ++++++ .../date-picker/src/shared/utils/index.ts | 9 + .../utils/isElementInputSegment/index.ts | 16 ++ .../isEverySegmentValid.ts | 19 +- .../isEverySegmentValueExplicit.ts | 15 +- .../utils/isExplicitSegmentValue/index.ts | 28 +++ .../isExplicitSegmentValue.spec.ts | 27 +++ .../src/shared/utils/isValidSegment/index.ts | 21 ++ .../isValidSegment/isValidSegment.spec.ts | 64 +++++++ .../utils/isValidValueForSegment/index.ts | 29 +++ .../isValidValueForSegment.spec.ts | 40 ++++ packages/date-picker/tsconfig.json | 5 +- pnpm-lock.yaml | 3 - 31 files changed, 1319 insertions(+), 146 deletions(-) create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts create mode 100644 packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx create mode 100644 packages/date-picker/src/shared/utils/getRelativeSegment/index.ts create mode 100644 packages/date-picker/src/shared/utils/getValueFormatter/index.ts create mode 100644 packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts create mode 100644 packages/date-picker/src/shared/utils/isElementInputSegment/index.ts create mode 100644 packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts create mode 100644 packages/date-picker/src/shared/utils/isExplicitSegmentValue/isExplicitSegmentValue.spec.ts create mode 100644 packages/date-picker/src/shared/utils/isValidSegment/index.ts create mode 100644 packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts create mode 100644 packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts create mode 100644 packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts diff --git a/packages/date-picker/package.json b/packages/date-picker/package.json index 2dbe7e2693..87bf0a13cf 100644 --- a/packages/date-picker/package.json +++ b/packages/date-picker/package.json @@ -22,7 +22,6 @@ "@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 b9076df507..41226340d3 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 { charsPerSegment, defaultMax, defaultMin } from '../shared/constants'; +import { defaultMax, defaultMin } from '../shared/constants'; import { getFormattedDateString, getFormattedSegmentsFromDate, + getValueFormatter, } from '../shared/utils'; import { @@ -79,7 +79,7 @@ describe('DatePicker keyboard interaction', () => { const segmentCases = ['year', 'month', 'day'] as Array; describe.each(segmentCases)('%p segment', segment => { - const formatter = getValueFormatter(charsPerSegment[segment]); + const formatter = getValueFormatter(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 c68789da3d..7954a8df4f 100644 --- a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.tsx @@ -8,7 +8,6 @@ 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 { @@ -18,7 +17,11 @@ import { } from '../../shared/components/DateInput'; import { DateInputSegmentChangeEventHandler } from '../../shared/components/DateInput/DateInputSegment'; import { useSharedDatePickerContext } from '../../shared/context'; -import { getFormattedDateStringFromSegments } from '../../shared/utils'; +import { + getFormattedDateStringFromSegments, + getRelativeSegmentRef, + isElementInputSegment, +} from '../../shared/utils'; import { useDatePickerContext } from '../DatePickerContext'; import { getSegmentToFocus } from '../utils/getSegmentToFocus'; @@ -107,11 +110,77 @@ 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 @@ -163,9 +232,10 @@ 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 69386ef015..f0851e022b 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, { useEffect } from 'react'; +import React, { FocusEventHandler, useEffect } from 'react'; import isEqual from 'lodash/isEqual'; import isNull from 'lodash/isNull'; @@ -7,25 +7,37 @@ import { isInvalidDateObject, isValidDate, } from '@leafygreen-ui/date-utils'; -import { InputBox } from '@leafygreen-ui/input-box'; +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 { - charsPerSegment, - dateSegmentRules, - defaultMin, -} 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 { DateInputSegment } from '../DateInputSegment'; +import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; +import { + segmentPartsWrapperStyles, + separatorLiteralDisabledStyles, + separatorLiteralStyles, +} from './DateInputBox.styles'; import { DateInputBoxProps } from './DateInputBox.types'; /** @@ -50,13 +62,25 @@ export const DateInputBox = React.forwardRef( labelledBy, segmentRefs, onSegmentChange, - onKeyDown, ...rest }: DateInputBoxProps, fwdRef, ) => { const { isDirty, formatParts, disabled, min, max, setIsDirty } = 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(() => { @@ -94,41 +118,92 @@ 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/DateInputSegment/DateInputSegment.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx index 06ce3c37e4..8f56fb113f 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 @@ -3,14 +3,13 @@ import { jest } from '@jest/globals'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { getValueFormatter } from '@leafygreen-ui/input-box'; - -import { charsPerSegment, defaultMax, defaultMin } from '../../../constants'; +import { defaultMax, defaultMin } from '../../../constants'; import { SharedDatePickerProvider, SharedDatePickerProviderProps, } from '../../../context'; import { DateSegment } from '../../../types'; +import { getValueFormatter } from '../../../utils'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; @@ -245,7 +244,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Arrow Keys', () => { describe('day input', () => { - const formatter = getValueFormatter(charsPerSegment['day']); + const formatter = getValueFormatter('day'); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -391,7 +390,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('month input', () => { - const formatter = getValueFormatter(charsPerSegment['month']); + const formatter = getValueFormatter('month'); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -553,7 +552,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('year input', () => { - const formatter = getValueFormatter(charsPerSegment['year']); + const formatter = getValueFormatter('year'); describe('Up arrow', () => { test('calls handler with value +1', () => { 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 68af1ce4cf..207fde92d3 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,8 +1,88 @@ 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 b219a989d0..df30f5303f 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -1,7 +1,11 @@ -import React from 'react'; +import React, { ChangeEventHandler, KeyboardEventHandler } from 'react'; import { cx } from '@leafygreen-ui/emotion'; -import { InputSegment } from '@leafygreen-ui/input-box'; +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 { charsPerSegment, @@ -10,11 +14,18 @@ import { defaultPlaceholder, } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; -import { DateSegment } from '../../../types'; -import { getAutoComplete } from '../../../utils'; +import { getAutoComplete, getValueFormatter } from '../../../utils'; -import { segmentWidthStyles } from './DateInputSegment.styles'; +import { getNewSegmentValueFromArrowKeyPress } from './utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +import { + baseStyles, + fontSizeStyles, + segmentSizeStyles, + segmentThemeStyles, + segmentWidthStyles, +} from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; +import { getNewSegmentValueFromInputValue } from './utils'; /** * Controlled component @@ -45,45 +56,159 @@ export const DateInputSegment = React.forwardRef< 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; + } - const shouldNotRollover = ( - [DateSegment.Year] as Array - ).includes(segment); + // 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(); - const shouldSkipValidation = ( - [DateSegment.Year] as Array - ).includes(segment); + /** 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 ( - ); }, 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 53d916292d..c025f5ad11 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,5 @@ import React from 'react'; -import { InputSegmentChangeEventHandler } from '@leafygreen-ui/input-box'; import { DarkModeProps, keyMap } from '@leafygreen-ui/lib'; import { DateSegment, DateSegmentValue } from '../../../types'; @@ -14,10 +13,9 @@ export interface DateInputSegmentChangeEvent { }; } -export type DateInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< - DateSegment, - DateSegmentValue ->; +export type DateInputSegmentChangeEventHandler = ( + dateSegmentChangeEvent: DateInputSegmentChangeEvent, +) => void; export interface DateInputSegmentProps extends DarkModeProps, 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 new file mode 100644 index 0000000000..832c7c978a --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000000..095fe83b01 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -0,0 +1,159 @@ +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 new file mode 100644 index 0000000000..1aff779713 --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 0000000000..f71520a27c --- /dev/null +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts @@ -0,0 +1 @@ +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; diff --git a/packages/date-picker/src/shared/constants.ts b/packages/date-picker/src/shared/constants.ts index 8d46029865..3efdaaa8cc 100644 --- a/packages/date-picker/src/shared/constants.ts +++ b/packages/date-picker/src/shared/constants.ts @@ -2,8 +2,6 @@ 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 @@ -71,17 +69,3 @@ 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 94a467ad02..49cbaafded 100644 --- a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts +++ b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts @@ -1,8 +1,6 @@ -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, @@ -18,7 +16,7 @@ export const getFormattedDateStringFromSegments = ( } const segment = part.type as DateSegment; - const formatter = getValueFormatter(charsPerSegment[segment]); + const formatter = getValueFormatter(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 new file mode 100644 index 0000000000..9c4370ca5c --- /dev/null +++ b/packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx @@ -0,0 +1,181 @@ +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 new file mode 100644 index 0000000000..c298bddd5a --- /dev/null +++ b/packages/date-picker/src/shared/utils/getRelativeSegment/index.ts @@ -0,0 +1,122 @@ +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 dbb8ae65bc..bcbf01f260 100644 --- a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts +++ b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts @@ -1,8 +1,7 @@ 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'; @@ -13,8 +12,8 @@ export const getFormattedSegmentsFromDate = ( const segments = getSegmentsFromDate(date); return { - day: getValueFormatter(charsPerSegment['day'])(segments['day']), - month: getValueFormatter(charsPerSegment['month'])(segments['month']), - year: getValueFormatter(charsPerSegment['year'])(segments['year']), + day: getValueFormatter('day')(segments['day']), + month: getValueFormatter('month')(segments['month']), + year: getValueFormatter('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 new file mode 100644 index 0000000000..bf759d62bc --- /dev/null +++ b/packages/date-picker/src/shared/utils/getValueFormatter/index.ts @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000000..9b04b141ea --- /dev/null +++ b/packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts @@ -0,0 +1,61 @@ +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 cc082f9041..354af9cf99 100644 --- a/packages/date-picker/src/shared/utils/index.ts +++ b/packages/date-picker/src/shared/utils/index.ts @@ -9,6 +9,10 @@ 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 { @@ -16,7 +20,12 @@ 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 new file mode 100644 index 0000000000..4bacd83464 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isElementInputSegment/index.ts @@ -0,0 +1,16 @@ +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 049a3b9b30..6e338ec5b9 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -1,24 +1,11 @@ -import inRange from 'lodash/inRange'; - -import { isValidValueForSegment } from '@leafygreen-ui/input-box'; - -import { defaultMax, defaultMin } from '../../constants'; -import { DateSegment, DateSegmentsState, DateSegmentValue } from '../../types'; +import { DateSegment, DateSegmentsState } from '../../types'; +import { isValidValueForSegment } from '../isValidValueForSegment'; /** * 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 as DateSegmentValue, - defaultMin[segment as DateSegment], - defaultMax[segment as DateSegment], - DateSegment, - segment === DateSegment.Year - ? (value: DateSegmentValue) => inRange(Number(value), 1000, 9999 + 1) - : undefined, - ), + isValidValueForSegment(segment as DateSegment, value), ); }; diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts b/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts index 894f0237b2..10ec19bd54 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts @@ -1,18 +1,5 @@ -import { createExplicitSegmentValidator } from '@leafygreen-ui/input-box'; - -import { dateSegmentRules } from '../../constants'; import { DateSegment, DateSegmentsState } from '../../types'; - -/** - * 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( - DateSegment, - dateSegmentRules, -); +import { isExplicitSegmentValue } from '../isExplicitSegmentValue'; /** * 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 new file mode 100644 index 0000000000..e357588425 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000000..7011ecb6a4 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isExplicitSegmentValue/isExplicitSegmentValue.spec.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000000..861fbeca75 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isValidSegment/index.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000000..0993fec4be --- /dev/null +++ b/packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts @@ -0,0 +1,64 @@ +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 new file mode 100644 index 0000000000..802dd3baf1 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000000..4b29066629 --- /dev/null +++ b/packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -0,0 +1,40 @@ +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 b99731e7c9..48c679b834 100644 --- a/packages/date-picker/tsconfig.json +++ b/packages/date-picker/tsconfig.json @@ -41,9 +41,6 @@ { "path": "../icon-button" }, - { - "path": "../input-box" - }, { "path": "../leafygreen-provider" }, @@ -72,4 +69,4 @@ "path": "../typography" } ] -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2de0639a3c..3de735ab2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1660,9 +1660,6 @@ 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 From 95de319271ca606fefd309381457ab02b3b62d5e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 16:36:47 -0400 Subject: [PATCH 33/56] feat(date-picker): integrate input-box for date segment handling and enhance validation logic --- packages/date-picker/package.json | 1 + .../DatePicker/DatePicker.keyboard3.spec.tsx | 6 +- .../DatePickerInput/DatePickerInput.tsx | 77 +------- .../DateInput/DateInputBox/DateInputBox.tsx | 149 ++++---------- .../DateInputSegment.spec.tsx | 11 +- .../DateInputSegment.styles.ts | 80 -------- .../DateInputSegment/DateInputSegment.tsx | 181 +++--------------- .../DateInputSegment.types.ts | 8 +- .../getNewSegmentValueFromArrowKeyPress.ts | 36 ---- .../getNewSegmentValueFromInputValue.spec.ts | 159 --------------- .../getNewSegmentValueFromInputValue.ts | 56 ------ .../DateInput/DateInputSegment/utils/index.ts | 1 - packages/date-picker/src/shared/constants.ts | 16 ++ .../getFormattedDateStringFromSegments.ts | 6 +- .../getRelativeSegment.spec.tsx | 181 ------------------ .../shared/utils/getRelativeSegment/index.ts | 122 ------------ .../getFormattedSegmentsFromDate.ts | 9 +- .../shared/utils/getValueFormatter/index.ts | 29 --- .../getValueFormatter/valueFormatter.spec.ts | 61 ------ .../date-picker/src/shared/utils/index.ts | 9 - .../utils/isElementInputSegment/index.ts | 16 -- .../isEverySegmentValid.ts | 19 +- .../isEverySegmentValueExplicit.ts | 15 +- .../utils/isExplicitSegmentValue/index.ts | 28 --- .../isExplicitSegmentValue.spec.ts | 27 --- .../src/shared/utils/isValidSegment/index.ts | 21 -- .../isValidSegment/isValidSegment.spec.ts | 64 ------- .../utils/isValidValueForSegment/index.ts | 29 --- .../isValidValueForSegment.spec.ts | 40 ---- packages/date-picker/tsconfig.json | 5 +- pnpm-lock.yaml | 3 + 31 files changed, 146 insertions(+), 1319 deletions(-) delete mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts delete mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts delete mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts delete mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputSegment/utils/index.ts delete mode 100644 packages/date-picker/src/shared/utils/getRelativeSegment/getRelativeSegment.spec.tsx delete mode 100644 packages/date-picker/src/shared/utils/getRelativeSegment/index.ts delete mode 100644 packages/date-picker/src/shared/utils/getValueFormatter/index.ts delete mode 100644 packages/date-picker/src/shared/utils/getValueFormatter/valueFormatter.spec.ts delete mode 100644 packages/date-picker/src/shared/utils/isElementInputSegment/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isExplicitSegmentValue/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isExplicitSegmentValue/isExplicitSegmentValue.spec.ts delete mode 100644 packages/date-picker/src/shared/utils/isValidSegment/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isValidSegment/isValidSegment.spec.ts delete mode 100644 packages/date-picker/src/shared/utils/isValidValueForSegment/index.ts delete mode 100644 packages/date-picker/src/shared/utils/isValidValueForSegment/isValidValueForSegment.spec.ts 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..b9076df507 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,7 @@ 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[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..69386ef015 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,25 @@ 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, + defaultMin, +} from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; import { useDateSegments } from '../../../hooks'; -import { - DateSegment, - DateSegmentsState, - DateSegmentValue, - isDateSegment, -} from '../../../types'; +import { DateSegment, DateSegmentsState } from '../../../types'; import { getMaxSegmentValue, getMinSegmentValue, - getRelativeSegment, - getValueFormatter, isEverySegmentFilled, isEverySegmentValueExplicit, - isExplicitSegmentValue, newDateFromSegments, } from '../../../utils'; import { DateInputSegment } from '../DateInputSegment'; -import { DateInputSegmentChangeEventHandler } from '../DateInputSegment/DateInputSegment.types'; -import { - segmentPartsWrapperStyles, - separatorLiteralDisabledStyles, - separatorLiteralStyles, -} from './DateInputBox.styles'; import { DateInputBoxProps } from './DateInputBox.types'; /** @@ -62,25 +50,13 @@ export const DateInputBox = React.forwardRef( labelledBy, segmentRefs, onSegmentChange, + onKeyDown, ...rest }: DateInputBoxProps, fwdRef, ) => { const { isDirty, formatParts, disabled, min, max, setIsDirty } = 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 +94,41 @@ 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 ( -
( + + )} {...rest} - > - {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/DateInputSegment/DateInputSegment.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx index 8f56fb113f..06ce3c37e4 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 @@ -3,13 +3,14 @@ import { jest } from '@jest/globals'; import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { defaultMax, defaultMin } from '../../../constants'; +import { getValueFormatter } from '@leafygreen-ui/input-box'; + +import { charsPerSegment, defaultMax, defaultMin } from '../../../constants'; import { SharedDatePickerProvider, SharedDatePickerProviderProps, } from '../../../context'; import { DateSegment } from '../../../types'; -import { getValueFormatter } from '../../../utils'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; @@ -244,7 +245,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Arrow Keys', () => { describe('day input', () => { - const formatter = getValueFormatter('day'); + const formatter = getValueFormatter(charsPerSegment['day']); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -390,7 +391,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('month input', () => { - const formatter = getValueFormatter('month'); + const formatter = getValueFormatter(charsPerSegment['month']); describe('Up arrow', () => { test('calls handler with value +1', () => { @@ -552,7 +553,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('year input', () => { - const formatter = getValueFormatter('year'); + const formatter = getValueFormatter(charsPerSegment['year']); describe('Up arrow', () => { test('calls handler with value +1', () => { 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..b219a989d0 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -1,11 +1,7 @@ -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, @@ -14,18 +10,11 @@ import { defaultPlaceholder, } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; -import { getAutoComplete, getValueFormatter } from '../../../utils'; +import { DateSegment } from '../../../types'; +import { getAutoComplete } from '../../../utils'; -import { getNewSegmentValueFromArrowKeyPress } from './utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; -import { - baseStyles, - fontSizeStyles, - segmentSizeStyles, - segmentThemeStyles, - segmentWidthStyles, -} from './DateInputSegment.styles'; +import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; -import { getNewSegmentValueFromInputValue } from './utils'; /** * Controlled component @@ -56,159 +45,45 @@ export const DateInputSegment = React.forwardRef< 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; - } + const autoComplete = getAutoComplete(autoCompleteProp, segment); - default: { - break; - } - } + const shouldNotRollover = ( + [DateSegment.Year] as Array + ).includes(segment); - onKeyDown?.(e); - }; + const shouldSkipValidation = ( + [DateSegment.Year] as Array + ).includes(segment); - // 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 ( - ); }, 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..53d916292d 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,5 +1,6 @@ import React from 'react'; +import { InputSegmentChangeEventHandler } from '@leafygreen-ui/input-box'; import { DarkModeProps, keyMap } from '@leafygreen-ui/lib'; import { DateSegment, DateSegmentValue } from '../../../types'; @@ -13,9 +14,10 @@ export interface DateInputSegmentChangeEvent { }; } -export type DateInputSegmentChangeEventHandler = ( - dateSegmentChangeEvent: DateInputSegmentChangeEvent, -) => void; +export type DateInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< + DateSegment, + DateSegmentValue +>; export interface DateInputSegmentProps extends DarkModeProps, 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..94a467ad02 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,7 @@ export const getFormattedDateStringFromSegments = ( } const segment = part.type as DateSegment; - const formatter = getValueFormatter(segment); + const formatter = getValueFormatter(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..dbb8ae65bc 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,8 @@ 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['day'])(segments['day']), + month: getValueFormatter(charsPerSegment['month'])(segments['month']), + year: getValueFormatter(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..049a3b9b30 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -1,11 +1,24 @@ -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 as DateSegment, + value as DateSegmentValue, + defaultMin[segment as DateSegment], + defaultMax[segment as DateSegment], + DateSegment, + 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..894f0237b2 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( + DateSegment, + 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 3de735ab2c..2de0639a3c 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 From d7853dc4f1d1e18c87394f88a8781466805ba140 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sat, 1 Nov 2025 18:45:22 -0400 Subject: [PATCH 34/56] refactor(date-picker, input-box): implement context for segment management and streamline props handling in DateInput components --- .../DateInput/DateInputBox/DateInputBox.tsx | 47 +- .../DateInputSegment.spec.tsx | 611 ++++++++++++------ .../DateInputSegment/DateInputSegment.tsx | 33 +- .../DateInputSegment.types.ts | 2 - .../input-box/src/InputBox/InputBox.spec.tsx | 10 +- packages/input-box/src/InputBox/InputBox.tsx | 72 ++- .../input-box/src/InputBox/InputBox.types.ts | 140 +++- .../InputBoxContext/InputBoxContext.spec.ts | 0 .../src/InputBoxContext/InputBoxContext.tsx | 139 ++++ .../InputBoxContext/InputBoxContext.types.ts | 0 .../input-box/src/InputBoxContext/index.ts | 5 + .../src/InputSegment/InputSegment.spec.tsx | 238 ++++--- .../src/InputSegment/InputSegment.tsx | 26 +- .../src/InputSegment/InputSegment.types.ts | 16 +- packages/input-box/src/index.ts | 5 + packages/input-box/src/testutils/index.tsx | 102 +-- 16 files changed, 1033 insertions(+), 413 deletions(-) create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.spec.ts create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.tsx create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.types.ts create mode 100644 packages/input-box/src/InputBoxContext/index.ts 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 69386ef015..0d91355443 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -58,6 +58,10 @@ export const DateInputBox = React.forwardRef( const { isDirty, formatParts, disabled, min, max, setIsDirty } = useSharedDatePickerContext(); + // TODO: add context to store the value and segmentsRef so that the DateInputSegment can access it + // const { value, segmentsRef, labelledby, segments } + // + /** if the value is a `Date` the component is dirty */ useEffect(() => { if (isDateObject(value) && !isDirty) { @@ -104,16 +108,17 @@ export const DateInputBox = React.forwardRef( ( ( max={getMaxSegmentValue(partType, { date: value, max })} segment={partType} value={segments[partType]} - onChange={onChange} - onBlur={onBlur} + // onChange={onChange} + // onBlur={onBlur} /> )} + // TODO:Segment={DateInputSegment} {...rest} - > + > + {/* {renderFormat(formatParts, DateInputSegment, value, labelledBy)} */} +
); }, ); DateInputBox.displayName = 'DateInputBox'; + +// // renderSegment as a function +// const RenderFormat = (formatParts: Intl.DateTimeFormatPart[], Segment: ReactComponent, value) => { +// return ( +//
+// {formatParts?.map((part, i) => { +// if (part.type === 'literal') { +// return ( +// +// {part.value} +// +// ); +// } else if (isInputSegment(part.type, segmentEnum)) { +// // render segement +// return ; +// } +// })} +//
+// ); +// }; + +// TODO: consider renaming min/max names to minSegment/maxSegment 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 06ce3c37e4..9eeeb2d554 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 @@ -15,26 +15,55 @@ import { DateSegment } from '../../../types'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; +import { + InputBoxProvider, + type InputBoxProviderProps, +} from '@leafygreen-ui/input-box'; + const renderSegment = ( props?: Partial, ctx?: Partial, + providerProps?: Partial>, ) => { const defaultProps = { value: '', - onChange: () => {}, + onChange: () => {}, //TODO: remove this segment: 'day' as DateSegment, }; + const defaultProviderProps = { + onChange: () => {}, + onBlur: () => {}, + }; + const result = render( - + + + , ); - const rerenderSegment = (newProps: Partial) => + const rerenderSegment = ( + newProps: Partial, + newProviderProps?: Partial>, + ) => result.rerender( - , + + + + , , ); @@ -145,7 +174,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Typing', () => { describe('into an empty segment', () => { test('calls the change handler', () => { - const { input } = renderSegment({ + const { input } = renderSegment({}, undefined, { onChange: onChangeHandler, }); @@ -156,7 +185,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('allows zero character', () => { - const { input } = renderSegment({ + const { input } = renderSegment({}, undefined, { onChange: onChangeHandler, }); @@ -167,12 +196,12 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('allows typing leading zeroes', async () => { - const { input, rerenderSegment } = renderSegment({ + const { input, rerenderSegment } = renderSegment({}, undefined, { onChange: onChangeHandler, }); userEvent.type(input, '0'); - rerenderSegment({ value: '0' }); + rerenderSegment({ value: '0' }, { onChange: onChangeHandler }); userEvent.type(input, '2'); await waitFor(() => { @@ -183,7 +212,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('does not allow non-number characters', () => { - const { input } = renderSegment({ + const { input } = renderSegment({}, undefined, { onChange: onChangeHandler, }); @@ -194,10 +223,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { 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, - }); + const { input } = renderSegment( + { + value: '2', + // onChange: onChangeHandler, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '6'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -206,10 +241,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('resets the value when the value is complete', () => { - const { input } = renderSegment({ - value: '26', - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + value: '26', + // onChange: onChangeHandler, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '4'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -222,7 +263,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Keyboard', () => { describe('Backspace', () => { test('does not call the onChangeHandler when the value is initially empty', () => { - const { input } = renderSegment({ + const { input } = renderSegment({}, undefined, { onChange: onChangeHandler, }); @@ -231,10 +272,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('clears the input when there is a value', () => { - const { input } = renderSegment({ - value: '26', - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + value: '26', + // onChange: onChangeHandler, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{backspace}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -249,11 +296,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Up arrow', () => { test('calls handler with value +1', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: formatter(15), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -264,11 +317,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with default `min` if initially undefined', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: '', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -277,11 +336,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('rolls value over to default `min` value if value exceeds `max`', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMax['day']), - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: formatter(defaultMax['day']), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -290,12 +355,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with provided `min` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - min: 5, - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: '', + min: 5, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -304,12 +375,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); 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, - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: formatter(defaultMax['day']), + min: 5, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -320,11 +397,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Down arrow', () => { test('calls handler with value -1', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: formatter(15), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -335,11 +418,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with default `max` if initially undefined', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: '', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -348,11 +437,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('rolls value over to default `max` value if value exceeds `min`', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMin['day']), - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: formatter(defaultMin['day']), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -361,12 +456,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with provided `max` prop if initially undefined', () => { - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - max: 25, - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: '', + max: 25, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -375,12 +476,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); 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, - }); + const { input } = renderSegment( + { + segment: 'day', + // onChange: onChangeHandler, + value: formatter(defaultMin['day']), + max: 25, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -395,11 +502,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Up arrow', () => { test('calls handler with value +1', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(6), - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: formatter(6), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -410,11 +523,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with default `min` if initially undefined', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: '', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -425,11 +544,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('rolls value over to default `min` value if value exceeds `max`', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(defaultMax['month']), - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: formatter(defaultMax['month']), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -440,12 +565,18 @@ 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, - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: '', + min: 5, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -456,12 +587,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); 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, - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: formatter(defaultMax['month']), + min: 5, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -474,11 +611,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Down arrow', () => { test('calls handler with value -1', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(6), - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: formatter(6), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -489,11 +632,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with default `max` if initially undefined', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: '', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -504,11 +653,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('rolls value over to default `max` value if value exceeds `min`', () => { - const { input } = renderSegment({ - segment: 'month', - onChange: onChangeHandler, - value: formatter(defaultMin['month']), - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: formatter(defaultMin['month']), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -519,12 +674,18 @@ 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, - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: '', + max: 10, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -535,12 +696,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); 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, - }); + const { input } = renderSegment( + { + segment: 'month', + // onChange: onChangeHandler, + value: formatter(defaultMin['month']), + max: 10, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -557,11 +724,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Up arrow', () => { test('calls handler with value +1', () => { - const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: formatter(1993), - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: formatter(1993), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ @@ -571,11 +744,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with default `min` if initially undefined', () => { - const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: '', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -586,11 +765,17 @@ 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']), - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: formatter(defaultMax['year']), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -601,12 +786,18 @@ 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, - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: '', + min: 1969, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -619,11 +810,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Down arrow', () => { test('calls handler with value -1', () => { - const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: formatter(1993), - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: formatter(1993), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ @@ -633,11 +830,17 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); test('calls handler with default `max` if initially undefined', () => { - const { input } = renderSegment({ - segment: 'year', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: '', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -648,11 +851,17 @@ 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']), - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: formatter(defaultMin['year']), + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -663,12 +872,18 @@ 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, - }); + const { input } = renderSegment( + { + segment: 'year', + // onChange: onChangeHandler, + value: '', + max: 2000, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -684,9 +899,15 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('on a single SPACE', () => { describe('does not call the onChangeHandler ', () => { test('when the input is initially empty', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + // onChange: onChangeHandler, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{space}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -694,10 +915,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { }); describe('calls the onChangeHandler', () => { test('when the input has a value', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - value: '12', - }); + const { input } = renderSegment( + { + // onChange: onChangeHandler, + value: '12', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{space}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -712,9 +939,15 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('on a double SPACE', () => { describe('does not call the onChangeHandler ', () => { test('when the input is initially empty', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + // onChange: onChangeHandler, + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{space}{space}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -723,10 +956,16 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('calls the onChangeHandler', () => { test('when the input has a value', () => { - const { input } = renderSegment({ - onChange: onChangeHandler, - value: '12', - }); + const { input } = renderSegment( + { + // onChange: onChangeHandler, + value: '12', + }, + undefined, + { + onChange: onChangeHandler, + }, + ); userEvent.type(input, '{space}{space}'); expect(onChangeHandler).toHaveBeenCalledWith( 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 b219a989d0..44370ac990 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -32,12 +32,11 @@ export const DateInputSegment = React.forwardRef< ( { segment, - value, - min: minProp, - max: maxProp, - onChange, - onBlur, - onKeyDown, + value, // TODO: will be read from date input boxcontext + min: minProp, // TODO: will be generated from context + max: maxProp, // TODO: will be generated from context + // onChange, // TODO: will be read from context + // onBlur, // TODO: will be read from context ...rest }: DateInputSegmentProps, fwdRef, @@ -45,12 +44,23 @@ export const DateInputSegment = React.forwardRef< const min = minProp ?? defaultMin[segment]; const max = maxProp ?? defaultMax[segment]; + // min = getMinSegmentValue(segment, { date: value, min }); + // max = getMaxSegmentValue(segment, { date: value, max }); + const { size, disabled, autoComplete: autoCompleteProp, + // min, + // max, } = useSharedDatePickerContext(); + // TODO: read the value, segmentsRef, labelledby, segments from context + // const { value, segmentsRef, labelledby, segments } = useContext(); + + // const min = getMinSegmentValue(segment, { date: value, min }); + // const max = getMaxSegmentValue(segment, { date: value, max }); + const autoComplete = getAutoComplete(autoCompleteProp, segment); const shouldNotRollover = ( @@ -65,22 +75,21 @@ export const DateInputSegment = React.forwardRef< { ), @@ -373,12 +373,12 @@ describe('packages/input-box', () => { key={partType} segment={partType} value={segmentsMock[partType]} - onChange={onChange} + // onChange={onChange} onBlur={onBlur} - charsPerSegment={charsPerSegmentMock[partType]} + // charsPerSegment={charsPerSegmentMock[partType]} min={defaultMinMock[partType]} max={defaultMaxMock[partType]} - segmentEnum={SegmentObjMock} + // segmentEnum={SegmentObjMock} size={Size.Default} /> )} diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 464f09ee7e..f7cb2d80ab 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -25,20 +25,21 @@ import { } from './InputBox.styles'; import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; +import { InputBoxProvider } from '../InputBoxContext'; + /** * Generic controlled input box component * Renders an input box with appropriate segment order & separator characters. * * @internal */ -export const InputBoxWithRef = >( +export const InputBoxWithRef = ( { className, labelledBy, segmentRefs, onSegmentChange, onKeyDown, - segments, setSegment, disabled, charsPerSegment, @@ -73,7 +74,7 @@ export const InputBoxWithRef = >( /** Fired when an individual segment value changes */ const handleSegmentInputChange: InputSegmentChangeEventHandler< - T[keyof T], + T, string > = segmentChangeEvent => { let segmentValue = segmentChangeEvent.value; @@ -202,34 +203,45 @@ export const InputBoxWithRef = >( }; return ( - // We want to allow keydown events to be captured by the parent so that the parent can handle the event. - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
- {formatParts?.map((part, i) => { - if (part.type === 'literal') { - return ( - - {part.value} - - ); - } else if (isInputSegment(part.type, segmentEnum)) { - const segmentProps = { - onChange: handleSegmentInputChange, - onBlur: handleSegmentInputBlur, - partType: part.type, - }; - return renderSegment(segmentProps); - } - })} -
+ {/* // */} + {/* // We want to allow keydown events to be captured by the parent so that the parent can handle the event. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {formatParts?.map((part, i) => { + if (part.type === 'literal') { + return ( + + {part.value} + + ); + } else if (isInputSegment(part.type, segmentEnum)) { + const segmentProps = { + onChange: handleSegmentInputChange, + onBlur: handleSegmentInputBlur, + partType: part.type, + }; + return renderSegment(segmentProps); + + // TODO: return ; + } + })} +
+ {/* //
*/} + ); }; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index dc61d7b9d0..df74b8993c 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -21,12 +21,127 @@ export type InputChangeEventHandler = ( changeEvent: InputChangeEvent, ) => void; -export interface InputBoxProps> +// export interface InputBoxProps> +// extends Omit, 'onChange' | 'children'> { +// /** +// * Callback fired when any segment changes, but not necessarily a full value +// */ +// onSegmentChange?: InputSegmentChangeEventHandler; + +// /** +// * id of the labelling element +// */ +// labelledBy?: string; + +// /** +// * An object that maps the segment names to their refs +// * +// * @example +// * { day: ref, month: ref, year: ref } +// */ +// segmentRefs: Record< +// T[keyof T], +// ReturnType> +// >; + +// /** +// * An enumerable object that maps the segment names to their values +// * +// * @example +// * { Day: 'day', Month: 'month', Year: 'year' } +// */ +// segmentEnum: T; + +// /** +// * An object containing the values of the segments +// * +// * @example +// * { day: '1', month: '2', year: '2025' } +// */ +// segments: Record; + +// /** +// * A function that sets the value of a segment +// * +// * @example +// * (segment: 'day', value: '1') => void; +// */ +// setSegment: (segment: T[keyof T], value: string) => void; + +// /** +// * The format parts of the date +// * +// * @example +// * [ +// * { type: 'month', value: '02' }, +// * { type: 'literal', value: '-' }, +// * { type: 'day', value: '02' }, +// * { type: 'literal', value: '-' }, +// * { type: 'year', value: '2025' }, +// * ] +// */ +// formatParts?: Array; + +// /** +// * The number of characters per segment +// * +// * @example +// * { day: 2, month: 2, year: 4 } +// */ +// charsPerSegment: Record; + +// /** +// * Whether the input box is disabled +// * +// * @default false +// */ +// disabled?: boolean; + +// /** +// * An object that maps the segment names to their rules. +// * +// * maxChars: the maximum number of characters for the segment +// * minExplicitValue: the minimum explicit value for the segment +// * +// * @example +// * { +// * day: { maxChars: 2, minExplicitValue: 1 }, +// * month: { maxChars: 2, minExplicitValue: 4 }, +// * year: { maxChars: 4, minExplicitValue: 1970 }, +// * } +// * +// * Explicit: Day = 5, 02 +// * Ambiguous: Day = 2 (could be 20-29) +// * +// */ +// segmentRules: Record; +// /** +// * An object that maps the segment names to their minimum values +// * +// * @example +// * { day: 0, month: 1, year: 1970 } +// */ +// minValues: Record; + +// /** +// * A function that renders a segment +// * +// * @example +// * (props: { +// * onChange: (event: React.ChangeEvent) => void, +// * onBlur: (event: React.FocusEvent) => void, +// * partType: 'day' | 'month' | 'year', +// * }) => React.ReactElement; +// */ +// renderSegment: (props: RenderSegmentProps) => React.ReactElement; +// } + +export interface InputBoxProps extends Omit, 'onChange' | 'children'> { /** * Callback fired when any segment changes, but not necessarily a full value */ - onSegmentChange?: InputSegmentChangeEventHandler; + onSegmentChange?: InputSegmentChangeEventHandler; /** * id of the labelling element @@ -39,10 +154,7 @@ export interface InputBoxProps> * @example * { day: ref, month: ref, year: ref } */ - segmentRefs: Record< - T[keyof T], - ReturnType> - >; + segmentRefs: Record>>; /** * An enumerable object that maps the segment names to their values @@ -50,7 +162,7 @@ export interface InputBoxProps> * @example * { Day: 'day', Month: 'month', Year: 'year' } */ - segmentEnum: T; + segmentEnum: Record; /** * An object containing the values of the segments @@ -58,7 +170,7 @@ export interface InputBoxProps> * @example * { day: '1', month: '2', year: '2025' } */ - segments: Record; + segments: Record; /** * A function that sets the value of a segment @@ -66,7 +178,7 @@ export interface InputBoxProps> * @example * (segment: 'day', value: '1') => void; */ - setSegment: (segment: T[keyof T], value: string) => void; + setSegment: (segment: T, value: string) => void; /** * The format parts of the date @@ -88,7 +200,7 @@ export interface InputBoxProps> * @example * { day: 2, month: 2, year: 4 } */ - charsPerSegment: Record; + charsPerSegment: Record; /** * Whether the input box is disabled @@ -114,14 +226,14 @@ export interface InputBoxProps> * Ambiguous: Day = 2 (could be 20-29) * */ - segmentRules: Record; + segmentRules: Record; /** * An object that maps the segment names to their minimum values * * @example * { day: 0, month: 1, year: 1970 } */ - minValues: Record; + minValues: Record; /** * A function that renders a segment @@ -133,7 +245,7 @@ export interface InputBoxProps> * partType: 'day' | 'month' | 'year', * }) => React.ReactElement; */ - renderSegment: (props: RenderSegmentProps) => React.ReactElement; + renderSegment: (props: RenderSegmentProps) => React.ReactElement; } /** @@ -145,7 +257,7 @@ export interface InputBoxProps> * @see https://stackoverflow.com/a/58473012 */ export interface InputBoxComponentType { - >( + ( props: InputBoxProps, ref: ForwardedRef, ): ReactElement | null; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx new file mode 100644 index 0000000000..607192a116 --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -0,0 +1,139 @@ +// // TODO: since we're no longer passing the enum object to inputSegment, t should extend a string not an object + +// import React, { createContext, useContext, useMemo } from 'react'; +// import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; + +// export interface InputBoxContextType { +// charsPerSegment: Record; +// segmentEnum: Record; +// onChange: InputSegmentChangeEventHandler; +// onBlur: (event: React.FocusEvent) => void; +// } + +// export interface InputBoxProviderProps> { +// children: React.ReactNode; +// charsPerSegment: Record; +// segmentEnum: T; +// onChange: InputSegmentChangeEventHandler; +// onBlur: (event: React.FocusEvent) => void; +// } + +// // The Context itself MUST be defined with a fixed type. +// // We use the most generic version of InputBoxContextType that the provider handles. +// export const InputBoxContext = createContext(null); + +// // The Provider takes the generic T and provides the value. +// export const InputBoxProvider = >({ +// children, +// charsPerSegment, +// segmentEnum, +// onChange, +// onBlur, +// }: InputBoxProviderProps) => { +// const value = useMemo( +// () => ({ +// charsPerSegment, +// segmentEnum, +// onChange, +// onBlur, +// }), +// [charsPerSegment, segmentEnum, onChange, onBlur], +// ); + +// // The 'value' here has the correct specific type T +// return ( +// +// {children} +// +// ); +// }; + +// // This is where we force the type T back. +// // We assert the type *at the point of consumption*. +// // You must provide a type argument when using the hook (e.g., useInputBoxContext()) +// export const useInputBoxContext = () => { +// // Assert the type of the context to be the specific generic type T +// const context = useContext(InputBoxContext) as InputBoxContextType | null; + +// if (!context) { +// throw new Error( +// 'useInputBoxContext must be used within an InputBoxProvider', +// ); +// } +// return context; +// }; + +import React, { createContext, useContext, useMemo } from 'react'; +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; + +// --- Type Helpers --- + +// Helper type to represent the constrained Enum Object structure +type SegmentEnumObject = Record; + +// --- Context Definition --- + +// 1. T is the string union of segment names (e.g., 'areaCode' | 'prefix') +export interface InputBoxContextType { + charsPerSegment: Record; // Keyed by T + segmentEnum: SegmentEnumObject; // Values are T + onChange: InputSegmentChangeEventHandler; + onBlur: (event: React.FocusEvent) => void; +} + +// --- Provider Props --- + +// 2. Props are generic over T and use SegmentEnumObject for segmentEnum +export interface InputBoxProviderProps { + children: React.ReactNode; + charsPerSegment: Record; + segmentEnum: SegmentEnumObject; + onChange: InputSegmentChangeEventHandler; + onBlur: (event: React.FocusEvent) => void; +} + +// 3. The Context constant is defined with the default/fixed type +export const InputBoxContext = createContext(null); + +// --- Provider Component --- + +// 4. Provider is generic over T, the string union +export const InputBoxProvider = ({ + children, + charsPerSegment, + segmentEnum, + onChange, + onBlur, +}: InputBoxProviderProps) => { + const value = useMemo( + () => ({ + charsPerSegment, + segmentEnum, + onChange, + onBlur, + }), + [charsPerSegment, segmentEnum, onChange, onBlur], + ); + + // Single assertion to the fixed context type + return ( + + {children} + + ); +}; + +// --- Hook Component --- + +// 5. The hook is generic over T, the string union +export const useInputBoxContext = () => { + // Assert the context type to the specific generic T + const context = useContext(InputBoxContext) as InputBoxContextType | null; + + if (!context) { + throw new Error( + 'useInputBoxContext must be used within an InputBoxProvider', + ); + } + return context; +}; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/input-box/src/InputBoxContext/index.ts b/packages/input-box/src/InputBoxContext/index.ts new file mode 100644 index 0000000000..5adefa71fd --- /dev/null +++ b/packages/input-box/src/InputBoxContext/index.ts @@ -0,0 +1,5 @@ +export { + InputBoxContext, + InputBoxProvider, + useInputBoxContext, +} from './InputBoxContext'; diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 2aca0dd10f..175c1712c2 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -104,9 +104,7 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - onChange: onChangeHandler, - }); + const { input } = renderSegment({}, { onChange: onChangeHandler }); userEvent.type(input, '8'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -119,9 +117,7 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - onChange: onChangeHandler, - }); + const { input } = renderSegment({}, { onChange: onChangeHandler }); userEvent.type(input, '0'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -134,9 +130,7 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - onChange: onChangeHandler, - }); + const { input } = renderSegment({}, { onChange: onChangeHandler }); userEvent.type(input, 'aB$/'); expect(onChangeHandler).not.toHaveBeenCalled(); }); @@ -148,10 +142,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - value: '2', - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + value: '2', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '6'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -164,10 +160,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - value: '26', - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + value: '26', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '4'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -189,11 +187,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(15), + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -208,12 +208,14 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), - step: 2, - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(15), + step: 2, + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -228,11 +230,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'day', + value: '', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -247,11 +251,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMaxMock['day']), - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(defaultMaxMock['day']), + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -266,12 +272,14 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMaxMock['day']), - shouldNotRollover: true, - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(defaultMaxMock['day']), + shouldNotRollover: true, + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -288,11 +296,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(15), + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -307,12 +317,14 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(15), - step: 2, - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(15), + step: 2, + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -327,11 +339,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '', - }); + const { input } = renderSegment( + { + segment: 'day', + value: '', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -346,11 +360,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMinMock['day']), - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(defaultMinMock['day']), + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -365,12 +381,14 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: formatter(defaultMinMock['day']), - shouldNotRollover: true, - }); + const { input } = renderSegment( + { + segment: 'day', + value: formatter(defaultMinMock['day']), + shouldNotRollover: true, + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -387,11 +405,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '12', - }); + const { input } = renderSegment( + { + segment: 'day', + value: '12', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{backspace}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -404,10 +424,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + segment: 'day', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{backspace}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -423,10 +445,12 @@ describe('packages/input-segment', () => { string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + segment: 'day', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{space}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -438,11 +462,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '12', - }); + const { input } = renderSegment( + { + segment: 'day', + value: '12', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{space}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -458,10 +484,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - }); + const { input } = renderSegment( + { + segment: 'day', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{space}{space}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -473,11 +501,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({ - segment: 'day', - onChange: onChangeHandler, - value: '12', - }); + const { input } = renderSegment( + { + segment: 'day', + value: '12', + }, + { onChange: onChangeHandler }, + ); userEvent.type(input, '{space}{space}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -500,12 +530,12 @@ describe('packages/input-segment', () => { test('With required props', () => { {}} + // onChange={() => {}} value="12" - charsPerSegment={2} + // charsPerSegment={2} min={1} max={31} - segmentEnum={SegmentObjMock} + // segmentEnum={SegmentObjMock} size={Size.Default} />; }); @@ -513,12 +543,12 @@ describe('packages/input-segment', () => { test('With all props', () => { {}} + // onChange={() => {}} value="12" - charsPerSegment={2} + // charsPerSegment={2} min={1} max={31} - segmentEnum={SegmentObjMock} + // segmentEnum={SegmentObjMock} size={Size.Default} step={1} shouldNotRollover={false} diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 988e26d046..1bdbe66239 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -20,6 +20,8 @@ import { InputSegmentProps, } from './InputSegment.types'; +import { useInputBoxContext } from '../InputBoxContext'; + /** * Generic controlled input segment component * @@ -28,19 +30,19 @@ import { * * @internal */ -const InputSegmentWithRef = , V extends string>( +const InputSegmentWithRef = ( { segment, value, - onChange, - onBlur, + // onChange, // TODO: will be read from context + // onBlur, // TODO: will be read from context onKeyDown, size, - charsPerSegment, - min, - max, + // charsPerSegment, // TODO: will be read from context + min, // minSegmentValue + max, // maxSegmentValue className, - segmentEnum, + // segmentEnum, // TODO: will be read from context step = 1, shouldNotRollover = false, shouldSkipValidation = false, @@ -49,10 +51,20 @@ const InputSegmentWithRef = , V extends string>( fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); + const { + onChange, + onBlur, + charsPerSegment: charsPerSegmentContext, + segmentEnum, + } = useInputBoxContext(); // TODO: since we're no longer passing the enum object to inputSegment, t should extend a string not an object const baseFontSize = useUpdatedBaseFontSize(); + const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter(charsPerSegment, min === 0); const pattern = `[0-9]{${charsPerSegment}}`; + // TODO: read onChange, onBlur from context + // const { onChange, onBlur, charsPerSegment, segmentEnum } = useInputBoxContext(Context); + /** * Receives native input events, * determines whether the input value is valid and should change, diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 9cb70f76f7..d959f2e208 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -20,10 +20,8 @@ export type InputSegmentChangeEventHandler< V extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; -export interface InputSegmentProps< - T extends Record, - V extends string, -> extends Omit< +export interface InputSegmentProps + extends Omit< React.ComponentPropsWithRef<'input'>, 'onChange' | 'size' | 'step' > { @@ -35,7 +33,7 @@ export interface InputSegmentProps< * 'month' * 'year' */ - segment: T[keyof T]; + segment: T; /** * The value of the segment @@ -50,7 +48,7 @@ export interface InputSegmentProps< /** * Custom onChange handler */ - onChange: InputSegmentChangeEventHandler; + // onChange: InputSegmentChangeEventHandler; /** * The number of characters per segment @@ -58,7 +56,7 @@ export interface InputSegmentProps< * @example * 4 */ - charsPerSegment: number; + // charsPerSegment: number; /** * Minimum value. @@ -86,7 +84,7 @@ export interface InputSegmentProps< * @example * { Day: 'day', Month: 'month', Year: 'year' } */ - segmentEnum: T; + // segmentEnum: T; /** * Size of the segment @@ -129,7 +127,7 @@ export interface InputSegmentProps< * @see https://stackoverflow.com/a/58473012 */ export interface InputSegmentComponentType { - , V extends string>( + ( props: InputSegmentProps, ref: ForwardedRef, ): ReactElement | null; diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index 34d65de6af..1ea5247328 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -15,3 +15,8 @@ export { isValidSegmentName, isValidSegmentValue, } from './utils/isValidSegment/isValidSegment'; +export { + useInputBoxContext, + InputBoxProvider, + type InputBoxProviderProps, +} from './InputBoxContext/InputBoxContext'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 88f132463d..a32bc7e293 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,3 +1,4 @@ +// TODO: fix this import { createRef } from 'react'; import React from 'react'; import { render, RenderResult } from '@testing-library/react'; @@ -14,6 +15,8 @@ import { InputSegmentProps, } from '../InputSegment/InputSegment.types'; import { ExplicitSegmentRule } from '../utils'; +import { InputBoxProvider } from '../InputBoxContext'; +import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext'; export const SegmentObjMock = { Month: 'month', @@ -94,7 +97,7 @@ export const segmentWidthStyles: Record = { `, }; -export const defaultProps: Partial> = { +export const defaultProps: Partial> = { segments: segmentsMock, segmentEnum: SegmentObjMock, segmentRefs: segmentRefsMock, @@ -156,12 +159,12 @@ export const InputBoxWithState = ({ disabled={disabled} segment={partType} value={segments[partType]} - onChange={onChange} + // onChange={onChange} onBlur={onBlur} - charsPerSegment={charsPerSegmentMock[partType]} + // charsPerSegment={charsPerSegmentMock[partType]} min={defaultMinMock[partType]} max={defaultMaxMock[partType]} - segmentEnum={SegmentObjMock} + // segmentEnum={SegmentObjMock} size={Size.Default} data-testid={`input-segment-${partType}`} className={segmentWidthStyles[partType]} @@ -195,28 +198,33 @@ export const renderInputBoxWithState = ({ return { ...utils, dayInput, monthInput, yearInput }; }; -const createRenderSegment = ( - mergedProps: InputBoxProps, -) => { +const createRenderSegment = (mergedProps: InputBoxProps) => { const RenderSegment = ({ onChange, onBlur, partType, }: RenderSegmentProps) => ( - + > + + ); return RenderSegment; @@ -226,41 +234,39 @@ interface RenderInputBoxReturnType { dayInput: HTMLInputElement; monthInput: HTMLInputElement; yearInput: HTMLInputElement; - rerenderInputBox: ( - props: Partial>, - ) => void; + rerenderInputBox: (props: Partial>) => void; } export const renderInputBox = ({ ...props -}: Partial>): RenderResult & +}: Partial>): RenderResult & RenderInputBoxReturnType => { const mergedProps = { ...defaultProps, ...props, - } as InputBoxProps; + } as InputBoxProps; const finalMergedProps = { ...mergedProps, renderSegment: mergedProps.renderSegment ?? createRenderSegment(mergedProps), - } as InputBoxProps; + } as InputBoxProps; const result = render(); const rerenderInputBox = ({ ...props - }: Partial>) => { + }: Partial>) => { const mergedProps = { ...defaultProps, ...props, - } as InputBoxProps; + } as InputBoxProps; const finalMergedProps = { ...mergedProps, renderSegment: mergedProps.renderSegment ?? createRenderSegment(mergedProps), - } as InputBoxProps; + } as InputBoxProps; result.rerender(); }; @@ -291,21 +297,29 @@ interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; rerenderSegment: ( - newProps: Partial>, + newProps: Partial>, ) => void; } export const renderSegment = ( - props?: Partial>, + props?: Partial>, + providerProps?: Partial>, ): RenderResult & RenderSegmentReturnType => { - const defaultProps: InputSegmentProps = { - value: '', + const defaultProviderProps: Partial> = { + charsPerSegment: charsPerSegmentMock, + segmentEnum: SegmentObjMock, onChange: () => {}, + onBlur: () => {}, + }; + + const defaultProps: InputSegmentProps = { + value: '', + // onChange: () => {}, segment: 'day', - charsPerSegment: charsPerSegmentMock['day'], + // charsPerSegment: charsPerSegmentMock['day'], min: defaultMinMock['day'], max: defaultMaxMock['day'], - segmentEnum: SegmentObjMock, + // segmentEnum: SegmentObjMock, size: Size.Default, shouldNotRollover: false, placeholder: defaultPlaceholderMock['day'], @@ -318,12 +332,26 @@ export const renderSegment = ( ...props, }; - const utils = render(); + const mergedProviderProps = { + ...defaultProviderProps, + ...providerProps, + } as InputBoxProviderProps; + + const utils = render( + + + , + ); const rerenderSegment = ( - newProps: Partial>, + newProps: Partial>, + newProviderProps?: Partial>, ) => { - utils.rerender(); + utils.rerender( + + + , + ); }; const getInput = () => From cae12d5be1191f23b84906076bf25574adec5db0 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sat, 1 Nov 2025 22:22:55 -0400 Subject: [PATCH 35/56] refactor(input-box): update InputBox and InputSegment components to use context for segment rendering and streamline prop handling --- .../input-box/src/InputBox/InputBox.spec.tsx | 121 ++------ packages/input-box/src/InputBox/InputBox.tsx | 17 +- .../input-box/src/InputBox/InputBox.types.ts | 10 +- .../InputBoxContext/InputBoxContext.spec.ts | 0 .../InputBoxContext/InputBoxContext.spec.tsx | 56 ++++ .../src/InputBoxContext/InputBoxContext.tsx | 86 +----- .../src/InputSegment/InputSegment.spec.tsx | 12 +- .../src/InputSegment/InputSegment.tsx | 21 +- .../src/InputSegment/InputSegment.types.ts | 26 +- packages/input-box/src/testutils/index.tsx | 285 ++++++++---------- .../src/testutils/testutils.mocks.ts | 83 +++++ 11 files changed, 311 insertions(+), 406 deletions(-) delete mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.spec.ts create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx create mode 100644 packages/input-box/src/testutils/testutils.mocks.ts diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index e5844e86bc..41307be375 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -7,18 +7,20 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputSegment } from '../InputSegment'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; import { - charsPerSegmentMock, - defaultMaxMock, - defaultMinMock, + InputSegmentWrapper, renderInputBox, renderInputBoxWithState, - SegmentObjMock, - segmentRefsMock, - segmentRulesMock, - segmentsMock, } from '../testutils'; import { InputBox } from './InputBox'; +import { + SegmentObjMock, + segmentsMock, + charsPerSegmentMock, + segmentRulesMock, + defaultMinMock, + segmentRefsMock, +} from '../testutils/testutils.mocks'; describe('packages/input-box', () => { describe('Rendering', () => { @@ -53,16 +55,16 @@ describe('packages/input-box', () => { describe('rerendering', () => { test('with new value updates the segments', () => { - const { rerenderInputBox, dayInput, monthInput, yearInput } = + const { rerenderInputBox, getDayInput, getMonthInput, getYearInput } = renderInputBox({}); - expect(dayInput.value).toBe('02'); - expect(monthInput.value).toBe('02'); - expect(yearInput.value).toBe('2025'); + expect(getDayInput().value).toBe('02'); + expect(getMonthInput().value).toBe('02'); + expect(getYearInput().value).toBe('2025'); rerenderInputBox({ segments: { day: '26', month: '09', year: '1993' } }); - expect(dayInput.value).toBe('26'); - expect(monthInput.value).toBe('09'); - expect(yearInput.value).toBe('1993'); + expect(getDayInput().value).toBe('26'); + expect(getMonthInput().value).toBe('09'); + expect(getYearInput().value).toBe('1993'); }); }); @@ -119,87 +121,13 @@ describe('packages/input-box', () => { }); }); - describe('renderSegment', () => { - test('calls renderSegment for each segment with correct props', () => { - const mockRenderSegment = jest.fn( - ({ - partType, - onChange, - onBlur, - }: { - partType: SegmentObjMock; - onChange: any; - onBlur: any; - }) => ( - // @ts-expect-error - we are not passing all the props to the InputSegment component - - ), - ); - renderInputBox({ - renderSegment: mockRenderSegment, - formatParts: [ - { type: 'year', value: '' }, - { type: 'literal', value: '-' }, - { type: 'month', value: '' }, - { type: 'literal', value: '-' }, - { type: 'day', value: '' }, - ], - }); - // Verify renderSegment was called (may be called multiple times in dev mode in R17) - expect(mockRenderSegment).toHaveBeenCalled(); - - // Collect all unique partTypes that were called - const calledPartTypes = mockRenderSegment.mock.calls.map( - call => call[0].partType, - ); - - // Remove duplicate partTypes - const uniqueCalledPartTypes = [...new Set(calledPartTypes)]; - - // Verify all three segment types were rendered - expect(uniqueCalledPartTypes).toHaveLength(3); - expect(uniqueCalledPartTypes).toContain('year'); - expect(uniqueCalledPartTypes).toContain('month'); - expect(uniqueCalledPartTypes).toContain('day'); - - // Verify each segment type was called with correct props - expect(mockRenderSegment).toHaveBeenCalledWith( - expect.objectContaining({ - partType: 'year', - onChange: expect.any(Function), - onBlur: expect.any(Function), - }), - ); - expect(mockRenderSegment).toHaveBeenCalledWith( - expect.objectContaining({ - partType: 'month', - onChange: expect.any(Function), - onBlur: expect.any(Function), - }), - ); - expect(mockRenderSegment).toHaveBeenCalledWith( - expect.objectContaining({ - partType: 'day', - onChange: expect.any(Function), - onBlur: expect.any(Function), - }), - ); - }); - }); - describe('auto-focus', () => { test('focuses the next segment when an explicit value is entered', () => { const { dayInput, monthInput } = renderInputBoxWithState({}); userEvent.type(monthInput, '02'); expect(dayInput).toHaveFocus(); + expect(monthInput.value).toBe('02'); }); test('focus remains in the current segment when an ambiguous value is entered', () => { @@ -368,20 +296,7 @@ describe('packages/input-box', () => { charsPerSegment={charsPerSegmentMock} segmentRules={segmentRulesMock} minValues={defaultMinMock} - renderSegment={({ onChange, onBlur, partType }) => ( - - )} + segment={InputSegmentWrapper} />; }); }); diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index f7cb2d80ab..5e8e68d3ba 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -46,7 +46,8 @@ export const InputBoxWithRef = ( formatParts, segmentEnum, segmentRules, - renderSegment, + // renderSegment, + segment, minValues, ...rest }: InputBoxProps, @@ -209,8 +210,7 @@ export const InputBoxWithRef = ( onBlur={handleSegmentInputBlur} segmentEnum={segmentEnum} > - {/* // */} - {/* // We want to allow keydown events to be captured by the parent so that the parent can handle the event. */} + {/* We want to allow keydown events to be captured by the parent so that the parent can handle the event. */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
( ); } else if (isInputSegment(part.type, segmentEnum)) { - const segmentProps = { - onChange: handleSegmentInputChange, - onBlur: handleSegmentInputBlur, - partType: part.type, - }; - return renderSegment(segmentProps); - - // TODO: return ; + const Segment = segment; + return ; } })}
- {/* //
*/} ); }; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index df74b8993c..de2991011d 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -236,16 +236,12 @@ export interface InputBoxProps minValues: Record; /** - * A function that renders a segment + * A component that renders a segment * * @example - * (props: { - * onChange: (event: React.ChangeEvent) => void, - * onBlur: (event: React.FocusEvent) => void, - * partType: 'day' | 'month' | 'year', - * }) => React.ReactElement; + * segment={DateInputSegment} */ - renderSegment: (props: RenderSegmentProps) => React.ReactElement; + segment: React.ComponentType<{ segment: T }>; } /** diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx new file mode 100644 index 0000000000..ad0b13692e --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; + +import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; +import { + charsPerSegmentMock, + SegmentObjMock, +} from '../testutils/testutils.mocks'; + +describe('InputBoxContext', () => { + const mockOnChange = jest.fn(); + const mockOnBlur = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('throws error when used outside of InputBoxProvider', () => { + /** + * 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(() => useInputBoxContext()); + expect(result.error.message).toEqual( + 'useInputBoxContext must be used within an InputBoxProvider', + ); + } else { + expect(() => + renderHook(() => useInputBoxContext()), + ).toThrow('useInputBoxContext must be used within an InputBoxProvider'); + } + }); + + test('provides context values that match the props passed to the provider', () => { + const { result } = renderHook(() => useInputBoxContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.charsPerSegment).toBe(charsPerSegmentMock); + expect(result.current.segmentEnum).toBe(SegmentObjMock); + expect(result.current.onChange).toBe(mockOnChange); + expect(result.current.onBlur).toBe(mockOnBlur); + }); +}); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 607192a116..9228d43bcd 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -1,79 +1,10 @@ -// // TODO: since we're no longer passing the enum object to inputSegment, t should extend a string not an object - -// import React, { createContext, useContext, useMemo } from 'react'; -// import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; - -// export interface InputBoxContextType { -// charsPerSegment: Record; -// segmentEnum: Record; -// onChange: InputSegmentChangeEventHandler; -// onBlur: (event: React.FocusEvent) => void; -// } - -// export interface InputBoxProviderProps> { -// children: React.ReactNode; -// charsPerSegment: Record; -// segmentEnum: T; -// onChange: InputSegmentChangeEventHandler; -// onBlur: (event: React.FocusEvent) => void; -// } - -// // The Context itself MUST be defined with a fixed type. -// // We use the most generic version of InputBoxContextType that the provider handles. -// export const InputBoxContext = createContext(null); - -// // The Provider takes the generic T and provides the value. -// export const InputBoxProvider = >({ -// children, -// charsPerSegment, -// segmentEnum, -// onChange, -// onBlur, -// }: InputBoxProviderProps) => { -// const value = useMemo( -// () => ({ -// charsPerSegment, -// segmentEnum, -// onChange, -// onBlur, -// }), -// [charsPerSegment, segmentEnum, onChange, onBlur], -// ); - -// // The 'value' here has the correct specific type T -// return ( -// -// {children} -// -// ); -// }; - -// // This is where we force the type T back. -// // We assert the type *at the point of consumption*. -// // You must provide a type argument when using the hook (e.g., useInputBoxContext()) -// export const useInputBoxContext = () => { -// // Assert the type of the context to be the specific generic type T -// const context = useContext(InputBoxContext) as InputBoxContextType | null; - -// if (!context) { -// throw new Error( -// 'useInputBoxContext must be used within an InputBoxProvider', -// ); -// } -// return context; -// }; - import React, { createContext, useContext, useMemo } from 'react'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; -// --- Type Helpers --- - // Helper type to represent the constrained Enum Object structure type SegmentEnumObject = Record; -// --- Context Definition --- - -// 1. T is the string union of segment names (e.g., 'areaCode' | 'prefix') +// T is the string union of segment names (e.g., 'areaCode' | 'prefix') export interface InputBoxContextType { charsPerSegment: Record; // Keyed by T segmentEnum: SegmentEnumObject; // Values are T @@ -81,9 +12,7 @@ export interface InputBoxContextType { onBlur: (event: React.FocusEvent) => void; } -// --- Provider Props --- - -// 2. Props are generic over T and use SegmentEnumObject for segmentEnum +// Props are generic over T and use SegmentEnumObject for segmentEnum export interface InputBoxProviderProps { children: React.ReactNode; charsPerSegment: Record; @@ -92,12 +21,10 @@ export interface InputBoxProviderProps { onBlur: (event: React.FocusEvent) => void; } -// 3. The Context constant is defined with the default/fixed type +// The Context constant is defined with the default/fixed type export const InputBoxContext = createContext(null); -// --- Provider Component --- - -// 4. Provider is generic over T, the string union +// Provider is generic over T, the string union export const InputBoxProvider = ({ children, charsPerSegment, @@ -116,6 +43,7 @@ export const InputBoxProvider = ({ ); // Single assertion to the fixed context type + // TODO: why is this necessary? return ( {children} @@ -123,9 +51,7 @@ export const InputBoxProvider = ({ ); }; -// --- Hook Component --- - -// 5. The hook is generic over T, the string union +// The hook is generic over T, the string union export const useInputBoxContext = () => { // Assert the context type to the specific generic T const context = useContext(InputBoxContext) as InputBoxContextType | null; diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 175c1712c2..1b59ebbb99 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -3,15 +3,15 @@ import userEvent from '@testing-library/user-event'; import { Size } from '@leafygreen-ui/tokens'; +import { renderSegment, setSegmentProps } from '../testutils'; +import { getValueFormatter } from '../utils'; + import { + SegmentObjMock, charsPerSegmentMock, defaultMaxMock, defaultMinMock, - renderSegment, - SegmentObjMock, - setSegmentProps, -} from '../testutils'; -import { getValueFormatter } from '../utils'; +} from '../testutils/testutils.mocks'; import { InputSegment, InputSegmentChangeEventHandler } from '.'; @@ -172,6 +172,8 @@ describe('packages/input-segment', () => { expect.objectContaining({ value: '4' }), ); }); + + // TODO: test min/max }); describe('keyboard events', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 1bdbe66239..e90ab1bcab 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -2,6 +2,7 @@ import React, { ChangeEventHandler, ForwardedRef, KeyboardEventHandler, + FocusEvent, } from 'react'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; @@ -34,15 +35,13 @@ const InputSegmentWithRef = ( { segment, value, - // onChange, // TODO: will be read from context - // onBlur, // TODO: will be read from context onKeyDown, size, - // charsPerSegment, // TODO: will be read from context min, // minSegmentValue max, // maxSegmentValue className, - // segmentEnum, // TODO: will be read from context + onChange: onChangeProp, + onBlur: onBlurProp, step = 1, shouldNotRollover = false, shouldSkipValidation = false, @@ -56,15 +55,12 @@ const InputSegmentWithRef = ( onBlur, charsPerSegment: charsPerSegmentContext, segmentEnum, - } = useInputBoxContext(); // TODO: since we're no longer passing the enum object to inputSegment, t should extend a string not an object + } = useInputBoxContext(); const baseFontSize = useUpdatedBaseFontSize(); const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter(charsPerSegment, min === 0); const pattern = `[0-9]{${charsPerSegment}}`; - // TODO: read onChange, onBlur from context - // const { onChange, onBlur, charsPerSegment, segmentEnum } = useInputBoxContext(Context); - /** * Receives native input events, * determines whether the input value is valid and should change, @@ -95,6 +91,8 @@ const InputSegmentWithRef = ( // If the value has not changed, ensure the input value is reset target.value = value; } + + onChangeProp?.(e); }; /** Handle keydown presses that don't natively fire a change event */ @@ -182,6 +180,11 @@ const InputSegmentWithRef = ( onKeyDown?.(e); }; + const handleBlur = (e: FocusEvent) => { + onBlur?.(e); + onBlurProp?.(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 @@ -198,7 +201,7 @@ const InputSegmentWithRef = ( min={min} max={max} onChange={handleChange} - onBlur={onBlur} + onBlur={handleBlur} onKeyDown={handleKeyDown} data-segment={String(segment)} className={getInputSegmentStyles({ diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index d959f2e208..c347e36164 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -21,10 +21,7 @@ export type InputSegmentChangeEventHandler< > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; export interface InputSegmentProps - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'onChange' | 'size' | 'step' - > { + extends Omit, 'size' | 'step'> { /** * Which segment this input represents * @@ -45,19 +42,6 @@ export interface InputSegmentProps */ value: V; - /** - * Custom onChange handler - */ - // onChange: InputSegmentChangeEventHandler; - - /** - * The number of characters per segment - * - * @example - * 4 - */ - // charsPerSegment: number; - /** * Minimum value. * @@ -78,14 +62,6 @@ export interface InputSegmentProps */ max: number; - /** - * An enumerable object that maps the segment names to their values - * - * @example - * { Day: 'day', Month: 'month', Year: 'year' } - */ - // segmentEnum: T; - /** * Size of the segment * diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index a32bc7e293..305bc7fd99 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,101 +1,30 @@ // TODO: fix this -import { createRef } from 'react'; +import { createContext, useContext } from 'react'; import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { css } from '@leafygreen-ui/emotion'; -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { Size } from '@leafygreen-ui/tokens'; import { InputBox, InputBoxProps } from '../InputBox'; -import { RenderSegmentProps } from '../InputBox/InputBox.types'; import { InputSegment } from '../InputSegment'; import { InputSegmentChangeEventHandler, InputSegmentProps, } from '../InputSegment/InputSegment.types'; -import { ExplicitSegmentRule } from '../utils'; import { InputBoxProvider } from '../InputBoxContext'; import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext'; - -export const SegmentObjMock = { - Month: 'month', - Day: 'day', - Year: 'year', -} as const; -export type SegmentObjMock = - (typeof SegmentObjMock)[keyof typeof SegmentObjMock]; - -export type SegmentRefsMock = Record< +import { SegmentObjMock, - ReturnType> ->; - -export const segmentRefsMock: SegmentRefsMock = { - month: createRef(), - day: createRef(), - year: createRef(), -}; - -export const segmentsMock: Record = { - month: '02', - day: '02', - year: '2025', -}; -export const charsPerSegmentMock: Record = { - month: 2, - day: 2, - year: 4, -}; -export const segmentRulesMock: Record = { - month: { maxChars: 2, minExplicitValue: 2 }, - day: { maxChars: 2, minExplicitValue: 4 }, - year: { maxChars: 4, minExplicitValue: 1970 }, -}; -export const defaultMinMock: Record = { - month: 1, - day: 0, - year: 1970, -}; -export const defaultMaxMock: Record = { - month: 12, - day: 31, - year: 2038, -}; - -export const defaultPlaceholderMock: Record = { - day: 'DD', - month: 'MM', - year: 'YYYY', -} as const; - -export const defaultFormatPartsMock: Array = [ - { type: 'month', value: '' }, - { type: 'literal', value: '-' }, - { type: 'day', value: '' }, - { type: 'literal', value: '-' }, - { type: 'year', value: '' }, -]; - -/** The percentage of 1ch these specific characters take up */ -export const characterWidth = { - // // Standard font - D: 46 / 40, - M: 55 / 40, - Y: 50 / 40, -} as const; - -export const segmentWidthStyles: Record = { - day: css` - width: ${charsPerSegmentMock.day * characterWidth.D}ch; - `, - month: css` - width: ${charsPerSegmentMock.month * characterWidth.M}ch; - `, - year: css` - width: ${charsPerSegmentMock.year * characterWidth.Y}ch; - `, -}; + SegmentRefsMock, + defaultMinMock, + defaultMaxMock, + charsPerSegmentMock, + defaultFormatPartsMock, + segmentRulesMock, + defaultPlaceholderMock, + segmentsMock, + segmentRefsMock, +} from './testutils.mocks'; export const defaultProps: Partial> = { segments: segmentsMock, @@ -107,6 +36,59 @@ export const defaultProps: Partial> = { segmentRules: segmentRulesMock, }; +/* + * InputBoxWrapper Context and Provider + */ +const InputBoxWrapperContext = createContext<{ + segments: Record; + segmentRefs: SegmentRefsMock; +} | null>(null); + +const InputBoxWrapperProvider = ({ + children, + segments, + segmentRefs, +}: { + children: React.ReactNode; + segments: Record; + segmentRefs: SegmentRefsMock; +}) => { + return ( + + {children} + + ); +}; + +const useInputBoxWrapperContext = () => { + const context = useContext(InputBoxWrapperContext); + if (!context) { + throw new Error( + 'useInputBoxWrapperContext must be used within InputBoxWrapperProvider', + ); + } + return context; +}; + +export const InputSegmentWrapper = ({ + segment, +}: { + segment: SegmentObjMock; +}) => { + const { segments, segmentRefs } = useInputBoxWrapperContext(); + return ( + + ); +}; + /** * This component is used to render the InputBox component for testing purposes. * Includes segment state management and a default renderSegment function. @@ -141,38 +123,21 @@ export const InputBoxWithState = ({ }; return ( - ( - - )} - /> + + + ); }; @@ -198,43 +163,14 @@ export const renderInputBoxWithState = ({ return { ...utils, dayInput, monthInput, yearInput }; }; -const createRenderSegment = (mergedProps: InputBoxProps) => { - const RenderSegment = ({ - onChange, - onBlur, - partType, - }: RenderSegmentProps) => ( - - - - ); - - return RenderSegment; -}; - interface RenderInputBoxReturnType { dayInput: HTMLInputElement; monthInput: HTMLInputElement; yearInput: HTMLInputElement; rerenderInputBox: (props: Partial>) => void; + getDayInput: () => HTMLInputElement; + getMonthInput: () => HTMLInputElement; + getYearInput: () => HTMLInputElement; } export const renderInputBox = ({ @@ -248,11 +184,17 @@ export const renderInputBox = ({ const finalMergedProps = { ...mergedProps, - renderSegment: - mergedProps.renderSegment ?? createRenderSegment(mergedProps), + segment: mergedProps.segment ?? InputSegmentWrapper, } as InputBoxProps; - const result = render(); + const result = render( + + + , + ); const rerenderInputBox = ({ ...props @@ -264,25 +206,41 @@ export const renderInputBox = ({ const finalMergedProps = { ...mergedProps, - renderSegment: - mergedProps.renderSegment ?? createRenderSegment(mergedProps), + segment: mergedProps.segment ?? InputSegmentWrapper, } as InputBoxProps; - result.rerender(); + result.rerender( + + + , + ); }; - const dayInput = result.getByTestId('input-segment-day') as HTMLInputElement; - const monthInput = result.getByTestId( - 'input-segment-month', - ) as HTMLInputElement; - const yearInput = result.getByTestId( - 'input-segment-year', - ) as HTMLInputElement; + const getDayInput = () => + result.getByTestId('input-segment-day') as HTMLInputElement; + const getMonthInput = () => + result.getByTestId('input-segment-month') as HTMLInputElement; + const getYearInput = () => + result.getByTestId('input-segment-year') as HTMLInputElement; - return { ...result, rerenderInputBox, dayInput, monthInput, yearInput }; + return { + ...result, + rerenderInputBox, + dayInput: getDayInput(), + monthInput: getMonthInput(), + yearInput: getYearInput(), + getDayInput, + getMonthInput, + getYearInput, + }; }; -// InputSegment Utils +/* + * InputSegment Utils + */ export const setSegmentProps = (segment: SegmentObjMock) => { return { segment: segment, @@ -314,12 +272,9 @@ export const renderSegment = ( const defaultProps: InputSegmentProps = { value: '', - // onChange: () => {}, segment: 'day', - // charsPerSegment: charsPerSegmentMock['day'], min: defaultMinMock['day'], max: defaultMaxMock['day'], - // segmentEnum: SegmentObjMock, size: Size.Default, shouldNotRollover: false, placeholder: defaultPlaceholderMock['day'], diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts new file mode 100644 index 0000000000..586a3d55ab --- /dev/null +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -0,0 +1,83 @@ +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { createRef } from 'react'; +import { ExplicitSegmentRule } from '../utils'; +import { css } from '@leafygreen-ui/emotion'; + +export const SegmentObjMock = { + Month: 'month', + Day: 'day', + Year: 'year', +} as const; +export type SegmentObjMock = + (typeof SegmentObjMock)[keyof typeof SegmentObjMock]; + +export type SegmentRefsMock = Record< + SegmentObjMock, + ReturnType> +>; + +export const segmentRefsMock: SegmentRefsMock = { + month: createRef(), + day: createRef(), + year: createRef(), +}; + +export const segmentsMock: Record = { + month: '02', + day: '02', + year: '2025', +}; +export const charsPerSegmentMock: Record = { + month: 2, + day: 2, + year: 4, +}; +export const segmentRulesMock: Record = { + month: { maxChars: 2, minExplicitValue: 2 }, + day: { maxChars: 2, minExplicitValue: 4 }, + year: { maxChars: 4, minExplicitValue: 1970 }, +}; +export const defaultMinMock: Record = { + month: 1, + day: 0, + year: 1970, +}; +export const defaultMaxMock: Record = { + month: 12, + day: 31, + year: 2038, +}; + +export const defaultPlaceholderMock: Record = { + day: 'DD', + month: 'MM', + year: 'YYYY', +} as const; + +export const defaultFormatPartsMock: Array = [ + { type: 'month', value: '' }, + { type: 'literal', value: '-' }, + { type: 'day', value: '' }, + { type: 'literal', value: '-' }, + { type: 'year', value: '' }, +]; + +/** The percentage of 1ch these specific characters take up */ +export const characterWidth = { + // // Standard font + D: 46 / 40, + M: 55 / 40, + Y: 50 / 40, +} as const; + +export const segmentWidthStyles: Record = { + day: css` + width: ${charsPerSegmentMock.day * characterWidth.D}ch; + `, + month: css` + width: ${charsPerSegmentMock.month * characterWidth.M}ch; + `, + year: css` + width: ${charsPerSegmentMock.year * characterWidth.Y}ch; + `, +}; From b24d5bcd219335768298e0595cddd4f522e233d5 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sat, 1 Nov 2025 22:28:49 -0400 Subject: [PATCH 36/56] refactor(input-box): clarify type handling in InputBoxContext with detailed comments on type assertions --- packages/input-box/src/InputBoxContext/InputBoxContext.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 9228d43bcd..36d99cb6f3 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -21,7 +21,7 @@ export interface InputBoxProviderProps { onBlur: (event: React.FocusEvent) => void; } -// The Context constant is defined with the default/fixed type +// The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the segments yet. export const InputBoxContext = createContext(null); // Provider is generic over T, the string union @@ -42,8 +42,7 @@ export const InputBoxProvider = ({ [charsPerSegment, segmentEnum, onChange, onBlur], ); - // Single assertion to the fixed context type - // TODO: why is this necessary? + // The provider passes a strict type of T but the context is defined as a loose type of string so TS sees a potential type mismatch. This assertion says that we know that the types do not overlap but we guarantee that the strict provider value satisfies the fixed context requirement. return ( {children} From b57553103513047596c0444a33f5be86003d8e40 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sun, 2 Nov 2025 16:16:59 -0500 Subject: [PATCH 37/56] refactor(input-box): enhance type handling in InputBox and InputSegment components for improved clarity and consistency --- packages/input-box/src/InputBox/InputBox.tsx | 21 ++- .../input-box/src/InputBox/InputBox.types.ts | 153 ++---------------- .../src/InputBoxContext/InputBoxContext.tsx | 30 ++-- .../src/InputSegment/InputSegment.stories.tsx | 32 +++- .../src/InputSegment/InputSegment.tsx | 14 +- .../src/InputSegment/InputSegment.types.ts | 25 +-- packages/input-box/src/testutils/index.tsx | 5 +- 7 files changed, 99 insertions(+), 181 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 5e8e68d3ba..4798fa39ed 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -33,7 +33,7 @@ import { InputBoxProvider } from '../InputBoxContext'; * * @internal */ -export const InputBoxWithRef = ( +export const InputBoxWithRef = ( { className, labelledBy, @@ -46,15 +46,28 @@ export const InputBoxWithRef = ( formatParts, segmentEnum, segmentRules, - // renderSegment, segment, minValues, ...rest - }: InputBoxProps, + }: InputBoxProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); + console.log('🌻Storybook: InputBox', { + segmentEnum, + segmentRules, + charsPerSegment, + minValues, + formatParts, + segmentRefs, + onSegmentChange, + onKeyDown, + setSegment, + disabled, + ...rest, + }); + const isExplicitSegmentValue = createExplicitSegmentValidator( segmentEnum, segmentRules, @@ -75,7 +88,7 @@ export const InputBoxWithRef = ( /** Fired when an individual segment value changes */ const handleSegmentInputChange: InputSegmentChangeEventHandler< - T, + Segment, string > = segmentChangeEvent => { let segmentValue = segmentChangeEvent.value; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index de2991011d..59536d588e 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -6,142 +6,21 @@ import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; import { ExplicitSegmentRule } from '../utils'; -export interface RenderSegmentProps { - onChange: InputSegmentChangeEventHandler; - onBlur: FocusEventHandler; - partType: T; -} - -export interface InputChangeEvent { +export interface InputChangeEvent { value: DateType; - segments: Record; + segments: Record; } -export type InputChangeEventHandler = ( - changeEvent: InputChangeEvent, +export type InputChangeEventHandler = ( + changeEvent: InputChangeEvent, ) => void; -// export interface InputBoxProps> -// extends Omit, 'onChange' | 'children'> { -// /** -// * Callback fired when any segment changes, but not necessarily a full value -// */ -// onSegmentChange?: InputSegmentChangeEventHandler; - -// /** -// * id of the labelling element -// */ -// labelledBy?: string; - -// /** -// * An object that maps the segment names to their refs -// * -// * @example -// * { day: ref, month: ref, year: ref } -// */ -// segmentRefs: Record< -// T[keyof T], -// ReturnType> -// >; - -// /** -// * An enumerable object that maps the segment names to their values -// * -// * @example -// * { Day: 'day', Month: 'month', Year: 'year' } -// */ -// segmentEnum: T; - -// /** -// * An object containing the values of the segments -// * -// * @example -// * { day: '1', month: '2', year: '2025' } -// */ -// segments: Record; - -// /** -// * A function that sets the value of a segment -// * -// * @example -// * (segment: 'day', value: '1') => void; -// */ -// setSegment: (segment: T[keyof T], value: string) => void; - -// /** -// * The format parts of the date -// * -// * @example -// * [ -// * { type: 'month', value: '02' }, -// * { type: 'literal', value: '-' }, -// * { type: 'day', value: '02' }, -// * { type: 'literal', value: '-' }, -// * { type: 'year', value: '2025' }, -// * ] -// */ -// formatParts?: Array; - -// /** -// * The number of characters per segment -// * -// * @example -// * { day: 2, month: 2, year: 4 } -// */ -// charsPerSegment: Record; - -// /** -// * Whether the input box is disabled -// * -// * @default false -// */ -// disabled?: boolean; - -// /** -// * An object that maps the segment names to their rules. -// * -// * maxChars: the maximum number of characters for the segment -// * minExplicitValue: the minimum explicit value for the segment -// * -// * @example -// * { -// * day: { maxChars: 2, minExplicitValue: 1 }, -// * month: { maxChars: 2, minExplicitValue: 4 }, -// * year: { maxChars: 4, minExplicitValue: 1970 }, -// * } -// * -// * Explicit: Day = 5, 02 -// * Ambiguous: Day = 2 (could be 20-29) -// * -// */ -// segmentRules: Record; -// /** -// * An object that maps the segment names to their minimum values -// * -// * @example -// * { day: 0, month: 1, year: 1970 } -// */ -// minValues: Record; - -// /** -// * A function that renders a segment -// * -// * @example -// * (props: { -// * onChange: (event: React.ChangeEvent) => void, -// * onBlur: (event: React.FocusEvent) => void, -// * partType: 'day' | 'month' | 'year', -// * }) => React.ReactElement; -// */ -// renderSegment: (props: RenderSegmentProps) => React.ReactElement; -// } - -export interface InputBoxProps +export interface InputBoxProps extends Omit, 'onChange' | 'children'> { /** * Callback fired when any segment changes, but not necessarily a full value */ - onSegmentChange?: InputSegmentChangeEventHandler; + onSegmentChange?: InputSegmentChangeEventHandler; /** * id of the labelling element @@ -154,7 +33,7 @@ export interface InputBoxProps * @example * { day: ref, month: ref, year: ref } */ - segmentRefs: Record>>; + segmentRefs: Record>>; /** * An enumerable object that maps the segment names to their values @@ -162,7 +41,7 @@ export interface InputBoxProps * @example * { Day: 'day', Month: 'month', Year: 'year' } */ - segmentEnum: Record; + segmentEnum: Record; /** * An object containing the values of the segments @@ -170,7 +49,7 @@ export interface InputBoxProps * @example * { day: '1', month: '2', year: '2025' } */ - segments: Record; + segments: Record; /** * A function that sets the value of a segment @@ -178,7 +57,7 @@ export interface InputBoxProps * @example * (segment: 'day', value: '1') => void; */ - setSegment: (segment: T, value: string) => void; + setSegment: (segment: Segment, value: string) => void; /** * The format parts of the date @@ -200,7 +79,7 @@ export interface InputBoxProps * @example * { day: 2, month: 2, year: 4 } */ - charsPerSegment: Record; + charsPerSegment: Record; /** * Whether the input box is disabled @@ -226,14 +105,14 @@ export interface InputBoxProps * Ambiguous: Day = 2 (could be 20-29) * */ - segmentRules: Record; + segmentRules: Record; /** * An object that maps the segment names to their minimum values * * @example * { day: 0, month: 1, year: 1970 } */ - minValues: Record; + minValues: Record; /** * A component that renders a segment @@ -241,7 +120,7 @@ export interface InputBoxProps * @example * segment={DateInputSegment} */ - segment: React.ComponentType<{ segment: T }>; + segment: React.ComponentType<{ segment: Segment }>; } /** @@ -253,8 +132,8 @@ export interface InputBoxProps * @see https://stackoverflow.com/a/58473012 */ export interface InputBoxComponentType { - ( - props: InputBoxProps, + ( + props: InputBoxProps, ref: ForwardedRef, ): ReactElement | null; displayName?: string; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 36d99cb6f3..578e1b925c 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -2,36 +2,36 @@ import React, { createContext, useContext, useMemo } from 'react'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; // Helper type to represent the constrained Enum Object structure -type SegmentEnumObject = Record; +type SegmentEnumObject = Record; // T is the string union of segment names (e.g., 'areaCode' | 'prefix') -export interface InputBoxContextType { - charsPerSegment: Record; // Keyed by T - segmentEnum: SegmentEnumObject; // Values are T - onChange: InputSegmentChangeEventHandler; +export interface InputBoxContextType { + charsPerSegment: Record; // Keyed by Segment + segmentEnum: SegmentEnumObject; // Values are Segment + onChange: InputSegmentChangeEventHandler; onBlur: (event: React.FocusEvent) => void; } // Props are generic over T and use SegmentEnumObject for segmentEnum -export interface InputBoxProviderProps { +export interface InputBoxProviderProps { children: React.ReactNode; - charsPerSegment: Record; - segmentEnum: SegmentEnumObject; - onChange: InputSegmentChangeEventHandler; + charsPerSegment: Record; + segmentEnum: SegmentEnumObject; + onChange: InputSegmentChangeEventHandler; onBlur: (event: React.FocusEvent) => void; } -// The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the segments yet. +// The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the string yet. export const InputBoxContext = createContext(null); // Provider is generic over T, the string union -export const InputBoxProvider = ({ +export const InputBoxProvider = ({ children, charsPerSegment, segmentEnum, onChange, onBlur, -}: InputBoxProviderProps) => { +}: InputBoxProviderProps) => { const value = useMemo( () => ({ charsPerSegment, @@ -51,9 +51,11 @@ export const InputBoxProvider = ({ }; // The hook is generic over T, the string union -export const useInputBoxContext = () => { +export const useInputBoxContext = () => { // Assert the context type to the specific generic T - const context = useContext(InputBoxContext) as InputBoxContextType | null; + const context = useContext( + InputBoxContext, + ) as InputBoxContextType | null; if (!context) { throw new Error( diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 12598e2440..f3fff56d7d 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -15,9 +15,10 @@ import { defaultMinMock, defaultPlaceholderMock, SegmentObjMock, -} from '../testutils'; +} from '../testutils/testutils.mocks'; import { InputSegment } from '.'; +import { InputBoxProvider } from '../InputBoxContext'; const meta: StoryMetaType = { title: 'Components/Inputs/InputBox/InputSegment', @@ -25,7 +26,14 @@ const meta: StoryMetaType = { decorators: [ (StoryFn, context) => ( - + {}} + onBlur={() => {}} + > + + ), ], @@ -78,7 +86,14 @@ const meta: StoryMetaType = { }, decorator: (StoryFn, context) => ( - + {}} + onBlur={() => {}} + > + + ), }, @@ -89,14 +104,17 @@ export default meta; export const LiveExample: StoryFn = props => { const [value, setValue] = useState(''); return ( - { setValue(value); console.log('🌻Storybook: onChange', { value }); }} - /> + onBlur={() => {}} + > + + ); }; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index e90ab1bcab..277b853f47 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -31,7 +31,7 @@ import { useInputBoxContext } from '../InputBoxContext'; * * @internal */ -const InputSegmentWithRef = ( +const InputSegmentWithRef = ( { segment, value, @@ -46,7 +46,7 @@ const InputSegmentWithRef = ( shouldNotRollover = false, shouldSkipValidation = false, ...rest - }: InputSegmentProps, + }: InputSegmentProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); @@ -55,7 +55,7 @@ const InputSegmentWithRef = ( onBlur, charsPerSegment: charsPerSegmentContext, segmentEnum, - } = useInputBoxContext(); + } = useInputBoxContext(); const baseFontSize = useUpdatedBaseFontSize(); const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter(charsPerSegment, min === 0); @@ -85,7 +85,7 @@ const InputSegmentWithRef = ( if (hasValueChanged) { onChange({ segment, - value: newValue as V, + value: newValue as Value, }); } else { // If the value has not changed, ensure the input value is reset @@ -131,7 +131,7 @@ const InputSegmentWithRef = ( /** Fire a custom change event when the up/down arrow keys are pressed */ onChange({ segment, - value: valueString as V, + value: valueString as Value, meta: { key }, }); break; @@ -147,7 +147,7 @@ const InputSegmentWithRef = ( /** Fire a custom change event when the backspace key is pressed */ onChange({ segment, - value: '' as V, + value: '' as Value, meta: { key }, }); } @@ -164,7 +164,7 @@ const InputSegmentWithRef = ( /** Fire a custom change event when the space key is pressed */ onChange({ segment, - value: '' as V, + value: '' as Value, meta: { key }, }); } diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index c347e36164..bdc09df9bb 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -3,9 +3,12 @@ import React, { ForwardedRef, ReactElement } from 'react'; import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; -export interface InputSegmentChangeEvent { - segment: T; - value: V; +export interface InputSegmentChangeEvent< + Segment extends string, + Value extends string, +> { + segment: Segment; + value: Value; meta?: { key?: (typeof keyMap)[keyof typeof keyMap]; [key: string]: any; @@ -16,11 +19,11 @@ export interface InputSegmentChangeEvent { * The type for the onChange handler */ export type InputSegmentChangeEventHandler< - T extends string, - V extends string, -> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + Segment extends string, + Value extends string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; -export interface InputSegmentProps +export interface InputSegmentProps extends Omit, 'size' | 'step'> { /** * Which segment this input represents @@ -30,7 +33,7 @@ export interface InputSegmentProps * 'month' * 'year' */ - segment: T; + segment: Segment; /** * The value of the segment @@ -40,7 +43,7 @@ export interface InputSegmentProps * '2' * '2025' */ - value: V; + value: Value; /** * Minimum value. @@ -103,8 +106,8 @@ export interface InputSegmentProps * @see https://stackoverflow.com/a/58473012 */ export interface InputSegmentComponentType { - ( - props: InputSegmentProps, + ( + props: InputSegmentProps, ref: ForwardedRef, ): ReactElement | null; displayName?: string; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 305bc7fd99..1cd8f9658f 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,4 +1,3 @@ -// TODO: fix this import { createContext, useContext } from 'react'; import React from 'react'; import { render, RenderResult } from '@testing-library/react'; @@ -24,6 +23,7 @@ import { defaultPlaceholderMock, segmentsMock, segmentRefsMock, + segmentWidthStyles, } from './testutils.mocks'; export const defaultProps: Partial> = { @@ -85,6 +85,9 @@ export const InputSegmentWrapper = ({ max={defaultMaxMock[segment]} size={Size.Default} data-testid={`input-segment-${segment}`} + className={segmentWidthStyles[segment]} + shouldSkipValidation={segment === SegmentObjMock.Year} + shouldNotRollover={segment === SegmentObjMock.Year} /> ); }; From 8236c8d0b65f8108610d04196f63c643077e1c92 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sun, 2 Nov 2025 21:14:16 -0500 Subject: [PATCH 38/56] refactor(input-box): update InputSegment and InputBox components to utilize context for segment values and enhance prop handling --- .../DateInputBox/DateInputBoxContext.tsx | 0 .../DateInputSegment.types.ts | 12 +- packages/input-box/src/InputBox/InputBox.tsx | 17 +- .../InputBoxContext/InputBoxContext.spec.tsx | 6 + .../src/InputBoxContext/InputBoxContext.tsx | 11 +- .../src/InputSegment/InputSegment.spec.tsx | 288 +++++++++--------- .../src/InputSegment/InputSegment.stories.tsx | 52 ++-- .../src/InputSegment/InputSegment.tsx | 28 +- .../src/InputSegment/InputSegment.types.ts | 27 +- packages/input-box/src/testutils/index.tsx | 164 ++++------ 10 files changed, 282 insertions(+), 323 deletions(-) create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx new file mode 100644 index 0000000000..e69de29bb2 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 d1887f28db..9c995e7a1a 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 @@ -25,12 +25,12 @@ export interface DateInputSegmentProps /** 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; + // /** 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 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; + // /** Optional maximum value. Defaults to 31 for day, 12 for month, 2038 for year */ + // max?: number; } diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 4798fa39ed..71affcc691 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -48,26 +48,13 @@ export const InputBoxWithRef = ( segmentRules, segment, minValues, + segments, ...rest }: InputBoxProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); - console.log('🌻Storybook: InputBox', { - segmentEnum, - segmentRules, - charsPerSegment, - minValues, - formatParts, - segmentRefs, - onSegmentChange, - onKeyDown, - setSegment, - disabled, - ...rest, - }); - const isExplicitSegmentValue = createExplicitSegmentValidator( segmentEnum, segmentRules, @@ -222,6 +209,8 @@ export const InputBoxWithRef = ( onChange={handleSegmentInputChange} onBlur={handleSegmentInputBlur} segmentEnum={segmentEnum} + segmentRefs={segmentRefs} + segments={segments} > {/* We want to allow keydown events to be captured by the parent so that the parent can handle the event. */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index ad0b13692e..a0b9483f95 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -6,6 +6,8 @@ import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; import { charsPerSegmentMock, SegmentObjMock, + segmentRefsMock, + segmentsMock, } from '../testutils/testutils.mocks'; describe('InputBoxContext', () => { @@ -42,6 +44,8 @@ describe('InputBoxContext', () => { segmentEnum={SegmentObjMock} onChange={mockOnChange} onBlur={mockOnBlur} + segmentRefs={segmentRefsMock} + segments={segmentsMock} > {children} @@ -52,5 +56,7 @@ describe('InputBoxContext', () => { expect(result.current.segmentEnum).toBe(SegmentObjMock); expect(result.current.onChange).toBe(mockOnChange); expect(result.current.onBlur).toBe(mockOnBlur); + expect(result.current.segmentRefs).toBe(segmentRefsMock); + expect(result.current.segments).toBe(segmentsMock); }); }); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 578e1b925c..16c5307fca 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; // Helper type to represent the constrained Enum Object structure type SegmentEnumObject = Record; @@ -10,6 +11,8 @@ export interface InputBoxContextType { segmentEnum: SegmentEnumObject; // Values are Segment onChange: InputSegmentChangeEventHandler; onBlur: (event: React.FocusEvent) => void; + segmentRefs: Record>>; + segments: Record; } // Props are generic over T and use SegmentEnumObject for segmentEnum @@ -19,6 +22,8 @@ export interface InputBoxProviderProps { segmentEnum: SegmentEnumObject; onChange: InputSegmentChangeEventHandler; onBlur: (event: React.FocusEvent) => void; + segmentRefs: Record>>; + segments: Record; } // The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the string yet. @@ -31,6 +36,8 @@ export const InputBoxProvider = ({ segmentEnum, onChange, onBlur, + segmentRefs, + segments, }: InputBoxProviderProps) => { const value = useMemo( () => ({ @@ -38,8 +45,10 @@ export const InputBoxProvider = ({ segmentEnum, onChange, onBlur, + segmentRefs, + segments, }), - [charsPerSegment, segmentEnum, onChange, onBlur], + [charsPerSegment, segmentEnum, onChange, onBlur, segmentRefs, segments], ); // The provider passes a strict type of T but the context is defined as a loose type of string so TS sees a potential type mismatch. This assertion says that we know that the types do not overlap but we guarantee that the strict provider value satisfies the fixed context requirement. diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 1b59ebbb99..bbe839bbc9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -19,7 +19,9 @@ describe('packages/input-segment', () => { describe('aria attributes', () => { describe.each(['day', 'month', 'year'])('%p', segment => { test(`${segment} segment has aria-label`, () => { - const { input } = renderSegment({ segment: segment as SegmentObjMock }); + const { input } = renderSegment({ + props: { segment: segment as SegmentObjMock }, + }); expect(input).toHaveAttribute('aria-label', segment); }); }); @@ -33,65 +35,73 @@ describe('packages/input-segment', () => { }); test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ value: '12' }); + const { input } = renderSegment({ + providerProps: { segments: { day: '12', month: '', year: '' } }, + }); expect(input.value).toBe('12'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - value: '12', + providerProps: { segments: { day: '12', month: '', year: '' } }, }); - rerenderSegment({ value: '08' }); + rerenderSegment({ + newProviderProps: { 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({ ...setSegmentProps('month') }); + const { input } = renderSegment({ props: setSegmentProps('month') }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { const { input } = renderSegment({ - ...setSegmentProps('month'), - value: '26', + props: setSegmentProps('month'), + providerProps: { segments: { day: '', month: '26', year: '' } }, }); expect(input.value).toBe('26'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - ...setSegmentProps('month'), - value: '26', + props: setSegmentProps('month'), + providerProps: { segments: { day: '', month: '26', year: '' } }, }); - rerenderSegment({ value: '08' }); + rerenderSegment({ + newProviderProps: { 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({ ...setSegmentProps('year') }); + const { input } = renderSegment({ props: setSegmentProps('year') }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { const { input } = renderSegment({ - ...setSegmentProps('year'), - value: '2023', + props: setSegmentProps('year'), + providerProps: { segments: { day: '', month: '', year: '2023' } }, }); expect(input.value).toBe('2023'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - ...setSegmentProps('year'), - value: '2023', + props: setSegmentProps('year'), + providerProps: { segments: { day: '', month: '', year: '2023' } }, + }); + rerenderSegment({ + newProviderProps: { segments: { day: '', month: '', year: '1993' } }, }); - rerenderSegment({ value: '1993' }); expect(getInput().value).toBe('1993'); }); }); @@ -104,7 +114,9 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({}, { onChange: onChangeHandler }); + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); userEvent.type(input, '8'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -117,7 +129,9 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({}, { onChange: onChangeHandler }); + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); userEvent.type(input, '0'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -130,7 +144,9 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment({}, { onChange: onChangeHandler }); + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); userEvent.type(input, 'aB$/'); expect(onChangeHandler).not.toHaveBeenCalled(); }); @@ -142,12 +158,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - value: '2', + const { input } = renderSegment({ + providerProps: { + segments: { day: '2', month: '', year: '' }, + onChange: onChangeHandler, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '6'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -160,12 +176,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - value: '26', + const { input } = renderSegment({ + providerProps: { + segments: { day: '26', month: '', year: '' }, + onChange: onChangeHandler, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '4'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -189,13 +205,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(15), + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -210,14 +226,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(15), - step: 2, + const { input } = renderSegment({ + props: { segment: 'day', step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -232,13 +247,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: '', + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '', month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -253,13 +268,17 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(defaultMaxMock['day']), + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -274,14 +293,17 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(defaultMaxMock['day']), - shouldNotRollover: true, + const { input } = renderSegment({ + props: { shouldRollover: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -298,13 +320,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(15), + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -319,14 +340,13 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(15), - step: 2, + const { input } = renderSegment({ + props: { step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -341,13 +361,10 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: '', - }, - { onChange: onChangeHandler }, - ); + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { onChange: onChangeHandler }, + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -362,13 +379,16 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(defaultMinMock['day']), + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -383,14 +403,17 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: formatter(defaultMinMock['day']), - shouldNotRollover: true, + const { input } = renderSegment({ + props: { shouldRollover: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -407,13 +430,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: '12', + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{backspace}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -426,12 +448,9 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - }, - { onChange: onChangeHandler }, - ); + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); userEvent.type(input, '{backspace}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -447,12 +466,9 @@ describe('packages/input-segment', () => { string >; - const { input } = renderSegment( - { - segment: 'day', - }, - { onChange: onChangeHandler }, - ); + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); userEvent.type(input, '{space}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -464,13 +480,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: '12', + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{space}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -486,12 +501,9 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - }, - { onChange: onChangeHandler }, - ); + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); userEvent.type(input, '{space}{space}'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -503,13 +515,12 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - const { input } = renderSegment( - { - segment: 'day', - value: '12', + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, }, - { onChange: onChangeHandler }, - ); + }); userEvent.type(input, '{space}{space}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -530,30 +541,17 @@ describe('packages/input-segment', () => { }); test('With required props', () => { - {}} - value="12" - // charsPerSegment={2} - min={1} - max={31} - // segmentEnum={SegmentObjMock} - size={Size.Default} - />; + ; }); test('With all props', () => { {}} - value="12" - // charsPerSegment={2} min={1} max={31} - // segmentEnum={SegmentObjMock} size={Size.Default} step={1} - shouldNotRollover={false} + shouldRollover={true} shouldSkipValidation={false} placeholder="12" className="test" diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index f3fff56d7d..7bb7139f6d 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -15,6 +15,8 @@ import { defaultMinMock, defaultPlaceholderMock, SegmentObjMock, + segmentRefsMock, + segmentsMock, } from '../testutils/testutils.mocks'; import { InputSegment } from '.'; @@ -26,27 +28,18 @@ const meta: StoryMetaType = { decorators: [ (StoryFn, context) => ( - {}} - onBlur={() => {}} - > - - + ), ], args: { segment: SegmentObjMock.Day, - value: '', - charsPerSegment: charsPerSegmentMock[SegmentObjMock.Day], - segmentObj: SegmentObjMock, + min: defaultMinMock[SegmentObjMock.Day], max: defaultMaxMock[SegmentObjMock.Day], size: Size.Default, placeholder: defaultPlaceholderMock[SegmentObjMock.Day], - shouldNotRollover: false, + shouldRollover: true, step: 1, darkMode: false, }, @@ -55,12 +48,6 @@ const meta: StoryMetaType = { control: 'select', options: Object.values(Size), }, - shouldNotRollover: { - control: 'boolean', - }, - step: { - control: 'number', - }, darkMode: { control: 'boolean', }, @@ -75,13 +62,17 @@ const meta: StoryMetaType = { 'onChange', 'charsPerSegment', 'segmentEnum', + 'min', + 'max', + 'shouldRollover', + 'shouldSkipValidation', + 'step', ], }, generate: { combineArgs: { darkMode: [false, true], - value: ['', '6', '06'], - segment: ['day'], + segment: ['day', 'month', 'year'], size: Object.values(Size), }, decorator: (StoryFn, context) => ( @@ -91,6 +82,12 @@ const meta: StoryMetaType = { segmentEnum={SegmentObjMock} onChange={() => {}} onBlur={() => {}} + segmentRefs={segmentRefsMock} + segments={{ + day: '02', + month: '8', + year: '2025', + }} > @@ -102,20 +99,23 @@ const meta: StoryMetaType = { export default meta; export const LiveExample: StoryFn = props => { - const [value, setValue] = useState(''); return ( { - setValue(value); - console.log('🌻Storybook: onChange', { value }); - }} + onChange={() => {}} onBlur={() => {}} + segmentRefs={segmentRefsMock} + segments={segmentsMock} > - + ); }; export const Generated = () => {}; + +// TODO: save this and then update DatePicker. Ask team about tests for date picker. +// TODO: add min/max tests +// TODO: documentation +// TODO: PR comments diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 277b853f47..24f86033bb 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -22,6 +22,7 @@ import { } from './InputSegment.types'; import { useInputBoxContext } from '../InputBoxContext'; +import { useMergeRefs } from '@leafygreen-ui/hooks'; /** * Generic controlled input segment component @@ -31,10 +32,9 @@ import { useInputBoxContext } from '../InputBoxContext'; * * @internal */ -const InputSegmentWithRef = ( +const InputSegmentWithRef = ( { segment, - value, onKeyDown, size, min, // minSegmentValue @@ -43,10 +43,10 @@ const InputSegmentWithRef = ( onChange: onChangeProp, onBlur: onBlurProp, step = 1, - shouldNotRollover = false, - shouldSkipValidation = false, + shouldRollover = true, // TODO: shouldRollover + shouldSkipValidation = false, // TODO: shouldSkipValidation ...rest - }: InputSegmentProps, + }: InputSegmentProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); @@ -55,12 +55,18 @@ const InputSegmentWithRef = ( onBlur, charsPerSegment: charsPerSegmentContext, segmentEnum, + segmentRefs, + segments, } = useInputBoxContext(); const baseFontSize = useUpdatedBaseFontSize(); const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter(charsPerSegment, min === 0); const pattern = `[0-9]{${charsPerSegment}}`; + const segmentRef = segmentRefs[segment]; + const mergedRef = useMergeRefs([fwdRef, segmentRef]); + const value = segments[segment]; + /** * Receives native input events, * determines whether the input value is valid and should change, @@ -85,7 +91,7 @@ const InputSegmentWithRef = ( if (hasValueChanged) { onChange({ segment, - value: newValue as Value, + value: newValue, }); } else { // If the value has not changed, ensure the input value is reset @@ -124,14 +130,14 @@ const InputSegmentWithRef = ( min, max, step, - shouldNotRollover, + shouldNotRollover: !shouldRollover, }); const valueString = formatter(newValue); /** Fire a custom change event when the up/down arrow keys are pressed */ onChange({ segment, - value: valueString as Value, + value: valueString, meta: { key }, }); break; @@ -147,7 +153,7 @@ const InputSegmentWithRef = ( /** Fire a custom change event when the backspace key is pressed */ onChange({ segment, - value: '' as Value, + value: '', meta: { key }, }); } @@ -164,7 +170,7 @@ const InputSegmentWithRef = ( /** Fire a custom change event when the space key is pressed */ onChange({ segment, - value: '' as Value, + value: '', meta: { key }, }); } @@ -193,7 +199,7 @@ const InputSegmentWithRef = ( {...rest} aria-label={String(segment)} id={String(segment)} - ref={fwdRef} + ref={mergedRef} type="text" pattern={pattern} role="spinbutton" diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index bdc09df9bb..6873683026 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -23,8 +23,11 @@ export type InputSegmentChangeEventHandler< Value extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; -export interface InputSegmentProps - extends Omit, 'size' | 'step'> { +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' + > { /** * Which segment this input represents * @@ -35,16 +38,6 @@ export interface InputSegmentProps */ segment: Segment; - /** - * The value of the segment - * - * @example - * '1' - * '2' - * '2025' - */ - value: Value; - /** * Minimum value. * @@ -83,11 +76,11 @@ export interface InputSegmentProps step?: number; /** - * Whether the segment should not rollover + * Whether the segment should rollover * - * @default false + * @default true */ - shouldNotRollover?: boolean; + shouldRollover?: boolean; /** * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. @@ -106,8 +99,8 @@ export interface InputSegmentProps * @see https://stackoverflow.com/a/58473012 */ export interface InputSegmentComponentType { - ( - props: InputSegmentProps, + ( + props: InputSegmentProps, ref: ForwardedRef, ): ReactElement | null; displayName?: string; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 1cd8f9658f..78d5d3eb0e 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,4 +1,3 @@ -import { createContext, useContext } from 'react'; import React from 'react'; import { render, RenderResult } from '@testing-library/react'; @@ -14,7 +13,6 @@ import { InputBoxProvider } from '../InputBoxContext'; import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext'; import { SegmentObjMock, - SegmentRefsMock, defaultMinMock, defaultMaxMock, charsPerSegmentMock, @@ -36,58 +34,21 @@ export const defaultProps: Partial> = { segmentRules: segmentRulesMock, }; -/* - * InputBoxWrapper Context and Provider - */ -const InputBoxWrapperContext = createContext<{ - segments: Record; - segmentRefs: SegmentRefsMock; -} | null>(null); - -const InputBoxWrapperProvider = ({ - children, - segments, - segmentRefs, -}: { - children: React.ReactNode; - segments: Record; - segmentRefs: SegmentRefsMock; -}) => { - return ( - - {children} - - ); -}; - -const useInputBoxWrapperContext = () => { - const context = useContext(InputBoxWrapperContext); - if (!context) { - throw new Error( - 'useInputBoxWrapperContext must be used within InputBoxWrapperProvider', - ); - } - return context; -}; - export const InputSegmentWrapper = ({ segment, }: { segment: SegmentObjMock; }) => { - const { segments, segmentRefs } = useInputBoxWrapperContext(); return ( ); }; @@ -126,21 +87,19 @@ export const InputBoxWithState = ({ }; return ( - - - + ); }; @@ -190,14 +149,7 @@ export const renderInputBox = ({ segment: mergedProps.segment ?? InputSegmentWrapper, } as InputBoxProps; - const result = render( - - - , - ); + const result = render(); const rerenderInputBox = ({ ...props @@ -212,14 +164,7 @@ export const renderInputBox = ({ segment: mergedProps.segment ?? InputSegmentWrapper, } as InputBoxProps; - result.rerender( - - - , - ); + result.rerender(); }; const getDayInput = () => @@ -257,41 +202,52 @@ export const setSegmentProps = (segment: SegmentObjMock) => { interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; - rerenderSegment: ( - newProps: Partial>, - ) => void; + rerenderSegment: (params: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => void; } -export const renderSegment = ( - props?: Partial>, - providerProps?: Partial>, -): RenderResult & RenderSegmentReturnType => { - const defaultProviderProps: Partial> = { - charsPerSegment: charsPerSegmentMock, - segmentEnum: SegmentObjMock, - onChange: () => {}, - onBlur: () => {}, - }; +const defaultSegmentProviderProps: Partial< + InputBoxProviderProps +> = { + charsPerSegment: charsPerSegmentMock, + segmentEnum: SegmentObjMock, + onChange: () => {}, + onBlur: () => {}, + segments: { + day: '', + month: '', + year: '', + }, + segmentRefs: segmentRefsMock, +}; - const defaultProps: InputSegmentProps = { - value: '', - segment: 'day', - min: defaultMinMock['day'], - max: defaultMaxMock['day'], - size: Size.Default, - shouldNotRollover: false, - placeholder: defaultPlaceholderMock['day'], - // @ts-expect-error - data-testid - ['data-testid']: 'lg-input-segment', - }; +const defaultSegmentProps: InputSegmentProps = { + segment: 'day', + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + size: Size.Default, + shouldRollover: true, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', +}; +export const renderSegment = ({ + props = {}, + providerProps = {}, +}: { + props?: Partial>; + providerProps?: Partial>; +}): RenderResult & RenderSegmentReturnType => { const mergedProps = { - ...defaultProps, + ...defaultSegmentProps, ...props, - }; + } as InputSegmentProps; const mergedProviderProps = { - ...defaultProviderProps, + ...defaultSegmentProviderProps, ...providerProps, } as InputBoxProviderProps; @@ -300,11 +256,13 @@ export const renderSegment = ( , ); - - const rerenderSegment = ( - newProps: Partial>, - newProviderProps?: Partial>, - ) => { + const rerenderSegment = ({ + newProps = {}, + newProviderProps = {}, + }: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => { utils.rerender( From f3125b6e9cd9846107fef40468cdd439c7d5bcca Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Sun, 2 Nov 2025 21:32:43 -0500 Subject: [PATCH 39/56] refactor(date-picker): implement DateInputBoxContext for improved state management and enhance DateInputSegment integration --- .../DateInput/DateInputBox/DateInputBox.tsx | 70 ++++++++++--------- .../DateInputBox/DateInputBoxContext.tsx | 34 +++++++++ .../DateInputSegment.spec.tsx | 2 + .../DateInputSegment.stories.tsx | 3 + .../DateInputSegment/DateInputSegment.tsx | 43 ++++++------ .../DateInputSegment.types.ts | 5 +- 6 files changed, 102 insertions(+), 55 deletions(-) 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 0d91355443..3035733b02 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -27,6 +27,7 @@ import { import { DateInputSegment } from '../DateInputSegment'; import { DateInputBoxProps } from './DateInputBox.types'; +import { DateInputBoxProvider } from './DateInputBoxContext'; /** * Renders a styled date input with appropriate segment order & separator characters. @@ -55,7 +56,7 @@ export const DateInputBox = React.forwardRef( }: DateInputBoxProps, fwdRef, ) => { - const { isDirty, formatParts, disabled, min, max, setIsDirty } = + const { isDirty, formatParts, disabled, setIsDirty } = useSharedDatePickerContext(); // TODO: add context to store the value and segmentsRef so that the DateInputSegment can access it @@ -105,38 +106,41 @@ export const DateInputBox = React.forwardRef( }); return ( - ( - - )} - // TODO:Segment={DateInputSegment} - {...rest} - > - {/* {renderFormat(formatParts, DateInputSegment, value, labelledBy)} */} - + + ( + // + // )} + // TODO:Segment={DateInputSegment} + {...rest} + > + {/* {renderFormat(formatParts, DateInputSegment, value, labelledBy)} */} + + ); }, ); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx index e69de29bb2..ceb4a6a37a 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx @@ -0,0 +1,34 @@ +import { DateType } from '@leafygreen-ui/date-utils'; +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +export interface DateInputBoxContextType { + value?: DateType; +} + +export interface DateInputBoxProviderProps { + value?: DateType; +} + +export const DateInputBoxContext = + createContext(null); + +export const DateInputBoxProvider = ({ + children, + value, +}: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +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/DateInputSegment/DateInputSegment.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx index 9eeeb2d554..225ec6a603 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,3 +1,5 @@ +// @ts-nocheck +// TODO: fix this import React from 'react'; import { jest } from '@jest/globals'; import { render, waitFor } from '@testing-library/react'; 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..4fec430ce9 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 @@ -1,3 +1,6 @@ +// @ts-nocheck +// TODO: fix this + import React, { useState } from 'react'; import { StoryMetaType } from '@lg-tools/storybook-utils'; import { StoryFn } from '@storybook/react'; 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 44370ac990..5a43d9748e 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -11,10 +11,15 @@ import { } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; import { DateSegment } from '../../../types'; -import { getAutoComplete } from '../../../utils'; +import { + getAutoComplete, + getMaxSegmentValue, + getMinSegmentValue, +} from '../../../utils'; import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; +import { useDateInputBoxContext } from '../DateInputBox/DateInputBoxContext'; /** * Controlled component @@ -32,34 +37,30 @@ export const DateInputSegment = React.forwardRef< ( { segment, - value, // TODO: will be read from date input boxcontext - min: minProp, // TODO: will be generated from context - max: maxProp, // TODO: will be generated from context + // value, // TODO: will be read from date input boxcontext + // min: minProp, // TODO: will be generated from context + // max: maxProp, // TODO: will be generated from context // onChange, // TODO: will be read from context // onBlur, // TODO: will be read from context ...rest }: DateInputSegmentProps, fwdRef, ) => { - const min = minProp ?? defaultMin[segment]; - const max = maxProp ?? defaultMax[segment]; - - // min = getMinSegmentValue(segment, { date: value, min }); - // max = getMaxSegmentValue(segment, { date: value, max }); - const { size, disabled, autoComplete: autoCompleteProp, - // min, - // max, + min: minContextProp, + max: maxContextProp, } = useSharedDatePickerContext(); - // TODO: read the value, segmentsRef, labelledby, segments from context - // const { value, segmentsRef, labelledby, segments } = useContext(); - - // const min = getMinSegmentValue(segment, { date: value, min }); - // const max = getMaxSegmentValue(segment, { date: value, max }); + 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); @@ -75,7 +76,7 @@ export const DateInputSegment = React.forwardRef< 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 9c995e7a1a..df1c1376ff 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 @@ -21,7 +21,10 @@ export type DateInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< export interface DateInputSegmentProps extends DarkModeProps, - Omit, 'onChange'> { + Omit< + React.ComponentPropsWithoutRef<'input'>, + 'onChange' | 'value' | 'min' | 'max' + > { /** Which date segment this input represents. Determines the aria-label, and min/max values where relevant */ segment: DateSegment; From c40ad4c44c0ff570d2f1cde6774c7008248bb8e2 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 18:28:58 -0500 Subject: [PATCH 40/56] refactor(date-picker, input-box): enhance DateInput components by integrating context for segment management and improving prop handling --- .../DateInput/DateInputBox/DateInputBox.tsx | 64 ++--------- .../DateInputBox/DateInputBoxContext.tsx | 5 +- .../DateInputSegment.spec.tsx | 9 +- .../DateInputSegment/DateInputSegment.tsx | 106 +++++++----------- .../DateInputSegment.types.ts | 27 +---- packages/input-box/package.json | 1 + packages/input-box/src/InputBox.stories.tsx | 3 +- .../input-box/src/InputBox/InputBox.spec.tsx | 15 +-- packages/input-box/src/InputBox/InputBox.tsx | 11 +- .../input-box/src/InputBox/InputBox.types.ts | 29 +++-- .../InputBoxContext/InputBoxContext.spec.tsx | 8 +- .../src/InputBoxContext/InputBoxContext.tsx | 56 +++++++-- .../src/InputSegment/InputSegment.spec.tsx | 10 +- .../src/InputSegment/InputSegment.stories.tsx | 53 ++++++--- .../src/InputSegment/InputSegment.tsx | 65 ++++++----- .../src/InputSegment/InputSegment.types.ts | 30 ++--- packages/input-box/src/InputSegment/index.ts | 1 + packages/input-box/src/index.ts | 11 +- packages/input-box/src/testutils/index.tsx | 26 +++-- .../src/testutils/testutils.mocks.ts | 6 +- packages/input-box/tsconfig.json | 3 + 21 files changed, 277 insertions(+), 262 deletions(-) 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 3035733b02..f292008693 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -18,8 +18,6 @@ import { useSharedDatePickerContext } from '../../../context'; import { useDateSegments } from '../../../hooks'; import { DateSegment, DateSegmentsState } from '../../../types'; import { - getMaxSegmentValue, - getMinSegmentValue, isEverySegmentFilled, isEverySegmentValueExplicit, newDateFromSegments, @@ -56,13 +54,9 @@ export const DateInputBox = React.forwardRef( }: DateInputBoxProps, fwdRef, ) => { - const { isDirty, formatParts, disabled, setIsDirty } = + const { isDirty, formatParts, disabled, setIsDirty, size } = useSharedDatePickerContext(); - // TODO: add context to store the value and segmentsRef so that the DateInputSegment can access it - // const { value, segmentsRef, labelledby, segments } - // - /** if the value is a `Date` the component is dirty */ useEffect(() => { if (isDateObject(value) && !isDirty) { @@ -110,64 +104,24 @@ export const DateInputBox = React.forwardRef( ( - // - // )} - // TODO:Segment={DateInputSegment} + labelledBy={labelledBy} + segmentComponent={DateInputSegment} + size={size} {...rest} - > - {/* {renderFormat(formatParts, DateInputSegment, value, labelledBy)} */} - + /> ); }, ); DateInputBox.displayName = 'DateInputBox'; - -// // renderSegment as a function -// const RenderFormat = (formatParts: Intl.DateTimeFormatPart[], Segment: ReactComponent, value) => { -// return ( -//
-// {formatParts?.map((part, i) => { -// if (part.type === 'literal') { -// return ( -// -// {part.value} -// -// ); -// } else if (isInputSegment(part.type, segmentEnum)) { -// // render segement -// return ; -// } -// })} -//
-// ); -// }; - -// TODO: consider renaming min/max names to minSegment/maxSegment diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx index ceb4a6a37a..27f16b84f4 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx @@ -1,6 +1,7 @@ -import { DateType } from '@leafygreen-ui/date-utils'; import React, { createContext, PropsWithChildren, useContext } from 'react'; +import { DateType } from '@leafygreen-ui/date-utils'; + export interface DateInputBoxContextType { value?: DateType; } @@ -25,10 +26,12 @@ export const DateInputBoxProvider = ({ 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/DateInputSegment/DateInputSegment.spec.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.spec.tsx index 225ec6a603..915cd58261 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 @@ -6,6 +6,10 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { getValueFormatter } from '@leafygreen-ui/input-box'; +import { + InputBoxProvider, + type InputBoxProviderProps, +} from '@leafygreen-ui/input-box'; import { charsPerSegment, defaultMax, defaultMin } from '../../../constants'; import { @@ -17,11 +21,6 @@ import { DateSegment } from '../../../types'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; -import { - InputBoxProvider, - type InputBoxProviderProps, -} from '@leafygreen-ui/input-box'; - const renderSegment = ( props?: Partial, ctx?: Partial, 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 5a43d9748e..f51dabee90 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -3,12 +3,7 @@ import React from 'react'; import { cx } from '@leafygreen-ui/emotion'; 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 { DateSegment } from '../../../types'; import { @@ -16,10 +11,10 @@ import { getMaxSegmentValue, getMinSegmentValue, } from '../../../utils'; +import { useDateInputBoxContext } from '../DateInputBox/DateInputBoxContext'; import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; -import { useDateInputBoxContext } from '../DateInputBox/DateInputBoxContext'; /** * Controlled component @@ -33,70 +28,47 @@ import { useDateInputBoxContext } from '../DateInputBox/DateInputBoxContext'; export const DateInputSegment = React.forwardRef< HTMLInputElement, DateInputSegmentProps ->( - ( - { - segment, - // value, // TODO: will be read from date input boxcontext - // min: minProp, // TODO: will be generated from context - // max: maxProp, // TODO: will be generated from context - // onChange, // TODO: will be read from context - // onBlur, // TODO: will be read from context - ...rest - }: DateInputSegmentProps, - fwdRef, - ) => { - const { - size, - disabled, - autoComplete: autoCompleteProp, - min: minContextProp, - max: maxContextProp, - } = useSharedDatePickerContext(); +>(({ 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 { 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 autoComplete = getAutoComplete(autoCompleteProp, segment); - const shouldNotRollover = ( - [DateSegment.Year] as Array - ).includes(segment); + const shouldRollover = !([DateSegment.Year] as Array).includes( + segment, + ); - const shouldSkipValidation = ( - [DateSegment.Year] as Array - ).includes(segment); + const shouldSkipValidation = ( + [DateSegment.Year] as Array + ).includes(segment); - return ( - - ); - }, -); + 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 df1c1376ff..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,7 +1,8 @@ -import React from 'react'; - -import { InputSegmentChangeEventHandler } from '@leafygreen-ui/input-box'; -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'; @@ -20,20 +21,4 @@ export type DateInputSegmentChangeEventHandler = InputSegmentChangeEventHandler< >; export interface DateInputSegmentProps - extends DarkModeProps, - Omit< - React.ComponentPropsWithoutRef<'input'>, - 'onChange' | 'value' | 'min' | 'max' - > { - /** 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; -} + extends InputSegmentComponentProps {} diff --git a/packages/input-box/package.json b/packages/input-box/package.json index 3030c6e71e..2b5ef5e3c8 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -28,6 +28,7 @@ "access": "public" }, "dependencies": { + "@leafygreen-ui/a11y": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 05a065f2e8..2fbfa85033 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -8,9 +8,8 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; -import { InputBoxWithState } from './testutils'; - import { InputBox } from './InputBox'; +import { InputBoxWithState } from './testutils'; const meta: StoryMetaType = { title: 'Components/Inputs/InputBox', diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 41307be375..e9c04b1ad5 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -4,24 +4,23 @@ import userEvent from '@testing-library/user-event'; import { Size } from '@leafygreen-ui/tokens'; -import { InputSegment } from '../InputSegment'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; import { InputSegmentWrapper, renderInputBox, renderInputBoxWithState, } from '../testutils'; - -import { InputBox } from './InputBox'; import { - SegmentObjMock, - segmentsMock, charsPerSegmentMock, - segmentRulesMock, defaultMinMock, + SegmentObjMock, segmentRefsMock, + segmentRulesMock, + segmentsMock, } from '../testutils/testutils.mocks'; +import { InputBox } from './InputBox'; + describe('packages/input-box', () => { describe('Rendering', () => { describe.each(['day', 'month', 'year'])('%p', segment => { @@ -296,7 +295,9 @@ describe('packages/input-box', () => { charsPerSegment={charsPerSegmentMock} segmentRules={segmentRulesMock} minValues={defaultMinMock} - segment={InputSegmentWrapper} + segmentComponent={InputSegmentWrapper} + size={Size.Default} + disabled={false} />; }); }); diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 71affcc691..5d7697518c 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -7,6 +7,7 @@ import React, { import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; +import { InputBoxProvider } from '../InputBoxContext'; import { InputSegmentChangeEventHandler, isInputSegment, @@ -25,8 +26,6 @@ import { } from './InputBox.styles'; import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; -import { InputBoxProvider } from '../InputBoxContext'; - /** * Generic controlled input box component * Renders an input box with appropriate segment order & separator characters. @@ -46,9 +45,10 @@ export const InputBoxWithRef = ( formatParts, segmentEnum, segmentRules, - segment, + segmentComponent, minValues, segments, + size, ...rest }: InputBoxProps, fwdRef: ForwardedRef, @@ -211,6 +211,9 @@ export const InputBoxWithRef = ( segmentEnum={segmentEnum} segmentRefs={segmentRefs} segments={segments} + labelledBy={labelledBy} + size={size} + disabled={disabled} > {/* We want to allow keydown events to be captured by the parent so that the parent can handle the event. */} {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} @@ -231,7 +234,7 @@ export const InputBoxWithRef = ( ); } else if (isInputSegment(part.type, segmentEnum)) { - const Segment = segment; + const Segment = segmentComponent; return ; } })} diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 59536d588e..4ade022650 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -1,9 +1,13 @@ -import React, { FocusEventHandler, ForwardedRef, ReactElement } from 'react'; +import React, { ForwardedRef, ReactElement } from 'react'; import { DateType } from '@leafygreen-ui/date-utils'; import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { Size } from '@leafygreen-ui/tokens'; -import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { + InputSegmentChangeEventHandler, + InputSegmentComponentProps, +} from '../InputSegment/InputSegment.types'; import { ExplicitSegmentRule } from '../utils'; export interface InputChangeEvent { @@ -83,10 +87,8 @@ export interface InputBoxProps /** * Whether the input box is disabled - * - * @default false */ - disabled?: boolean; + disabled: boolean; /** * An object that maps the segment names to their rules. @@ -115,12 +117,23 @@ export interface InputBoxProps minValues: Record; /** - * A component that renders a segment + * The component that renders a segment. When mapping over the formatParts, we will render the segment component for each part using this component. + * This should be a React component that accepts the InputSegmentComponentProps type. + * + * @example + * segmentComponent={DateInputSegment} + */ + segmentComponent: React.ComponentType>; + + /** + * The size of the input box * * @example - * segment={DateInputSegment} + * Size.Default + * Size.Small + * Size.Large */ - segment: React.ComponentType<{ segment: Segment }>; + size: Size; } /** diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index a0b9483f95..c67eed44dd 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; +import { Size } from '@leafygreen-ui/tokens'; -import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; import { charsPerSegmentMock, SegmentObjMock, @@ -10,6 +10,8 @@ import { segmentsMock, } from '../testutils/testutils.mocks'; +import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; + describe('InputBoxContext', () => { const mockOnChange = jest.fn(); const mockOnBlur = jest.fn(); @@ -46,6 +48,8 @@ describe('InputBoxContext', () => { onBlur={mockOnBlur} segmentRefs={segmentRefsMock} segments={segmentsMock} + size={Size.Default} + disabled={false} > {children}
@@ -58,5 +62,7 @@ describe('InputBoxContext', () => { expect(result.current.onBlur).toBe(mockOnBlur); expect(result.current.segmentRefs).toBe(segmentRefsMock); expect(result.current.segments).toBe(segmentsMock); + expect(result.current.size).toBe(Size.Default); + expect(result.current.disabled).toBe(false); }); }); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 16c5307fca..7ace9c92f9 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -1,29 +1,42 @@ -import React, { createContext, useContext, useMemo } from 'react'; -import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, +} from 'react'; + import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; // Helper type to represent the constrained Enum Object structure type SegmentEnumObject = Record; // T is the string union of segment names (e.g., 'areaCode' | 'prefix') export interface InputBoxContextType { - charsPerSegment: Record; // Keyed by Segment - segmentEnum: SegmentEnumObject; // Values are Segment + charsPerSegment: Record; + disabled: boolean; + segmentEnum: SegmentEnumObject; onChange: InputSegmentChangeEventHandler; onBlur: (event: React.FocusEvent) => void; segmentRefs: Record>>; segments: Record; + labelledBy?: string; + size: Size; } // Props are generic over T and use SegmentEnumObject for segmentEnum export interface InputBoxProviderProps { - children: React.ReactNode; charsPerSegment: Record; + disabled: boolean; segmentEnum: SegmentEnumObject; onChange: InputSegmentChangeEventHandler; onBlur: (event: React.FocusEvent) => void; segmentRefs: Record>>; segments: Record; + labelledBy?: string; + size: Size; } // The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the string yet. @@ -31,24 +44,42 @@ export const InputBoxContext = createContext(null); // Provider is generic over T, the string union export const InputBoxProvider = ({ - children, charsPerSegment, - segmentEnum, + children, + disabled, + labelledBy, onChange, onBlur, - segmentRefs, segments, -}: InputBoxProviderProps) => { + segmentEnum, + segmentRefs, + size, +}: PropsWithChildren>) => { const value = useMemo( () => ({ charsPerSegment, - segmentEnum, + children, + disabled, + labelledBy, onChange, onBlur, - segmentRefs, segments, + segmentEnum, + segmentRefs, + size, }), - [charsPerSegment, segmentEnum, onChange, onBlur, segmentRefs, segments], + [ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, + ], ); // The provider passes a strict type of T but the context is defined as a loose type of string so TS sees a potential type mismatch. This assertion says that we know that the types do not overlap but we guarantee that the strict provider value satisfies the fixed context requirement. @@ -71,5 +102,6 @@ export const useInputBoxContext = () => { 'useInputBoxContext must be used within an InputBoxProvider', ); } + return context; }; diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index bbe839bbc9..7b6b208b46 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,17 +1,14 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { Size } from '@leafygreen-ui/tokens'; - import { renderSegment, setSegmentProps } from '../testutils'; -import { getValueFormatter } from '../utils'; - import { - SegmentObjMock, charsPerSegmentMock, defaultMaxMock, defaultMinMock, + SegmentObjMock, } from '../testutils/testutils.mocks'; +import { getValueFormatter } from '../utils'; import { InputSegment, InputSegmentChangeEventHandler } from '.'; @@ -541,7 +538,7 @@ describe('packages/input-segment', () => { }); test('With required props', () => { - ; + ; }); test('With all props', () => { @@ -549,7 +546,6 @@ describe('packages/input-segment', () => { segment="day" min={1} max={31} - size={Size.Default} step={1} shouldRollover={true} shouldSkipValidation={false} diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 7bb7139f6d..6d521736cf 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -1,5 +1,4 @@ -/* eslint-disable no-console */ -import React, { useState } from 'react'; +import React from 'react'; import { storybookExcludedControlParams, StoryMetaType, @@ -9,6 +8,7 @@ import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; +import { InputBoxProvider } from '../InputBoxContext'; import { charsPerSegmentMock, defaultMaxMock, @@ -20,13 +20,17 @@ import { } from '../testutils/testutils.mocks'; import { InputSegment } from '.'; -import { InputBoxProvider } from '../InputBoxContext'; -const meta: StoryMetaType = { +interface InputSegmentStoryProps { + size: Size; + segments: Record; +} + +const meta: StoryMetaType = { title: 'Components/Inputs/InputBox/InputSegment', component: InputSegment, decorators: [ - (StoryFn, context) => ( + (StoryFn, context: any) => ( @@ -34,7 +38,6 @@ const meta: StoryMetaType = { ], args: { segment: SegmentObjMock.Day, - min: defaultMinMock[SegmentObjMock.Day], max: defaultMaxMock[SegmentObjMock.Day], size: Size.Default, @@ -67,6 +70,7 @@ const meta: StoryMetaType = { 'shouldRollover', 'shouldSkipValidation', 'step', + 'placeholder', ], }, generate: { @@ -74,6 +78,18 @@ const meta: StoryMetaType = { darkMode: [false, true], segment: ['day', 'month', 'year'], size: Object.values(Size), + segments: [ + { + day: '2', + month: '8', + year: '2025', + }, + { + day: '', + month: '', + year: '', + }, + ], }, decorator: (StoryFn, context) => ( @@ -83,13 +99,19 @@ const meta: StoryMetaType = { onChange={() => {}} onBlur={() => {}} segmentRefs={segmentRefsMock} - segments={{ - day: '02', - month: '8', - year: '2025', - }} + segments={context?.args.segments} + size={context?.args.size} + disabled={false} > - + ), @@ -98,7 +120,10 @@ const meta: StoryMetaType = { }; export default meta; -export const LiveExample: StoryFn = props => { +export const LiveExample: StoryFn = ( + props, + context: any, +) => { return ( = props => { onBlur={() => {}} segmentRefs={segmentRefsMock} segments={segmentsMock} + disabled={false} + size={context?.args?.size || Size.Default} > diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 24f86033bb..f0ba05824f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -1,14 +1,17 @@ import React, { ChangeEventHandler, + FocusEvent, ForwardedRef, KeyboardEventHandler, - FocusEvent, } from 'react'; +import { VisuallyHidden } from '@leafygreen-ui/a11y'; +import { useMergeRefs } from '@leafygreen-ui/hooks'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; +import { useInputBoxContext } from '../InputBoxContext'; import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, @@ -21,9 +24,6 @@ import { InputSegmentProps, } from './InputSegment.types'; -import { useInputBoxContext } from '../InputBoxContext'; -import { useMergeRefs } from '@leafygreen-ui/hooks'; - /** * Generic controlled input segment component * @@ -36,7 +36,6 @@ const InputSegmentWithRef = ( { segment, onKeyDown, - size, min, // minSegmentValue max, // maxSegmentValue className, @@ -57,6 +56,9 @@ const InputSegmentWithRef = ( segmentEnum, segmentRefs, segments, + labelledBy, + size, + disabled, } = useInputBoxContext(); const baseFontSize = useUpdatedBaseFontSize(); const charsPerSegment = charsPerSegmentContext[segment]; @@ -194,29 +196,38 @@ const InputSegmentWithRef = ( // 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 + + // These attributes are returned from the hook as input props and we pass that to an input element return ( - + <> + + + {value && `${segment} ${value}`} + + ); }; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 6873683026..97cfd60c7f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,7 +1,6 @@ import React, { ForwardedRef, ReactElement } from 'react'; import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; export interface InputSegmentChangeEvent< Segment extends string, @@ -15,6 +14,7 @@ export interface InputSegmentChangeEvent< }; } +// TODO: consider renaming min/max names to minSegment/maxSegment /** * The type for the onChange handler */ @@ -39,7 +39,7 @@ export interface InputSegmentProps segment: Segment; /** - * Minimum value. + * Minimum value for the segment * * @example * 1 @@ -49,7 +49,7 @@ export interface InputSegmentProps min: number; /** - * Maximum value. + * Maximum value for the segment * * @example * 31 @@ -58,16 +58,6 @@ export interface InputSegmentProps */ max: number; - /** - * Size of the segment - * - * @example - * Size.Default - * Size.Small - * Size.Large - */ - size: Size; - /** * The step value for the arrow keys * @@ -116,3 +106,17 @@ export function isInputSegment>( if (typeof str !== 'string') return false; return Object.values(segmentObj).includes(str); } + +/** + * Base props for custom segment components passed to InputBox. + * + * Extend this interface to define props for custom segment implementations. + * InputBox will provide additional props internally (e.g., onChange, value, min, max). + */ +export interface InputSegmentComponentProps + extends Omit< + React.ComponentPropsWithoutRef<'input'>, + 'onChange' | 'value' | 'min' | 'max' + > { + segment: Segment; +} diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts index 283810ebcb..8e2840befb 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1,5 +1,6 @@ export { InputSegment } from './InputSegment'; export { type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, type InputSegmentProps, } from './InputSegment.types'; diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index 1ea5247328..2da0d9fcb4 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,7 +1,13 @@ export { InputBox, type InputBoxProps } from './InputBox'; +export { + InputBoxProvider, + type InputBoxProviderProps, + useInputBoxContext, +} from './InputBoxContext/InputBoxContext'; export { InputSegment, type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, type InputSegmentProps, } from './InputSegment'; export { @@ -15,8 +21,3 @@ export { isValidSegmentName, isValidSegmentValue, } from './utils/isValidSegment/isValidSegment'; -export { - useInputBoxContext, - InputBoxProvider, - type InputBoxProviderProps, -} from './InputBoxContext/InputBoxContext'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 78d5d3eb0e..2b0ff8a25b 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -4,23 +4,24 @@ import { render, RenderResult } from '@testing-library/react'; import { Size } from '@leafygreen-ui/tokens'; import { InputBox, InputBoxProps } from '../InputBox'; +import { InputBoxProvider } from '../InputBoxContext'; +import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext'; import { InputSegment } from '../InputSegment'; import { InputSegmentChangeEventHandler, InputSegmentProps, } from '../InputSegment/InputSegment.types'; -import { InputBoxProvider } from '../InputBoxContext'; -import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext'; + import { - SegmentObjMock, - defaultMinMock, - defaultMaxMock, charsPerSegmentMock, defaultFormatPartsMock, - segmentRulesMock, + defaultMaxMock, + defaultMinMock, defaultPlaceholderMock, - segmentsMock, + SegmentObjMock, segmentRefsMock, + segmentRulesMock, + segmentsMock, segmentWidthStyles, } from './testutils.mocks'; @@ -44,11 +45,11 @@ export const InputSegmentWrapper = ({ segment={segment} min={defaultMinMock[segment]} max={defaultMaxMock[segment]} - size={Size.Default} data-testid={`input-segment-${segment}`} className={segmentWidthStyles[segment]} shouldSkipValidation={segment === SegmentObjMock.Year} shouldRollover={segment !== SegmentObjMock.Year} + placeholder={defaultPlaceholderMock[segment]} /> ); }; @@ -98,7 +99,8 @@ export const InputBoxWithState = ({ segmentRules={segmentRulesMock} onSegmentChange={onSegmentChange} minValues={defaultMinMock} - segment={InputSegmentWrapper} + segmentComponent={InputSegmentWrapper} + size={Size.Default} /> ); }; @@ -146,7 +148,7 @@ export const renderInputBox = ({ const finalMergedProps = { ...mergedProps, - segment: mergedProps.segment ?? InputSegmentWrapper, + segmentComponent: mergedProps.segmentComponent ?? InputSegmentWrapper, } as InputBoxProps; const result = render(); @@ -161,7 +163,7 @@ export const renderInputBox = ({ const finalMergedProps = { ...mergedProps, - segment: mergedProps.segment ?? InputSegmentWrapper, + segmentComponent: mergedProps.segmentComponent ?? InputSegmentWrapper, } as InputBoxProps; result.rerender(); @@ -227,7 +229,6 @@ const defaultSegmentProps: InputSegmentProps = { segment: 'day', min: defaultMinMock['day'], max: defaultMaxMock['day'], - size: Size.Default, shouldRollover: true, placeholder: defaultPlaceholderMock['day'], // @ts-expect-error - data-testid @@ -256,6 +257,7 @@ export const renderSegment = ({ , ); + const rerenderSegment = ({ newProps = {}, newProviderProps = {}, diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 586a3d55ab..d1e062ac30 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -1,7 +1,9 @@ -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { createRef } from 'react'; -import { ExplicitSegmentRule } from '../utils'; + import { css } from '@leafygreen-ui/emotion'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +import { ExplicitSegmentRule } from '../utils'; export const SegmentObjMock = { Month: 'month', diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json index cba2152d8f..7f78ef8970 100644 --- a/packages/input-box/tsconfig.json +++ b/packages/input-box/tsconfig.json @@ -18,6 +18,9 @@ "**/*.stories.*" ], "references": [ + { + "path": "../a11y" + }, { "path": "../emotion" }, From 8a76a792ac96bcc02bad660c175b4b7ed345df4f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 4 Nov 2025 12:40:56 -0500 Subject: [PATCH 41/56] refactor(date-picker, input-box): improve DateInputSegment tests and stories by enhancing prop handling and integrating context for segment management --- .../DateInputSegment.spec.tsx | 877 +++++------------- .../DateInputSegment.stories.tsx | 69 +- .../src/InputSegment/InputSegment.stories.tsx | 19 +- 3 files changed, 297 insertions(+), 668 deletions(-) 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 915cd58261..14c8ed0fee 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,8 +1,6 @@ -// @ts-nocheck -// TODO: fix this 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 { getValueFormatter } from '@leafygreen-ui/input-box'; @@ -10,59 +8,88 @@ 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 { DateInputBoxProvider } from '../DateInputBox/DateInputBoxContext'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; -const renderSegment = ( - props?: Partial, - ctx?: Partial, - providerProps?: Partial>, -) => { - const defaultProps = { +const renderSegment = ({ + props = {}, + sharedDatePickerProviderProps = {}, + inputBoxProviderProps = {}, +}: { + props?: Partial; + sharedDatePickerProviderProps?: Partial; + inputBoxProviderProps?: Partial>; +}) => { + const defaultSegmentProps = { value: '', onChange: () => {}, //TODO: remove this segment: 'day' as DateSegment, }; - const defaultProviderProps = { + const defaultInputBoxProviderProps = { onChange: () => {}, onBlur: () => {}, + disabled: false, + size: Size.Default, + segmentRefs: segmentRefsMock, + segments: { + day: '', + month: '', + year: '', + }, }; const result = render( - + - + + + , ); - const rerenderSegment = ( - newProps: Partial, - newProviderProps?: Partial>, - ) => + const rerenderSegment = ({ + newProps = {}, + newInputBoxProviderProps = {}, + }: { + newProps?: Partial; + newInputBoxProviderProps?: Partial>; + }) => result.rerender( - + - + + + , , @@ -87,248 +114,134 @@ 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({}, undefined, { - onChange: onChangeHandler, - }); - - userEvent.type(input, '8'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '8' }), - ); - }); - - test('allows zero character', () => { - const { input } = renderSegment({}, undefined, { - onChange: onChangeHandler, - }); - - userEvent.type(input, '0'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '0' }), - ); - }); - - test('allows typing leading zeroes', async () => { - const { input, rerenderSegment } = renderSegment({}, undefined, { - onChange: onChangeHandler, - }); - - userEvent.type(input, '0'); - rerenderSegment({ value: '0' }, { onChange: onChangeHandler }); - - userEvent.type(input, '2'); - await waitFor(() => { - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '02' }), - ); - }); - }); - - test('does not allow non-number characters', () => { - const { input } = renderSegment({}, undefined, { - 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, - }, - undefined, - { - 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, + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '2023' }, }, - undefined, - { - onChange: onChangeHandler, + }); + rerenderSegment({ + newInputBoxProviderProps: { + segments: { day: '', month: '', year: '1993' }, }, - ); - - 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({}, undefined, { - 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - userEvent.type(input, '{backspace}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '' }), - ); - }); - }); - describe('Arrow Keys', () => { describe('day input', () => { const formatter = getValueFormatter(charsPerSegment['day']); describe('Up arrow', () => { - test('calls handler with value +1', () => { - const { input } = renderSegment( - { - segment: 'day', - // onChange: onChangeHandler, - value: formatter(15), - }, - undefined, - { + test('calls handler with value +1 if value is less than max', () => { + const { input } = renderSegment({ + 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', () => { - const { input } = renderSegment( - { - segment: 'day', - // onChange: onChangeHandler, - value: '', - }, - undefined, - { + test('calls handler with min if value is undefined', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -336,79 +249,35 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `min` value if value exceeds `max`', () => { - const { input } = renderSegment( - { - segment: 'day', - // onChange: onChangeHandler, - value: formatter(defaultMax['day']), - }, - undefined, - { + test('rolls value over to min value if value exceeds `max`', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { + day: formatter(defaultMax['day']), + month: '', + year: '', + }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: formatter(5) }), - ); - }); }); describe('Down arrow', () => { - test('calls handler with value -1', () => { - const { input } = renderSegment( - { - segment: 'day', - // onChange: onChangeHandler, - value: formatter(15), - }, - undefined, - { + test('calls handler with value -1 if value is greater than min', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: formatter(15), month: '', year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -418,18 +287,14 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `max` if initially undefined', () => { - const { input } = renderSegment( - { - segment: 'day', - // onChange: onChangeHandler, - value: '', - }, - undefined, - { + test('calls handler with max if value is undefined', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -437,64 +302,24 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `max` value if value exceeds `min`', () => { - const { input } = renderSegment( - { - segment: 'day', - // onChange: onChangeHandler, - value: formatter(defaultMin['day']), - }, - undefined, - { + test('rolls value over to max value if value is less than min', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + inputBoxProviderProps: { + segments: { + day: formatter(defaultMin['day']), + month: '', + year: '', + }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: formatter(25) }), - ); - }); }); }); @@ -502,18 +327,14 @@ describe('packages/date-picker/shared/date-input-segment', () => { const formatter = getValueFormatter(charsPerSegment['month']); describe('Up arrow', () => { - test('calls handler with value +1', () => { - const { input } = renderSegment( - { - segment: 'month', - // onChange: onChangeHandler, - value: formatter(6), - }, - undefined, - { + test('calls handler with value +1 if value is less than max', () => { + const { input } = renderSegment({ + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: formatter(6), year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -523,18 +344,14 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `min` if initially undefined', () => { - const { input } = renderSegment( - { - segment: 'month', - // onChange: onChangeHandler, - value: '', - }, - undefined, - { + test('calls handler with min if value is undefined', () => { + const { input } = renderSegment({ + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -544,18 +361,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `min` value if value exceeds `max`', () => { - const { input } = renderSegment( - { - segment: 'month', - // onChange: onChangeHandler, - value: formatter(defaultMax['month']), - }, - undefined, - { + test('rolls value over to min value if value exceeds max', () => { + const { input } = renderSegment({ + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { + day: '', + month: formatter(defaultMax['month']), + year: '', + }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -564,65 +381,17 @@ 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(5), - }), - ); - }); }); describe('Down arrow', () => { - test('calls handler with value -1', () => { - const { input } = renderSegment( - { - segment: 'month', - // onChange: onChangeHandler, - value: formatter(6), - }, - undefined, - { + test('calls handler with value -1 if value is greater than min', () => { + const { input } = renderSegment({ + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: formatter(6), year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -632,18 +401,14 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `max` if initially undefined', () => { - const { input } = renderSegment( - { - segment: 'month', - // onChange: onChangeHandler, - value: '', - }, - undefined, - { + test('calls handler with max if value is undefined', () => { + const { input } = renderSegment({ + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -653,18 +418,18 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('rolls value over to default `max` value if value exceeds `min`', () => { - const { input } = renderSegment( - { - segment: 'month', - // onChange: onChangeHandler, - value: formatter(defaultMin['month']), - }, - undefined, - { + test('rolls value over to max value if value is less than min', () => { + const { input } = renderSegment({ + props: { segment: 'month' }, + inputBoxProviderProps: { + segments: { + day: '', + month: formatter(defaultMin['month']), + year: '', + }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -673,50 +438,6 @@ 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(10), - }), - ); - }); }); }); @@ -724,18 +445,14 @@ describe('packages/date-picker/shared/date-input-segment', () => { const formatter = getValueFormatter(charsPerSegment['year']); describe('Up arrow', () => { - test('calls handler with value +1', () => { - const { input } = renderSegment( - { - segment: 'year', - // onChange: onChangeHandler, - value: formatter(1993), - }, - undefined, - { + test('calls handler with value +1 if value is less than max', () => { + const { input } = renderSegment({ + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: formatter(1993) }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ @@ -744,18 +461,14 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `min` if initially undefined', () => { - const { input } = renderSegment( - { - segment: 'year', - // onChange: onChangeHandler, - value: '', - }, - undefined, - { + test('calls handler with min if value is undefined', () => { + const { input } = renderSegment({ + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -766,17 +479,17 @@ 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']), - }, - undefined, - { + const { input } = renderSegment({ + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { + day: '', + month: '', + year: formatter(defaultMax['year']), + }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -785,43 +498,17 @@ 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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(1969), - }), - ); - }); }); describe('Down arrow', () => { - test('calls handler with value -1', () => { - const { input } = renderSegment( - { - segment: 'year', - // onChange: onChangeHandler, - value: formatter(1993), - }, - undefined, - { + test('calls handler with value -1 if value is greater than min', () => { + const { input } = renderSegment({ + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: formatter(1993) }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ @@ -830,18 +517,14 @@ describe('packages/date-picker/shared/date-input-segment', () => { ); }); - test('calls handler with default `max` if initially undefined', () => { - const { input } = renderSegment( - { - segment: 'year', - // onChange: onChangeHandler, - value: '', - }, - undefined, - { + test('calls handler with max if value is undefined', () => { + const { input } = renderSegment({ + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { day: '', month: '', year: '' }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -852,17 +535,17 @@ 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']), - }, - undefined, - { + const { input } = renderSegment({ + props: { segment: 'year' }, + inputBoxProviderProps: { + segments: { + day: '', + month: '', + year: formatter(defaultMin['year']), + }, onChange: onChangeHandler, }, - ); + }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -871,110 +554,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, - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - 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, - }, - undefined, - { - 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', - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - 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, - }, - undefined, - { - 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', - }, - undefined, - { - onChange: onChangeHandler, - }, - ); - - 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 4fec430ce9..241a751c0e 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 @@ -1,25 +1,50 @@ -// @ts-nocheck -// TODO: fix this - 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 '../DateInputBox/DateInputBoxContext'; import { DateInputSegment } from './DateInputSegment'; const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => ( - + + {}} + onBlur={() => {}} + segmentRefs={useSegmentRefs()} + segments={ctx?.args.segments} + size={Size.Default} + disabled={false} + > + + + ); @@ -66,17 +91,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/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 6d521736cf..3d837d9c4d 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { storybookExcludedControlParams, StoryMetaType, @@ -19,7 +19,7 @@ import { segmentsMock, } from '../testutils/testutils.mocks'; -import { InputSegment } from '.'; +import { InputSegment, InputSegmentChangeEventHandler } from '.'; interface InputSegmentStoryProps { size: Size; @@ -124,14 +124,23 @@ export const LiveExample: StoryFn = ( props, context: any, ) => { + const [segments, setSegments] = useState(segmentsMock); + + const handleChange: InputSegmentChangeEventHandler< + SegmentObjMock, + string + > = ({ segment, value }) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; + return ( {}} + onChange={handleChange} onBlur={() => {}} segmentRefs={segmentRefsMock} - segments={segmentsMock} + segments={segments} disabled={false} size={context?.args?.size || Size.Default} > @@ -142,7 +151,5 @@ export const LiveExample: StoryFn = ( export const Generated = () => {}; -// TODO: save this and then update DatePicker. Ask team about tests for date picker. // TODO: add min/max tests // TODO: documentation -// TODO: PR comments From 2141a34ee93cdd3c23879af9df88ec6fb2ae5174 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 4 Nov 2025 14:44:06 -0500 Subject: [PATCH 42/56] refactor(date-picker): reorganize DateInputBox context imports and remove unused DateInputBoxContext file --- .../DateInput/DateInputBox/DateInputBox.tsx | 2 +- .../DateInputBoxContext.spec.tsx | 38 +++++++++++++++++++ .../DateInputBoxContext.tsx | 26 ++++++++----- .../DateInputBoxContext.types.ts | 15 ++++++++ .../DateInput/DateInputBoxContext/index.ts | 5 +++ .../DateInputSegment.spec.tsx | 2 +- .../DateInputSegment.stories.tsx | 2 +- .../DateInputSegment/DateInputSegment.tsx | 2 +- .../InputBoxContext/InputBoxContext.spec.tsx | 4 +- .../src/InputBoxContext/InputBoxContext.tsx | 2 +- 10 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.spec.tsx rename packages/date-picker/src/shared/components/DateInput/{DateInputBox => DateInputBoxContext}/DateInputBoxContext.tsx (63%) create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts create mode 100644 packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/index.ts 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 f292008693..bb74e9813d 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -25,7 +25,7 @@ import { import { DateInputSegment } from '../DateInputSegment'; import { DateInputBoxProps } from './DateInputBox.types'; -import { DateInputBoxProvider } from './DateInputBoxContext'; +import { DateInputBoxProvider } from '../DateInputBoxContext'; /** * Renders a styled date input with appropriate segment order & separator characters. 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..42215ae5e5 --- /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 { + useDateInputBoxContext, + DateInputBoxProvider, +} 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/DateInputBox/DateInputBoxContext.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx similarity index 63% rename from packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx rename to packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx index 27f16b84f4..eabf74255f 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBoxContext.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx @@ -1,18 +1,19 @@ import React, { createContext, PropsWithChildren, useContext } from 'react'; - -import { DateType } from '@leafygreen-ui/date-utils'; - -export interface DateInputBoxContextType { - value?: DateType; -} - -export interface DateInputBoxProviderProps { - value?: DateType; -} +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, @@ -24,6 +25,11 @@ export const DateInputBoxProvider = ({ ); }; +/** + * Hook to access the DateInputBoxContext + * + * Depends on {@link DateInputBoxContextType} + */ export const useDateInputBoxContext = () => { const context = useContext(DateInputBoxContext); 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 14c8ed0fee..855b5bfd1e 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 @@ -17,7 +17,7 @@ import { } from '../../../context'; import { segmentRefsMock } from '../../../testutils'; import { DateSegment } from '../../../types'; -import { DateInputBoxProvider } from '../DateInputBox/DateInputBoxContext'; +import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; 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 241a751c0e..ee2db78455 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 @@ -16,7 +16,7 @@ import { } from '../../../context'; import { useSegmentRefs } from '../../../hooks'; import { DateSegment } from '../../../types'; -import { DateInputBoxProvider } from '../DateInputBox/DateInputBoxContext'; +import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegment } from './DateInputSegment'; 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 f51dabee90..6d07de36c3 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -11,7 +11,7 @@ import { getMaxSegmentValue, getMinSegmentValue, } from '../../../utils'; -import { useDateInputBoxContext } from '../DateInputBox/DateInputBoxContext'; +import { useDateInputBoxContext } from '../DateInputBoxContext'; import { segmentWidthStyles } from './DateInputSegment.styles'; import { DateInputSegmentProps } from './DateInputSegment.types'; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index c67eed44dd..9ff76d1558 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -29,12 +29,12 @@ describe('InputBoxContext', () => { if (isReact17()) { const { result } = renderHook(() => useInputBoxContext()); expect(result.error.message).toEqual( - 'useInputBoxContext must be used within an InputBoxProvider', + 'useInputBoxContext must be used within a InputBoxProvider', ); } else { expect(() => renderHook(() => useInputBoxContext()), - ).toThrow('useInputBoxContext must be used within an InputBoxProvider'); + ).toThrow('useInputBoxContext must be used within a InputBoxProvider'); } }); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 7ace9c92f9..6eeb63eaa3 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -99,7 +99,7 @@ export const useInputBoxContext = () => { if (!context) { throw new Error( - 'useInputBoxContext must be used within an InputBoxProvider', + 'useInputBoxContext must be used within a InputBoxProvider', ); } From 8e4ada0f2bfbdb8195b86bd1385dec6aee988cad Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 4 Nov 2025 14:49:22 -0500 Subject: [PATCH 43/56] docs(input-box): expand README with detailed component descriptions, features, and usage examples for InputBox, InputBoxContext, and InputSegment --- packages/input-box/README.md | 102 ++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 67bcec1d73..1c52aa17f9 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -1,4 +1,102 @@ # Internal Input Box -An internal component intended to be used by any date or time component. -I.e. `DatePicker`, `TimeInput` etc. +An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. + +This package provides three main components that work together to create segmented input experiences: + +## Components + +### InputBox + +A generic controlled input box component that renders an input with multiple segments separated by literals. + +**Key Features:** + +- **Auto-format**: Automatically formats segment values when they reach an explicit state (e.g., when a day value becomes unambiguous) +- **Auto-focus**: Automatically advances focus to the next segment when the current segment is complete +- **Keyboard navigation**: Handles left/right arrow key navigation between segments +- **Segment management**: Renders segments and separators based on `formatParts` (from `Intl.DateTimeFormat`) + +The component handles high-level interactions like moving between segments, while delegating segment-specific logic to the `InputSegment` component. + +### InputBoxContext + +A React context provider that shares common state and handlers across all input segments. + +**Provides:** + +- `charsPerSegment`: Maximum character length for each segment +- `segments`: Current values for all segments +- `segmentRefs`: References to each segment's input element +- `segmentEnum`: Enumeration mapping segment names to their values +- `onChange`: Handler for segment value changes +- `onBlur`: Handler for segment blur events +- `labelledBy`: ID of the labelling element for accessibility +- `size`: Size of the input components +- `disabled`: Disabled state of the input + +This context allows `InputSegment` components to access shared configuration and state without prop drilling, while maintaining type safety through generics. + +### InputSegment + +A controlled input segment component that renders a single input field within an `InputBox`. + +**Key Features:** + +- **Up/down arrow key navigation**: Increment/decrement segment values using arrow keys +- **Value validation**: Validates input against configurable min/max ranges +- **Auto-formatting**: Formats values with leading zeros based on character length +- **Rollover support**: Optionally rolls over values (e.g., 31 → 1 for days, or stops at boundaries) +- **Keyboard interaction**: Handles backspace and space keys to clear values +- **onChange/onBlur events**: Fires custom change events with segment metadata + +**Props:** + +- `segment`: The segment identifier (e.g., 'day', 'month', 'year') +- `min`/`max`: Valid range for the segment value +- `step`: Increment/decrement step for arrow keys (default: 1) +- `shouldRollover`: Whether values should wrap around at boundaries +- `shouldSkipValidation`: Skips validation for segments that allow extended ranges + +## Usage + +Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for a complete implementation example. + +**Basic pattern:** + +```tsx +import { InputBox, InputBoxProvider } from '@leafygreen-ui/input-box'; + +// 1. Create a custom segment component +const MySegment = ({ segment, ...props }) => ( + +); + +// 2. Use InputBox with your segments +; +``` + +## Installation + +This is an internal package and should only be used by other LeafyGreen UI components. + +```bash +pnpm add @leafygreen-ui/input-box +``` From cb9ec7fea81b8f2450b929421eb253f1c96467f4 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 4 Nov 2025 15:51:09 -0500 Subject: [PATCH 44/56] docs(input-box): update README to reflect changes in component structure and props for InputBox and InputSegment --- packages/input-box/README.md | 40 +++++++++++++++++------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 1c52aa17f9..145b54c891 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -2,7 +2,7 @@ An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. -This package provides three main components that work together to create segmented input experiences: +This package provides two main components that work together to create segmented input experiences: ## Components @@ -17,25 +17,24 @@ A generic controlled input box component that renders an input with multiple seg - **Keyboard navigation**: Handles left/right arrow key navigation between segments - **Segment management**: Renders segments and separators based on `formatParts` (from `Intl.DateTimeFormat`) -The component handles high-level interactions like moving between segments, while delegating segment-specific logic to the `InputSegment` component. +The component handles high-level interactions like moving between segments, while delegating segment-specific logic to the `InputSegment` component. Internally, it uses `InputBoxContext` to share state and handlers across all segments. -### InputBoxContext - -A React context provider that shares common state and handlers across all input segments. - -**Provides:** +**Props:** -- `charsPerSegment`: Maximum character length for each segment -- `segments`: Current values for all segments -- `segmentRefs`: References to each segment's input element -- `segmentEnum`: Enumeration mapping segment names to their values -- `onChange`: Handler for segment value changes -- `onBlur`: Handler for segment blur events +- `segments`: Record of current segment values (e.g., `{ day: '01', month: '02', year: '2025' }`) +- `setSegment`: Function to update a segment value `(segment, value) => void` +- `segmentEnum`: Enumerable object mapping segment names to values (e.g., `{ Day: 'day', Month: 'month', Year: 'year' }`) +- `segmentComponent`: React component to render each segment (must accept `InputSegmentComponentProps`) +- `formatParts`: Array of `Intl.DateTimeFormatPart` defining segment order and separators +- `charsPerSegment`: Record of maximum characters per segment (e.g., `{ day: 2, month: 2, year: 4 }`) +- `segmentRefs`: Record mapping segment names to their input refs +- `segmentRules`: Record of validation rules per segment with `maxChars` and `minExplicitValue` +- `minValues`: Record of minimum values per segment (e.g., `{ day: 1, month: 1, year: 1970 }`) +- `disabled`: Whether the input is disabled +- `size`: Size of the input (`Size.Default`, `Size.Small`, or `Size.XSmall`) +- `onSegmentChange`: Optional callback fired when any segment changes - `labelledBy`: ID of the labelling element for accessibility -- `size`: Size of the input components -- `disabled`: Disabled state of the input - -This context allows `InputSegment` components to access shared configuration and state without prop drilling, while maintaining type safety through generics. +- Standard div props are also supported (className, onKeyDown, etc.) ### InputSegment @@ -57,11 +56,10 @@ A controlled input segment component that renders a single input field within an - `step`: Increment/decrement step for arrow keys (default: 1) - `shouldRollover`: Whether values should wrap around at boundaries - `shouldSkipValidation`: Skips validation for segments that allow extended ranges +- native input props are passed through to the input element ## Usage -Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for a complete implementation example. - **Basic pattern:** ```tsx @@ -93,9 +91,9 @@ const MySegment = ({ segment, ...props }) => ( />; ``` -## Installation +Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for a implementation example. -This is an internal package and should only be used by other LeafyGreen UI components. +## Installation ```bash pnpm add @leafygreen-ui/input-box From 015cf67471d5a75f2e6debba2d80f649a28fbf6d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 4 Nov 2025 15:56:10 -0500 Subject: [PATCH 45/56] refactor(input-box): rename `shouldRollover` to `shouldWrap` for clarity and update related documentation and tests --- .../DateInput/DateInputSegment/DateInputSegment.tsx | 4 ++-- packages/input-box/README.md | 2 +- .../input-box/src/InputSegment/InputSegment.spec.tsx | 10 +++++----- .../src/InputSegment/InputSegment.stories.tsx | 7 ++----- packages/input-box/src/InputSegment/InputSegment.tsx | 6 +++--- .../input-box/src/InputSegment/InputSegment.types.ts | 4 ++-- packages/input-box/src/testutils/index.tsx | 4 ++-- 7 files changed, 17 insertions(+), 20 deletions(-) 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 6d07de36c3..4108142568 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -45,7 +45,7 @@ export const DateInputSegment = React.forwardRef< const autoComplete = getAutoComplete(autoCompleteProp, segment); - const shouldRollover = !([DateSegment.Year] as Array).includes( + const shouldWrap = !([DateSegment.Year] as Array).includes( segment, ); @@ -64,7 +64,7 @@ export const DateInputSegment = React.forwardRef< autoComplete={autoComplete} className={cx(segmentWidthStyles[segment])} data-testid="lg-date_picker_input-segment" - shouldRollover={shouldRollover} + shouldWrap={shouldWrap} shouldSkipValidation={shouldSkipValidation} step={1} /> diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 145b54c891..e09844d3e7 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -54,7 +54,7 @@ A controlled input segment component that renders a single input field within an - `segment`: The segment identifier (e.g., 'day', 'month', 'year') - `min`/`max`: Valid range for the segment value - `step`: Increment/decrement step for arrow keys (default: 1) -- `shouldRollover`: Whether values should wrap around at boundaries +- `shouldWrap`: Whether values should wrap around at min/max boundaries - `shouldSkipValidation`: Skips validation for segments that allow extended ranges - native input props are passed through to the input element diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 7b6b208b46..b485639513 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -285,13 +285,13 @@ describe('packages/input-segment', () => { ); }); - test('does not rollover if `shouldNotRollover` is true', () => { + test('does not wrap if `shouldWrap` is false', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { shouldRollover: false }, + props: { shouldWrap: false }, providerProps: { onChange: onChangeHandler, segments: { @@ -395,13 +395,13 @@ describe('packages/input-segment', () => { ); }); - test('does not rollover if `shouldNotRollover` is true', () => { + test('does not wrap if `shouldWrap` is false', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { shouldRollover: false }, + props: { shouldWrap: false }, providerProps: { onChange: onChangeHandler, segments: { @@ -547,7 +547,7 @@ describe('packages/input-segment', () => { min={1} max={31} step={1} - shouldRollover={true} + shouldWrap={true} shouldSkipValidation={false} placeholder="12" className="test" diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 3d837d9c4d..ba9b0223c7 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -42,7 +42,7 @@ const meta: StoryMetaType = { max: defaultMaxMock[SegmentObjMock.Day], size: Size.Default, placeholder: defaultPlaceholderMock[SegmentObjMock.Day], - shouldRollover: true, + shouldWrap: true, step: 1, darkMode: false, }, @@ -67,7 +67,7 @@ const meta: StoryMetaType = { 'segmentEnum', 'min', 'max', - 'shouldRollover', + 'shouldWrap', 'shouldSkipValidation', 'step', 'placeholder', @@ -150,6 +150,3 @@ export const LiveExample: StoryFn = ( }; export const Generated = () => {}; - -// TODO: add min/max tests -// TODO: documentation diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index f0ba05824f..c16590defb 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -42,8 +42,8 @@ const InputSegmentWithRef = ( onChange: onChangeProp, onBlur: onBlurProp, step = 1, - shouldRollover = true, // TODO: shouldRollover - shouldSkipValidation = false, // TODO: shouldSkipValidation + shouldWrap = true, + shouldSkipValidation = false, ...rest }: InputSegmentProps, fwdRef: ForwardedRef, @@ -132,7 +132,7 @@ const InputSegmentWithRef = ( min, max, step, - shouldNotRollover: !shouldRollover, + shouldNotRollover: !shouldWrap, }); const valueString = formatter(newValue); diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 97cfd60c7f..21dcc9fd9c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -66,11 +66,11 @@ export interface InputSegmentProps step?: number; /** - * Whether the segment should rollover + * Whether the segment should wrap at min/max boundaries * * @default true */ - shouldRollover?: boolean; + shouldWrap?: boolean; /** * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 2b0ff8a25b..2be4f0d516 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -48,7 +48,7 @@ export const InputSegmentWrapper = ({ data-testid={`input-segment-${segment}`} className={segmentWidthStyles[segment]} shouldSkipValidation={segment === SegmentObjMock.Year} - shouldRollover={segment !== SegmentObjMock.Year} + shouldWrap={segment !== SegmentObjMock.Year} placeholder={defaultPlaceholderMock[segment]} /> ); @@ -229,7 +229,7 @@ const defaultSegmentProps: InputSegmentProps = { segment: 'day', min: defaultMinMock['day'], max: defaultMaxMock['day'], - shouldRollover: true, + shouldWrap: true, placeholder: defaultPlaceholderMock['day'], // @ts-expect-error - data-testid ['data-testid']: 'lg-input-segment', From 4583b57e59fb31955d9613281a34fa21e0e3c79a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 4 Nov 2025 16:51:31 -0500 Subject: [PATCH 46/56] refactor(date-picker, input-box): reorganize imports and enhance prop handling in DateInputBox and InputBox components for improved clarity and functionality --- .../DateInput/DateInputBox/DateInputBox.tsx | 2 +- .../DateInputBoxContext.spec.tsx | 2 +- .../DateInputBoxContext.tsx | 1 + packages/input-box/src/InputBox.stories.tsx | 7 +- .../input-box/src/InputBox/InputBox.spec.tsx | 61 ++++++----- packages/input-box/src/testutils/index.tsx | 100 +++++++----------- 6 files changed, 77 insertions(+), 96 deletions(-) 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 bb74e9813d..9c6155fbcc 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -22,10 +22,10 @@ import { isEverySegmentValueExplicit, newDateFromSegments, } from '../../../utils'; +import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegment } from '../DateInputSegment'; import { DateInputBoxProps } from './DateInputBox.types'; -import { DateInputBoxProvider } from '../DateInputBoxContext'; /** * Renders a styled date input with appropriate segment order & separator characters. 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 index 42215ae5e5..2e382cc102 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.spec.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.spec.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; import { - useDateInputBoxContext, DateInputBoxProvider, + useDateInputBoxContext, } from './DateInputBoxContext'; describe('DateInputBoxContext', () => { diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx index eabf74255f..50199b4158 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, PropsWithChildren, useContext } from 'react'; + import { DateInputBoxContextType, DateInputBoxProviderProps, diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 2fbfa85033..5b4ca6dd66 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -8,7 +8,8 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; -import { InputBox } from './InputBox'; +import { SegmentObjMock } from './testutils/testutils.mocks'; +import { InputBox, InputBoxProps } from './InputBox'; import { InputBoxWithState } from './testutils'; const meta: StoryMetaType = { @@ -47,5 +48,7 @@ const meta: StoryMetaType = { export default meta; export const LiveExample: StoryFn = props => { - return ; + return ( + >)} /> + ); }; diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index e9c04b1ad5..f982b6ce64 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -5,11 +5,7 @@ import userEvent from '@testing-library/user-event'; import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; -import { - InputSegmentWrapper, - renderInputBox, - renderInputBoxWithState, -} from '../testutils'; +import { InputSegmentWrapper, renderInputBox } from '../testutils'; import { charsPerSegmentMock, defaultMinMock, @@ -42,7 +38,9 @@ describe('packages/input-box', () => { }); test('renders filled segments when a value is passed', () => { - const { dayInput, monthInput, yearInput } = renderInputBox({}); + const { dayInput, monthInput, yearInput } = renderInputBox({ + segments: { day: '02', month: '02', year: '2025' }, + }); expect(dayInput.value).toBe('02'); expect(monthInput.value).toBe('02'); @@ -54,13 +52,20 @@ describe('packages/input-box', () => { describe('rerendering', () => { test('with new value updates the segments', () => { + const setSegment = jest.fn(); const { rerenderInputBox, getDayInput, getMonthInput, getYearInput } = - renderInputBox({}); + renderInputBox({ + segments: { day: '02', month: '02', year: '2025' }, + setSegment, + }); expect(getDayInput().value).toBe('02'); expect(getMonthInput().value).toBe('02'); expect(getYearInput().value).toBe('2025'); - rerenderInputBox({ segments: { day: '26', month: '09', year: '1993' } }); + rerenderInputBox({ + segments: { day: '26', month: '09', year: '1993' }, + setSegment, + }); expect(getDayInput().value).toBe('26'); expect(getMonthInput().value).toBe('09'); expect(getYearInput().value).toBe('1993'); @@ -122,7 +127,7 @@ describe('packages/input-box', () => { describe('auto-focus', () => { test('focuses the next segment when an explicit value is entered', () => { - const { dayInput, monthInput } = renderInputBoxWithState({}); + const { dayInput, monthInput } = renderInputBox({}); userEvent.type(monthInput, '02'); expect(dayInput).toHaveFocus(); @@ -130,21 +135,21 @@ describe('packages/input-box', () => { }); test('focus remains in the current segment when an ambiguous value is entered', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '2'); expect(dayInput).toHaveFocus(); }); test('focuses the previous segment when a backspace is pressed and the current segment is empty', () => { - const { dayInput, monthInput } = renderInputBoxWithState({}); + const { dayInput, monthInput } = renderInputBox({}); userEvent.type(dayInput, '{backspace}'); expect(monthInput).toHaveFocus(); }); test('focus remains in the current segment when a backspace is pressed and the current segment is not empty', () => { - const { monthInput } = renderInputBoxWithState({}); + const { monthInput } = renderInputBox({}); userEvent.type(monthInput, '2'); userEvent.type(monthInput, '{backspace}'); @@ -154,7 +159,7 @@ describe('packages/input-box', () => { describe('Mouse interaction', () => { test('click on segment focuses it', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.click(dayInput); expect(dayInput).toHaveFocus(); }); @@ -162,7 +167,7 @@ describe('packages/input-box', () => { describe('Keyboard interaction', () => { test('Tab moves focus to next segment', () => { - const { dayInput, monthInput, yearInput } = renderInputBoxWithState({}); + const { dayInput, monthInput, yearInput } = renderInputBox({}); userEvent.click(monthInput); userEvent.tab(); expect(dayInput).toHaveFocus(); @@ -171,7 +176,7 @@ describe('packages/input-box', () => { }); test('Right arrow key moves focus to next segment', () => { - const { dayInput, monthInput, yearInput } = renderInputBoxWithState({}); + const { dayInput, monthInput, yearInput } = renderInputBox({}); userEvent.click(monthInput); userEvent.type(monthInput, '{arrowright}'); expect(dayInput).toHaveFocus(); @@ -180,7 +185,7 @@ describe('packages/input-box', () => { }); test('Left arrow key moves focus to previous segment', () => { - const { dayInput, monthInput, yearInput } = renderInputBoxWithState({}); + const { dayInput, monthInput, yearInput } = renderInputBox({}); userEvent.click(yearInput); userEvent.type(yearInput, '{arrowleft}'); expect(dayInput).toHaveFocus(); @@ -192,25 +197,25 @@ describe('packages/input-box', () => { describe('typing', () => { describe('explicit value', () => { test('updates the rendered segment value', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '26'); expect(dayInput.value).toBe('26'); }); test('segment value is immediately formatted', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '5'); expect(dayInput.value).toBe('05'); }); test('allows leading zeros', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '02'); expect(dayInput.value).toBe('02'); }); test('allows 00 as minimum value', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '00'); expect(dayInput.value).toBe('00'); }); @@ -218,26 +223,26 @@ describe('packages/input-box', () => { describe('ambiguous value', () => { test('segment value is not immediately formatted', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '2'); expect(dayInput.value).toBe('2'); }); test('value is formatted on segment blur', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '2'); userEvent.tab(); expect(dayInput.value).toBe('02'); }); test('allows leading zeros', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '0'); expect(dayInput.value).toBe('0'); }); test('allows backspace to delete the value', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '2'); userEvent.type(dayInput, '{backspace}'); expect(dayInput.value).toBe(''); @@ -246,14 +251,14 @@ describe('packages/input-box', () => { describe('onBlur', () => { test('returns no value with leading zero on blur', () => { - const { monthInput } = renderInputBoxWithState({}); + const { monthInput } = renderInputBox({}); userEvent.type(monthInput, '0'); userEvent.tab(); expect(monthInput.value).toBe(''); }); test('returns value with leading zero on blur', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '0'); userEvent.tab(); expect(dayInput.value).toBe('00'); @@ -261,13 +266,13 @@ describe('packages/input-box', () => { }); test('does not allow non-number characters', () => { - const { dayInput } = renderInputBoxWithState({}); + const { dayInput } = renderInputBox({}); userEvent.type(dayInput, 'aB$/'); expect(dayInput.value).toBe(''); }); test('backspace resets the input', () => { - const { dayInput, yearInput } = renderInputBoxWithState({}); + const { dayInput, yearInput } = renderInputBox({}); userEvent.type(dayInput, '21'); userEvent.type(dayInput, '{backspace}'); expect(dayInput.value).toBe(''); diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 2be4f0d516..5912743142 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -7,10 +7,7 @@ import { InputBox, InputBoxProps } from '../InputBox'; import { InputBoxProvider } from '../InputBoxContext'; import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext'; import { InputSegment } from '../InputSegment'; -import { - InputSegmentChangeEventHandler, - InputSegmentProps, -} from '../InputSegment/InputSegment.types'; +import { InputSegmentProps } from '../InputSegment/InputSegment.types'; import { charsPerSegmentMock, @@ -35,6 +32,11 @@ export const defaultProps: Partial> = { segmentRules: segmentRulesMock, }; +/** + * This component is used to render the InputSegment component for testing purposes. + * @param segment - The segment to render + * @returns + */ export const InputSegmentWrapper = ({ segment, }: { @@ -57,18 +59,18 @@ export const InputSegmentWrapper = ({ /** * This component is used to render the InputBox component for testing purposes. * Includes segment state management and a default renderSegment function. + * Props can override the internal state management. */ export const InputBoxWithState = ({ - onSegmentChange, - disabled = false, segments: segmentsProp = { day: '', month: '', year: '', }, -}: { - onSegmentChange?: InputSegmentChangeEventHandler; - disabled?: boolean; + setSegment: setSegmentProp, + disabled = false, + ...props +}: Partial> & { segments?: Record; }) => { const dayRef = React.useRef(null); @@ -83,50 +85,33 @@ export const InputBoxWithState = ({ const [segments, setSegments] = React.useState(segmentsProp); - const setSegment = (segment: SegmentObjMock, value: string) => { + const defaultSetSegment = (segment: SegmentObjMock, value: string) => { setSegments(prev => ({ ...prev, [segment]: value })); }; + // If setSegment is provided, use controlled mode with the provided segments + // Otherwise, use internal state management + const effectiveSegments = setSegmentProp ? segmentsProp : segments; + const effectiveSetSegment = setSegmentProp ?? defaultSetSegment; + return ( ); }; -interface RenderInputBoxWithStateReturnType { - dayInput: HTMLInputElement; - monthInput: HTMLInputElement; - yearInput: HTMLInputElement; -} - -export const renderInputBoxWithState = ({ - onSegmentChange, -}: { - onSegmentChange?: InputSegmentChangeEventHandler; -}): RenderResult & RenderInputBoxWithStateReturnType => { - const utils = render(); - - const dayInput = utils.getByTestId('input-segment-day') as HTMLInputElement; - const monthInput = utils.getByTestId( - 'input-segment-month', - ) as HTMLInputElement; - const yearInput = utils.getByTestId('input-segment-year') as HTMLInputElement; - - return { ...utils, dayInput, monthInput, yearInput }; -}; - interface RenderInputBoxReturnType { dayInput: HTMLInputElement; monthInput: HTMLInputElement; @@ -137,37 +122,15 @@ interface RenderInputBoxReturnType { getYearInput: () => HTMLInputElement; } +/** + * Renders InputBox with internal state management for testing purposes. + * Props can be passed to override the default state behavior. + */ export const renderInputBox = ({ ...props -}: Partial>): RenderResult & +}: Partial> = {}): RenderResult & RenderInputBoxReturnType => { - const mergedProps = { - ...defaultProps, - ...props, - } as InputBoxProps; - - const finalMergedProps = { - ...mergedProps, - segmentComponent: mergedProps.segmentComponent ?? InputSegmentWrapper, - } as InputBoxProps; - - const result = render(); - - const rerenderInputBox = ({ - ...props - }: Partial>) => { - const mergedProps = { - ...defaultProps, - ...props, - } as InputBoxProps; - - const finalMergedProps = { - ...mergedProps, - segmentComponent: mergedProps.segmentComponent ?? InputSegmentWrapper, - } as InputBoxProps; - - result.rerender(); - }; + const result = render(); const getDayInput = () => result.getByTestId('input-segment-day') as HTMLInputElement; @@ -176,6 +139,12 @@ export const renderInputBox = ({ const getYearInput = () => result.getByTestId('input-segment-year') as HTMLInputElement; + const rerenderInputBox = ( + newProps: Partial>, + ) => { + result.rerender(); + }; + return { ...result, rerenderInputBox, @@ -235,6 +204,9 @@ const defaultSegmentProps: InputSegmentProps = { ['data-testid']: 'lg-input-segment', }; +/** + * Renders the InputSegment component for testing purposes. + */ export const renderSegment = ({ props = {}, providerProps = {}, From d5527432b4a8b490693d3505c9f9f27874f87208 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 4 Nov 2025 17:01:23 -0500 Subject: [PATCH 47/56] test(input-box): add comprehensive tests for segment navigation and rendering behavior in InputBox component --- .../input-box/src/InputBox/InputBox.spec.tsx | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index f982b6ce64..989e5b980e 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -5,7 +5,11 @@ import userEvent from '@testing-library/user-event'; import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; -import { InputSegmentWrapper, renderInputBox } from '../testutils'; +import { + InputBoxWithState, + InputSegmentWrapper, + renderInputBox, +} from '../testutils'; import { charsPerSegmentMock, defaultMinMock, @@ -16,6 +20,7 @@ import { } from '../testutils/testutils.mocks'; import { InputBox } from './InputBox'; +import { render } from '@testing-library/react'; describe('packages/input-box', () => { describe('Rendering', () => { @@ -283,6 +288,111 @@ describe('packages/input-box', () => { }); }); + describe('Arrow keys with auto-advance', () => { + test('arrow up does not auto-advance to next segment', () => { + const { monthInput, dayInput } = renderInputBox({ + segments: { day: '', month: '05', year: '' }, + }); + + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowup}'); + expect(monthInput).toHaveFocus(); + expect(dayInput).not.toHaveFocus(); + }); + + test('arrow down does not auto-advance to next segment', () => { + const { monthInput, dayInput } = renderInputBox({ + segments: { day: '', month: '05', year: '' }, + }); + + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowdown}'); + expect(monthInput).toHaveFocus(); + expect(dayInput).not.toHaveFocus(); + }); + }); + + describe('Edge cases for segment navigation', () => { + test('does not auto-advance from the last segment', () => { + const { yearInput } = renderInputBox({ + segments: { day: '', month: '', year: '' }, + }); + + userEvent.click(yearInput); + userEvent.type(yearInput, '2025'); + expect(yearInput).toHaveFocus(); + }); + + test('arrow left from first segment keeps focus on first segment', () => { + const { monthInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('arrow right from last segment keeps focus on last segment', () => { + const { yearInput } = renderInputBox({}); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('backspace from first empty segment keeps focus on first segment', () => { + const { monthInput } = renderInputBox({ + segments: { day: '', month: '', year: '' }, + }); + + userEvent.click(monthInput); + userEvent.type(monthInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + }); + + describe('Format parts and literal separators', () => { + test('renders literal separators between segments', () => { + const { container } = renderInputBox({ + formatParts: [ + { type: 'month', value: '02' }, + { type: 'literal', value: '/' }, + { type: 'day', value: '02' }, + { type: 'literal', value: '/' }, + { type: 'year', value: '2025' }, + ], + }); + + const separators = container.querySelectorAll('span'); + expect(separators.length).toBeGreaterThanOrEqual(2); + expect(container.textContent).toContain('/'); + }); + + test('does not render non-segment parts as inputs', () => { + const { container } = render( + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs).toHaveLength(2); // Only month and day, not the literal + }); + }); + + describe('Disabled state', () => { + test('all segments are disabled when disabled prop is true', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + disabled: true, + }); + + expect(dayInput).toBeDisabled(); + expect(monthInput).toBeDisabled(); + expect(yearInput).toBeDisabled(); + }); + }); + /* eslint-disable jest/no-disabled-tests */ describe.skip('types behave as expected', () => { test('InputBox throws error when no required props are provided', () => { From 941e93c7e276d112199346b6dc259e9397cb88fb Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 11:33:50 -0500 Subject: [PATCH 48/56] fix(input-box, date-picker): address validation and formatting issues in InputBox and DateInputBox components; enhance tests for edge cases and input behavior --- .../DateInput/DateInputBox/DateInputBox.tsx | 2 +- .../input-box/src/InputBox/InputBox.spec.tsx | 45 +- packages/input-box/src/InputBox/InputBox.tsx | 7 + .../src/InputSegment/InputSegment.spec.tsx | 387 ++++++++++++++---- .../src/InputSegment/InputSegment.tsx | 6 + .../createExplicitSegmentValidator.ts | 13 +- .../getNewSegmentValueFromInputValue.ts | 9 + .../isValidValueForSegment.ts | 7 + 8 files changed, 386 insertions(+), 90 deletions(-) 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 9c6155fbcc..462b34c3e4 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -113,7 +113,7 @@ export const DateInputBox = React.forwardRef( disabled={disabled} segmentRules={dateSegmentRules} onSegmentChange={onSegmentChange} - minValues={defaultMin} + minValues={defaultMin} //TODO: this is incorrect, this should use the min/max utils labelledBy={labelledBy} segmentComponent={DateInputSegment} size={size} diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 989e5b980e..c419f47cb1 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -51,8 +51,6 @@ describe('packages/input-box', () => { expect(monthInput.value).toBe('02'); expect(yearInput.value).toBe('2025'); }); - - test.todo('does not render non-segment parts as inputs'); }); describe('rerendering', () => { @@ -199,6 +197,24 @@ describe('packages/input-box', () => { }); }); + describe('onBlur', () => { + test('returns no value with leading zero on blur', () => { + // min value is 1 + const { monthInput } = renderInputBox({}); + userEvent.type(monthInput, '0'); + userEvent.tab(); + expect(monthInput.value).toBe(''); + }); + + test('returns value with leading zero on blur', () => { + // min value is 0 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '0'); + userEvent.tab(); + expect(dayInput.value).toBe('00'); + }); + }); + describe('typing', () => { describe('explicit value', () => { test('updates the rendered segment value', () => { @@ -219,7 +235,7 @@ describe('packages/input-box', () => { expect(dayInput.value).toBe('02'); }); - test('allows 00 as minimum value', () => { + test('allows 00 as a valid value if min value is 0', () => { const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '00'); expect(dayInput.value).toBe('00'); @@ -254,19 +270,20 @@ describe('packages/input-box', () => { }); }); - describe('onBlur', () => { - test('returns no value with leading zero on blur', () => { - const { monthInput } = renderInputBox({}); - userEvent.type(monthInput, '0'); - userEvent.tab(); - expect(monthInput.value).toBe(''); + describe('min/max range', () => { + test('does not allow values outside max range', () => { + // max is 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '32'); + expect(dayInput.value).toBe('02'); }); - test('returns value with leading zero on blur', () => { - const { dayInput } = renderInputBox({}); - userEvent.type(dayInput, '0'); - userEvent.tab(); - expect(dayInput.value).toBe('00'); + test('allows values below min range', () => { + // min is 1. We still allow values below min range because the user can still type in the value and it will be formatted. It should still be displayed but an error message should be shown. + const { monthInput } = renderInputBox({}); + userEvent.type(monthInput, '2'); + // should be formatted to 02 since 2 is explicitly valid + expect(monthInput.value).toBe('02'); }); }); diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 5d7697518c..3b7bb2e76c 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -83,6 +83,13 @@ export const InputBoxWithRef = ( const changedViaArrowKeys = meta?.key === keyMap.ArrowDown || meta?.key === keyMap.ArrowUp; + console.log('🚨handleSegmentInputChange', { + segmentName, + segmentValue, + changedViaArrowKeys, + isExplicitSegmentValue: isExplicitSegmentValue(segmentName, segmentValue), + }); + // Auto-format the segment if it is explicit and was not changed via arrow-keys e.g. up/down arrows. if ( !changedViaArrowKeys && diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index b485639513..36291acc01 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -14,93 +14,36 @@ import { InputSegment, InputSegmentChangeEventHandler } from '.'; describe('packages/input-segment', () => { describe('aria attributes', () => { - describe.each(['day', 'month', 'year'])('%p', segment => { - test(`${segment} segment has aria-label`, () => { - const { input } = renderSegment({ - props: { segment: segment as SegmentObjMock }, - }); - expect(input).toHaveAttribute('aria-label', segment); + test(`segment has aria-label`, () => { + const { input } = renderSegment({ + props: { segment: 'day' }, }); + expect(input).toHaveAttribute('aria-label', 'day'); }); }); describe('rendering', () => { - describe('day segment', () => { - test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({}); - expect(input.value).toBe(''); - }); - - test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, - }); - expect(input.value).toBe('12'); - }); - - test('rerendering updates the value', () => { - const { getInput, rerenderSegment } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, - }); - - rerenderSegment({ - newProviderProps: { segments: { day: '08', month: '', year: '' } }, - }); - expect(getInput().value).toBe('08'); - }); + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({}); + expect(input.value).toBe(''); }); - describe('month segment', () => { - test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ props: setSegmentProps('month') }); - expect(input.value).toBe(''); - }); - - test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ - props: setSegmentProps('month'), - providerProps: { segments: { day: '', month: '26', year: '' } }, - }); - expect(input.value).toBe('26'); - }); - - test('rerendering updates the value', () => { - const { getInput, rerenderSegment } = renderSegment({ - props: setSegmentProps('month'), - providerProps: { segments: { day: '', month: '26', year: '' } }, - }); - - rerenderSegment({ - newProviderProps: { segments: { day: '', month: '08', year: '' } }, - }); - expect(getInput().value).toBe('08'); + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ + providerProps: { segments: { day: '12', month: '', year: '' } }, }); + expect(input.value).toBe('12'); }); - describe('year segment', () => { - test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ props: setSegmentProps('year') }); - expect(input.value).toBe(''); + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + providerProps: { segments: { day: '12', month: '', year: '' } }, }); - test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ - props: setSegmentProps('year'), - providerProps: { segments: { day: '', month: '', year: '2023' } }, - }); - expect(input.value).toBe('2023'); - }); - - test('rerendering updates the value', () => { - const { getInput, rerenderSegment } = renderSegment({ - props: setSegmentProps('year'), - providerProps: { segments: { day: '', month: '', year: '2023' } }, - }); - rerenderSegment({ - newProviderProps: { segments: { day: '', month: '', year: '1993' } }, - }); - expect(getInput().value).toBe('1993'); + rerenderSegment({ + newProviderProps: { segments: { day: '08', month: '', year: '' } }, }); + expect(getInput().value).toBe('08'); }); }); @@ -309,6 +252,48 @@ describe('packages/input-segment', () => { }), ); }); + + test('formats value with leading zero', () => { + const formatter = getValueFormatter( + charsPerSegmentMock['day'], + defaultMinMock['day'] === 0, + ); + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '06', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '07' }), + ); + }); + + test('formats values without leading zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '3', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '04' }), + ); + }); }); describe('Down arrow', () => { @@ -419,6 +404,44 @@ describe('packages/input-segment', () => { }), ); }); + + test('formats value with leading zero', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '06', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '05' }), + ); + }); + + test('formats values without leading zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '3', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '02' }), + ); + }); }); describe('Backspace', () => { @@ -530,6 +553,222 @@ describe('packages/input-segment', () => { }); }); + describe('onBlur handler', () => { + test('calls the custom onBlur prop when provided', () => { + const onBlurHandler = jest.fn(); + const { input } = renderSegment({ + props: { onBlur: onBlurHandler }, + }); + + input.focus(); + input.blur(); + + expect(onBlurHandler).toHaveBeenCalled(); + }); + + test('calls both context and prop onBlur handlers', () => { + const contextOnBlur = jest.fn(); + const propOnBlur = jest.fn(); + const { input } = renderSegment({ + props: { onBlur: propOnBlur }, + providerProps: { onBlur: contextOnBlur }, + }); + + input.focus(); + input.blur(); + + expect(contextOnBlur).toHaveBeenCalled(); + expect(propOnBlur).toHaveBeenCalled(); + }); + }); + + describe('custom onKeyDown handler', () => { + test('calls the custom onKeyDown prop when provided', () => { + const onKeyDownHandler = jest.fn(); + const { input } = renderSegment({ + props: { onKeyDown: onKeyDownHandler }, + }); + + userEvent.type(input, '5'); + + expect(onKeyDownHandler).toHaveBeenCalled(); + }); + + test('custom onKeyDown is called alongside internal handler', () => { + const onKeyDownHandler = jest.fn(); + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { onKeyDown: onKeyDownHandler }, + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{arrowup}'); + + expect(onKeyDownHandler).toHaveBeenCalled(); + expect(onChangeHandler).toHaveBeenCalled(); + }); + }); + + describe('disabled state', () => { + test('input is disabled when disabled context prop is true', () => { + const { input } = renderSegment({ + providerProps: { disabled: true }, + }); + + expect(input).toBeDisabled(); + }); + + test('does not call onChange when disabled and typed into', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { disabled: true, onChange: onChangeHandler }, + }); + + userEvent.type(input, '5'); + + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('shouldSkipValidation prop', () => { + test('allows values outside min/max range when shouldSkipValidation is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', shouldSkipValidation: true }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '9', month: '', year: '' }, + }, + }); + + userEvent.type(input, '9'); + + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'day', value: '99' }), + ); + }); + + test('does not allows values outside min/max range when shouldSkipValidation is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', shouldSkipValidation: false }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '9', month: '', year: '' }, + }, + }); + + userEvent.type(input, '9'); + + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('formats values without leading zeros when shouldSkipValidation is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldSkipValidation: true, + shouldWrap: false, + }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '0', month: '', year: '3' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'year', value: '0004' }), + ); + }); + }); + + // describe('custom onChange prop', () => { + // test('calls prop-level onChange in addition to context onChange', () => { + // const contextOnChange = jest.fn() as InputSegmentChangeEventHandler< + // SegmentObjMock, + // string + // >; + // const propOnChange = jest.fn(); + // const { input } = renderSegment({ + // props: { onChange: propOnChange }, + // providerProps: { onChange: contextOnChange }, + // }); + + // userEvent.type(input, '5'); + + // expect(contextOnChange).toHaveBeenCalled(); + // expect(propOnChange).toHaveBeenCalled(); + // }); + // }); + + // describe('accessibility attributes', () => { + // test('has role="spinbutton"', () => { + // const { input } = renderSegment({}); + // expect(input).toHaveAttribute('role', 'spinbutton'); + // }); + + // test('has correct data-segment attribute', () => { + // const { input } = renderSegment({ + // props: { segment: 'month' }, + // }); + // expect(input).toHaveAttribute('data-segment', 'month'); + // }); + + // test('has correct pattern attribute', () => { + // const { input } = renderSegment({ + // props: { segment: 'day' }, + // }); + // // day segment has 2 chars per segment + // expect(input).toHaveAttribute('pattern', '[0-9]{2}'); + // }); + + // test('has min and max attributes', () => { + // const { input } = renderSegment({ + // props: { segment: 'day' }, + // }); + // expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); + // expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); + // }); + + // test('has aria-live region that announces value changes', () => { + // const { container, rerenderSegment } = renderSegment({ + // props: { segment: 'day' }, + // providerProps: { segments: { day: '15', month: '', year: '' } }, + // }); + + // const liveRegion = container.querySelector('[aria-live="polite"]'); + // expect(liveRegion).toBeInTheDocument(); + // expect(liveRegion).toHaveTextContent('day 15'); + // }); + + // test('aria-live region is empty when value is empty', () => { + // const { container } = renderSegment({ + // props: { segment: 'day' }, + // }); + + // const liveRegion = container.querySelector('[aria-live="polite"]'); + // expect(liveRegion).toBeInTheDocument(); + // expect(liveRegion).toHaveTextContent(''); + // }); + // }); + /* eslint-disable jest/no-disabled-tests */ describe.skip('types behave as expected', () => { test('InputSegment throws error when no required props are provided', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index c16590defb..303f0dea40 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -88,6 +88,12 @@ const InputSegmentWithRef = ( shouldSkipValidation, ); + // console.log('😡newValue', { + // segment, + // value, + // newValue, + // }); + const hasValueChanged = newValue !== value; if (hasValueChanged) { diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 200d832632..f3d3e10120 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -28,6 +28,7 @@ export interface ExplicitSegmentRule { * const rules = { * day: { maxChars: 2, minExplicitValue: 1 }, * month: { maxChars: 2, minExplicitValue: 1 }, + * //TODO: need to pass in allowZero as an argument to isValidSegmentValue */ export function createExplicitSegmentValidator< T extends Record, @@ -35,8 +36,10 @@ export function createExplicitSegmentValidator< return (segment: T[keyof T], value: string): boolean => { if ( !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) - ) + ) { + console.log('‼️'); return false; + } const rule = rules[segment]; if (!rule) return false; @@ -46,6 +49,14 @@ export function createExplicitSegmentValidator< ? Number(value) >= rule.minExplicitValue : false; + console.log('🎃isExplicitSegmentValue', { + segment, + value, + isMaxLength, + meetsMinValue, + isExplicitSegmentValue: isMaxLength || meetsMinValue, + }); + return isMaxLength || meetsMinValue; }; } diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 0c1644a73e..b7300f9a80 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -72,6 +72,12 @@ export const getNewSegmentValueFromInputValue = < segmentEnum, ); + // console.log('💚isIncomingValueValid', { + // currentValue, + // incomingValue, + // isIncomingValueValid, + // }); + if (isIncomingValueValid || shouldSkipValidation) { const newValue = truncateStart(incomingValue, { length: charsPerSegment, @@ -82,5 +88,8 @@ export const getNewSegmentValueFromInputValue = < const typedChar = last(incomingValue.split('')); const newValue = typedChar === '0' ? '0' : typedChar ?? ''; + // console.log('💚💚is', { + // newValue, + // }); return newValue as V; }; diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts index 7a8df1593e..139846b155 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts @@ -45,5 +45,12 @@ export const isValidValueForSegment = ( const isInRange = inRange(Number(value), defaultMin, defaultMax + 1); + // console.log('👿isInRange', { + // value, + // defaultMin, + // defaultMax, + // isInRange, + // }); + return isValidSegmentAndValue && isInRange; }; From 491ae487c83b415ae936089899c6387a991db9bb Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 16:22:44 -0500 Subject: [PATCH 49/56] refactor(input-box, date-picker): remove defaultMin prop and enhance validation logic for segment inputs; update tests for improved coverage and clarity --- .../DateInput/DateInputBox/DateInputBox.tsx | 7 +- .../input-box/src/InputBox/InputBox.spec.tsx | 61 +++-- packages/input-box/src/InputBox/InputBox.tsx | 24 +- .../input-box/src/InputBox/InputBox.types.ts | 7 - .../src/InputSegment/InputSegment.spec.tsx | 223 +++++++++++------- .../src/InputSegment/InputSegment.tsx | 13 +- .../src/InputSegment/InputSegment.types.ts | 1 + packages/input-box/src/testutils/index.tsx | 1 - .../createExplicitSegmentValidator.ts | 21 +- 9 files changed, 203 insertions(+), 155 deletions(-) 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 462b34c3e4..8305e034d7 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -9,11 +9,7 @@ import { } from '@leafygreen-ui/date-utils'; import { InputBox } from '@leafygreen-ui/input-box'; -import { - charsPerSegment, - dateSegmentRules, - defaultMin, -} from '../../../constants'; +import { charsPerSegment, dateSegmentRules } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; import { useDateSegments } from '../../../hooks'; import { DateSegment, DateSegmentsState } from '../../../types'; @@ -113,7 +109,6 @@ export const DateInputBox = React.forwardRef( disabled={disabled} segmentRules={dateSegmentRules} onSegmentChange={onSegmentChange} - minValues={defaultMin} //TODO: this is incorrect, this should use the min/max utils labelledBy={labelledBy} segmentComponent={DateInputSegment} size={size} diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index c419f47cb1..ad3a13bb43 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { jest } from '@jest/globals'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Size } from '@leafygreen-ui/tokens'; @@ -12,7 +13,6 @@ import { } from '../testutils'; import { charsPerSegmentMock, - defaultMinMock, SegmentObjMock, segmentRefsMock, segmentRulesMock, @@ -20,7 +20,6 @@ import { } from '../testutils/testutils.mocks'; import { InputBox } from './InputBox'; -import { render } from '@testing-library/react'; describe('packages/input-box', () => { describe('Rendering', () => { @@ -90,7 +89,7 @@ describe('packages/input-box', () => { ); }); - test('is called when deleting from a single segment', () => { + test('is called when deleting from a segment', () => { const onSegmentChange = jest.fn>(); const { dayInput } = renderInputBox({ @@ -198,7 +197,7 @@ describe('packages/input-box', () => { }); describe('onBlur', () => { - test('returns no value with leading zero on blur', () => { + test('returns no value with leading zero if min value is not 0', () => { // min value is 1 const { monthInput } = renderInputBox({}); userEvent.type(monthInput, '0'); @@ -206,13 +205,37 @@ describe('packages/input-box', () => { expect(monthInput.value).toBe(''); }); - test('returns value with leading zero on blur', () => { + test('returns value with leading zero if min value is 0', () => { // min value is 0 const { dayInput } = renderInputBox({}); userEvent.type(dayInput, '0'); userEvent.tab(); expect(dayInput.value).toBe('00'); }); + + test('returns value with leading zero if value is explicit', () => { + const { dayInput } = renderInputBox({}); + // 0-31 + userEvent.type(dayInput, '4'); + userEvent.tab(); + expect(dayInput.value).toBe('04'); + }); + + test('returns value without if value is explicit and meets the character limit', () => { + const { dayInput } = renderInputBox({}); + // 0-31 + userEvent.type(dayInput, '29'); + userEvent.tab(); + expect(dayInput.value).toBe('29'); + }); + + test('returns value with leading zero if value is ambiguous', () => { + const { dayInput } = renderInputBox({}); + // 1-31 + userEvent.type(dayInput, '1'); // 1 can be 1 or 1n + userEvent.tab(); + expect(dayInput.value).toBe('01'); + }); }); describe('typing', () => { @@ -271,19 +294,22 @@ describe('packages/input-box', () => { }); describe('min/max range', () => { - test('does not allow values outside max range', () => { - // max is 31 - const { dayInput } = renderInputBox({}); - userEvent.type(dayInput, '32'); - expect(dayInput.value).toBe('02'); - }); + describe('does not allow values outside max range', () => { + test('and returns single digit value if it is ambiguous', () => { + // max is 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '32'); + // returns the last valid value + expect(dayInput.value).toBe('2'); + }); - test('allows values below min range', () => { - // min is 1. We still allow values below min range because the user can still type in the value and it will be formatted. It should still be displayed but an error message should be shown. - const { monthInput } = renderInputBox({}); - userEvent.type(monthInput, '2'); - // should be formatted to 02 since 2 is explicitly valid - expect(monthInput.value).toBe('02'); + test('and returns formatted value if it is explicit', () => { + // max is 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '34'); + // returns the last valid value + expect(dayInput.value).toBe('04'); + }); }); }); @@ -426,7 +452,6 @@ describe('packages/input-box', () => { setSegment={() => {}} charsPerSegment={charsPerSegmentMock} segmentRules={segmentRulesMock} - minValues={defaultMinMock} segmentComponent={InputSegmentWrapper} size={Size.Default} disabled={false} diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 3b7bb2e76c..f5f01aed49 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -46,7 +46,6 @@ export const InputBoxWithRef = ( segmentEnum, segmentRules, segmentComponent, - minValues, segments, size, ...rest @@ -64,10 +63,11 @@ export const InputBoxWithRef = ( const getFormattedSegmentValue = ( segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], segmentValue: string, + allowsZero: boolean, ): string => { const formatter = getValueFormatter( charsPerSegment[segmentName], - minValues[segmentName] === 0, + allowsZero, ); const formattedValue = formatter(segmentValue); return formattedValue; @@ -82,20 +82,19 @@ export const InputBoxWithRef = ( const { segment: segmentName, meta } = segmentChangeEvent; const changedViaArrowKeys = meta?.key === keyMap.ArrowDown || meta?.key === keyMap.ArrowUp; - - console.log('🚨handleSegmentInputChange', { - segmentName, - segmentValue, - changedViaArrowKeys, - isExplicitSegmentValue: isExplicitSegmentValue(segmentName, segmentValue), - }); + const minSegmentValue = meta?.min as number; + const allowsZero = minSegmentValue === 0; // Auto-format the segment if it is explicit and was not changed via arrow-keys e.g. up/down arrows. if ( !changedViaArrowKeys && - isExplicitSegmentValue(segmentName, segmentValue) + isExplicitSegmentValue(segmentName, segmentValue, allowsZero) ) { - segmentValue = getFormattedSegmentValue(segmentName, segmentValue); + segmentValue = getFormattedSegmentValue( + segmentName, + segmentValue, + allowsZero, + ); // Auto-advance focus (if possible) const nextSegmentName = getRelativeSegment('next', { @@ -118,11 +117,14 @@ export const InputBoxWithRef = ( const handleSegmentInputBlur: FocusEventHandler = e => { const segmentName = e.target.getAttribute('id'); const segmentValue = e.target.value; + const minValue = Number(e.target.getAttribute('min')); + const allowsZero = minValue === 0; if (isInputSegment(segmentName, segmentEnum)) { const formattedValue = getFormattedSegmentValue( segmentName, segmentValue, + allowsZero, ); setSegment(segmentName, formattedValue); } diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 4ade022650..ae5c3840ad 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -108,13 +108,6 @@ export interface InputBoxProps * */ segmentRules: Record; - /** - * An object that maps the segment names to their minimum values - * - * @example - * { day: 0, month: 1, year: 1970 } - */ - minValues: Record; /** * The component that renders a segment. When mapping over the formatParts, we will render the segment component for each part using this component. diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 36291acc01..d65a551ac8 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -20,6 +20,19 @@ describe('packages/input-segment', () => { }); expect(input).toHaveAttribute('aria-label', 'day'); }); + + test('has role="spinbutton"', () => { + const { input } = renderSegment({}); + expect(input).toHaveAttribute('role', 'spinbutton'); + }); + + test('has min and max attributes', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); + expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); + }); }); describe('rendering', () => { @@ -128,8 +141,6 @@ describe('packages/input-segment', () => { expect.objectContaining({ value: '4' }), ); }); - - // TODO: test min/max }); describe('keyboard events', () => { @@ -253,11 +264,29 @@ describe('packages/input-segment', () => { ); }); - test('formats value with leading zero', () => { - const formatter = getValueFormatter( - charsPerSegmentMock['day'], - defaultMinMock['day'] === 0, + test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldWrap: false, + }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '0', month: '', year: '3' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'year', value: '0004' }), ); + }); + + test('formats value with leading zero', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string @@ -405,6 +434,28 @@ describe('packages/input-segment', () => { ); }); + test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldWrap: false, + }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '0', month: '', year: '3' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'year', value: '0002' }), + ); + }); + test('formats value with leading zero', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, @@ -551,6 +602,70 @@ describe('packages/input-segment', () => { }); }); }); + + describe('min/max range', () => { + test('does not allow values outside max range', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // max is 31 + const { input } = renderSegment({ + providerProps: { + segments: { day: '3', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '2'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '2' }), + ); + }); + + test('allows values below min range', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // min is 1. We allow values below min range. + const { input } = renderSegment({ + props: { ...setSegmentProps('month') }, + providerProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '0'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '0' }), + ); + }); + + test('allows values above max range when skipValidation is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // max is 2038 + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldSkipValidation: true, + }, + providerProps: { + segments: { day: '', month: '', year: '203' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '9'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '2039' }), + ); + }); + }); }); describe('onBlur handler', () => { @@ -674,101 +789,27 @@ describe('packages/input-segment', () => { expect(onChangeHandler).not.toHaveBeenCalled(); }); + }); - test('formats values without leading zeros when shouldSkipValidation is true', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + describe('custom onChange prop', () => { + test('calls prop-level onChange in addition to context onChange', () => { + const contextOnChange = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; + const propOnChange = jest.fn(); const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldSkipValidation: true, - shouldWrap: false, - }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '0', month: '', year: '3' }, - }, + props: { onChange: propOnChange }, + providerProps: { onChange: contextOnChange }, }); - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'year', value: '0004' }), - ); + userEvent.type(input, '5'); + + expect(contextOnChange).toHaveBeenCalled(); + expect(propOnChange).toHaveBeenCalled(); }); }); - // describe('custom onChange prop', () => { - // test('calls prop-level onChange in addition to context onChange', () => { - // const contextOnChange = jest.fn() as InputSegmentChangeEventHandler< - // SegmentObjMock, - // string - // >; - // const propOnChange = jest.fn(); - // const { input } = renderSegment({ - // props: { onChange: propOnChange }, - // providerProps: { onChange: contextOnChange }, - // }); - - // userEvent.type(input, '5'); - - // expect(contextOnChange).toHaveBeenCalled(); - // expect(propOnChange).toHaveBeenCalled(); - // }); - // }); - - // describe('accessibility attributes', () => { - // test('has role="spinbutton"', () => { - // const { input } = renderSegment({}); - // expect(input).toHaveAttribute('role', 'spinbutton'); - // }); - - // test('has correct data-segment attribute', () => { - // const { input } = renderSegment({ - // props: { segment: 'month' }, - // }); - // expect(input).toHaveAttribute('data-segment', 'month'); - // }); - - // test('has correct pattern attribute', () => { - // const { input } = renderSegment({ - // props: { segment: 'day' }, - // }); - // // day segment has 2 chars per segment - // expect(input).toHaveAttribute('pattern', '[0-9]{2}'); - // }); - - // test('has min and max attributes', () => { - // const { input } = renderSegment({ - // props: { segment: 'day' }, - // }); - // expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); - // expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); - // }); - - // test('has aria-live region that announces value changes', () => { - // const { container, rerenderSegment } = renderSegment({ - // props: { segment: 'day' }, - // providerProps: { segments: { day: '15', month: '', year: '' } }, - // }); - - // const liveRegion = container.querySelector('[aria-live="polite"]'); - // expect(liveRegion).toBeInTheDocument(); - // expect(liveRegion).toHaveTextContent('day 15'); - // }); - - // test('aria-live region is empty when value is empty', () => { - // const { container } = renderSegment({ - // props: { segment: 'day' }, - // }); - - // const liveRegion = container.querySelector('[aria-live="polite"]'); - // expect(liveRegion).toBeInTheDocument(); - // expect(liveRegion).toHaveTextContent(''); - // }); - // }); - /* eslint-disable jest/no-disabled-tests */ describe.skip('types behave as expected', () => { test('InputSegment throws error when no required props are provided', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 303f0dea40..e6f077a753 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -88,18 +88,13 @@ const InputSegmentWithRef = ( shouldSkipValidation, ); - // console.log('😡newValue', { - // segment, - // value, - // newValue, - // }); - const hasValueChanged = newValue !== value; if (hasValueChanged) { onChange({ segment, value: newValue, + meta: { min }, }); } else { // If the value has not changed, ensure the input value is reset @@ -146,7 +141,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: valueString, - meta: { key }, + meta: { key, min }, }); break; } @@ -162,7 +157,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: '', - meta: { key }, + meta: { key, min }, }); } @@ -179,7 +174,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: '', - meta: { key }, + meta: { key, min }, }); } diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 21dcc9fd9c..7cbeaa34db 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -10,6 +10,7 @@ export interface InputSegmentChangeEvent< value: Value; meta?: { key?: (typeof keyMap)[keyof typeof keyMap]; + min: number; [key: string]: any; }; } diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 5912743142..37692aed37 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -103,7 +103,6 @@ export const InputBoxWithState = ({ charsPerSegment={charsPerSegmentMock} formatParts={defaultFormatPartsMock} segmentRules={segmentRulesMock} - minValues={defaultMinMock} segmentComponent={InputSegmentWrapper} size={Size.Default} disabled={disabled} diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index f3d3e10120..3c108c8e1a 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -28,16 +28,21 @@ export interface ExplicitSegmentRule { * const rules = { * day: { maxChars: 2, minExplicitValue: 1 }, * month: { maxChars: 2, minExplicitValue: 1 }, - * //TODO: need to pass in allowZero as an argument to isValidSegmentValue */ export function createExplicitSegmentValidator< T extends Record, >(segmentEnum: T, rules: Record) { - return (segment: T[keyof T], value: string): boolean => { + return ( + segment: T[keyof T], + value: string, + allowsZero?: boolean, + ): boolean => { if ( - !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) + !( + isValidSegmentValue(value, allowsZero) && + isValidSegmentName(segmentEnum, segment) + ) ) { - console.log('‼️'); return false; } @@ -49,14 +54,6 @@ export function createExplicitSegmentValidator< ? Number(value) >= rule.minExplicitValue : false; - console.log('🎃isExplicitSegmentValue', { - segment, - value, - isMaxLength, - meetsMinValue, - isExplicitSegmentValue: isMaxLength || meetsMinValue, - }); - return isMaxLength || meetsMinValue; }; } From 2ed06d6e9ebc77bd2a204e09c8faced555fe82f2 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 17:07:25 -0500 Subject: [PATCH 50/56] refactor(input-box): standardize parameter naming from `allowsZero` to `allowZero` across components and utility functions for consistency --- packages/input-box/README.md | 2 -- packages/input-box/src/InputBox/InputBox.tsx | 14 +++++++------- .../createExplicitSegmentValidator.ts | 8 ++------ .../utils/getValueFormatter/getValueFormatter.ts | 2 +- 4 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index e09844d3e7..eb13b7e00b 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -29,7 +29,6 @@ The component handles high-level interactions like moving between segments, whil - `charsPerSegment`: Record of maximum characters per segment (e.g., `{ day: 2, month: 2, year: 4 }`) - `segmentRefs`: Record mapping segment names to their input refs - `segmentRules`: Record of validation rules per segment with `maxChars` and `minExplicitValue` -- `minValues`: Record of minimum values per segment (e.g., `{ day: 1, month: 1, year: 1970 }`) - `disabled`: Whether the input is disabled - `size`: Size of the input (`Size.Default`, `Size.Small`, or `Size.XSmall`) - `onSegmentChange`: Optional callback fired when any segment changes @@ -85,7 +84,6 @@ const MySegment = ({ segment, ...props }) => ( charsPerSegment={charsPerSegment} segmentRefs={segmentRefs} segmentRules={segmentRules} - minValues={minValues} disabled={false} size={Size.Default} />; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index f5f01aed49..0824b9dce0 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -63,11 +63,11 @@ export const InputBoxWithRef = ( const getFormattedSegmentValue = ( segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], segmentValue: string, - allowsZero: boolean, + allowZero: boolean, ): string => { const formatter = getValueFormatter( charsPerSegment[segmentName], - allowsZero, + allowZero, ); const formattedValue = formatter(segmentValue); return formattedValue; @@ -83,17 +83,17 @@ export const InputBoxWithRef = ( const changedViaArrowKeys = meta?.key === keyMap.ArrowDown || meta?.key === keyMap.ArrowUp; const minSegmentValue = meta?.min as number; - const allowsZero = minSegmentValue === 0; + const allowZero = minSegmentValue === 0; // Auto-format the segment if it is explicit and was not changed via arrow-keys e.g. up/down arrows. if ( !changedViaArrowKeys && - isExplicitSegmentValue(segmentName, segmentValue, allowsZero) + isExplicitSegmentValue(segmentName, segmentValue, allowZero) ) { segmentValue = getFormattedSegmentValue( segmentName, segmentValue, - allowsZero, + allowZero, ); // Auto-advance focus (if possible) @@ -118,13 +118,13 @@ export const InputBoxWithRef = ( const segmentName = e.target.getAttribute('id'); const segmentValue = e.target.value; const minValue = Number(e.target.getAttribute('min')); - const allowsZero = minValue === 0; + const allowZero = minValue === 0; if (isInputSegment(segmentName, segmentEnum)) { const formattedValue = getFormattedSegmentValue( segmentName, segmentValue, - allowsZero, + allowZero, ); setSegment(segmentName, formattedValue); } diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 3c108c8e1a..ea60da2a18 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -32,14 +32,10 @@ export interface ExplicitSegmentRule { export function createExplicitSegmentValidator< T extends Record, >(segmentEnum: T, rules: Record) { - return ( - segment: T[keyof T], - value: string, - allowsZero?: boolean, - ): boolean => { + return (segment: T[keyof T], value: string, allowZero?: boolean): boolean => { if ( !( - isValidSegmentValue(value, allowsZero) && + isValidSegmentValue(value, allowZero) && isValidSegmentName(segmentEnum, segment) ) ) { diff --git a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts index f2c6d822e6..79530ff8b2 100644 --- a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts +++ b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts @@ -7,7 +7,7 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * otherwise, pad the string with 0s, or trim it to n chars * * @param charsPerSegment - the number of characters per segment - * @param allowsZero - + * @param allowZero - * @param val - the value to format * @returns a value formatter function for the provided segment * From 15450a501937ff07a699dfc1db73413977bd262e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 18:40:39 -0500 Subject: [PATCH 51/56] refactor(input-box): enhance createExplicitSegmentValidator documentation by adding parameter descriptions and examples for improved clarity --- .../createExplicitSegmentValidator.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index ea60da2a18..7ae30e97c3 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -17,9 +17,21 @@ export interface ExplicitSegmentRule { * Factory function that creates a segment value validator * @param segmentEnum - The segment enum/object to validate against * @param rules - Rules for each segment type - * @returns A function that checks if a segment value is explicit + * @returns A function that checks if a segment value is explicit and accepts the segment, value, and allowZero parameters + * @param segment - The segment to validate + * @param value - The value to validate + * @param allowZero - Whether to allow zero values * * @example + * const segmentEnum = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * const rules = { + * day: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 1 }, + * @example * const segmentObj = { * Day: 'day', * Month: 'month', From 94306ec05a719b03ba63dca3db687b55e8e33566 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 18:50:43 -0500 Subject: [PATCH 52/56] test(input-box): enhance mouse and keyboard interaction tests for segment focus behavior in InputBox component --- .../input-box/src/InputBox/InputBox.spec.tsx | 80 +++++++++++++++---- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index ad3a13bb43..8c4129c6bd 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -160,14 +160,22 @@ describe('packages/input-box', () => { }); describe('Mouse interaction', () => { - test('click on segment focuses it', () => { + test('click on segment focuses it when the segment is empty', () => { const { dayInput } = renderInputBox({}); userEvent.click(dayInput); expect(dayInput).toHaveFocus(); }); + + test('click on segment focuses it when the segment is not empty', () => { + const { dayInput } = renderInputBox({ + segments: { day: '02', month: '', year: '' }, + }); + userEvent.click(dayInput); + expect(dayInput).toHaveFocus(); + }); }); - describe('Keyboard interaction', () => { + describe.only('Keyboard interaction', () => { test('Tab moves focus to next segment', () => { const { dayInput, monthInput, yearInput } = renderInputBox({}); userEvent.click(monthInput); @@ -177,22 +185,62 @@ describe('packages/input-box', () => { expect(yearInput).toHaveFocus(); }); - test('Right arrow key moves focus to next segment', () => { - const { dayInput, monthInput, yearInput } = renderInputBox({}); - userEvent.click(monthInput); - userEvent.type(monthInput, '{arrowright}'); - expect(dayInput).toHaveFocus(); - userEvent.type(dayInput, '{arrowright}'); - expect(yearInput).toHaveFocus(); + describe('Right arrow', () => { + test('Right arrow key moves focus to next segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowright}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('Right arrow key moves focus to next segment when the segment is not empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowright}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('Right arrow key moves focus to next segment when the value starts with 0', () => { + const { dayInput, monthInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.type(monthInput, '0{arrowright}'); + expect(dayInput).toHaveFocus(); + }); }); - test('Left arrow key moves focus to previous segment', () => { - const { dayInput, monthInput, yearInput } = renderInputBox({}); - userEvent.click(yearInput); - userEvent.type(yearInput, '{arrowleft}'); - expect(dayInput).toHaveFocus(); - userEvent.type(dayInput, '{arrowleft}'); - expect(monthInput).toHaveFocus(); + describe('Left arrow', () => { + test('Left arrow key moves focus to previous segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({}); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('Left arrow key moves focus to previous segment when the segment is not empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('Left arrow key moves focus to previous segment when the value starts with 0', () => { + const { dayInput, yearInput } = renderInputBox({}); + userEvent.click(yearInput); + userEvent.type(yearInput, '0{arrowleft}'); + expect(dayInput).toHaveFocus(); + }); }); }); From 34ab4e657c27cc720684588a5a372602a469d6b4 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 19:00:21 -0500 Subject: [PATCH 53/56] test(input-box): add tests for Up and Down arrow key interactions to maintain focus in InputBox segments --- .../input-box/src/InputBox/InputBox.spec.tsx | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 8c4129c6bd..cd5bba8b6e 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -175,7 +175,7 @@ describe('packages/input-box', () => { }); }); - describe.only('Keyboard interaction', () => { + describe('Keyboard interaction', () => { test('Tab moves focus to next segment', () => { const { dayInput, monthInput, yearInput } = renderInputBox({}); userEvent.click(monthInput); @@ -242,6 +242,42 @@ describe('packages/input-box', () => { expect(dayInput).toHaveFocus(); }); }); + + describe('Up arrow', () => { + test('keeps the focus in the current segment when the segment is empty', () => { + const { dayInput } = renderInputBox({}); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowup}'); + expect(dayInput).toHaveFocus(); + }); + + test('keeps the focus in the current segment when the segment is not empty', () => { + const { dayInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowup}'); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Down arrow', () => { + test('keeps the focus in the current segment when the segment is empty', () => { + const { dayInput } = renderInputBox({}); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowdown}'); + expect(dayInput).toHaveFocus(); + }); + + test('keeps the focus in the current segment when the segment is not empty', () => { + const { dayInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowdown}'); + expect(dayInput).toHaveFocus(); + }); + }); }); describe('onBlur', () => { From 762bc655f78f26e0e2e9ef60969ebadae7e9e5d1 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 19:23:50 -0500 Subject: [PATCH 54/56] refactor(input-box, date-picker): streamline InputBoxContext structure and enhance type definitions; update InputBox and InputSegment components for improved clarity and functionality --- .../DateInputSegment/DateInputSegment.tsx | 9 +---- packages/input-box/src/InputBox.stories.tsx | 9 +++++ .../src/InputBoxContext/InputBoxContext.tsx | 38 ++----------------- .../InputBoxContext/InputBoxContext.types.ts | 21 ++++++++++ .../input-box/src/InputBoxContext/index.ts | 5 +++ .../src/InputSegment/InputSegment.stories.tsx | 7 +++- .../src/InputSegment/InputSegment.tsx | 2 - packages/input-box/src/index.ts | 2 +- packages/input-box/src/testutils/index.tsx | 3 +- pnpm-lock.yaml | 3 ++ 10 files changed, 53 insertions(+), 46 deletions(-) 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 4108142568..9f4ae4e39b 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -45,13 +45,8 @@ export const DateInputSegment = React.forwardRef< const autoComplete = getAutoComplete(autoCompleteProp, segment); - const shouldWrap = !([DateSegment.Year] as Array).includes( - segment, - ); - - const shouldSkipValidation = ( - [DateSegment.Year] as Array - ).includes(segment); + const shouldWrap = segment !== DateSegment.Year; + const shouldSkipValidation = segment === DateSegment.Year; return ( = { title: 'Components/Inputs/InputBox', @@ -41,9 +42,17 @@ const meta: StoryMetaType = { 'labelledBy', 'onSegmentChange', 'renderSegment', + 'segmentComponent', + 'segmentEnum', ], }, }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + }, }; export default meta; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 6eeb63eaa3..23ec2fc4fc 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -4,40 +4,10 @@ import React, { useContext, useMemo, } from 'react'; - -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; -import { Size } from '@leafygreen-ui/tokens'; - -import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; - -// Helper type to represent the constrained Enum Object structure -type SegmentEnumObject = Record; - -// T is the string union of segment names (e.g., 'areaCode' | 'prefix') -export interface InputBoxContextType { - charsPerSegment: Record; - disabled: boolean; - segmentEnum: SegmentEnumObject; - onChange: InputSegmentChangeEventHandler; - onBlur: (event: React.FocusEvent) => void; - segmentRefs: Record>>; - segments: Record; - labelledBy?: string; - size: Size; -} - -// Props are generic over T and use SegmentEnumObject for segmentEnum -export interface InputBoxProviderProps { - charsPerSegment: Record; - disabled: boolean; - segmentEnum: SegmentEnumObject; - onChange: InputSegmentChangeEventHandler; - onBlur: (event: React.FocusEvent) => void; - segmentRefs: Record>>; - segments: Record; - labelledBy?: string; - size: Size; -} +import { + InputBoxContextType, + InputBoxProviderProps, +} from './InputBoxContext.types'; // The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the string yet. export const InputBoxContext = createContext(null); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts index e69de29bb2..40f47a35c7 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts @@ -0,0 +1,21 @@ +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; + +type SegmentEnumObject = Record; + +export interface InputBoxContextType { + charsPerSegment: Record; + disabled: boolean; + segmentEnum: SegmentEnumObject; + onChange: InputSegmentChangeEventHandler; + onBlur: (event: React.FocusEvent) => void; + segmentRefs: Record>>; + segments: Record; + labelledBy?: string; + size: Size; +} + +export interface InputBoxProviderProps + extends InputBoxContextType {} diff --git a/packages/input-box/src/InputBoxContext/index.ts b/packages/input-box/src/InputBoxContext/index.ts index 5adefa71fd..b438cee411 100644 --- a/packages/input-box/src/InputBoxContext/index.ts +++ b/packages/input-box/src/InputBoxContext/index.ts @@ -3,3 +3,8 @@ export { InputBoxProvider, useInputBoxContext, } from './InputBoxContext'; + +export type { + InputBoxContextType, + InputBoxProviderProps, +} from './InputBoxContext.types'; diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index ba9b0223c7..459f6b9d8e 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -84,6 +84,11 @@ const meta: StoryMetaType = { month: '8', year: '2025', }, + { + day: '00', + month: '0', + year: '0000', + }, { day: '', month: '', @@ -144,7 +149,7 @@ export const LiveExample: StoryFn = ( disabled={false} size={context?.args?.size || Size.Default} > - + ); }; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index e6f077a753..63aec2ea2f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -197,8 +197,6 @@ const InputSegmentWithRef = ( // 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 - - // These attributes are returned from the hook as input props and we pass that to an input element return ( <> ); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2de0639a3c..4b2249f6c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2258,6 +2258,9 @@ importers: packages/input-box: dependencies: + '@leafygreen-ui/a11y': + specifier: workspace:^ + version: link:../a11y '@leafygreen-ui/date-utils': specifier: workspace:^ version: link:../date-utils From e3f5a3483348c12e61c5ad58d035ad2736c296b4 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 12:39:46 -0500 Subject: [PATCH 55/56] docs(input-box): update README.md to enhance installation instructions, usage examples, and component prop descriptions for better clarity and usability --- packages/input-box/README.md | 164 +++++++++++++++++++++-------------- 1 file changed, 98 insertions(+), 66 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index eb13b7e00b..3961601fb7 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -1,10 +1,75 @@ -# Internal Input Box +# Input Box -An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/input-box.svg) + +## Installation + +### PNPM + +```shell +pnpm add @leafygreen-ui/input-box +``` + +### Yarn + +```shell +yarn add @leafygreen-ui/input-box +``` + +### NPM + +```shell +npm install @leafygreen-ui/input-box +``` + +## Example + +```tsx +import { InputBox, InputSegment } from '@leafygreen-ui/input-box'; +import { Size } from '@leafygreen-ui/tokens'; + +// 1. Create a custom segment component +const MySegment = ({ segment, ...props }) => ( + +); + +// 2. Use InputBox with your segments + console.log(segment, value)} + segmentEnum={{ Day: 'day', Month: 'month', Year: 'year' }} + segmentComponent={MySegment} + formatParts={[ + { type: 'month', value: '02' }, + { type: 'literal', value: '/' }, + { type: 'day', value: '01' }, + { type: 'literal', value: '/' }, + { type: 'year', value: '2025' } + ]} + charsPerSegment={{ day: 2, month: 2, year: 4 }} + segmentRefs={{ day: dayRef, month: monthRef, year: yearRef }} + segmentRules={{ + day: { maxChars: 2, minExplicitValue: 1 }, + month: { maxChars: 2, minExplicitValue: 4 }, + year: { maxChars: 4, minExplicitValue: 1970 } + }} + disabled={false} + size={Size.Default} +/> +``` + +Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for an implementation example. + +## Overview -This package provides two main components that work together to create segmented input experiences: +An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. -## Components +This package provides two main components that work together to create segmented input experiences. ### InputBox @@ -12,28 +77,31 @@ A generic controlled input box component that renders an input with multiple seg **Key Features:** -- **Auto-format**: Automatically formats segment values when they reach an explicit state (e.g., when a day value becomes unambiguous) +- **Auto-format**: Automatically pads segment values with leading zeros (based on `charsPerSegment`) when they become explicit/unambiguous. A value is explicit when it either: (1) reaches the maximum character length, or (2) meets or exceeds the `minExplicitValue` threshold (e.g., typing "5" for day → "05", but typing "2" stays "2" since it could be 20-29). Also formats on blur. - **Auto-focus**: Automatically advances focus to the next segment when the current segment is complete - **Keyboard navigation**: Handles left/right arrow key navigation between segments - **Segment management**: Renders segments and separators based on `formatParts` (from `Intl.DateTimeFormat`) The component handles high-level interactions like moving between segments, while delegating segment-specific logic to the `InputSegment` component. Internally, it uses `InputBoxContext` to share state and handlers across all segments. -**Props:** - -- `segments`: Record of current segment values (e.g., `{ day: '01', month: '02', year: '2025' }`) -- `setSegment`: Function to update a segment value `(segment, value) => void` -- `segmentEnum`: Enumerable object mapping segment names to values (e.g., `{ Day: 'day', Month: 'month', Year: 'year' }`) -- `segmentComponent`: React component to render each segment (must accept `InputSegmentComponentProps`) -- `formatParts`: Array of `Intl.DateTimeFormatPart` defining segment order and separators -- `charsPerSegment`: Record of maximum characters per segment (e.g., `{ day: 2, month: 2, year: 4 }`) -- `segmentRefs`: Record mapping segment names to their input refs -- `segmentRules`: Record of validation rules per segment with `maxChars` and `minExplicitValue` -- `disabled`: Whether the input is disabled -- `size`: Size of the input (`Size.Default`, `Size.Small`, or `Size.XSmall`) -- `onSegmentChange`: Optional callback fired when any segment changes -- `labelledBy`: ID of the labelling element for accessibility -- Standard div props are also supported (className, onKeyDown, etc.) +#### Props + +| Prop | Type | Description | Default | +| ------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `segments` | `Record` | An object containing the values of the segments.

Example: `{ day: '01', month: '02', year: '2025' }` | | +| `setSegment` | `(segment: Segment, value: string) => void` | A function that sets the value of a segment.

Example: `(segment: 'day', value: '15') => void` | | +| `segmentEnum` | `Record` | An enumerable object that maps the segment names to their values.

Example: `{ Day: 'day', Month: 'month', Year: 'year' }` | | +| `segmentComponent` | `React.ComponentType>` | React component to render each segment (must accept `InputSegmentComponentProps`).

Example: `DateInputSegment` | | +| `formatParts` | `Array` | Array of `Intl.DateTimeFormatPart` defining segment order and separators.

Example:
`[{ type: 'month', value: '02' },`
`{ type: 'literal', value: '/' }, ...]` | | +| `charsPerSegment` | `Record` | Record of maximum characters per segment.

Example: `{ day: 2, month: 2, year: 4 }` | | +| `segmentRefs` | `Record>>` | Record mapping segment names to their input refs.

Example: `{ day: dayRef, month: monthRef, year: yearRef }` | | +| `segmentRules` | `Record` | Record of validation rules per segment with `maxChars` and `minExplicitValue`.

Example:
`{ day: { maxChars: 2, minExplicitValue: 1 },`
`month: { maxChars: 2, minExplicitValue: 4 }, ... }` | | +| `disabled` | `boolean` | Whether the input is disabled | | +| `size` | `Size` | Size of the input.

Example: `Size.Default`, `Size.Small`, or `Size.XSmall` | | +| `onSegmentChange` | `InputSegmentChangeEventHandler` | Optional callback fired when any segment changes | | +| `labelledBy` | `string` | ID of the labelling element for accessibility.

Example: `'date-input-label'` | | + +\+ other HTML `div` element props ### InputSegment @@ -48,51 +116,15 @@ A controlled input segment component that renders a single input field within an - **Keyboard interaction**: Handles backspace and space keys to clear values - **onChange/onBlur events**: Fires custom change events with segment metadata -**Props:** - -- `segment`: The segment identifier (e.g., 'day', 'month', 'year') -- `min`/`max`: Valid range for the segment value -- `step`: Increment/decrement step for arrow keys (default: 1) -- `shouldWrap`: Whether values should wrap around at min/max boundaries -- `shouldSkipValidation`: Skips validation for segments that allow extended ranges -- native input props are passed through to the input element - -## Usage - -**Basic pattern:** +#### Props -```tsx -import { InputBox, InputBoxProvider } from '@leafygreen-ui/input-box'; +| Prop | Type | Description | Default | +| ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `segment` | `string` | The segment identifier.

Example: `'day'`, `'month'`, or `'year'` | | +| `min` | `number` | Minimum valid value for the segment.

Example: `1` for day, `1` for month, `1900` for year | | +| `max` | `number` | Maximum valid value for the segment.

Example: `31` for day, `12` for month, `2100` for year | | +| `step` | `number` | Increment/decrement step for arrow keys | `1` | +| `shouldWrap` | `boolean` | Whether values should wrap around at min/max boundaries.

Example: `true` to wrap 31 → 1 for days | | +| `shouldSkipValidation` | `boolean` | Skips validation for segments that allow extended ranges | | -// 1. Create a custom segment component -const MySegment = ({ segment, ...props }) => ( - -); - -// 2. Use InputBox with your segments -; -``` - -Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for a implementation example. - -## Installation - -```bash -pnpm add @leafygreen-ui/input-box -``` +\+ native HTML `input` element props From 0fd74e6021882eb597c4caaa25381c455a1ccc16 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 11:53:16 -0500 Subject: [PATCH 56/56] refactor(date-picker): simplify ProviderWrapper in DateInputSegment stories for better segment handling --- .../DateInputSegment.stories.tsx | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) 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 ee2db78455..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 @@ -20,34 +20,35 @@ import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegment } from './DateInputSegment'; -const ProviderWrapper = (Story: StoryFn, ctx?: { args: any }) => ( - - - - {}} - onBlur={() => {}} - segmentRefs={useSegmentRefs()} - segments={ctx?.args.segments} - size={Size.Default} - disabled={false} - > - - - - - -); +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,