From 7e6e4b4e92560d80415c8ed9002e34a4480f753f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 15:30:32 -0500 Subject: [PATCH 01/36] 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 02/36] remove segement files --- .../src/InputSegment/InputSegment.spec.tsx | 843 ------------------ .../src/InputSegment/InputSegment.stories.tsx | 157 ---- .../src/InputSegment/InputSegment.styles.ts | 103 --- .../src/InputSegment/InputSegment.tsx | 240 ----- .../src/InputSegment/InputSegment.types.ts | 101 --- packages/input-box/src/InputSegment/index.ts | 7 +- packages/input-box/src/testutils/index.tsx | 250 ------ 7 files changed, 1 insertion(+), 1700 deletions(-) delete mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx delete mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.tsx delete mode 100644 packages/input-box/src/InputSegment/InputSegment.styles.ts delete mode 100644 packages/input-box/src/InputSegment/InputSegment.tsx delete mode 100644 packages/input-box/src/testutils/index.tsx diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx deleted file mode 100644 index 150239094f..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ /dev/null @@ -1,843 +0,0 @@ -import React from 'react'; -import userEvent from '@testing-library/user-event'; - -import { renderSegment, setSegmentProps } from '../testutils'; -import { - charsPerSegmentMock, - defaultMaxMock, - defaultMinMock, - SegmentObjMock, -} from '../testutils/testutils.mocks'; -import { getValueFormatter } from '../utils'; - -import { InputSegment, InputSegmentChangeEventHandler } from '.'; - -describe('packages/input-segment', () => { - describe('aria attributes', () => { - test(`segment has aria-label`, () => { - const { input } = renderSegment({ - props: { segment: 'day' }, - }); - expect(input).toHaveAttribute('aria-label', 'day'); - }); - - test('has role="spinbutton"', () => { - const { input } = renderSegment({}); - expect(input).toHaveAttribute('role', 'spinbutton'); - }); - - test('has min and max attributes', () => { - const { input } = renderSegment({ - props: { segment: 'day' }, - }); - expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); - expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); - }); - }); - - describe('rendering', () => { - test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({}); - expect(input.value).toBe(''); - }); - - test('Rendering with a value sets the input value', () => { - const { input } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, - }); - expect(input.value).toBe('12'); - }); - - test('rerendering updates the value', () => { - const { getInput, rerenderSegment } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, - }); - - rerenderSegment({ - newProviderProps: { segments: { day: '08', month: '', year: '' } }, - }); - expect(getInput().value).toBe('08'); - }); - }); - - describe('typing', () => { - describe('into an empty segment', () => { - test('calls the change handler', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '8'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '8' }), - ); - }); - - test('allows zero character', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '0'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '0' }), - ); - }); - - test('does not allow non-number characters', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, - }); - userEvent.type(input, 'aB$/'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - }); - - describe('into a segment with a value', () => { - test('allows typing additional characters if the current value is incomplete', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - segments: { day: '2', month: '', year: '' }, - onChange: onChangeHandler, - }, - }); - - userEvent.type(input, '6'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '26' }), - ); - }); - - test('resets the value when the value is complete', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - segments: { day: '26', month: '', year: '' }, - onChange: onChangeHandler, - }, - }); - - userEvent.type(input, '4'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '4' }), - ); - }); - }); - - describe('keyboard events', () => { - describe('Arrow keys', () => { - const formatter = getValueFormatter({ - charsPerSegment: charsPerSegmentMock['day'], - allowZero: defaultMinMock['day'] === 0, - }); - - describe('Up arrow', () => { - test('calls handler with value default +1 step', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(16), - }), - ); - }); - - test('calls handler with custom `step`', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day', step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(17), - }), - ); - }); - - test('calls handler with `min`', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMinMock['day']), - }), - ); - }); - - test('rolls value over to `min` value if value exceeds `max`', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMinMock['day']), - }), - ); - }); - - test('does not wrap if `shouldWrap` is false', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMaxMock['day'] + 1), - }), - ); - }); - - test('does not wrap if `shouldWrap` is false and value is less than min', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldWrap: false, - }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '0', month: '', year: '3' }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'year', value: '0004' }), - ); - }); - - test('formats value with leading zero', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '06', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '07' }), - ); - }); - - test('formats values without leading zeros', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '3', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowup}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '04' }), - ); - }); - }); - - describe('Down arrow', () => { - test('calls handler with value default -1 step', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(14), - }), - ); - }); - - test('calls handler with custom `step`', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(13), - }), - ); - }); - - test('calls handler with `max`', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMaxMock['day']), - }), - ); - }); - - test('rolls value over to `max` value if value exceeds `min`', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMaxMock['day']), - }), - ); - }); - - test('does not wrap if `shouldWrap` is false', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ - value: formatter(defaultMinMock['day'] - 1), - }), - ); - }); - - test('does not wrap if `shouldWrap` is false and value is less than min', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldWrap: false, - }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '0', month: '', year: '3' }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'year', value: '0002' }), - ); - }); - - test('formats value with leading zero', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '06', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '05' }), - ); - }); - - test('formats values without leading zeros', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '3', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{arrowdown}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '02' }), - ); - }); - }); - - describe('Backspace', () => { - test('clears the input when there is a value', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{backspace}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '' }), - ); - }); - - test('does not call the onChangeHandler when the value is initially empty', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '{backspace}'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - }); - - describe('Space', () => { - describe('on a single SPACE', () => { - test('does not call the onChangeHandler when the value is initially empty', () => { - const onChangeHandler = - jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - - const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '{space}'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - - test('calls the onChangeHandler when the value is present', () => { - const onChangeHandler = - jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{space}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '' }), - ); - }); - }); - - describe('on a double SPACE', () => { - test('does not call the onChangeHandler when the value is initially empty', () => { - const onChangeHandler = - jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '{space}{space}'); - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - - test('calls the onChangeHandler when the value is present', () => { - const onChangeHandler = - jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, - }); - - userEvent.type(input, '{space}{space}'); - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '' }), - ); - }); - }); - }); - }); - }); - - describe('min/max range', () => { - test('does not allow values outside max range', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - // max is 31 - const { input } = renderSegment({ - providerProps: { - segments: { day: '3', month: '', year: '' }, - onChange: onChangeHandler, - }, - }); - userEvent.type(input, '2'); - // returns the last valid value - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '2' }), - ); - }); - - test('allows values below min range', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - // min is 1. We allow values below min range. - const { input } = renderSegment({ - props: { ...setSegmentProps('month') }, - providerProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, - }); - userEvent.type(input, '0'); - // returns the last valid value - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '0' }), - ); - }); - - test('allows values above max range when skipValidation is true', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - // max is 2038 - const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldSkipValidation: true, - }, - providerProps: { - segments: { day: '', month: '', year: '203' }, - onChange: onChangeHandler, - }, - }); - userEvent.type(input, '9'); - // returns the last valid value - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '2039' }), - ); - }); - }); - }); - - describe('onBlur handler', () => { - test('calls the custom onBlur prop when provided', () => { - const onBlurHandler = jest.fn(); - const { input } = renderSegment({ - props: { onBlur: onBlurHandler }, - }); - - input.focus(); - input.blur(); - - expect(onBlurHandler).toHaveBeenCalled(); - }); - - test('calls both context and prop onBlur handlers', () => { - const contextOnBlur = jest.fn(); - const propOnBlur = jest.fn(); - const { input } = renderSegment({ - props: { onBlur: propOnBlur }, - providerProps: { onBlur: contextOnBlur }, - }); - - input.focus(); - input.blur(); - - expect(contextOnBlur).toHaveBeenCalled(); - expect(propOnBlur).toHaveBeenCalled(); - }); - }); - - describe('custom onKeyDown handler', () => { - test('calls the custom onKeyDown prop when provided', () => { - const onKeyDownHandler = jest.fn(); - const { input } = renderSegment({ - props: { onKeyDown: onKeyDownHandler }, - }); - - userEvent.type(input, '5'); - - expect(onKeyDownHandler).toHaveBeenCalled(); - }); - - test('custom onKeyDown is called alongside internal handler', () => { - const onKeyDownHandler = jest.fn(); - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { onKeyDown: onKeyDownHandler }, - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '{arrowup}'); - - expect(onKeyDownHandler).toHaveBeenCalled(); - expect(onChangeHandler).toHaveBeenCalled(); - }); - }); - - describe('disabled state', () => { - test('input is disabled when disabled context prop is true', () => { - const { input } = renderSegment({ - providerProps: { disabled: true }, - }); - - expect(input).toBeDisabled(); - }); - - test('does not call onChange when disabled and typed into', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - providerProps: { disabled: true, onChange: onChangeHandler }, - }); - - userEvent.type(input, '5'); - - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - }); - - describe('shouldSkipValidation prop', () => { - test('allows values outside min/max range when shouldSkipValidation is true', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day', shouldSkipValidation: true }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '9', month: '', year: '' }, - }, - }); - - userEvent.type(input, '9'); - - expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'day', value: '99' }), - ); - }); - - test('does not allows values outside min/max range when shouldSkipValidation is false', () => { - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { segment: 'day', shouldSkipValidation: false }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '9', month: '', year: '' }, - }, - }); - - userEvent.type(input, '9'); - - expect(onChangeHandler).not.toHaveBeenCalled(); - }); - }); - - describe('custom onChange prop', () => { - test('calls prop-level onChange in addition to context onChange', () => { - const contextOnChange = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const propOnChange = jest.fn(); - const { input } = renderSegment({ - props: { onChange: propOnChange }, - providerProps: { onChange: contextOnChange }, - }); - - userEvent.type(input, '5'); - - expect(contextOnChange).toHaveBeenCalled(); - expect(propOnChange).toHaveBeenCalled(); - }); - }); - - /* eslint-disable jest/no-disabled-tests */ - describe.skip('types behave as expected', () => { - test('InputSegment throws error when no required props are provided', () => { - // @ts-expect-error - missing required props - ; - }); - - test('With required props', () => { - ; - }); - - test('With all props', () => { - {}} - onKeyDown={() => {}} - disabled={false} - data-testid="test-id" - id="day" - ref={React.createRef()} - />; - }); - }); -}); diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx deleted file mode 100644 index 459f6b9d8e..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useState } from 'react'; -import { - storybookExcludedControlParams, - StoryMetaType, -} from '@lg-tools/storybook-utils'; -import { StoryFn } from '@storybook/react'; - -import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; -import { Size } from '@leafygreen-ui/tokens'; - -import { InputBoxProvider } from '../InputBoxContext'; -import { - charsPerSegmentMock, - defaultMaxMock, - defaultMinMock, - defaultPlaceholderMock, - SegmentObjMock, - segmentRefsMock, - segmentsMock, -} from '../testutils/testutils.mocks'; - -import { InputSegment, InputSegmentChangeEventHandler } from '.'; - -interface InputSegmentStoryProps { - size: Size; - segments: Record; -} - -const meta: StoryMetaType = { - title: 'Components/Inputs/InputBox/InputSegment', - component: InputSegment, - decorators: [ - (StoryFn, context: any) => ( - - - - ), - ], - args: { - segment: SegmentObjMock.Day, - min: defaultMinMock[SegmentObjMock.Day], - max: defaultMaxMock[SegmentObjMock.Day], - size: Size.Default, - placeholder: defaultPlaceholderMock[SegmentObjMock.Day], - shouldWrap: true, - step: 1, - darkMode: false, - }, - argTypes: { - size: { - control: 'select', - options: Object.values(Size), - }, - darkMode: { - control: 'boolean', - }, - }, - parameters: { - default: 'LiveExample', - controls: { - exclude: [ - ...storybookExcludedControlParams, - 'segment', - 'value', - 'onChange', - 'charsPerSegment', - 'segmentEnum', - 'min', - 'max', - 'shouldWrap', - 'shouldSkipValidation', - 'step', - 'placeholder', - ], - }, - generate: { - combineArgs: { - darkMode: [false, true], - segment: ['day', 'month', 'year'], - size: Object.values(Size), - segments: [ - { - day: '2', - month: '8', - year: '2025', - }, - { - day: '00', - month: '0', - year: '0000', - }, - { - day: '', - month: '', - year: '', - }, - ], - }, - decorator: (StoryFn, context) => ( - - {}} - onBlur={() => {}} - segmentRefs={segmentRefsMock} - segments={context?.args.segments} - size={context?.args.size} - disabled={false} - > - - - - ), - }, - }, -}; -export default meta; - -export const LiveExample: StoryFn = ( - props, - context: any, -) => { - const [segments, setSegments] = useState(segmentsMock); - - const handleChange: InputSegmentChangeEventHandler< - SegmentObjMock, - string - > = ({ segment, value }) => { - setSegments(prev => ({ ...prev, [segment]: value })); - }; - - return ( - {}} - segmentRefs={segmentRefsMock} - segments={segments} - disabled={false} - size={context?.args?.size || Size.Default} - > - - - ); -}; - -export const Generated = () => {}; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts deleted file mode 100644 index c759609a82..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { css, cx } from '@leafygreen-ui/emotion'; -import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; -import { - BaseFontSize, - fontFamilies, - Size, - typeScales, -} from '@leafygreen-ui/tokens'; - -export const baseStyles = css` - font-family: ${fontFamilies.default}; - font-size: ${BaseFontSize.Body1}px; - font-variant: tabular-nums; - text-align: center; - border: none; - border-radius: 0; - padding: 0; - - &::-webkit-outer-spin-button, - &::-webkit-inner-spin-button { - -webkit-appearance: none; - appearance: none; - margin: 0; - } - -moz-appearance: textfield; /* Firefox */ - appearance: textfield; - - &:focus { - outline: none; - } -`; - -export const segmentThemeStyles: Record = { - [Theme.Light]: css` - background-color: transparent; - color: ${palette.black}; - - &::placeholder { - color: ${palette.gray.light1}; - } - - &:focus { - background-color: ${palette.blue.light3}; - } - `, - [Theme.Dark]: css` - background-color: transparent; - color: ${palette.gray.light2}; - - &::placeholder { - color: ${palette.gray.dark1}; - } - - &:focus { - background-color: ${palette.blue.dark3}; - } - `, -}; - -export const fontSizeStyles: Record = { - [BaseFontSize.Body1]: css` - --base-font-size: ${BaseFontSize.Body1}px; - `, - [BaseFontSize.Body2]: css` - --base-font-size: ${BaseFontSize.Body2}px; - `, -}; - -export const segmentSizeStyles: Record = { - [Size.XSmall]: css` - font-size: ${typeScales.body1.fontSize}px; - `, - [Size.Small]: css` - font-size: ${typeScales.body1.fontSize}px; - `, - [Size.Default]: css` - font-size: var(--base-font-size, ${typeScales.body1.fontSize}px); - `, - [Size.Large]: css` - font-size: 18px; // Intentionally off-token - `, -}; - -export const getInputSegmentStyles = ({ - className, - baseFontSize, - theme, - size, -}: { - className?: string; - baseFontSize: BaseFontSize; - theme: Theme; - size: Size; -}) => { - return cx( - baseStyles, - fontSizeStyles[baseFontSize], - segmentThemeStyles[theme], - segmentSizeStyles[size], - className, - ); -}; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx deleted file mode 100644 index 82d30ad76a..0000000000 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { - ChangeEventHandler, - FocusEvent, - ForwardedRef, - KeyboardEventHandler, -} from 'react'; - -import { VisuallyHidden } from '@leafygreen-ui/a11y'; -import { useMergeRefs } from '@leafygreen-ui/hooks'; -import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { keyMap } from '@leafygreen-ui/lib'; -import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; - -import { useInputBoxContext } from '../InputBoxContext'; -import { - getNewSegmentValueFromArrowKeyPress, - getNewSegmentValueFromInputValue, - getValueFormatter, -} from '../utils'; - -import { getInputSegmentStyles } from './InputSegment.styles'; -import { - InputSegmentComponentType, - InputSegmentProps, -} from './InputSegment.types'; - -/** - * Generic controlled input segment component - * - * Renders a single input segment with configurable - * character padding, validation, and formatting. - * - * @internal - */ -const InputSegmentWithRef = ( - { - segment, - onKeyDown, - min, // minSegmentValue - max, // maxSegmentValue - className, - onChange: onChangeProp, - onBlur: onBlurProp, - step = 1, - shouldWrap = true, - shouldSkipValidation = false, - ...rest - }: InputSegmentProps, - fwdRef: ForwardedRef, -) => { - const { theme } = useDarkMode(); - const { - onChange, - onBlur, - charsPerSegment: charsPerSegmentContext, - segmentEnum, - segmentRefs, - segments, - labelledBy, - size, - disabled, - } = useInputBoxContext(); - const baseFontSize = useUpdatedBaseFontSize(); - const charsPerSegment = charsPerSegmentContext[segment]; - const formatter = getValueFormatter({ - charsPerSegment, - allowZero: min === 0, - }); - const pattern = `[0-9]{${charsPerSegment}}`; - - const segmentRef = segmentRefs[segment]; - const mergedRef = useMergeRefs([fwdRef, segmentRef]); - const value = segments[segment]; - - /** - * Receives native input events, - * determines whether the input value is valid and should change, - * and fires a custom `InputSegmentChangeEvent`. - */ - const handleChange: ChangeEventHandler = e => { - const { target } = e; - - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: value, - incomingValue: target.value, - charsPerSegment, - defaultMin: min, - defaultMax: max, - segmentEnum, - shouldSkipValidation, - }); - - const hasValueChanged = newValue !== value; - - if (hasValueChanged) { - onChange({ - segment, - value: newValue, - meta: { min }, - }); - } else { - // If the value has not changed, ensure the input value is reset - target.value = value; - } - - onChangeProp?.(e); - }; - - /** Handle keydown presses that don't natively fire a change event */ - const handleKeyDown: KeyboardEventHandler = e => { - const { key, target } = e as React.KeyboardEvent & { - target: HTMLInputElement; - }; - - // A key press can be an `arrow`, `enter`, `space`, etc so we check for number presses - // We also check for `space` because Number(' ') returns true - const isNumber = Number(key) && key !== keyMap.Space; - - if (isNumber) { - // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called. - - if (target.value.length === charsPerSegment) { - target.value = ''; - } - } - - switch (key) { - case keyMap.ArrowUp: - case keyMap.ArrowDown: { - e.preventDefault(); - - const newValue = getNewSegmentValueFromArrowKeyPress({ - key, - value, - min, - max, - step, - shouldWrap: shouldWrap, - }); - const valueString = formatter(newValue); - - /** Fire a custom change event when the up/down arrow keys are pressed */ - onChange({ - segment, - value: valueString, - meta: { key, min }, - }); - break; - } - - // On backspace the value is reset - case keyMap.Backspace: { - // Don't fire change event if the input is initially empty - if (value) { - // Stop propagation to prevent parent handlers from firing - e.stopPropagation(); - - /** Fire a custom change event when the backspace key is pressed */ - onChange({ - segment, - value: '', - meta: { key, min }, - }); - } - - break; - } - - // On space the value is reset - case keyMap.Space: { - e.preventDefault(); - - // Don't fire change event if the input is initially empty - if (value) { - /** Fire a custom change event when the space key is pressed */ - onChange({ - segment, - value: '', - meta: { key, min }, - }); - } - - break; - } - - default: { - break; - } - } - - onKeyDown?.(e); - }; - - const handleBlur = (e: FocusEvent) => { - onBlur?.(e); - onBlurProp?.(e); - }; - - // Note: Using a text input with pattern attribute due to Firefox - // stripping leading zeros on number inputs - Thanks @matt-d-rat - // Number inputs also don't support the `selectionStart`/`End` API - return ( - <> - - - {value && `${segment} ${value}`} - - - ); -}; - -export const InputSegment = React.forwardRef( - InputSegmentWithRef, -) as InputSegmentComponentType; - -InputSegment.displayName = 'InputSegment'; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 7cbeaa34db..9d0d5b1e8e 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,5 +1,3 @@ -import React, { ForwardedRef, ReactElement } from 'react'; - import { keyMap } from '@leafygreen-ui/lib'; export interface InputSegmentChangeEvent< @@ -15,7 +13,6 @@ export interface InputSegmentChangeEvent< }; } -// TODO: consider renaming min/max names to minSegment/maxSegment /** * The type for the onChange handler */ @@ -23,101 +20,3 @@ export type InputSegmentChangeEventHandler< Segment extends string, Value extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; - -export interface InputSegmentProps - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' - > { - /** - * Which segment this input represents - * - * @example - * 'day' - * 'month' - * 'year' - */ - segment: Segment; - - /** - * Minimum value for the segment - * - * @example - * 1 - * 1 - * 1970 - */ - min: number; - - /** - * Maximum value for the segment - * - * @example - * 31 - * 12 - * 2038 - */ - max: number; - - /** - * The step value for the arrow keys - * - * @default 1 - */ - step?: number; - - /** - * Whether the segment should wrap at min/max boundaries - * - * @default true - */ - shouldWrap?: boolean; - - /** - * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. - * - * @default false - */ - shouldSkipValidation?: boolean; -} - -/** - * Type definition for the InputSegment component that maintains generic type safety with forwardRef. - * - * Interface with a generic call signature that preserves type parameters() when using forwardRef. - * React.forwardRef loses type parameters, so this interface is used to restore them. - * - * @see https://stackoverflow.com/a/58473012 - */ -export interface InputSegmentComponentType { - ( - props: InputSegmentProps, - ref: ForwardedRef, - ): ReactElement | null; - displayName?: string; -} - -/** - * Returns whether the given string is a valid segment - */ -export function isInputSegment>( - str: any, - segmentObj: T, -): str is T[keyof T] { - if (typeof str !== 'string') return false; - return Object.values(segmentObj).includes(str); -} - -/** - * Base props for custom segment components passed to InputBox. - * - * Extend this interface to define props for custom segment implementations. - * InputBox will provide additional props internally (e.g., onChange, value, min, max). - */ -export interface InputSegmentComponentProps - extends Omit< - React.ComponentPropsWithoutRef<'input'>, - 'onChange' | 'value' | 'min' | 'max' - > { - segment: Segment; -} diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts index 8e2840befb..7e21581ebf 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1,6 +1 @@ -export { InputSegment } from './InputSegment'; -export { - type InputSegmentChangeEventHandler, - type InputSegmentComponentProps, - type InputSegmentProps, -} from './InputSegment.types'; +export { type InputSegmentChangeEventHandler } from './InputSegment.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx deleted file mode 100644 index 80cee23566..0000000000 --- a/packages/input-box/src/testutils/index.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import React from 'react'; -import { render, RenderResult } from '@testing-library/react'; - -import { Size } from '@leafygreen-ui/tokens'; - -import { InputBox, InputBoxProps } from '../InputBox'; -import { InputBoxProvider } from '../InputBoxContext'; -import { InputBoxProviderProps } from '../InputBoxContext'; -import { InputSegment } from '../InputSegment'; -import { InputSegmentProps } from '../InputSegment/InputSegment.types'; - -import { - charsPerSegmentMock, - defaultFormatPartsMock, - defaultMaxMock, - defaultMinMock, - defaultPlaceholderMock, - SegmentObjMock, - segmentRefsMock, - segmentRulesMock, - segmentsMock, - segmentWidthStyles, -} from './testutils.mocks'; - -export const defaultProps: Partial> = { - segments: segmentsMock, - segmentEnum: SegmentObjMock, - segmentRefs: segmentRefsMock, - setSegment: () => {}, - charsPerSegment: charsPerSegmentMock, - formatParts: defaultFormatPartsMock, - segmentRules: segmentRulesMock, -}; - -/** - * This component is used to render the InputSegment component for testing purposes. - * @param segment - The segment to render - * @returns - */ -export const InputSegmentWrapper = ({ - segment, -}: { - segment: SegmentObjMock; -}) => { - return ( - - ); -}; - -/** - * This component is used to render the InputBox component for testing purposes. - * Includes segment state management and a default renderSegment function. - * Props can override the internal state management. - */ -export const InputBoxWithState = ({ - segments: segmentsProp = { - day: '', - month: '', - year: '', - }, - setSegment: setSegmentProp, - disabled = false, - ...props -}: Partial> & { - segments?: Record; -}) => { - const dayRef = React.useRef(null); - const monthRef = React.useRef(null); - const yearRef = React.useRef(null); - - const segmentRefs = { - day: dayRef, - month: monthRef, - year: yearRef, - }; - - const [segments, setSegments] = React.useState(segmentsProp); - - const defaultSetSegment = (segment: SegmentObjMock, value: string) => { - setSegments(prev => ({ ...prev, [segment]: value })); - }; - - // If setSegment is provided, use controlled mode with the provided segments - // Otherwise, use internal state management - const effectiveSegments = setSegmentProp ? segmentsProp : segments; - const effectiveSetSegment = setSegmentProp ?? defaultSetSegment; - - return ( - - ); -}; - -interface RenderInputBoxReturnType { - dayInput: HTMLInputElement; - monthInput: HTMLInputElement; - yearInput: HTMLInputElement; - rerenderInputBox: (props: Partial>) => void; - getDayInput: () => HTMLInputElement; - getMonthInput: () => HTMLInputElement; - getYearInput: () => HTMLInputElement; -} - -/** - * Renders InputBox with internal state management for testing purposes. - * Props can be passed to override the default state behavior. - */ -export const renderInputBox = ({ - ...props -}: Partial> = {}): RenderResult & - RenderInputBoxReturnType => { - const result = render(); - - const getDayInput = () => - result.getByTestId('input-segment-day') as HTMLInputElement; - const getMonthInput = () => - result.getByTestId('input-segment-month') as HTMLInputElement; - const getYearInput = () => - result.getByTestId('input-segment-year') as HTMLInputElement; - - const rerenderInputBox = ( - newProps: Partial>, - ) => { - result.rerender(); - }; - - return { - ...result, - rerenderInputBox, - dayInput: getDayInput(), - monthInput: getMonthInput(), - yearInput: getYearInput(), - getDayInput, - getMonthInput, - getYearInput, - }; -}; - -/* - * InputSegment Utils - */ -export const setSegmentProps = (segment: SegmentObjMock) => { - return { - segment: segment, - charsPerSegment: charsPerSegmentMock[segment], - min: defaultMinMock[segment], - max: defaultMaxMock[segment], - placeholder: defaultPlaceholderMock[segment], - }; -}; - -interface RenderSegmentReturnType { - getInput: () => HTMLInputElement; - input: HTMLInputElement; - rerenderSegment: (params: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => void; -} - -const defaultSegmentProviderProps: Partial< - InputBoxProviderProps -> = { - charsPerSegment: charsPerSegmentMock, - segmentEnum: SegmentObjMock, - onChange: () => {}, - onBlur: () => {}, - segments: { - day: '', - month: '', - year: '', - }, - segmentRefs: segmentRefsMock, -}; - -const defaultSegmentProps: InputSegmentProps = { - segment: 'day', - min: defaultMinMock['day'], - max: defaultMaxMock['day'], - shouldWrap: true, - placeholder: defaultPlaceholderMock['day'], - // @ts-expect-error - data-testid - ['data-testid']: 'lg-input-segment', -}; - -/** - * Renders the InputSegment component for testing purposes. - */ -export const renderSegment = ({ - props = {}, - providerProps = {}, -}: { - props?: Partial>; - providerProps?: Partial>; -}): RenderResult & RenderSegmentReturnType => { - const mergedProps = { - ...defaultSegmentProps, - ...props, - } as InputSegmentProps; - - const mergedProviderProps = { - ...defaultSegmentProviderProps, - ...providerProps, - } as InputBoxProviderProps; - - const utils = render( - - - , - ); - - const rerenderSegment = ({ - newProps = {}, - newProviderProps = {}, - }: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => { - utils.rerender( - - - , - ); - }; - - const getInput = () => - utils.getByTestId('lg-input-segment') as HTMLInputElement; - return { ...utils, getInput, input: getInput(), rerenderSegment }; -}; From 691bde94912f0449246f9c4fb1027d6686a29ac2 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 16:49:28 -0500 Subject: [PATCH 03/36] feat(input-box): implement InputSegment component with styles, tests, and stories --- .../src/InputSegment/InputSegment.spec.tsx | 843 ++++++++++++++++++ .../src/InputSegment/InputSegment.stories.tsx | 157 ++++ .../src/InputSegment/InputSegment.styles.ts | 103 +++ .../src/InputSegment/InputSegment.tsx | 240 +++++ .../src/InputSegment/InputSegment.types.ts | 101 +++ packages/input-box/src/InputSegment/index.ts | 7 +- packages/input-box/src/testutils/index.tsx | 250 ++++++ 7 files changed, 1700 insertions(+), 1 deletion(-) create mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.styles.ts create mode 100644 packages/input-box/src/InputSegment/InputSegment.tsx create mode 100644 packages/input-box/src/testutils/index.tsx diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx new file mode 100644 index 0000000000..150239094f --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -0,0 +1,843 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { renderSegment, setSegmentProps } from '../testutils'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + SegmentObjMock, +} from '../testutils/testutils.mocks'; +import { getValueFormatter } from '../utils'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +describe('packages/input-segment', () => { + describe('aria attributes', () => { + test(`segment has aria-label`, () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + expect(input).toHaveAttribute('aria-label', 'day'); + }); + + test('has role="spinbutton"', () => { + const { input } = renderSegment({}); + expect(input).toHaveAttribute('role', 'spinbutton'); + }); + + test('has min and max attributes', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); + expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); + }); + }); + + describe('rendering', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({}); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ + providerProps: { segments: { day: '12', month: '', year: '' } }, + }); + expect(input.value).toBe('12'); + }); + + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + providerProps: { segments: { day: '12', month: '', year: '' } }, + }); + + rerenderSegment({ + newProviderProps: { segments: { day: '08', month: '', year: '' } }, + }); + expect(getInput().value).toBe('08'); + }); + }); + + describe('typing', () => { + describe('into an empty segment', () => { + test('calls the change handler', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '8'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '8' }), + ); + }); + + test('allows zero character', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '0'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '0' }), + ); + }); + + test('does not allow non-number characters', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + userEvent.type(input, 'aB$/'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('into a segment with a value', () => { + test('allows typing additional characters if the current value is incomplete', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + segments: { day: '2', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + + userEvent.type(input, '6'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '26' }), + ); + }); + + test('resets the value when the value is complete', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + segments: { day: '26', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); + }); + + describe('keyboard events', () => { + describe('Arrow keys', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegmentMock['day'], + allowZero: defaultMinMock['day'] === 0, + }); + + describe('Up arrow', () => { + test('calls handler with value default +1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(16), + }), + ); + }); + + test('calls handler with custom `step`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(17), + }), + ); + }); + + test('calls handler with `min`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('rolls value over to `min` value if value exceeds `max`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day'] + 1), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldWrap: false, + }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '0', month: '', year: '3' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'year', value: '0004' }), + ); + }); + + test('formats value with leading zero', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '06', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '07' }), + ); + }); + + test('formats values without leading zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '3', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '04' }), + ); + }); + }); + + describe('Down arrow', () => { + test('calls handler with value default -1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(14), + }), + ); + }); + + test('calls handler with custom `step`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(13), + }), + ); + }); + + test('calls handler with `max`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('rolls value over to `max` value if value exceeds `min`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day'] - 1), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldWrap: false, + }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '0', month: '', year: '3' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'year', value: '0002' }), + ); + }); + + test('formats value with leading zero', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '06', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '05' }), + ); + }); + + test('formats values without leading zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '3', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '02' }), + ); + }); + }); + + describe('Backspace', () => { + test('clears the input when there is a value', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('Space', () => { + describe('on a single SPACE', () => { + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('calls the onChangeHandler when the value is present', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + + describe('on a double SPACE', () => { + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('calls the onChangeHandler when the value is present', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + }); + }); + }); + + describe('min/max range', () => { + test('does not allow values outside max range', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // max is 31 + const { input } = renderSegment({ + providerProps: { + segments: { day: '3', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '2'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '2' }), + ); + }); + + test('allows values below min range', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // min is 1. We allow values below min range. + const { input } = renderSegment({ + props: { ...setSegmentProps('month') }, + providerProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '0'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '0' }), + ); + }); + + test('allows values above max range when skipValidation is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // max is 2038 + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldSkipValidation: true, + }, + providerProps: { + segments: { day: '', month: '', year: '203' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '9'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '2039' }), + ); + }); + }); + }); + + describe('onBlur handler', () => { + test('calls the custom onBlur prop when provided', () => { + const onBlurHandler = jest.fn(); + const { input } = renderSegment({ + props: { onBlur: onBlurHandler }, + }); + + input.focus(); + input.blur(); + + expect(onBlurHandler).toHaveBeenCalled(); + }); + + test('calls both context and prop onBlur handlers', () => { + const contextOnBlur = jest.fn(); + const propOnBlur = jest.fn(); + const { input } = renderSegment({ + props: { onBlur: propOnBlur }, + providerProps: { onBlur: contextOnBlur }, + }); + + input.focus(); + input.blur(); + + expect(contextOnBlur).toHaveBeenCalled(); + expect(propOnBlur).toHaveBeenCalled(); + }); + }); + + describe('custom onKeyDown handler', () => { + test('calls the custom onKeyDown prop when provided', () => { + const onKeyDownHandler = jest.fn(); + const { input } = renderSegment({ + props: { onKeyDown: onKeyDownHandler }, + }); + + userEvent.type(input, '5'); + + expect(onKeyDownHandler).toHaveBeenCalled(); + }); + + test('custom onKeyDown is called alongside internal handler', () => { + const onKeyDownHandler = jest.fn(); + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { onKeyDown: onKeyDownHandler }, + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{arrowup}'); + + expect(onKeyDownHandler).toHaveBeenCalled(); + expect(onChangeHandler).toHaveBeenCalled(); + }); + }); + + describe('disabled state', () => { + test('input is disabled when disabled context prop is true', () => { + const { input } = renderSegment({ + providerProps: { disabled: true }, + }); + + expect(input).toBeDisabled(); + }); + + test('does not call onChange when disabled and typed into', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { disabled: true, onChange: onChangeHandler }, + }); + + userEvent.type(input, '5'); + + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('shouldSkipValidation prop', () => { + test('allows values outside min/max range when shouldSkipValidation is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', shouldSkipValidation: true }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '9', month: '', year: '' }, + }, + }); + + userEvent.type(input, '9'); + + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'day', value: '99' }), + ); + }); + + test('does not allows values outside min/max range when shouldSkipValidation is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', shouldSkipValidation: false }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '9', month: '', year: '' }, + }, + }); + + userEvent.type(input, '9'); + + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('custom onChange prop', () => { + test('calls prop-level onChange in addition to context onChange', () => { + const contextOnChange = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const propOnChange = jest.fn(); + const { input } = renderSegment({ + props: { onChange: propOnChange }, + providerProps: { onChange: contextOnChange }, + }); + + userEvent.type(input, '5'); + + expect(contextOnChange).toHaveBeenCalled(); + expect(propOnChange).toHaveBeenCalled(); + }); + }); + + /* eslint-disable jest/no-disabled-tests */ + describe.skip('types behave as expected', () => { + test('InputSegment throws error when no required props are provided', () => { + // @ts-expect-error - missing required props + ; + }); + + test('With required props', () => { + ; + }); + + test('With all props', () => { + {}} + onKeyDown={() => {}} + disabled={false} + data-testid="test-id" + id="day" + ref={React.createRef()} + />; + }); + }); +}); diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx new file mode 100644 index 0000000000..459f6b9d8e --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -0,0 +1,157 @@ +import React, { useState } from 'react'; +import { + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; +import { StoryFn } from '@storybook/react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputBoxProvider } from '../InputBoxContext'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +interface InputSegmentStoryProps { + size: Size; + segments: Record; +} + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox/InputSegment', + component: InputSegment, + decorators: [ + (StoryFn, context: any) => ( + + + + ), + ], + args: { + segment: SegmentObjMock.Day, + min: defaultMinMock[SegmentObjMock.Day], + max: defaultMaxMock[SegmentObjMock.Day], + size: Size.Default, + placeholder: defaultPlaceholderMock[SegmentObjMock.Day], + shouldWrap: true, + step: 1, + darkMode: false, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + darkMode: { + control: 'boolean', + }, + }, + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segment', + 'value', + 'onChange', + 'charsPerSegment', + 'segmentEnum', + 'min', + 'max', + 'shouldWrap', + 'shouldSkipValidation', + 'step', + 'placeholder', + ], + }, + generate: { + combineArgs: { + darkMode: [false, true], + segment: ['day', 'month', 'year'], + size: Object.values(Size), + segments: [ + { + day: '2', + month: '8', + year: '2025', + }, + { + day: '00', + month: '0', + year: '0000', + }, + { + day: '', + month: '', + year: '', + }, + ], + }, + decorator: (StoryFn, context) => ( + + {}} + onBlur={() => {}} + segmentRefs={segmentRefsMock} + segments={context?.args.segments} + size={context?.args.size} + disabled={false} + > + + + + ), + }, + }, +}; +export default meta; + +export const LiveExample: StoryFn = ( + props, + context: any, +) => { + const [segments, setSegments] = useState(segmentsMock); + + const handleChange: InputSegmentChangeEventHandler< + SegmentObjMock, + string + > = ({ segment, value }) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; + + return ( + {}} + segmentRefs={segmentRefsMock} + segments={segments} + disabled={false} + size={context?.args?.size || Size.Default} + > + + + ); +}; + +export const Generated = () => {}; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts new file mode 100644 index 0000000000..c759609a82 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -0,0 +1,103 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { + BaseFontSize, + fontFamilies, + Size, + typeScales, +} from '@leafygreen-ui/tokens'; + +export const baseStyles = css` + font-family: ${fontFamilies.default}; + font-size: ${BaseFontSize.Body1}px; + font-variant: tabular-nums; + text-align: center; + border: none; + border-radius: 0; + padding: 0; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; + } + -moz-appearance: textfield; /* Firefox */ + appearance: textfield; + + &:focus { + outline: none; + } +`; + +export const segmentThemeStyles: Record = { + [Theme.Light]: css` + background-color: transparent; + color: ${palette.black}; + + &::placeholder { + color: ${palette.gray.light1}; + } + + &:focus { + background-color: ${palette.blue.light3}; + } + `, + [Theme.Dark]: css` + background-color: transparent; + color: ${palette.gray.light2}; + + &::placeholder { + color: ${palette.gray.dark1}; + } + + &:focus { + background-color: ${palette.blue.dark3}; + } + `, +}; + +export const fontSizeStyles: Record = { + [BaseFontSize.Body1]: css` + --base-font-size: ${BaseFontSize.Body1}px; + `, + [BaseFontSize.Body2]: css` + --base-font-size: ${BaseFontSize.Body2}px; + `, +}; + +export const segmentSizeStyles: Record = { + [Size.XSmall]: css` + font-size: ${typeScales.body1.fontSize}px; + `, + [Size.Small]: css` + font-size: ${typeScales.body1.fontSize}px; + `, + [Size.Default]: css` + font-size: var(--base-font-size, ${typeScales.body1.fontSize}px); + `, + [Size.Large]: css` + font-size: 18px; // Intentionally off-token + `, +}; + +export const getInputSegmentStyles = ({ + className, + baseFontSize, + theme, + size, +}: { + className?: string; + baseFontSize: BaseFontSize; + theme: Theme; + size: Size; +}) => { + return cx( + baseStyles, + fontSizeStyles[baseFontSize], + segmentThemeStyles[theme], + segmentSizeStyles[size], + className, + ); +}; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx new file mode 100644 index 0000000000..82d30ad76a --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -0,0 +1,240 @@ +import React, { + ChangeEventHandler, + FocusEvent, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; + +import { VisuallyHidden } from '@leafygreen-ui/a11y'; +import { useMergeRefs } from '@leafygreen-ui/hooks'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; +import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; + +import { useInputBoxContext } from '../InputBoxContext'; +import { + getNewSegmentValueFromArrowKeyPress, + getNewSegmentValueFromInputValue, + getValueFormatter, +} from '../utils'; + +import { getInputSegmentStyles } from './InputSegment.styles'; +import { + InputSegmentComponentType, + InputSegmentProps, +} from './InputSegment.types'; + +/** + * Generic controlled input segment component + * + * Renders a single input segment with configurable + * character padding, validation, and formatting. + * + * @internal + */ +const InputSegmentWithRef = ( + { + segment, + onKeyDown, + min, // minSegmentValue + max, // maxSegmentValue + className, + onChange: onChangeProp, + onBlur: onBlurProp, + step = 1, + shouldWrap = true, + shouldSkipValidation = false, + ...rest + }: InputSegmentProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + const { + onChange, + onBlur, + charsPerSegment: charsPerSegmentContext, + segmentEnum, + segmentRefs, + segments, + labelledBy, + size, + disabled, + } = useInputBoxContext(); + const baseFontSize = useUpdatedBaseFontSize(); + const charsPerSegment = charsPerSegmentContext[segment]; + const formatter = getValueFormatter({ + charsPerSegment, + allowZero: min === 0, + }); + const pattern = `[0-9]{${charsPerSegment}}`; + + const segmentRef = segmentRefs[segment]; + const mergedRef = useMergeRefs([fwdRef, segmentRef]); + const value = segments[segment]; + + /** + * Receives native input events, + * determines whether the input value is valid and should change, + * and fires a custom `InputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: value, + incomingValue: target.value, + charsPerSegment, + defaultMin: min, + defaultMax: max, + segmentEnum, + shouldSkipValidation, + }); + + const hasValueChanged = newValue !== value; + + if (hasValueChanged) { + onChange({ + segment, + value: newValue, + meta: { min }, + }); + } else { + // If the value has not changed, ensure the input value is reset + target.value = value; + } + + onChangeProp?.(e); + }; + + /** Handle keydown presses that don't natively fire a change event */ + const handleKeyDown: KeyboardEventHandler = e => { + const { key, target } = e as React.KeyboardEvent & { + target: HTMLInputElement; + }; + + // A key press can be an `arrow`, `enter`, `space`, etc so we check for number presses + // We also check for `space` because Number(' ') returns true + const isNumber = Number(key) && key !== keyMap.Space; + + if (isNumber) { + // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called. + + if (target.value.length === charsPerSegment) { + target.value = ''; + } + } + + switch (key) { + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + e.preventDefault(); + + const newValue = getNewSegmentValueFromArrowKeyPress({ + key, + value, + min, + max, + step, + shouldWrap: shouldWrap, + }); + const valueString = formatter(newValue); + + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString, + meta: { key, min }, + }); + break; + } + + // On backspace the value is reset + case keyMap.Backspace: { + // Don't fire change event if the input is initially empty + if (value) { + // Stop propagation to prevent parent handlers from firing + e.stopPropagation(); + + /** Fire a custom change event when the backspace key is pressed */ + onChange({ + segment, + value: '', + meta: { key, min }, + }); + } + + break; + } + + // On space the value is reset + case keyMap.Space: { + e.preventDefault(); + + // Don't fire change event if the input is initially empty + if (value) { + /** Fire a custom change event when the space key is pressed */ + onChange({ + segment, + value: '', + meta: { key, min }, + }); + } + + break; + } + + default: { + break; + } + } + + onKeyDown?.(e); + }; + + const handleBlur = (e: FocusEvent) => { + onBlur?.(e); + onBlurProp?.(e); + }; + + // Note: Using a text input with pattern attribute due to Firefox + // stripping leading zeros on number inputs - Thanks @matt-d-rat + // Number inputs also don't support the `selectionStart`/`End` API + return ( + <> + + + {value && `${segment} ${value}`} + + + ); +}; + +export const InputSegment = React.forwardRef( + InputSegmentWithRef, +) as InputSegmentComponentType; + +InputSegment.displayName = 'InputSegment'; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 9d0d5b1e8e..7cbeaa34db 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,3 +1,5 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + import { keyMap } from '@leafygreen-ui/lib'; export interface InputSegmentChangeEvent< @@ -13,6 +15,7 @@ export interface InputSegmentChangeEvent< }; } +// TODO: consider renaming min/max names to minSegment/maxSegment /** * The type for the onChange handler */ @@ -20,3 +23,101 @@ export type InputSegmentChangeEventHandler< Segment extends string, Value extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' + > { + /** + * Which segment this input represents + * + * @example + * 'day' + * 'month' + * 'year' + */ + segment: Segment; + + /** + * Minimum value for the segment + * + * @example + * 1 + * 1 + * 1970 + */ + min: number; + + /** + * Maximum value for the segment + * + * @example + * 31 + * 12 + * 2038 + */ + max: number; + + /** + * The step value for the arrow keys + * + * @default 1 + */ + step?: number; + + /** + * Whether the segment should wrap at min/max boundaries + * + * @default true + */ + shouldWrap?: boolean; + + /** + * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * + * @default false + */ + shouldSkipValidation?: boolean; +} + +/** + * Type definition for the InputSegment component that maintains generic type safety with forwardRef. + * + * Interface with a generic call signature that preserves type parameters() when using forwardRef. + * React.forwardRef loses type parameters, so this interface is used to restore them. + * + * @see https://stackoverflow.com/a/58473012 + */ +export interface InputSegmentComponentType { + ( + props: InputSegmentProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; +} + +/** + * Returns whether the given string is a valid segment + */ +export function isInputSegment>( + str: any, + segmentObj: T, +): str is T[keyof T] { + if (typeof str !== 'string') return false; + return Object.values(segmentObj).includes(str); +} + +/** + * Base props for custom segment components passed to InputBox. + * + * Extend this interface to define props for custom segment implementations. + * InputBox will provide additional props internally (e.g., onChange, value, min, max). + */ +export interface InputSegmentComponentProps + extends Omit< + React.ComponentPropsWithoutRef<'input'>, + 'onChange' | 'value' | 'min' | 'max' + > { + segment: Segment; +} diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts index 7e21581ebf..8e2840befb 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1 +1,6 @@ -export { type InputSegmentChangeEventHandler } from './InputSegment.types'; +export { InputSegment } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, + type InputSegmentProps, +} from './InputSegment.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx new file mode 100644 index 0000000000..80cee23566 --- /dev/null +++ b/packages/input-box/src/testutils/index.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; + +import { Size } from '@leafygreen-ui/tokens'; + +import { InputBox, InputBoxProps } from '../InputBox'; +import { InputBoxProvider } from '../InputBoxContext'; +import { InputBoxProviderProps } from '../InputBoxContext'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentProps } from '../InputSegment/InputSegment.types'; + +import { + charsPerSegmentMock, + defaultFormatPartsMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, + segmentWidthStyles, +} from './testutils.mocks'; + +export const defaultProps: Partial> = { + segments: segmentsMock, + segmentEnum: SegmentObjMock, + segmentRefs: segmentRefsMock, + setSegment: () => {}, + charsPerSegment: charsPerSegmentMock, + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, +}; + +/** + * This component is used to render the InputSegment component for testing purposes. + * @param segment - The segment to render + * @returns + */ +export const InputSegmentWrapper = ({ + segment, +}: { + segment: SegmentObjMock; +}) => { + return ( + + ); +}; + +/** + * This component is used to render the InputBox component for testing purposes. + * Includes segment state management and a default renderSegment function. + * Props can override the internal state management. + */ +export const InputBoxWithState = ({ + segments: segmentsProp = { + day: '', + month: '', + year: '', + }, + setSegment: setSegmentProp, + disabled = false, + ...props +}: Partial> & { + segments?: Record; +}) => { + const dayRef = React.useRef(null); + const monthRef = React.useRef(null); + const yearRef = React.useRef(null); + + const segmentRefs = { + day: dayRef, + month: monthRef, + year: yearRef, + }; + + const [segments, setSegments] = React.useState(segmentsProp); + + const defaultSetSegment = (segment: SegmentObjMock, value: string) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; + + // If setSegment is provided, use controlled mode with the provided segments + // Otherwise, use internal state management + const effectiveSegments = setSegmentProp ? segmentsProp : segments; + const effectiveSetSegment = setSegmentProp ?? defaultSetSegment; + + return ( + + ); +}; + +interface RenderInputBoxReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + rerenderInputBox: (props: Partial>) => void; + getDayInput: () => HTMLInputElement; + getMonthInput: () => HTMLInputElement; + getYearInput: () => HTMLInputElement; +} + +/** + * Renders InputBox with internal state management for testing purposes. + * Props can be passed to override the default state behavior. + */ +export const renderInputBox = ({ + ...props +}: Partial> = {}): RenderResult & + RenderInputBoxReturnType => { + const result = render(); + + const getDayInput = () => + result.getByTestId('input-segment-day') as HTMLInputElement; + const getMonthInput = () => + result.getByTestId('input-segment-month') as HTMLInputElement; + const getYearInput = () => + result.getByTestId('input-segment-year') as HTMLInputElement; + + const rerenderInputBox = ( + newProps: Partial>, + ) => { + result.rerender(); + }; + + return { + ...result, + rerenderInputBox, + dayInput: getDayInput(), + monthInput: getMonthInput(), + yearInput: getYearInput(), + getDayInput, + getMonthInput, + getYearInput, + }; +}; + +/* + * InputSegment Utils + */ +export const setSegmentProps = (segment: SegmentObjMock) => { + return { + segment: segment, + charsPerSegment: charsPerSegmentMock[segment], + min: defaultMinMock[segment], + max: defaultMaxMock[segment], + placeholder: defaultPlaceholderMock[segment], + }; +}; + +interface RenderSegmentReturnType { + getInput: () => HTMLInputElement; + input: HTMLInputElement; + rerenderSegment: (params: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => void; +} + +const defaultSegmentProviderProps: Partial< + InputBoxProviderProps +> = { + charsPerSegment: charsPerSegmentMock, + segmentEnum: SegmentObjMock, + onChange: () => {}, + onBlur: () => {}, + segments: { + day: '', + month: '', + year: '', + }, + segmentRefs: segmentRefsMock, +}; + +const defaultSegmentProps: InputSegmentProps = { + segment: 'day', + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + shouldWrap: true, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', +}; + +/** + * Renders the InputSegment component for testing purposes. + */ +export const renderSegment = ({ + props = {}, + providerProps = {}, +}: { + props?: Partial>; + providerProps?: Partial>; +}): RenderResult & RenderSegmentReturnType => { + const mergedProps = { + ...defaultSegmentProps, + ...props, + } as InputSegmentProps; + + const mergedProviderProps = { + ...defaultSegmentProviderProps, + ...providerProps, + } as InputBoxProviderProps; + + const utils = render( + + + , + ); + + const rerenderSegment = ({ + newProps = {}, + newProviderProps = {}, + }: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => { + utils.rerender( + + + , + ); + }; + + const getInput = () => + utils.getByTestId('lg-input-segment') as HTMLInputElement; + return { ...utils, getInput, input: getInput(), rerenderSegment }; +}; From b2984f37738b3c1c970d18e5aded769c73e9165d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 16:50:17 -0500 Subject: [PATCH 04/36] feat(input-box): add @leafygreen-ui/a11y dependency and update InputSegment component references --- packages/input-box/package.json | 1 + .../src/InputSegment/InputSegment.stories.tsx | 3 - .../src/InputSegment/InputSegment.types.ts | 1 - packages/input-box/src/testutils/index.tsx | 148 +----------------- packages/input-box/tsconfig.json | 3 + pnpm-lock.yaml | 3 + tools/install/src/ALL_PACKAGES.ts | 3 + 7 files changed, 13 insertions(+), 149 deletions(-) diff --git a/packages/input-box/package.json b/packages/input-box/package.json index 3030c6e71e..2b5ef5e3c8 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -28,6 +28,7 @@ "access": "public" }, "dependencies": { + "@leafygreen-ui/a11y": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 459f6b9d8e..b31cd67f7f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -65,9 +65,6 @@ const meta: StoryMetaType = { 'onChange', 'charsPerSegment', 'segmentEnum', - 'min', - 'max', - 'shouldWrap', 'shouldSkipValidation', 'step', 'placeholder', diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 7cbeaa34db..84f6997a39 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -15,7 +15,6 @@ export interface InputSegmentChangeEvent< }; } -// TODO: consider renaming min/max names to minSegment/maxSegment /** * The type for the onChange handler */ diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 80cee23566..4ef3b1941b 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,162 +1,20 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { Size } from '@leafygreen-ui/tokens'; - -import { InputBox, InputBoxProps } from '../InputBox'; -import { InputBoxProvider } from '../InputBoxContext'; -import { InputBoxProviderProps } from '../InputBoxContext'; -import { InputSegment } from '../InputSegment'; +import { InputBoxProvider } from '../InputBoxContext/InputBoxContext'; +import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext.types'; +import { InputSegment } from '../InputSegment/InputSegment'; import { InputSegmentProps } from '../InputSegment/InputSegment.types'; import { charsPerSegmentMock, - defaultFormatPartsMock, defaultMaxMock, defaultMinMock, defaultPlaceholderMock, SegmentObjMock, segmentRefsMock, - segmentRulesMock, - segmentsMock, - segmentWidthStyles, } from './testutils.mocks'; -export const defaultProps: Partial> = { - segments: segmentsMock, - segmentEnum: SegmentObjMock, - segmentRefs: segmentRefsMock, - setSegment: () => {}, - charsPerSegment: charsPerSegmentMock, - formatParts: defaultFormatPartsMock, - segmentRules: segmentRulesMock, -}; - -/** - * This component is used to render the InputSegment component for testing purposes. - * @param segment - The segment to render - * @returns - */ -export const InputSegmentWrapper = ({ - segment, -}: { - segment: SegmentObjMock; -}) => { - return ( - - ); -}; - -/** - * This component is used to render the InputBox component for testing purposes. - * Includes segment state management and a default renderSegment function. - * Props can override the internal state management. - */ -export const InputBoxWithState = ({ - segments: segmentsProp = { - day: '', - month: '', - year: '', - }, - setSegment: setSegmentProp, - disabled = false, - ...props -}: Partial> & { - segments?: Record; -}) => { - const dayRef = React.useRef(null); - const monthRef = React.useRef(null); - const yearRef = React.useRef(null); - - const segmentRefs = { - day: dayRef, - month: monthRef, - year: yearRef, - }; - - const [segments, setSegments] = React.useState(segmentsProp); - - const defaultSetSegment = (segment: SegmentObjMock, value: string) => { - setSegments(prev => ({ ...prev, [segment]: value })); - }; - - // If setSegment is provided, use controlled mode with the provided segments - // Otherwise, use internal state management - const effectiveSegments = setSegmentProp ? segmentsProp : segments; - const effectiveSetSegment = setSegmentProp ?? defaultSetSegment; - - return ( - - ); -}; - -interface RenderInputBoxReturnType { - dayInput: HTMLInputElement; - monthInput: HTMLInputElement; - yearInput: HTMLInputElement; - rerenderInputBox: (props: Partial>) => void; - getDayInput: () => HTMLInputElement; - getMonthInput: () => HTMLInputElement; - getYearInput: () => HTMLInputElement; -} - -/** - * Renders InputBox with internal state management for testing purposes. - * Props can be passed to override the default state behavior. - */ -export const renderInputBox = ({ - ...props -}: Partial> = {}): RenderResult & - RenderInputBoxReturnType => { - const result = render(); - - const getDayInput = () => - result.getByTestId('input-segment-day') as HTMLInputElement; - const getMonthInput = () => - result.getByTestId('input-segment-month') as HTMLInputElement; - const getYearInput = () => - result.getByTestId('input-segment-year') as HTMLInputElement; - - const rerenderInputBox = ( - newProps: Partial>, - ) => { - result.rerender(); - }; - - return { - ...result, - rerenderInputBox, - dayInput: getDayInput(), - monthInput: getMonthInput(), - yearInput: getYearInput(), - getDayInput, - getMonthInput, - getYearInput, - }; -}; - /* * InputSegment Utils */ diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json index cba2152d8f..7f78ef8970 100644 --- a/packages/input-box/tsconfig.json +++ b/packages/input-box/tsconfig.json @@ -18,6 +18,9 @@ "**/*.stories.*" ], "references": [ + { + "path": "../a11y" + }, { "path": "../emotion" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de735ab2c..bb53b8e9d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2255,6 +2255,9 @@ importers: packages/input-box: dependencies: + '@leafygreen-ui/a11y': + specifier: workspace:^ + version: link:../a11y '@leafygreen-ui/date-utils': specifier: workspace:^ version: link:../date-utils diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index 67b943e5fe..2486ff963e 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -15,6 +15,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/code', '@leafygreen-ui/code-editor', '@leafygreen-ui/combobox', + '@leafygreen-ui/compound-component', '@leafygreen-ui/confirmation-modal', '@leafygreen-ui/context-drawer', '@leafygreen-ui/copyable', @@ -25,6 +26,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/emotion', '@leafygreen-ui/empty-state', '@leafygreen-ui/expandable-card', + '@leafygreen-ui/feature-walls', '@leafygreen-ui/form-field', '@leafygreen-ui/form-footer', '@leafygreen-ui/gallery-indicator', @@ -34,6 +36,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/icon-button', '@leafygreen-ui/info-sprinkle', '@leafygreen-ui/inline-definition', + '@leafygreen-ui/input-box', '@leafygreen-ui/input-option', '@leafygreen-ui/leafygreen-provider', '@leafygreen-ui/lib', From b0d7bbaab125bda638f7b45e2be8ed32e546c439 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 17:39:41 -0500 Subject: [PATCH 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] feat(input-box): add placeholder for InputSegment types --- packages/input-box/src/InputSegment/InputSegment.types.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/input-box/src/InputSegment/InputSegment.types.ts diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts new file mode 100644 index 0000000000..e39a43f7e7 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -0,0 +1 @@ +// This file is a placeholder for the InputSegment types. From ad1f01753947230f0ea7f5a7794ec9e2e70f52ac Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 13:58:13 -0500 Subject: [PATCH 11/36] refactor(input-box): move InputSegmentChangeEventHandler import to shared types for better organization --- packages/input-box/src/InputSegment/InputSegment.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index b31cd67f7f..284b26c887 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -19,7 +19,8 @@ import { segmentsMock, } from '../testutils/testutils.mocks'; -import { InputSegment, InputSegmentChangeEventHandler } from '.'; +import { InputSegment } from '.'; +import { InputSegmentChangeEventHandler } from '../shared/types'; interface InputSegmentStoryProps { size: Size; From 81a943c1ef0ee4f32b11ca3cfb4b4352868f9e19 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 14:10:09 -0500 Subject: [PATCH 12/36] refactor(input-box): rename min and max props to minSegmentValue and maxSegmentValue for consistency --- .../src/InputSegment/InputSegment.spec.tsx | 6 ++-- .../src/InputSegment/InputSegment.stories.tsx | 4 +-- .../src/InputSegment/InputSegment.tsx | 29 +++++++++---------- .../src/InputSegment/InputSegment.types.ts | 4 +-- packages/input-box/src/testutils/index.tsx | 8 ++--- 5 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 961b14489c..f92d6859e5 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -819,14 +819,14 @@ describe('packages/input-segment', () => { }); test('With required props', () => { - ; + ; }); test('With all props', () => { = { ], args: { segment: SegmentObjMock.Day, - min: defaultMinMock[SegmentObjMock.Day], - max: defaultMaxMock[SegmentObjMock.Day], + minSegmentValue: defaultMinMock[SegmentObjMock.Day], + maxSegmentValue: defaultMaxMock[SegmentObjMock.Day], size: Size.Default, placeholder: defaultPlaceholderMock[SegmentObjMock.Day], shouldWrap: true, diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 82d30ad76a..595845f609 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -30,14 +30,13 @@ import { * Renders a single input segment with configurable * character padding, validation, and formatting. * - * @internal */ const InputSegmentWithRef = ( { segment, onKeyDown, - min, // minSegmentValue - max, // maxSegmentValue + minSegmentValue, + maxSegmentValue, className, onChange: onChangeProp, onBlur: onBlurProp, @@ -64,7 +63,7 @@ const InputSegmentWithRef = ( const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter({ charsPerSegment, - allowZero: min === 0, + allowZero: minSegmentValue === 0, }); const pattern = `[0-9]{${charsPerSegment}}`; @@ -85,8 +84,8 @@ const InputSegmentWithRef = ( currentValue: value, incomingValue: target.value, charsPerSegment, - defaultMin: min, - defaultMax: max, + defaultMin: minSegmentValue, + defaultMax: maxSegmentValue, segmentEnum, shouldSkipValidation, }); @@ -97,7 +96,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: newValue, - meta: { min }, + meta: { min: minSegmentValue }, }); } else { // If the value has not changed, ensure the input value is reset @@ -133,10 +132,10 @@ const InputSegmentWithRef = ( const newValue = getNewSegmentValueFromArrowKeyPress({ key, value, - min, - max, + min: minSegmentValue, + max: maxSegmentValue, step, - shouldWrap: shouldWrap, + shouldWrap, }); const valueString = formatter(newValue); @@ -144,7 +143,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: valueString, - meta: { key, min }, + meta: { key, min: minSegmentValue }, }); break; } @@ -160,7 +159,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: '', - meta: { key, min }, + meta: { key, min: minSegmentValue }, }); } @@ -177,7 +176,7 @@ const InputSegmentWithRef = ( onChange({ segment, value: '', - meta: { key, min }, + meta: { key, min: minSegmentValue }, }); } @@ -212,8 +211,8 @@ const InputSegmentWithRef = ( pattern={pattern} role="spinbutton" value={value} - min={min} - max={max} + min={minSegmentValue} + max={maxSegmentValue} onChange={handleChange} onBlur={handleBlur} onKeyDown={handleKeyDown} diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 88188f76a8..abec242e1a 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -23,7 +23,7 @@ export interface InputSegmentProps * 1 * 1970 */ - min: number; + minSegmentValue: number; /** * Maximum value for the segment @@ -33,7 +33,7 @@ export interface InputSegmentProps * 12 * 2038 */ - max: number; + maxSegmentValue: number; /** * The step value for the arrow keys diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 4ef3b1941b..74d2c07b62 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -22,8 +22,8 @@ export const setSegmentProps = (segment: SegmentObjMock) => { return { segment: segment, charsPerSegment: charsPerSegmentMock[segment], - min: defaultMinMock[segment], - max: defaultMaxMock[segment], + minSegmentValue: defaultMinMock[segment], + maxSegmentValue: defaultMaxMock[segment], placeholder: defaultPlaceholderMock[segment], }; }; @@ -54,8 +54,8 @@ const defaultSegmentProviderProps: Partial< const defaultSegmentProps: InputSegmentProps = { segment: 'day', - min: defaultMinMock['day'], - max: defaultMaxMock['day'], + minSegmentValue: defaultMinMock['day'], + maxSegmentValue: defaultMaxMock['day'], shouldWrap: true, placeholder: defaultPlaceholderMock['day'], // @ts-expect-error - data-testid From 8cfadbe825a4c8508c7d8c7f7264375f6ac51af6 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 14:17:05 -0500 Subject: [PATCH 13/36] refactor(input-box): simplify placeholder logic in InputSegment stories using defaultPlaceholderMock --- .../input-box/src/InputSegment/InputSegment.stories.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 7f89d93f60..2f8767c1de 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -108,11 +108,7 @@ const meta: StoryMetaType = { > From 68fc653733e61d6483325d1f207245b5777fa624 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 14:49:30 -0500 Subject: [PATCH 14/36] refactor(input-box): update InputSegment styles to use dynamic theme styles and improve organization --- .../src/InputSegment/InputSegment.spec.tsx | 2 +- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.styles.ts | 34 ++++++++----------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index f92d6859e5..e5243bb24f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; +import { InputSegmentChangeEventHandler } from '../shared/types'; import { renderSegment, setSegmentProps } from '../testutils'; import { charsPerSegmentMock, @@ -11,7 +12,6 @@ import { import { getValueFormatter } from '../utils'; import { InputSegment } from '.'; -import { InputSegmentChangeEventHandler } from '../shared/types'; describe('packages/input-segment', () => { describe('aria attributes', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 2f8767c1de..4bdd4df7b8 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -9,6 +9,7 @@ import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; import { InputBoxProvider } from '../InputBoxContext'; +import { InputSegmentChangeEventHandler } from '../shared/types'; import { charsPerSegmentMock, defaultMaxMock, @@ -20,7 +21,6 @@ import { } from '../testutils/testutils.mocks'; import { InputSegment } from '.'; -import { InputSegmentChangeEventHandler } from '../shared/types'; interface InputSegmentStoryProps { size: Size; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts index c759609a82..39f4f3da64 100644 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -1,11 +1,13 @@ import { css, cx } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; import { BaseFontSize, + color, fontFamilies, + InteractionState, Size, typeScales, + Variant, } from '@leafygreen-ui/tokens'; export const baseStyles = css` @@ -31,31 +33,23 @@ export const baseStyles = css` } `; -export const segmentThemeStyles: Record = { - [Theme.Light]: css` +export const getSegmentThemeStyles = (theme: Theme) => { + return css` background-color: transparent; - color: ${palette.black}; + color: ${color[theme].text[Variant.Primary][InteractionState.Default]}; &::placeholder { - color: ${palette.gray.light1}; + color: ${color[theme].text[Variant.Placeholder][ + InteractionState.Default + ]}; } &:focus { - background-color: ${palette.blue.light3}; + background-color: ${color[theme].background[Variant.Primary][ + InteractionState.Focus + ]}; } - `, - [Theme.Dark]: css` - background-color: transparent; - color: ${palette.gray.light2}; - - &::placeholder { - color: ${palette.gray.dark1}; - } - - &:focus { - background-color: ${palette.blue.dark3}; - } - `, + `; }; export const fontSizeStyles: Record = { @@ -96,7 +90,7 @@ export const getInputSegmentStyles = ({ return cx( baseStyles, fontSizeStyles[baseFontSize], - segmentThemeStyles[theme], + getSegmentThemeStyles(theme), segmentSizeStyles[size], className, ); From a04d5ec7608d8d50d0d0311133778fbd9326bcb0 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 15:30:21 -0500 Subject: [PATCH 15/36] feat(input-box): extend InputSegmentProps to include hours, minutes, and seconds segments --- .../input-box/src/InputSegment/InputSegment.types.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index abec242e1a..0b2dfbed59 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -12,6 +12,9 @@ export interface InputSegmentProps * 'day' * 'month' * 'year' + * 'hours' + * 'minutes' + * 'seconds' */ segment: Segment; @@ -22,6 +25,9 @@ export interface InputSegmentProps * 1 * 1 * 1970 + * 0 + * 0 + * 0 */ minSegmentValue: number; @@ -32,6 +38,9 @@ export interface InputSegmentProps * 31 * 12 * 2038 + * 23 + * 59 + * 59 */ maxSegmentValue: number; From 0101c327a3dc755d01d3295074b30d90e5376302 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 15:35:42 -0500 Subject: [PATCH 16/36] refactor(input-box): rename onChange and onBlur props in InputSegment to improve clarity --- .../input-box/src/InputSegment/InputSegment.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 595845f609..b65ab7485b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -49,8 +49,8 @@ const InputSegmentWithRef = ( ) => { const { theme } = useDarkMode(); const { - onChange, - onBlur, + onChange: onChangeContextProp, + onBlur: onBlurContextProp, charsPerSegment: charsPerSegmentContext, segmentEnum, segmentRefs, @@ -93,7 +93,7 @@ const InputSegmentWithRef = ( const hasValueChanged = newValue !== value; if (hasValueChanged) { - onChange({ + onChangeContextProp({ segment, value: newValue, meta: { min: minSegmentValue }, @@ -140,7 +140,7 @@ const InputSegmentWithRef = ( const valueString = formatter(newValue); /** Fire a custom change event when the up/down arrow keys are pressed */ - onChange({ + onChangeContextProp({ segment, value: valueString, meta: { key, min: minSegmentValue }, @@ -156,7 +156,7 @@ const InputSegmentWithRef = ( e.stopPropagation(); /** Fire a custom change event when the backspace key is pressed */ - onChange({ + onChangeContextProp({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -173,7 +173,7 @@ const InputSegmentWithRef = ( // Don't fire change event if the input is initially empty if (value) { /** Fire a custom change event when the space key is pressed */ - onChange({ + onChangeContextProp({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -192,7 +192,7 @@ const InputSegmentWithRef = ( }; const handleBlur = (e: FocusEvent) => { - onBlur?.(e); + onBlurContextProp?.(e); onBlurProp?.(e); }; From 662f2ddc2a98929529e5fac82146592cb8fae704 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 10 Nov 2025 15:52:59 -0500 Subject: [PATCH 17/36] refactor(input-box): rename shouldSkipValidation prop to shouldValidate for clarity and consistency --- .../src/InputSegment/InputSegment.spec.tsx | 14 +++++++------- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../input-box/src/InputSegment/InputSegment.tsx | 4 ++-- .../src/InputSegment/InputSegment.types.ts | 8 ++++---- .../getNewSegmentValueFromInputValue.spec.ts | 8 ++++---- .../getNewSegmentValueFromInputValue.ts | 9 +++++---- 6 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index e5243bb24f..67b99d3249 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -644,7 +644,7 @@ describe('packages/input-segment', () => { ); }); - test('allows values above max range when skipValidation is true', () => { + test('allows values above max range when shouldValidate is false', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string @@ -653,7 +653,7 @@ describe('packages/input-segment', () => { const { input } = renderSegment({ props: { ...setSegmentProps('year'), - shouldSkipValidation: true, + shouldValidate: false, }, providerProps: { segments: { day: '', month: '', year: '203' }, @@ -753,13 +753,13 @@ describe('packages/input-segment', () => { }); describe('shouldSkipValidation prop', () => { - test('allows values outside min/max range when shouldSkipValidation is true', () => { + test('allows values outside min/max range when shouldValidate is false', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { segment: 'day', shouldSkipValidation: true }, + props: { segment: 'day', shouldValidate: false }, providerProps: { onChange: onChangeHandler, segments: { day: '9', month: '', year: '' }, @@ -773,13 +773,13 @@ describe('packages/input-segment', () => { ); }); - test('does not allows values outside min/max range when shouldSkipValidation is false', () => { + test('does not allows values outside min/max range when shouldValidate is true', () => { const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { segment: 'day', shouldSkipValidation: false }, + props: { segment: 'day', shouldValidate: true }, providerProps: { onChange: onChangeHandler, segments: { day: '9', month: '', year: '' }, @@ -829,7 +829,7 @@ describe('packages/input-segment', () => { maxSegmentValue={31} step={1} shouldWrap={true} - shouldSkipValidation={false} + shouldValidate={true} placeholder="12" className="test" onBlur={() => {}} diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 4bdd4df7b8..2c1e404c28 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -66,7 +66,7 @@ const meta: StoryMetaType = { 'onChange', 'charsPerSegment', 'segmentEnum', - 'shouldSkipValidation', + 'shouldValidate', 'step', 'placeholder', ], diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index b65ab7485b..ef48604ff9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -42,7 +42,7 @@ const InputSegmentWithRef = ( onBlur: onBlurProp, step = 1, shouldWrap = true, - shouldSkipValidation = false, + shouldValidate = true, ...rest }: InputSegmentProps, fwdRef: ForwardedRef, @@ -87,7 +87,7 @@ const InputSegmentWithRef = ( defaultMin: minSegmentValue, defaultMax: maxSegmentValue, segmentEnum, - shouldSkipValidation, + shouldValidate, }); const hasValueChanged = newValue !== value; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 0b2dfbed59..672e2dc811 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -52,18 +52,18 @@ export interface InputSegmentProps step?: number; /** - * Whether the segment should wrap at min/max boundaries + * Whether the segment should wrap at max boundaries when using the up arrow key. * * @default true */ shouldWrap?: boolean; /** - * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * Whether the segment should validate. Skipping validation is useful for segments that allow values outside of the default range. * - * @default false + * @default true */ - shouldSkipValidation?: boolean; + shouldValidate?: boolean; } /** diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index b6645ed8f2..c1a37e1153 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -196,7 +196,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { expect(newValue).toEqual(`00`); }); - test('accepts 00 as input when shouldSkipValidation is true and value is less than defaultMin', () => { + test('accepts 00 as input when shouldValidate is false and value is less than defaultMin', () => { const newValue = getNewSegmentValueFromInputValue({ segmentName: 'day', currentValue: '0', @@ -205,7 +205,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin: 1, defaultMax: 15, segmentEnum: segmentObj, - shouldSkipValidation: true, + shouldValidate: false, }); expect(newValue).toEqual(`00`); }); @@ -239,7 +239,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { expect(newValue).toEqual('2024'); }); - test('truncates from start when shouldSkipValidation is true and value exceeds charsPerSegment', () => { + test('truncates from start when shouldValidate is false and value exceeds charsPerSegment', () => { const newValue = getNewSegmentValueFromInputValue({ segmentName: 'year', currentValue: '000', @@ -248,7 +248,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { defaultMin: 1970, defaultMax: 2099, segmentEnum: segmentObj, - shouldSkipValidation: true, + shouldValidate: false, }); expect(newValue).toEqual('0001'); }); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index f8b8398407..66178da60f 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -15,7 +15,7 @@ interface GetNewSegmentValueFromInputValue< defaultMin: number; defaultMax: number; segmentEnum: Readonly>; - shouldSkipValidation?: boolean; + shouldValidate?: boolean; } /** @@ -33,7 +33,7 @@ interface GetNewSegmentValueFromInputValue< * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment * @param segmentEnum - The segment enum/object containing the segment names and their corresponding values to validate against - * @param shouldSkipValidation - Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * @param shouldValidate - Whether the segment should validate. Skipping validation is useful for segments that allow values outside of the default range. * @returns The new value for the segment * @example * // The segmentEnum is the object that contains the segment names and their corresponding values @@ -78,7 +78,7 @@ interface GetNewSegmentValueFromInputValue< * defaultMin: 1970, * defaultMax: 2038, * segmentEnum, - * shouldSkipValidation: true, + * shouldValidate: false, * }); // '000' * * * getNewSegmentValueFromInputValue({ * segmentName: 'minute', @@ -101,12 +101,13 @@ export const getNewSegmentValueFromInputValue = < defaultMin, defaultMax, segmentEnum, - shouldSkipValidation = false, + shouldValidate = true, }: GetNewSegmentValueFromInputValue): Value => { // If the incoming value is not a valid number const isIncomingValueNumber = !isNaN(Number(incomingValue)); // macOS adds a period when pressing SPACE twice inside a text input. const doesIncomingValueContainPeriod = /\./.test(incomingValue); + const shouldSkipValidation = !shouldValidate; // if the current value is "full", do not allow any additional characters to be entered const wouldCauseOverflow = From 967b33bdab900d8e0e12670bf56d881a7552383d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 13:08:21 -0500 Subject: [PATCH 18/36] refactor(input-box): reorganize imports in testutils for better clarity and structure --- packages/input-box/src/testutils/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 74d2c07b62..063f529ab5 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { InputBoxProvider } from '../InputBoxContext/InputBoxContext'; -import { InputBoxProviderProps } from '../InputBoxContext/InputBoxContext.types'; -import { InputSegment } from '../InputSegment/InputSegment'; -import { InputSegmentProps } from '../InputSegment/InputSegment.types'; +import { + InputBoxProvider, + type InputBoxProviderProps, +} from '../InputBoxContext'; +import { InputSegment, type InputSegmentProps } from '../InputSegment'; import { charsPerSegmentMock, From a589e9407b0490a073a876d3cb06d8eee829724a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 13:12:17 -0500 Subject: [PATCH 19/36] refactor(input-box): remove deprecated InputSegment types and update imports in InputBoxContext --- .../InputBoxContext/InputBoxContext.types.ts | 2 +- .../types/types.ts => shared.types.ts} | 28 +++++++++++++++++++ .../src/shared/types/InputSegment.types.ts | 22 --------------- packages/input-box/src/shared/types/index.ts | 5 ---- 4 files changed, 29 insertions(+), 28 deletions(-) rename packages/input-box/src/{shared/types/types.ts => shared.types.ts} (65%) delete mode 100644 packages/input-box/src/shared/types/InputSegment.types.ts delete mode 100644 packages/input-box/src/shared/types/index.ts diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts index 3752b6fbdb..0834e83062 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts @@ -1,7 +1,7 @@ import { InputSegmentChangeEventHandler, SharedInputBoxTypes, -} from '../shared/types'; +} from '../shared.types'; export interface InputBoxContextType extends SharedInputBoxTypes { diff --git a/packages/input-box/src/shared/types/types.ts b/packages/input-box/src/shared.types.ts similarity index 65% rename from packages/input-box/src/shared/types/types.ts rename to packages/input-box/src/shared.types.ts index 1592dd7351..87e6e31264 100644 --- a/packages/input-box/src/shared/types/types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,5 +1,33 @@ +import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; +/** + * Shared Input Segment Change Event + */ +export interface InputSegmentChangeEvent< + Segment extends string, + Value extends string, +> { + segment: Segment; + value: Value; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + min: number; + [key: string]: any; + }; +} + +/** + * The type for the onChange handler + */ +export type InputSegmentChangeEventHandler< + Segment extends string, + Value extends string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +/** + * Shared Input Box Types + */ export interface SharedInputBoxTypes { /** * The number of characters per segment diff --git a/packages/input-box/src/shared/types/InputSegment.types.ts b/packages/input-box/src/shared/types/InputSegment.types.ts deleted file mode 100644 index 9d0d5b1e8e..0000000000 --- a/packages/input-box/src/shared/types/InputSegment.types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { keyMap } from '@leafygreen-ui/lib'; - -export interface InputSegmentChangeEvent< - Segment extends string, - Value extends string, -> { - segment: Segment; - value: Value; - meta?: { - key?: (typeof keyMap)[keyof typeof keyMap]; - min: number; - [key: string]: any; - }; -} - -/** - * The type for the onChange handler - */ -export type InputSegmentChangeEventHandler< - Segment extends string, - Value extends string, -> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; diff --git a/packages/input-box/src/shared/types/index.ts b/packages/input-box/src/shared/types/index.ts deleted file mode 100644 index ed97d5fc1d..0000000000 --- a/packages/input-box/src/shared/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { - InputSegmentChangeEvent, - InputSegmentChangeEventHandler, -} from './InputSegment.types'; -export type { SharedInputBoxTypes } from './types'; From e8a37052b1af45bd00d56a6a57693e0c54fa6fac Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 13:38:56 -0500 Subject: [PATCH 20/36] refactor(input-box): update InputSegmentChangeEventHandler import to use type alias from shared.types --- packages/input-box/src/InputSegment/InputSegment.spec.tsx | 2 +- packages/input-box/src/InputSegment/InputSegment.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 67b99d3249..5f0efa66c9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { InputSegmentChangeEventHandler } from '../shared/types'; +import { type InputSegmentChangeEventHandler } from '../shared.types'; import { renderSegment, setSegmentProps } from '../testutils'; import { charsPerSegmentMock, diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 2c1e404c28..1a690beaa0 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -9,7 +9,7 @@ import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; import { InputBoxProvider } from '../InputBoxContext'; -import { InputSegmentChangeEventHandler } from '../shared/types'; +import { type InputSegmentChangeEventHandler } from '../shared.types'; import { charsPerSegmentMock, defaultMaxMock, From 4cf138e4cc60aadd7d251edf51a0e54eb97b947c Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 14:28:25 -0500 Subject: [PATCH 21/36] refactor(input-box): enhance InputSegment types and documentation, adding isInputSegment utility and improving component descriptions --- .../src/InputSegment/InputSegment.tsx | 37 +++++++++++++++---- .../src/InputSegment/InputSegment.types.ts | 25 ------------- packages/input-box/src/shared.types.ts | 29 +++++++++++++++ 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index ef48604ff9..bc0802b07c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -24,13 +24,6 @@ import { InputSegmentProps, } from './InputSegment.types'; -/** - * Generic controlled input segment component - * - * Renders a single input segment with configurable - * character padding, validation, and formatting. - * - */ const InputSegmentWithRef = ( { segment, @@ -232,6 +225,36 @@ const InputSegmentWithRef = ( ); }; +/** + * Generic controlled input segment component to be used within the InputBox component. + * + * This component renders a single input segment from an array of format parts (typically `Intl.DateTimeFormatPart`) + * passed to the InputBox component. It is designed primarily for date and time input segments, where each segment + * represents a distinct part of the date/time format (e.g., month, day, year, hour, minute). + * + * Each segment is configurable with character padding, validation, and formatting rules. + * + * @example + * // Used internally by InputBox to render segments from formatParts: + * + * // Date format: + * // [ + * // { type: 'month', value: '02' }, + * // { type: 'literal', value: '-' }, + * // { type: 'day', value: '02' }, + * // { type: 'literal', value: '-' }, + * // { type: 'year', value: '2025' }, + * // ] + * + * // Time format: + * // [ + * // { type: 'hour', value: '14' }, + * // { type: 'literal', value: ':' }, + * // { type: 'minute', value: '30' }, + * // { type: 'literal', value: ':' }, + * // { type: 'second', value: '45' }, + * // ] + */ export const InputSegment = React.forwardRef( InputSegmentWithRef, ) as InputSegmentComponentType; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 672e2dc811..9610dc78dd 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -81,28 +81,3 @@ export interface InputSegmentComponentType { ): ReactElement | null; displayName?: string; } - -/** - * Returns whether the given string is a valid segment - */ -export function isInputSegment>( - str: any, - segmentObj: T, -): str is T[keyof T] { - if (typeof str !== 'string') return false; - return Object.values(segmentObj).includes(str); -} - -/** - * Base props for custom segment components passed to InputBox. - * - * Extend this interface to define props for custom segment implementations. - * InputBox will provide additional props internally (e.g., onChange, value, min, max). - */ -export interface InputSegmentComponentProps - extends Omit< - React.ComponentPropsWithoutRef<'input'>, - 'onChange' | 'value' | 'min' | 'max' - > { - segment: Segment; -} diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 87e6e31264..9425849565 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,6 +1,10 @@ import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; +/** + * SharedInput Segment Types + */ + /** * Shared Input Segment Change Event */ @@ -25,6 +29,31 @@ export type InputSegmentChangeEventHandler< Value extends string, > = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; +/** + * Returns whether the given string is a valid segment + */ +export function isInputSegment>( + segment: unknown, + segmentObj: T, +): segment is T[keyof T] { + if (typeof segment !== 'string') return false; + return Object.values(segmentObj).includes(segment); +} + +/** + * Base props for custom segment components passed to InputBox. + * + * Extend this interface to define props for custom segment implementations. + * InputBox will provide additional props internally (e.g., onChange, value, min, max). + */ +export interface InputSegmentComponentProps + extends Omit< + React.ComponentPropsWithoutRef<'input'>, + 'onChange' | 'value' | 'min' | 'max' + > { + segment: Segment; +} + /** * Shared Input Box Types */ From a7062e2be9368a04a7325619b5b87496870b4025 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 16:09:56 -0500 Subject: [PATCH 22/36] refactor(input-box): streamline InputSegment exports by removing unused types --- packages/input-box/src/InputSegment/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts index 15d4eb10d8..1def69f1c7 100644 --- a/packages/input-box/src/InputSegment/index.ts +++ b/packages/input-box/src/InputSegment/index.ts @@ -1,5 +1,2 @@ export { InputSegment } from './InputSegment'; -export { - type InputSegmentComponentProps, - type InputSegmentProps, -} from './InputSegment.types'; +export { type InputSegmentProps } from './InputSegment.types'; From dd132ea8c37432a8b024c94d7eacaae8f7c57a53 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Tue, 11 Nov 2025 16:14:46 -0500 Subject: [PATCH 23/36] test(input-box): add accessibility test for InputSegment to ensure no violations when tooltip is closed --- .../input-box/src/InputSegment/InputSegment.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 5f0efa66c9..cae730b424 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; import { type InputSegmentChangeEventHandler } from '../shared.types'; import { renderSegment, setSegmentProps } from '../testutils'; @@ -15,6 +16,14 @@ import { InputSegment } from '.'; describe('packages/input-segment', () => { describe('aria attributes', () => { + test('does not have basic accessibility issues when tooltip is not open', async () => { + const { container } = renderSegment({ + props: { segment: 'day' }, + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + test(`segment has aria-label`, () => { const { input } = renderSegment({ props: { segment: 'day' }, From 0e9b9bdf7ec3ae1565677d97af6d93936b518c81 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 10:14:47 -0500 Subject: [PATCH 24/36] refactor(input-box): update InputSegment to remove size prop and enhance type definitions for better clarity --- .../InputBoxContext/InputBoxContext.spec.tsx | 4 - .../src/InputBoxContext/InputBoxContext.tsx | 5 +- .../src/InputSegment/InputSegment.spec.tsx | 443 ++++++++---------- .../src/InputSegment/InputSegment.stories.tsx | 98 ++-- .../src/InputSegment/InputSegment.tsx | 48 +- .../src/InputSegment/InputSegment.types.ts | 60 ++- packages/input-box/src/shared.types.ts | 40 +- packages/input-box/src/testutils/index.tsx | 77 +-- .../src/testutils/testutils.mocks.ts | 2 + 9 files changed, 346 insertions(+), 431 deletions(-) diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index 6f2b22a0db..31007d8db1 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; -import { Size } from '@leafygreen-ui/tokens'; import { charsPerSegmentMock, @@ -48,7 +47,6 @@ describe('InputBoxContext', () => { onBlur={mockOnBlur} segmentRefs={segmentRefsMock} segments={segmentsMock} - size={Size.Default} disabled={false} > {children} @@ -63,7 +61,6 @@ describe('InputBoxContext', () => { onBlur, segmentRefs, segments, - size, disabled, } = result.current; @@ -73,7 +70,6 @@ describe('InputBoxContext', () => { expect(onBlur).toBe(mockOnBlur); expect(segmentRefs).toBe(segmentRefsMock); expect(segments).toBe(segmentsMock); - expect(size).toBe(Size.Default); expect(disabled).toBe(false); }); }); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index db487b6a7a..4279a3e3fe 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -1,3 +1,5 @@ +// TODO: NO LONGER NEEDED + import React, { createContext, PropsWithChildren, @@ -24,7 +26,6 @@ export const InputBoxProvider = ({ segments, segmentEnum, segmentRefs, - size, }: PropsWithChildren>) => { const value = useMemo( () => ({ @@ -37,7 +38,6 @@ export const InputBoxProvider = ({ segments, segmentEnum, segmentRefs, - size, }), [ charsPerSegment, @@ -49,7 +49,6 @@ export const InputBoxProvider = ({ segments, segmentEnum, segmentRefs, - size, ], ); diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index cae730b424..f9888c6dc8 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { type InputSegmentChangeEventHandler } from '../shared.types'; -import { renderSegment, setSegmentProps } from '../testutils'; +import { renderSegment } from '../testutils'; import { charsPerSegmentMock, defaultMaxMock, @@ -18,7 +18,7 @@ describe('packages/input-segment', () => { describe('aria attributes', () => { test('does not have basic accessibility issues when tooltip is not open', async () => { const { container } = renderSegment({ - props: { segment: 'day' }, + segment: 'day', }); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -26,7 +26,7 @@ describe('packages/input-segment', () => { test(`segment has aria-label`, () => { const { input } = renderSegment({ - props: { segment: 'day' }, + segment: 'day', }); expect(input).toHaveAttribute('aria-label', 'day'); }); @@ -38,7 +38,9 @@ describe('packages/input-segment', () => { test('has min and max attributes', () => { const { input } = renderSegment({ - props: { segment: 'day' }, + segment: 'day', + minSegmentValue: defaultMinMock['day'], + maxSegmentValue: defaultMaxMock['day'], }); expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); @@ -47,24 +49,29 @@ describe('packages/input-segment', () => { describe('rendering', () => { test('Rendering with undefined sets the value to empty string', () => { - const { input } = renderSegment({}); + const { input } = renderSegment({ + segment: 'day', + value: '', + }); expect(input.value).toBe(''); }); test('Rendering with a value sets the input value', () => { const { input } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, + segment: 'day', + value: '12', }); expect(input.value).toBe('12'); }); test('rerendering updates the value', () => { const { getInput, rerenderSegment } = renderSegment({ - providerProps: { segments: { day: '12', month: '', year: '' } }, + segment: 'day', + value: '12', }); rerenderSegment({ - newProviderProps: { segments: { day: '08', month: '', year: '' } }, + value: '08', }); expect(getInput().value).toBe('08'); }); @@ -78,7 +85,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '8'); @@ -93,7 +101,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '0'); @@ -108,7 +117,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, 'aB$/'); expect(onChangeHandler).not.toHaveBeenCalled(); @@ -122,10 +132,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - segments: { day: '2', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + value: '2', + onChange: onChangeHandler, }); userEvent.type(input, '6'); @@ -140,10 +149,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - segments: { day: '26', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + value: '26', + maxSegmentValue: 31, + onChange: onChangeHandler, }); userEvent.type(input, '4'); @@ -167,11 +176,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowup}'); @@ -188,11 +195,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day', step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + step: 2, + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowup}'); @@ -209,17 +215,17 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '', + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMinMock['day']), + value: formatter(0), }), ); }); @@ -230,21 +236,17 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(31), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMinMock['day']), + value: formatter(0), }), ); }); @@ -255,44 +257,48 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMaxMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + shouldWrap: false, + onChange: onChangeHandler, + value: formatter(31), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMaxMock['day'] + 1), + value: formatter(31 + 1), }), ); }); test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const formatter = getValueFormatter({ + charsPerSegment: 4, + allowZero: false, + }); + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldWrap: false, - }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '0', month: '', year: '3' }, - }, + segment: 'year', + minSegmentValue: 1970, + maxSegmentValue: 2038, + charsPerSegment: 4, + shouldWrap: false, + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'year', value: '0004' }), + expect.objectContaining({ + segment: 'year', + value: formatter(3 + 1), + }), ); }); @@ -302,11 +308,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '06', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '06', }); userEvent.type(input, '{arrowup}'); @@ -321,16 +325,14 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '3', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowup}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '04' }), + expect.objectContaining({ value: formatter(3 + 1) }), ); }); }); @@ -342,16 +344,15 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(14), + value: formatter(15 - 1), }), ); }); @@ -362,17 +363,16 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { step: 2 }, - providerProps: { - onChange: onChangeHandler, - segments: { day: formatter(15), month: '', year: '' }, - }, + segment: 'day', + step: 2, + onChange: onChangeHandler, + value: formatter(15), }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(13), + value: formatter(15 - 2), }), ); }); @@ -383,14 +383,16 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMaxMock['day']), + value: formatter(31), }), ); }); @@ -401,68 +403,71 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + onChange: onChangeHandler, + value: formatter(0), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMaxMock['day']), + value: formatter(31), }), ); }); - test('does not wrap if `shouldWrap` is false', () => { + /* eslint-disable jest/no-disabled-tests */ + test.skip('does not wrap if `shouldWrap` is false', () => { + // TODO: this should not wrap the min value const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { shouldWrap: false }, - providerProps: { - onChange: onChangeHandler, - segments: { - day: formatter(defaultMinMock['day']), - month: '', - year: '', - }, - }, + segment: 'day', + shouldWrap: false, + onChange: onChangeHandler, + value: formatter(0), + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ - value: formatter(defaultMinMock['day'] - 1), + value: formatter(0 - 1), }), ); }); test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const formatter = getValueFormatter({ + charsPerSegment: 4, + allowZero: false, + }); + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< SegmentObjMock, string >; const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldWrap: false, - }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '0', month: '', year: '3' }, - }, + segment: 'year', + minSegmentValue: 1970, + maxSegmentValue: 2038, + charsPerSegment: 4, + shouldWrap: false, + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ segment: 'year', value: '0002' }), + expect.objectContaining({ + segment: 'year', + value: formatter(3 - 1), + }), ); }); @@ -472,11 +477,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '06', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '06', }); userEvent.type(input, '{arrowdown}'); @@ -491,16 +494,14 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day' }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '3', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '3', }); userEvent.type(input, '{arrowdown}'); expect(onChangeHandler).toHaveBeenCalledWith( - expect.objectContaining({ value: '02' }), + expect.objectContaining({ value: formatter(3 - 1) }), ); }); }); @@ -512,10 +513,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '12', }); userEvent.type(input, '{backspace}'); @@ -530,7 +530,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '{backspace}'); @@ -548,7 +549,8 @@ describe('packages/input-segment', () => { >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '{space}'); @@ -562,10 +564,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '12', }); userEvent.type(input, '{space}'); @@ -583,7 +584,8 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { onChange: onChangeHandler }, + segment: 'day', + onChange: onChangeHandler, }); userEvent.type(input, '{space}{space}'); @@ -597,10 +599,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { - onChange: onChangeHandler, - segments: { day: '12', month: '', year: '' }, - }, + segment: 'day', + onChange: onChangeHandler, + value: '12', }); userEvent.type(input, '{space}{space}'); @@ -619,15 +620,15 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - // max is 31 const { input } = renderSegment({ - providerProps: { - segments: { day: '3', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'day', + onChange: onChangeHandler, + value: '3', + maxSegmentValue: 31, + minSegmentValue: 0, }); userEvent.type(input, '2'); - // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ value: '2' }), ); @@ -640,14 +641,14 @@ describe('packages/input-segment', () => { >; // min is 1. We allow values below min range. const { input } = renderSegment({ - props: { ...setSegmentProps('month') }, - providerProps: { - segments: { day: '', month: '', year: '' }, - onChange: onChangeHandler, - }, + segment: 'month', + minSegmentValue: 1, + maxSegmentValue: 12, + onChange: onChangeHandler, + value: '', }); userEvent.type(input, '0'); - // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ value: '0' }), ); @@ -658,19 +659,17 @@ describe('packages/input-segment', () => { SegmentObjMock, string >; - // max is 2038 + const { input } = renderSegment({ - props: { - ...setSegmentProps('year'), - shouldValidate: false, - }, - providerProps: { - segments: { day: '', month: '', year: '203' }, - onChange: onChangeHandler, - }, + segment: 'year', + charsPerSegment: 4, + maxSegmentValue: 2038, + shouldValidate: false, + onChange: onChangeHandler, + value: '203', }); userEvent.type(input, '9'); - // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( expect.objectContaining({ value: '2039' }), ); @@ -679,10 +678,11 @@ describe('packages/input-segment', () => { }); describe('onBlur handler', () => { - test('calls the custom onBlur prop when provided', () => { + test('calls the onBlur handler when the input is blurred', () => { const onBlurHandler = jest.fn(); const { input } = renderSegment({ - props: { onBlur: onBlurHandler }, + segment: 'day', + onBlur: onBlurHandler, }); input.focus(); @@ -690,57 +690,27 @@ describe('packages/input-segment', () => { expect(onBlurHandler).toHaveBeenCalled(); }); - - test('calls both context and prop onBlur handlers', () => { - const contextOnBlur = jest.fn(); - const propOnBlur = jest.fn(); - const { input } = renderSegment({ - props: { onBlur: propOnBlur }, - providerProps: { onBlur: contextOnBlur }, - }); - - input.focus(); - input.blur(); - - expect(contextOnBlur).toHaveBeenCalled(); - expect(propOnBlur).toHaveBeenCalled(); - }); }); - describe('custom onKeyDown handler', () => { - test('calls the custom onKeyDown prop when provided', () => { + describe('onKeyDown handler', () => { + test('calls the onKeyDown handler when a key is pressed', () => { const onKeyDownHandler = jest.fn(); const { input } = renderSegment({ - props: { onKeyDown: onKeyDownHandler }, + segment: 'day', + onKeyDown: onKeyDownHandler, }); userEvent.type(input, '5'); expect(onKeyDownHandler).toHaveBeenCalled(); }); - - test('custom onKeyDown is called alongside internal handler', () => { - const onKeyDownHandler = jest.fn(); - const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const { input } = renderSegment({ - props: { onKeyDown: onKeyDownHandler }, - providerProps: { onChange: onChangeHandler }, - }); - - userEvent.type(input, '{arrowup}'); - - expect(onKeyDownHandler).toHaveBeenCalled(); - expect(onChangeHandler).toHaveBeenCalled(); - }); }); describe('disabled state', () => { test('input is disabled when disabled context prop is true', () => { const { input } = renderSegment({ - providerProps: { disabled: true }, + segment: 'day', + disabled: true, }); expect(input).toBeDisabled(); @@ -752,7 +722,9 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - providerProps: { disabled: true, onChange: onChangeHandler }, + segment: 'day', + disabled: true, + onChange: onChangeHandler, }); userEvent.type(input, '5'); @@ -768,11 +740,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day', shouldValidate: false }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '9', month: '', year: '' }, - }, + segment: 'day', + shouldValidate: false, + onChange: onChangeHandler, + value: '9', }); userEvent.type(input, '9'); @@ -788,11 +759,10 @@ describe('packages/input-segment', () => { string >; const { input } = renderSegment({ - props: { segment: 'day', shouldValidate: true }, - providerProps: { - onChange: onChangeHandler, - segments: { day: '9', month: '', year: '' }, - }, + segment: 'day', + shouldValidate: true, + onChange: onChangeHandler, + value: '9', }); userEvent.type(input, '9'); @@ -801,25 +771,6 @@ describe('packages/input-segment', () => { }); }); - describe('custom onChange prop', () => { - test('calls prop-level onChange in addition to context onChange', () => { - const contextOnChange = jest.fn() as InputSegmentChangeEventHandler< - SegmentObjMock, - string - >; - const propOnChange = jest.fn(); - const { input } = renderSegment({ - props: { onChange: propOnChange }, - providerProps: { onChange: contextOnChange }, - }); - - userEvent.type(input, '5'); - - expect(contextOnChange).toHaveBeenCalled(); - expect(propOnChange).toHaveBeenCalled(); - }); - }); - /* eslint-disable jest/no-disabled-tests */ describe.skip('types behave as expected', () => { test('InputSegment throws error when no required props are provided', () => { @@ -828,7 +779,19 @@ describe('packages/input-segment', () => { }); test('With required props', () => { - ; + {}} + onBlur={() => {}} + onKeyDown={() => {}} + disabled={false} + size={'default'} + segmentEnum={SegmentObjMock} + />; }); test('With all props', () => { @@ -836,14 +799,14 @@ describe('packages/input-segment', () => { segment="day" minSegmentValue={1} maxSegmentValue={31} - step={1} - shouldWrap={true} - shouldValidate={true} - placeholder="12" - className="test" + value="" + charsPerSegment={2} + onChange={() => {}} onBlur={() => {}} onKeyDown={() => {}} disabled={false} + size={'default'} + segmentEnum={SegmentObjMock} data-testid="test-id" id="day" ref={React.createRef()} diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 1a690beaa0..af5263341b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -8,23 +8,19 @@ import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; -import { InputBoxProvider } from '../InputBoxContext'; -import { type InputSegmentChangeEventHandler } from '../shared.types'; import { charsPerSegmentMock, defaultMaxMock, defaultMinMock, defaultPlaceholderMock, + InputSegmentValueMock, SegmentObjMock, - segmentRefsMock, - segmentsMock, } from '../testutils/testutils.mocks'; import { InputSegment } from '.'; interface InputSegmentStoryProps { - size: Size; - segments: Record; + darkMode: boolean; } const meta: StoryMetaType = { @@ -46,6 +42,7 @@ const meta: StoryMetaType = { shouldWrap: true, step: 1, darkMode: false, + charsPerSegment: charsPerSegmentMock[SegmentObjMock.Day], }, argTypes: { size: { @@ -74,44 +71,27 @@ const meta: StoryMetaType = { generate: { combineArgs: { darkMode: [false, true], - segment: ['day', 'month', 'year'], + segment: ['day', 'year'], size: Object.values(Size), - segments: [ - { - day: '2', - month: '8', - year: '2025', - }, - { - day: '00', - month: '0', - year: '0000', - }, - { - day: '', - month: '', - year: '', - }, - ], + value: ['', '2', '0', '00', '2025', '0000'], }, + excludeCombinations: [ + { + value: ['2', '0', '00'], + segment: 'year', + }, + { + value: ['2025', '0000'], + segment: ['day'], + }, + ], decorator: (StoryFn, context) => ( - {}} - onBlur={() => {}} - segmentRefs={segmentRefsMock} - segments={context?.args.segments} - size={context?.args.size} - disabled={false} - > - - + ), }, @@ -119,32 +99,22 @@ const meta: StoryMetaType = { }; export default meta; -export const LiveExample: StoryFn = ( - props, - context: any, -) => { - const [segments, setSegments] = useState(segmentsMock); - - const handleChange: InputSegmentChangeEventHandler< - SegmentObjMock, - string - > = ({ segment, value }) => { - setSegments(prev => ({ ...prev, [segment]: value })); - }; +export const LiveExample: StoryFn = ({ + // @ts-ignore - darkMode is not a valid prop for InputSegment + darkMode: _darkMode, + ...rest +}) => { + const [value, setValue] = useState(''); return ( - {}} - segmentRefs={segmentRefsMock} - segments={segments} - disabled={false} - size={context?.args?.size || Size.Default} - > - - + { + setValue(value); + }} + /> ); }; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index bc0802b07c..dd65082200 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -6,12 +6,10 @@ import React, { } from 'react'; import { VisuallyHidden } from '@leafygreen-ui/a11y'; -import { useMergeRefs } from '@leafygreen-ui/hooks'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; -import { useInputBoxContext } from '../InputBoxContext'; import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, @@ -24,46 +22,35 @@ import { InputSegmentProps, } from './InputSegment.types'; -const InputSegmentWithRef = ( +const InputSegmentWithRef = ( { segment, onKeyDown, minSegmentValue, maxSegmentValue, className, - onChange: onChangeProp, - onBlur: onBlurProp, + onChange, + onBlur, + segmentEnum, + size, + disabled, + value, + charsPerSegment, step = 1, shouldWrap = true, shouldValidate = true, ...rest - }: InputSegmentProps, + }: InputSegmentProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); - const { - onChange: onChangeContextProp, - onBlur: onBlurContextProp, - charsPerSegment: charsPerSegmentContext, - segmentEnum, - segmentRefs, - segments, - labelledBy, - size, - disabled, - } = useInputBoxContext(); const baseFontSize = useUpdatedBaseFontSize(); - const charsPerSegment = charsPerSegmentContext[segment]; const formatter = getValueFormatter({ charsPerSegment, allowZero: minSegmentValue === 0, }); const pattern = `[0-9]{${charsPerSegment}}`; - const segmentRef = segmentRefs[segment]; - const mergedRef = useMergeRefs([fwdRef, segmentRef]); - const value = segments[segment]; - /** * Receives native input events, * determines whether the input value is valid and should change, @@ -86,7 +73,7 @@ const InputSegmentWithRef = ( const hasValueChanged = newValue !== value; if (hasValueChanged) { - onChangeContextProp({ + onChange({ segment, value: newValue, meta: { min: minSegmentValue }, @@ -95,8 +82,6 @@ const InputSegmentWithRef = ( // If the value has not changed, ensure the input value is reset target.value = value; } - - onChangeProp?.(e); }; /** Handle keydown presses that don't natively fire a change event */ @@ -133,7 +118,7 @@ const InputSegmentWithRef = ( const valueString = formatter(newValue); /** Fire a custom change event when the up/down arrow keys are pressed */ - onChangeContextProp({ + onChange({ segment, value: valueString, meta: { key, min: minSegmentValue }, @@ -149,7 +134,7 @@ const InputSegmentWithRef = ( e.stopPropagation(); /** Fire a custom change event when the backspace key is pressed */ - onChangeContextProp({ + onChange({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -166,7 +151,7 @@ const InputSegmentWithRef = ( // Don't fire change event if the input is initially empty if (value) { /** Fire a custom change event when the space key is pressed */ - onChangeContextProp({ + onChange({ segment, value: '', meta: { key, min: minSegmentValue }, @@ -185,8 +170,7 @@ const InputSegmentWithRef = ( }; const handleBlur = (e: FocusEvent) => { - onBlurContextProp?.(e); - onBlurProp?.(e); + onBlur?.(e); }; // Note: Using a text input with pattern attribute due to Firefox @@ -196,10 +180,10 @@ const InputSegmentWithRef = ( <> - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' - > { - /** - * Which segment this input represents - * - * @example - * 'day' - * 'month' - * 'year' - * 'hours' - * 'minutes' - * 'seconds' - */ - segment: Segment; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputSegmentComponentProps } from '../shared.types'; +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' | 'onBlur' | 'onChange' + >, + Omit, 'segments'> { /** * Minimum value for the segment * @@ -64,6 +56,36 @@ export interface InputSegmentProps * @default true */ shouldValidate?: boolean; + + /** + * The value of the segment + * + * @example + * '1' + * '2' + * '2025' + */ + value: Value; + + /** + * The number of characters per segment + * + * @example + * 2 + * 2 + * 4 + */ + charsPerSegment: number; + + /** + * The size of the input box + * + * @example + * Size.Default + * Size.Small + * Size.Large + */ + size: Size; } /** @@ -75,8 +97,8 @@ export interface InputSegmentProps * @see https://stackoverflow.com/a/58473012 */ export interface InputSegmentComponentType { - ( - props: InputSegmentProps, + ( + props: InputSegmentProps, ref: ForwardedRef, ): ReactElement | null; displayName?: string; diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 9425849565..7114ba3f89 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,5 +1,4 @@ import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; /** * SharedInput Segment Types @@ -44,14 +43,27 @@ export function isInputSegment>( * Base props for custom segment components passed to InputBox. * * Extend this interface to define props for custom segment implementations. - * InputBox will provide additional props internally (e.g., onChange, value, min, max). */ export interface InputSegmentComponentProps extends Omit< - React.ComponentPropsWithoutRef<'input'>, - 'onChange' | 'value' | 'min' | 'max' - > { + React.ComponentPropsWithoutRef<'input'>, + 'onChange' | 'value' | 'min' | 'max' | 'size' | 'disabled' + >, + Pick, 'segments' | 'segmentEnum'> { + /** + * Which segment this input represents + * + * @example + * 'day' + * 'month' + * 'year' + * 'hours' + * 'minutes' + * 'seconds' + */ segment: Segment; + onChange: InputSegmentChangeEventHandler; + onBlur: React.FocusEventHandler; } /** @@ -90,15 +102,15 @@ export interface SharedInputBoxTypes { */ segments: Record; - /** - * The size of the input box - * - * @example - * Size.Default - * Size.Small - * Size.Large - */ - size: Size; + // /** + // * The size of the input box + // * + // * @example + // * Size.Default + // * Size.Small + // * Size.Large + // */ + // size: Size; /** * Whether the input box is disabled diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 063f529ab5..c4d9e26dd9 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,10 +1,6 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { - InputBoxProvider, - type InputBoxProviderProps, -} from '../InputBoxContext'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; import { @@ -12,12 +8,12 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, + InputSegmentValueMock, SegmentObjMock, - segmentRefsMock, } from './testutils.mocks'; /* - * InputSegment Utils + * InputSegment Utils // TODO: remove this? */ export const setSegmentProps = (segment: SegmentObjMock) => { return { @@ -32,33 +28,25 @@ export const setSegmentProps = (segment: SegmentObjMock) => { interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; - rerenderSegment: (params: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => void; + rerenderSegment: ( + newProps: Partial>, + ) => void; } -const defaultSegmentProviderProps: Partial< - InputBoxProviderProps +const defaultSegmentProps: InputSegmentProps< + SegmentObjMock, + InputSegmentValueMock > = { - charsPerSegment: charsPerSegmentMock, - segmentEnum: SegmentObjMock, - onChange: () => {}, - onBlur: () => {}, - segments: { - day: '', - month: '', - year: '', - }, - segmentRefs: segmentRefsMock, -}; - -const defaultSegmentProps: InputSegmentProps = { segment: 'day', minSegmentValue: defaultMinMock['day'], maxSegmentValue: defaultMaxMock['day'], shouldWrap: true, placeholder: defaultPlaceholderMock['day'], + onChange: () => {}, + onBlur: () => {}, + value: '', + charsPerSegment: charsPerSegmentMock['day'], + segmentEnum: SegmentObjMock, // @ts-expect-error - data-testid ['data-testid']: 'lg-input-segment', }; @@ -66,41 +54,20 @@ const defaultSegmentProps: InputSegmentProps = { /** * Renders the InputSegment component for testing purposes. */ -export const renderSegment = ({ - props = {}, - providerProps = {}, -}: { - props?: Partial>; - providerProps?: Partial>; -}): RenderResult & RenderSegmentReturnType => { +export const renderSegment = ( + props: Partial>, +): RenderResult & RenderSegmentReturnType => { const mergedProps = { ...defaultSegmentProps, ...props, - } as InputSegmentProps; - - const mergedProviderProps = { - ...defaultSegmentProviderProps, - ...providerProps, - } as InputBoxProviderProps; + } as InputSegmentProps; - const utils = render( - - - , - ); + const utils = render(); - const rerenderSegment = ({ - newProps = {}, - newProviderProps = {}, - }: { - newProps?: Partial>; - newProviderProps?: Partial>; - }) => { - utils.rerender( - - - , - ); + const rerenderSegment = ( + newProps: Partial>, + ) => { + utils.rerender(); }; const getInput = () => diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 0466e233e3..8edda55612 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -5,6 +5,8 @@ import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { ExplicitSegmentRule } from '../utils'; +export type InputSegmentValueMock = string; + export const SegmentObjMock = { Month: 'month', Day: 'day', From 5e73301c9446a12ad318141046a566c16b7ba6c3 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 10:25:12 -0500 Subject: [PATCH 25/36] refactor(input-box): enhance InputSegment types by adding onChange and onBlur event handlers with detailed documentation --- packages/input-box/src/shared.types.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7114ba3f89..7ad6c9163c 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -62,7 +62,21 @@ export interface InputSegmentComponentProps * 'seconds' */ segment: Segment; + + /** + * The handler for the onChange event that will be read in the InputSegment component + * + * @example + * (event: InputSegmentChangeEvent) => void + */ onChange: InputSegmentChangeEventHandler; + + /** + * The handler for the onBlur event that will be read by the InputSegment component + * + * @example + * (event: React.FocusEvent) => void + */ onBlur: React.FocusEventHandler; } @@ -102,16 +116,6 @@ export interface SharedInputBoxTypes { */ segments: Record; - // /** - // * The size of the input box - // * - // * @example - // * Size.Default - // * Size.Small - // * Size.Large - // */ - // size: Size; - /** * Whether the input box is disabled */ From 6db5451cb8b43aae5336ae1449066aa638208411 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 12:50:37 -0500 Subject: [PATCH 26/36] refactor(input-box): update InputSegment types to extend from 'div' and include additional props for improved functionality --- .../input-box/src/InputSegment/InputSegment.types.ts | 7 +++++-- packages/input-box/src/shared.types.ts | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 1b3faf8414..03de1ebb2c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -7,9 +7,12 @@ import { InputSegmentComponentProps } from '../shared.types'; export interface InputSegmentProps extends Omit< React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' | 'onBlur' | 'onChange' + 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' >, - Omit, 'segments'> { + Pick< + InputSegmentComponentProps, + 'onChange' | 'onBlur' | 'segment' | 'segmentEnum' + > { /** * Minimum value for the segment * diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7ad6c9163c..97023db4d8 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -45,11 +45,11 @@ export function isInputSegment>( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit< - React.ComponentPropsWithoutRef<'input'>, - 'onChange' | 'value' | 'min' | 'max' | 'size' | 'disabled' - >, - Pick, 'segments' | 'segmentEnum'> { + extends Omit, 'onChange'>, + Pick< + SharedInputBoxTypes, + 'segments' | 'segmentEnum' | 'disabled' | 'segmentRefs' + > { /** * Which segment this input represents * From bf2eeda185b4a0fdb37009ce6287c255dc027c45 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 13:14:02 -0500 Subject: [PATCH 27/36] refactor(input-box): remove InputBoxContext and related tests to streamline input box functionality --- .../InputBoxContext/InputBoxContext.spec.tsx | 75 ------------------ .../src/InputBoxContext/InputBoxContext.tsx | 77 ------------------- .../InputBoxContext/InputBoxContext.types.ts | 20 ----- .../input-box/src/InputBoxContext/index.ts | 9 --- packages/input-box/src/shared.types.ts | 20 +---- 5 files changed, 3 insertions(+), 198 deletions(-) delete mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx delete mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.tsx delete mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.types.ts delete mode 100644 packages/input-box/src/InputBoxContext/index.ts diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx deleted file mode 100644 index 31007d8db1..0000000000 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; - -import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; - -import { - charsPerSegmentMock, - SegmentObjMock, - segmentRefsMock, - segmentsMock, -} from '../testutils/testutils.mocks'; - -import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; - -describe('InputBoxContext', () => { - const mockOnChange = jest.fn(); - const mockOnBlur = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - test('throws error when used outside of InputBoxProvider', () => { - /** - * The version of `renderHook` imported from "@testing-library/react-hooks", (used in React 17) - * has an error boundary, and doesn't throw errors as expected: - * https://github.com/testing-library/react-hooks-testing-library/blob/main/src/index.ts#L5 - * */ - if (isReact17()) { - const { result } = renderHook(() => useInputBoxContext()); - expect(result.error.message).toEqual( - 'useInputBoxContext must be used within a InputBoxProvider', - ); - } else { - expect(() => - renderHook(() => useInputBoxContext()), - ).toThrow('useInputBoxContext must be used within a InputBoxProvider'); - } - }); - - test('provides context values that match the props passed to the provider', () => { - const { result } = renderHook(() => useInputBoxContext(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - const { - charsPerSegment, - segmentEnum, - onChange, - onBlur, - segmentRefs, - segments, - disabled, - } = result.current; - - expect(charsPerSegment).toBe(charsPerSegmentMock); - expect(segmentEnum).toBe(SegmentObjMock); - expect(onChange).toBe(mockOnChange); - expect(onBlur).toBe(mockOnBlur); - expect(segmentRefs).toBe(segmentRefsMock); - expect(segments).toBe(segmentsMock); - expect(disabled).toBe(false); - }); -}); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx deleted file mode 100644 index 4279a3e3fe..0000000000 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// TODO: NO LONGER NEEDED - -import React, { - createContext, - PropsWithChildren, - useContext, - useMemo, -} from 'react'; - -import { - InputBoxContextType, - InputBoxProviderProps, -} from './InputBoxContext.types'; - -// The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the string yet. -export const InputBoxContext = createContext(null); - -// Provider is generic over T, the string union -export const InputBoxProvider = ({ - charsPerSegment, - children, - disabled, - labelledBy, - onChange, - onBlur, - segments, - segmentEnum, - segmentRefs, -}: PropsWithChildren>) => { - const value = useMemo( - () => ({ - charsPerSegment, - children, - disabled, - labelledBy, - onChange, - onBlur, - segments, - segmentEnum, - segmentRefs, - }), - [ - charsPerSegment, - children, - disabled, - labelledBy, - onChange, - onBlur, - segments, - segmentEnum, - segmentRefs, - ], - ); - - // The provider passes a strict type of T but the context is defined as a loose type of string so TS sees a potential type mismatch. This assertion says that we know that the types do not overlap but we guarantee that the strict provider value satisfies the fixed context requirement. - return ( - - {children} - - ); -}; - -// The hook is generic over T, the string union -export const useInputBoxContext = () => { - // Assert the context type to the specific generic T - const context = useContext( - InputBoxContext, - ) as InputBoxContextType | null; - - if (!context) { - throw new Error( - 'useInputBoxContext must be used within a InputBoxProvider', - ); - } - - return context; -}; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts deleted file mode 100644 index 0834e83062..0000000000 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - InputSegmentChangeEventHandler, - SharedInputBoxTypes, -} from '../shared.types'; - -export interface InputBoxContextType - extends SharedInputBoxTypes { - /** - * The handler for the onChange event that will be read in the InputSegment component - */ - onChange: InputSegmentChangeEventHandler; - - /** - * The handler for the onBlur event that will be read by the InputSegment component - */ - onBlur: (event: React.FocusEvent) => void; -} - -export interface InputBoxProviderProps - extends InputBoxContextType {} diff --git a/packages/input-box/src/InputBoxContext/index.ts b/packages/input-box/src/InputBoxContext/index.ts deleted file mode 100644 index 226a86c6bb..0000000000 --- a/packages/input-box/src/InputBoxContext/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - InputBoxContext, - InputBoxProvider, - useInputBoxContext, -} from './InputBoxContext'; -export type { - InputBoxContextType, - InputBoxProviderProps, -} from './InputBoxContext.types'; diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 97023db4d8..41c8ac4c6c 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -46,10 +46,7 @@ export function isInputSegment>( */ export interface InputSegmentComponentProps extends Omit, 'onChange'>, - Pick< - SharedInputBoxTypes, - 'segments' | 'segmentEnum' | 'disabled' | 'segmentRefs' - > { + SharedInputBoxTypes { /** * Which segment this input represents * @@ -82,16 +79,10 @@ export interface InputSegmentComponentProps /** * Shared Input Box Types + * + * These types are shared between the InputBox and the segmentComponent. */ export interface SharedInputBoxTypes { - /** - * The number of characters per segment - * - * @example - * { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - /** * An enumerable object that maps the segment names to their values * @@ -120,9 +111,4 @@ export interface SharedInputBoxTypes { * Whether the input box is disabled */ disabled: boolean; - - /** - * id of the labelling element - */ - labelledBy?: string; } From 904fb8cf2c9f0ea5cb3e1b3ef9e54cb04540a155 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 15:14:36 -0500 Subject: [PATCH 28/36] refactor(input-box): simplify InputSegment types by removing Value generic and updating related components for improved clarity --- .../src/InputSegment/InputSegment.stories.tsx | 3 +-- .../src/InputSegment/InputSegment.tsx | 4 +-- .../src/InputSegment/InputSegment.types.ts | 8 +++--- packages/input-box/src/testutils/index.tsx | 27 ++++--------------- .../src/testutils/testutils.mocks.ts | 2 -- 5 files changed, 12 insertions(+), 32 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index af5263341b..6cb56d500b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -13,7 +13,6 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, - InputSegmentValueMock, SegmentObjMock, } from '../testutils/testutils.mocks'; @@ -104,7 +103,7 @@ export const LiveExample: StoryFn = ({ darkMode: _darkMode, ...rest }) => { - const [value, setValue] = useState(''); + const [value, setValue] = useState(''); return ( ( +const InputSegmentWithRef = ( { segment, onKeyDown, @@ -40,7 +40,7 @@ const InputSegmentWithRef = ( shouldWrap = true, shouldValidate = true, ...rest - }: InputSegmentProps, + }: InputSegmentProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 03de1ebb2c..f1c4757227 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -4,7 +4,7 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentComponentProps } from '../shared.types'; -export interface InputSegmentProps +export interface InputSegmentProps extends Omit< React.ComponentPropsWithRef<'input'>, 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' @@ -68,7 +68,7 @@ export interface InputSegmentProps * '2' * '2025' */ - value: Value; + value: string; /** * The number of characters per segment @@ -100,8 +100,8 @@ export interface InputSegmentProps * @see https://stackoverflow.com/a/58473012 */ export interface InputSegmentComponentType { - ( - props: InputSegmentProps, + ( + props: InputSegmentProps, ref: ForwardedRef, ): ReactElement | null; displayName?: string; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index c4d9e26dd9..bda44375ad 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -8,35 +8,18 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, - InputSegmentValueMock, SegmentObjMock, } from './testutils.mocks'; -/* - * InputSegment Utils // TODO: remove this? - */ -export const setSegmentProps = (segment: SegmentObjMock) => { - return { - segment: segment, - charsPerSegment: charsPerSegmentMock[segment], - minSegmentValue: defaultMinMock[segment], - maxSegmentValue: defaultMaxMock[segment], - placeholder: defaultPlaceholderMock[segment], - }; -}; - interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; rerenderSegment: ( - newProps: Partial>, + newProps: Partial>, ) => void; } -const defaultSegmentProps: InputSegmentProps< - SegmentObjMock, - InputSegmentValueMock -> = { +const defaultSegmentProps: InputSegmentProps = { segment: 'day', minSegmentValue: defaultMinMock['day'], maxSegmentValue: defaultMaxMock['day'], @@ -55,17 +38,17 @@ const defaultSegmentProps: InputSegmentProps< * Renders the InputSegment component for testing purposes. */ export const renderSegment = ( - props: Partial>, + props: Partial>, ): RenderResult & RenderSegmentReturnType => { const mergedProps = { ...defaultSegmentProps, ...props, - } as InputSegmentProps; + } as InputSegmentProps; const utils = render(); const rerenderSegment = ( - newProps: Partial>, + newProps: Partial>, ) => { utils.rerender(); }; diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 8edda55612..0466e233e3 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -5,8 +5,6 @@ import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { ExplicitSegmentRule } from '../utils'; -export type InputSegmentValueMock = string; - export const SegmentObjMock = { Month: 'month', Day: 'day', From 7b1db769c2fb305c3fd6ba6347a5a686fdb75ca0 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:15:54 -0500 Subject: [PATCH 29/36] refactor(input-box): update InputSegment types to include value prop and remove unused segmentRefs and segments properties for improved clarity --- .../src/InputSegment/InputSegment.stories.tsx | 4 +-- .../src/InputSegment/InputSegment.tsx | 1 - packages/input-box/src/shared.types.ts | 29 ++++++------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 6cb56d500b..a548edda4c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -72,11 +72,11 @@ const meta: StoryMetaType = { darkMode: [false, true], segment: ['day', 'year'], size: Object.values(Size), - value: ['', '2', '0', '00', '2025', '0000'], + value: ['', '2', '02', '0', '00', '2025', '0000'], }, excludeCombinations: [ { - value: ['2', '0', '00'], + value: ['2', '02', '0', '00'], segment: 'year', }, { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index cc592b8598..f907525f95 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -180,7 +180,6 @@ const InputSegmentWithRef = ( <> >( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit, 'onChange'>, + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'onChange' | 'value' | 'disabled' + >, SharedInputBoxTypes { /** * Which segment this input represents @@ -69,12 +72,14 @@ export interface InputSegmentComponentProps onChange: InputSegmentChangeEventHandler; /** - * The handler for the onBlur event that will be read by the InputSegment component + * The value of the segment * * @example - * (event: React.FocusEvent) => void + * '1' + * '2' + * '2025' */ - onBlur: React.FocusEventHandler; + value: string; } /** @@ -91,22 +96,6 @@ export interface SharedInputBoxTypes { */ segmentEnum: Record; - /** - * An object that maps the segment names to their refs - * - * @example - * { day: ref, month: ref, year: ref } - */ - segmentRefs: Record>; - - /** - * An object containing the values of the segments - * - * @example - * { day: '1', month: '2', year: '2025' } - */ - segments: Record; - /** * Whether the input box is disabled */ From 2dc01343a4ac0f5f9a64e4caf298f0b660ef7a1b Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 10:47:29 -0500 Subject: [PATCH 30/36] testing From b4dd84daef19a91493916518615520cde26998de Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:07:27 -0500 Subject: [PATCH 31/36] refactor(input-box): remove unused dependencies and update InputSegment types for consistency --- packages/input-box/package.json | 1 - .../src/InputSegment/InputSegment.stories.tsx | 3 +++ .../src/InputSegment/InputSegment.types.ts | 26 ------------------- packages/input-box/src/shared.types.ts | 6 ++--- .../getNewSegmentValueFromInputValue.ts | 3 +-- .../time-input/src/TimeInput/TimeInput.tsx | 2 +- 6 files changed, 8 insertions(+), 33 deletions(-) diff --git a/packages/input-box/package.json b/packages/input-box/package.json index 2b5ef5e3c8..cc5cb766c5 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -33,7 +33,6 @@ "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", "@leafygreen-ui/date-utils": "workspace:^", - "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", "@leafygreen-ui/typography": "workspace:^" }, diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index a548edda4c..80ea1054ac 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -116,5 +116,8 @@ export const LiveExample: StoryFn = ({ /> ); }; +LiveExample.parameters = { + chromatic: { disableSnapshot: true }, +}; export const Generated = () => {}; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index f1c4757227..1ad6384879 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -15,27 +15,11 @@ export interface InputSegmentProps > { /** * Minimum value for the segment - * - * @example - * 1 - * 1 - * 1970 - * 0 - * 0 - * 0 */ minSegmentValue: number; /** * Maximum value for the segment - * - * @example - * 31 - * 12 - * 2038 - * 23 - * 59 - * 59 */ maxSegmentValue: number; @@ -62,21 +46,11 @@ export interface InputSegmentProps /** * The value of the segment - * - * @example - * '1' - * '2' - * '2025' */ value: string; /** * The number of characters per segment - * - * @example - * 2 - * 2 - * 4 */ charsPerSegment: number; diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 1409a7a8cb..35552be958 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -57,9 +57,9 @@ export interface InputSegmentComponentProps * 'day' * 'month' * 'year' - * 'hours' - * 'minutes' - * 'seconds' + * 'hour' + * 'minute' + * 'second' */ segment: Segment; diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 66178da60f..5057b3abea 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -107,7 +107,6 @@ export const getNewSegmentValueFromInputValue = < const isIncomingValueNumber = !isNaN(Number(incomingValue)); // macOS adds a period when pressing SPACE twice inside a text input. const doesIncomingValueContainPeriod = /\./.test(incomingValue); - const shouldSkipValidation = !shouldValidate; // if the current value is "full", do not allow any additional characters to be entered const wouldCauseOverflow = @@ -130,7 +129,7 @@ export const getNewSegmentValueFromInputValue = < segmentEnum, }); - if (isIncomingValueValid || shouldSkipValidation) { + if (isIncomingValueValid || !shouldValidate) { const newValue = truncateStart(incomingValue, { length: charsPerSegment, }); diff --git a/packages/time-input/src/TimeInput/TimeInput.tsx b/packages/time-input/src/TimeInput/TimeInput.tsx index 44a8b67970..b97481ed36 100644 --- a/packages/time-input/src/TimeInput/TimeInput.tsx +++ b/packages/time-input/src/TimeInput/TimeInput.tsx @@ -1,5 +1,6 @@ import React, { forwardRef } from 'react'; +import { DateType } from '@leafygreen-ui/date-utils'; import { useControlled } from '@leafygreen-ui/hooks'; import LeafyGreenProvider, { useDarkMode, @@ -17,7 +18,6 @@ import { import { TimeInputContent } from '../TimeInputContent'; import { TimeInputProps } from './TimeInput.types'; -import { DateType } from '@leafygreen-ui/date-utils'; export const TimeInput = forwardRef( ( From f2cfaa30a60baaef205da5819ba7dfed501e6bc6 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:08:54 -0500 Subject: [PATCH 32/36] update lock file --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37083bd443..d17f46d811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2198,9 +2198,6 @@ importers: '@leafygreen-ui/lib': specifier: workspace:^ version: link:../lib - '@leafygreen-ui/palette': - specifier: workspace:^ - version: link:../palette '@leafygreen-ui/tokens': specifier: workspace:^ version: link:../tokens From c269b96a986acd6bf9371c47b7f59e88ff8c9e11 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:19:31 -0500 Subject: [PATCH 33/36] testing From 73ea2738631ac4a1847145b2bfae11273fec263f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:58:48 -0500 Subject: [PATCH 34/36] testing build From a55bf242b17244d39346895674c47b363817f67c Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 12:31:41 -0500 Subject: [PATCH 35/36] testing build From af7501fbf9ac53e525d6ed10a31e5f0280dd2020 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 18:24:32 -0500 Subject: [PATCH 36/36] refactor(input-box): rename charsPerSegment to charsCount for consistency across InputSegment components --- .../src/InputSegment/InputSegment.spec.tsx | 10 +++++----- .../src/InputSegment/InputSegment.stories.tsx | 11 ++++------- .../input-box/src/InputSegment/InputSegment.tsx | 10 +++++----- .../src/InputSegment/InputSegment.types.ts | 2 +- packages/input-box/src/shared.types.ts | 13 ------------- packages/input-box/src/testutils/index.tsx | 16 +++++----------- 6 files changed, 20 insertions(+), 42 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index f9888c6dc8..8d779b8809 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -287,7 +287,7 @@ describe('packages/input-segment', () => { segment: 'year', minSegmentValue: 1970, maxSegmentValue: 2038, - charsPerSegment: 4, + charsCount: 4, shouldWrap: false, onChange: onChangeHandler, value: '3', @@ -456,7 +456,7 @@ describe('packages/input-segment', () => { segment: 'year', minSegmentValue: 1970, maxSegmentValue: 2038, - charsPerSegment: 4, + charsCount: 4, shouldWrap: false, onChange: onChangeHandler, value: '3', @@ -662,7 +662,7 @@ describe('packages/input-segment', () => { const { input } = renderSegment({ segment: 'year', - charsPerSegment: 4, + charsCount: 4, maxSegmentValue: 2038, shouldValidate: false, onChange: onChangeHandler, @@ -784,7 +784,7 @@ describe('packages/input-segment', () => { minSegmentValue={1} maxSegmentValue={31} value="" - charsPerSegment={2} + charsCount={2} onChange={() => {}} onBlur={() => {}} onKeyDown={() => {}} @@ -800,7 +800,7 @@ describe('packages/input-segment', () => { minSegmentValue={1} maxSegmentValue={31} value="" - charsPerSegment={2} + charsCount={2} onChange={() => {}} onBlur={() => {}} onKeyDown={() => {}} diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 80ea1054ac..95b6e7bc78 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -9,9 +9,6 @@ import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { Size } from '@leafygreen-ui/tokens'; import { - charsPerSegmentMock, - defaultMaxMock, - defaultMinMock, defaultPlaceholderMock, SegmentObjMock, } from '../testutils/testutils.mocks'; @@ -34,14 +31,14 @@ const meta: StoryMetaType = { ], args: { segment: SegmentObjMock.Day, - minSegmentValue: defaultMinMock[SegmentObjMock.Day], - maxSegmentValue: defaultMaxMock[SegmentObjMock.Day], + minSegmentValue: 0, + maxSegmentValue: 31, size: Size.Default, - placeholder: defaultPlaceholderMock[SegmentObjMock.Day], + placeholder: 'DD', shouldWrap: true, step: 1, darkMode: false, - charsPerSegment: charsPerSegmentMock[SegmentObjMock.Day], + charsCount: 2, }, argTypes: { size: { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index f907525f95..ad45c11948 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -35,7 +35,7 @@ const InputSegmentWithRef = ( size, disabled, value, - charsPerSegment, + charsCount, step = 1, shouldWrap = true, shouldValidate = true, @@ -46,10 +46,10 @@ const InputSegmentWithRef = ( const { theme } = useDarkMode(); const baseFontSize = useUpdatedBaseFontSize(); const formatter = getValueFormatter({ - charsPerSegment, + charsPerSegment: charsCount, allowZero: minSegmentValue === 0, }); - const pattern = `[0-9]{${charsPerSegment}}`; + const pattern = `[0-9]{${charsCount}}`; /** * Receives native input events, @@ -63,7 +63,7 @@ const InputSegmentWithRef = ( segmentName: segment, currentValue: value, incomingValue: target.value, - charsPerSegment, + charsPerSegment: charsCount, defaultMin: minSegmentValue, defaultMax: maxSegmentValue, segmentEnum, @@ -97,7 +97,7 @@ const InputSegmentWithRef = ( 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) { + if (target.value.length === charsCount) { target.value = ''; } } diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 1ad6384879..9e436dd943 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -52,7 +52,7 @@ export interface InputSegmentProps /** * The number of characters per segment */ - charsPerSegment: number; + charsCount: number; /** * The size of the input box diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 35552be958..7e49b4b154 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -52,14 +52,6 @@ export interface InputSegmentComponentProps SharedInputBoxTypes { /** * Which segment this input represents - * - * @example - * 'day' - * 'month' - * 'year' - * 'hour' - * 'minute' - * 'second' */ segment: Segment; @@ -73,11 +65,6 @@ export interface InputSegmentComponentProps /** * The value of the segment - * - * @example - * '1' - * '2' - * '2025' */ value: string; } diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index bda44375ad..c33f3e4573 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -3,13 +3,7 @@ import { render, RenderResult } from '@testing-library/react'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; -import { - charsPerSegmentMock, - defaultMaxMock, - defaultMinMock, - defaultPlaceholderMock, - SegmentObjMock, -} from './testutils.mocks'; +import { SegmentObjMock } from './testutils.mocks'; interface RenderSegmentReturnType { getInput: () => HTMLInputElement; @@ -21,14 +15,14 @@ interface RenderSegmentReturnType { const defaultSegmentProps: InputSegmentProps = { segment: 'day', - minSegmentValue: defaultMinMock['day'], - maxSegmentValue: defaultMaxMock['day'], + minSegmentValue: 0, + maxSegmentValue: 31, shouldWrap: true, - placeholder: defaultPlaceholderMock['day'], + placeholder: 'DD', onChange: () => {}, onBlur: () => {}, value: '', - charsPerSegment: charsPerSegmentMock['day'], + charsCount: 2, segmentEnum: SegmentObjMock, // @ts-expect-error - data-testid ['data-testid']: 'lg-input-segment',