= 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 InputSegment
+ 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 (
+
+ );
+ }
+ })}
+
+ >
+ );
+};
+
+/**
+ * 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;
+
+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..7e8dc254cc
--- /dev/null
+++ b/packages/input-box/src/InputBox/InputBox.types.ts
@@ -0,0 +1,115 @@
+import React, { ForwardedRef, ReactElement } from 'react';
+
+import { DateType } from '@leafygreen-ui/date-utils';
+
+import {
+ InputSegmentChangeEventHandler,
+ InputSegmentComponentProps,
+ SharedInputBoxTypes,
+} from '../shared.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'>,
+ SharedInputBoxTypes {
+ /**
+ * Callback fired when any segment changes, but not necessarily a full value
+ */
+ onSegmentChange?: InputSegmentChangeEventHandler;
+
+ /**
+ * id of the labelling element
+ */
+ 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
+ *
+ * @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;
+
+ /**
+ * 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>;
+
+ /**
+ * An object that maps the segment names to their refs
+ *
+ * @example
+ * { day: ref, month: ref, year: ref }
+ */
+ segmentRefs?: Record>;
+}
+
+/**
+ * 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/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx
index 8d779b8809..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,
@@ -160,13 +159,31 @@ 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', () => {
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 95b6e7bc78..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 '@leafygreen-ui/tokens';
+import { Size } from '../shared.types';
import {
defaultPlaceholderMock,
SegmentObjMock,
@@ -39,6 +39,7 @@ const meta: StoryMetaType = {
step: 1,
darkMode: false,
charsCount: 2,
+ segmentEnum: SegmentObjMock,
},
argTypes: {
size: {
@@ -57,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 ad45c11948..a6c8b09cf6 100644
--- a/packages/input-box/src/InputSegment/InputSegment.tsx
+++ b/packages/input-box/src/InputSegment/InputSegment.tsx
@@ -10,10 +10,12 @@ import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { keyMap } from '@leafygreen-ui/lib';
import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography';
+import { Size } from '../shared.types';
import {
getNewSegmentValueFromArrowKeyPress,
getNewSegmentValueFromInputValue,
getValueFormatter,
+ isSingleDigitKey,
} from '../utils';
import { getInputSegmentStyles } from './InputSegment.styles';
@@ -32,10 +34,10 @@ const InputSegmentWithRef = (
onChange,
onBlur,
segmentEnum,
- size,
disabled,
value,
charsCount,
+ size = Size.Default,
step = 1,
shouldWrap = true,
shouldValidate = true,
@@ -90,13 +92,9 @@ 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 (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 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
+ if (isSingleDigitKey(key)) {
if (target.value.length === charsCount) {
target.value = '';
}
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/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..f225407b86
--- /dev/null
+++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts
@@ -0,0 +1,278 @@
+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 segments with their refs', () => {
+ const segments = {
+ month: 'month',
+ day: 'day',
+ year: 'year',
+ };
+
+ const { result } = renderHook(() => useSegmentRefs({ segments }));
+
+ expect(result.current).toHaveProperty('month');
+ expect(result.current.month).toHaveProperty('current');
+ expect(result.current).toHaveProperty('day');
+ expect(result.current.day).toHaveProperty('current');
+ expect(result.current).toHaveProperty('year');
+ 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(
+ ({ segments }) => useSegmentRefs({ segments }),
+ {
+ initialProps: { segments },
+ },
+ );
+
+ const initialMonthRef = result.current.month;
+ const initialDayRef = result.current.day;
+ const initialYearRef = result.current.year;
+
+ rerender({ segments });
+
+ expect(result.current.month).toBe(initialMonthRef);
+ expect(result.current.day).toBe(initialDayRef);
+ expect(result.current.year).toBe(initialYearRef);
+ });
+
+ test('returns the same refs when rerendered with a different segments object reference but the same values', () => {
+ const segments1 = {
+ month: 'month',
+ day: 'day',
+ year: 'year',
+ };
+
+ const segments2 = {
+ month: 'month',
+ day: 'day',
+ year: 'year',
+ };
+
+ const { result, rerender } = renderHook(
+ ({ segments }) => useSegmentRefs({ segments }),
+ {
+ initialProps: { segments: segments1 },
+ },
+ );
+
+ const initialMonthRef = result.current.month;
+ const initialDayRef = result.current.day;
+ const initialYearRef = result.current.year;
+
+ rerender({ segments: segments2 });
+
+ expect(result.current.month).toBe(initialMonthRef);
+ expect(result.current.day).toBe(initialDayRef);
+ expect(result.current.year).toBe(initialYearRef);
+ });
+
+ test('returns different refs when segments change', () => {
+ const segments1 = {
+ month: 'month',
+ day: 'day',
+ year: 'year',
+ };
+
+ const segments2 = {
+ hour: 'hour',
+ minute: 'minute',
+ second: 'second',
+ };
+
+ const { result, rerender } = renderHook(
+ ({ segments }) => useSegmentRefs({ segments }),
+ {
+ initialProps: { segments: segments1 },
+ },
+ );
+
+ const initialMonthRef = result.current.month;
+ const initialDayRef = result.current.day;
+ const initialYearRef = result.current.year;
+
+ // @ts-expect-error - segments2 has a different shape than segments1
+ rerender({ segments: segments2 });
+
+ // After rerender with different keys, the result should have new properties
+ expect(result.current).toHaveProperty('hour');
+ expect(result.current).toHaveProperty('minute');
+ expect(result.current).toHaveProperty('second');
+
+ // Old properties should not exist
+ expect(result.current).not.toHaveProperty('month');
+ expect(result.current).not.toHaveProperty('day');
+ expect(result.current).not.toHaveProperty('year');
+
+ // The new refs should have different values
+ expect((result.current as any).hour).not.toBe(initialMonthRef);
+ expect((result.current as any).minute).not.toBe(initialDayRef);
+ expect((result.current as any).second).not.toBe(initialYearRef);
+ });
+
+ test('returns updated object when segments are added', () => {
+ 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);
+ expect(result.current).toHaveProperty('year');
+ });
+
+ test('returns updated object when segments are removed', () => {
+ const initialSegments = {
+ month: 'month',
+ day: 'day',
+ year: 'year',
+ };
+
+ const newSegments = {
+ month: 'month',
+ day: 'day',
+ };
+
+ const { result, rerender } = renderHook(
+ ({ segments }) => useSegmentRefs({ segments }),
+ {
+ initialProps: { segments: initialSegments },
+ },
+ );
+
+ const initialResult = result.current;
+
+ // @ts-expect-error - newSegments has a different shape than initialSegments
+ rerender({ segments: newSegments });
+
+ expect(result.current).not.toBe(initialResult);
+ expect(Object.keys(result.current)).toHaveLength(2);
+ expect(result.current).not.toHaveProperty('year');
+ });
+ });
+
+ describe('with provided segmentRefs', () => {
+ test('returns provided segmentRefs instead of creating a new object', () => {
+ const segments = {
+ month: 'month',
+ day: 'day',
+ year: 'year',
+ };
+
+ const providedRefs = {
+ month: { current: null },
+ day: { current: null },
+ year: { current: null },
+ };
+
+ const { result } = renderHook(() =>
+ useSegmentRefs({ segments, segmentRefs: providedRefs }),
+ );
+
+ // Should return the exact same ref objects that were provided
+ expect(result.current).toBe(providedRefs);
+ expect(result.current.month).toBe(providedRefs.month);
+ expect(result.current.day).toBe(providedRefs.day);
+ expect(result.current.year).toBe(providedRefs.year);
+ });
+
+ test('creates new segmentRefs object when provided segmentRefs is empty', () => {
+ const segments = {
+ month: 'month',
+ day: 'day',
+ year: 'year',
+ };
+
+ const providedRefs = {};
+
+ const { result } = renderHook(() =>
+ // @ts-expect-error - providedRefs is empty but that's okay in this case
+ useSegmentRefs({ segments, segmentRefs: providedRefs }),
+ );
+
+ expect(result.current.month).toBeDefined();
+ expect(result.current.day).toBeDefined();
+ expect(result.current.year).toBeDefined();
+ });
+ });
+
+ 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..1b2d32904a
--- /dev/null
+++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts
@@ -0,0 +1,39 @@
+import { useMemo } from 'react';
+import { isEmpty } from 'lodash';
+
+import { useDynamicRefs, useObjectDependency } from '@leafygreen-ui/hooks';
+
+/**
+ * Creates a memoized object of refs for each segment.
+ * @param segments - An object mapping segment names to their values.
+ * @param segmentRefs - An optional object mapping segment names to their refs.
+ * @returns If segmentRefs are provided, return them. Otherwise, create a new 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,
+ segmentRefs,
+}: {
+ segments: Record;
+ segmentRefs?: Record>;
+}) => {
+ const hasProvidedSegmentRefs = segmentRefs && !isEmpty(segmentRefs);
+
+ /** Use object dependency to avoid triggering re-render when the segments object reference changes and the values are the same */
+ const segmentsObj = useObjectDependency(segments);
+ const getSegmentRef = useDynamicRefs();
+
+ const createdSegmentRefs = useMemo(
+ () =>
+ Object.fromEntries(
+ Object.entries(segmentsObj).map(([key]) => [key, getSegmentRef(key)]),
+ ) as Record>,
+ [getSegmentRef, segmentsObj],
+ );
+
+ return hasProvidedSegmentRefs ? segmentRefs : createdSegmentRefs;
+};
diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts
index f70976968b..56f7ff51cf 100644
--- a/packages/input-box/src/index.ts
+++ b/packages/input-box/src/index.ts
@@ -1,3 +1,10 @@
+export { InputBox, type InputBoxProps } from './InputBox';
+export { InputSegment, type InputSegmentProps } from './InputSegment';
+export {
+ type InputSegmentChangeEventHandler,
+ isInputSegment,
+ Size,
+} from './shared.types';
export {
createExplicitSegmentValidator,
type ExplicitSegmentRule,
diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts
index 7e49b4b154..f866fce8fa 100644
--- a/packages/input-box/src/shared.types.ts
+++ b/packages/input-box/src/shared.types.ts
@@ -1,4 +1,7 @@
import { keyMap } from '@leafygreen-ui/lib';
+import { Size } from '@leafygreen-ui/tokens';
+
+export { Size };
/**
* SharedInput Segment Types
@@ -47,7 +50,7 @@ export function isInputSegment>(
export interface InputSegmentComponentProps
extends Omit<
React.ComponentPropsWithRef<'input'>,
- 'onChange' | 'value' | 'disabled'
+ 'onChange' | 'value' | 'disabled' | 'size' | 'step'
>,
SharedInputBoxTypes {
/**
@@ -67,6 +70,11 @@ export interface InputSegmentComponentProps
* The value of the segment
*/
value: string;
+
+ /**
+ * The number of characters per segment
+ */
+ charsCount: number;
}
/**
@@ -87,4 +95,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 c33f3e4573..1da7a3dc5d 100644
--- a/packages/input-box/src/testutils/index.tsx
+++ b/packages/input-box/src/testutils/index.tsx
@@ -1,9 +1,165 @@
import React from 'react';
import { render, RenderResult } from '@testing-library/react';
+import { InputBox, InputBoxProps } from '../InputBox';
import { InputSegment, type InputSegmentProps } from '../InputSegment';
+import { InputSegmentComponentProps, Size } from '../shared.types';
-import { SegmentObjMock } from './testutils.mocks';
+import {
+ defaultFormatPartsMock,
+ defaultMaxMock,
+ defaultMinMock,
+ defaultPlaceholderMock,
+ SegmentObjMock,
+ segmentRulesMock,
+ segmentsMock,
+ segmentWidthStyles,
+} from './testutils.mocks';
+
+export const defaultProps: Partial> = {
+ segments: segmentsMock,
+ segmentEnum: SegmentObjMock,
+ setSegment: () => {},
+ 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 = React.forwardRef<
+ HTMLInputElement,
+ InputSegmentComponentProps
+>(
+ (
+ {
+ segment,
+ value,
+ onChange = () => {},
+ onBlur = () => {},
+ segmentEnum = SegmentObjMock,
+ disabled = false,
+ charsCount,
+ size,
+ },
+ ref,
+ ) => {
+ return (
+
+ );
+ },
+);
+
+InputSegmentWrapper.displayName = '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 = ({
+ segments: segmentsProp = {
+ day: '',
+ month: '',
+ year: '',
+ },
+ setSegment: setSegmentProp,
+ disabled = false,
+ size = Size.Default,
+ ...props
+}: Partial> & {
+ segments?: Record;
+}) => {
+ 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,
+ };
+};
interface RenderSegmentReturnType {
getInput: () => HTMLInputElement;
diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts
deleted file mode 100644
index 0466e233e3..0000000000
--- a/packages/input-box/src/testutils/testutils.mocks.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-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/testutils/testutils.mocks.tsx b/packages/input-box/src/testutils/testutils.mocks.tsx
new file mode 100644
index 0000000000..076ccb0df8
--- /dev/null
+++ b/packages/input-box/src/testutils/testutils.mocks.tsx
@@ -0,0 +1,178 @@
+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 = {
+ 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 dateSegmentEmptyMock: Record = {
+ month: '',
+ day: '',
+ year: '',
+};
+
+export const segmentsMock: Record = {
+ month: '02',
+ day: '02',
+ year: '2025',
+};
+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,
+ H: 46 / 40,
+ MM: 55 / 40,
+ S: 46 / 40,
+} as const;
+
+export const segmentWidthStyles: Record = {
+ day: css`
+ width: ${segmentRulesMock['day'].maxChars * characterWidth.D}ch;
+ `,
+ month: css`
+ width: ${segmentRulesMock['month'].maxChars * characterWidth.M}ch;
+ `,
+ year: css`
+ 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/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts
index 9754f2fa90..7aab16f5d5 100644
--- a/packages/input-box/src/utils/index.ts
+++ b/packages/input-box/src/utils/index.ts
@@ -10,6 +10,7 @@ export {
} from './getRelativeSegment/getRelativeSegment';
export { getValueFormatter } from './getValueFormatter/getValueFormatter';
export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment';
+export { isSingleDigitKey } from './isSingleDigitKey/isSingleDigitKey';
export {
isValidSegmentName,
isValidSegmentValue,
diff --git a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts
new file mode 100644
index 0000000000..dd63466d13
--- /dev/null
+++ b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts
@@ -0,0 +1,15 @@
+import range from 'lodash/range';
+
+import { keyMap } from '@leafygreen-ui/lib';
+
+import { isSingleDigitKey } from './isSingleDigitKey';
+
+describe('packages/input-box/utils/isSingleDigit', () => {
+ test.each(range(10))('returns true for %i character', i => {
+ expect(isSingleDigitKey(`${i}`)).toBe(true);
+ });
+
+ test.each(Object.values(keyMap))('returns false for %s', key => {
+ expect(isSingleDigitKey(key)).toBe(false);
+ });
+});
diff --git a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts
new file mode 100644
index 0000000000..b48112550b
--- /dev/null
+++ b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.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 isSingleDigitKey = (key: string): boolean => /^[0-9]$/.test(key);
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: