From 4f0dc24a51ef7128f19350b9d0898492483f86fc Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 7 Oct 2025 18:31:48 -0400 Subject: [PATCH 001/124] 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 002/124] 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 003/124] 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 004/124] 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 005/124] 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 006/124] 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 007/124] 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 008/124] 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 009/124] 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 010/124] 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 011/124] 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 012/124] 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 013/124] 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 014/124] 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 015/124] 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 016/124] 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 017/124] 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 018/124] 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 019/124] 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 020/124] 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 021/124] 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 022/124] 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 023/124] 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 024/124] 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 025/124] 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 026/124] 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 027/124] 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 028/124] 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 029/124] 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 030/124] 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 031/124] 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 032/124] 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 033/124] 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 034/124] 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 035/124] 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 036/124] 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 037/124] 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 038/124] 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 039/124] 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 040/124] 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 041/124] 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 042/124] 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 043/124] 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 044/124] 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 045/124] 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 046/124] 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 047/124] 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 048/124] 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 049/124] 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 050/124] 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 051/124] 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 052/124] 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 053/124] 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 054/124] 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 055/124] 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 09d117b1d0804cf789c613256e5bdbb350a74915 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 13:35:07 -0500 Subject: [PATCH 056/124] feat(input-box): enhance InputBox and InputSegment components with new features and documentation. --- packages/input-box/README.md | 132 ++- packages/input-box/package.json | 1 + packages/input-box/src/InputBox.stories.tsx | 63 ++ .../input-box/src/InputBox/InputBox.spec.tsx | 544 +++++++++++ .../input-box/src/InputBox/InputBox.styles.ts | 42 + packages/input-box/src/InputBox/InputBox.tsx | 259 ++++++ .../input-box/src/InputBox/InputBox.types.ts | 146 +++ packages/input-box/src/InputBox/index.ts | 2 + .../InputBoxContext/InputBoxContext.spec.tsx | 68 ++ .../src/InputBoxContext/InputBoxContext.tsx | 77 ++ .../InputBoxContext/InputBoxContext.types.ts | 21 + .../input-box/src/InputBoxContext/index.ts | 10 + .../src/InputSegment/InputSegment.spec.tsx | 843 ++++++++++++++++++ .../src/InputSegment/InputSegment.stories.tsx | 157 ++++ .../src/InputSegment/InputSegment.styles.ts | 103 +++ .../src/InputSegment/InputSegment.tsx | 240 +++++ .../src/InputSegment/InputSegment.types.ts | 123 +++ packages/input-box/src/InputSegment/index.ts | 6 + packages/input-box/src/index.ts | 12 + packages/input-box/src/testutils/index.tsx | 250 ++++++ .../src/testutils/testutils.mocks.ts | 85 ++ .../createExplicitSegmentValidator.ts | 20 +- packages/input-box/tsconfig.json | 3 + 23 files changed, 3200 insertions(+), 7 deletions(-) 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/InputBoxContext/InputBoxContext.spec.tsx 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 create mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.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/testutils/index.tsx create mode 100644 packages/input-box/src/testutils/testutils.mocks.ts diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 67bcec1d73..3961601fb7 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -1,4 +1,130 @@ -# Internal Input Box +# Input Box -An internal component intended to be used by any date or time component. -I.e. `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 + +An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. + +This package provides two main components that work together to create segmented input experiences. + +### InputBox + +A generic controlled input box component that renders an input with multiple segments separated by literals. + +**Key Features:** + +- **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 + +| 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 + +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 + +| 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 | | + +\+ native HTML `input` element props 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 new file mode 100644 index 0000000000..83f9f0ff8a --- /dev/null +++ b/packages/input-box/src/InputBox.stories.tsx @@ -0,0 +1,63 @@ +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 { SegmentObjMock } from './testutils/testutils.mocks'; +import { InputBox, InputBoxProps } from './InputBox'; +import { InputBoxWithState } from './testutils'; +import { Size } from '@leafygreen-ui/tokens'; + +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', + 'segmentComponent', + 'segmentEnum', + ], + }, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + }, +}; +export default meta; + +export const LiveExample: StoryFn = props => { + return ( + >)} /> + ); +}; 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..cd5bba8b6e --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -0,0 +1,544 @@ +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'; + +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { + InputBoxWithState, + InputSegmentWrapper, + renderInputBox, +} from '../testutils'; +import { + charsPerSegmentMock, + 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 => { + 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({ + segments: { day: '02', month: '02', year: '2025' }, + }); + + expect(dayInput.value).toBe('02'); + expect(monthInput.value).toBe('02'); + expect(yearInput.value).toBe('2025'); + }); + }); + + describe('rerendering', () => { + test('with new value updates the segments', () => { + const setSegment = jest.fn(); + const { rerenderInputBox, getDayInput, getMonthInput, getYearInput } = + 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' }, + setSegment, + }); + expect(getDayInput().value).toBe('26'); + expect(getMonthInput().value).toBe('09'); + expect(getYearInput().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 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('auto-focus', () => { + test('focuses the next segment when an explicit value is entered', () => { + const { dayInput, monthInput } = renderInputBox({}); + + 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', () => { + 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 } = 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 } = renderInputBox({}); + + userEvent.type(monthInput, '2'); + userEvent.type(monthInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + }); + + describe('Mouse interaction', () => { + 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', () => { + test('Tab moves focus to next segment', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.tab(); + expect(dayInput).toHaveFocus(); + userEvent.tab(); + 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(); + }); + }); + + 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(); + }); + }); + + 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', () => { + test('returns no value with leading zero if min value is not 0', () => { + // min value is 1 + const { monthInput } = renderInputBox({}); + userEvent.type(monthInput, '0'); + userEvent.tab(); + expect(monthInput.value).toBe(''); + }); + + 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', () => { + describe('explicit value', () => { + test('updates the rendered segment value', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '26'); + expect(dayInput.value).toBe('26'); + }); + + test('segment value is immediately formatted', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '5'); + expect(dayInput.value).toBe('05'); + }); + + test('allows leading zeros', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '02'); + expect(dayInput.value).toBe('02'); + }); + + test('allows 00 as a valid value if min value is 0', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '00'); + expect(dayInput.value).toBe('00'); + }); + }); + + describe('ambiguous value', () => { + test('segment value is not immediately formatted', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + expect(dayInput.value).toBe('2'); + }); + + test('value is formatted on segment blur', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + userEvent.tab(); + expect(dayInput.value).toBe('02'); + }); + + test('allows leading zeros', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '0'); + expect(dayInput.value).toBe('0'); + }); + + test('allows backspace to delete the value', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + userEvent.type(dayInput, '{backspace}'); + expect(dayInput.value).toBe(''); + }); + }); + + describe('min/max range', () => { + 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('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'); + }); + }); + }); + + test('does not allow non-number characters', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, 'aB$/'); + expect(dayInput.value).toBe(''); + }); + + test('backspace resets the input', () => { + const { dayInput, yearInput } = renderInputBox({}); + userEvent.type(dayInput, '21'); + userEvent.type(dayInput, '{backspace}'); + expect(dayInput.value).toBe(''); + + userEvent.type(yearInput, '1993'); + userEvent.type(yearInput, '{backspace}'); + expect(yearInput.value).toBe(''); + }); + }); + + 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', () => { + // @ts-expect-error - missing required props + ; + }); + }); + + test('With required props', () => { + {}} + charsPerSegment={charsPerSegmentMock} + segmentRules={segmentRulesMock} + segmentComponent={InputSegmentWrapper} + size={Size.Default} + disabled={false} + />; + }); +}); 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..53e3de972e --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.styles.ts @@ -0,0 +1,42 @@ +import { css, cx } 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}; + `, +}; + +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 new file mode 100644 index 0000000000..bd93e20325 --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -0,0 +1,259 @@ +import React, { + FocusEventHandler, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; + +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { InputBoxProvider } from '../InputBoxContext'; +import { + InputSegmentChangeEventHandler, + isInputSegment, +} from '../InputSegment/InputSegment.types'; +import { + createExplicitSegmentValidator, + getRelativeSegment, + getRelativeSegmentRef, + getValueFormatter, + isElementInputSegment, +} from '../utils'; + +import { + getSegmentPartsWrapperStyles, + getSeparatorLiteralStyles, +} from './InputBox.styles'; +import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; + +/** + * Generic controlled input box component + * Renders an input box with appropriate segment order & separator characters. + * + * @internal + */ +export const InputBoxWithRef = ( + { + className, + labelledBy, + segmentRefs, + onSegmentChange, + onKeyDown, + setSegment, + disabled, + charsPerSegment, + formatParts, + segmentEnum, + segmentRules, + segmentComponent, + segments, + size, + ...rest + }: InputBoxProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + + const isExplicitSegmentValue = createExplicitSegmentValidator({ + segmentEnum, + rules: segmentRules, + }); + + /** Formats and sets the segment value. */ + const getFormattedSegmentValue = ( + segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], + segmentValue: string, + allowZero: boolean, + ): string => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment[segmentName], + allowZero, + }); + const formattedValue = formatter(segmentValue); + return formattedValue; + }; + + /** Fired when an individual segment value changes */ + const handleSegmentInputChange: InputSegmentChangeEventHandler< + Segment, + string + > = segmentChangeEvent => { + let segmentValue = segmentChangeEvent.value; + const { segment: segmentName, meta } = segmentChangeEvent; + const changedViaArrowKeys = + meta?.key === keyMap.ArrowDown || meta?.key === keyMap.ArrowUp; + const minSegmentValue = meta?.min as number; + 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, allowZero) + ) { + segmentValue = getFormattedSegmentValue( + segmentName, + segmentValue, + allowZero, + ); + + // 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. Formats the segment value and sets it. */ + const handleSegmentInputBlur: FocusEventHandler = e => { + const segmentName = e.target.getAttribute('id'); + const segmentValue = e.target.value; + const minValue = Number(e.target.getAttribute('min')); + const allowZero = minValue === 0; + + if (isInputSegment(segmentName, segmentEnum)) { + const formattedValue = getFormattedSegmentValue( + segmentName, + segmentValue, + allowZero, + ); + setSegment(segmentName, formattedValue); + } + }; + + /** 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; + 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 ( + + {/* 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 Segment = segmentComponent; + return ; + } + })} +
+
+ ); +}; + +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..ae5c3840ad --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -0,0 +1,146 @@ +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, + InputSegmentComponentProps, +} from '../InputSegment/InputSegment.types'; +import { ExplicitSegmentRule } from '../utils'; + +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; + + /** + * An object that maps the segment names to their refs + * + * @example + * { day: ref, month: ref, year: ref } + */ + segmentRefs: Record>>; + + /** + * An enumerable object that maps the segment names to their values + * + * @example + * { Day: 'day', Month: 'month', Year: 'year' } + */ + segmentEnum: Record; + + /** + * 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: Segment, 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 + */ + 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; + + /** + * 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 + * Size.Default + * Size.Small + * Size.Large + */ + size: Size; +} + +/** + * 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 { + ( + 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..5b2e30901f --- /dev/null +++ b/packages/input-box/src/InputBox/index.ts @@ -0,0 +1,2 @@ +export { InputBox } from './InputBox'; +export { type InputBoxProps } from './InputBox.types'; 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..9ff76d1558 --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + charsPerSegmentMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; + +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 a InputBoxProvider', + ); + } else { + expect(() => + renderHook(() => useInputBoxContext()), + ).toThrow('useInputBoxContext must be used within a 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); + 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 new file mode 100644 index 0000000000..23ec2fc4fc --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -0,0 +1,77 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, +} from 'react'; +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); + +// Provider is generic over T, the string union +export const InputBoxProvider = ({ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, +}: PropsWithChildren>) => { + const value = useMemo( + () => ({ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, + }), + [ + 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. + return ( + + {children} + + ); +}; + +// 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 a 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..40f47a35c7 --- /dev/null +++ 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 new file mode 100644 index 0000000000..b438cee411 --- /dev/null +++ b/packages/input-box/src/InputBoxContext/index.ts @@ -0,0 +1,10 @@ +export { + InputBoxContext, + InputBoxProvider, + useInputBoxContext, +} from './InputBoxContext'; + +export type { + InputBoxContextType, + InputBoxProviderProps, +} from './InputBoxContext.types'; 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..150239094f --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -0,0 +1,843 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { renderSegment, setSegmentProps } from '../testutils'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + SegmentObjMock, +} from '../testutils/testutils.mocks'; +import { getValueFormatter } from '../utils'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +describe('packages/input-segment', () => { + describe('aria attributes', () => { + test(`segment has aria-label`, () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + 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', () => { + 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'); + }); + }); + + describe('typing', () => { + describe('into an empty segment', () => { + test('calls the change handler', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { + segments: { day: '2', month: '', year: '' }, + 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({ + providerProps: { + segments: { day: '26', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); + }); + + describe('keyboard events', () => { + describe('Arrow keys', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegmentMock['day'], + allowZero: defaultMinMock['day'] === 0, + }); + + describe('Up arrow', () => { + test('calls handler with value default +1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day', step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '', month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day'] + 1), + }), + ); + }); + + 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 + >; + 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', () => { + test('calls handler with value default -1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { onChange: onChangeHandler }, + }); + + 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({ + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day'] - 1), + }), + ); + }); + + 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, + 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', () => { + test('clears the input when there is a value', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + 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({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + 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({ + providerProps: { 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({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + }); + }); + }); + + 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', () => { + 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(); + }); + }); + + 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(); + }); + }); + + /* 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', () => { + ; + }); + + test('With all props', () => { + {}} + onKeyDown={() => {}} + disabled={false} + data-testid="test-id" + id="day" + ref={React.createRef()} + />; + }); + }); +}); 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..459f6b9d8e --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -0,0 +1,157 @@ +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 { InputBoxProvider } from '../InputBoxContext'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +interface InputSegmentStoryProps { + size: Size; + segments: Record; +} + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox/InputSegment', + component: InputSegment, + decorators: [ + (StoryFn, context: any) => ( + + + + ), + ], + args: { + segment: SegmentObjMock.Day, + min: defaultMinMock[SegmentObjMock.Day], + max: defaultMaxMock[SegmentObjMock.Day], + size: Size.Default, + placeholder: defaultPlaceholderMock[SegmentObjMock.Day], + shouldWrap: true, + step: 1, + darkMode: false, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + darkMode: { + control: 'boolean', + }, + }, + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segment', + 'value', + 'onChange', + 'charsPerSegment', + 'segmentEnum', + 'min', + 'max', + 'shouldWrap', + 'shouldSkipValidation', + 'step', + 'placeholder', + ], + }, + generate: { + combineArgs: { + darkMode: [false, true], + segment: ['day', 'month', 'year'], + size: Object.values(Size), + segments: [ + { + day: '2', + month: '8', + year: '2025', + }, + { + day: '00', + month: '0', + year: '0000', + }, + { + day: '', + month: '', + year: '', + }, + ], + }, + decorator: (StoryFn, context) => ( + + {}} + onBlur={() => {}} + segmentRefs={segmentRefsMock} + segments={context?.args.segments} + size={context?.args.size} + disabled={false} + > + + + + ), + }, + }, +}; +export default meta; + +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 ( + {}} + segmentRefs={segmentRefsMock} + segments={segments} + disabled={false} + size={context?.args?.size || Size.Default} + > + + + ); +}; + +export const Generated = () => {}; 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..430cb6efe4 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -0,0 +1,103 @@ +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 + `, +}; + +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 new file mode 100644 index 0000000000..82d30ad76a --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -0,0 +1,240 @@ +import React, { + ChangeEventHandler, + FocusEvent, + ForwardedRef, + KeyboardEventHandler, +} 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, + getValueFormatter, +} from '../utils'; + +import { getInputSegmentStyles } from './InputSegment.styles'; +import { + InputSegmentComponentType, + InputSegmentProps, +} from './InputSegment.types'; + +/** + * Generic controlled input segment component + * + * Renders a single input segment with configurable + * character padding, validation, and formatting. + * + * @internal + */ +const InputSegmentWithRef = ( + { + segment, + onKeyDown, + min, // minSegmentValue + max, // maxSegmentValue + className, + onChange: onChangeProp, + onBlur: onBlurProp, + step = 1, + shouldWrap = true, + shouldSkipValidation = false, + ...rest + }: InputSegmentProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + const { + onChange, + onBlur, + charsPerSegment: charsPerSegmentContext, + segmentEnum, + segmentRefs, + segments, + labelledBy, + size, + disabled, + } = useInputBoxContext(); + const baseFontSize = useUpdatedBaseFontSize(); + const charsPerSegment = charsPerSegmentContext[segment]; + const formatter = getValueFormatter({ + charsPerSegment, + allowZero: 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, + * and fires a custom `InputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: value, + incomingValue: target.value, + charsPerSegment, + defaultMin: min, + defaultMax: max, + segmentEnum, + shouldSkipValidation, + }); + + 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 + target.value = value; + } + + onChangeProp?.(e); + }; + + /** 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. This will clear the input and the number will be inserted into the input when onChange is called. + + if (target.value.length === charsPerSegment) { + target.value = ''; + } + } + + switch (key) { + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + e.preventDefault(); + + const newValue = getNewSegmentValueFromArrowKeyPress({ + key, + value, + min, + max, + step, + shouldWrap: shouldWrap, + }); + const valueString = formatter(newValue); + + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString, + meta: { key, min }, + }); + 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, min }, + }); + } + + 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, min }, + }); + } + + break; + } + + default: { + break; + } + } + + 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 + return ( + <> + + + {value && `${segment} ${value}`} + + + ); +}; + +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..7cbeaa34db --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -0,0 +1,123 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + +import { keyMap } from '@leafygreen-ui/lib'; + +export interface InputSegmentChangeEvent< + Segment extends string, + Value extends string, +> { + segment: Segment; + value: Value; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + min: number; + [key: string]: any; + }; +} + +// TODO: consider renaming min/max names to minSegment/maxSegment +/** + * The type for the onChange handler + */ +export type InputSegmentChangeEventHandler< + Segment extends string, + Value extends string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' + > { + /** + * Which segment this input represents + * + * @example + * 'day' + * 'month' + * 'year' + */ + segment: Segment; + + /** + * Minimum value for the segment + * + * @example + * 1 + * 1 + * 1970 + */ + min: number; + + /** + * Maximum value for the segment + * + * @example + * 31 + * 12 + * 2038 + */ + max: number; + + /** + * The step value for the arrow keys + * + * @default 1 + */ + step?: number; + + /** + * Whether the segment should wrap at min/max boundaries + * + * @default true + */ + shouldWrap?: boolean; + + /** + * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * + * @default false + */ + shouldSkipValidation?: boolean; +} + +/** + * 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 { + ( + 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); +} + +/** + * 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 new file mode 100644 index 0000000000..8e2840befb --- /dev/null +++ b/packages/input-box/src/InputSegment/index.ts @@ -0,0 +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 f70976968b..a4bdff7e54 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,3 +1,15 @@ +export { InputBox, type InputBoxProps } from './InputBox'; +export { + InputBoxProvider, + type InputBoxProviderProps, + useInputBoxContext, +} from './InputBoxContext'; +export { + InputSegment, + type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, + type InputSegmentProps, +} from './InputSegment'; export { createExplicitSegmentValidator, type ExplicitSegmentRule, diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx new file mode 100644 index 0000000000..80cee23566 --- /dev/null +++ b/packages/input-box/src/testutils/index.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +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'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentProps } from '../InputSegment/InputSegment.types'; + +import { + charsPerSegmentMock, + defaultFormatPartsMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, + segmentWidthStyles, +} from './testutils.mocks'; + +export const defaultProps: Partial> = { + segments: segmentsMock, + segmentEnum: SegmentObjMock, + segmentRefs: segmentRefsMock, + setSegment: () => {}, + charsPerSegment: charsPerSegmentMock, + formatParts: defaultFormatPartsMock, + 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, +}: { + segment: SegmentObjMock; +}) => { + return ( + + ); +}; + +/** + * 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 = ({ + segments: segmentsProp = { + day: '', + month: '', + year: '', + }, + setSegment: setSegmentProp, + disabled = false, + ...props +}: Partial> & { + 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 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 RenderInputBoxReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + rerenderInputBox: (props: Partial>) => void; + getDayInput: () => HTMLInputElement; + getMonthInput: () => HTMLInputElement; + 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 & + RenderInputBoxReturnType => { + const result = render(); + + 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; + + const rerenderInputBox = ( + newProps: Partial>, + ) => { + result.rerender(); + }; + + return { + ...result, + rerenderInputBox, + dayInput: getDayInput(), + monthInput: getMonthInput(), + yearInput: getYearInput(), + getDayInput, + getMonthInput, + getYearInput, + }; +}; + +/* + * InputSegment Utils + */ +export const setSegmentProps = (segment: SegmentObjMock) => { + return { + segment: segment, + charsPerSegment: charsPerSegmentMock[segment], + min: defaultMinMock[segment], + max: defaultMaxMock[segment], + placeholder: defaultPlaceholderMock[segment], + }; +}; + +interface RenderSegmentReturnType { + getInput: () => HTMLInputElement; + input: HTMLInputElement; + rerenderSegment: (params: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => void; +} + +const defaultSegmentProviderProps: Partial< + InputBoxProviderProps +> = { + charsPerSegment: charsPerSegmentMock, + segmentEnum: SegmentObjMock, + onChange: () => {}, + onBlur: () => {}, + segments: { + day: '', + month: '', + year: '', + }, + segmentRefs: segmentRefsMock, +}; + +const defaultSegmentProps: InputSegmentProps = { + segment: 'day', + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + shouldWrap: true, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', +}; + +/** + * Renders the InputSegment component for testing purposes. + */ +export const renderSegment = ({ + props = {}, + providerProps = {}, +}: { + props?: Partial>; + providerProps?: Partial>; +}): RenderResult & RenderSegmentReturnType => { + const mergedProps = { + ...defaultSegmentProps, + ...props, + } as InputSegmentProps; + + const mergedProviderProps = { + ...defaultSegmentProviderProps, + ...providerProps, + } as InputBoxProviderProps; + + const utils = render( + + + , + ); + + const rerenderSegment = ({ + newProps = {}, + newProviderProps = {}, + }: { + newProps?: Partial>; + newProviderProps?: 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/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts new file mode 100644 index 0000000000..d1e062ac30 --- /dev/null +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -0,0 +1,85 @@ +import { createRef } from 'react'; + +import { css } from '@leafygreen-ui/emotion'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +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: 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; + `, +}; diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 3c0ed0b910..447d2f4ac0 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -25,7 +25,11 @@ export interface ExplicitSegmentRule { * * @param segmentEnum - The segment enum/object containing the segment names and their corresponding values 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 segmentObj = { @@ -73,11 +77,19 @@ export function createExplicitSegmentValidator< segmentEnum: SegmentEnum; rules: Record; }) { - return (segment: SegmentEnum[keyof SegmentEnum], value: string): boolean => { + return ( + segment: SegmentEnum[keyof SegmentEnum], + value: string, + allowZero?: boolean, + ): boolean => { if ( - !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) - ) + !( + isValidSegmentValue(value, allowZero) && + isValidSegmentName(segmentEnum, segment) + ) + ) { return false; + } const rule = rules[segment]; if (!rule) return false; 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 35d975b1c61d9d6bdebf4045e76203b4df090c9a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 13:46:16 -0500 Subject: [PATCH 057/124] feat(input-box): add '@leafygreen-ui/a11y' as a dependency in pnpm-lock.yaml --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de735ab2c..bb53b8e9d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2255,6 +2255,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 2f81c18508fda0894befea21feced44245b4fc16 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 17:25:04 -0500 Subject: [PATCH 058/124] fix(input-box): fix lint errors --- packages/input-box/README.md | 50 +++++++++---------- packages/input-box/src/InputBox.stories.tsx | 2 +- .../src/InputBoxContext/InputBoxContext.tsx | 1 + .../input-box/src/InputBoxContext/index.ts | 1 - 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 3961601fb7..03d9832031 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -49,18 +49,18 @@ const MySegment = ({ segment, ...props }) => ( { type: 'literal', value: '/' }, { type: 'day', value: '01' }, { type: 'literal', value: '/' }, - { type: 'year', value: '2025' } + { 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 } + year: { maxChars: 4, minExplicitValue: 1970 }, }} disabled={false} size={Size.Default} -/> +/>; ``` Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for an implementation example. @@ -86,20 +86,20 @@ The component handles high-level interactions like moving between segments, whil #### 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'` | | +| 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 @@ -118,13 +118,13 @@ A controlled input segment component that renders a single input field within an #### Props -| 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 | | +| 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 | | \+ native HTML `input` element props diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 83f9f0ff8a..c3d6db794a 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,11 +7,11 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; +import { Size } from '@leafygreen-ui/tokens'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; import { InputBoxWithState } from './testutils'; -import { Size } from '@leafygreen-ui/tokens'; const meta: StoryMetaType = { title: 'Components/Inputs/InputBox', diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 23ec2fc4fc..db487b6a7a 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -4,6 +4,7 @@ import React, { useContext, useMemo, } from 'react'; + import { InputBoxContextType, InputBoxProviderProps, diff --git a/packages/input-box/src/InputBoxContext/index.ts b/packages/input-box/src/InputBoxContext/index.ts index b438cee411..226a86c6bb 100644 --- a/packages/input-box/src/InputBoxContext/index.ts +++ b/packages/input-box/src/InputBoxContext/index.ts @@ -3,7 +3,6 @@ export { InputBoxProvider, useInputBoxContext, } from './InputBoxContext'; - export type { InputBoxContextType, InputBoxProviderProps, From 86fbca9ee5de775c1a2169ec5936f266df046a0d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 17:37:48 -0500 Subject: [PATCH 059/124] feat(input-box): set default size for InputBox in stories and refactor InputSegment styles for improved class handling --- packages/input-box/src/InputBox.stories.tsx | 3 +++ .../src/InputSegment/InputSegment.styles.ts | 18 +++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index c3d6db794a..55e1c5400f 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -53,6 +53,9 @@ const meta: StoryMetaType = { options: Object.values(Size), }, }, + args: { + size: Size.Default, + }, }; export default meta; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts index 430cb6efe4..c759609a82 100644 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ b/packages/input-box/src/InputSegment/InputSegment.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'; import { @@ -78,7 +78,7 @@ export const segmentSizeStyles: Record = { font-size: var(--base-font-size, ${typeScales.body1.fontSize}px); `, [Size.Large]: css` - font-size: ${18}px; // Intentionally off-token + font-size: 18px; // Intentionally off-token `, }; @@ -93,11 +93,11 @@ export const getInputSegmentStyles = ({ theme: Theme; size: Size; }) => { - return css` - ${baseStyles} - ${fontSizeStyles[baseFontSize]} - ${segmentThemeStyles[theme]} - ${segmentSizeStyles[size]} - ${className} - `; + return cx( + baseStyles, + fontSizeStyles[baseFontSize], + segmentThemeStyles[theme], + segmentSizeStyles[size], + className, + ); }; From 0fd74e6021882eb597c4caaa25381c455a1ccc16 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 11:53:16 -0500 Subject: [PATCH 060/124] 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, From 7e6e4b4e92560d80415c8ed9002e34a4480f753f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 15:30:32 -0500 Subject: [PATCH 061/124] feat(input-box): implement InputBoxContext and InputBoxProvider with associated types and tests --- .../InputBoxContext/InputBoxContext.spec.tsx | 68 ++ .../src/InputBoxContext/InputBoxContext.tsx | 78 ++ .../InputBoxContext/InputBoxContext.types.ts | 21 + .../input-box/src/InputBoxContext/index.ts | 9 + .../src/InputSegment/InputSegment.spec.tsx | 843 ++++++++++++++++++ .../src/InputSegment/InputSegment.stories.tsx | 157 ++++ .../src/InputSegment/InputSegment.styles.ts | 103 +++ .../src/InputSegment/InputSegment.tsx | 240 +++++ .../src/InputSegment/InputSegment.types.ts | 123 +++ packages/input-box/src/InputSegment/index.ts | 6 + packages/input-box/src/testutils/index.tsx | 250 ++++++ .../src/testutils/testutils.mocks.ts | 85 ++ .../createExplicitSegmentValidator.ts | 20 +- 13 files changed, 1999 insertions(+), 4 deletions(-) create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx 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 create mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.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/testutils/index.tsx create mode 100644 packages/input-box/src/testutils/testutils.mocks.ts 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..9ff76d1558 --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + charsPerSegmentMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; + +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 a InputBoxProvider', + ); + } else { + expect(() => + renderHook(() => useInputBoxContext()), + ).toThrow('useInputBoxContext must be used within a 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); + 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 new file mode 100644 index 0000000000..db487b6a7a --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -0,0 +1,78 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, +} from 'react'; + +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); + +// Provider is generic over T, the string union +export const InputBoxProvider = ({ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, +}: PropsWithChildren>) => { + const value = useMemo( + () => ({ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, + }), + [ + 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. + return ( + + {children} + + ); +}; + +// 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 a 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..40f47a35c7 --- /dev/null +++ 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 new file mode 100644 index 0000000000..226a86c6bb --- /dev/null +++ b/packages/input-box/src/InputBoxContext/index.ts @@ -0,0 +1,9 @@ +export { + InputBoxContext, + InputBoxProvider, + useInputBoxContext, +} from './InputBoxContext'; +export type { + InputBoxContextType, + InputBoxProviderProps, +} from './InputBoxContext.types'; 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..150239094f --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -0,0 +1,843 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { renderSegment, setSegmentProps } from '../testutils'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + SegmentObjMock, +} from '../testutils/testutils.mocks'; +import { getValueFormatter } from '../utils'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +describe('packages/input-segment', () => { + describe('aria attributes', () => { + test(`segment has aria-label`, () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + 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', () => { + 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'); + }); + }); + + describe('typing', () => { + describe('into an empty segment', () => { + test('calls the change handler', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { + segments: { day: '2', month: '', year: '' }, + 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({ + providerProps: { + segments: { day: '26', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); + }); + + describe('keyboard events', () => { + describe('Arrow keys', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegmentMock['day'], + allowZero: defaultMinMock['day'] === 0, + }); + + describe('Up arrow', () => { + test('calls handler with value default +1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day', step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '', month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day'] + 1), + }), + ); + }); + + 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 + >; + 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', () => { + test('calls handler with value default -1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { onChange: onChangeHandler }, + }); + + 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({ + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day'] - 1), + }), + ); + }); + + 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, + 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', () => { + test('clears the input when there is a value', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + 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({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + 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({ + providerProps: { 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({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + }); + }); + }); + + 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', () => { + 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(); + }); + }); + + 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(); + }); + }); + + /* 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', () => { + ; + }); + + test('With all props', () => { + {}} + onKeyDown={() => {}} + disabled={false} + data-testid="test-id" + id="day" + ref={React.createRef()} + />; + }); + }); +}); 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..459f6b9d8e --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -0,0 +1,157 @@ +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 { InputBoxProvider } from '../InputBoxContext'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +interface InputSegmentStoryProps { + size: Size; + segments: Record; +} + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox/InputSegment', + component: InputSegment, + decorators: [ + (StoryFn, context: any) => ( + + + + ), + ], + args: { + segment: SegmentObjMock.Day, + min: defaultMinMock[SegmentObjMock.Day], + max: defaultMaxMock[SegmentObjMock.Day], + size: Size.Default, + placeholder: defaultPlaceholderMock[SegmentObjMock.Day], + shouldWrap: true, + step: 1, + darkMode: false, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + darkMode: { + control: 'boolean', + }, + }, + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segment', + 'value', + 'onChange', + 'charsPerSegment', + 'segmentEnum', + 'min', + 'max', + 'shouldWrap', + 'shouldSkipValidation', + 'step', + 'placeholder', + ], + }, + generate: { + combineArgs: { + darkMode: [false, true], + segment: ['day', 'month', 'year'], + size: Object.values(Size), + segments: [ + { + day: '2', + month: '8', + year: '2025', + }, + { + day: '00', + month: '0', + year: '0000', + }, + { + day: '', + month: '', + year: '', + }, + ], + }, + decorator: (StoryFn, context) => ( + + {}} + onBlur={() => {}} + segmentRefs={segmentRefsMock} + segments={context?.args.segments} + size={context?.args.size} + disabled={false} + > + + + + ), + }, + }, +}; +export default meta; + +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 ( + {}} + segmentRefs={segmentRefsMock} + segments={segments} + disabled={false} + size={context?.args?.size || Size.Default} + > + + + ); +}; + +export const Generated = () => {}; 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..c759609a82 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -0,0 +1,103 @@ +import { css, cx } 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: 18px; // Intentionally off-token + `, +}; + +export const getInputSegmentStyles = ({ + className, + baseFontSize, + theme, + size, +}: { + className?: string; + baseFontSize: BaseFontSize; + theme: Theme; + size: Size; +}) => { + return cx( + 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 new file mode 100644 index 0000000000..82d30ad76a --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -0,0 +1,240 @@ +import React, { + ChangeEventHandler, + FocusEvent, + ForwardedRef, + KeyboardEventHandler, +} 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, + getValueFormatter, +} from '../utils'; + +import { getInputSegmentStyles } from './InputSegment.styles'; +import { + InputSegmentComponentType, + InputSegmentProps, +} from './InputSegment.types'; + +/** + * Generic controlled input segment component + * + * Renders a single input segment with configurable + * character padding, validation, and formatting. + * + * @internal + */ +const InputSegmentWithRef = ( + { + segment, + onKeyDown, + min, // minSegmentValue + max, // maxSegmentValue + className, + onChange: onChangeProp, + onBlur: onBlurProp, + step = 1, + shouldWrap = true, + shouldSkipValidation = false, + ...rest + }: InputSegmentProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + const { + onChange, + onBlur, + charsPerSegment: charsPerSegmentContext, + segmentEnum, + segmentRefs, + segments, + labelledBy, + size, + disabled, + } = useInputBoxContext(); + const baseFontSize = useUpdatedBaseFontSize(); + const charsPerSegment = charsPerSegmentContext[segment]; + const formatter = getValueFormatter({ + charsPerSegment, + allowZero: 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, + * and fires a custom `InputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: value, + incomingValue: target.value, + charsPerSegment, + defaultMin: min, + defaultMax: max, + segmentEnum, + shouldSkipValidation, + }); + + 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 + target.value = value; + } + + onChangeProp?.(e); + }; + + /** 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. This will clear the input and the number will be inserted into the input when onChange is called. + + if (target.value.length === charsPerSegment) { + target.value = ''; + } + } + + switch (key) { + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + e.preventDefault(); + + const newValue = getNewSegmentValueFromArrowKeyPress({ + key, + value, + min, + max, + step, + shouldWrap: shouldWrap, + }); + const valueString = formatter(newValue); + + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString, + meta: { key, min }, + }); + 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, min }, + }); + } + + 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, min }, + }); + } + + break; + } + + default: { + break; + } + } + + 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 + return ( + <> + + + {value && `${segment} ${value}`} + + + ); +}; + +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..7cbeaa34db --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -0,0 +1,123 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + +import { keyMap } from '@leafygreen-ui/lib'; + +export interface InputSegmentChangeEvent< + Segment extends string, + Value extends string, +> { + segment: Segment; + value: Value; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + min: number; + [key: string]: any; + }; +} + +// TODO: consider renaming min/max names to minSegment/maxSegment +/** + * The type for the onChange handler + */ +export type InputSegmentChangeEventHandler< + Segment extends string, + Value extends string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' + > { + /** + * Which segment this input represents + * + * @example + * 'day' + * 'month' + * 'year' + */ + segment: Segment; + + /** + * Minimum value for the segment + * + * @example + * 1 + * 1 + * 1970 + */ + min: number; + + /** + * Maximum value for the segment + * + * @example + * 31 + * 12 + * 2038 + */ + max: number; + + /** + * The step value for the arrow keys + * + * @default 1 + */ + step?: number; + + /** + * Whether the segment should wrap at min/max boundaries + * + * @default true + */ + shouldWrap?: boolean; + + /** + * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * + * @default false + */ + shouldSkipValidation?: boolean; +} + +/** + * 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 { + ( + 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); +} + +/** + * 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 new file mode 100644 index 0000000000..8e2840befb --- /dev/null +++ b/packages/input-box/src/InputSegment/index.ts @@ -0,0 +1,6 @@ +export { InputSegment } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, + type InputSegmentProps, +} from './InputSegment.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx new file mode 100644 index 0000000000..80cee23566 --- /dev/null +++ b/packages/input-box/src/testutils/index.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +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'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentProps } from '../InputSegment/InputSegment.types'; + +import { + charsPerSegmentMock, + defaultFormatPartsMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, + segmentWidthStyles, +} from './testutils.mocks'; + +export const defaultProps: Partial> = { + segments: segmentsMock, + segmentEnum: SegmentObjMock, + segmentRefs: segmentRefsMock, + setSegment: () => {}, + charsPerSegment: charsPerSegmentMock, + formatParts: defaultFormatPartsMock, + 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, +}: { + segment: SegmentObjMock; +}) => { + return ( + + ); +}; + +/** + * 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 = ({ + segments: segmentsProp = { + day: '', + month: '', + year: '', + }, + setSegment: setSegmentProp, + disabled = false, + ...props +}: Partial> & { + 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 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 RenderInputBoxReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + rerenderInputBox: (props: Partial>) => void; + getDayInput: () => HTMLInputElement; + getMonthInput: () => HTMLInputElement; + 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 & + RenderInputBoxReturnType => { + const result = render(); + + 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; + + const rerenderInputBox = ( + newProps: Partial>, + ) => { + result.rerender(); + }; + + return { + ...result, + rerenderInputBox, + dayInput: getDayInput(), + monthInput: getMonthInput(), + yearInput: getYearInput(), + getDayInput, + getMonthInput, + getYearInput, + }; +}; + +/* + * InputSegment Utils + */ +export const setSegmentProps = (segment: SegmentObjMock) => { + return { + segment: segment, + charsPerSegment: charsPerSegmentMock[segment], + min: defaultMinMock[segment], + max: defaultMaxMock[segment], + placeholder: defaultPlaceholderMock[segment], + }; +}; + +interface RenderSegmentReturnType { + getInput: () => HTMLInputElement; + input: HTMLInputElement; + rerenderSegment: (params: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => void; +} + +const defaultSegmentProviderProps: Partial< + InputBoxProviderProps +> = { + charsPerSegment: charsPerSegmentMock, + segmentEnum: SegmentObjMock, + onChange: () => {}, + onBlur: () => {}, + segments: { + day: '', + month: '', + year: '', + }, + segmentRefs: segmentRefsMock, +}; + +const defaultSegmentProps: InputSegmentProps = { + segment: 'day', + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + shouldWrap: true, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', +}; + +/** + * Renders the InputSegment component for testing purposes. + */ +export const renderSegment = ({ + props = {}, + providerProps = {}, +}: { + props?: Partial>; + providerProps?: Partial>; +}): RenderResult & RenderSegmentReturnType => { + const mergedProps = { + ...defaultSegmentProps, + ...props, + } as InputSegmentProps; + + const mergedProviderProps = { + ...defaultSegmentProviderProps, + ...providerProps, + } as InputBoxProviderProps; + + const utils = render( + + + , + ); + + const rerenderSegment = ({ + newProps = {}, + newProviderProps = {}, + }: { + newProps?: Partial>; + newProviderProps?: 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/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts new file mode 100644 index 0000000000..d1e062ac30 --- /dev/null +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -0,0 +1,85 @@ +import { createRef } from 'react'; + +import { css } from '@leafygreen-ui/emotion'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +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: 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; + `, +}; diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 3c0ed0b910..447d2f4ac0 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -25,7 +25,11 @@ export interface ExplicitSegmentRule { * * @param segmentEnum - The segment enum/object containing the segment names and their corresponding values 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 segmentObj = { @@ -73,11 +77,19 @@ export function createExplicitSegmentValidator< segmentEnum: SegmentEnum; rules: Record; }) { - return (segment: SegmentEnum[keyof SegmentEnum], value: string): boolean => { + return ( + segment: SegmentEnum[keyof SegmentEnum], + value: string, + allowZero?: boolean, + ): boolean => { if ( - !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) - ) + !( + isValidSegmentValue(value, allowZero) && + isValidSegmentName(segmentEnum, segment) + ) + ) { return false; + } const rule = rules[segment]; if (!rule) return false; From 1c69f5de017034deb79682f650083e1eac322f7d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 15:33:16 -0500 Subject: [PATCH 062/124] remove segement files --- .../src/InputSegment/InputSegment.spec.tsx | 843 ------------------ .../src/InputSegment/InputSegment.stories.tsx | 157 ---- .../src/InputSegment/InputSegment.styles.ts | 103 --- .../src/InputSegment/InputSegment.tsx | 240 ----- .../src/InputSegment/InputSegment.types.ts | 101 --- packages/input-box/src/InputSegment/index.ts | 7 +- packages/input-box/src/testutils/index.tsx | 250 ------ 7 files changed, 1 insertion(+), 1700 deletions(-) delete mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx delete mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.tsx delete mode 100644 packages/input-box/src/InputSegment/InputSegment.styles.ts delete mode 100644 packages/input-box/src/InputSegment/InputSegment.tsx delete mode 100644 packages/input-box/src/testutils/index.tsx diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx deleted file mode 100644 index 150239094f..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ /dev/null @@ -1,843 +0,0 @@ -import React from 'react'; -import userEvent from '@testing-library/user-event'; - -import { renderSegment, setSegmentProps } from '../testutils'; -import { - charsPerSegmentMock, - defaultMaxMock, - defaultMinMock, - SegmentObjMock, -} from '../testutils/testutils.mocks'; -import { getValueFormatter } from '../utils'; - -import { InputSegment, InputSegmentChangeEventHandler } from '.'; - -describe('packages/input-segment', () => { - describe('aria attributes', () => { - test(`segment has aria-label`, () => { - const { input } = renderSegment({ - props: { segment: 'day' }, - }); - 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', () => { - 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'); - }); - }); - - describe('typing', () => { - describe('into an empty segment', () => { - test('calls the change handler', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { 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({ - providerProps: { 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({ - providerProps: { 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({ - providerProps: { - segments: { day: '2', month: '', year: '' }, - 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({ - providerProps: { - segments: { day: '26', month: '', year: '' }, - onChange: onChangeHandler, - }, - }); - - userEvent.type(input, '4'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '4' }), - ); - }); - }); - - describe('keyboard events', () => { - describe('Arrow keys', () => { - const formatter = getValueFormatter({ - charsPerSegment: charsPerSegmentMock['day'], - allowZero: defaultMinMock['day'] === 0, - }); - - describe('Up arrow', () => { - test('calls handler with value default +1 step', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - 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({ - props: { segment: 'day', step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - 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({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '', month: '', year: '' }, - }, - }); - - 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({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMinMock['day']), - }), - ); - }); - - test('does not wrap if `shouldWrap` is false', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMaxMock['day'] + 1), - }), - ); - }); - - 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 - >; - 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', () => { - test('calls handler with value default -1 step', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - 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({ - props: { step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - 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({ - props: { segment: 'day' }, - providerProps: { onChange: onChangeHandler }, - }); - - 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({ - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMaxMock['day']), - }), - ); - }); - - test('does not wrap if `shouldWrap` is false', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMinMock['day'] - 1), - }), - ); - }); - - 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, - 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', () => { - test('clears the input when there is a value', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, - }); - - 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({ - providerProps: { 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({ - providerProps: { 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({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, - }); - - 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({ - providerProps: { 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({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{space}{space}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '' }), - ); - }); - }); - }); - }); - }); - - 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', () => { - 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(); - }); - }); - - 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(); - }); - }); - - /* 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', () => { - ; - }); - - test('With all props', () => { - {}} - onKeyDown={() => {}} - disabled={false} - data-testid="test-id" - id="day" - ref={React.createRef()} - />; - }); - }); -}); diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx deleted file mode 100644 index 459f6b9d8e..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ /dev/null @@ -1,157 +0,0 @@ -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 { InputBoxProvider } from '../InputBoxContext'; -import { - charsPerSegmentMock, - defaultMaxMock, - defaultMinMock, - defaultPlaceholderMock, - SegmentObjMock, - segmentRefsMock, - segmentsMock, -} from '../testutils/testutils.mocks'; - -import { InputSegment, InputSegmentChangeEventHandler } from '.'; - -interface InputSegmentStoryProps { - size: Size; - segments: Record; -} - -const meta: StoryMetaType = { - title: 'Components/Inputs/InputBox/InputSegment', - component: InputSegment, - decorators: [ - (StoryFn, context: any) => ( - - - - ), - ], - args: { - segment: SegmentObjMock.Day, - min: defaultMinMock[SegmentObjMock.Day], - max: defaultMaxMock[SegmentObjMock.Day], - size: Size.Default, - placeholder: defaultPlaceholderMock[SegmentObjMock.Day], - shouldWrap: true, - step: 1, - darkMode: false, - }, - argTypes: { - size: { - control: 'select', - options: Object.values(Size), - }, - darkMode: { - control: 'boolean', - }, - }, - parameters: { - default: 'LiveExample', - controls: { - exclude: [ - ...storybookExcludedControlParams, - 'segment', - 'value', - 'onChange', - 'charsPerSegment', - 'segmentEnum', - 'min', - 'max', - 'shouldWrap', - 'shouldSkipValidation', - 'step', - 'placeholder', - ], - }, - generate: { - combineArgs: { - darkMode: [false, true], - segment: ['day', 'month', 'year'], - size: Object.values(Size), - segments: [ - { - day: '2', - month: '8', - year: '2025', - }, - { - day: '00', - month: '0', - year: '0000', - }, - { - day: '', - month: '', - year: '', - }, - ], - }, - decorator: (StoryFn, context) => ( - - {}} - onBlur={() => {}} - segmentRefs={segmentRefsMock} - segments={context?.args.segments} - size={context?.args.size} - disabled={false} - > - - - - ), - }, - }, -}; -export default meta; - -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 ( - {}} - segmentRefs={segmentRefsMock} - segments={segments} - disabled={false} - size={context?.args?.size || Size.Default} - > - - - ); -}; - -export const Generated = () => {}; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts deleted file mode 100644 index c759609a82..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { css, cx } 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: 18px; // Intentionally off-token - `, -}; - -export const getInputSegmentStyles = ({ - className, - baseFontSize, - theme, - size, -}: { - className?: string; - baseFontSize: BaseFontSize; - theme: Theme; - size: Size; -}) => { - return cx( - 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 deleted file mode 100644 index 82d30ad76a..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { - ChangeEventHandler, - FocusEvent, - ForwardedRef, - KeyboardEventHandler, -} 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, - getValueFormatter, -} from '../utils'; - -import { getInputSegmentStyles } from './InputSegment.styles'; -import { - InputSegmentComponentType, - InputSegmentProps, -} from './InputSegment.types'; - -/** - * Generic controlled input segment component - * - * Renders a single input segment with configurable - * character padding, validation, and formatting. - * - * @internal - */ -const InputSegmentWithRef = ( - { - segment, - onKeyDown, - min, // minSegmentValue - max, // maxSegmentValue - className, - onChange: onChangeProp, - onBlur: onBlurProp, - step = 1, - shouldWrap = true, - shouldSkipValidation = false, - ...rest - }: InputSegmentProps, - fwdRef: ForwardedRef, -) => { - const { theme } = useDarkMode(); - const { - onChange, - onBlur, - charsPerSegment: charsPerSegmentContext, - segmentEnum, - segmentRefs, - segments, - labelledBy, - size, - disabled, - } = useInputBoxContext(); - const baseFontSize = useUpdatedBaseFontSize(); - const charsPerSegment = charsPerSegmentContext[segment]; - const formatter = getValueFormatter({ - charsPerSegment, - allowZero: 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, - * and fires a custom `InputSegmentChangeEvent`. - */ - const handleChange: ChangeEventHandler = e => { - const { target } = e; - - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: value, - incomingValue: target.value, - charsPerSegment, - defaultMin: min, - defaultMax: max, - segmentEnum, - shouldSkipValidation, - }); - - 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 - target.value = value; - } - - onChangeProp?.(e); - }; - - /** 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. This will clear the input and the number will be inserted into the input when onChange is called. - - if (target.value.length === charsPerSegment) { - target.value = ''; - } - } - - switch (key) { - case keyMap.ArrowUp: - case keyMap.ArrowDown: { - e.preventDefault(); - - const newValue = getNewSegmentValueFromArrowKeyPress({ - key, - value, - min, - max, - step, - shouldWrap: shouldWrap, - }); - const valueString = formatter(newValue); - - /** Fire a custom change event when the up/down arrow keys are pressed */ - onChange({ - segment, - value: valueString, - meta: { key, min }, - }); - 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, min }, - }); - } - - 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, min }, - }); - } - - break; - } - - default: { - break; - } - } - - 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 - return ( - <> - - - {value && `${segment} ${value}`} - - - ); -}; - -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 index 7cbeaa34db..9d0d5b1e8e 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,5 +1,3 @@ -import React, { ForwardedRef, ReactElement } from 'react'; - import { keyMap } from '@leafygreen-ui/lib'; export interface InputSegmentChangeEvent< @@ -15,7 +13,6 @@ export interface InputSegmentChangeEvent< }; } -// TODO: consider renaming min/max names to minSegment/maxSegment /** * The type for the onChange handler */ @@ -23,101 +20,3 @@ export type InputSegmentChangeEventHandler< Segment extends string, Value extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; - -export interface InputSegmentProps - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' - > { - /** - * Which segment this input represents - * - * @example - * 'day' - * 'month' - * 'year' - */ - segment: Segment; - - /** - * Minimum value for the segment - * - * @example - * 1 - * 1 - * 1970 - */ - min: number; - - /** - * Maximum value for the segment - * - * @example - * 31 - * 12 - * 2038 - */ - max: number; - - /** - * The step value for the arrow keys - * - * @default 1 - */ - step?: number; - - /** - * Whether the segment should wrap at min/max boundaries - * - * @default true - */ - shouldWrap?: boolean; - - /** - * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. - * - * @default false - */ - shouldSkipValidation?: boolean; -} - -/** - * 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 { - ( - 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); -} - -/** - * 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 8e2840befb..7e21581ebf 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1,6 +1 @@ -export { InputSegment } from './InputSegment'; -export { - type InputSegmentChangeEventHandler, - type InputSegmentComponentProps, - type InputSegmentProps, -} from './InputSegment.types'; +export { type InputSegmentChangeEventHandler } from './InputSegment.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx deleted file mode 100644 index 80cee23566..0000000000 --- a/packages/input-box/src/testutils/index.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import React from 'react'; -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'; -import { InputSegment } from '../InputSegment'; -import { InputSegmentProps } from '../InputSegment/InputSegment.types'; - -import { - charsPerSegmentMock, - defaultFormatPartsMock, - defaultMaxMock, - defaultMinMock, - defaultPlaceholderMock, - SegmentObjMock, - segmentRefsMock, - segmentRulesMock, - segmentsMock, - segmentWidthStyles, -} from './testutils.mocks'; - -export const defaultProps: Partial> = { - segments: segmentsMock, - segmentEnum: SegmentObjMock, - segmentRefs: segmentRefsMock, - setSegment: () => {}, - charsPerSegment: charsPerSegmentMock, - formatParts: defaultFormatPartsMock, - 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, -}: { - segment: SegmentObjMock; -}) => { - return ( - - ); -}; - -/** - * 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 = ({ - segments: segmentsProp = { - day: '', - month: '', - year: '', - }, - setSegment: setSegmentProp, - disabled = false, - ...props -}: Partial> & { - 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 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 RenderInputBoxReturnType { - dayInput: HTMLInputElement; - monthInput: HTMLInputElement; - yearInput: HTMLInputElement; - rerenderInputBox: (props: Partial>) => void; - getDayInput: () => HTMLInputElement; - getMonthInput: () => HTMLInputElement; - 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 & - RenderInputBoxReturnType => { - const result = render(); - - 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; - - const rerenderInputBox = ( - newProps: Partial>, - ) => { - result.rerender(); - }; - - return { - ...result, - rerenderInputBox, - dayInput: getDayInput(), - monthInput: getMonthInput(), - yearInput: getYearInput(), - getDayInput, - getMonthInput, - getYearInput, - }; -}; - -/* - * InputSegment Utils - */ -export const setSegmentProps = (segment: SegmentObjMock) => { - return { - segment: segment, - charsPerSegment: charsPerSegmentMock[segment], - min: defaultMinMock[segment], - max: defaultMaxMock[segment], - placeholder: defaultPlaceholderMock[segment], - }; -}; - -interface RenderSegmentReturnType { - getInput: () => HTMLInputElement; - input: HTMLInputElement; - rerenderSegment: (params: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => void; -} - -const defaultSegmentProviderProps: Partial< - InputBoxProviderProps -> = { - charsPerSegment: charsPerSegmentMock, - segmentEnum: SegmentObjMock, - onChange: () => {}, - onBlur: () => {}, - segments: { - day: '', - month: '', - year: '', - }, - segmentRefs: segmentRefsMock, -}; - -const defaultSegmentProps: InputSegmentProps = { - segment: 'day', - min: defaultMinMock['day'], - max: defaultMaxMock['day'], - shouldWrap: true, - placeholder: defaultPlaceholderMock['day'], - // @ts-expect-error - data-testid - ['data-testid']: 'lg-input-segment', -}; - -/** - * Renders the InputSegment component for testing purposes. - */ -export const renderSegment = ({ - props = {}, - providerProps = {}, -}: { - props?: Partial>; - providerProps?: Partial>; -}): RenderResult & RenderSegmentReturnType => { - const mergedProps = { - ...defaultSegmentProps, - ...props, - } as InputSegmentProps; - - const mergedProviderProps = { - ...defaultSegmentProviderProps, - ...providerProps, - } as InputBoxProviderProps; - - const utils = render( - - - , - ); - - const rerenderSegment = ({ - newProps = {}, - newProviderProps = {}, - }: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => { - utils.rerender( - - - , - ); - }; - - const getInput = () => - utils.getByTestId('lg-input-segment') as HTMLInputElement; - return { ...utils, getInput, input: getInput(), rerenderSegment }; -}; From 691bde94912f0449246f9c4fb1027d6686a29ac2 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 16:49:28 -0500 Subject: [PATCH 063/124] feat(input-box): implement InputSegment component with styles, tests, and stories --- .../src/InputSegment/InputSegment.spec.tsx | 843 ++++++++++++++++++ .../src/InputSegment/InputSegment.stories.tsx | 157 ++++ .../src/InputSegment/InputSegment.styles.ts | 103 +++ .../src/InputSegment/InputSegment.tsx | 240 +++++ .../src/InputSegment/InputSegment.types.ts | 101 +++ packages/input-box/src/InputSegment/index.ts | 7 +- packages/input-box/src/testutils/index.tsx | 250 ++++++ 7 files changed, 1700 insertions(+), 1 deletion(-) create mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.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/testutils/index.tsx 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..150239094f --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -0,0 +1,843 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { renderSegment, setSegmentProps } from '../testutils'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + SegmentObjMock, +} from '../testutils/testutils.mocks'; +import { getValueFormatter } from '../utils'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +describe('packages/input-segment', () => { + describe('aria attributes', () => { + test(`segment has aria-label`, () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + 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', () => { + 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'); + }); + }); + + describe('typing', () => { + describe('into an empty segment', () => { + test('calls the change handler', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { + segments: { day: '2', month: '', year: '' }, + 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({ + providerProps: { + segments: { day: '26', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); + }); + + describe('keyboard events', () => { + describe('Arrow keys', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegmentMock['day'], + allowZero: defaultMinMock['day'] === 0, + }); + + describe('Up arrow', () => { + test('calls handler with value default +1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day', step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '', month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day'] + 1), + }), + ); + }); + + 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 + >; + 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', () => { + test('calls handler with value default -1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + 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({ + props: { segment: 'day' }, + providerProps: { onChange: onChangeHandler }, + }); + + 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({ + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day'] - 1), + }), + ); + }); + + 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, + 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', () => { + test('clears the input when there is a value', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + 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({ + providerProps: { 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({ + providerProps: { 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({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + 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({ + providerProps: { 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({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + }); + }); + }); + + 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', () => { + 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(); + }); + }); + + 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(); + }); + }); + + /* 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', () => { + ; + }); + + test('With all props', () => { + {}} + onKeyDown={() => {}} + disabled={false} + data-testid="test-id" + id="day" + ref={React.createRef()} + />; + }); + }); +}); 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..459f6b9d8e --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -0,0 +1,157 @@ +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 { InputBoxProvider } from '../InputBoxContext'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +interface InputSegmentStoryProps { + size: Size; + segments: Record; +} + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox/InputSegment', + component: InputSegment, + decorators: [ + (StoryFn, context: any) => ( + + + + ), + ], + args: { + segment: SegmentObjMock.Day, + min: defaultMinMock[SegmentObjMock.Day], + max: defaultMaxMock[SegmentObjMock.Day], + size: Size.Default, + placeholder: defaultPlaceholderMock[SegmentObjMock.Day], + shouldWrap: true, + step: 1, + darkMode: false, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + darkMode: { + control: 'boolean', + }, + }, + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segment', + 'value', + 'onChange', + 'charsPerSegment', + 'segmentEnum', + 'min', + 'max', + 'shouldWrap', + 'shouldSkipValidation', + 'step', + 'placeholder', + ], + }, + generate: { + combineArgs: { + darkMode: [false, true], + segment: ['day', 'month', 'year'], + size: Object.values(Size), + segments: [ + { + day: '2', + month: '8', + year: '2025', + }, + { + day: '00', + month: '0', + year: '0000', + }, + { + day: '', + month: '', + year: '', + }, + ], + }, + decorator: (StoryFn, context) => ( + + {}} + onBlur={() => {}} + segmentRefs={segmentRefsMock} + segments={context?.args.segments} + size={context?.args.size} + disabled={false} + > + + + + ), + }, + }, +}; +export default meta; + +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 ( + {}} + segmentRefs={segmentRefsMock} + segments={segments} + disabled={false} + size={context?.args?.size || Size.Default} + > + + + ); +}; + +export const Generated = () => {}; 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..c759609a82 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -0,0 +1,103 @@ +import { css, cx } 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: 18px; // Intentionally off-token + `, +}; + +export const getInputSegmentStyles = ({ + className, + baseFontSize, + theme, + size, +}: { + className?: string; + baseFontSize: BaseFontSize; + theme: Theme; + size: Size; +}) => { + return cx( + 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 new file mode 100644 index 0000000000..82d30ad76a --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -0,0 +1,240 @@ +import React, { + ChangeEventHandler, + FocusEvent, + ForwardedRef, + KeyboardEventHandler, +} 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, + getValueFormatter, +} from '../utils'; + +import { getInputSegmentStyles } from './InputSegment.styles'; +import { + InputSegmentComponentType, + InputSegmentProps, +} from './InputSegment.types'; + +/** + * Generic controlled input segment component + * + * Renders a single input segment with configurable + * character padding, validation, and formatting. + * + * @internal + */ +const InputSegmentWithRef = ( + { + segment, + onKeyDown, + min, // minSegmentValue + max, // maxSegmentValue + className, + onChange: onChangeProp, + onBlur: onBlurProp, + step = 1, + shouldWrap = true, + shouldSkipValidation = false, + ...rest + }: InputSegmentProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + const { + onChange, + onBlur, + charsPerSegment: charsPerSegmentContext, + segmentEnum, + segmentRefs, + segments, + labelledBy, + size, + disabled, + } = useInputBoxContext(); + const baseFontSize = useUpdatedBaseFontSize(); + const charsPerSegment = charsPerSegmentContext[segment]; + const formatter = getValueFormatter({ + charsPerSegment, + allowZero: 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, + * and fires a custom `InputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: value, + incomingValue: target.value, + charsPerSegment, + defaultMin: min, + defaultMax: max, + segmentEnum, + shouldSkipValidation, + }); + + 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 + target.value = value; + } + + onChangeProp?.(e); + }; + + /** 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. This will clear the input and the number will be inserted into the input when onChange is called. + + if (target.value.length === charsPerSegment) { + target.value = ''; + } + } + + switch (key) { + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + e.preventDefault(); + + const newValue = getNewSegmentValueFromArrowKeyPress({ + key, + value, + min, + max, + step, + shouldWrap: shouldWrap, + }); + const valueString = formatter(newValue); + + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString, + meta: { key, min }, + }); + 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, min }, + }); + } + + 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, min }, + }); + } + + break; + } + + default: { + break; + } + } + + 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 + return ( + <> + + + {value && `${segment} ${value}`} + + + ); +}; + +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 index 9d0d5b1e8e..7cbeaa34db 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,3 +1,5 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + import { keyMap } from '@leafygreen-ui/lib'; export interface InputSegmentChangeEvent< @@ -13,6 +15,7 @@ export interface InputSegmentChangeEvent< }; } +// TODO: consider renaming min/max names to minSegment/maxSegment /** * The type for the onChange handler */ @@ -20,3 +23,101 @@ export type InputSegmentChangeEventHandler< Segment extends string, Value extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' + > { + /** + * Which segment this input represents + * + * @example + * 'day' + * 'month' + * 'year' + */ + segment: Segment; + + /** + * Minimum value for the segment + * + * @example + * 1 + * 1 + * 1970 + */ + min: number; + + /** + * Maximum value for the segment + * + * @example + * 31 + * 12 + * 2038 + */ + max: number; + + /** + * The step value for the arrow keys + * + * @default 1 + */ + step?: number; + + /** + * Whether the segment should wrap at min/max boundaries + * + * @default true + */ + shouldWrap?: boolean; + + /** + * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * + * @default false + */ + shouldSkipValidation?: boolean; +} + +/** + * 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 { + ( + 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); +} + +/** + * 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 7e21581ebf..8e2840befb 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1 +1,6 @@ -export { type InputSegmentChangeEventHandler } from './InputSegment.types'; +export { InputSegment } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, + type InputSegmentProps, +} from './InputSegment.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx new file mode 100644 index 0000000000..80cee23566 --- /dev/null +++ b/packages/input-box/src/testutils/index.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +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'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentProps } from '../InputSegment/InputSegment.types'; + +import { + charsPerSegmentMock, + defaultFormatPartsMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, + segmentWidthStyles, +} from './testutils.mocks'; + +export const defaultProps: Partial> = { + segments: segmentsMock, + segmentEnum: SegmentObjMock, + segmentRefs: segmentRefsMock, + setSegment: () => {}, + charsPerSegment: charsPerSegmentMock, + formatParts: defaultFormatPartsMock, + 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, +}: { + segment: SegmentObjMock; +}) => { + return ( + + ); +}; + +/** + * 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 = ({ + segments: segmentsProp = { + day: '', + month: '', + year: '', + }, + setSegment: setSegmentProp, + disabled = false, + ...props +}: Partial> & { + 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 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 RenderInputBoxReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + rerenderInputBox: (props: Partial>) => void; + getDayInput: () => HTMLInputElement; + getMonthInput: () => HTMLInputElement; + 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 & + RenderInputBoxReturnType => { + const result = render(); + + 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; + + const rerenderInputBox = ( + newProps: Partial>, + ) => { + result.rerender(); + }; + + return { + ...result, + rerenderInputBox, + dayInput: getDayInput(), + monthInput: getMonthInput(), + yearInput: getYearInput(), + getDayInput, + getMonthInput, + getYearInput, + }; +}; + +/* + * InputSegment Utils + */ +export const setSegmentProps = (segment: SegmentObjMock) => { + return { + segment: segment, + charsPerSegment: charsPerSegmentMock[segment], + min: defaultMinMock[segment], + max: defaultMaxMock[segment], + placeholder: defaultPlaceholderMock[segment], + }; +}; + +interface RenderSegmentReturnType { + getInput: () => HTMLInputElement; + input: HTMLInputElement; + rerenderSegment: (params: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => void; +} + +const defaultSegmentProviderProps: Partial< + InputBoxProviderProps +> = { + charsPerSegment: charsPerSegmentMock, + segmentEnum: SegmentObjMock, + onChange: () => {}, + onBlur: () => {}, + segments: { + day: '', + month: '', + year: '', + }, + segmentRefs: segmentRefsMock, +}; + +const defaultSegmentProps: InputSegmentProps = { + segment: 'day', + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + shouldWrap: true, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', +}; + +/** + * Renders the InputSegment component for testing purposes. + */ +export const renderSegment = ({ + props = {}, + providerProps = {}, +}: { + props?: Partial>; + providerProps?: Partial>; +}): RenderResult & RenderSegmentReturnType => { + const mergedProps = { + ...defaultSegmentProps, + ...props, + } as InputSegmentProps; + + const mergedProviderProps = { + ...defaultSegmentProviderProps, + ...providerProps, + } as InputBoxProviderProps; + + const utils = render( + + + , + ); + + const rerenderSegment = ({ + newProps = {}, + newProviderProps = {}, + }: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => { + utils.rerender( + + + , + ); + }; + + const getInput = () => + utils.getByTestId('lg-input-segment') as HTMLInputElement; + return { ...utils, getInput, input: getInput(), rerenderSegment }; +}; From b2984f37738b3c1c970d18e5aded769c73e9165d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 16:50:17 -0500 Subject: [PATCH 064/124] feat(input-box): add @leafygreen-ui/a11y dependency and update InputSegment component references --- packages/input-box/package.json | 1 + .../src/InputSegment/InputSegment.stories.tsx | 3 - .../src/InputSegment/InputSegment.types.ts | 1 - packages/input-box/src/testutils/index.tsx | 148 +----------------- packages/input-box/tsconfig.json | 3 + pnpm-lock.yaml | 3 + tools/install/src/ALL_PACKAGES.ts | 3 + 7 files changed, 13 insertions(+), 149 deletions(-) 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/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 459f6b9d8e..b31cd67f7f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -65,9 +65,6 @@ const meta: StoryMetaType = { 'onChange', 'charsPerSegment', 'segmentEnum', - 'min', - 'max', - 'shouldWrap', 'shouldSkipValidation', 'step', 'placeholder', diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 7cbeaa34db..84f6997a39 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -15,7 +15,6 @@ export interface InputSegmentChangeEvent< }; } -// TODO: consider renaming min/max names to minSegment/maxSegment /** * The type for the onChange handler */ diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 80cee23566..4ef3b1941b 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,162 +1,20 @@ import React from 'react'; 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'; -import { InputSegment } from '../InputSegment'; +import { InputBoxProvider } from '../InputBoxContext/InputBoxContext'; +import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext.types'; +import { InputSegment } from '../InputSegment/InputSegment'; import { InputSegmentProps } from '../InputSegment/InputSegment.types'; import { charsPerSegmentMock, - defaultFormatPartsMock, defaultMaxMock, defaultMinMock, defaultPlaceholderMock, SegmentObjMock, segmentRefsMock, - segmentRulesMock, - segmentsMock, - segmentWidthStyles, } from './testutils.mocks'; -export const defaultProps: Partial> = { - segments: segmentsMock, - segmentEnum: SegmentObjMock, - segmentRefs: segmentRefsMock, - setSegment: () => {}, - charsPerSegment: charsPerSegmentMock, - formatParts: defaultFormatPartsMock, - 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, -}: { - segment: SegmentObjMock; -}) => { - return ( - - ); -}; - -/** - * 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 = ({ - segments: segmentsProp = { - day: '', - month: '', - year: '', - }, - setSegment: setSegmentProp, - disabled = false, - ...props -}: Partial> & { - 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 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 RenderInputBoxReturnType { - dayInput: HTMLInputElement; - monthInput: HTMLInputElement; - yearInput: HTMLInputElement; - rerenderInputBox: (props: Partial>) => void; - getDayInput: () => HTMLInputElement; - getMonthInput: () => HTMLInputElement; - 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 & - RenderInputBoxReturnType => { - const result = render(); - - 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; - - const rerenderInputBox = ( - newProps: Partial>, - ) => { - result.rerender(); - }; - - return { - ...result, - rerenderInputBox, - dayInput: getDayInput(), - monthInput: getMonthInput(), - yearInput: getYearInput(), - getDayInput, - getMonthInput, - getYearInput, - }; -}; - /* * InputSegment Utils */ 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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de735ab2c..bb53b8e9d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2255,6 +2255,9 @@ importers: packages/input-box: dependencies: + '@leafygreen-ui/a11y': + specifier: workspace:^ + version: link:../a11y '@leafygreen-ui/date-utils': specifier: workspace:^ version: link:../date-utils diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index 67b943e5fe..2486ff963e 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -15,6 +15,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/code', '@leafygreen-ui/code-editor', '@leafygreen-ui/combobox', + '@leafygreen-ui/compound-component', '@leafygreen-ui/confirmation-modal', '@leafygreen-ui/context-drawer', '@leafygreen-ui/copyable', @@ -25,6 +26,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/emotion', '@leafygreen-ui/empty-state', '@leafygreen-ui/expandable-card', + '@leafygreen-ui/feature-walls', '@leafygreen-ui/form-field', '@leafygreen-ui/form-footer', '@leafygreen-ui/gallery-indicator', @@ -34,6 +36,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/icon-button', '@leafygreen-ui/info-sprinkle', '@leafygreen-ui/inline-definition', + '@leafygreen-ui/input-box', '@leafygreen-ui/input-option', '@leafygreen-ui/leafygreen-provider', '@leafygreen-ui/lib', From b0d7bbaab125bda638f7b45e2be8ed32e546c439 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 17:39:41 -0500 Subject: [PATCH 065/124] refactor(input-box): update createExplicitSegmentValidator tests to use object parameter format for improved clarity and consistency --- .../createExplicitSegmentValidator.spec.ts | 343 +++++++++++++++--- .../createExplicitSegmentValidator.ts | 34 +- 2 files changed, 312 insertions(+), 65 deletions(-) diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts index 535e7096a1..df950376f3 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -24,115 +24,362 @@ const isExplicitSegmentValue = createExplicitSegmentValidator({ 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); + expect(isExplicitSegmentValue({ segment: 'day', value: '1' })).toBe( + false, + ); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '2', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '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); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '4', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '5', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '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); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '01', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '10', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '22', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '31', + }), + ).toBe(true); }); test('returns false for invalid values', () => { - expect(isExplicitSegmentValue('day', '0')).toBe(false); - expect(isExplicitSegmentValue('day', '')).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '0', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ segment: 'day', value: '', allowZero: false }), + ).toBe(false); }); }); describe('month segment', () => { test('returns false for single digit below minExplicitValue', () => { - expect(isExplicitSegmentValue('month', '1')).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '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); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '2', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '3', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '9', + }), + ).toBe(true); }); test('returns true for two-digit values (maxChars)', () => { - expect(isExplicitSegmentValue('month', '01')).toBe(true); - expect(isExplicitSegmentValue('month', '12')).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '01', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '12', + }), + ).toBe(true); }); test('returns false for invalid values', () => { - expect(isExplicitSegmentValue('month', '0')).toBe(false); - expect(isExplicitSegmentValue('month', '')).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '0', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'month', + value: '', + }), + ).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); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '1', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '20', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '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); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '1970', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '2000', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '2023', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '0001', + }), + ).toBe(true); }); test('returns false for invalid values', () => { - expect(isExplicitSegmentValue('year', '0')).toBe(false); - expect(isExplicitSegmentValue('year', '')).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '0', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'year', + value: '', + }), + ).toBe(false); }); }); describe('hour segment', () => { test('returns false for single digit below minExplicitValue', () => { - expect(isExplicitSegmentValue('hour', '1')).toBe(false); - expect(isExplicitSegmentValue('hour', '0')).toBe(false); - expect(isExplicitSegmentValue('hour', '2')).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '1', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '0', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '2', + }), + ).toBe(false); }); test('returns true for single digit at or above minExplicitValue', () => { - expect(isExplicitSegmentValue('hour', '3')).toBe(true); - expect(isExplicitSegmentValue('hour', '9')).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '3', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '9', + }), + ).toBe(true); }); test('returns true for two-digit values at or above minExplicitValue', () => { - expect(isExplicitSegmentValue('hour', '12')).toBe(true); - expect(isExplicitSegmentValue('hour', '23')).toBe(true); - expect(isExplicitSegmentValue('hour', '05')).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '12', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '23', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'hour', + value: '05', + }), + ).toBe(true); }); }); describe('minute segment', () => { test('returns false for single digit below minExplicitValue', () => { - expect(isExplicitSegmentValue('minute', '0')).toBe(false); - expect(isExplicitSegmentValue('minute', '1')).toBe(false); - expect(isExplicitSegmentValue('minute', '5')).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'minute', + value: '0', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'minute', + value: '1', + }), + ).toBe(false); + expect( + isExplicitSegmentValue({ + segment: 'minute', + value: '5', + }), + ).toBe(false); }); test('returns true for single digit at or above minExplicitValue', () => { - expect(isExplicitSegmentValue('minute', '6')).toBe(true); - expect(isExplicitSegmentValue('minute', '7')).toBe(true); - expect(isExplicitSegmentValue('minute', '9')).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'minute', + value: '6', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'minute', + value: '7', + }), + ).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'minute', + value: '9', + }), + ).toBe(true); }); test('returns true for two-digit values at or above minExplicitValue', () => { - expect(isExplicitSegmentValue('minute', '59')).toBe(true); + expect( + isExplicitSegmentValue({ + segment: 'minute', + value: '59', + }), + ).toBe(true); + }); + }); + + describe('allowZero', () => { + test('returns false when allowZero is false', () => { + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '00', + allowZero: false, + }), + ).toBe(false); + }); + + test('returns true when allowZero is true', () => { + expect( + isExplicitSegmentValue({ + segment: 'day', + value: '00', + allowZero: true, + }), + ).toBe(true); }); }); 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('millisecond', '12')).toBe(false); + expect( + isExplicitSegmentValue({ + // @ts-expect-error Testing invalid segment + segment: 'invalid', + value: '10', + }), + ).toBe(false); + + expect( + isExplicitSegmentValue({ + // @ts-expect-error Testing invalid segment + segment: 'millisecond', + value: '12', + }), + ).toBe(false); }); }); }); diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 447d2f4ac0..cd3396a34c 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -43,7 +43,6 @@ export interface ExplicitSegmentRule { * day: { maxChars: 2, minExplicitValue: 4 }, * month: { maxChars: 2, minExplicitValue: 2 }, * year: { maxChars: 4 }, - * hour: { maxChars: 2, minExplicitValue: 3 }, * minute: { maxChars: 2, minExplicitValue: 6 }, * }; * @@ -56,17 +55,14 @@ export interface ExplicitSegmentRule { * rules, * }); * - * isExplicitSegmentValue('day', '1'); // false (Ambiguous - below min value and max length) - * isExplicitSegmentValue('day', '01'); // true (Explicit - meets max length) - * isExplicitSegmentValue('day', '4'); // true (Explicit - meets min value) - * isExplicitSegmentValue('year', '2000'); // true (Explicit - meets max length) - * isExplicitSegmentValue('year', '1'); // false (Ambiguous - below max length) - * isExplicitSegmentValue('hour', '05'); // true (Explicit - meets min value) - * isExplicitSegmentValue('hour', '23'); // true (Explicit - meets max length) - * isExplicitSegmentValue('hour', '2'); // false (Ambiguous - below min value) - * isExplicitSegmentValue('minute', '07'); // true (Explicit - meets min value) - * isExplicitSegmentValue('minute', '59'); // true (Explicit - meets max length) - * isExplicitSegmentValue('minute', '5'); // false (Ambiguous - below min value) + * isExplicitSegmentValue({ segment: 'day', value: '1', allowZero: false }); // false (Ambiguous - below min value and max length) + * isExplicitSegmentValue({ segment: 'day', value: '01', allowZero: false }); // true (Explicit - meets max length) + * isExplicitSegmentValue({ segment: 'day', value: '4', allowZero: false }); // true (Explicit - meets min value) + * isExplicitSegmentValue({ segment: 'year', value: '2000', allowZero: false }); // true (Explicit - meets max length) + * isExplicitSegmentValue({ segment: 'year', value: '1', allowZero: false }); // false (Ambiguous - below max length) + * isExplicitSegmentValue({ segment: 'minute', value: '07', allowZero: false }); // true (Explicit - meets min value) + * isExplicitSegmentValue({ segment: 'minute', value: '59', allowZero: false }); // true (Explicit - meets max length) + * isExplicitSegmentValue({ segment: 'minute', value: '5', allowZero: false }); // false (Ambiguous - below min value) */ export function createExplicitSegmentValidator< SegmentEnum extends Record, @@ -77,11 +73,15 @@ export function createExplicitSegmentValidator< segmentEnum: SegmentEnum; rules: Record; }) { - return ( - segment: SegmentEnum[keyof SegmentEnum], - value: string, - allowZero?: boolean, - ): boolean => { + return ({ + segment, + value, + allowZero = false, + }: { + segment: SegmentEnum[keyof SegmentEnum]; + value: string; + allowZero?: boolean; + }): boolean => { if ( !( isValidSegmentValue(value, allowZero) && From 39868974760c997dfb36fac57608acf3e10dd2c9 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 17:40:45 -0500 Subject: [PATCH 066/124] test(input-box): refactor InputBoxContext tests for improved readability by destructuring context values --- .../InputBoxContext/InputBoxContext.spec.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index 9ff76d1558..6f2b22a0db 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -56,13 +56,24 @@ describe('InputBoxContext', () => { ), }); - expect(result.current.charsPerSegment).toBe(charsPerSegmentMock); - 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); - expect(result.current.size).toBe(Size.Default); - expect(result.current.disabled).toBe(false); + const { + charsPerSegment, + segmentEnum, + onChange, + onBlur, + segmentRefs, + segments, + size, + disabled, + } = result.current; + + expect(charsPerSegment).toBe(charsPerSegmentMock); + expect(segmentEnum).toBe(SegmentObjMock); + expect(onChange).toBe(mockOnChange); + expect(onBlur).toBe(mockOnBlur); + expect(segmentRefs).toBe(segmentRefsMock); + expect(segments).toBe(segmentsMock); + expect(size).toBe(Size.Default); + expect(disabled).toBe(false); }); }); From 2eda96e54a2cbdf74e94a3067b7a2086261cbfca Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 10:38:52 -0500 Subject: [PATCH 067/124] refactor(input-box): update InputBoxContext types to extend SharedInputBoxTypes and remove deprecated InputSegment types --- .../InputBoxContext/InputBoxContext.types.ts | 27 +++++---- packages/input-box/src/InputSegment/index.ts | 1 - .../types}/InputSegment.types.ts | 0 packages/input-box/src/shared/types/index.ts | 5 ++ packages/input-box/src/shared/types/types.ts | 55 +++++++++++++++++++ 5 files changed, 73 insertions(+), 15 deletions(-) delete mode 100644 packages/input-box/src/InputSegment/index.ts rename packages/input-box/src/{InputSegment => shared/types}/InputSegment.types.ts (100%) create mode 100644 packages/input-box/src/shared/types/index.ts create mode 100644 packages/input-box/src/shared/types/types.ts diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts index 40f47a35c7..3752b6fbdb 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts @@ -1,20 +1,19 @@ -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; -import { Size } from '@leafygreen-ui/tokens'; +import { + InputSegmentChangeEventHandler, + SharedInputBoxTypes, +} from '../shared/types'; -import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; - -type SegmentEnumObject = Record; - -export interface InputBoxContextType { - charsPerSegment: Record; - disabled: boolean; - segmentEnum: SegmentEnumObject; +export interface InputBoxContextType + extends SharedInputBoxTypes { + /** + * The handler for the onChange event that will be read in the InputSegment component + */ onChange: InputSegmentChangeEventHandler; + + /** + * The handler for the onBlur event that will be read by the InputSegment component + */ onBlur: (event: React.FocusEvent) => void; - segmentRefs: Record>>; - segments: Record; - labelledBy?: string; - size: Size; } export interface InputBoxProviderProps diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts deleted file mode 100644 index 7e21581ebf..0000000000 --- a/packages/input-box/src/InputSegment/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { type InputSegmentChangeEventHandler } from './InputSegment.types'; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/shared/types/InputSegment.types.ts similarity index 100% rename from packages/input-box/src/InputSegment/InputSegment.types.ts rename to packages/input-box/src/shared/types/InputSegment.types.ts diff --git a/packages/input-box/src/shared/types/index.ts b/packages/input-box/src/shared/types/index.ts new file mode 100644 index 0000000000..ed97d5fc1d --- /dev/null +++ b/packages/input-box/src/shared/types/index.ts @@ -0,0 +1,5 @@ +export type { + InputSegmentChangeEvent, + InputSegmentChangeEventHandler, +} from './InputSegment.types'; +export type { SharedInputBoxTypes } from './types'; diff --git a/packages/input-box/src/shared/types/types.ts b/packages/input-box/src/shared/types/types.ts new file mode 100644 index 0000000000..1592dd7351 --- /dev/null +++ b/packages/input-box/src/shared/types/types.ts @@ -0,0 +1,55 @@ +import { Size } from '@leafygreen-ui/tokens'; + +export interface SharedInputBoxTypes { + /** + * The number of characters per segment + * + * @example + * { day: 2, month: 2, year: 4 } + */ + charsPerSegment: Record; + + /** + * An enumerable object that maps the segment names to their values + * + * @example + * { Day: 'day', Month: 'month', Year: 'year' } + */ + segmentEnum: Record; + + /** + * An object that maps the segment names to their refs + * + * @example + * { day: ref, month: ref, year: ref } + */ + segmentRefs: Record>; + + /** + * An object containing the values of the segments + * + * @example + * { day: '1', month: '2', year: '2025' } + */ + segments: Record; + + /** + * The size of the input box + * + * @example + * Size.Default + * Size.Small + * Size.Large + */ + size: Size; + + /** + * Whether the input box is disabled + */ + disabled: boolean; + + /** + * id of the labelling element + */ + labelledBy?: string; +} From fff055766afcef17eaaeab56ef0b77d4caf1814a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 11:11:50 -0500 Subject: [PATCH 068/124] fix(input-box): correct comment formatting in testutils.mocks.ts for clarity --- packages/input-box/src/testutils/testutils.mocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index d1e062ac30..0466e233e3 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -66,7 +66,7 @@ export const defaultFormatPartsMock: Array = [ /** The percentage of 1ch these specific characters take up */ export const characterWidth = { - // // Standard font + // Standard font D: 46 / 40, M: 55 / 40, Y: 50 / 40, From 959c5a161df4e3d62e0db0e4e2f4960d2c44046b Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 13:47:16 -0500 Subject: [PATCH 069/124] feat(input-box): add InputSegment component for modular input handling --- packages/input-box/src/InputSegment/index.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/input-box/src/InputSegment/index.ts diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts new file mode 100644 index 0000000000..932ef6b10b --- /dev/null +++ b/packages/input-box/src/InputSegment/index.ts @@ -0,0 +1 @@ +// Export the InputSegment component From e97d393f90c6b8ff3584f9b4cf63af23ac43311c Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 13:50:19 -0500 Subject: [PATCH 070/124] feat(input-box): add placeholder for InputSegment types --- packages/input-box/src/InputSegment/InputSegment.types.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/input-box/src/InputSegment/InputSegment.types.ts 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..e39a43f7e7 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -0,0 +1 @@ +// This file is a placeholder for the InputSegment types. From ad1f01753947230f0ea7f5a7794ec9e2e70f52ac Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 13:58:13 -0500 Subject: [PATCH 071/124] refactor(input-box): move InputSegmentChangeEventHandler import to shared types for better organization --- packages/input-box/src/InputSegment/InputSegment.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index b31cd67f7f..284b26c887 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -19,7 +19,8 @@ import { segmentsMock, } from '../testutils/testutils.mocks'; -import { InputSegment, InputSegmentChangeEventHandler } from '.'; +import { InputSegment } from '.'; +import { InputSegmentChangeEventHandler } from '../shared/types'; interface InputSegmentStoryProps { size: Size; From 81a943c1ef0ee4f32b11ca3cfb4b4352868f9e19 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 14:10:09 -0500 Subject: [PATCH 072/124] refactor(input-box): rename min and max props to minSegmentValue and maxSegmentValue for consistency --- .../src/InputSegment/InputSegment.spec.tsx | 6 ++-- .../src/InputSegment/InputSegment.stories.tsx | 4 +-- .../src/InputSegment/InputSegment.tsx | 29 +++++++++---------- .../src/InputSegment/InputSegment.types.ts | 4 +-- packages/input-box/src/testutils/index.tsx | 8 ++--- 5 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 961b14489c..f92d6859e5 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -819,14 +819,14 @@ describe('packages/input-segment', () => { }); test('With required props', () => { - ; + ; }); test('With all props', () => { = { ], args: { segment: SegmentObjMock.Day, - min: defaultMinMock[SegmentObjMock.Day], - max: defaultMaxMock[SegmentObjMock.Day], + minSegmentValue: defaultMinMock[SegmentObjMock.Day], + maxSegmentValue: defaultMaxMock[SegmentObjMock.Day], size: Size.Default, placeholder: defaultPlaceholderMock[SegmentObjMock.Day], shouldWrap: true, diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 82d30ad76a..595845f609 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -30,14 +30,13 @@ import { * Renders a single input segment with configurable * character padding, validation, and formatting. * - * @internal */ const InputSegmentWithRef = ( { segment, onKeyDown, - min, // minSegmentValue - max, // maxSegmentValue + minSegmentValue, + maxSegmentValue, className, onChange: onChangeProp, onBlur: onBlurProp, @@ -64,7 +63,7 @@ const InputSegmentWithRef = ( const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter({ charsPerSegment, - allowZero: min === 0, + allowZero: minSegmentValue === 0, }); const pattern = `[0-9]{${charsPerSegment}}`; @@ -85,8 +84,8 @@ const InputSegmentWithRef = ( currentValue: value, incomingValue: target.value, charsPerSegment, - defaultMin: min, - defaultMax: max, + defaultMin: minSegmentValue, + defaultMax: maxSegmentValue, segmentEnum, shouldSkipValidation, }); @@ -97,7 +96,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: newValue, - meta: { min }, + meta: { min: minSegmentValue }, }); } else { // If the value has not changed, ensure the input value is reset @@ -133,10 +132,10 @@ const InputSegmentWithRef = ( const newValue = getNewSegmentValueFromArrowKeyPress({ key, value, - min, - max, + min: minSegmentValue, + max: maxSegmentValue, step, - shouldWrap: shouldWrap, + shouldWrap, }); const valueString = formatter(newValue); @@ -144,7 +143,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: valueString, - meta: { key, min }, + meta: { key, min: minSegmentValue }, }); break; } @@ -160,7 +159,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: '', - meta: { key, min }, + meta: { key, min: minSegmentValue }, }); } @@ -177,7 +176,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: '', - meta: { key, min }, + meta: { key, min: minSegmentValue }, }); } @@ -212,8 +211,8 @@ const InputSegmentWithRef = ( pattern={pattern} role="spinbutton" value={value} - min={min} - max={max} + min={minSegmentValue} + max={maxSegmentValue} onChange={handleChange} onBlur={handleBlur} onKeyDown={handleKeyDown} diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 88188f76a8..abec242e1a 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -23,7 +23,7 @@ export interface InputSegmentProps * 1 * 1970 */ - min: number; + minSegmentValue: number; /** * Maximum value for the segment @@ -33,7 +33,7 @@ export interface InputSegmentProps * 12 * 2038 */ - max: number; + maxSegmentValue: number; /** * The step value for the arrow keys diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 4ef3b1941b..74d2c07b62 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -22,8 +22,8 @@ export const setSegmentProps = (segment: SegmentObjMock) => { return { segment: segment, charsPerSegment: charsPerSegmentMock[segment], - min: defaultMinMock[segment], - max: defaultMaxMock[segment], + minSegmentValue: defaultMinMock[segment], + maxSegmentValue: defaultMaxMock[segment], placeholder: defaultPlaceholderMock[segment], }; }; @@ -54,8 +54,8 @@ const defaultSegmentProviderProps: Partial< const defaultSegmentProps: InputSegmentProps = { segment: 'day', - min: defaultMinMock['day'], - max: defaultMaxMock['day'], + minSegmentValue: defaultMinMock['day'], + maxSegmentValue: defaultMaxMock['day'], shouldWrap: true, placeholder: defaultPlaceholderMock['day'], // @ts-expect-error - data-testid From 8cfadbe825a4c8508c7d8c7f7264375f6ac51af6 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 14:17:05 -0500 Subject: [PATCH 073/124] refactor(input-box): simplify placeholder logic in InputSegment stories using defaultPlaceholderMock --- .../input-box/src/InputSegment/InputSegment.stories.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 7f89d93f60..2f8767c1de 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -108,11 +108,7 @@ const meta: StoryMetaType = { > From 68fc653733e61d6483325d1f207245b5777fa624 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 14:49:30 -0500 Subject: [PATCH 074/124] refactor(input-box): update InputSegment styles to use dynamic theme styles and improve organization --- .../src/InputSegment/InputSegment.spec.tsx | 2 +- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.styles.ts | 34 ++++++++----------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index f92d6859e5..e5243bb24f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; +import { InputSegmentChangeEventHandler } from '../shared/types'; import { renderSegment, setSegmentProps } from '../testutils'; import { charsPerSegmentMock, @@ -11,7 +12,6 @@ import { import { getValueFormatter } from '../utils'; import { InputSegment } from '.'; -import { InputSegmentChangeEventHandler } from '../shared/types'; describe('packages/input-segment', () => { describe('aria attributes', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 2f8767c1de..4bdd4df7b8 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -9,6 +9,7 @@ import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; import { InputBoxProvider } from '../InputBoxContext'; +import { InputSegmentChangeEventHandler } from '../shared/types'; import { charsPerSegmentMock, defaultMaxMock, @@ -20,7 +21,6 @@ import { } from '../testutils/testutils.mocks'; import { InputSegment } from '.'; -import { InputSegmentChangeEventHandler } from '../shared/types'; interface InputSegmentStoryProps { size: Size; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts index c759609a82..39f4f3da64 100644 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -1,11 +1,13 @@ import { css, cx } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; import { BaseFontSize, + color, fontFamilies, + InteractionState, Size, typeScales, + Variant, } from '@leafygreen-ui/tokens'; export const baseStyles = css` @@ -31,31 +33,23 @@ export const baseStyles = css` } `; -export const segmentThemeStyles: Record = { - [Theme.Light]: css` +export const getSegmentThemeStyles = (theme: Theme) => { + return css` background-color: transparent; - color: ${palette.black}; + color: ${color[theme].text[Variant.Primary][InteractionState.Default]}; &::placeholder { - color: ${palette.gray.light1}; + color: ${color[theme].text[Variant.Placeholder][ + InteractionState.Default + ]}; } &:focus { - background-color: ${palette.blue.light3}; + background-color: ${color[theme].background[Variant.Primary][ + InteractionState.Focus + ]}; } - `, - [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 = { @@ -96,7 +90,7 @@ export const getInputSegmentStyles = ({ return cx( baseStyles, fontSizeStyles[baseFontSize], - segmentThemeStyles[theme], + getSegmentThemeStyles(theme), segmentSizeStyles[size], className, ); From a04d5ec7608d8d50d0d0311133778fbd9326bcb0 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 15:30:21 -0500 Subject: [PATCH 075/124] feat(input-box): extend InputSegmentProps to include hours, minutes, and seconds segments --- .../input-box/src/InputSegment/InputSegment.types.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index abec242e1a..0b2dfbed59 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -12,6 +12,9 @@ export interface InputSegmentProps * 'day' * 'month' * 'year' + * 'hours' + * 'minutes' + * 'seconds' */ segment: Segment; @@ -22,6 +25,9 @@ export interface InputSegmentProps * 1 * 1 * 1970 + * 0 + * 0 + * 0 */ minSegmentValue: number; @@ -32,6 +38,9 @@ export interface InputSegmentProps * 31 * 12 * 2038 + * 23 + * 59 + * 59 */ maxSegmentValue: number; From 0101c327a3dc755d01d3295074b30d90e5376302 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 15:35:42 -0500 Subject: [PATCH 076/124] refactor(input-box): rename onChange and onBlur props in InputSegment to improve clarity --- .../input-box/src/InputSegment/InputSegment.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 595845f609..b65ab7485b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -49,8 +49,8 @@ const InputSegmentWithRef = ( ) => { const { theme } = useDarkMode(); const { - onChange, - onBlur, + onChange: onChangeContextProp, + onBlur: onBlurContextProp, charsPerSegment: charsPerSegmentContext, segmentEnum, segmentRefs, @@ -93,7 +93,7 @@ const InputSegmentWithRef = ( const hasValueChanged = newValue !== value; if (hasValueChanged) { - onChange({ + onChangeContextProp({ segment, value: newValue, meta: { min: minSegmentValue }, @@ -140,7 +140,7 @@ const InputSegmentWithRef = ( const valueString = formatter(newValue); /** Fire a custom change event when the up/down arrow keys are pressed */ - onChange({ + onChangeContextProp({ segment, value: valueString, meta: { key, min: minSegmentValue }, @@ -156,7 +156,7 @@ const InputSegmentWithRef = ( e.stopPropagation(); /** Fire a custom change event when the backspace key is pressed */ - onChange({ + onChangeContextProp({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -173,7 +173,7 @@ const InputSegmentWithRef = ( // 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({ + onChangeContextProp({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -192,7 +192,7 @@ const InputSegmentWithRef = ( }; const handleBlur = (e: FocusEvent) => { - onBlur?.(e); + onBlurContextProp?.(e); onBlurProp?.(e); }; From 662f2ddc2a98929529e5fac82146592cb8fae704 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 15:52:59 -0500 Subject: [PATCH 077/124] refactor(input-box): rename shouldSkipValidation prop to shouldValidate for clarity and consistency --- .../src/InputSegment/InputSegment.spec.tsx | 14 +++++++------- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../input-box/src/InputSegment/InputSegment.tsx | 4 ++-- .../src/InputSegment/InputSegment.types.ts | 8 ++++---- .../getNewSegmentValueFromInputValue.spec.ts | 8 ++++---- .../getNewSegmentValueFromInputValue.ts | 9 +++++---- 6 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index e5243bb24f..67b99d3249 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -644,7 +644,7 @@ describe('packages/input-segment', () => { ); }); - test('allows values above max range when skipValidation is true', () => { + test('allows values above max range when shouldValidate is false', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string @@ -653,7 +653,7 @@ describe('packages/input-segment', () => { const { input } = renderSegment({ props: { ...setSegmentProps('year'), - shouldSkipValidation: true, + shouldValidate: false, }, providerProps: { segments: { day: '', month: '', year: '203' }, @@ -753,13 +753,13 @@ describe('packages/input-segment', () => { }); describe('shouldSkipValidation prop', () => { - test('allows values outside min/max range when shouldSkipValidation is true', () => { + test('allows values outside min/max range when shouldValidate is false', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { segment: 'day', shouldSkipValidation: true }, + props: { segment: 'day', shouldValidate: false }, providerProps: { onChange: onChangeHandler, segments: { day: '9', month: '', year: '' }, @@ -773,13 +773,13 @@ describe('packages/input-segment', () => { ); }); - test('does not allows values outside min/max range when shouldSkipValidation is false', () => { + test('does not allows values outside min/max range when shouldValidate is true', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { segment: 'day', shouldSkipValidation: false }, + props: { segment: 'day', shouldValidate: true }, providerProps: { onChange: onChangeHandler, segments: { day: '9', month: '', year: '' }, @@ -829,7 +829,7 @@ describe('packages/input-segment', () => { maxSegmentValue={31} step={1} shouldWrap={true} - shouldSkipValidation={false} + shouldValidate={true} placeholder="12" className="test" onBlur={() => {}} diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 4bdd4df7b8..2c1e404c28 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 = { 'onChange', 'charsPerSegment', 'segmentEnum', - 'shouldSkipValidation', + 'shouldValidate', 'step', 'placeholder', ], diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index b65ab7485b..ef48604ff9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -42,7 +42,7 @@ const InputSegmentWithRef = ( onBlur: onBlurProp, step = 1, shouldWrap = true, - shouldSkipValidation = false, + shouldValidate = true, ...rest }: InputSegmentProps, fwdRef: ForwardedRef, @@ -87,7 +87,7 @@ const InputSegmentWithRef = ( defaultMin: minSegmentValue, defaultMax: maxSegmentValue, segmentEnum, - shouldSkipValidation, + shouldValidate, }); 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 0b2dfbed59..672e2dc811 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -52,18 +52,18 @@ export interface InputSegmentProps step?: number; /** - * Whether the segment should wrap at min/max boundaries + * Whether the segment should wrap at max boundaries when using the up arrow key. * * @default true */ shouldWrap?: boolean; /** - * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * Whether the segment should validate. Skipping validation is useful for segments that allow values outside of the default range. * - * @default false + * @default true */ - shouldSkipValidation?: boolean; + shouldValidate?: boolean; } /** diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index b6645ed8f2..c1a37e1153 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -196,7 +196,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { expect(newValue).toEqual(`00`); }); - test('accepts 00 as input when shouldSkipValidation is true and value is less than defaultMin', () => { + test('accepts 00 as input when shouldValidate is false and value is less than defaultMin', () => { const newValue = getNewSegmentValueFromInputValue({ segmentName: 'day', currentValue: '0', @@ -205,7 +205,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin: 1, defaultMax: 15, segmentEnum: segmentObj, - shouldSkipValidation: true, + shouldValidate: false, }); expect(newValue).toEqual(`00`); }); @@ -239,7 +239,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { expect(newValue).toEqual('2024'); }); - test('truncates from start when shouldSkipValidation is true and value exceeds charsPerSegment', () => { + test('truncates from start when shouldValidate is false and value exceeds charsPerSegment', () => { const newValue = getNewSegmentValueFromInputValue({ segmentName: 'year', currentValue: '000', @@ -248,7 +248,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin: 1970, defaultMax: 2099, segmentEnum: segmentObj, - shouldSkipValidation: true, + shouldValidate: false, }); expect(newValue).toEqual('0001'); }); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index f8b8398407..66178da60f 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -15,7 +15,7 @@ interface GetNewSegmentValueFromInputValue< defaultMin: number; defaultMax: number; segmentEnum: Readonly>; - shouldSkipValidation?: boolean; + shouldValidate?: boolean; } /** @@ -33,7 +33,7 @@ interface GetNewSegmentValueFromInputValue< * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment * @param segmentEnum - The segment enum/object containing the segment names and their corresponding values to validate against - * @param shouldSkipValidation - Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * @param shouldValidate - Whether the segment should validate. Skipping validation 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 @@ -78,7 +78,7 @@ interface GetNewSegmentValueFromInputValue< * defaultMin: 1970, * defaultMax: 2038, * segmentEnum, - * shouldSkipValidation: true, + * shouldValidate: false, * }); // '000' * * * getNewSegmentValueFromInputValue({ * segmentName: 'minute', @@ -101,12 +101,13 @@ export const getNewSegmentValueFromInputValue = < defaultMin, defaultMax, segmentEnum, - shouldSkipValidation = false, + shouldValidate = true, }: GetNewSegmentValueFromInputValue): Value => { // 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); + const shouldSkipValidation = !shouldValidate; // if the current value is "full", do not allow any additional characters to be entered const wouldCauseOverflow = From 967b33bdab900d8e0e12670bf56d881a7552383d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 13:08:21 -0500 Subject: [PATCH 078/124] refactor(input-box): reorganize imports in testutils for better clarity and structure --- packages/input-box/src/testutils/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 74d2c07b62..063f529ab5 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { InputBoxProvider } from '../InputBoxContext/InputBoxContext'; -import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext.types'; -import { InputSegment } from '../InputSegment/InputSegment'; -import { InputSegmentProps } from '../InputSegment/InputSegment.types'; +import { + InputBoxProvider, + type InputBoxProviderProps, +} from '../InputBoxContext'; +import { InputSegment, type InputSegmentProps } from '../InputSegment'; import { charsPerSegmentMock, From a589e9407b0490a073a876d3cb06d8eee829724a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 13:12:17 -0500 Subject: [PATCH 079/124] refactor(input-box): remove deprecated InputSegment types and update imports in InputBoxContext --- .../InputBoxContext/InputBoxContext.types.ts | 2 +- .../types/types.ts => shared.types.ts} | 28 +++++++++++++++++++ .../src/shared/types/InputSegment.types.ts | 22 --------------- packages/input-box/src/shared/types/index.ts | 5 ---- 4 files changed, 29 insertions(+), 28 deletions(-) rename packages/input-box/src/{shared/types/types.ts => shared.types.ts} (65%) delete mode 100644 packages/input-box/src/shared/types/InputSegment.types.ts delete mode 100644 packages/input-box/src/shared/types/index.ts diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts index 3752b6fbdb..0834e83062 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts @@ -1,7 +1,7 @@ import { InputSegmentChangeEventHandler, SharedInputBoxTypes, -} from '../shared/types'; +} from '../shared.types'; export interface InputBoxContextType extends SharedInputBoxTypes { diff --git a/packages/input-box/src/shared/types/types.ts b/packages/input-box/src/shared.types.ts similarity index 65% rename from packages/input-box/src/shared/types/types.ts rename to packages/input-box/src/shared.types.ts index 1592dd7351..87e6e31264 100644 --- a/packages/input-box/src/shared/types/types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,5 +1,33 @@ +import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; +/** + * Shared Input Segment Change Event + */ +export interface InputSegmentChangeEvent< + Segment extends string, + Value extends string, +> { + segment: Segment; + value: Value; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + min: number; + [key: string]: any; + }; +} + +/** + * The type for the onChange handler + */ +export type InputSegmentChangeEventHandler< + Segment extends string, + Value extends string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +/** + * Shared Input Box Types + */ export interface SharedInputBoxTypes { /** * The number of characters per segment diff --git a/packages/input-box/src/shared/types/InputSegment.types.ts b/packages/input-box/src/shared/types/InputSegment.types.ts deleted file mode 100644 index 9d0d5b1e8e..0000000000 --- a/packages/input-box/src/shared/types/InputSegment.types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { keyMap } from '@leafygreen-ui/lib'; - -export interface InputSegmentChangeEvent< - Segment extends string, - Value extends string, -> { - segment: Segment; - value: Value; - meta?: { - key?: (typeof keyMap)[keyof typeof keyMap]; - min: number; - [key: string]: any; - }; -} - -/** - * The type for the onChange handler - */ -export type InputSegmentChangeEventHandler< - Segment extends string, - Value extends string, -> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; diff --git a/packages/input-box/src/shared/types/index.ts b/packages/input-box/src/shared/types/index.ts deleted file mode 100644 index ed97d5fc1d..0000000000 --- a/packages/input-box/src/shared/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { - InputSegmentChangeEvent, - InputSegmentChangeEventHandler, -} from './InputSegment.types'; -export type { SharedInputBoxTypes } from './types'; From e8a37052b1af45bd00d56a6a57693e0c54fa6fac Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 13:38:56 -0500 Subject: [PATCH 080/124] refactor(input-box): update InputSegmentChangeEventHandler import to use type alias from shared.types --- packages/input-box/src/InputSegment/InputSegment.spec.tsx | 2 +- packages/input-box/src/InputSegment/InputSegment.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 67b99d3249..5f0efa66c9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { InputSegmentChangeEventHandler } from '../shared/types'; +import { type InputSegmentChangeEventHandler } from '../shared.types'; import { renderSegment, setSegmentProps } from '../testutils'; import { charsPerSegmentMock, diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 2c1e404c28..1a690beaa0 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -9,7 +9,7 @@ import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; import { InputBoxProvider } from '../InputBoxContext'; -import { InputSegmentChangeEventHandler } from '../shared/types'; +import { type InputSegmentChangeEventHandler } from '../shared.types'; import { charsPerSegmentMock, defaultMaxMock, From 4cf138e4cc60aadd7d251edf51a0e54eb97b947c Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 14:28:25 -0500 Subject: [PATCH 081/124] refactor(input-box): enhance InputSegment types and documentation, adding isInputSegment utility and improving component descriptions --- .../src/InputSegment/InputSegment.tsx | 37 +++++++++++++++---- .../src/InputSegment/InputSegment.types.ts | 25 ------------- packages/input-box/src/shared.types.ts | 29 +++++++++++++++ 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index ef48604ff9..bc0802b07c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -24,13 +24,6 @@ import { InputSegmentProps, } from './InputSegment.types'; -/** - * Generic controlled input segment component - * - * Renders a single input segment with configurable - * character padding, validation, and formatting. - * - */ const InputSegmentWithRef = ( { segment, @@ -232,6 +225,36 @@ const InputSegmentWithRef = ( ); }; +/** + * Generic controlled input segment component to be used within the InputBox component. + * + * This component renders a single input segment from an array of format parts (typically `Intl.DateTimeFormatPart`) + * passed to the InputBox component. It is designed primarily for date and time input segments, where each segment + * represents a distinct part of the date/time format (e.g., month, day, year, hour, minute). + * + * Each segment is configurable with character padding, validation, and formatting rules. + * + * @example + * // Used internally by InputBox to render segments from formatParts: + * + * // Date format: + * // [ + * // { type: 'month', value: '02' }, + * // { type: 'literal', value: '-' }, + * // { type: 'day', value: '02' }, + * // { type: 'literal', value: '-' }, + * // { type: 'year', value: '2025' }, + * // ] + * + * // Time format: + * // [ + * // { type: 'hour', value: '14' }, + * // { type: 'literal', value: ':' }, + * // { type: 'minute', value: '30' }, + * // { type: 'literal', value: ':' }, + * // { type: 'second', value: '45' }, + * // ] + */ export const InputSegment = React.forwardRef( InputSegmentWithRef, ) as InputSegmentComponentType; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 672e2dc811..9610dc78dd 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -81,28 +81,3 @@ export interface InputSegmentComponentType { ): 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); -} - -/** - * 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/shared.types.ts b/packages/input-box/src/shared.types.ts index 87e6e31264..9425849565 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,6 +1,10 @@ import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; +/** + * SharedInput Segment Types + */ + /** * Shared Input Segment Change Event */ @@ -25,6 +29,31 @@ export type InputSegmentChangeEventHandler< Value extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; +/** + * Returns whether the given string is a valid segment + */ +export function isInputSegment>( + segment: unknown, + segmentObj: T, +): segment is T[keyof T] { + if (typeof segment !== 'string') return false; + return Object.values(segmentObj).includes(segment); +} + +/** + * 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; +} + /** * Shared Input Box Types */ From a7062e2be9368a04a7325619b5b87496870b4025 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 16:09:56 -0500 Subject: [PATCH 082/124] refactor(input-box): streamline InputSegment exports by removing unused types --- packages/input-box/src/InputSegment/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts index 15d4eb10d8..1def69f1c7 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1,5 +1,2 @@ export { InputSegment } from './InputSegment'; -export { - type InputSegmentComponentProps, - type InputSegmentProps, -} from './InputSegment.types'; +export { type InputSegmentProps } from './InputSegment.types'; From dd132ea8c37432a8b024c94d7eacaae8f7c57a53 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 16:14:46 -0500 Subject: [PATCH 083/124] test(input-box): add accessibility test for InputSegment to ensure no violations when tooltip is closed --- .../input-box/src/InputSegment/InputSegment.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 5f0efa66c9..cae730b424 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; import { type InputSegmentChangeEventHandler } from '../shared.types'; import { renderSegment, setSegmentProps } from '../testutils'; @@ -15,6 +16,14 @@ import { InputSegment } from '.'; describe('packages/input-segment', () => { describe('aria attributes', () => { + test('does not have basic accessibility issues when tooltip is not open', async () => { + const { container } = renderSegment({ + props: { segment: 'day' }, + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + test(`segment has aria-label`, () => { const { input } = renderSegment({ props: { segment: 'day' }, From 0e9b9bdf7ec3ae1565677d97af6d93936b518c81 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 10:14:47 -0500 Subject: [PATCH 084/124] refactor(input-box): update InputSegment to remove size prop and enhance type definitions for better clarity --- .../InputBoxContext/InputBoxContext.spec.tsx | 4 - .../src/InputBoxContext/InputBoxContext.tsx | 5 +- .../src/InputSegment/InputSegment.spec.tsx | 443 ++++++++---------- .../src/InputSegment/InputSegment.stories.tsx | 98 ++-- .../src/InputSegment/InputSegment.tsx | 48 +- .../src/InputSegment/InputSegment.types.ts | 60 ++- packages/input-box/src/shared.types.ts | 40 +- packages/input-box/src/testutils/index.tsx | 77 +-- .../src/testutils/testutils.mocks.ts | 2 + 9 files changed, 346 insertions(+), 431 deletions(-) diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index 6f2b22a0db..31007d8db1 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; -import { Size } from '@leafygreen-ui/tokens'; import { charsPerSegmentMock, @@ -48,7 +47,6 @@ describe('InputBoxContext', () => { onBlur={mockOnBlur} segmentRefs={segmentRefsMock} segments={segmentsMock} - size={Size.Default} disabled={false} > {children} @@ -63,7 +61,6 @@ describe('InputBoxContext', () => { onBlur, segmentRefs, segments, - size, disabled, } = result.current; @@ -73,7 +70,6 @@ describe('InputBoxContext', () => { expect(onBlur).toBe(mockOnBlur); expect(segmentRefs).toBe(segmentRefsMock); expect(segments).toBe(segmentsMock); - expect(size).toBe(Size.Default); expect(disabled).toBe(false); }); }); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index db487b6a7a..4279a3e3fe 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -1,3 +1,5 @@ +// TODO: NO LONGER NEEDED + import React, { createContext, PropsWithChildren, @@ -24,7 +26,6 @@ export const InputBoxProvider = ({ segments, segmentEnum, segmentRefs, - size, }: PropsWithChildren>) => { const value = useMemo( () => ({ @@ -37,7 +38,6 @@ export const InputBoxProvider = ({ segments, segmentEnum, segmentRefs, - size, }), [ charsPerSegment, @@ -49,7 +49,6 @@ export const InputBoxProvider = ({ segments, segmentEnum, segmentRefs, - size, ], ); diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index cae730b424..f9888c6dc8 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { type InputSegmentChangeEventHandler } from '../shared.types'; -import { renderSegment, setSegmentProps } from '../testutils'; +import { renderSegment } from '../testutils'; import { charsPerSegmentMock, defaultMaxMock, @@ -18,7 +18,7 @@ describe('packages/input-segment', () => { describe('aria attributes', () => { test('does not have basic accessibility issues when tooltip is not open', async () => { const { container } = renderSegment({ - props: { segment: 'day' }, + segment: 'day', }); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -26,7 +26,7 @@ describe('packages/input-segment', () => { test(`segment has aria-label`, () => { const { input } = renderSegment({ - props: { segment: 'day' }, + segment: 'day', }); expect(input).toHaveAttribute('aria-label', 'day'); }); @@ -38,7 +38,9 @@ describe('packages/input-segment', () => { test('has min and max attributes', () => { const { input } = renderSegment({ - props: { segment: 'day' }, + segment: 'day', + minSegmentValue: defaultMinMock['day'], + maxSegmentValue: defaultMaxMock['day'], }); expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); @@ -47,24 +49,29 @@ describe('packages/input-segment', () => { describe('rendering', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({}); + const { input } = renderSegment({ + segment: 'day', + value: '', + }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { const { input } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, + segment: 'day', + value: '12', }); expect(input.value).toBe('12'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, + segment: 'day', + value: '12', }); rerenderSegment({ - newProviderProps: { segments: { day: '08', month: '', year: '' } }, + value: '08', }); expect(getInput().value).toBe('08'); }); @@ -78,7 +85,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '8'); @@ -93,7 +101,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '0'); @@ -108,7 +117,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, 'aB$/'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -122,10 +132,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - segments: { day: '2', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + value: '2', + onChange: onChangeHandler, }); userEvent.type(input, '6'); @@ -140,10 +149,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - segments: { day: '26', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + value: '26', + maxSegmentValue: 31, + onChange: onChangeHandler, }); userEvent.type(input, '4'); @@ -167,11 +176,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowup}'); @@ -188,11 +195,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day', step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + step: 2, + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowup}'); @@ -209,17 +215,17 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '', + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMinMock['day']), + value: formatter(0), }), ); }); @@ -230,21 +236,17 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(31), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMinMock['day']), + value: formatter(0), }), ); }); @@ -255,44 +257,48 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + shouldWrap: false, + onChange: onChangeHandler, + value: formatter(31), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMaxMock['day'] + 1), + value: formatter(31 + 1), }), ); }); test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const formatter = getValueFormatter({ + charsPerSegment: 4, + allowZero: false, + }); + 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' }, - }, + segment: 'year', + minSegmentValue: 1970, + maxSegmentValue: 2038, + charsPerSegment: 4, + shouldWrap: false, + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'year', value: '0004' }), + expect.objectContaining({ + segment: 'year', + value: formatter(3 + 1), + }), ); }); @@ -302,11 +308,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '06', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '06', }); userEvent.type(input, '{arrowup}'); @@ -321,16 +325,14 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '3', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '04' }), + expect.objectContaining({ value: formatter(3 + 1) }), ); }); }); @@ -342,16 +344,15 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(14), + value: formatter(15 - 1), }), ); }); @@ -362,17 +363,16 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + step: 2, + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(13), + value: formatter(15 - 2), }), ); }); @@ -383,14 +383,16 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMaxMock['day']), + value: formatter(31), }), ); }); @@ -401,68 +403,71 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(0), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMaxMock['day']), + value: formatter(31), }), ); }); - test('does not wrap if `shouldWrap` is false', () => { + /* eslint-disable jest/no-disabled-tests */ + test.skip('does not wrap if `shouldWrap` is false', () => { + // TODO: this should not wrap the min value const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + shouldWrap: false, + onChange: onChangeHandler, + value: formatter(0), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMinMock['day'] - 1), + value: formatter(0 - 1), }), ); }); test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const formatter = getValueFormatter({ + charsPerSegment: 4, + allowZero: false, + }); + 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' }, - }, + segment: 'year', + minSegmentValue: 1970, + maxSegmentValue: 2038, + charsPerSegment: 4, + shouldWrap: false, + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'year', value: '0002' }), + expect.objectContaining({ + segment: 'year', + value: formatter(3 - 1), + }), ); }); @@ -472,11 +477,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '06', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '06', }); userEvent.type(input, '{arrowdown}'); @@ -491,16 +494,14 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '3', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '02' }), + expect.objectContaining({ value: formatter(3 - 1) }), ); }); }); @@ -512,10 +513,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '12', }); userEvent.type(input, '{backspace}'); @@ -530,7 +530,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '{backspace}'); @@ -548,7 +549,8 @@ describe('packages/input-segment', () => { >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '{space}'); @@ -562,10 +564,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '12', }); userEvent.type(input, '{space}'); @@ -583,7 +584,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '{space}{space}'); @@ -597,10 +599,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '12', }); userEvent.type(input, '{space}{space}'); @@ -619,15 +620,15 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - // max is 31 const { input } = renderSegment({ - providerProps: { - segments: { day: '3', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + onChange: onChangeHandler, + value: '3', + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '2'); - // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ value: '2' }), ); @@ -640,14 +641,14 @@ describe('packages/input-segment', () => { >; // min is 1. We allow values below min range. const { input } = renderSegment({ - props: { ...setSegmentProps('month') }, - providerProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'month', + minSegmentValue: 1, + maxSegmentValue: 12, + onChange: onChangeHandler, + value: '', }); userEvent.type(input, '0'); - // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ value: '0' }), ); @@ -658,19 +659,17 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - // max is 2038 + const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldValidate: false, - }, - providerProps: { - segments: { day: '', month: '', year: '203' }, - onChange: onChangeHandler, - }, + segment: 'year', + charsPerSegment: 4, + maxSegmentValue: 2038, + shouldValidate: false, + onChange: onChangeHandler, + value: '203', }); userEvent.type(input, '9'); - // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ value: '2039' }), ); @@ -679,10 +678,11 @@ describe('packages/input-segment', () => { }); describe('onBlur handler', () => { - test('calls the custom onBlur prop when provided', () => { + test('calls the onBlur handler when the input is blurred', () => { const onBlurHandler = jest.fn(); const { input } = renderSegment({ - props: { onBlur: onBlurHandler }, + segment: 'day', + onBlur: onBlurHandler, }); input.focus(); @@ -690,57 +690,27 @@ describe('packages/input-segment', () => { 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', () => { + describe('onKeyDown handler', () => { + test('calls the onKeyDown handler when a key is pressed', () => { const onKeyDownHandler = jest.fn(); const { input } = renderSegment({ - props: { onKeyDown: onKeyDownHandler }, + segment: 'day', + 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 }, + segment: 'day', + disabled: true, }); expect(input).toBeDisabled(); @@ -752,7 +722,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { disabled: true, onChange: onChangeHandler }, + segment: 'day', + disabled: true, + onChange: onChangeHandler, }); userEvent.type(input, '5'); @@ -768,11 +740,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day', shouldValidate: false }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '9', month: '', year: '' }, - }, + segment: 'day', + shouldValidate: false, + onChange: onChangeHandler, + value: '9', }); userEvent.type(input, '9'); @@ -788,11 +759,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day', shouldValidate: true }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '9', month: '', year: '' }, - }, + segment: 'day', + shouldValidate: true, + onChange: onChangeHandler, + value: '9', }); userEvent.type(input, '9'); @@ -801,25 +771,6 @@ describe('packages/input-segment', () => { }); }); - 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(); - }); - }); - /* eslint-disable jest/no-disabled-tests */ describe.skip('types behave as expected', () => { test('InputSegment throws error when no required props are provided', () => { @@ -828,7 +779,19 @@ describe('packages/input-segment', () => { }); test('With required props', () => { - ; + {}} + onBlur={() => {}} + onKeyDown={() => {}} + disabled={false} + size={'default'} + segmentEnum={SegmentObjMock} + />; }); test('With all props', () => { @@ -836,14 +799,14 @@ describe('packages/input-segment', () => { segment="day" minSegmentValue={1} maxSegmentValue={31} - step={1} - shouldWrap={true} - shouldValidate={true} - placeholder="12" - className="test" + value="" + charsPerSegment={2} + onChange={() => {}} onBlur={() => {}} onKeyDown={() => {}} disabled={false} + size={'default'} + segmentEnum={SegmentObjMock} data-testid="test-id" id="day" ref={React.createRef()} diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 1a690beaa0..af5263341b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -8,23 +8,19 @@ import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; -import { InputBoxProvider } from '../InputBoxContext'; -import { type InputSegmentChangeEventHandler } from '../shared.types'; import { charsPerSegmentMock, defaultMaxMock, defaultMinMock, defaultPlaceholderMock, + InputSegmentValueMock, SegmentObjMock, - segmentRefsMock, - segmentsMock, } from '../testutils/testutils.mocks'; import { InputSegment } from '.'; interface InputSegmentStoryProps { - size: Size; - segments: Record; + darkMode: boolean; } const meta: StoryMetaType = { @@ -46,6 +42,7 @@ const meta: StoryMetaType = { shouldWrap: true, step: 1, darkMode: false, + charsPerSegment: charsPerSegmentMock[SegmentObjMock.Day], }, argTypes: { size: { @@ -74,44 +71,27 @@ const meta: StoryMetaType = { generate: { combineArgs: { darkMode: [false, true], - segment: ['day', 'month', 'year'], + segment: ['day', 'year'], size: Object.values(Size), - segments: [ - { - day: '2', - month: '8', - year: '2025', - }, - { - day: '00', - month: '0', - year: '0000', - }, - { - day: '', - month: '', - year: '', - }, - ], + value: ['', '2', '0', '00', '2025', '0000'], }, + excludeCombinations: [ + { + value: ['2', '0', '00'], + segment: 'year', + }, + { + value: ['2025', '0000'], + segment: ['day'], + }, + ], decorator: (StoryFn, context) => ( - {}} - onBlur={() => {}} - segmentRefs={segmentRefsMock} - segments={context?.args.segments} - size={context?.args.size} - disabled={false} - > - - + ), }, @@ -119,32 +99,22 @@ const meta: StoryMetaType = { }; export default meta; -export const LiveExample: StoryFn = ( - props, - context: any, -) => { - const [segments, setSegments] = useState(segmentsMock); - - const handleChange: InputSegmentChangeEventHandler< - SegmentObjMock, - string - > = ({ segment, value }) => { - setSegments(prev => ({ ...prev, [segment]: value })); - }; +export const LiveExample: StoryFn = ({ + // @ts-ignore - darkMode is not a valid prop for InputSegment + darkMode: _darkMode, + ...rest +}) => { + const [value, setValue] = useState(''); return ( - {}} - segmentRefs={segmentRefsMock} - segments={segments} - disabled={false} - size={context?.args?.size || Size.Default} - > - - + { + setValue(value); + }} + /> ); }; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index bc0802b07c..dd65082200 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -6,12 +6,10 @@ import React, { } 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, @@ -24,46 +22,35 @@ import { InputSegmentProps, } from './InputSegment.types'; -const InputSegmentWithRef = ( +const InputSegmentWithRef = ( { segment, onKeyDown, minSegmentValue, maxSegmentValue, className, - onChange: onChangeProp, - onBlur: onBlurProp, + onChange, + onBlur, + segmentEnum, + size, + disabled, + value, + charsPerSegment, step = 1, shouldWrap = true, shouldValidate = true, ...rest - }: InputSegmentProps, + }: InputSegmentProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); - const { - onChange: onChangeContextProp, - onBlur: onBlurContextProp, - charsPerSegment: charsPerSegmentContext, - segmentEnum, - segmentRefs, - segments, - labelledBy, - size, - disabled, - } = useInputBoxContext(); const baseFontSize = useUpdatedBaseFontSize(); - const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter({ charsPerSegment, allowZero: minSegmentValue === 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, @@ -86,7 +73,7 @@ const InputSegmentWithRef = ( const hasValueChanged = newValue !== value; if (hasValueChanged) { - onChangeContextProp({ + onChange({ segment, value: newValue, meta: { min: minSegmentValue }, @@ -95,8 +82,6 @@ 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 */ @@ -133,7 +118,7 @@ const InputSegmentWithRef = ( const valueString = formatter(newValue); /** Fire a custom change event when the up/down arrow keys are pressed */ - onChangeContextProp({ + onChange({ segment, value: valueString, meta: { key, min: minSegmentValue }, @@ -149,7 +134,7 @@ const InputSegmentWithRef = ( e.stopPropagation(); /** Fire a custom change event when the backspace key is pressed */ - onChangeContextProp({ + onChange({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -166,7 +151,7 @@ const InputSegmentWithRef = ( // Don't fire change event if the input is initially empty if (value) { /** Fire a custom change event when the space key is pressed */ - onChangeContextProp({ + onChange({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -185,8 +170,7 @@ const InputSegmentWithRef = ( }; const handleBlur = (e: FocusEvent) => { - onBlurContextProp?.(e); - onBlurProp?.(e); + onBlur?.(e); }; // Note: Using a text input with pattern attribute due to Firefox @@ -196,10 +180,10 @@ const InputSegmentWithRef = ( <> - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' - > { - /** - * Which segment this input represents - * - * @example - * 'day' - * 'month' - * 'year' - * 'hours' - * 'minutes' - * 'seconds' - */ - segment: Segment; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputSegmentComponentProps } from '../shared.types'; +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' | 'onBlur' | 'onChange' + >, + Omit, 'segments'> { /** * Minimum value for the segment * @@ -64,6 +56,36 @@ export interface InputSegmentProps * @default true */ shouldValidate?: boolean; + + /** + * The value of the segment + * + * @example + * '1' + * '2' + * '2025' + */ + value: Value; + + /** + * The number of characters per segment + * + * @example + * 2 + * 2 + * 4 + */ + charsPerSegment: number; + + /** + * The size of the input box + * + * @example + * Size.Default + * Size.Small + * Size.Large + */ + size: Size; } /** @@ -75,8 +97,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/shared.types.ts b/packages/input-box/src/shared.types.ts index 9425849565..7114ba3f89 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,5 +1,4 @@ import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; /** * SharedInput Segment Types @@ -44,14 +43,27 @@ export function isInputSegment>( * 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' - > { + React.ComponentPropsWithoutRef<'input'>, + 'onChange' | 'value' | 'min' | 'max' | 'size' | 'disabled' + >, + Pick, 'segments' | 'segmentEnum'> { + /** + * Which segment this input represents + * + * @example + * 'day' + * 'month' + * 'year' + * 'hours' + * 'minutes' + * 'seconds' + */ segment: Segment; + onChange: InputSegmentChangeEventHandler; + onBlur: React.FocusEventHandler; } /** @@ -90,15 +102,15 @@ export interface SharedInputBoxTypes { */ segments: Record; - /** - * The size of the input box - * - * @example - * Size.Default - * Size.Small - * Size.Large - */ - size: Size; + // /** + // * The size of the input box + // * + // * @example + // * Size.Default + // * Size.Small + // * Size.Large + // */ + // size: Size; /** * Whether the input box is disabled diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 063f529ab5..c4d9e26dd9 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,10 +1,6 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { - InputBoxProvider, - type InputBoxProviderProps, -} from '../InputBoxContext'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; import { @@ -12,12 +8,12 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, + InputSegmentValueMock, SegmentObjMock, - segmentRefsMock, } from './testutils.mocks'; /* - * InputSegment Utils + * InputSegment Utils // TODO: remove this? */ export const setSegmentProps = (segment: SegmentObjMock) => { return { @@ -32,33 +28,25 @@ export const setSegmentProps = (segment: SegmentObjMock) => { interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; - rerenderSegment: (params: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => void; + rerenderSegment: ( + newProps: Partial>, + ) => void; } -const defaultSegmentProviderProps: Partial< - InputBoxProviderProps +const defaultSegmentProps: InputSegmentProps< + SegmentObjMock, + InputSegmentValueMock > = { - charsPerSegment: charsPerSegmentMock, - segmentEnum: SegmentObjMock, - onChange: () => {}, - onBlur: () => {}, - segments: { - day: '', - month: '', - year: '', - }, - segmentRefs: segmentRefsMock, -}; - -const defaultSegmentProps: InputSegmentProps = { segment: 'day', minSegmentValue: defaultMinMock['day'], maxSegmentValue: defaultMaxMock['day'], shouldWrap: true, placeholder: defaultPlaceholderMock['day'], + onChange: () => {}, + onBlur: () => {}, + value: '', + charsPerSegment: charsPerSegmentMock['day'], + segmentEnum: SegmentObjMock, // @ts-expect-error - data-testid ['data-testid']: 'lg-input-segment', }; @@ -66,41 +54,20 @@ const defaultSegmentProps: InputSegmentProps = { /** * Renders the InputSegment component for testing purposes. */ -export const renderSegment = ({ - props = {}, - providerProps = {}, -}: { - props?: Partial>; - providerProps?: Partial>; -}): RenderResult & RenderSegmentReturnType => { +export const renderSegment = ( + props: Partial>, +): RenderResult & RenderSegmentReturnType => { const mergedProps = { ...defaultSegmentProps, ...props, - } as InputSegmentProps; - - const mergedProviderProps = { - ...defaultSegmentProviderProps, - ...providerProps, - } as InputBoxProviderProps; + } as InputSegmentProps; - const utils = render( - - - , - ); + const utils = render(); - const rerenderSegment = ({ - newProps = {}, - newProviderProps = {}, - }: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => { - utils.rerender( - - - , - ); + const rerenderSegment = ( + newProps: Partial>, + ) => { + utils.rerender(); }; const getInput = () => diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 0466e233e3..8edda55612 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -5,6 +5,8 @@ import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { ExplicitSegmentRule } from '../utils'; +export type InputSegmentValueMock = string; + export const SegmentObjMock = { Month: 'month', Day: 'day', From 5e73301c9446a12ad318141046a566c16b7ba6c3 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 10:25:12 -0500 Subject: [PATCH 085/124] refactor(input-box): enhance InputSegment types by adding onChange and onBlur event handlers with detailed documentation --- packages/input-box/src/shared.types.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7114ba3f89..7ad6c9163c 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -62,7 +62,21 @@ export interface InputSegmentComponentProps * 'seconds' */ segment: Segment; + + /** + * The handler for the onChange event that will be read in the InputSegment component + * + * @example + * (event: InputSegmentChangeEvent) => void + */ onChange: InputSegmentChangeEventHandler; + + /** + * The handler for the onBlur event that will be read by the InputSegment component + * + * @example + * (event: React.FocusEvent) => void + */ onBlur: React.FocusEventHandler; } @@ -102,16 +116,6 @@ export interface SharedInputBoxTypes { */ segments: Record; - // /** - // * The size of the input box - // * - // * @example - // * Size.Default - // * Size.Small - // * Size.Large - // */ - // size: Size; - /** * Whether the input box is disabled */ From 6db5451cb8b43aae5336ae1449066aa638208411 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 12:50:37 -0500 Subject: [PATCH 086/124] refactor(input-box): update InputSegment types to extend from 'div' and include additional props for improved functionality --- .../input-box/src/InputSegment/InputSegment.types.ts | 7 +++++-- packages/input-box/src/shared.types.ts | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 1b3faf8414..03de1ebb2c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -7,9 +7,12 @@ import { InputSegmentComponentProps } from '../shared.types'; export interface InputSegmentProps extends Omit< React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' | 'onBlur' | 'onChange' + 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' >, - Omit, 'segments'> { + Pick< + InputSegmentComponentProps, + 'onChange' | 'onBlur' | 'segment' | 'segmentEnum' + > { /** * Minimum value for the segment * diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7ad6c9163c..97023db4d8 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -45,11 +45,11 @@ export function isInputSegment>( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit< - React.ComponentPropsWithoutRef<'input'>, - 'onChange' | 'value' | 'min' | 'max' | 'size' | 'disabled' - >, - Pick, 'segments' | 'segmentEnum'> { + extends Omit, 'onChange'>, + Pick< + SharedInputBoxTypes, + 'segments' | 'segmentEnum' | 'disabled' | 'segmentRefs' + > { /** * Which segment this input represents * From d4ec60db3cb2137296a3a62bde27f0730779e4f8 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 13:03:47 -0500 Subject: [PATCH 087/124] refactor(input-box): simplify SharedInputBoxTypes by removing redundant properties and enhancing type clarity --- .../input-box/src/InputBox/InputBox.types.ts | 34 ++----------------- .../InputBoxContext/InputBoxContext.spec.tsx | 3 ++ .../src/InputBoxContext/InputBoxContext.tsx | 1 + packages/input-box/src/shared.types.ts | 15 ++------ 4 files changed, 9 insertions(+), 44 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index ebc0f66e1a..db89a226fd 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -1,11 +1,11 @@ import React, { ForwardedRef, ReactElement } from 'react'; import { DateType } from '@leafygreen-ui/date-utils'; -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { InputSegmentChangeEventHandler, InputSegmentComponentProps, + SharedInputBoxTypes, } from '../shared.types'; import { ExplicitSegmentRule } from '../utils'; @@ -19,7 +19,8 @@ export type InputChangeEventHandler = ( ) => void; export interface InputBoxProps - extends Omit, 'onChange' | 'children'> { + extends Omit, 'onChange' | 'children'>, + SharedInputBoxTypes { /** * Callback fired when any segment changes, but not necessarily a full value */ @@ -30,30 +31,6 @@ export interface InputBoxProps */ labelledBy?: string; - /** - * An object that maps the segment names to their refs - * - * @example - * { day: ref, month: ref, year: ref } - */ - segmentRefs: Record>>; - - /** - * An enumerable object that maps the segment names to their values - * - * @example - * { Day: 'day', Month: 'month', Year: 'year' } - */ - segmentEnum: Record; - - /** - * 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 * @@ -84,11 +61,6 @@ export interface InputBoxProps */ charsPerSegment: Record; - /** - * Whether the input box is disabled - */ - disabled: boolean; - /** * An object that maps the segment names to their rules. * diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index 31007d8db1..9bf6800b5c 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -1,3 +1,6 @@ +// TODO: NO LONGER NEEDED +// @ts-nocheck + import React from 'react'; import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 4279a3e3fe..00b8ddba1e 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -1,4 +1,5 @@ // TODO: NO LONGER NEEDED +// @ts-nocheck import React, { createContext, diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 97023db4d8..f78cc586c9 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -82,16 +82,10 @@ export interface InputSegmentComponentProps /** * Shared Input Box Types + * + * These types are shared between the InputBox and the segmentComponent. */ export interface SharedInputBoxTypes { - /** - * The number of characters per segment - * - * @example - * { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - /** * An enumerable object that maps the segment names to their values * @@ -120,9 +114,4 @@ export interface SharedInputBoxTypes { * Whether the input box is disabled */ disabled: boolean; - - /** - * id of the labelling element - */ - labelledBy?: string; } From bf2eeda185b4a0fdb37009ce6287c255dc027c45 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 13:14:02 -0500 Subject: [PATCH 088/124] refactor(input-box): remove InputBoxContext and related tests to streamline input box functionality --- .../InputBoxContext/InputBoxContext.spec.tsx | 75 ------------------ .../src/InputBoxContext/InputBoxContext.tsx | 77 ------------------- .../InputBoxContext/InputBoxContext.types.ts | 20 ----- .../input-box/src/InputBoxContext/index.ts | 9 --- packages/input-box/src/shared.types.ts | 20 +---- 5 files changed, 3 insertions(+), 198 deletions(-) delete mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx delete mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.tsx delete mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.types.ts delete mode 100644 packages/input-box/src/InputBoxContext/index.ts diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx deleted file mode 100644 index 31007d8db1..0000000000 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; - -import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; - -import { - charsPerSegmentMock, - SegmentObjMock, - segmentRefsMock, - segmentsMock, -} from '../testutils/testutils.mocks'; - -import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; - -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 a InputBoxProvider', - ); - } else { - expect(() => - renderHook(() => useInputBoxContext()), - ).toThrow('useInputBoxContext must be used within a InputBoxProvider'); - } - }); - - test('provides context values that match the props passed to the provider', () => { - const { result } = renderHook(() => useInputBoxContext(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - const { - charsPerSegment, - segmentEnum, - onChange, - onBlur, - segmentRefs, - segments, - disabled, - } = result.current; - - expect(charsPerSegment).toBe(charsPerSegmentMock); - expect(segmentEnum).toBe(SegmentObjMock); - expect(onChange).toBe(mockOnChange); - expect(onBlur).toBe(mockOnBlur); - expect(segmentRefs).toBe(segmentRefsMock); - expect(segments).toBe(segmentsMock); - expect(disabled).toBe(false); - }); -}); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx deleted file mode 100644 index 4279a3e3fe..0000000000 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// TODO: NO LONGER NEEDED - -import React, { - createContext, - PropsWithChildren, - useContext, - useMemo, -} from 'react'; - -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); - -// Provider is generic over T, the string union -export const InputBoxProvider = ({ - charsPerSegment, - children, - disabled, - labelledBy, - onChange, - onBlur, - segments, - segmentEnum, - segmentRefs, -}: PropsWithChildren>) => { - const value = useMemo( - () => ({ - charsPerSegment, - children, - disabled, - labelledBy, - onChange, - onBlur, - segments, - segmentEnum, - segmentRefs, - }), - [ - charsPerSegment, - children, - disabled, - labelledBy, - onChange, - onBlur, - segments, - segmentEnum, - segmentRefs, - ], - ); - - // 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} - - ); -}; - -// 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 a InputBoxProvider', - ); - } - - return context; -}; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts deleted file mode 100644 index 0834e83062..0000000000 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - InputSegmentChangeEventHandler, - SharedInputBoxTypes, -} from '../shared.types'; - -export interface InputBoxContextType - extends SharedInputBoxTypes { - /** - * The handler for the onChange event that will be read in the InputSegment component - */ - onChange: InputSegmentChangeEventHandler; - - /** - * The handler for the onBlur event that will be read by the InputSegment component - */ - onBlur: (event: React.FocusEvent) => void; -} - -export interface InputBoxProviderProps - extends InputBoxContextType {} diff --git a/packages/input-box/src/InputBoxContext/index.ts b/packages/input-box/src/InputBoxContext/index.ts deleted file mode 100644 index 226a86c6bb..0000000000 --- a/packages/input-box/src/InputBoxContext/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - InputBoxContext, - InputBoxProvider, - useInputBoxContext, -} from './InputBoxContext'; -export type { - InputBoxContextType, - InputBoxProviderProps, -} from './InputBoxContext.types'; diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 97023db4d8..41c8ac4c6c 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -46,10 +46,7 @@ export function isInputSegment>( */ export interface InputSegmentComponentProps extends Omit, 'onChange'>, - Pick< - SharedInputBoxTypes, - 'segments' | 'segmentEnum' | 'disabled' | 'segmentRefs' - > { + SharedInputBoxTypes { /** * Which segment this input represents * @@ -82,16 +79,10 @@ export interface InputSegmentComponentProps /** * Shared Input Box Types + * + * These types are shared between the InputBox and the segmentComponent. */ export interface SharedInputBoxTypes { - /** - * The number of characters per segment - * - * @example - * { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - /** * An enumerable object that maps the segment names to their values * @@ -120,9 +111,4 @@ export interface SharedInputBoxTypes { * Whether the input box is disabled */ disabled: boolean; - - /** - * id of the labelling element - */ - labelledBy?: string; } From 41b814c96fec3b59b9a60985e2fce9575969d1b7 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 14:27:47 -0500 Subject: [PATCH 089/124] wip --- .../DateInput/DateInputBox/DateInputBox.tsx | 1 - .../DateInputSegment/DateInputSegment.tsx | 82 +++++++++++-------- packages/input-box/src/index.ts | 1 + 3 files changed, 49 insertions(+), 35 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 8305e034d7..6ca52a2096 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -111,7 +111,6 @@ export const DateInputBox = React.forwardRef( onSegmentChange={onSegmentChange} labelledBy={labelledBy} segmentComponent={DateInputSegment} - size={size} {...rest} /> 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 9f4ae4e39b..c405729be1 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -3,7 +3,12 @@ import React from 'react'; import { cx } from '@leafygreen-ui/emotion'; import { InputSegment } from '@leafygreen-ui/input-box'; -import { defaultMax, defaultMin, defaultPlaceholder } from '../../../constants'; +import { + charsPerSegment, + defaultMax, + defaultMin, + defaultPlaceholder, +} from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; import { DateSegment } from '../../../types'; import { @@ -28,42 +33,51 @@ import { DateInputSegmentProps } from './DateInputSegment.types'; export const DateInputSegment = React.forwardRef< HTMLInputElement, DateInputSegmentProps ->(({ segment, ...rest }: DateInputSegmentProps, fwdRef) => { - const { - autoComplete: autoCompleteProp, - min: minContextProp, - max: maxContextProp, - } = useSharedDatePickerContext(); +>( + ( + { segment, segmentRefs, segments, ...rest }: DateInputSegmentProps, + fwdRef, + ) => { + const { + autoComplete: autoCompleteProp, + min: minContextProp, + max: maxContextProp, + size, + } = 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 shouldWrap = segment !== DateSegment.Year; - const shouldSkipValidation = segment === DateSegment.Year; + const shouldWrap = segment !== DateSegment.Year; + const shouldValidate = segment !== DateSegment.Year; - return ( - - ); -}); + return ( + + ); + }, +); DateInputSegment.displayName = 'DateInputSegment'; diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index 845c076be5..e19e1813ea 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -14,4 +14,5 @@ export { export { type InputSegmentChangeEventHandler, isInputSegment, + type InputSegmentComponentProps, } from './shared.types'; From 904fb8cf2c9f0ea5cb3e1b3ef9e54cb04540a155 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 15:14:36 -0500 Subject: [PATCH 090/124] refactor(input-box): simplify InputSegment types by removing Value generic and updating related components for improved clarity --- .../src/InputSegment/InputSegment.stories.tsx | 3 +-- .../src/InputSegment/InputSegment.tsx | 4 +-- .../src/InputSegment/InputSegment.types.ts | 8 +++--- packages/input-box/src/testutils/index.tsx | 27 ++++--------------- .../src/testutils/testutils.mocks.ts | 2 -- 5 files changed, 12 insertions(+), 32 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index af5263341b..6cb56d500b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -13,7 +13,6 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, - InputSegmentValueMock, SegmentObjMock, } from '../testutils/testutils.mocks'; @@ -104,7 +103,7 @@ export const LiveExample: StoryFn = ({ darkMode: _darkMode, ...rest }) => { - const [value, setValue] = useState(''); + const [value, setValue] = useState(''); return ( ( +const InputSegmentWithRef = ( { segment, onKeyDown, @@ -40,7 +40,7 @@ const InputSegmentWithRef = ( shouldWrap = true, shouldValidate = true, ...rest - }: InputSegmentProps, + }: InputSegmentProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 03de1ebb2c..f1c4757227 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -4,7 +4,7 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentComponentProps } from '../shared.types'; -export interface InputSegmentProps +export interface InputSegmentProps extends Omit< React.ComponentPropsWithRef<'input'>, 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' @@ -68,7 +68,7 @@ export interface InputSegmentProps * '2' * '2025' */ - value: Value; + value: string; /** * The number of characters per segment @@ -100,8 +100,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 c4d9e26dd9..bda44375ad 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -8,35 +8,18 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, - InputSegmentValueMock, SegmentObjMock, } from './testutils.mocks'; -/* - * InputSegment Utils // TODO: remove this? - */ -export const setSegmentProps = (segment: SegmentObjMock) => { - return { - segment: segment, - charsPerSegment: charsPerSegmentMock[segment], - minSegmentValue: defaultMinMock[segment], - maxSegmentValue: defaultMaxMock[segment], - placeholder: defaultPlaceholderMock[segment], - }; -}; - interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; rerenderSegment: ( - newProps: Partial>, + newProps: Partial>, ) => void; } -const defaultSegmentProps: InputSegmentProps< - SegmentObjMock, - InputSegmentValueMock -> = { +const defaultSegmentProps: InputSegmentProps = { segment: 'day', minSegmentValue: defaultMinMock['day'], maxSegmentValue: defaultMaxMock['day'], @@ -55,17 +38,17 @@ const defaultSegmentProps: InputSegmentProps< * Renders the InputSegment component for testing purposes. */ export const renderSegment = ( - props: Partial>, + props: Partial>, ): RenderResult & RenderSegmentReturnType => { const mergedProps = { ...defaultSegmentProps, ...props, - } as InputSegmentProps; + } as InputSegmentProps; const utils = render(); const rerenderSegment = ( - newProps: Partial>, + newProps: Partial>, ) => { utils.rerender(); }; diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 8edda55612..0466e233e3 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -5,8 +5,6 @@ import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { ExplicitSegmentRule } from '../utils'; -export type InputSegmentValueMock = string; - export const SegmentObjMock = { Month: 'month', Day: 'day', From 3792f8bf247f72c5440abd55f545bb37fcdc6a59 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 15:19:05 -0500 Subject: [PATCH 091/124] refactor(input-box): update InputSegment and InputBox types to include value prop and streamline segment handling --- packages/input-box/src/InputBox/InputBox.tsx | 4 +- .../input-box/src/InputBox/InputBox.types.ts | 16 +++++ packages/input-box/src/shared.types.ts | 29 +++----- packages/input-box/src/testutils/index.tsx | 69 ++++++++++--------- 4 files changed, 65 insertions(+), 53 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index b6ee5feee7..9435b8a9d0 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -244,8 +244,8 @@ export const InputBoxWithRef = ( onChange={handleSegmentInputChange} onBlur={handleSegmentInputBlur} segmentEnum={segmentEnum} - segments={segments} - segmentRefs={segmentRefs} + value={segments[part.type]} + ref={segmentRefs[part.type]} disabled={disabled} /> ); diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index db89a226fd..156a4c3b1a 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -31,6 +31,14 @@ export interface InputBoxProps */ labelledBy?: string; + /** + * 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 * @@ -88,6 +96,14 @@ export interface InputBoxProps * segmentComponent={DateInputSegment} */ segmentComponent: React.ComponentType>; + + /** + * An object that maps the segment names to their refs + * + * @example + * { day: ref, month: ref, year: ref } + */ + segmentRefs: Record>; } /** diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 41c8ac4c6c..1409a7a8cb 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -45,7 +45,10 @@ export function isInputSegment>( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit, 'onChange'>, + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'onChange' | 'value' | 'disabled' + >, SharedInputBoxTypes { /** * Which segment this input represents @@ -69,12 +72,14 @@ export interface InputSegmentComponentProps onChange: InputSegmentChangeEventHandler; /** - * The handler for the onBlur event that will be read by the InputSegment component + * The value of the segment * * @example - * (event: React.FocusEvent) => void + * '1' + * '2' + * '2025' */ - onBlur: React.FocusEventHandler; + value: string; } /** @@ -91,22 +96,6 @@ export interface SharedInputBoxTypes { */ segmentEnum: Record; - /** - * An object that maps the segment names to their refs - * - * @example - * { day: ref, month: ref, year: ref } - */ - segmentRefs: Record>; - - /** - * An object containing the values of the segments - * - * @example - * { day: '1', month: '2', year: '2025' } - */ - segments: Record; - /** * Whether the input box is disabled */ diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 2846fc4fdb..9072b1f0ce 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -35,37 +35,44 @@ export const defaultProps: Partial> = { * @param segment - The segment to render * @returns */ -export const InputSegmentWrapper = ({ - segment, - segments, - onChange = () => {}, - onBlur = () => {}, - segmentEnum = SegmentObjMock, - disabled = false, - segmentRefs = segmentRefsMock, -}: InputSegmentComponentProps) => { - return ( - - ); -}; +export const InputSegmentWrapper = React.forwardRef< + HTMLInputElement, + InputSegmentComponentProps +>( + ( + { + segment, + value, + onChange = () => {}, + onBlur = () => {}, + segmentEnum = SegmentObjMock, + disabled = false, + }, + ref, + ) => { + return ( + + ); + }, +); /** * This component is used to render the InputBox component for testing purposes. From a486f0822bb4e511e4e39f622167202378a17886 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:08:06 -0500 Subject: [PATCH 092/124] refactor(date-picker): streamline DateInput components by simplifying props and enhancing context usage --- .../DateInput/DateInputBox/DateInputBox.tsx | 2 +- .../DateInputSegment.spec.tsx | 318 ++++++------------ .../DateInputSegment.stories.tsx | 66 +--- .../DateInputSegment/DateInputSegment.tsx | 78 ++--- .../isEverySegmentValueExplicit.ts | 2 +- packages/input-box/src/index.ts | 10 +- packages/input-box/src/testutils/index.tsx | 2 +- 7 files changed, 159 insertions(+), 319 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 6ca52a2096..ae4658c61c 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -50,7 +50,7 @@ export const DateInputBox = React.forwardRef( }: DateInputBoxProps, fwdRef, ) => { - const { isDirty, formatParts, disabled, setIsDirty, size } = + const { isDirty, formatParts, disabled, setIsDirty } = useSharedDatePickerContext(); /** if the value is a `Date` the component is dirty */ 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 20406824db..f167febdeb 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 @@ -4,94 +4,56 @@ import { render } 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 { Size } from '@leafygreen-ui/tokens'; -import { charsPerSegment, defaultMax, defaultMin } from '../../../constants'; +import { + charsPerSegment, + defaultMax, + defaultMin, + defaultPlaceholder, +} from '../../../constants'; import { SharedDatePickerProvider, SharedDatePickerProviderProps, } from '../../../context'; -import { segmentRefsMock } from '../../../testutils'; import { DateSegment } from '../../../types'; import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegmentChangeEventHandler } from './DateInputSegment.types'; import { DateInputSegment, type DateInputSegmentProps } from '.'; -const renderSegment = ({ - props = {}, - sharedDatePickerProviderProps = {}, - inputBoxProviderProps = {}, -}: { - props?: Partial; - sharedDatePickerProviderProps?: Partial; - inputBoxProviderProps?: Partial>; -}) => { +const renderSegment = ( + props?: Partial, + ctx?: Partial, +) => { const defaultSegmentProps = { value: '', - onChange: () => {}, //TODO: remove this - segment: 'day' as DateSegment, - }; - - const defaultInputBoxProviderProps = { onChange: () => {}, - onBlur: () => {}, + segment: 'day' as DateSegment, disabled: false, - size: Size.Default, - segmentRefs: segmentRefsMock, - segments: { - day: '', - month: '', - year: '', - }, + segmentEnum: DateSegment, + charsPerSegment: charsPerSegment['day'], + minSegmentValue: defaultMin['day'], + maxSegmentValue: defaultMax['day'], + placeholder: defaultPlaceholder['day'], + shouldWrap: true, + shouldValidate: true, + step: 1, }; const result = render( - - - - - - + + + + , ); - const rerenderSegment = ({ - newProps = {}, - newInputBoxProviderProps = {}, - }: { - newProps?: Partial; - newInputBoxProviderProps?: Partial>; - }) => + const rerenderSegment = (newProps: Partial) => result.rerender( - - - - - - - , + + + + , ); @@ -116,65 +78,51 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('rendering', () => { describe('day segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ props: { segment: 'day' } }); + const { input } = renderSegment({ segment: 'day' }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { const { input } = renderSegment({ - props: { segment: 'day' }, - inputBoxProviderProps: { - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + value: '12', }); expect(input.value).toBe('12'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - props: { segment: 'day' }, - inputBoxProviderProps: { - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + value: '12', }); - rerenderSegment({ - newInputBoxProviderProps: { - segments: { day: '08', month: '', year: '' }, - }, - }); + rerenderSegment({ value: '08' }); expect(getInput().value).toBe('08'); }); }); describe('month segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ props: { segment: 'month' } }); + const { input } = renderSegment({ segment: 'month' }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { const { input } = renderSegment({ - props: { segment: 'month' }, - inputBoxProviderProps: { - segments: { day: '', month: '26', year: '' }, - }, + segment: 'month', + value: '26', }); expect(input.value).toBe('26'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - props: { segment: 'month' }, - inputBoxProviderProps: { - segments: { day: '', month: '26', year: '' }, - }, + segment: 'month', + value: '26', }); rerenderSegment({ - newInputBoxProviderProps: { - segments: { day: '', month: '08', year: '' }, - }, + value: '08', }); expect(getInput().value).toBe('08'); }); @@ -182,31 +130,25 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('year segment', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({ props: { segment: 'year' } }); + const { input } = renderSegment({ segment: 'year' }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { const { input } = renderSegment({ - props: { segment: 'year' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '2023' }, - }, + segment: 'year', + value: '2023', }); expect(input.value).toBe('2023'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - props: { segment: 'year' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '2023' }, - }, + segment: 'year', + value: '2023', }); rerenderSegment({ - newInputBoxProviderProps: { - segments: { day: '', month: '', year: '1993' }, - }, + value: '1993', }); expect(getInput().value).toBe('1993'); }); @@ -223,11 +165,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Up arrow', () => { 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, - }, + segment: 'day', + value: formatter(15), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -238,11 +178,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('calls handler with min if value is undefined', () => { const { input } = renderSegment({ - props: { segment: 'day' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + value: '', + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -253,15 +191,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { 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, - }, + segment: 'day', + value: formatter(defaultMax['day']), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -274,11 +206,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Down arrow', () => { 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, - }, + segment: 'day', + value: formatter(15), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); @@ -291,11 +221,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('calls handler with max if value is undefined', () => { const { input } = renderSegment({ - props: { segment: 'day' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + value: '', + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); @@ -306,15 +234,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { 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, - }, + segment: 'day', + value: formatter(defaultMin['day']), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); @@ -333,11 +255,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Up arrow', () => { 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, - }, + segment: 'month', + value: formatter(6), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -350,11 +270,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('calls handler with min if value is undefined', () => { const { input } = renderSegment({ - props: { segment: 'month' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'month', + value: '', + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -367,15 +285,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { 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, - }, + segment: 'month', + value: formatter(defaultMax['month']), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -390,11 +302,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Down arrow', () => { 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, - }, + segment: 'month', + value: formatter(6), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); @@ -407,11 +317,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('calls handler with max if value is undefined', () => { const { input } = renderSegment({ - props: { segment: 'month' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'month', + value: '', + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); @@ -424,15 +332,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { 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, - }, + segment: 'month', + value: formatter(defaultMin['month']), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); @@ -453,11 +355,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Up arrow', () => { 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, - }, + segment: 'year', + value: formatter(1993), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -469,11 +369,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('calls handler with min if value is undefined', () => { const { input } = renderSegment({ - props: { segment: 'year' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'year', + value: '', + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -486,15 +384,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('does _not_ rollover if value exceeds max', () => { const { input } = renderSegment({ - props: { segment: 'year' }, - inputBoxProviderProps: { - segments: { - day: '', - month: '', - year: formatter(defaultMax['year']), - }, - onChange: onChangeHandler, - }, + segment: 'year', + value: formatter(defaultMax['year']), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowup}'); @@ -509,11 +401,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Down arrow', () => { 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, - }, + segment: 'year', + value: formatter(1993), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( @@ -525,11 +415,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('calls handler with max if value is undefined', () => { const { input } = renderSegment({ - props: { segment: 'year' }, - inputBoxProviderProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'year', + value: '', + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); @@ -542,15 +430,9 @@ describe('packages/date-picker/shared/date-input-segment', () => { test('does _not_ rollover if value exceeds min', () => { const { input } = renderSegment({ - props: { segment: 'year' }, - inputBoxProviderProps: { - segments: { - day: '', - month: '', - year: formatter(defaultMin['year']), - }, - onChange: onChangeHandler, - }, + segment: 'year', + value: formatter(defaultMin['year']), + onChange: onChangeHandler, }); userEvent.type(input, '{arrowdown}'); 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 2b1875f14c..dab42f551e 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.stories.tsx @@ -2,48 +2,24 @@ 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 { useSegmentRefs } from '../../../hooks'; -import { DateSegment } from '../../../types'; +import { DateSegment, DateSegmentValue } from '../../../types'; import { DateInputBoxProvider } from '../DateInputBoxContext'; import { DateInputSegment } from './DateInputSegment'; 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} - > - - + @@ -77,9 +53,13 @@ const meta: StoryMetaType< }, ], }, + controls: { + exclude: ['segmentEnum', 'onChange', 'disabled'], + }, }, args: { segment: 'day', + segmentEnum: DateSegment, }, argTypes: { segment: { @@ -92,34 +72,18 @@ const meta: StoryMetaType< export default meta; const Template: StoryFn = props => { - const [segments, setSegments] = useState({ - day: '', - month: '', - year: '', - }); - - const handleChange: InputSegmentChangeEventHandler = ({ - segment, - value, - }) => { - setSegments(prev => ({ ...prev, [segment]: value })); - }; + const [value, setValue] = useState(''); return ( - {}} - segmentRefs={useSegmentRefs()} - segments={segments} - size={Size.Default} - disabled={false} - > - - + { + setValue(value); + }} + /> ); 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 c405729be1..ee11b19841 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -33,51 +33,45 @@ import { DateInputSegmentProps } from './DateInputSegment.types'; export const DateInputSegment = React.forwardRef< HTMLInputElement, DateInputSegmentProps ->( - ( - { segment, segmentRefs, segments, ...rest }: DateInputSegmentProps, - fwdRef, - ) => { - const { - autoComplete: autoCompleteProp, - min: minContextProp, - max: maxContextProp, - size, - } = useSharedDatePickerContext(); +>(({ segment, ...rest }: DateInputSegmentProps, fwdRef) => { + const { + autoComplete: autoCompleteProp, + min: minContextProp, + max: maxContextProp, + size, + } = 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: dateValue } = useDateInputBoxContext(); + const min = + getMinSegmentValue(segment, { date: dateValue, min: minContextProp }) ?? + defaultMin[segment]; + const max = + getMaxSegmentValue(segment, { date: dateValue, max: maxContextProp }) ?? + defaultMax[segment]; - const autoComplete = getAutoComplete(autoCompleteProp, segment); + const autoComplete = getAutoComplete(autoCompleteProp, segment); - const shouldWrap = segment !== DateSegment.Year; - const shouldValidate = segment !== DateSegment.Year; + const shouldWrap = segment !== DateSegment.Year; + const shouldValidate = segment !== DateSegment.Year; - return ( - - ); - }, -); + return ( + + ); +}); DateInputSegment.displayName = 'DateInputSegment'; diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts b/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts index b8478bbc23..ddc37747d1 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValueExplicit/isEverySegmentValueExplicit.ts @@ -22,6 +22,6 @@ export const isEverySegmentValueExplicit = ( segments: DateSegmentsState, ): boolean => { return Object.entries(segments).every(([segment, value]) => - isExplicitSegmentValue(segment as DateSegment, value), + isExplicitSegmentValue({ segment: segment as DateSegment, value }), ); }; diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index e19e1813ea..460a24b527 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,5 +1,10 @@ export { InputBox, type InputBoxProps } from './InputBox'; export { InputSegment, type InputSegmentProps } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, + isInputSegment, +} from './shared.types'; export { createExplicitSegmentValidator, type ExplicitSegmentRule, @@ -11,8 +16,3 @@ export { isValidSegmentName, isValidSegmentValue, } from './utils/isValidSegment/isValidSegment'; -export { - type InputSegmentChangeEventHandler, - isInputSegment, - type InputSegmentComponentProps, -} from './shared.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 9072b1f0ce..e36c01f024 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -5,6 +5,7 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputBox, InputBoxProps } from '../InputBox'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; +import { InputSegmentComponentProps } from '../shared.types'; import { charsPerSegmentMock, @@ -18,7 +19,6 @@ import { segmentsMock, segmentWidthStyles, } from './testutils.mocks'; -import { InputSegmentComponentProps } from '../shared.types'; export const defaultProps: Partial> = { segments: segmentsMock, From 7b1db769c2fb305c3fd6ba6347a5a686fdb75ca0 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:15:54 -0500 Subject: [PATCH 093/124] refactor(input-box): update InputSegment types to include value prop and remove unused segmentRefs and segments properties for improved clarity --- .../src/InputSegment/InputSegment.stories.tsx | 4 +-- .../src/InputSegment/InputSegment.tsx | 1 - packages/input-box/src/shared.types.ts | 29 ++++++------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 6cb56d500b..a548edda4c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -72,11 +72,11 @@ const meta: StoryMetaType = { darkMode: [false, true], segment: ['day', 'year'], size: Object.values(Size), - value: ['', '2', '0', '00', '2025', '0000'], + value: ['', '2', '02', '0', '00', '2025', '0000'], }, excludeCombinations: [ { - value: ['2', '0', '00'], + value: ['2', '02', '0', '00'], segment: 'year', }, { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index cc592b8598..f907525f95 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -180,7 +180,6 @@ const InputSegmentWithRef = ( <> >( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit, 'onChange'>, + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'onChange' | 'value' | 'disabled' + >, SharedInputBoxTypes { /** * Which segment this input represents @@ -69,12 +72,14 @@ export interface InputSegmentComponentProps onChange: InputSegmentChangeEventHandler; /** - * The handler for the onBlur event that will be read by the InputSegment component + * The value of the segment * * @example - * (event: React.FocusEvent) => void + * '1' + * '2' + * '2025' */ - onBlur: React.FocusEventHandler; + value: string; } /** @@ -91,22 +96,6 @@ export interface SharedInputBoxTypes { */ segmentEnum: Record; - /** - * An object that maps the segment names to their refs - * - * @example - * { day: ref, month: ref, year: ref } - */ - segmentRefs: Record>; - - /** - * An object containing the values of the segments - * - * @example - * { day: '1', month: '2', year: '2025' } - */ - segments: Record; - /** * Whether the input box is disabled */ From 20da91918fa2822bcdb8aefee493aca6c3fb2241 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:16:52 -0500 Subject: [PATCH 094/124] refactor(input-box): remove unused Size import from InputBox.spec.tsx for cleaner code --- packages/input-box/src/InputBox/InputBox.spec.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index a6e4c4f516..dfe2c7f376 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -3,8 +3,6 @@ import { jest } from '@jest/globals'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Size } from '@leafygreen-ui/tokens'; - import { InputSegmentChangeEventHandler } from '../shared.types'; import { InputBoxWithState, From 6942348825f3476545c057fe76d9ad759b9758a1 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:48:51 -0500 Subject: [PATCH 095/124] refactor(input-box): enhance InputBox and InputSegment documentation, update props for clarity, and streamline type exports --- packages/input-box/README.md | 139 +++++++++++++-------- packages/input-box/src/index.ts | 8 +- packages/input-box/src/testutils/index.tsx | 4 +- 3 files changed, 97 insertions(+), 54 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 03d9832031..c60cd696e9 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -29,11 +29,28 @@ import { InputBox, InputSegment } from '@leafygreen-ui/input-box'; import { Size } from '@leafygreen-ui/tokens'; // 1. Create a custom segment component -const MySegment = ({ segment, ...props }) => ( +// InputBox will pass: segment, value, onChange, onBlur, segmentEnum, disabled, ref, aria-labelledby +// You add: minSegmentValue, maxSegmentValue, charsPerSegment, size, and any other InputSegment props +const MySegment = ({ + segment, + value, + onChange, + onBlur, + segmentEnum, + disabled, + ...props +}) => ( ); @@ -54,77 +71,101 @@ const MySegment = ({ segment, ...props }) => ( 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 }, + day: { maxChars: 2, minExplicitValue: 4 }, + month: { maxChars: 2, minExplicitValue: 2 }, year: { maxChars: 4, minExplicitValue: 1970 }, }} disabled={false} - size={Size.Default} />; ``` -Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for an implementation example. +Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for a full implementation example. ## Overview -An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. +An internal component for building date or time inputs with multiple segments (e.g., `DatePicker`, `TimeInput`). -This package provides two main components that work together to create segmented input experiences. +### How It Works + +`InputBox` handles the high-level coordination (navigation, formatting, focus management), while `InputSegment` handles individual segment behavior (validation, arrow key increments). + +**The `segmentComponent` Pattern:** + +`InputBox` doesn't directly render `InputSegment` components. Instead, you provide a custom `segmentComponent` that acts as a wrapper: + +1. **InputBox automatically passes** these props to your `segmentComponent`: + + - `segment` - the segment identifier (e.g., `'day'`, `'month'`) + - `value` - the current segment value + - `onChange` - change handler for the segment + - `onBlur` - blur handler for the segment + - `segmentEnum` - the segment enum object + - `disabled` - whether the segment is disabled + - `ref` - ref for the input element + - `aria-labelledby` - accessibility label reference + +2. **Your `segmentComponent` adds** segment-specific configuration: + - `minSegmentValue` / `maxSegmentValue` - validation ranges + - `charsPerSegment` - character length + - `size` - input size + - `step`, `shouldWrap`, `shouldValidate` - optional behavior customization + +This pattern allows you to define segment-specific rules (like min/max values that vary by segment) while keeping the core InputBox logic generic and reusable. ### InputBox -A generic controlled input box component that renders an input with multiple segments separated by literals. +A generic controlled input component that renders multiple segments separated by literals (e.g., `MM/DD/YYYY`). **Key Features:** -- **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. +- **Auto-format**: Pads values with leading zeros when explicit (reaches max length or `minExplicitValue` threshold) +- **Auto-advance**: Moves focus to next segment when current segment is complete +- **Keyboard navigation**: Arrow keys move between segments, backspace navigates back when empty #### 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 +| Prop | Type | Description | Default | +| ------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | ------- | +| `segments` | `Record` | Current values for all segments | | +| `setSegment` | `(segment: Segment, value: string) => void` | Callback to update a segment's value | | +| `segmentEnum` | `Record` | Maps segment names to values (e.g., `{ Day: 'day' }`) | | +| `segmentComponent` | `React.ComponentType>` | Custom wrapper component that renders InputSegment with segment-specific props | | +| `formatParts` | `Array` | Defines segment order and separators | | +| `charsPerSegment` | `Record` | Max characters per segment (e.g., `{ day: 2, year: 4 }`) | | +| `segmentRefs` | `Record>` | Refs for each segment input | | +| `segmentRules` | `Record` | Rules for auto-formatting (`maxChars`, `minExplicitValue`) | | +| `disabled` | `boolean` | Disables all segments | | +| `onSegmentChange` | `InputSegmentChangeEventHandler` | Callback fired on any segment change | | +| `labelledBy` | `string` | ID of labelling element for accessibility | | + +\+ other HTML `div` props ### InputSegment -A controlled input segment component that renders a single input field within an `InputBox`. +A generic controlled input field for a single segment within `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 +- **Arrow key increment/decrement**: Up/down arrows adjust values with optional wrapping +- **Value validation**: Validates against min/max ranges +- **Keyboard shortcuts**: Backspace/Space clears the value #### Props -| 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 | | - -\+ native HTML `input` element props +| Prop | Type | Description | Default | +| ----------------- | ------------------------------------------------- | -------------------------------------------- | ------- | +| `segment` | `Segment` | Segment identifier (e.g., `'day'`) | | +| `value` | `string` | Current segment value | | +| `minSegmentValue` | `number` | Minimum valid value | | +| `maxSegmentValue` | `number` | Maximum valid value | | +| `charsPerSegment` | `number` | Max character length | | +| `size` | `Size` | Input size | | +| `segmentEnum` | `Record` | Segment enum from InputBox | | +| `onChange` | `InputSegmentChangeEventHandler` | Change handler | | +| `onBlur` | `FocusEventHandler` | Blur handler | | +| `disabled` | `boolean` | Disables the segment | | +| `step` | `number` | Arrow key increment/decrement step | `1` | +| `shouldWrap` | `boolean` | Whether to wrap at boundaries (e.g., 31 → 1) | `true` | +| `shouldValidate` | `boolean` | Whether to validate against min/max | `true` | + +\+ native HTML `input` props diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index 845c076be5..f3e9605536 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,5 +1,9 @@ export { InputBox, type InputBoxProps } from './InputBox'; export { InputSegment, type InputSegmentProps } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + isInputSegment, +} from './shared.types'; export { createExplicitSegmentValidator, type ExplicitSegmentRule, @@ -11,7 +15,3 @@ export { isValidSegmentName, isValidSegmentValue, } from './utils/isValidSegment/isValidSegment'; -export { - type InputSegmentChangeEventHandler, - isInputSegment, -} from './shared.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 9072b1f0ce..c565201007 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -5,6 +5,7 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputBox, InputBoxProps } from '../InputBox'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; +import { InputSegmentComponentProps } from '../shared.types'; import { charsPerSegmentMock, @@ -18,7 +19,6 @@ import { segmentsMock, segmentWidthStyles, } from './testutils.mocks'; -import { InputSegmentComponentProps } from '../shared.types'; export const defaultProps: Partial> = { segments: segmentsMock, @@ -74,6 +74,8 @@ export const InputSegmentWrapper = React.forwardRef< }, ); +InputSegmentWrapper.displayName = 'InputSegmentWrapper'; + /** * This component is used to render the InputBox component for testing purposes. * Includes segment state management and a default renderSegment function. From 2dc01343a4ac0f5f9a64e4caf298f0b660ef7a1b Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 10:47:29 -0500 Subject: [PATCH 096/124] testing From b4dd84daef19a91493916518615520cde26998de Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:07:27 -0500 Subject: [PATCH 097/124] refactor(input-box): remove unused dependencies and update InputSegment types for consistency --- packages/input-box/package.json | 1 - .../src/InputSegment/InputSegment.stories.tsx | 3 +++ .../src/InputSegment/InputSegment.types.ts | 26 ------------------- packages/input-box/src/shared.types.ts | 6 ++--- .../getNewSegmentValueFromInputValue.ts | 3 +-- .../time-input/src/TimeInput/TimeInput.tsx | 2 +- 6 files changed, 8 insertions(+), 33 deletions(-) diff --git a/packages/input-box/package.json b/packages/input-box/package.json index 2b5ef5e3c8..cc5cb766c5 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -33,7 +33,6 @@ "@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:^" }, diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index a548edda4c..80ea1054ac 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -116,5 +116,8 @@ export const LiveExample: StoryFn = ({ /> ); }; +LiveExample.parameters = { + chromatic: { disableSnapshot: true }, +}; export const Generated = () => {}; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index f1c4757227..1ad6384879 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -15,27 +15,11 @@ export interface InputSegmentProps > { /** * Minimum value for the segment - * - * @example - * 1 - * 1 - * 1970 - * 0 - * 0 - * 0 */ minSegmentValue: number; /** * Maximum value for the segment - * - * @example - * 31 - * 12 - * 2038 - * 23 - * 59 - * 59 */ maxSegmentValue: number; @@ -62,21 +46,11 @@ export interface InputSegmentProps /** * The value of the segment - * - * @example - * '1' - * '2' - * '2025' */ value: string; /** * The number of characters per segment - * - * @example - * 2 - * 2 - * 4 */ charsPerSegment: number; diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 1409a7a8cb..35552be958 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -57,9 +57,9 @@ export interface InputSegmentComponentProps * 'day' * 'month' * 'year' - * 'hours' - * 'minutes' - * 'seconds' + * 'hour' + * 'minute' + * 'second' */ segment: Segment; diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 66178da60f..5057b3abea 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -107,7 +107,6 @@ export const getNewSegmentValueFromInputValue = < const isIncomingValueNumber = !isNaN(Number(incomingValue)); // macOS adds a period when pressing SPACE twice inside a text input. const doesIncomingValueContainPeriod = /\./.test(incomingValue); - const shouldSkipValidation = !shouldValidate; // if the current value is "full", do not allow any additional characters to be entered const wouldCauseOverflow = @@ -130,7 +129,7 @@ export const getNewSegmentValueFromInputValue = < segmentEnum, }); - if (isIncomingValueValid || shouldSkipValidation) { + if (isIncomingValueValid || !shouldValidate) { const newValue = truncateStart(incomingValue, { length: charsPerSegment, }); diff --git a/packages/time-input/src/TimeInput/TimeInput.tsx b/packages/time-input/src/TimeInput/TimeInput.tsx index 44a8b67970..b97481ed36 100644 --- a/packages/time-input/src/TimeInput/TimeInput.tsx +++ b/packages/time-input/src/TimeInput/TimeInput.tsx @@ -1,5 +1,6 @@ import React, { forwardRef } from 'react'; +import { DateType } from '@leafygreen-ui/date-utils'; import { useControlled } from '@leafygreen-ui/hooks'; import LeafyGreenProvider, { useDarkMode, @@ -17,7 +18,6 @@ import { import { TimeInputContent } from '../TimeInputContent'; import { TimeInputProps } from './TimeInput.types'; -import { DateType } from '@leafygreen-ui/date-utils'; export const TimeInput = forwardRef( ( From f2cfaa30a60baaef205da5819ba7dfed501e6bc6 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:08:54 -0500 Subject: [PATCH 098/124] update lock file --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37083bd443..d17f46d811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2198,9 +2198,6 @@ 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 From c269b96a986acd6bf9371c47b7f59e88ff8c9e11 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:19:31 -0500 Subject: [PATCH 099/124] testing From 73ea2738631ac4a1847145b2bfae11273fec263f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:58:48 -0500 Subject: [PATCH 100/124] testing build From a55bf242b17244d39346895674c47b363817f67c Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 12:31:41 -0500 Subject: [PATCH 101/124] testing build From 67d8f9f0183125cb6627f1836c42a3f2cb205a39 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 14:43:23 -0500 Subject: [PATCH 102/124] test(input-segment): add test for resetting value with complete zeros and update InputSegment story with segmentEnum --- .../src/InputSegment/InputSegment.spec.tsx | 18 ++++++++++++++++++ .../src/InputSegment/InputSegment.stories.tsx | 1 + .../src/InputSegment/InputSegment.tsx | 9 +++------ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index f9888c6dc8..04c9fbf185 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -160,6 +160,24 @@ describe('packages/input-segment', () => { expect.objectContaining({ value: '4' }), ); }); + + test('resets the value when the value is complete with zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + value: '00', + maxSegmentValue: 31, + onChange: onChangeHandler, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); }); describe('keyboard events', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 80ea1054ac..316d71d127 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -42,6 +42,7 @@ const meta: StoryMetaType = { step: 1, darkMode: false, charsPerSegment: charsPerSegmentMock[SegmentObjMock.Day], + segmentEnum: SegmentObjMock, }, argTypes: { size: { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index f907525f95..4a46fa7c59 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -90,13 +90,10 @@ const InputSegmentWithRef = ( 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 the value is a number, we check if the input is full and reset it if it is. The number will be inserted into the input when onChange is called. + // This is to handle the case where the user tries to type a number when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked + const isNumber = /^[0-9]$/.test(key); 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) { target.value = ''; } From d7c1fc2f066ecff639794069214870c34017f889 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 14:48:35 -0500 Subject: [PATCH 103/124] refactor(input-box): update separator literal styles to use new token-based approach --- .../input-box/src/InputBox/InputBox.styles.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.styles.ts b/packages/input-box/src/InputBox/InputBox.styles.ts index 53e3de972e..d5df050331 100644 --- a/packages/input-box/src/InputBox/InputBox.styles.ts +++ b/packages/input-box/src/InputBox/InputBox.styles.ts @@ -1,6 +1,6 @@ import { css, cx } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; +import { color, InteractionState, Variant } from '@leafygreen-ui/tokens'; export const segmentPartsWrapperStyles = css` display: flex; @@ -12,14 +12,10 @@ 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}; - `, -}; +export const getSeparatorLiteralDisabledStyles = (theme: Theme) => + css` + color: ${color[theme].text[Variant.Disabled][InteractionState.Default]}; + `; export const getSeparatorLiteralStyles = ({ theme, @@ -29,7 +25,7 @@ export const getSeparatorLiteralStyles = ({ disabled?: boolean; }) => { return cx(separatorLiteralStyles, { - [separatorLiteralDisabledStyles[theme]]: disabled, + [getSeparatorLiteralDisabledStyles(theme)]: disabled, }); }; From f52ed19053ad1d8a1e6948dcda0f8b7167f0de45 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 15:59:43 -0500 Subject: [PATCH 104/124] fix(input-segment): add missing line to check for number input handling --- packages/input-box/src/InputSegment/InputSegment.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 4a46fa7c59..9977d45b56 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -93,6 +93,7 @@ const InputSegmentWithRef = ( // If the value is a number, we check if the input is full and reset it if it is. The number will be inserted into the input when onChange is called. // This is to handle the case where the user tries to type a number when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked const isNumber = /^[0-9]$/.test(key); + if (isNumber) { if (target.value.length === charsPerSegment) { target.value = ''; From 68e5f2c7b334b74f26b8067fac665f456689bfdc Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 09:56:19 -0500 Subject: [PATCH 105/124] refactor(input-segment): update comments and variable name for clarity in digit input handling --- packages/input-box/src/InputSegment/InputSegment.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index ee965bca0e..936893770f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -90,11 +90,11 @@ const InputSegmentWithRef = ( target: HTMLInputElement; }; - // If the value is a number, we check if the input is full and reset it if it is. The number will be inserted into the input when onChange is called. - // This is to handle the case where the user tries to type a number when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked - const isNumber = /^[0-9]$/.test(key); + // If the value is a single digit, we check if the input is full and reset it if it is. The digit will be inserted into the input when onChange is called. + // This is to handle the case where the user tries to type a single digit when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked + const isSingleDigit = /^[0-9]$/.test(key); - if (isNumber) { + if (isSingleDigit) { if (target.value.length === charsCount) { target.value = ''; } From 3896c9c51f3ee439057b08701859613b94c16fd4 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 09:57:52 -0500 Subject: [PATCH 106/124] refactor(input-box): update comment to reflect correct component responsible for increment/decrement logic --- packages/input-box/src/InputBox/InputBox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 9435b8a9d0..a1fef41a83 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -182,7 +182,7 @@ export const InputBoxWithRef = ( case keyMap.ArrowUp: case keyMap.ArrowDown: { - // increment/decrement logic implemented by DateInputSegment + // increment/decrement logic implemented by InputSegment break; } From 410813e146e6f602518ad01e6e9326d5ad178129 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 10:13:47 -0500 Subject: [PATCH 107/124] refactor(input-segment): utilize isSingleDigit utility for digit input handling --- .../input-box/src/InputSegment/InputSegment.tsx | 5 ++--- packages/input-box/src/utils/index.ts | 1 + .../src/utils/isSingleDigit/isSingleDigit.spec.ts | 14 ++++++++++++++ .../src/utils/isSingleDigit/isSingleDigit.ts | 7 +++++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts create mode 100644 packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 936893770f..fa9cd00443 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -14,6 +14,7 @@ import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, getValueFormatter, + isSingleDigit, } from '../utils'; import { getInputSegmentStyles } from './InputSegment.styles'; @@ -92,9 +93,7 @@ const InputSegmentWithRef = ( // If the value is a single digit, we check if the input is full and reset it if it is. The digit will be inserted into the input when onChange is called. // This is to handle the case where the user tries to type a single digit when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked - const isSingleDigit = /^[0-9]$/.test(key); - - if (isSingleDigit) { + if (isSingleDigit(key)) { if (target.value.length === charsCount) { target.value = ''; } diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index 9754f2fa90..ab8267d6dd 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -15,3 +15,4 @@ export { isValidSegmentValue, } from './isValidSegment/isValidSegment'; export { isValidValueForSegment } from './isValidValueForSegment/isValidValueForSegment'; +export { isSingleDigit } from './isSingleDigit/isSingleDigit'; diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts new file mode 100644 index 0000000000..cffc7ae307 --- /dev/null +++ b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts @@ -0,0 +1,14 @@ +import range from 'lodash/range'; + +import { isSingleDigit } from './isSingleDigit'; +import { keyMap } from '@leafygreen-ui/lib'; + +describe('packages/input-box/utils/isSingleDigit', () => { + test.each(range(10))('returns true for %i character', i => { + expect(isSingleDigit(`${i}`)).toBe(true); + }); + + test.each(Object.values(keyMap))('returns false for %s', key => { + expect(isSingleDigit(key)).toBe(false); + }); +}); diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts new file mode 100644 index 0000000000..4d0670df47 --- /dev/null +++ b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts @@ -0,0 +1,7 @@ +/** + * Checks if the key is a single digit. + * + * @param key - The key to check. + * @returns True if the key is a single digit, false otherwise. + */ +export const isSingleDigit = (key: string): boolean => /^[0-9]$/.test(key); From ed31fdc88bc67aba8ca7e5eda2e82c95a1edcd05 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 10:23:36 -0500 Subject: [PATCH 108/124] refactor(input-box): enhance documentation for InputBox component to clarify functionality and usage --- packages/input-box/src/InputBox/InputBox.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index a1fef41a83..e69d28d26d 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -25,13 +25,7 @@ import { } from './InputBox.styles'; import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; -/** - * Generic controlled input box component - * Renders an input box with appropriate segment order & separator characters. - * - * @internal - */ -export const InputBoxWithRef = ( +const InputBoxWithRef = ( { className, labelledBy, @@ -256,6 +250,12 @@ export const InputBoxWithRef = ( ); }; +/** + * Generic controlled input box component that renders multiple input segments with separators. + * + * Supports auto-formatting, auto-advance focus, keyboard navigation (arrow keys), value increment/decrement, + * validation, and blur formatting. It is designed primarily for date and time inputs. + */ export const InputBox = React.forwardRef( InputBoxWithRef, ) as InputBoxComponentType; From adaa3b6f52f1f2f041b7944fa52d5048afceeb3f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 11:45:18 -0500 Subject: [PATCH 109/124] refactor(input-box): integrate size prop into InputBox and InputSegment components for enhanced customization --- packages/input-box/src/InputBox.stories.tsx | 7 +++- .../input-box/src/InputBox/InputBox.spec.tsx | 3 +- packages/input-box/src/InputBox/InputBox.tsx | 10 ++++-- .../input-box/src/InputBox/InputBox.types.ts | 8 ----- .../src/InputSegment/InputSegment.spec.tsx | 5 ++- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.tsx | 3 +- .../src/InputSegment/InputSegment.types.ts | 33 ++----------------- packages/input-box/src/shared.types.ts | 20 ++++++++++- packages/input-box/src/testutils/index.tsx | 11 ++++--- .../src/testutils/testutils.mocks.ts | 11 ++----- packages/input-box/src/utils/index.ts | 2 +- .../utils/isSingleDigit/isSingleDigit.spec.ts | 3 +- 13 files changed, 53 insertions(+), 65 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 5b52faf9af..e3175d9930 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,6 +7,7 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; +import { Size } from '@leafygreen-ui/tokens'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; @@ -35,7 +36,6 @@ const meta: StoryMetaType = { 'segmentObj', 'segmentRefs', 'setSegment', - 'charsPerSegment', 'formatParts', 'segmentRules', 'labelledBy', @@ -50,9 +50,14 @@ const meta: StoryMetaType = { disabled: { control: 'boolean', }, + size: { + control: 'select', + options: Object.values(Size), + }, }, args: { disabled: false, + size: Size.Default, }, }; export default meta; diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index dfe2c7f376..3f484858b2 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -10,7 +10,6 @@ import { renderInputBox, } from '../testutils'; import { - charsPerSegmentMock, SegmentObjMock, segmentRefsMock, segmentRulesMock, @@ -528,10 +527,10 @@ describe('packages/input-box', () => { segmentRefs={segmentRefsMock} segments={segmentsMock} setSegment={() => {}} - charsPerSegment={charsPerSegmentMock} segmentRules={segmentRulesMock} segmentComponent={InputSegmentWrapper} disabled={false} + size={'default'} />; }); }); diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index e69d28d26d..adef396b40 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -6,6 +6,7 @@ import React, { import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentChangeEventHandler, @@ -34,12 +35,12 @@ const InputBoxWithRef = ( onKeyDown, setSegment, disabled, - charsPerSegment, formatParts, segmentEnum, segmentRules, segmentComponent, segments, + size = Size.Default, ...rest }: InputBoxProps, fwdRef: ForwardedRef, @@ -51,6 +52,9 @@ const InputBoxWithRef = ( rules: segmentRules, }); + const getCharsPerSegment = (segment: Segment) => + segmentRules[segment].maxChars; + /** Formats and sets the segment value. */ const getFormattedSegmentValue = ( segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], @@ -58,7 +62,7 @@ const InputBoxWithRef = ( allowZero: boolean, ): string => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment[segmentName], + charsPerSegment: getCharsPerSegment(segmentName), allowZero, }); const formattedValue = formatter(segmentValue); @@ -241,6 +245,8 @@ const InputBoxWithRef = ( value={segments[part.type]} ref={segmentRefs[part.type]} disabled={disabled} + charsCount={getCharsPerSegment(part.type)} + size={size} /> ); } diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 156a4c3b1a..84b71f0a1d 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -61,14 +61,6 @@ export interface InputBoxProps */ formatParts?: Array; - /** - * The number of characters per segment - * - * @example - * { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - /** * An object that maps the segment names to their rules. * diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 90749592c5..0ba99616e9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -5,7 +5,6 @@ import { axe } from 'jest-axe'; import { type InputSegmentChangeEventHandler } from '../shared.types'; import { renderSegment } from '../testutils'; import { - charsPerSegmentMock, defaultMaxMock, defaultMinMock, SegmentObjMock, @@ -183,8 +182,8 @@ describe('packages/input-segment', () => { describe('keyboard events', () => { describe('Arrow keys', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegmentMock['day'], - allowZero: defaultMinMock['day'] === 0, + charsPerSegment: 2, + allowZero: true, }); describe('Up arrow', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 1811d2ac32..5802410cac 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -58,7 +58,7 @@ const meta: StoryMetaType = { 'segment', 'value', 'onChange', - 'charsPerSegment', + 'charsCount', 'segmentEnum', 'shouldValidate', 'step', diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index fa9cd00443..eb9d40249c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -8,6 +8,7 @@ import React, { import { VisuallyHidden } from '@leafygreen-ui/a11y'; 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 { @@ -33,10 +34,10 @@ const InputSegmentWithRef = ( onChange, onBlur, segmentEnum, - size, disabled, value, charsCount, + size = Size.Default, step = 1, shouldWrap = true, shouldValidate = true, diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 9e436dd943..a9d724d5b9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,18 +1,9 @@ -import React, { ForwardedRef, ReactElement } from 'react'; - -import { Size } from '@leafygreen-ui/tokens'; +import { ForwardedRef, ReactElement } from 'react'; import { InputSegmentComponentProps } from '../shared.types'; export interface InputSegmentProps - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' - >, - Pick< - InputSegmentComponentProps, - 'onChange' | 'onBlur' | 'segment' | 'segmentEnum' - > { + extends InputSegmentComponentProps { /** * Minimum value for the segment */ @@ -43,26 +34,6 @@ export interface InputSegmentProps * @default true */ shouldValidate?: boolean; - - /** - * The value of the segment - */ - value: string; - - /** - * The number of characters per segment - */ - charsCount: number; - - /** - * The size of the input box - * - * @example - * Size.Default - * Size.Small - * Size.Large - */ - size: Size; } /** diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7e49b4b154..ea8c8774f4 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,4 +1,5 @@ import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; /** * SharedInput Segment Types @@ -47,7 +48,7 @@ export function isInputSegment>( export interface InputSegmentComponentProps extends Omit< React.ComponentPropsWithRef<'input'>, - 'onChange' | 'value' | 'disabled' + 'onChange' | 'value' | 'disabled' | 'size' | 'step' >, SharedInputBoxTypes { /** @@ -67,6 +68,11 @@ export interface InputSegmentComponentProps * The value of the segment */ value: string; + + /** + * The number of characters per segment + */ + charsCount: number; } /** @@ -87,4 +93,16 @@ export interface SharedInputBoxTypes { * Whether the input box is disabled */ disabled: boolean; + + /** + * The size of the input box + * + * @example + * Size.Default + * Size.Small + * Size.Large + * + * @default Size.Default + */ + size?: Size; } diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 2b97033a7e..49f95233dc 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -8,7 +8,6 @@ import { InputSegment, type InputSegmentProps } from '../InputSegment'; import { InputSegmentComponentProps } from '../shared.types'; import { - charsPerSegmentMock, defaultFormatPartsMock, defaultMaxMock, defaultMinMock, @@ -25,7 +24,6 @@ export const defaultProps: Partial> = { segmentEnum: SegmentObjMock, segmentRefs: segmentRefsMock, setSegment: () => {}, - charsPerSegment: charsPerSegmentMock, formatParts: defaultFormatPartsMock, segmentRules: segmentRulesMock, }; @@ -47,6 +45,8 @@ export const InputSegmentWrapper = React.forwardRef< onBlur = () => {}, segmentEnum = SegmentObjMock, disabled = false, + charsCount, + size, }, ref, ) => { @@ -62,10 +62,10 @@ export const InputSegmentWrapper = React.forwardRef< minSegmentValue={defaultMinMock[segment]} maxSegmentValue={defaultMaxMock[segment]} value={value} - charsCount={charsPerSegmentMock[segment]} + charsCount={charsCount} onChange={onChange} onBlur={onBlur} - size={Size.Default} + size={size} segmentEnum={segmentEnum} ref={ref} disabled={disabled} @@ -89,6 +89,7 @@ export const InputBoxWithState = ({ }, setSegment: setSegmentProp, disabled = false, + size = Size.Default, ...props }: Partial> & { segments?: Record; @@ -120,11 +121,11 @@ export const InputBoxWithState = ({ segmentRefs={segmentRefs} segments={effectiveSegments} setSegment={effectiveSetSegment} - charsPerSegment={charsPerSegmentMock} formatParts={defaultFormatPartsMock} segmentRules={segmentRulesMock} segmentComponent={InputSegmentWrapper} disabled={disabled} + size={size} {...props} /> ); diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 0466e233e3..b945838c43 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -29,11 +29,6 @@ export const segmentsMock: Record = { 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 }, @@ -74,12 +69,12 @@ export const characterWidth = { export const segmentWidthStyles: Record = { day: css` - width: ${charsPerSegmentMock.day * characterWidth.D}ch; + width: ${segmentRulesMock['day'].maxChars * characterWidth.D}ch; `, month: css` - width: ${charsPerSegmentMock.month * characterWidth.M}ch; + width: ${segmentRulesMock['month'].maxChars * characterWidth.M}ch; `, year: css` - width: ${charsPerSegmentMock.year * characterWidth.Y}ch; + width: ${segmentRulesMock['year'].maxChars * characterWidth.Y}ch; `, }; diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index ab8267d6dd..ab1d6778d8 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -10,9 +10,9 @@ export { } from './getRelativeSegment/getRelativeSegment'; export { getValueFormatter } from './getValueFormatter/getValueFormatter'; export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment'; +export { isSingleDigit } from './isSingleDigit/isSingleDigit'; export { isValidSegmentName, isValidSegmentValue, } from './isValidSegment/isValidSegment'; export { isValidValueForSegment } from './isValidValueForSegment/isValidValueForSegment'; -export { isSingleDigit } from './isSingleDigit/isSingleDigit'; diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts index cffc7ae307..4649204712 100644 --- a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts +++ b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts @@ -1,8 +1,9 @@ import range from 'lodash/range'; -import { isSingleDigit } from './isSingleDigit'; import { keyMap } from '@leafygreen-ui/lib'; +import { isSingleDigit } from './isSingleDigit'; + describe('packages/input-box/utils/isSingleDigit', () => { test.each(range(10))('returns true for %i character', i => { expect(isSingleDigit(`${i}`)).toBe(true); From a106f7153bd922f533bf97f704c85a695c478d75 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 13:17:10 -0500 Subject: [PATCH 110/124] refactor(input-box): migrate Size import to shared.types for consistent usage across components --- packages/input-box/src/InputBox.stories.tsx | 2 +- packages/input-box/src/InputBox/InputBox.tsx | 2 +- packages/input-box/src/InputSegment/InputSegment.stories.tsx | 2 +- packages/input-box/src/InputSegment/InputSegment.tsx | 2 +- packages/input-box/src/index.ts | 1 + packages/input-box/src/shared.types.ts | 2 ++ packages/input-box/src/testutils/index.tsx | 4 +--- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index e3175d9930..75b01b256a 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,7 +7,7 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; -import { Size } from '@leafygreen-ui/tokens'; +import { Size } from './shared.types'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index adef396b40..16671707bb 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -6,10 +6,10 @@ import React, { import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentChangeEventHandler, + Size, isInputSegment, } from '../shared.types'; import { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 5802410cac..ed15580d49 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -6,7 +6,7 @@ import { import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; -import { Size } from '@leafygreen-ui/tokens'; +import { Size } from '../shared.types'; import { defaultPlaceholderMock, diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index eb9d40249c..bf8abc9fec 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -8,7 +8,7 @@ import React, { import { VisuallyHidden } from '@leafygreen-ui/a11y'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; +import { Size } from '../shared.types'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; import { diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index f3e9605536..56f7ff51cf 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -3,6 +3,7 @@ export { InputSegment, type InputSegmentProps } from './InputSegment'; export { type InputSegmentChangeEventHandler, isInputSegment, + Size, } from './shared.types'; export { createExplicitSegmentValidator, diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index ea8c8774f4..f866fce8fa 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,6 +1,8 @@ import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; +export { Size }; + /** * SharedInput Segment Types */ diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 49f95233dc..a265a99d26 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,11 +1,9 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { Size } from '@leafygreen-ui/tokens'; - import { InputBox, InputBoxProps } from '../InputBox'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; -import { InputSegmentComponentProps } from '../shared.types'; +import { InputSegmentComponentProps, Size } from '../shared.types'; import { defaultFormatPartsMock, From df546c1397d7216a2729a1cd2a36590f7d0044ac Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 16:21:46 -0500 Subject: [PATCH 111/124] refactor(input-box): enhance InputBox and InputSegment tests with segmentRefs integration for improved focus handling --- packages/input-box/src/InputBox.stories.tsx | 2 +- .../input-box/src/InputBox/InputBox.spec.tsx | 191 ++++++++++-------- packages/input-box/src/InputBox/InputBox.tsx | 11 +- .../input-box/src/InputBox/InputBox.types.ts | 2 +- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.tsx | 2 +- packages/input-box/src/hooks/index.ts | 1 + .../useSegmentRefs/useSegmentRefs.spec.ts | 166 +++++++++++++++ .../hooks/useSegmentRefs/useSegmentRefs.ts | 29 +++ packages/input-box/src/testutils/index.tsx | 11 - 10 files changed, 318 insertions(+), 99 deletions(-) create mode 100644 packages/input-box/src/hooks/index.ts create mode 100644 packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts create mode 100644 packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 75b01b256a..1c9336cd45 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,10 +7,10 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; -import { Size } from './shared.types'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; +import { Size } from './shared.types'; import { InputBoxWithState } from './testutils'; const meta: StoryMetaType = { diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 3f484858b2..a45ecfe632 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -125,35 +125,48 @@ describe('packages/input-box', () => { }); describe('auto-focus', () => { - test('focuses the next segment when an explicit value is entered', () => { - const { dayInput, monthInput } = renderInputBox({}); - - userEvent.type(monthInput, '02'); - expect(dayInput).toHaveFocus(); - expect(monthInput.value).toBe('02'); - }); + describe.each([undefined, segmentRefsMock])( + 'when segmentRefs are %p', + segmentRefs => { + test('focuses the next segment when an explicit value is entered', () => { + const { dayInput, monthInput } = renderInputBox({ + segmentRefs, + }); + + 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', () => { - const { dayInput } = renderInputBox({}); + test('focus remains in the current segment when an ambiguous value is entered', () => { + const { dayInput } = renderInputBox({ + segmentRefs, + }); - userEvent.type(dayInput, '2'); - expect(dayInput).toHaveFocus(); - }); + 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 } = renderInputBox({}); + test('focuses the previous segment when a backspace is pressed and the current segment is empty', () => { + const { dayInput, monthInput } = renderInputBox({ + segmentRefs, + }); - userEvent.type(dayInput, '{backspace}'); - expect(monthInput).toHaveFocus(); - }); + 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 } = renderInputBox({}); + test('focus remains in the current segment when a backspace is pressed and the current segment is not empty', () => { + const { monthInput } = renderInputBox({ + segmentRefs, + }); - userEvent.type(monthInput, '2'); - userEvent.type(monthInput, '{backspace}'); - expect(monthInput).toHaveFocus(); - }); + userEvent.type(monthInput, '2'); + userEvent.type(monthInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + }, + ); }); describe('Mouse interaction', () => { @@ -182,64 +195,6 @@ describe('packages/input-box', () => { 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(); - }); - }); - - 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(); - }); - }); - describe('Up arrow', () => { test('keeps the focus in the current segment when the segment is empty', () => { const { dayInput } = renderInputBox({}); @@ -275,6 +230,79 @@ describe('packages/input-box', () => { expect(dayInput).toHaveFocus(); }); }); + + describe.each([undefined, segmentRefsMock])( + 'when segmentRefs are %p', + segmentRefs => { + describe('Right arrow', () => { + test('Right arrow key moves focus to next segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segmentRefs, + }); + 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({ + segmentRefs, + 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({ + segmentRefs, + }); + userEvent.click(monthInput); + userEvent.type(monthInput, '0{arrowright}'); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Left arrow', () => { + test('Left arrow key moves focus to previous segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segmentRefs, + }); + 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({ + segmentRefs, + 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({ + segmentRefs, + }); + userEvent.click(yearInput); + userEvent.type(yearInput, '0{arrowleft}'); + expect(dayInput).toHaveFocus(); + }); + }); + }, + ); }); describe('onBlur', () => { @@ -524,7 +552,6 @@ describe('packages/input-box', () => { test('With required props', () => { {}} segmentRules={segmentRulesMock} diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 16671707bb..ec32c0cbe0 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -7,10 +7,11 @@ import React, { import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; +import { useSegmentRefs } from '../hooks'; import { InputSegmentChangeEventHandler, - Size, isInputSegment, + Size, } from '../shared.types'; import { createExplicitSegmentValidator, @@ -30,7 +31,7 @@ const InputBoxWithRef = ( { className, labelledBy, - segmentRefs, + segmentRefs: segmentRefsProp, onSegmentChange, onKeyDown, setSegment, @@ -47,11 +48,17 @@ const InputBoxWithRef = ( ) => { const { theme } = useDarkMode(); + /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ + const internalSegmentRefs = useSegmentRefs(segments); + const segmentRefs = segmentRefsProp || internalSegmentRefs; + + /** Create a validator for explicit segment values. */ const isExplicitSegmentValue = createExplicitSegmentValidator({ segmentEnum, rules: segmentRules, }); + /** Get the maximum number of characters per segment. */ const getCharsPerSegment = (segment: Segment) => segmentRules[segment].maxChars; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 84b71f0a1d..7e8dc254cc 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -95,7 +95,7 @@ export interface InputBoxProps * @example * { day: ref, month: ref, year: ref } */ - segmentRefs: Record>; + segmentRefs?: Record>; } /** diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index ed15580d49..0166d5b7f7 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -6,8 +6,8 @@ import { import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; -import { Size } from '../shared.types'; +import { Size } from '../shared.types'; import { defaultPlaceholderMock, SegmentObjMock, diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index bf8abc9fec..5883bcb9e4 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -8,9 +8,9 @@ import React, { import { VisuallyHidden } from '@leafygreen-ui/a11y'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '../shared.types'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; +import { Size } from '../shared.types'; import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, diff --git a/packages/input-box/src/hooks/index.ts b/packages/input-box/src/hooks/index.ts new file mode 100644 index 0000000000..9c97080e94 --- /dev/null +++ b/packages/input-box/src/hooks/index.ts @@ -0,0 +1 @@ +export { useSegmentRefs } from './useSegmentRefs/useSegmentRefs'; diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts new file mode 100644 index 0000000000..64a80422e1 --- /dev/null +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts @@ -0,0 +1,166 @@ +import { renderHook } from '@leafygreen-ui/testing-lib'; + +import { useSegmentRefs } from './useSegmentRefs'; + +describe('packages/input-box/hooks/useSegmentRefs', () => { + describe('basic functionality', () => { + test('returns an object with refs for each segment', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toHaveProperty('month'); + expect(result.current).toHaveProperty('day'); + expect(result.current).toHaveProperty('year'); + }); + + test('each returned value is a valid React ref object', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current.month).toHaveProperty('current'); + expect(result.current.day).toHaveProperty('current'); + expect(result.current.year).toHaveProperty('current'); + }); + + test('handles empty segments object', () => { + const segments = {}; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toEqual({}); + }); + + test('handles single segment', () => { + const segments = { input: 'input' }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toHaveProperty('input'); + expect(result.current.input).toHaveProperty('current'); + }); + }); + + describe('memoization', () => { + test('returns the same refs when rerendered with the same segments object', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result, rerender } = renderHook(() => useSegmentRefs(segments)); + + const initialMonthRef = result.current.month; + const initialDayRef = result.current.day; + const initialYearRef = result.current.year; + + rerender(); + + expect(result.current.month).toBe(initialMonthRef); + expect(result.current.day).toBe(initialDayRef); + expect(result.current.year).toBe(initialYearRef); + }); + + test('returns the same object structure when rerendered', () => { + const segments = { + month: 'month', + day: 'day', + }; + + const { result, rerender } = renderHook(() => useSegmentRefs(segments)); + + const initialResult = result.current; + + rerender(); + + // The object itself should be the same (memoized) + expect(result.current).toBe(initialResult); + }); + }); + + describe('with different segment configurations', () => { + test('returns new object when segments change', () => { + const initialSegments = { + month: 'month', + day: 'day', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs(segments), + { + initialProps: { segments: initialSegments }, + }, + ); + + const initialResult = result.current; + + const newSegments = { + month: 'month', + day: 'day', + year: 'year', + }; + + rerender({ segments: newSegments }); + + // Should return a new object when segments change + expect(result.current).not.toBe(initialResult); + expect(Object.keys(result.current)).toHaveLength(3); + }); + + test('works with different key types', () => { + const segments = { + hour: 'hour', + minute: 'minute', + second: 'second', + meridiem: 'meridiem', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toHaveProperty('hour'); + expect(result.current).toHaveProperty('minute'); + expect(result.current).toHaveProperty('second'); + expect(result.current).toHaveProperty('meridiem'); + expect(Object.keys(result.current)).toHaveLength(4); + }); + }); + + describe('ref uniqueness', () => { + test('each segment gets a unique ref', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current.month).not.toBe(result.current.day); + expect(result.current.day).not.toBe(result.current.year); + expect(result.current.month).not.toBe(result.current.year); + }); + + test('different hook instances return different refs', () => { + const segments = { + month: 'month', + day: 'day', + }; + + const { result: result1 } = renderHook(() => useSegmentRefs(segments)); + const { result: result2 } = renderHook(() => useSegmentRefs(segments)); + + expect(result1.current.month).not.toBe(result2.current.month); + expect(result1.current.day).not.toBe(result2.current.day); + }); + }); +}); diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts new file mode 100644 index 0000000000..95ddba4537 --- /dev/null +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; + +import { useDynamicRefs } from '@leafygreen-ui/hooks'; + +/** + * Creates a memoized object of refs for each segment. + * @param segments - An object mapping segment names to their values. + * @returns An object mapping segment names to their refs. + * + * @example + * const segments = { day: 'day', month: 'month', year: 'year' }; + * const segmentRefs = useSegmentRefs(segments); + * // segmentRefs is { day: ref, month: ref, year: ref } + */ +export const useSegmentRefs = ( + segments: Record, +) => { + const getSegmentRef = useDynamicRefs(); + + const segmentRefs = useMemo( + () => + Object.fromEntries( + Object.entries(segments).map(([key]) => [key, getSegmentRef(key)]), + ) as Record>, + [getSegmentRef, segments], + ); + + return segmentRefs; +}; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index a265a99d26..fb930748bc 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -92,16 +92,6 @@ export const InputBoxWithState = ({ }: Partial> & { 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 defaultSetSegment = (segment: SegmentObjMock, value: string) => { @@ -116,7 +106,6 @@ export const InputBoxWithState = ({ return ( Date: Mon, 17 Nov 2025 16:09:47 -0500 Subject: [PATCH 112/124] feat(input-box): add comprehensive mocks for date and time segments in testutils for enhanced testing capabilities --- .../src/testutils/{testutils.mocks.ts => testutils.mocks.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/input-box/src/testutils/{testutils.mocks.ts => testutils.mocks.tsx} (100%) diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.tsx similarity index 100% rename from packages/input-box/src/testutils/testutils.mocks.ts rename to packages/input-box/src/testutils/testutils.mocks.tsx From 4d1030bead7fb687667e6ee732961d32567b0a6e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 17 Nov 2025 16:10:31 -0500 Subject: [PATCH 113/124] feat(input-box): integrate lodash for utility functions and enhance InputBox stories with date and time segment examples --- packages/input-box/package.json | 3 +- packages/input-box/src/InputBox.stories.tsx | 91 +++++++++++++++- packages/input-box/src/InputBox/InputBox.tsx | 6 +- .../src/testutils/testutils.mocks.tsx | 100 +++++++++++++++++- pnpm-lock.yaml | 3 + 5 files changed, 195 insertions(+), 8 deletions(-) diff --git a/packages/input-box/package.json b/packages/input-box/package.json index cc5cb766c5..4e6fa66876 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -34,7 +34,8 @@ "@leafygreen-ui/hooks": "workspace:^", "@leafygreen-ui/date-utils": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", - "@leafygreen-ui/typography": "workspace:^" + "@leafygreen-ui/typography": "workspace:^", + "lodash": "^4.17.21" }, "peerDependencies": { "@leafygreen-ui/leafygreen-provider": "workspace:^" diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 1c9336cd45..8640e65765 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -1,29 +1,45 @@ +/* eslint-disable no-console */ import React from 'react'; import { storybookExcludedControlParams, StoryMetaType, } from '@lg-tools/storybook-utils'; -import { StoryFn } from '@storybook/react'; +import { StoryFn, StoryObj } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { palette } from '@leafygreen-ui/palette'; -import { SegmentObjMock } from './testutils/testutils.mocks'; +import { + dateSegmentEmptyMock, + defaultFormatPartsMock, + SegmentObjMock, + segmentRulesMock, + segmentsMock, + timeFormatPartsMock, + TimeInputSegmentWrapper, + TimeSegmentObjMock, + timeSegmentRulesMock, + timeSegmentsEmptyMock, + timeSegmentsMock, +} from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; import { Size } from './shared.types'; -import { InputBoxWithState } from './testutils'; +import { InputBoxWithState, InputSegmentWrapper } from './testutils'; const meta: StoryMetaType = { title: 'Components/Inputs/InputBox', component: InputBox, decorators: [ - StoryFn => ( + (StoryFn, context: any) => (
- + + +
), ], @@ -45,6 +61,19 @@ const meta: StoryMetaType = { 'segmentEnum', ], }, + generate: { + storyNames: ['Date', 'Time'], + combineArgs: { + disabled: [false, true], + size: Object.values(Size), + darkMode: [false, true], + }, + decorator: (StoryFn, context) => ( + + + + ), + }, }, argTypes: { disabled: { @@ -58,6 +87,13 @@ const meta: StoryMetaType = { args: { disabled: false, size: Size.Default, + setSegment: (segment: SegmentObjMock, value: string) => { + console.log('setSegment', segment, value); + }, + segmentComponent: InputSegmentWrapper, + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, + segmentEnum: SegmentObjMock, }, }; export default meta; @@ -67,3 +103,48 @@ export const LiveExample: StoryFn = props => { >)} /> ); }; +LiveExample.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Date: StoryObj> = { + parameters: { + generate: { + combineArgs: { + segments: [segmentsMock, dateSegmentEmptyMock], + }, + }, + }, + args: { + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, + segmentEnum: SegmentObjMock, + setSegment: (segment: SegmentObjMock, value: string) => { + console.log('setSegment', segment, value); + }, + disabled: false, + size: Size.Default, + segmentComponent: InputSegmentWrapper, + }, +}; + +export const Time: StoryObj> = { + parameters: { + generate: { + combineArgs: { + segments: [timeSegmentsMock, timeSegmentsEmptyMock], + }, + }, + }, + args: { + formatParts: timeFormatPartsMock, + segmentRules: timeSegmentRulesMock, + segmentEnum: TimeSegmentObjMock, + setSegment: (segment: TimeSegmentObjMock, value: string) => { + console.log('setSegment', segment, value); + }, + disabled: false, + size: Size.Default, + segmentComponent: TimeInputSegmentWrapper, + }, +}; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index ec32c0cbe0..df9ab2786b 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -3,9 +3,10 @@ import React, { ForwardedRef, KeyboardEventHandler, } from 'react'; +import isEmpty from 'lodash/isEmpty'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { keyMap } from '@leafygreen-ui/lib'; +import { consoleOnce, keyMap } from '@leafygreen-ui/lib'; import { useSegmentRefs } from '../hooks'; import { @@ -48,6 +49,9 @@ const InputBoxWithRef = ( ) => { const { theme } = useDarkMode(); + if (isEmpty(segments)) + consoleOnce.error('Error in Leafygreen InputBox: segments is required'); + /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ const internalSegmentRefs = useSegmentRefs(segments); const segmentRefs = segmentRefsProp || internalSegmentRefs; diff --git a/packages/input-box/src/testutils/testutils.mocks.tsx b/packages/input-box/src/testutils/testutils.mocks.tsx index b945838c43..076ccb0df8 100644 --- a/packages/input-box/src/testutils/testutils.mocks.tsx +++ b/packages/input-box/src/testutils/testutils.mocks.tsx @@ -1,8 +1,10 @@ -import { createRef } from 'react'; +import React, { createRef, forwardRef } from 'react'; import { css } from '@leafygreen-ui/emotion'; import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentComponentProps } from '../shared.types'; import { ExplicitSegmentRule } from '../utils'; export const SegmentObjMock = { @@ -24,6 +26,12 @@ export const segmentRefsMock: SegmentRefsMock = { year: createRef(), }; +export const dateSegmentEmptyMock: Record = { + month: '', + day: '', + year: '', +}; + export const segmentsMock: Record = { month: '02', day: '02', @@ -65,6 +73,9 @@ export const characterWidth = { D: 46 / 40, M: 55 / 40, Y: 50 / 40, + H: 46 / 40, + MM: 55 / 40, + S: 46 / 40, } as const; export const segmentWidthStyles: Record = { @@ -78,3 +89,90 @@ export const segmentWidthStyles: Record = { width: ${segmentRulesMock['year'].maxChars * characterWidth.Y}ch; `, }; + +/** Mocks for time generate story */ +export const TimeSegmentObjMock = { + Hour: 'hour', + Minute: 'minute', + Second: 'second', +} as const; +export type TimeSegmentObjMock = + (typeof TimeSegmentObjMock)[keyof typeof TimeSegmentObjMock]; + +export const timeSegmentsMock: Record = { + hour: '23', + minute: '00', + second: '59', +}; + +export const timeSegmentsEmptyMock: Record = { + hour: '', + minute: '', + second: '', +}; + +export const timeSegmentRulesMock: Record< + TimeSegmentObjMock, + ExplicitSegmentRule +> = { + hour: { maxChars: 2, minExplicitValue: 3 }, + minute: { maxChars: 2, minExplicitValue: 6 }, + second: { maxChars: 2, minExplicitValue: 6 }, +}; + +export const timeMinMock: Record = { + hour: 0, + minute: 0, + second: 0, +}; +export const timeMaxMock: Record = { + hour: 23, + minute: 59, + second: 59, +}; + +export const timePlaceholderMock: Record = { + hour: 'HH', + minute: 'MM', + second: 'SS', +} as const; + +export const timeFormatPartsMock: Array = [ + { type: 'hour', value: '' }, + { type: 'literal', value: ':' }, + { type: 'minute', value: '' }, + { type: 'literal', value: ':' }, + { type: 'second', value: '' }, +]; + +export const timeSegmentWidthStyles: Record = { + hour: css` + width: ${timeSegmentRulesMock['hour'].maxChars * characterWidth.D}ch; + `, + minute: css` + width: ${timeSegmentRulesMock['minute'].maxChars * characterWidth.MM}ch; + `, + second: css` + width: ${timeSegmentRulesMock['second'].maxChars * characterWidth.Y}ch; + `, +}; + +export const TimeInputSegmentWrapper = forwardRef< + HTMLInputElement, + InputSegmentComponentProps +>((props, ref) => { + const { segment, ...rest } = props; + return ( + + ); +}); + +TimeInputSegmentWrapper.displayName = 'TimeInputSegmentWrapper'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d17f46d811..1ee880d083 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2204,6 +2204,9 @@ importers: '@leafygreen-ui/typography': specifier: workspace:^ version: link:../typography + lodash: + specifier: ^4.17.21 + version: 4.17.21 packages/input-option: dependencies: From f342d2f7da013943e0fab40ce6cfd8d359687a94 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 17 Nov 2025 16:29:26 -0500 Subject: [PATCH 114/124] refactor(input-box): remove unused props from InputBox stories and testutils for cleaner code --- packages/input-box/src/InputBox.stories.tsx | 7 ------- packages/input-box/src/testutils/index.tsx | 2 -- 2 files changed, 9 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 8640e65765..889b6fbe8b 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -87,13 +87,6 @@ const meta: StoryMetaType = { args: { disabled: false, size: Size.Default, - setSegment: (segment: SegmentObjMock, value: string) => { - console.log('setSegment', segment, value); - }, - segmentComponent: InputSegmentWrapper, - formatParts: defaultFormatPartsMock, - segmentRules: segmentRulesMock, - segmentEnum: SegmentObjMock, }, }; export default meta; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index fb930748bc..1da7a3dc5d 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -11,7 +11,6 @@ import { defaultMinMock, defaultPlaceholderMock, SegmentObjMock, - segmentRefsMock, segmentRulesMock, segmentsMock, segmentWidthStyles, @@ -20,7 +19,6 @@ import { export const defaultProps: Partial> = { segments: segmentsMock, segmentEnum: SegmentObjMock, - segmentRefs: segmentRefsMock, setSegment: () => {}, formatParts: defaultFormatPartsMock, segmentRules: segmentRulesMock, From f7f28eb00164d2467ad39383e770a1cbafaa4afb Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 17 Nov 2025 21:09:06 -0500 Subject: [PATCH 115/124] fix(input-box): ensure segments prop is required and handle error logging in InputBox component --- .../input-box/src/InputBox/InputBox.spec.tsx | 18 ++++++++++++++++++ packages/input-box/src/InputBox/InputBox.tsx | 10 ++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index a45ecfe632..bad6e9229c 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -3,6 +3,8 @@ import { jest } from '@jest/globals'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { consoleOnce } from '@leafygreen-ui/lib'; + import { InputSegmentChangeEventHandler } from '../shared.types'; import { InputBoxWithState, @@ -19,6 +21,22 @@ import { import { InputBox } from './InputBox'; describe('packages/input-box', () => { + describe('basic functionality', () => { + test('returns null when no segments are provided', () => { + const consoleOnceSpy = jest + .spyOn(consoleOnce, 'error') + .mockImplementation(() => {}); + + // @ts-expect-error - missing props + const { container } = render(); + + expect(container.firstChild).toBeNull(); + expect(consoleOnceSpy).toHaveBeenCalledWith( + 'Error in Leafygreen InputBox: segments is required', + ); + }); + }); + describe('Rendering', () => { describe.each(['day', 'month', 'year'])('%p', segment => { test('renders the correct aria attributes', () => { diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index df9ab2786b..88ac2e9045 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -49,13 +49,15 @@ const InputBoxWithRef = ( ) => { const { theme } = useDarkMode(); - if (isEmpty(segments)) - consoleOnce.error('Error in Leafygreen InputBox: segments is required'); - /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ - const internalSegmentRefs = useSegmentRefs(segments); + const internalSegmentRefs = useSegmentRefs(segments ?? {}); const segmentRefs = segmentRefsProp || internalSegmentRefs; + if (isEmpty(segmentRefs) || isEmpty(segments)) { + consoleOnce.error('Error in Leafygreen InputBox: segments is required'); + return null; + } + /** Create a validator for explicit segment values. */ const isExplicitSegmentValue = createExplicitSegmentValidator({ segmentEnum, From 454b5221605b96ef0882237d8bc54d69bf763372 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 09:46:54 -0500 Subject: [PATCH 116/124] refactor(date-input): remove unused charsPerSegment prop and update related references to charsCount for consistency --- .../components/DateInput/DateInputBox/DateInputBox.tsx | 3 +-- .../DateInput/DateInputSegment/DateInputSegment.spec.tsx | 2 +- .../DateInputSegment/DateInputSegment.stories.tsx | 8 ++++++-- .../DateInput/DateInputSegment/DateInputSegment.tsx | 5 +---- 4 files changed, 9 insertions(+), 9 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 ae4658c61c..0039b25ce5 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,7 @@ import { } from '@leafygreen-ui/date-utils'; import { InputBox } from '@leafygreen-ui/input-box'; -import { charsPerSegment, dateSegmentRules } from '../../../constants'; +import { dateSegmentRules } from '../../../constants'; import { useSharedDatePickerContext } from '../../../context'; import { useDateSegments } from '../../../hooks'; import { DateSegment, DateSegmentsState } from '../../../types'; @@ -102,7 +102,6 @@ export const DateInputBox = React.forwardRef( onKeyDown={onKeyDown} segmentRefs={segmentRefs} segmentEnum={DateSegment} - charsPerSegment={charsPerSegment} formatParts={formatParts} segments={segments} setSegment={setSegment} 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 f167febdeb..1eabe37ad8 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 @@ -31,7 +31,7 @@ const renderSegment = ( segment: 'day' as DateSegment, disabled: false, segmentEnum: DateSegment, - charsPerSegment: charsPerSegment['day'], + charsCount: charsPerSegment['day'], minSegmentValue: defaultMin['day'], maxSegmentValue: defaultMax['day'], placeholder: defaultPlaceholder['day'], 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 dab42f551e..e8e256c356 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 @@ -37,7 +37,7 @@ const meta: StoryMetaType< generate: { combineArgs: { darkMode: [false, true], - value: [undefined, '6', '2023'], + value: ['', '6', '2023'], segment: ['day', 'month', 'year'], size: Object.values(Size), }, @@ -54,7 +54,7 @@ const meta: StoryMetaType< ], }, controls: { - exclude: ['segmentEnum', 'onChange', 'disabled'], + exclude: ['segmentEnum', 'onChange', 'disabled', 'charsCount', 'value'], }, }, args: { @@ -66,6 +66,10 @@ const meta: StoryMetaType< control: 'select', options: ['day', 'month', 'year'], }, + size: { + control: 'select', + options: Object.values(Size), + }, }, }; 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 ee11b19841..ed92f2d146 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -38,7 +38,6 @@ export const DateInputSegment = React.forwardRef< autoComplete: autoCompleteProp, min: minContextProp, max: maxContextProp, - size, } = useSharedDatePickerContext(); const { value: dateValue } = useDateInputBoxContext(); @@ -67,9 +66,7 @@ export const DateInputSegment = React.forwardRef< data-testid="lg-date_picker_input-segment" shouldWrap={shouldWrap} shouldValidate={shouldValidate} - step={1} - size={size} - charsPerSegment={charsPerSegment[segment]} + charsCount={charsPerSegment[segment]} /> ); }); From 2fc54a3b86e55a42412b2ccbc56881f33c0428bc Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 10:04:56 -0500 Subject: [PATCH 117/124] refactor(date-input): remove onKeyDown prop from DateInputBox and update DateInputBoxProviderProps to extend PropsWithChildren for better type handling --- .../components/DateInput/DateInputBox/DateInputBox.tsx | 2 -- .../DateInputBoxContext/DateInputBoxContext.types.ts | 9 +++------ 2 files changed, 3 insertions(+), 8 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 0039b25ce5..b9baa60415 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -45,7 +45,6 @@ export const DateInputBox = React.forwardRef( labelledBy, segmentRefs, onSegmentChange, - onKeyDown, ...rest }: DateInputBoxProps, fwdRef, @@ -99,7 +98,6 @@ export const DateInputBox = React.forwardRef( {} From 56c13ea748a496adea8e18553a856ac8fad3e971 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 10:19:39 -0500 Subject: [PATCH 118/124] fix(date-input): import DateType and add comment about allowing any 4-digit year value in custom validation for date segments --- .../DateInput/DateInputBoxContext/DateInputBoxContext.types.ts | 3 ++- .../shared/utils/isEverySegmentValid/isEverySegmentValid.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 3f2a1f925e..ef3b636545 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts @@ -1,6 +1,7 @@ -import { DateType } from '@leafygreen-ui/date-utils'; import { PropsWithChildren } from 'react'; +import { DateType } from '@leafygreen-ui/date-utils'; + export interface DateInputBoxContextType { /** * Date value in UTC time diff --git a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts index 4ebf0db829..24014f191a 100644 --- a/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts +++ b/packages/date-picker/src/shared/utils/isEverySegmentValid/isEverySegmentValid.ts @@ -17,6 +17,7 @@ export const isEverySegmentValid = (segments: DateSegmentsState): boolean => { defaultMax: defaultMax[segment as DateSegment], segmentEnum: DateSegment, customValidation: + // allow any 4-digit year value regardless of defined min/max ranges segment === DateSegment.Year ? (value: DateSegmentValue) => inRange(Number(value), 1000, 9999 + 1) : undefined, From 6ac1984a918cb3ce1afbe443da959c5d7beb5ad5 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 10:53:48 -0500 Subject: [PATCH 119/124] refactor(date-picker, input-box): standardize usage of charsCount instead of charsPerSegment across components and tests for consistency --- .../DatePicker/DatePicker.keyboard3.spec.tsx | 2 +- .../DateInputSegment/DateInputSegment.spec.tsx | 6 +++--- .../getFormattedDateStringFromSegments.ts | 2 +- .../getFormattedSegmentsFromDate.ts | 6 +++--- packages/input-box/src/InputBox/InputBox.tsx | 2 +- .../src/InputSegment/InputSegment.spec.tsx | 6 +++--- .../input-box/src/InputSegment/InputSegment.tsx | 2 +- .../getValueFormatter/getValueFormatter.ts | 17 ++++++++--------- .../getValueFormatter/valueFormatter.spec.ts | 12 ++++++------ 9 files changed, 27 insertions(+), 28 deletions(-) diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx index 51d6105491..406f589c9a 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx @@ -80,7 +80,7 @@ describe('DatePicker keyboard interaction', () => { const segmentCases = ['year', 'month', 'day'] as Array; describe.each(segmentCases)('%p segment', segment => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment[segment], + charsCount: charsPerSegment[segment], }); /** Utility only for this suite. Returns the day|month|year element from the render result */ const getRelevantInput = (renderResult: RenderDatePickerResult) => 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 1eabe37ad8..5185e08cc9 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 @@ -159,7 +159,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('Arrow Keys', () => { describe('day input', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['day'], + charsCount: charsPerSegment['day'], }); describe('Up arrow', () => { @@ -249,7 +249,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('month input', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['month'], + charsCount: charsPerSegment['month'], }); describe('Up arrow', () => { @@ -349,7 +349,7 @@ describe('packages/date-picker/shared/date-input-segment', () => { describe('year input', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['year'], + charsCount: charsPerSegment['year'], }); describe('Up arrow', () => { diff --git a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts index cd48508669..531b43e303 100644 --- a/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts +++ b/packages/date-picker/src/shared/utils/getFormattedDateString/getFormattedDateStringFromSegments.ts @@ -19,7 +19,7 @@ export const getFormattedDateStringFromSegments = ( const segment = part.type as DateSegment; const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment[segment], + charsCount: 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 5b95cc563e..8fd8616d9c 100644 --- a/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts +++ b/packages/date-picker/src/shared/utils/getSegmentsFromDate/getFormattedSegmentsFromDate.ts @@ -13,13 +13,13 @@ export const getFormattedSegmentsFromDate = ( const segments = getSegmentsFromDate(date); return { - day: getValueFormatter({ charsPerSegment: charsPerSegment['day'] })( + day: getValueFormatter({ charsCount: charsPerSegment['day'] })( segments['day'], ), - month: getValueFormatter({ charsPerSegment: charsPerSegment['month'] })( + month: getValueFormatter({ charsCount: charsPerSegment['month'] })( segments['month'], ), - year: getValueFormatter({ charsPerSegment: charsPerSegment['year'] })( + year: getValueFormatter({ charsCount: charsPerSegment['year'] })( segments['year'], ), }; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 88ac2e9045..962b510358 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -75,7 +75,7 @@ const InputBoxWithRef = ( allowZero: boolean, ): string => { const formatter = getValueFormatter({ - charsPerSegment: getCharsPerSegment(segmentName), + charsCount: getCharsPerSegment(segmentName), allowZero, }); const formattedValue = formatter(segmentValue); diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 0ba99616e9..a8df2c1b5e 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -182,7 +182,7 @@ describe('packages/input-segment', () => { describe('keyboard events', () => { describe('Arrow keys', () => { const formatter = getValueFormatter({ - charsPerSegment: 2, + charsCount: 2, allowZero: true, }); @@ -292,7 +292,7 @@ describe('packages/input-segment', () => { test('does not wrap if `shouldWrap` is false and value is less than min', () => { const formatter = getValueFormatter({ - charsPerSegment: 4, + charsCount: 4, allowZero: false, }); @@ -461,7 +461,7 @@ describe('packages/input-segment', () => { test('does not wrap if `shouldWrap` is false and value is less than min', () => { const formatter = getValueFormatter({ - charsPerSegment: 4, + charsCount: 4, allowZero: false, }); diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 5883bcb9e4..dc8171a3e3 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -48,7 +48,7 @@ const InputSegmentWithRef = ( const { theme } = useDarkMode(); const baseFontSize = useUpdatedBaseFontSize(); const formatter = getValueFormatter({ - charsPerSegment: charsCount, + charsCount, allowZero: minSegmentValue === 0, }); const pattern = `[0-9]{${charsCount}}`; diff --git a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts index 4bffe299ac..66ccdb0b4c 100644 --- a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts +++ b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts @@ -6,10 +6,12 @@ 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 charsCount - the number of characters * @param allowZero - whether to allow zero-like values + * * @returns a value formatter function for the provided segment - * - @param val - the value to format (string, number, or undefined) + * + * @param val - the value to format (string, number, or undefined) * * @example * const formatter = getValueFormatter({ charsPerSegment: 2 }); @@ -26,10 +28,10 @@ import { isZeroLike } from '@leafygreen-ui/lib'; */ export const getValueFormatter = ({ - charsPerSegment, + charsCount, allowZero = false, }: { - charsPerSegment: number; + charsCount: number; allowZero?: boolean; }) => (val: string | number | undefined) => { @@ -40,11 +42,8 @@ export const getValueFormatter = 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, - ); + const padded = padStart(Number(val).toString(), charsCount, '0'); + const trimmed = padded.slice(padded.length - charsCount, 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 index 8f22456d15..4c410d6b91 100644 --- a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts +++ b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts @@ -10,7 +10,7 @@ const charsPerSegment: Record = { describe('packages/input-box/utils/valueFormatter', () => { describe('one segment', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['one'], + charsCount: charsPerSegment['one'], }); test('returns the value as is', () => { @@ -28,7 +28,7 @@ describe('packages/input-box/utils/valueFormatter', () => { describe('two segments', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['two'], + charsCount: charsPerSegment['two'], }); test('formats 2 digit values', () => { @@ -58,7 +58,7 @@ describe('packages/input-box/utils/valueFormatter', () => { describe('three segments', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['three'], + charsCount: charsPerSegment['three'], }); test('formats 4 digit values', () => { @@ -89,7 +89,7 @@ describe('packages/input-box/utils/valueFormatter', () => { describe('with allowZero allows leading zeros', () => { test('with one segment', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['one'], + charsCount: charsPerSegment['one'], allowZero: true, }); expect(formatter('0')).toEqual('0'); @@ -97,7 +97,7 @@ describe('packages/input-box/utils/valueFormatter', () => { test('with two segments', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['two'], + charsCount: charsPerSegment['two'], allowZero: true, }); expect(formatter('0')).toEqual('00'); @@ -105,7 +105,7 @@ describe('packages/input-box/utils/valueFormatter', () => { test('with three segments', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment['three'], + charsCount: charsPerSegment['three'], allowZero: true, }); expect(formatter('0')).toEqual('000'); From 68cd5fbf93104c86438fdf72cf52f87c70b7f6c5 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 14:26:54 -0500 Subject: [PATCH 120/124] refactor(date-input): simplify DateInputBox by removing unused props for cleaner implementation --- .../DateInput/DateInputBox/DateInputBox.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 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 b9baa60415..801be76d09 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -37,18 +37,7 @@ import { DateInputBoxProps } from './DateInputBox.types'; * @internal */ export const DateInputBox = React.forwardRef( - ( - { - value, - setValue, - className, - labelledBy, - segmentRefs, - onSegmentChange, - ...rest - }: DateInputBoxProps, - fwdRef, - ) => { + ({ value, setValue, ...rest }: DateInputBoxProps, fwdRef) => { const { isDirty, formatParts, disabled, setIsDirty } = useSharedDatePickerContext(); @@ -98,15 +87,12 @@ export const DateInputBox = React.forwardRef( From b44f58b63232b5658d093c23a084e8e251c2704e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 15:16:07 -0500 Subject: [PATCH 121/124] fix(date-picker): correct parameter name from disableSnapshots to disableSnapshot in DatePicker stories and pass size prop to InputBox --- packages/date-picker/src/DatePicker.stories.tsx | 6 +++--- .../components/DateInput/DateInputBox/DateInputBox.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/date-picker/src/DatePicker.stories.tsx b/packages/date-picker/src/DatePicker.stories.tsx index 4c63203362..deecfc8527 100644 --- a/packages/date-picker/src/DatePicker.stories.tsx +++ b/packages/date-picker/src/DatePicker.stories.tsx @@ -134,7 +134,7 @@ export const LiveExample: StoryFn = props => { }; LiveExample.parameters = { chromatic: { - disableSnapshots: true, + disableSnapshot: true, }, }; @@ -143,7 +143,7 @@ export const Uncontrolled: StoryFn = props => { }; Uncontrolled.parameters = { chromatic: { - disableSnapshots: true, + disableSnapshot: true, }, }; @@ -163,7 +163,7 @@ export const InModal: StoryFn = props => { }; InModal.parameters = { chromatic: { - disableSnapshots: true, + disableSnapshot: true, }, }; 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 801be76d09..7c1666af57 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -38,7 +38,7 @@ import { DateInputBoxProps } from './DateInputBox.types'; */ export const DateInputBox = React.forwardRef( ({ value, setValue, ...rest }: DateInputBoxProps, fwdRef) => { - const { isDirty, formatParts, disabled, setIsDirty } = + const { isDirty, formatParts, disabled, setIsDirty, size } = useSharedDatePickerContext(); /** if the value is a `Date` the component is dirty */ @@ -94,6 +94,7 @@ export const DateInputBox = React.forwardRef( disabled={disabled} segmentRules={dateSegmentRules} segmentComponent={DateInputSegment} + size={size} {...rest} /> From f476dde18ef43806117401f926ac697b8f16c7d2 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 21:22:52 -0500 Subject: [PATCH 122/124] remove isSingleDigit utility and its associated tests --- .../src/utils/isSingleDigit/isSingleDigit.spec.ts | 15 --------------- .../src/utils/isSingleDigit/isSingleDigit.ts | 7 ------- 2 files changed, 22 deletions(-) delete mode 100644 packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts delete mode 100644 packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts deleted file mode 100644 index 4649204712..0000000000 --- a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import range from 'lodash/range'; - -import { keyMap } from '@leafygreen-ui/lib'; - -import { isSingleDigit } from './isSingleDigit'; - -describe('packages/input-box/utils/isSingleDigit', () => { - test.each(range(10))('returns true for %i character', i => { - expect(isSingleDigit(`${i}`)).toBe(true); - }); - - test.each(Object.values(keyMap))('returns false for %s', key => { - expect(isSingleDigit(key)).toBe(false); - }); -}); diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts deleted file mode 100644 index 4d0670df47..0000000000 --- a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Checks if the key is a single digit. - * - * @param key - The key to check. - * @returns True if the key is a single digit, false otherwise. - */ -export const isSingleDigit = (key: string): boolean => /^[0-9]$/.test(key); From 8e4a4a45bdce5fdf78d93ecf4e73a9f200262fac Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 20 Nov 2025 16:59:54 -0500 Subject: [PATCH 123/124] test(date-picker): ensure real timers are used after tests in keyboard and mouse interaction specs --- .../DatePicker/DatePicker.keyboard1.spec.tsx | 3 +++ .../DatePicker/DatePicker.keyboard2.spec.tsx | 3 +++ .../DatePicker/DatePicker.keyboard3.spec.tsx | 3 +++ .../DatePicker/DatePicker.keyboard4.spec.tsx | 3 +++ .../src/DatePicker/DatePicker.mouse1.spec.tsx | 3 +++ .../src/DatePicker/DatePicker.mouse2.spec.tsx | 3 +++ .../src/DatePicker/DatePicker.mouse3.spec.tsx | 3 +++ .../src/DatePicker/DatePicker.spec.tsx | 3 +++ .../src/DatePicker/DatePicker.typing.spec.tsx | 3 +++ .../DatePickerInput/DatePickerInput.spec.tsx | 4 ++++ .../DatePickerMenu/DatePickerMenu.spec.tsx | 4 ++++ .../DatePickerMenu.timeZones.spec.tsx | 1 + .../DatePickerMenuHeader.spec.tsx | 4 ++++ .../src/isCurrentUTCDay/isCurrentUTCDay.spec.ts | 4 ++++ .../date-utils/src/isTodayTZ/isTodayTZ.spec.ts | 17 +++++++++++++++++ 15 files changed, 61 insertions(+) diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard1.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard1.spec.tsx index be5ad5102f..264137ccce 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard1.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard1.spec.tsx @@ -26,6 +26,9 @@ describe('DatePicker keyboard interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('focuses the current value', () => { test("when month returns to value's month", async () => { diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard2.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard2.spec.tsx index f39fb579dd..e3503afe07 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard2.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard2.spec.tsx @@ -27,6 +27,9 @@ describe('DatePicker keyboard interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('focuses the current value', () => { test("when month returns to value's month", async () => { diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx index 406f589c9a..2c78261b25 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard3.spec.tsx @@ -33,6 +33,9 @@ describe('DatePicker keyboard interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('arrow keys interaction when Input is focused', () => { describe('Left Arrow', () => { diff --git a/packages/date-picker/src/DatePicker/DatePicker.keyboard4.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.keyboard4.spec.tsx index 60bd289fcf..9faaf3290c 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.keyboard4.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.keyboard4.spec.tsx @@ -19,6 +19,9 @@ describe('DatePicker keyboard interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('arrow keys interaction when Menu is focused', () => { describe('basic arrow key behavior', () => { diff --git a/packages/date-picker/src/DatePicker/DatePicker.mouse1.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.mouse1.spec.tsx index 19f7a52b65..afac9f4b5e 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.mouse1.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.mouse1.spec.tsx @@ -23,6 +23,9 @@ describe('DatePicker mouse interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('Clicking the input', () => { test('opens the menu', async () => { diff --git a/packages/date-picker/src/DatePicker/DatePicker.mouse2.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.mouse2.spec.tsx index 8bb2581391..01111e27ea 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.mouse2.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.mouse2.spec.tsx @@ -19,6 +19,9 @@ describe('DatePicker mouse interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('Clicking a Calendar cell', () => { test('closes the menu', async () => { diff --git a/packages/date-picker/src/DatePicker/DatePicker.mouse3.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.mouse3.spec.tsx index a9421254f7..ab9a1ad7bb 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.mouse3.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.mouse3.spec.tsx @@ -18,6 +18,9 @@ describe('DatePicker mouse interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('Month select menu', () => { test('menu opens over the calendar menu', async () => { diff --git a/packages/date-picker/src/DatePicker/DatePicker.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.spec.tsx index 2b723a2469..c45c2555b9 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.spec.tsx @@ -39,6 +39,9 @@ describe('packages/date-picker', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('Rendering', () => { /// Note: Many rendering tests should be handled by Chromatic diff --git a/packages/date-picker/src/DatePicker/DatePicker.typing.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.typing.spec.tsx index 1d7c5d1dd5..840ed0c56b 100644 --- a/packages/date-picker/src/DatePicker/DatePicker.typing.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePicker.typing.spec.tsx @@ -36,6 +36,9 @@ describe('DatePicker typing interaction', () => { afterEach(() => { jest.restoreAllMocks(); }); + afterAll(() => { + jest.useRealTimers(); + }); describe('Typing', () => { test('does not open the menu', async () => { diff --git a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.spec.tsx b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.spec.tsx index 6b456d95d5..0dc386a1a4 100644 --- a/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerInput/DatePickerInput.spec.tsx @@ -55,6 +55,10 @@ describe('packages/date-picker/date-picker-input', () => { jest.useFakeTimers().setSystemTime(testDate); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('Typing', () => { test('typing into a segment updates the segment value', () => { const { dayInput } = renderDatePickerInput(); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.spec.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.spec.tsx index 47104dc0b0..ec5e64118c 100644 --- a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.spec.tsx @@ -112,6 +112,10 @@ describe('packages/date-picker/date-picker-menu', () => { jest.useFakeTimers().setSystemTime(testToday); }); + afterEach(() => { + jest.useRealTimers(); + }); + describe('Rendering', () => { test('renders calendar grid', () => { const result = renderDatePickerMenu(); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.timeZones.spec.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.timeZones.spec.tsx index e896a61092..3149e8ef2e 100644 --- a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.timeZones.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.timeZones.spec.tsx @@ -128,6 +128,7 @@ describe('DatePicker time zone testing', () => { mockTimeZone(tz, UTCOffset); }); afterEach(() => { + jest.useRealTimers(); jest.restoreAllMocks(); }); diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.spec.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.spec.tsx index 4b93c4abc6..c3cfdc8910 100644 --- a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.spec.tsx +++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenuHeader/DatePickerMenuHeader.spec.tsx @@ -298,6 +298,10 @@ describe('packages/date-picker/menu/header', () => { jest.useFakeTimers(); }); + afterEach(() => { + jest.useRealTimers(); + }); + const AllMockProviders = ({ children }: PropsWithChildren<{}>) => { const [isSelectOpen, _setIsSelectOpen] = useState(false); diff --git a/packages/date-utils/src/isCurrentUTCDay/isCurrentUTCDay.spec.ts b/packages/date-utils/src/isCurrentUTCDay/isCurrentUTCDay.spec.ts index 18acfd16f2..c58ec394d6 100644 --- a/packages/date-utils/src/isCurrentUTCDay/isCurrentUTCDay.spec.ts +++ b/packages/date-utils/src/isCurrentUTCDay/isCurrentUTCDay.spec.ts @@ -8,6 +8,10 @@ describe('packages/date-utils/isCurrentUTCDay', () => { jest.useFakeTimers(); }); + afterEach(() => { + jest.useRealTimers(); + }); + test('returns true with UTC dates', () => { const midnightUTC = newUTC(2020, Month.December, 25, 0, 0); const elevenUTC = newUTC(2020, Month.December, 25, 23, 59); diff --git a/packages/date-utils/src/isTodayTZ/isTodayTZ.spec.ts b/packages/date-utils/src/isTodayTZ/isTodayTZ.spec.ts index e49ad73bbe..bdd0faa4fa 100644 --- a/packages/date-utils/src/isTodayTZ/isTodayTZ.spec.ts +++ b/packages/date-utils/src/isTodayTZ/isTodayTZ.spec.ts @@ -13,6 +13,10 @@ describe('packages/date-utils/isTodayTZ', () => { ); }); + afterEach(() => { + jest.useRealTimers(); + }); + test('Pacific/Honolulu', () => { const isToday = isTodayTZ(utc25, 'Pacific/Honolulu'); expect(isToday).toBe(true); @@ -41,6 +45,11 @@ describe('packages/date-utils/isTodayTZ', () => { new Date(Date.UTC(2023, Month.December, 26, 9, 0, 0)), ); }); + + afterEach(() => { + jest.useRealTimers(); + }); + test('Pacific/Honolulu', () => { const isToday = isTodayTZ(utc25, 'Pacific/Honolulu'); expect(isToday).toBe(true); @@ -70,6 +79,10 @@ describe('packages/date-utils/isTodayTZ', () => { ); }); + afterEach(() => { + jest.useRealTimers(); + }); + test('America/New_York', () => { const isToday = isTodayTZ(utc25, 'America/New_York'); expect(isToday).toBe(true); @@ -99,6 +112,10 @@ describe('packages/date-utils/isTodayTZ', () => { ); }); + afterEach(() => { + jest.useRealTimers(); + }); + test('America/New_York', () => { const isToday = isTodayTZ(utc25, 'America/New_York'); expect(isToday).toBe(false); From 7b02b5448032b476afd826cd7dccb4049e0b1b8f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 20 Nov 2025 18:47:00 -0500 Subject: [PATCH 124/124] refactor(date-picker): rename context value from `value` to `dateValue` in DateInputBox components --- .../components/DateInput/DateInputBox/DateInputBox.tsx | 2 +- .../DateInputBoxContext/DateInputBoxContext.spec.tsx | 6 ++++-- .../DateInput/DateInputBoxContext/DateInputBoxContext.tsx | 4 ++-- .../DateInputBoxContext/DateInputBoxContext.types.ts | 2 +- .../DateInput/DateInputSegment/DateInputSegment.tsx | 2 +- 5 files changed, 9 insertions(+), 7 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 7c1666af57..1988540c61 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBox/DateInputBox.tsx @@ -84,7 +84,7 @@ export const DateInputBox = React.forwardRef( }); return ( - + { const value = new Date(); const { result } = renderHook(() => useDateInputBoxContext(), { wrapper: ({ children }) => ( - {children} + + {children} + ), }); - expect(result.current.value).toEqual(value); + expect(result.current.dateValue).toEqual(value); }); }); diff --git a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx index 50199b4158..f4b83f5edc 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.tsx @@ -17,10 +17,10 @@ export const DateInputBoxContext = */ export const DateInputBoxProvider = ({ children, - value, + dateValue, }: PropsWithChildren) => { return ( - + {children} ); 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 index ef3b636545..3e74f6b42a 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts +++ b/packages/date-picker/src/shared/components/DateInput/DateInputBoxContext/DateInputBoxContext.types.ts @@ -6,7 +6,7 @@ export interface DateInputBoxContextType { /** * Date value in UTC time */ - value?: DateType; + dateValue?: DateType; } export interface DateInputBoxProviderProps 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 ed92f2d146..51d3eae149 100644 --- a/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx +++ b/packages/date-picker/src/shared/components/DateInput/DateInputSegment/DateInputSegment.tsx @@ -40,7 +40,7 @@ export const DateInputSegment = React.forwardRef< max: maxContextProp, } = useSharedDatePickerContext(); - const { value: dateValue } = useDateInputBoxContext(); + const { dateValue } = useDateInputBoxContext(); const min = getMinSegmentValue(segment, { date: dateValue, min: minContextProp }) ?? defaultMin[segment];