From 7e6e4b4e92560d80415c8ed9002e34a4480f753f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 15:30:32 -0500 Subject: [PATCH 1/9] 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 2/9] 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 b0d7bbaab125bda638f7b45e2be8ed32e546c439 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 17:39:41 -0500 Subject: [PATCH 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 a589e9407b0490a073a876d3cb06d8eee829724a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 13:12:17 -0500 Subject: [PATCH 9/9] 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';