From 09d117b1d0804cf789c613256e5bdbb350a74915 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 13:35:07 -0500 Subject: [PATCH 01/60] feat(input-box): enhance InputBox and InputSegment components with new features and documentation. --- packages/input-box/README.md | 132 ++- packages/input-box/package.json | 1 + packages/input-box/src/InputBox.stories.tsx | 63 ++ .../input-box/src/InputBox/InputBox.spec.tsx | 544 +++++++++++ .../input-box/src/InputBox/InputBox.styles.ts | 42 + packages/input-box/src/InputBox/InputBox.tsx | 259 ++++++ .../input-box/src/InputBox/InputBox.types.ts | 146 +++ packages/input-box/src/InputBox/index.ts | 2 + .../InputBoxContext/InputBoxContext.spec.tsx | 68 ++ .../src/InputBoxContext/InputBoxContext.tsx | 77 ++ .../InputBoxContext/InputBoxContext.types.ts | 21 + .../input-box/src/InputBoxContext/index.ts | 10 + .../src/InputSegment/InputSegment.spec.tsx | 843 ++++++++++++++++++ .../src/InputSegment/InputSegment.stories.tsx | 157 ++++ .../src/InputSegment/InputSegment.styles.ts | 103 +++ .../src/InputSegment/InputSegment.tsx | 240 +++++ .../src/InputSegment/InputSegment.types.ts | 123 +++ packages/input-box/src/InputSegment/index.ts | 6 + packages/input-box/src/index.ts | 12 + packages/input-box/src/testutils/index.tsx | 250 ++++++ .../src/testutils/testutils.mocks.ts | 85 ++ .../createExplicitSegmentValidator.ts | 20 +- packages/input-box/tsconfig.json | 3 + 23 files changed, 3200 insertions(+), 7 deletions(-) create mode 100644 packages/input-box/src/InputBox.stories.tsx create mode 100644 packages/input-box/src/InputBox/InputBox.spec.tsx create mode 100644 packages/input-box/src/InputBox/InputBox.styles.ts create mode 100644 packages/input-box/src/InputBox/InputBox.tsx create mode 100644 packages/input-box/src/InputBox/InputBox.types.ts create mode 100644 packages/input-box/src/InputBox/index.ts create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.tsx create mode 100644 packages/input-box/src/InputBoxContext/InputBoxContext.types.ts create mode 100644 packages/input-box/src/InputBoxContext/index.ts create mode 100644 packages/input-box/src/InputSegment/InputSegment.spec.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.stories.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.styles.ts create mode 100644 packages/input-box/src/InputSegment/InputSegment.tsx create mode 100644 packages/input-box/src/InputSegment/InputSegment.types.ts create mode 100644 packages/input-box/src/InputSegment/index.ts create mode 100644 packages/input-box/src/testutils/index.tsx create mode 100644 packages/input-box/src/testutils/testutils.mocks.ts diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 67bcec1d73..3961601fb7 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -1,4 +1,130 @@ -# Internal Input Box +# Input Box -An internal component intended to be used by any date or time component. -I.e. `DatePicker`, `TimeInput` etc. +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/input-box.svg) + +## Installation + +### PNPM + +```shell +pnpm add @leafygreen-ui/input-box +``` + +### Yarn + +```shell +yarn add @leafygreen-ui/input-box +``` + +### NPM + +```shell +npm install @leafygreen-ui/input-box +``` + +## Example + +```tsx +import { InputBox, InputSegment } from '@leafygreen-ui/input-box'; +import { Size } from '@leafygreen-ui/tokens'; + +// 1. Create a custom segment component +const MySegment = ({ segment, ...props }) => ( + +); + +// 2. Use InputBox with your segments + console.log(segment, value)} + segmentEnum={{ Day: 'day', Month: 'month', Year: 'year' }} + segmentComponent={MySegment} + formatParts={[ + { type: 'month', value: '02' }, + { type: 'literal', value: '/' }, + { type: 'day', value: '01' }, + { type: 'literal', value: '/' }, + { type: 'year', value: '2025' } + ]} + charsPerSegment={{ day: 2, month: 2, year: 4 }} + segmentRefs={{ day: dayRef, month: monthRef, year: yearRef }} + segmentRules={{ + day: { maxChars: 2, minExplicitValue: 1 }, + month: { maxChars: 2, minExplicitValue: 4 }, + year: { maxChars: 4, minExplicitValue: 1970 } + }} + disabled={false} + size={Size.Default} +/> +``` + +Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for an implementation example. + +## Overview + +An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. + +This package provides two main components that work together to create segmented input experiences. + +### InputBox + +A generic controlled input box component that renders an input with multiple segments separated by literals. + +**Key Features:** + +- **Auto-format**: Automatically pads segment values with leading zeros (based on `charsPerSegment`) when they become explicit/unambiguous. A value is explicit when it either: (1) reaches the maximum character length, or (2) meets or exceeds the `minExplicitValue` threshold (e.g., typing "5" for day → "05", but typing "2" stays "2" since it could be 20-29). Also formats on blur. +- **Auto-focus**: Automatically advances focus to the next segment when the current segment is complete +- **Keyboard navigation**: Handles left/right arrow key navigation between segments +- **Segment management**: Renders segments and separators based on `formatParts` (from `Intl.DateTimeFormat`) + +The component handles high-level interactions like moving between segments, while delegating segment-specific logic to the `InputSegment` component. Internally, it uses `InputBoxContext` to share state and handlers across all segments. + +#### Props + +| Prop | Type | Description | Default | +| ------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `segments` | `Record` | An object containing the values of the segments.

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

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

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

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

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

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

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

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

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

Example: `'date-input-label'` | | + +\+ other HTML `div` element props + +### InputSegment + +A controlled input segment component that renders a single input field within an `InputBox`. + +**Key Features:** + +- **Up/down arrow key navigation**: Increment/decrement segment values using arrow keys +- **Value validation**: Validates input against configurable min/max ranges +- **Auto-formatting**: Formats values with leading zeros based on character length +- **Rollover support**: Optionally rolls over values (e.g., 31 → 1 for days, or stops at boundaries) +- **Keyboard interaction**: Handles backspace and space keys to clear values +- **onChange/onBlur events**: Fires custom change events with segment metadata + +#### Props + +| Prop | Type | Description | Default | +| ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `segment` | `string` | The segment identifier.

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

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

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

Example: `true` to wrap 31 → 1 for days | | +| `shouldSkipValidation` | `boolean` | Skips validation for segments that allow extended ranges | | + +\+ native HTML `input` element props diff --git a/packages/input-box/package.json b/packages/input-box/package.json index 3030c6e71e..2b5ef5e3c8 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -28,6 +28,7 @@ "access": "public" }, "dependencies": { + "@leafygreen-ui/a11y": "workspace:^", "@leafygreen-ui/emotion": "workspace:^", "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/hooks": "workspace:^", diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx new file mode 100644 index 0000000000..83f9f0ff8a --- /dev/null +++ b/packages/input-box/src/InputBox.stories.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; +import { StoryFn } from '@storybook/react'; + +import { css } from '@leafygreen-ui/emotion'; +import { palette } from '@leafygreen-ui/palette'; + +import { SegmentObjMock } from './testutils/testutils.mocks'; +import { InputBox, InputBoxProps } from './InputBox'; +import { InputBoxWithState } from './testutils'; +import { Size } from '@leafygreen-ui/tokens'; + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox', + component: InputBox, + decorators: [ + StoryFn => ( +
+ +
+ ), + ], + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segments', + 'segmentObj', + 'segmentRefs', + 'setSegment', + 'charsPerSegment', + 'formatParts', + 'segmentRules', + 'labelledBy', + 'onSegmentChange', + 'renderSegment', + 'segmentComponent', + 'segmentEnum', + ], + }, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + }, +}; +export default meta; + +export const LiveExample: StoryFn = props => { + return ( + >)} /> + ); +}; diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx new file mode 100644 index 0000000000..cd5bba8b6e --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -0,0 +1,544 @@ +import React from 'react'; +import { jest } from '@jest/globals'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { Size } from '@leafygreen-ui/tokens'; + +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; +import { + InputBoxWithState, + InputSegmentWrapper, + renderInputBox, +} from '../testutils'; +import { + charsPerSegmentMock, + SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputBox } from './InputBox'; + +describe('packages/input-box', () => { + describe('Rendering', () => { + describe.each(['day', 'month', 'year'])('%p', segment => { + test('renders the correct aria attributes', () => { + const { getByLabelText } = renderInputBox({}); + const input = getByLabelText(segment); + + // each segment has appropriate aria label + expect(input).toHaveAttribute('aria-label', segment); + }); + }); + + test('renders segments in the correct order', () => { + const { getAllByRole } = renderInputBox({}); + const segments = getAllByRole('spinbutton'); + expect(segments[0]).toHaveAttribute('aria-label', 'month'); + expect(segments[1]).toHaveAttribute('aria-label', 'day'); + expect(segments[2]).toHaveAttribute('aria-label', 'year'); + }); + + test('renders filled segments when a value is passed', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segments: { day: '02', month: '02', year: '2025' }, + }); + + expect(dayInput.value).toBe('02'); + expect(monthInput.value).toBe('02'); + expect(yearInput.value).toBe('2025'); + }); + }); + + describe('rerendering', () => { + test('with new value updates the segments', () => { + const setSegment = jest.fn(); + const { rerenderInputBox, getDayInput, getMonthInput, getYearInput } = + renderInputBox({ + segments: { day: '02', month: '02', year: '2025' }, + setSegment, + }); + expect(getDayInput().value).toBe('02'); + expect(getMonthInput().value).toBe('02'); + expect(getYearInput().value).toBe('2025'); + + rerenderInputBox({ + segments: { day: '26', month: '09', year: '1993' }, + setSegment, + }); + expect(getDayInput().value).toBe('26'); + expect(getMonthInput().value).toBe('09'); + expect(getYearInput().value).toBe('1993'); + }); + }); + + describe('onSegmentChange', () => { + test('is called when a segment value changes', () => { + const onSegmentChange = + jest.fn>(); + const { dayInput } = renderInputBox({ + onSegmentChange, + segments: { day: '', month: '', year: '' }, + }); + expect(dayInput.value).toBe(''); + userEvent.type(dayInput, '2'); + expect(onSegmentChange).toHaveBeenCalledWith( + expect.objectContaining({ value: '2' }), + ); + }); + + test('is called when deleting from a segment', () => { + const onSegmentChange = + jest.fn>(); + const { dayInput } = renderInputBox({ + onSegmentChange, + segments: { day: '21', month: '', year: '' }, + }); + + userEvent.type(dayInput, '{backspace}'); + expect(onSegmentChange).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + + describe('setSegment', () => { + test('is called when a segment value changes', () => { + const setSegment = jest.fn(); + const { dayInput } = renderInputBox({ + setSegment, + segments: { day: '', month: '', year: '' }, + }); + userEvent.type(dayInput, '2'); + expect(setSegment).toHaveBeenCalledWith('day', '2'); + }); + + test('is called when deleting from a single segment', () => { + const setSegment = jest.fn(); + const { dayInput } = renderInputBox({ + setSegment, + segments: { day: '21', month: '', year: '' }, + }); + + userEvent.type(dayInput, '{backspace}'); + expect(setSegment).toHaveBeenCalledWith('day', ''); + }); + }); + + describe('auto-focus', () => { + test('focuses the next segment when an explicit value is entered', () => { + const { dayInput, monthInput } = renderInputBox({}); + + userEvent.type(monthInput, '02'); + expect(dayInput).toHaveFocus(); + expect(monthInput.value).toBe('02'); + }); + + test('focus remains in the current segment when an ambiguous value is entered', () => { + const { dayInput } = renderInputBox({}); + + userEvent.type(dayInput, '2'); + expect(dayInput).toHaveFocus(); + }); + + test('focuses the previous segment when a backspace is pressed and the current segment is empty', () => { + const { dayInput, monthInput } = renderInputBox({}); + + userEvent.type(dayInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + + test('focus remains in the current segment when a backspace is pressed and the current segment is not empty', () => { + const { monthInput } = renderInputBox({}); + + userEvent.type(monthInput, '2'); + userEvent.type(monthInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + }); + + describe('Mouse interaction', () => { + test('click on segment focuses it when the segment is empty', () => { + const { dayInput } = renderInputBox({}); + userEvent.click(dayInput); + expect(dayInput).toHaveFocus(); + }); + + test('click on segment focuses it when the segment is not empty', () => { + const { dayInput } = renderInputBox({ + segments: { day: '02', month: '', year: '' }, + }); + userEvent.click(dayInput); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Keyboard interaction', () => { + test('Tab moves focus to next segment', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.tab(); + expect(dayInput).toHaveFocus(); + userEvent.tab(); + expect(yearInput).toHaveFocus(); + }); + + describe('Right arrow', () => { + test('Right arrow key moves focus to next segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowright}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('Right arrow key moves focus to next segment when the segment is not empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowright}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('Right arrow key moves focus to next segment when the value starts with 0', () => { + const { dayInput, monthInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.type(monthInput, '0{arrowright}'); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Left arrow', () => { + test('Left arrow key moves focus to previous segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({}); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('Left arrow key moves focus to previous segment when the segment is not empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('Left arrow key moves focus to previous segment when the value starts with 0', () => { + const { dayInput, yearInput } = renderInputBox({}); + userEvent.click(yearInput); + userEvent.type(yearInput, '0{arrowleft}'); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Up arrow', () => { + test('keeps the focus in the current segment when the segment is empty', () => { + const { dayInput } = renderInputBox({}); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowup}'); + expect(dayInput).toHaveFocus(); + }); + + test('keeps the focus in the current segment when the segment is not empty', () => { + const { dayInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowup}'); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Down arrow', () => { + test('keeps the focus in the current segment when the segment is empty', () => { + const { dayInput } = renderInputBox({}); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowdown}'); + expect(dayInput).toHaveFocus(); + }); + + test('keeps the focus in the current segment when the segment is not empty', () => { + const { dayInput } = renderInputBox({ + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(dayInput); + userEvent.type(dayInput, '{arrowdown}'); + expect(dayInput).toHaveFocus(); + }); + }); + }); + + describe('onBlur', () => { + test('returns no value with leading zero if min value is not 0', () => { + // min value is 1 + const { monthInput } = renderInputBox({}); + userEvent.type(monthInput, '0'); + userEvent.tab(); + expect(monthInput.value).toBe(''); + }); + + test('returns value with leading zero if min value is 0', () => { + // min value is 0 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '0'); + userEvent.tab(); + expect(dayInput.value).toBe('00'); + }); + + test('returns value with leading zero if value is explicit', () => { + const { dayInput } = renderInputBox({}); + // 0-31 + userEvent.type(dayInput, '4'); + userEvent.tab(); + expect(dayInput.value).toBe('04'); + }); + + test('returns value without if value is explicit and meets the character limit', () => { + const { dayInput } = renderInputBox({}); + // 0-31 + userEvent.type(dayInput, '29'); + userEvent.tab(); + expect(dayInput.value).toBe('29'); + }); + + test('returns value with leading zero if value is ambiguous', () => { + const { dayInput } = renderInputBox({}); + // 1-31 + userEvent.type(dayInput, '1'); // 1 can be 1 or 1n + userEvent.tab(); + expect(dayInput.value).toBe('01'); + }); + }); + + describe('typing', () => { + describe('explicit value', () => { + test('updates the rendered segment value', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '26'); + expect(dayInput.value).toBe('26'); + }); + + test('segment value is immediately formatted', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '5'); + expect(dayInput.value).toBe('05'); + }); + + test('allows leading zeros', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '02'); + expect(dayInput.value).toBe('02'); + }); + + test('allows 00 as a valid value if min value is 0', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '00'); + expect(dayInput.value).toBe('00'); + }); + }); + + describe('ambiguous value', () => { + test('segment value is not immediately formatted', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + expect(dayInput.value).toBe('2'); + }); + + test('value is formatted on segment blur', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + userEvent.tab(); + expect(dayInput.value).toBe('02'); + }); + + test('allows leading zeros', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '0'); + expect(dayInput.value).toBe('0'); + }); + + test('allows backspace to delete the value', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + userEvent.type(dayInput, '{backspace}'); + expect(dayInput.value).toBe(''); + }); + }); + + describe('min/max range', () => { + describe('does not allow values outside max range', () => { + test('and returns single digit value if it is ambiguous', () => { + // max is 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '32'); + // returns the last valid value + expect(dayInput.value).toBe('2'); + }); + + test('and returns formatted value if it is explicit', () => { + // max is 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '34'); + // returns the last valid value + expect(dayInput.value).toBe('04'); + }); + }); + }); + + test('does not allow non-number characters', () => { + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, 'aB$/'); + expect(dayInput.value).toBe(''); + }); + + test('backspace resets the input', () => { + const { dayInput, yearInput } = renderInputBox({}); + userEvent.type(dayInput, '21'); + userEvent.type(dayInput, '{backspace}'); + expect(dayInput.value).toBe(''); + + userEvent.type(yearInput, '1993'); + userEvent.type(yearInput, '{backspace}'); + expect(yearInput.value).toBe(''); + }); + }); + + describe('Arrow keys with auto-advance', () => { + test('arrow up does not auto-advance to next segment', () => { + const { monthInput, dayInput } = renderInputBox({ + segments: { day: '', month: '05', year: '' }, + }); + + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowup}'); + expect(monthInput).toHaveFocus(); + expect(dayInput).not.toHaveFocus(); + }); + + test('arrow down does not auto-advance to next segment', () => { + const { monthInput, dayInput } = renderInputBox({ + segments: { day: '', month: '05', year: '' }, + }); + + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowdown}'); + expect(monthInput).toHaveFocus(); + expect(dayInput).not.toHaveFocus(); + }); + }); + + describe('Edge cases for segment navigation', () => { + test('does not auto-advance from the last segment', () => { + const { yearInput } = renderInputBox({ + segments: { day: '', month: '', year: '' }, + }); + + userEvent.click(yearInput); + userEvent.type(yearInput, '2025'); + expect(yearInput).toHaveFocus(); + }); + + test('arrow left from first segment keeps focus on first segment', () => { + const { monthInput } = renderInputBox({}); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('arrow right from last segment keeps focus on last segment', () => { + const { yearInput } = renderInputBox({}); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('backspace from first empty segment keeps focus on first segment', () => { + const { monthInput } = renderInputBox({ + segments: { day: '', month: '', year: '' }, + }); + + userEvent.click(monthInput); + userEvent.type(monthInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + }); + + describe('Format parts and literal separators', () => { + test('renders literal separators between segments', () => { + const { container } = renderInputBox({ + formatParts: [ + { type: 'month', value: '02' }, + { type: 'literal', value: '/' }, + { type: 'day', value: '02' }, + { type: 'literal', value: '/' }, + { type: 'year', value: '2025' }, + ], + }); + + const separators = container.querySelectorAll('span'); + expect(separators.length).toBeGreaterThanOrEqual(2); + expect(container.textContent).toContain('/'); + }); + + test('does not render non-segment parts as inputs', () => { + const { container } = render( + , + ); + + const inputs = container.querySelectorAll('input'); + expect(inputs).toHaveLength(2); // Only month and day, not the literal + }); + }); + + describe('Disabled state', () => { + test('all segments are disabled when disabled prop is true', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + disabled: true, + }); + + expect(dayInput).toBeDisabled(); + expect(monthInput).toBeDisabled(); + expect(yearInput).toBeDisabled(); + }); + }); + + /* eslint-disable jest/no-disabled-tests */ + describe.skip('types behave as expected', () => { + test('InputBox throws error when no required props are provided', () => { + // @ts-expect-error - missing required props + ; + }); + }); + + test('With required props', () => { + {}} + charsPerSegment={charsPerSegmentMock} + segmentRules={segmentRulesMock} + segmentComponent={InputSegmentWrapper} + size={Size.Default} + disabled={false} + />; + }); +}); diff --git a/packages/input-box/src/InputBox/InputBox.styles.ts b/packages/input-box/src/InputBox/InputBox.styles.ts new file mode 100644 index 0000000000..53e3de972e --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.styles.ts @@ -0,0 +1,42 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; + +export const segmentPartsWrapperStyles = css` + display: flex; + align-items: center; + gap: 1px; +`; + +export const separatorLiteralStyles = css` + user-select: none; +`; + +export const separatorLiteralDisabledStyles: Record = { + [Theme.Dark]: css` + color: ${palette.gray.dark2}; + `, + [Theme.Light]: css` + color: ${palette.gray.base}; + `, +}; + +export const getSeparatorLiteralStyles = ({ + theme, + disabled = false, +}: { + theme: Theme; + disabled?: boolean; +}) => { + return cx(separatorLiteralStyles, { + [separatorLiteralDisabledStyles[theme]]: disabled, + }); +}; + +export const getSegmentPartsWrapperStyles = ({ + className, +}: { + className?: string; +}) => { + return cx(segmentPartsWrapperStyles, className); +}; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx new file mode 100644 index 0000000000..bd93e20325 --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -0,0 +1,259 @@ +import React, { + FocusEventHandler, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; + +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; + +import { InputBoxProvider } from '../InputBoxContext'; +import { + InputSegmentChangeEventHandler, + isInputSegment, +} from '../InputSegment/InputSegment.types'; +import { + createExplicitSegmentValidator, + getRelativeSegment, + getRelativeSegmentRef, + getValueFormatter, + isElementInputSegment, +} from '../utils'; + +import { + getSegmentPartsWrapperStyles, + getSeparatorLiteralStyles, +} from './InputBox.styles'; +import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; + +/** + * Generic controlled input box component + * Renders an input box with appropriate segment order & separator characters. + * + * @internal + */ +export const InputBoxWithRef = ( + { + className, + labelledBy, + segmentRefs, + onSegmentChange, + onKeyDown, + setSegment, + disabled, + charsPerSegment, + formatParts, + segmentEnum, + segmentRules, + segmentComponent, + segments, + size, + ...rest + }: InputBoxProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + + const isExplicitSegmentValue = createExplicitSegmentValidator({ + segmentEnum, + rules: segmentRules, + }); + + /** Formats and sets the segment value. */ + const getFormattedSegmentValue = ( + segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], + segmentValue: string, + allowZero: boolean, + ): string => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment[segmentName], + allowZero, + }); + const formattedValue = formatter(segmentValue); + return formattedValue; + }; + + /** Fired when an individual segment value changes */ + const handleSegmentInputChange: InputSegmentChangeEventHandler< + Segment, + string + > = segmentChangeEvent => { + let segmentValue = segmentChangeEvent.value; + const { segment: segmentName, meta } = segmentChangeEvent; + const changedViaArrowKeys = + meta?.key === keyMap.ArrowDown || meta?.key === keyMap.ArrowUp; + const minSegmentValue = meta?.min as number; + const allowZero = minSegmentValue === 0; + + // Auto-format the segment if it is explicit and was not changed via arrow-keys e.g. up/down arrows. + if ( + !changedViaArrowKeys && + isExplicitSegmentValue(segmentName, segmentValue, allowZero) + ) { + segmentValue = getFormattedSegmentValue( + segmentName, + segmentValue, + allowZero, + ); + + // Auto-advance focus (if possible) + const nextSegmentName = getRelativeSegment('next', { + segment: segmentName, + formatParts, + }); + + if (nextSegmentName) { + const nextSegmentRef = segmentRefs[nextSegmentName]; + nextSegmentRef?.current?.focus(); + nextSegmentRef?.current?.select(); + } + } + + setSegment(segmentName, segmentValue); + onSegmentChange?.(segmentChangeEvent); + }; + + /** Triggered when a segment is blurred. Formats the segment value and sets it. */ + const handleSegmentInputBlur: FocusEventHandler = e => { + const segmentName = e.target.getAttribute('id'); + const segmentValue = e.target.value; + const minValue = Number(e.target.getAttribute('min')); + const allowZero = minValue === 0; + + if (isInputSegment(segmentName, segmentEnum)) { + const formattedValue = getFormattedSegmentValue( + segmentName, + segmentValue, + allowZero, + ); + setSegment(segmentName, formattedValue); + } + }; + + /** Called on any keydown within the input element. Manages arrow key navigation. */ + const handleInputKeyDown: KeyboardEventHandler = e => { + const { target: _target, key } = e; + const target = _target as HTMLElement; + const isSegment = isElementInputSegment(target, segmentRefs); + + // if target is not a segment, do nothing + if (!isSegment) return; + + const isSegmentEmpty = !target.value; + + switch (key) { + case keyMap.ArrowLeft: { + // Without this, the input ignores `.select()` + e.preventDefault(); + // if input is empty, + // set focus to prev input (if it exists) + const segmentToFocus = getRelativeSegmentRef('prev', { + segment: target, + formatParts, + segmentRefs, + }); + + segmentToFocus?.current?.focus(); + segmentToFocus?.current?.select(); + // otherwise, use default behavior + + break; + } + + case keyMap.ArrowRight: { + // Without this, the input ignores `.select()` + e.preventDefault(); + // if input is empty, + // set focus to next. input (if it exists) + const segmentToFocus = getRelativeSegmentRef('next', { + segment: target, + formatParts, + segmentRefs, + }); + + segmentToFocus?.current?.focus(); + segmentToFocus?.current?.select(); + // otherwise, use default behavior + + break; + } + + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + // increment/decrement logic implemented by DateInputSegment + break; + } + + case keyMap.Backspace: { + if (isSegmentEmpty) { + // prevent the backspace in the previous segment + e.preventDefault(); + + const segmentToFocus = getRelativeSegmentRef('prev', { + segment: target, + formatParts, + segmentRefs, + }); + segmentToFocus?.current?.focus(); + segmentToFocus?.current?.select(); + } + break; + } + + case keyMap.Space: + case keyMap.Enter: + case keyMap.Escape: + case keyMap.Tab: + // Behavior handled by parent or menu + break; + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + + return ( + + {/* We want to allow keydown events to be captured by the parent so that the parent can handle the event. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {formatParts?.map((part, i) => { + if (part.type === 'literal') { + return ( + + {part.value} + + ); + } else if (isInputSegment(part.type, segmentEnum)) { + const Segment = segmentComponent; + return ; + } + })} +
+
+ ); +}; + +export const InputBox = React.forwardRef( + InputBoxWithRef, +) as InputBoxComponentType; + +InputBox.displayName = 'InputBox'; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts new file mode 100644 index 0000000000..ae5c3840ad --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -0,0 +1,146 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + +import { DateType } from '@leafygreen-ui/date-utils'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + InputSegmentChangeEventHandler, + InputSegmentComponentProps, +} from '../InputSegment/InputSegment.types'; +import { ExplicitSegmentRule } from '../utils'; + +export interface InputChangeEvent { + value: DateType; + segments: Record; +} + +export type InputChangeEventHandler = ( + changeEvent: InputChangeEvent, +) => void; + +export interface InputBoxProps + extends Omit, 'onChange' | 'children'> { + /** + * Callback fired when any segment changes, but not necessarily a full value + */ + onSegmentChange?: InputSegmentChangeEventHandler; + + /** + * id of the labelling element + */ + labelledBy?: string; + + /** + * An object that maps the segment names to their refs + * + * @example + * { day: ref, month: ref, year: ref } + */ + segmentRefs: Record>>; + + /** + * An enumerable object that maps the segment names to their values + * + * @example + * { Day: 'day', Month: 'month', Year: 'year' } + */ + segmentEnum: Record; + + /** + * An object containing the values of the segments + * + * @example + * { day: '1', month: '2', year: '2025' } + */ + segments: Record; + + /** + * A function that sets the value of a segment + * + * @example + * (segment: 'day', value: '1') => void; + */ + setSegment: (segment: Segment, value: string) => void; + + /** + * The format parts of the date + * + * @example + * [ + * { type: 'month', value: '02' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '02' }, + * { type: 'literal', value: '-' }, + * { type: 'year', value: '2025' }, + * ] + */ + formatParts?: Array; + + /** + * The number of characters per segment + * + * @example + * { day: 2, month: 2, year: 4 } + */ + charsPerSegment: Record; + + /** + * Whether the input box is disabled + */ + disabled: boolean; + + /** + * An object that maps the segment names to their rules. + * + * maxChars: the maximum number of characters for the segment + * minExplicitValue: the minimum explicit value for the segment + * + * @example + * { + * day: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 4 }, + * year: { maxChars: 4, minExplicitValue: 1970 }, + * } + * + * Explicit: Day = 5, 02 + * Ambiguous: Day = 2 (could be 20-29) + * + */ + segmentRules: Record; + + /** + * The component that renders a segment. When mapping over the formatParts, we will render the segment component for each part using this component. + * This should be a React component that accepts the InputSegmentComponentProps type. + * + * @example + * segmentComponent={DateInputSegment} + */ + segmentComponent: React.ComponentType>; + + /** + * The size of the input box + * + * @example + * Size.Default + * Size.Small + * Size.Large + */ + size: Size; +} + +/** + * Type definition for the InputBox component that maintains generic type safety with forwardRef. + * + * Interface with a generic call signature that preserves type parameters() when using forwardRef. + * React.forwardRef loses type parameters, so this interface is used to restore them. + * + * @see https://stackoverflow.com/a/58473012 + */ +export interface InputBoxComponentType { + ( + props: InputBoxProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; +} diff --git a/packages/input-box/src/InputBox/index.ts b/packages/input-box/src/InputBox/index.ts new file mode 100644 index 0000000000..5b2e30901f --- /dev/null +++ b/packages/input-box/src/InputBox/index.ts @@ -0,0 +1,2 @@ +export { InputBox } from './InputBox'; +export { type InputBoxProps } from './InputBox.types'; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx new file mode 100644 index 0000000000..9ff76d1558 --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; +import { Size } from '@leafygreen-ui/tokens'; + +import { + charsPerSegmentMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputBoxProvider, useInputBoxContext } from './InputBoxContext'; + +describe('InputBoxContext', () => { + const mockOnChange = jest.fn(); + const mockOnBlur = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('throws error when used outside of InputBoxProvider', () => { + /** + * The version of `renderHook` imported from "@testing-library/react-hooks", (used in React 17) + * has an error boundary, and doesn't throw errors as expected: + * https://github.com/testing-library/react-hooks-testing-library/blob/main/src/index.ts#L5 + * */ + if (isReact17()) { + const { result } = renderHook(() => useInputBoxContext()); + expect(result.error.message).toEqual( + 'useInputBoxContext must be used within a InputBoxProvider', + ); + } else { + expect(() => + renderHook(() => useInputBoxContext()), + ).toThrow('useInputBoxContext must be used within a InputBoxProvider'); + } + }); + + test('provides context values that match the props passed to the provider', () => { + const { result } = renderHook(() => useInputBoxContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.charsPerSegment).toBe(charsPerSegmentMock); + expect(result.current.segmentEnum).toBe(SegmentObjMock); + expect(result.current.onChange).toBe(mockOnChange); + expect(result.current.onBlur).toBe(mockOnBlur); + expect(result.current.segmentRefs).toBe(segmentRefsMock); + expect(result.current.segments).toBe(segmentsMock); + expect(result.current.size).toBe(Size.Default); + expect(result.current.disabled).toBe(false); + }); +}); diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx new file mode 100644 index 0000000000..23ec2fc4fc --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -0,0 +1,77 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, +} from 'react'; +import { + InputBoxContextType, + InputBoxProviderProps, +} from './InputBoxContext.types'; + +// The Context constant is defined with the default/fixed type, which is string. This is the loose type because we don't know the type of the string yet. +export const InputBoxContext = createContext(null); + +// Provider is generic over T, the string union +export const InputBoxProvider = ({ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, +}: PropsWithChildren>) => { + const value = useMemo( + () => ({ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, + }), + [ + charsPerSegment, + children, + disabled, + labelledBy, + onChange, + onBlur, + segments, + segmentEnum, + segmentRefs, + size, + ], + ); + + // The provider passes a strict type of T but the context is defined as a loose type of string so TS sees a potential type mismatch. This assertion says that we know that the types do not overlap but we guarantee that the strict provider value satisfies the fixed context requirement. + return ( + + {children} + + ); +}; + +// The hook is generic over T, the string union +export const useInputBoxContext = () => { + // Assert the context type to the specific generic T + const context = useContext( + InputBoxContext, + ) as InputBoxContextType | null; + + if (!context) { + throw new Error( + 'useInputBoxContext must be used within a InputBoxProvider', + ); + } + + return context; +}; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts new file mode 100644 index 0000000000..40f47a35c7 --- /dev/null +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.types.ts @@ -0,0 +1,21 @@ +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputSegmentChangeEventHandler } from '../InputSegment/InputSegment.types'; + +type SegmentEnumObject = Record; + +export interface InputBoxContextType { + charsPerSegment: Record; + disabled: boolean; + segmentEnum: SegmentEnumObject; + onChange: InputSegmentChangeEventHandler; + onBlur: (event: React.FocusEvent) => void; + segmentRefs: Record>>; + segments: Record; + labelledBy?: string; + size: Size; +} + +export interface InputBoxProviderProps + extends InputBoxContextType {} diff --git a/packages/input-box/src/InputBoxContext/index.ts b/packages/input-box/src/InputBoxContext/index.ts new file mode 100644 index 0000000000..b438cee411 --- /dev/null +++ b/packages/input-box/src/InputBoxContext/index.ts @@ -0,0 +1,10 @@ +export { + InputBoxContext, + InputBoxProvider, + useInputBoxContext, +} from './InputBoxContext'; + +export type { + InputBoxContextType, + InputBoxProviderProps, +} from './InputBoxContext.types'; diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx new file mode 100644 index 0000000000..150239094f --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -0,0 +1,843 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { renderSegment, setSegmentProps } from '../testutils'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + SegmentObjMock, +} from '../testutils/testutils.mocks'; +import { getValueFormatter } from '../utils'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +describe('packages/input-segment', () => { + describe('aria attributes', () => { + test(`segment has aria-label`, () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + expect(input).toHaveAttribute('aria-label', 'day'); + }); + + test('has role="spinbutton"', () => { + const { input } = renderSegment({}); + expect(input).toHaveAttribute('role', 'spinbutton'); + }); + + test('has min and max attributes', () => { + const { input } = renderSegment({ + props: { segment: 'day' }, + }); + expect(input).toHaveAttribute('min', String(defaultMinMock['day'])); + expect(input).toHaveAttribute('max', String(defaultMaxMock['day'])); + }); + }); + + describe('rendering', () => { + test('Rendering with undefined sets the value to empty string', () => { + const { input } = renderSegment({}); + expect(input.value).toBe(''); + }); + + test('Rendering with a value sets the input value', () => { + const { input } = renderSegment({ + providerProps: { segments: { day: '12', month: '', year: '' } }, + }); + expect(input.value).toBe('12'); + }); + + test('rerendering updates the value', () => { + const { getInput, rerenderSegment } = renderSegment({ + providerProps: { segments: { day: '12', month: '', year: '' } }, + }); + + rerenderSegment({ + newProviderProps: { segments: { day: '08', month: '', year: '' } }, + }); + expect(getInput().value).toBe('08'); + }); + }); + + describe('typing', () => { + describe('into an empty segment', () => { + test('calls the change handler', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '8'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '8' }), + ); + }); + + test('allows zero character', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '0'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '0' }), + ); + }); + + test('does not allow non-number characters', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + userEvent.type(input, 'aB$/'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('into a segment with a value', () => { + test('allows typing additional characters if the current value is incomplete', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + segments: { day: '2', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + + userEvent.type(input, '6'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '26' }), + ); + }); + + test('resets the value when the value is complete', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + segments: { day: '26', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); + }); + + describe('keyboard events', () => { + describe('Arrow keys', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegmentMock['day'], + allowZero: defaultMinMock['day'] === 0, + }); + + describe('Up arrow', () => { + test('calls handler with value default +1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(16), + }), + ); + }); + + test('calls handler with custom `step`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(17), + }), + ); + }); + + test('calls handler with `min`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('rolls value over to `min` value if value exceeds `max`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMaxMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day'] + 1), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldWrap: false, + }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '0', month: '', year: '3' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'year', value: '0004' }), + ); + }); + + test('formats value with leading zero', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '06', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '07' }), + ); + }); + + test('formats values without leading zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '3', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowup}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '04' }), + ); + }); + }); + + describe('Down arrow', () => { + test('calls handler with value default -1 step', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(14), + }), + ); + }); + + test('calls handler with custom `step`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { step: 2 }, + providerProps: { + onChange: onChangeHandler, + segments: { day: formatter(15), month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(13), + }), + ); + }); + + test('calls handler with `max`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('rolls value over to `max` value if value exceeds `min`', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMaxMock['day']), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { shouldWrap: false }, + providerProps: { + onChange: onChangeHandler, + segments: { + day: formatter(defaultMinMock['day']), + month: '', + year: '', + }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + value: formatter(defaultMinMock['day'] - 1), + }), + ); + }); + + test('does not wrap if `shouldWrap` is false and value is less than min', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldWrap: false, + }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '0', month: '', year: '3' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'year', value: '0002' }), + ); + }); + + test('formats value with leading zero', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '06', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '05' }), + ); + }); + + test('formats values without leading zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day' }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '3', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{arrowdown}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '02' }), + ); + }); + }); + + describe('Backspace', () => { + test('clears the input when there is a value', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{backspace}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('Space', () => { + describe('on a single SPACE', () => { + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('calls the onChangeHandler when the value is present', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + + describe('on a double SPACE', () => { + test('does not call the onChangeHandler when the value is initially empty', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + + test('calls the onChangeHandler when the value is present', () => { + const onChangeHandler = + jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { + onChange: onChangeHandler, + segments: { day: '12', month: '', year: '' }, + }, + }); + + userEvent.type(input, '{space}{space}'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '' }), + ); + }); + }); + }); + }); + }); + + describe('min/max range', () => { + test('does not allow values outside max range', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // max is 31 + const { input } = renderSegment({ + providerProps: { + segments: { day: '3', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '2'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '2' }), + ); + }); + + test('allows values below min range', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // min is 1. We allow values below min range. + const { input } = renderSegment({ + props: { ...setSegmentProps('month') }, + providerProps: { + segments: { day: '', month: '', year: '' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '0'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '0' }), + ); + }); + + test('allows values above max range when skipValidation is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + // max is 2038 + const { input } = renderSegment({ + props: { + ...setSegmentProps('year'), + shouldSkipValidation: true, + }, + providerProps: { + segments: { day: '', month: '', year: '203' }, + onChange: onChangeHandler, + }, + }); + userEvent.type(input, '9'); + // returns the last valid value + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '2039' }), + ); + }); + }); + }); + + describe('onBlur handler', () => { + test('calls the custom onBlur prop when provided', () => { + const onBlurHandler = jest.fn(); + const { input } = renderSegment({ + props: { onBlur: onBlurHandler }, + }); + + input.focus(); + input.blur(); + + expect(onBlurHandler).toHaveBeenCalled(); + }); + + test('calls both context and prop onBlur handlers', () => { + const contextOnBlur = jest.fn(); + const propOnBlur = jest.fn(); + const { input } = renderSegment({ + props: { onBlur: propOnBlur }, + providerProps: { onBlur: contextOnBlur }, + }); + + input.focus(); + input.blur(); + + expect(contextOnBlur).toHaveBeenCalled(); + expect(propOnBlur).toHaveBeenCalled(); + }); + }); + + describe('custom onKeyDown handler', () => { + test('calls the custom onKeyDown prop when provided', () => { + const onKeyDownHandler = jest.fn(); + const { input } = renderSegment({ + props: { onKeyDown: onKeyDownHandler }, + }); + + userEvent.type(input, '5'); + + expect(onKeyDownHandler).toHaveBeenCalled(); + }); + + test('custom onKeyDown is called alongside internal handler', () => { + const onKeyDownHandler = jest.fn(); + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { onKeyDown: onKeyDownHandler }, + providerProps: { onChange: onChangeHandler }, + }); + + userEvent.type(input, '{arrowup}'); + + expect(onKeyDownHandler).toHaveBeenCalled(); + expect(onChangeHandler).toHaveBeenCalled(); + }); + }); + + describe('disabled state', () => { + test('input is disabled when disabled context prop is true', () => { + const { input } = renderSegment({ + providerProps: { disabled: true }, + }); + + expect(input).toBeDisabled(); + }); + + test('does not call onChange when disabled and typed into', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + providerProps: { disabled: true, onChange: onChangeHandler }, + }); + + userEvent.type(input, '5'); + + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('shouldSkipValidation prop', () => { + test('allows values outside min/max range when shouldSkipValidation is true', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', shouldSkipValidation: true }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '9', month: '', year: '' }, + }, + }); + + userEvent.type(input, '9'); + + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ segment: 'day', value: '99' }), + ); + }); + + test('does not allows values outside min/max range when shouldSkipValidation is false', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + props: { segment: 'day', shouldSkipValidation: false }, + providerProps: { + onChange: onChangeHandler, + segments: { day: '9', month: '', year: '' }, + }, + }); + + userEvent.type(input, '9'); + + expect(onChangeHandler).not.toHaveBeenCalled(); + }); + }); + + describe('custom onChange prop', () => { + test('calls prop-level onChange in addition to context onChange', () => { + const contextOnChange = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const propOnChange = jest.fn(); + const { input } = renderSegment({ + props: { onChange: propOnChange }, + providerProps: { onChange: contextOnChange }, + }); + + userEvent.type(input, '5'); + + expect(contextOnChange).toHaveBeenCalled(); + expect(propOnChange).toHaveBeenCalled(); + }); + }); + + /* eslint-disable jest/no-disabled-tests */ + describe.skip('types behave as expected', () => { + test('InputSegment throws error when no required props are provided', () => { + // @ts-expect-error - missing required props + ; + }); + + test('With required props', () => { + ; + }); + + test('With all props', () => { + {}} + onKeyDown={() => {}} + disabled={false} + data-testid="test-id" + id="day" + ref={React.createRef()} + />; + }); + }); +}); diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx new file mode 100644 index 0000000000..459f6b9d8e --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -0,0 +1,157 @@ +import React, { useState } from 'react'; +import { + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; +import { StoryFn } from '@storybook/react'; + +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; +import { Size } from '@leafygreen-ui/tokens'; + +import { InputBoxProvider } from '../InputBoxContext'; +import { + charsPerSegmentMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +import { InputSegment, InputSegmentChangeEventHandler } from '.'; + +interface InputSegmentStoryProps { + size: Size; + segments: Record; +} + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox/InputSegment', + component: InputSegment, + decorators: [ + (StoryFn, context: any) => ( + + + + ), + ], + args: { + segment: SegmentObjMock.Day, + min: defaultMinMock[SegmentObjMock.Day], + max: defaultMaxMock[SegmentObjMock.Day], + size: Size.Default, + placeholder: defaultPlaceholderMock[SegmentObjMock.Day], + shouldWrap: true, + step: 1, + darkMode: false, + }, + argTypes: { + size: { + control: 'select', + options: Object.values(Size), + }, + darkMode: { + control: 'boolean', + }, + }, + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segment', + 'value', + 'onChange', + 'charsPerSegment', + 'segmentEnum', + 'min', + 'max', + 'shouldWrap', + 'shouldSkipValidation', + 'step', + 'placeholder', + ], + }, + generate: { + combineArgs: { + darkMode: [false, true], + segment: ['day', 'month', 'year'], + size: Object.values(Size), + segments: [ + { + day: '2', + month: '8', + year: '2025', + }, + { + day: '00', + month: '0', + year: '0000', + }, + { + day: '', + month: '', + year: '', + }, + ], + }, + decorator: (StoryFn, context) => ( + + {}} + onBlur={() => {}} + segmentRefs={segmentRefsMock} + segments={context?.args.segments} + size={context?.args.size} + disabled={false} + > + + + + ), + }, + }, +}; +export default meta; + +export const LiveExample: StoryFn = ( + props, + context: any, +) => { + const [segments, setSegments] = useState(segmentsMock); + + const handleChange: InputSegmentChangeEventHandler< + SegmentObjMock, + string + > = ({ segment, value }) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; + + return ( + {}} + segmentRefs={segmentRefsMock} + segments={segments} + disabled={false} + size={context?.args?.size || Size.Default} + > + + + ); +}; + +export const Generated = () => {}; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts new file mode 100644 index 0000000000..430cb6efe4 --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -0,0 +1,103 @@ +import { css } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { palette } from '@leafygreen-ui/palette'; +import { + BaseFontSize, + fontFamilies, + Size, + typeScales, +} from '@leafygreen-ui/tokens'; + +export const baseStyles = css` + font-family: ${fontFamilies.default}; + font-size: ${BaseFontSize.Body1}px; + font-variant: tabular-nums; + text-align: center; + border: none; + border-radius: 0; + padding: 0; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; + } + -moz-appearance: textfield; /* Firefox */ + appearance: textfield; + + &:focus { + outline: none; + } +`; + +export const segmentThemeStyles: Record = { + [Theme.Light]: css` + background-color: transparent; + color: ${palette.black}; + + &::placeholder { + color: ${palette.gray.light1}; + } + + &:focus { + background-color: ${palette.blue.light3}; + } + `, + [Theme.Dark]: css` + background-color: transparent; + color: ${palette.gray.light2}; + + &::placeholder { + color: ${palette.gray.dark1}; + } + + &:focus { + background-color: ${palette.blue.dark3}; + } + `, +}; + +export const fontSizeStyles: Record = { + [BaseFontSize.Body1]: css` + --base-font-size: ${BaseFontSize.Body1}px; + `, + [BaseFontSize.Body2]: css` + --base-font-size: ${BaseFontSize.Body2}px; + `, +}; + +export const segmentSizeStyles: Record = { + [Size.XSmall]: css` + font-size: ${typeScales.body1.fontSize}px; + `, + [Size.Small]: css` + font-size: ${typeScales.body1.fontSize}px; + `, + [Size.Default]: css` + font-size: var(--base-font-size, ${typeScales.body1.fontSize}px); + `, + [Size.Large]: css` + font-size: ${18}px; // Intentionally off-token + `, +}; + +export const getInputSegmentStyles = ({ + className, + baseFontSize, + theme, + size, +}: { + className?: string; + baseFontSize: BaseFontSize; + theme: Theme; + size: Size; +}) => { + return css` + ${baseStyles} + ${fontSizeStyles[baseFontSize]} + ${segmentThemeStyles[theme]} + ${segmentSizeStyles[size]} + ${className} + `; +}; diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx new file mode 100644 index 0000000000..82d30ad76a --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -0,0 +1,240 @@ +import React, { + ChangeEventHandler, + FocusEvent, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; + +import { VisuallyHidden } from '@leafygreen-ui/a11y'; +import { useMergeRefs } from '@leafygreen-ui/hooks'; +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { keyMap } from '@leafygreen-ui/lib'; +import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; + +import { useInputBoxContext } from '../InputBoxContext'; +import { + getNewSegmentValueFromArrowKeyPress, + getNewSegmentValueFromInputValue, + getValueFormatter, +} from '../utils'; + +import { getInputSegmentStyles } from './InputSegment.styles'; +import { + InputSegmentComponentType, + InputSegmentProps, +} from './InputSegment.types'; + +/** + * Generic controlled input segment component + * + * Renders a single input segment with configurable + * character padding, validation, and formatting. + * + * @internal + */ +const InputSegmentWithRef = ( + { + segment, + onKeyDown, + min, // minSegmentValue + max, // maxSegmentValue + className, + onChange: onChangeProp, + onBlur: onBlurProp, + step = 1, + shouldWrap = true, + shouldSkipValidation = false, + ...rest + }: InputSegmentProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + const { + onChange, + onBlur, + charsPerSegment: charsPerSegmentContext, + segmentEnum, + segmentRefs, + segments, + labelledBy, + size, + disabled, + } = useInputBoxContext(); + const baseFontSize = useUpdatedBaseFontSize(); + const charsPerSegment = charsPerSegmentContext[segment]; + const formatter = getValueFormatter({ + charsPerSegment, + allowZero: min === 0, + }); + const pattern = `[0-9]{${charsPerSegment}}`; + + const segmentRef = segmentRefs[segment]; + const mergedRef = useMergeRefs([fwdRef, segmentRef]); + const value = segments[segment]; + + /** + * Receives native input events, + * determines whether the input value is valid and should change, + * and fires a custom `InputSegmentChangeEvent`. + */ + const handleChange: ChangeEventHandler = e => { + const { target } = e; + + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: value, + incomingValue: target.value, + charsPerSegment, + defaultMin: min, + defaultMax: max, + segmentEnum, + shouldSkipValidation, + }); + + const hasValueChanged = newValue !== value; + + if (hasValueChanged) { + onChange({ + segment, + value: newValue, + meta: { min }, + }); + } else { + // If the value has not changed, ensure the input value is reset + target.value = value; + } + + onChangeProp?.(e); + }; + + /** Handle keydown presses that don't natively fire a change event */ + const handleKeyDown: KeyboardEventHandler = e => { + const { key, target } = e as React.KeyboardEvent & { + target: HTMLInputElement; + }; + + // A key press can be an `arrow`, `enter`, `space`, etc so we check for number presses + // We also check for `space` because Number(' ') returns true + const isNumber = Number(key) && key !== keyMap.Space; + + if (isNumber) { + // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called. + + if (target.value.length === charsPerSegment) { + target.value = ''; + } + } + + switch (key) { + case keyMap.ArrowUp: + case keyMap.ArrowDown: { + e.preventDefault(); + + const newValue = getNewSegmentValueFromArrowKeyPress({ + key, + value, + min, + max, + step, + shouldWrap: shouldWrap, + }); + const valueString = formatter(newValue); + + /** Fire a custom change event when the up/down arrow keys are pressed */ + onChange({ + segment, + value: valueString, + meta: { key, min }, + }); + break; + } + + // On backspace the value is reset + case keyMap.Backspace: { + // Don't fire change event if the input is initially empty + if (value) { + // Stop propagation to prevent parent handlers from firing + e.stopPropagation(); + + /** Fire a custom change event when the backspace key is pressed */ + onChange({ + segment, + value: '', + meta: { key, min }, + }); + } + + break; + } + + // On space the value is reset + case keyMap.Space: { + e.preventDefault(); + + // Don't fire change event if the input is initially empty + if (value) { + /** Fire a custom change event when the space key is pressed */ + onChange({ + segment, + value: '', + meta: { key, min }, + }); + } + + break; + } + + default: { + break; + } + } + + onKeyDown?.(e); + }; + + const handleBlur = (e: FocusEvent) => { + onBlur?.(e); + onBlurProp?.(e); + }; + + // Note: Using a text input with pattern attribute due to Firefox + // stripping leading zeros on number inputs - Thanks @matt-d-rat + // Number inputs also don't support the `selectionStart`/`End` API + return ( + <> + + + {value && `${segment} ${value}`} + + + ); +}; + +export const InputSegment = React.forwardRef( + InputSegmentWithRef, +) as InputSegmentComponentType; + +InputSegment.displayName = 'InputSegment'; diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts new file mode 100644 index 0000000000..7cbeaa34db --- /dev/null +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -0,0 +1,123 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + +import { keyMap } from '@leafygreen-ui/lib'; + +export interface InputSegmentChangeEvent< + Segment extends string, + Value extends string, +> { + segment: Segment; + value: Value; + meta?: { + key?: (typeof keyMap)[keyof typeof keyMap]; + min: number; + [key: string]: any; + }; +} + +// TODO: consider renaming min/max names to minSegment/maxSegment +/** + * The type for the onChange handler + */ +export type InputSegmentChangeEventHandler< + Segment extends string, + Value extends string, +> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void; + +export interface InputSegmentProps + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'size' | 'step' | 'value' + > { + /** + * Which segment this input represents + * + * @example + * 'day' + * 'month' + * 'year' + */ + segment: Segment; + + /** + * Minimum value for the segment + * + * @example + * 1 + * 1 + * 1970 + */ + min: number; + + /** + * Maximum value for the segment + * + * @example + * 31 + * 12 + * 2038 + */ + max: number; + + /** + * The step value for the arrow keys + * + * @default 1 + */ + step?: number; + + /** + * Whether the segment should wrap at min/max boundaries + * + * @default true + */ + shouldWrap?: boolean; + + /** + * Whether the segment should skip validation. This is useful for segments that allow values outside of the default range. + * + * @default false + */ + shouldSkipValidation?: boolean; +} + +/** + * Type definition for the InputSegment component that maintains generic type safety with forwardRef. + * + * Interface with a generic call signature that preserves type parameters() when using forwardRef. + * React.forwardRef loses type parameters, so this interface is used to restore them. + * + * @see https://stackoverflow.com/a/58473012 + */ +export interface InputSegmentComponentType { + ( + props: InputSegmentProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; +} + +/** + * Returns whether the given string is a valid segment + */ +export function isInputSegment>( + str: any, + segmentObj: T, +): str is T[keyof T] { + if (typeof str !== 'string') return false; + return Object.values(segmentObj).includes(str); +} + +/** + * Base props for custom segment components passed to InputBox. + * + * Extend this interface to define props for custom segment implementations. + * InputBox will provide additional props internally (e.g., onChange, value, min, max). + */ +export interface InputSegmentComponentProps + extends Omit< + React.ComponentPropsWithoutRef<'input'>, + 'onChange' | 'value' | 'min' | 'max' + > { + segment: Segment; +} diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts new file mode 100644 index 0000000000..8e2840befb --- /dev/null +++ b/packages/input-box/src/InputSegment/index.ts @@ -0,0 +1,6 @@ +export { InputSegment } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, + type InputSegmentProps, +} from './InputSegment.types'; diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index f70976968b..a4bdff7e54 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,3 +1,15 @@ +export { InputBox, type InputBoxProps } from './InputBox'; +export { + InputBoxProvider, + type InputBoxProviderProps, + useInputBoxContext, +} from './InputBoxContext'; +export { + InputSegment, + type InputSegmentChangeEventHandler, + type InputSegmentComponentProps, + type InputSegmentProps, +} from './InputSegment'; export { createExplicitSegmentValidator, type ExplicitSegmentRule, diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx new file mode 100644 index 0000000000..80cee23566 --- /dev/null +++ b/packages/input-box/src/testutils/index.tsx @@ -0,0 +1,250 @@ +import React from 'react'; +import { render, RenderResult } from '@testing-library/react'; + +import { Size } from '@leafygreen-ui/tokens'; + +import { InputBox, InputBoxProps } from '../InputBox'; +import { InputBoxProvider } from '../InputBoxContext'; +import { InputBoxProviderProps } from '../InputBoxContext'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentProps } from '../InputSegment/InputSegment.types'; + +import { + charsPerSegmentMock, + defaultFormatPartsMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, + segmentWidthStyles, +} from './testutils.mocks'; + +export const defaultProps: Partial> = { + segments: segmentsMock, + segmentEnum: SegmentObjMock, + segmentRefs: segmentRefsMock, + setSegment: () => {}, + charsPerSegment: charsPerSegmentMock, + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, +}; + +/** + * This component is used to render the InputSegment component for testing purposes. + * @param segment - The segment to render + * @returns + */ +export const InputSegmentWrapper = ({ + segment, +}: { + segment: SegmentObjMock; +}) => { + return ( + + ); +}; + +/** + * This component is used to render the InputBox component for testing purposes. + * Includes segment state management and a default renderSegment function. + * Props can override the internal state management. + */ +export const InputBoxWithState = ({ + segments: segmentsProp = { + day: '', + month: '', + year: '', + }, + setSegment: setSegmentProp, + disabled = false, + ...props +}: Partial> & { + segments?: Record; +}) => { + const dayRef = React.useRef(null); + const monthRef = React.useRef(null); + const yearRef = React.useRef(null); + + const segmentRefs = { + day: dayRef, + month: monthRef, + year: yearRef, + }; + + const [segments, setSegments] = React.useState(segmentsProp); + + const defaultSetSegment = (segment: SegmentObjMock, value: string) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; + + // If setSegment is provided, use controlled mode with the provided segments + // Otherwise, use internal state management + const effectiveSegments = setSegmentProp ? segmentsProp : segments; + const effectiveSetSegment = setSegmentProp ?? defaultSetSegment; + + return ( + + ); +}; + +interface RenderInputBoxReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + rerenderInputBox: (props: Partial>) => void; + getDayInput: () => HTMLInputElement; + getMonthInput: () => HTMLInputElement; + getYearInput: () => HTMLInputElement; +} + +/** + * Renders InputBox with internal state management for testing purposes. + * Props can be passed to override the default state behavior. + */ +export const renderInputBox = ({ + ...props +}: Partial> = {}): RenderResult & + RenderInputBoxReturnType => { + const result = render(); + + const getDayInput = () => + result.getByTestId('input-segment-day') as HTMLInputElement; + const getMonthInput = () => + result.getByTestId('input-segment-month') as HTMLInputElement; + const getYearInput = () => + result.getByTestId('input-segment-year') as HTMLInputElement; + + const rerenderInputBox = ( + newProps: Partial>, + ) => { + result.rerender(); + }; + + return { + ...result, + rerenderInputBox, + dayInput: getDayInput(), + monthInput: getMonthInput(), + yearInput: getYearInput(), + getDayInput, + getMonthInput, + getYearInput, + }; +}; + +/* + * InputSegment Utils + */ +export const setSegmentProps = (segment: SegmentObjMock) => { + return { + segment: segment, + charsPerSegment: charsPerSegmentMock[segment], + min: defaultMinMock[segment], + max: defaultMaxMock[segment], + placeholder: defaultPlaceholderMock[segment], + }; +}; + +interface RenderSegmentReturnType { + getInput: () => HTMLInputElement; + input: HTMLInputElement; + rerenderSegment: (params: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => void; +} + +const defaultSegmentProviderProps: Partial< + InputBoxProviderProps +> = { + charsPerSegment: charsPerSegmentMock, + segmentEnum: SegmentObjMock, + onChange: () => {}, + onBlur: () => {}, + segments: { + day: '', + month: '', + year: '', + }, + segmentRefs: segmentRefsMock, +}; + +const defaultSegmentProps: InputSegmentProps = { + segment: 'day', + min: defaultMinMock['day'], + max: defaultMaxMock['day'], + shouldWrap: true, + placeholder: defaultPlaceholderMock['day'], + // @ts-expect-error - data-testid + ['data-testid']: 'lg-input-segment', +}; + +/** + * Renders the InputSegment component for testing purposes. + */ +export const renderSegment = ({ + props = {}, + providerProps = {}, +}: { + props?: Partial>; + providerProps?: Partial>; +}): RenderResult & RenderSegmentReturnType => { + const mergedProps = { + ...defaultSegmentProps, + ...props, + } as InputSegmentProps; + + const mergedProviderProps = { + ...defaultSegmentProviderProps, + ...providerProps, + } as InputBoxProviderProps; + + const utils = render( + + + , + ); + + const rerenderSegment = ({ + newProps = {}, + newProviderProps = {}, + }: { + newProps?: Partial>; + newProviderProps?: Partial>; + }) => { + utils.rerender( + + + , + ); + }; + + const getInput = () => + utils.getByTestId('lg-input-segment') as HTMLInputElement; + return { ...utils, getInput, input: getInput(), rerenderSegment }; +}; diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts new file mode 100644 index 0000000000..d1e062ac30 --- /dev/null +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -0,0 +1,85 @@ +import { createRef } from 'react'; + +import { css } from '@leafygreen-ui/emotion'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +import { ExplicitSegmentRule } from '../utils'; + +export const SegmentObjMock = { + Month: 'month', + Day: 'day', + Year: 'year', +} as const; +export type SegmentObjMock = + (typeof SegmentObjMock)[keyof typeof SegmentObjMock]; + +export type SegmentRefsMock = Record< + SegmentObjMock, + ReturnType> +>; + +export const segmentRefsMock: SegmentRefsMock = { + month: createRef(), + day: createRef(), + year: createRef(), +}; + +export const segmentsMock: Record = { + month: '02', + day: '02', + year: '2025', +}; +export const charsPerSegmentMock: Record = { + month: 2, + day: 2, + year: 4, +}; +export const segmentRulesMock: Record = { + month: { maxChars: 2, minExplicitValue: 2 }, + day: { maxChars: 2, minExplicitValue: 4 }, + year: { maxChars: 4, minExplicitValue: 1970 }, +}; +export const defaultMinMock: Record = { + month: 1, + day: 0, + year: 1970, +}; +export const defaultMaxMock: Record = { + month: 12, + day: 31, + year: 2038, +}; + +export const defaultPlaceholderMock: Record = { + day: 'DD', + month: 'MM', + year: 'YYYY', +} as const; + +export const defaultFormatPartsMock: Array = [ + { type: 'month', value: '' }, + { type: 'literal', value: '-' }, + { type: 'day', value: '' }, + { type: 'literal', value: '-' }, + { type: 'year', value: '' }, +]; + +/** The percentage of 1ch these specific characters take up */ +export const characterWidth = { + // // Standard font + D: 46 / 40, + M: 55 / 40, + Y: 50 / 40, +} as const; + +export const segmentWidthStyles: Record = { + day: css` + width: ${charsPerSegmentMock.day * characterWidth.D}ch; + `, + month: css` + width: ${charsPerSegmentMock.month * characterWidth.M}ch; + `, + year: css` + width: ${charsPerSegmentMock.year * characterWidth.Y}ch; + `, +}; diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 3c0ed0b910..447d2f4ac0 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -25,7 +25,11 @@ export interface ExplicitSegmentRule { * * @param segmentEnum - The segment enum/object containing the segment names and their corresponding values to validate against * @param rules - Rules for each segment type - * @returns A function that checks if a segment value is explicit + * @returns A function that checks if a segment value is explicit and accepts the segment, value, and allowZero parameters + * + * @param segment - The segment to validate + * @param value - The value to validate + * @param allowZero - Whether to allow zero values * * @example * const segmentObj = { @@ -73,11 +77,19 @@ export function createExplicitSegmentValidator< segmentEnum: SegmentEnum; rules: Record; }) { - return (segment: SegmentEnum[keyof SegmentEnum], value: string): boolean => { + return ( + segment: SegmentEnum[keyof SegmentEnum], + value: string, + allowZero?: boolean, + ): boolean => { if ( - !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) - ) + !( + isValidSegmentValue(value, allowZero) && + isValidSegmentName(segmentEnum, segment) + ) + ) { return false; + } const rule = rules[segment]; if (!rule) return false; diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json index cba2152d8f..7f78ef8970 100644 --- a/packages/input-box/tsconfig.json +++ b/packages/input-box/tsconfig.json @@ -18,6 +18,9 @@ "**/*.stories.*" ], "references": [ + { + "path": "../a11y" + }, { "path": "../emotion" }, From 35d975b1c61d9d6bdebf4045e76203b4df090c9a Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 13:46:16 -0500 Subject: [PATCH 02/60] feat(input-box): add '@leafygreen-ui/a11y' as a dependency in pnpm-lock.yaml --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de735ab2c..bb53b8e9d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2255,6 +2255,9 @@ importers: packages/input-box: dependencies: + '@leafygreen-ui/a11y': + specifier: workspace:^ + version: link:../a11y '@leafygreen-ui/date-utils': specifier: workspace:^ version: link:../date-utils From 2f81c18508fda0894befea21feced44245b4fc16 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 17:25:04 -0500 Subject: [PATCH 03/60] fix(input-box): fix lint errors --- packages/input-box/README.md | 50 +++++++++---------- packages/input-box/src/InputBox.stories.tsx | 2 +- .../src/InputBoxContext/InputBoxContext.tsx | 1 + .../input-box/src/InputBoxContext/index.ts | 1 - 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 3961601fb7..03d9832031 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -49,18 +49,18 @@ const MySegment = ({ segment, ...props }) => ( { type: 'literal', value: '/' }, { type: 'day', value: '01' }, { type: 'literal', value: '/' }, - { type: 'year', value: '2025' } + { type: 'year', value: '2025' }, ]} charsPerSegment={{ day: 2, month: 2, year: 4 }} segmentRefs={{ day: dayRef, month: monthRef, year: yearRef }} segmentRules={{ day: { maxChars: 2, minExplicitValue: 1 }, month: { maxChars: 2, minExplicitValue: 4 }, - year: { maxChars: 4, minExplicitValue: 1970 } + year: { maxChars: 4, minExplicitValue: 1970 }, }} disabled={false} size={Size.Default} -/> +/>; ``` Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for an implementation example. @@ -86,20 +86,20 @@ The component handles high-level interactions like moving between segments, whil #### Props -| Prop | Type | Description | Default | -| ------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `segments` | `Record` | An object containing the values of the segments.

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

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

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

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

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

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

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

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

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

Example: `'date-input-label'` | | +| Prop | Type | Description | Default | +| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| `segments` | `Record` | An object containing the values of the segments.

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

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

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

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

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

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

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

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

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

Example: `'date-input-label'` | | \+ other HTML `div` element props @@ -118,13 +118,13 @@ A controlled input segment component that renders a single input field within an #### Props -| Prop | Type | Description | Default | -| ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------- | ------- | -| `segment` | `string` | The segment identifier.

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

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

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

Example: `true` to wrap 31 → 1 for days | | -| `shouldSkipValidation` | `boolean` | Skips validation for segments that allow extended ranges | | +| Prop | Type | Description | Default | +| ---------------------- | --------- | --------------------------------------------------------------------------------------------------------- | ------- | +| `segment` | `string` | The segment identifier.

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

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

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

Example: `true` to wrap 31 → 1 for days | | +| `shouldSkipValidation` | `boolean` | Skips validation for segments that allow extended ranges | | \+ native HTML `input` element props diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 83f9f0ff8a..c3d6db794a 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,11 +7,11 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; +import { Size } from '@leafygreen-ui/tokens'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; import { InputBoxWithState } from './testutils'; -import { Size } from '@leafygreen-ui/tokens'; const meta: StoryMetaType = { title: 'Components/Inputs/InputBox', diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 23ec2fc4fc..db487b6a7a 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -4,6 +4,7 @@ import React, { useContext, useMemo, } from 'react'; + import { InputBoxContextType, InputBoxProviderProps, diff --git a/packages/input-box/src/InputBoxContext/index.ts b/packages/input-box/src/InputBoxContext/index.ts index b438cee411..226a86c6bb 100644 --- a/packages/input-box/src/InputBoxContext/index.ts +++ b/packages/input-box/src/InputBoxContext/index.ts @@ -3,7 +3,6 @@ export { InputBoxProvider, useInputBoxContext, } from './InputBoxContext'; - export type { InputBoxContextType, InputBoxProviderProps, From 86fbca9ee5de775c1a2169ec5936f266df046a0d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 6 Nov 2025 17:37:48 -0500 Subject: [PATCH 04/60] feat(input-box): set default size for InputBox in stories and refactor InputSegment styles for improved class handling --- packages/input-box/src/InputBox.stories.tsx | 3 +++ .../src/InputSegment/InputSegment.styles.ts | 18 +++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index c3d6db794a..55e1c5400f 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -53,6 +53,9 @@ const meta: StoryMetaType = { options: Object.values(Size), }, }, + args: { + size: Size.Default, + }, }; export default meta; diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts index 430cb6efe4..c759609a82 100644 --- a/packages/input-box/src/InputSegment/InputSegment.styles.ts +++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts @@ -1,4 +1,4 @@ -import { css } from '@leafygreen-ui/emotion'; +import { css, cx } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; import { palette } from '@leafygreen-ui/palette'; import { @@ -78,7 +78,7 @@ export const segmentSizeStyles: Record = { font-size: var(--base-font-size, ${typeScales.body1.fontSize}px); `, [Size.Large]: css` - font-size: ${18}px; // Intentionally off-token + font-size: 18px; // Intentionally off-token `, }; @@ -93,11 +93,11 @@ export const getInputSegmentStyles = ({ theme: Theme; size: Size; }) => { - return css` - ${baseStyles} - ${fontSizeStyles[baseFontSize]} - ${segmentThemeStyles[theme]} - ${segmentSizeStyles[size]} - ${className} - `; + return cx( + baseStyles, + fontSizeStyles[baseFontSize], + segmentThemeStyles[theme], + segmentSizeStyles[size], + className, + ); }; From 7e6e4b4e92560d80415c8ed9002e34a4480f753f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 7 Nov 2025 15:30:32 -0500 Subject: [PATCH 05/60] 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 06/60] 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 07/60] 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 08/60] 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 09/60] 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 10/60] 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 11/60] 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 12/60] 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 13/60] 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 14/60] 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 15/60] 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 16/60] 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 17/60] 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 18/60] 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 19/60] 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 20/60] 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 21/60] 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 22/60] 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 23/60] 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 24/60] 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 25/60] 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 26/60] 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 27/60] 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 28/60] 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 29/60] 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 30/60] refactor(input-box): update InputSegment types to extend from 'div' and include additional props for improved functionality --- .../input-box/src/InputSegment/InputSegment.types.ts | 7 +++++-- packages/input-box/src/shared.types.ts | 10 +++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 1b3faf8414..03de1ebb2c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -7,9 +7,12 @@ import { InputSegmentComponentProps } from '../shared.types'; export interface InputSegmentProps extends Omit< React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' | 'onBlur' | 'onChange' + 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' >, - Omit, 'segments'> { + Pick< + InputSegmentComponentProps, + 'onChange' | 'onBlur' | 'segment' | 'segmentEnum' + > { /** * Minimum value for the segment * diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7ad6c9163c..97023db4d8 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -45,11 +45,11 @@ export function isInputSegment>( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit< - React.ComponentPropsWithoutRef<'input'>, - 'onChange' | 'value' | 'min' | 'max' | 'size' | 'disabled' - >, - Pick, 'segments' | 'segmentEnum'> { + extends Omit, 'onChange'>, + Pick< + SharedInputBoxTypes, + 'segments' | 'segmentEnum' | 'disabled' | 'segmentRefs' + > { /** * Which segment this input represents * From d4ec60db3cb2137296a3a62bde27f0730779e4f8 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 13:03:47 -0500 Subject: [PATCH 31/60] refactor(input-box): simplify SharedInputBoxTypes by removing redundant properties and enhancing type clarity --- .../input-box/src/InputBox/InputBox.types.ts | 34 ++----------------- .../InputBoxContext/InputBoxContext.spec.tsx | 3 ++ .../src/InputBoxContext/InputBoxContext.tsx | 1 + packages/input-box/src/shared.types.ts | 15 ++------ 4 files changed, 9 insertions(+), 44 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index ebc0f66e1a..db89a226fd 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -1,11 +1,11 @@ import React, { ForwardedRef, ReactElement } from 'react'; import { DateType } from '@leafygreen-ui/date-utils'; -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { InputSegmentChangeEventHandler, InputSegmentComponentProps, + SharedInputBoxTypes, } from '../shared.types'; import { ExplicitSegmentRule } from '../utils'; @@ -19,7 +19,8 @@ export type InputChangeEventHandler = ( ) => void; export interface InputBoxProps - extends Omit, 'onChange' | 'children'> { + extends Omit, 'onChange' | 'children'>, + SharedInputBoxTypes { /** * Callback fired when any segment changes, but not necessarily a full value */ @@ -30,30 +31,6 @@ export interface InputBoxProps */ labelledBy?: string; - /** - * An object that maps the segment names to their refs - * - * @example - * { day: ref, month: ref, year: ref } - */ - segmentRefs: Record>>; - - /** - * An enumerable object that maps the segment names to their values - * - * @example - * { Day: 'day', Month: 'month', Year: 'year' } - */ - segmentEnum: Record; - - /** - * An object containing the values of the segments - * - * @example - * { day: '1', month: '2', year: '2025' } - */ - segments: Record; - /** * A function that sets the value of a segment * @@ -84,11 +61,6 @@ export interface InputBoxProps */ charsPerSegment: Record; - /** - * Whether the input box is disabled - */ - disabled: boolean; - /** * An object that maps the segment names to their rules. * diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx index 31007d8db1..9bf6800b5c 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.spec.tsx @@ -1,3 +1,6 @@ +// TODO: NO LONGER NEEDED +// @ts-nocheck + import React from 'react'; import { isReact17, renderHook } from '@leafygreen-ui/testing-lib'; diff --git a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx index 4279a3e3fe..00b8ddba1e 100644 --- a/packages/input-box/src/InputBoxContext/InputBoxContext.tsx +++ b/packages/input-box/src/InputBoxContext/InputBoxContext.tsx @@ -1,4 +1,5 @@ // TODO: NO LONGER NEEDED +// @ts-nocheck import React, { createContext, diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 97023db4d8..f78cc586c9 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -82,16 +82,10 @@ export interface InputSegmentComponentProps /** * Shared Input Box Types + * + * These types are shared between the InputBox and the segmentComponent. */ export interface SharedInputBoxTypes { - /** - * The number of characters per segment - * - * @example - * { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - /** * An enumerable object that maps the segment names to their values * @@ -120,9 +114,4 @@ export interface SharedInputBoxTypes { * Whether the input box is disabled */ disabled: boolean; - - /** - * id of the labelling element - */ - labelledBy?: string; } From bf2eeda185b4a0fdb37009ce6287c255dc027c45 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 13:14:02 -0500 Subject: [PATCH 32/60] 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 33/60] refactor(input-box): simplify InputSegment types by removing Value generic and updating related components for improved clarity --- .../src/InputSegment/InputSegment.stories.tsx | 3 +-- .../src/InputSegment/InputSegment.tsx | 4 +-- .../src/InputSegment/InputSegment.types.ts | 8 +++--- packages/input-box/src/testutils/index.tsx | 27 ++++--------------- .../src/testutils/testutils.mocks.ts | 2 -- 5 files changed, 12 insertions(+), 32 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index af5263341b..6cb56d500b 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -13,7 +13,6 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, - InputSegmentValueMock, SegmentObjMock, } from '../testutils/testutils.mocks'; @@ -104,7 +103,7 @@ export const LiveExample: StoryFn = ({ darkMode: _darkMode, ...rest }) => { - const [value, setValue] = useState(''); + const [value, setValue] = useState(''); return ( ( +const InputSegmentWithRef = ( { segment, onKeyDown, @@ -40,7 +40,7 @@ const InputSegmentWithRef = ( shouldWrap = true, shouldValidate = true, ...rest - }: InputSegmentProps, + }: InputSegmentProps, fwdRef: ForwardedRef, ) => { const { theme } = useDarkMode(); diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 03de1ebb2c..f1c4757227 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -4,7 +4,7 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentComponentProps } from '../shared.types'; -export interface InputSegmentProps +export interface InputSegmentProps extends Omit< React.ComponentPropsWithRef<'input'>, 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' @@ -68,7 +68,7 @@ export interface InputSegmentProps * '2' * '2025' */ - value: Value; + value: string; /** * The number of characters per segment @@ -100,8 +100,8 @@ export interface InputSegmentProps * @see https://stackoverflow.com/a/58473012 */ export interface InputSegmentComponentType { - ( - props: InputSegmentProps, + ( + props: InputSegmentProps, ref: ForwardedRef, ): ReactElement | null; displayName?: string; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index c4d9e26dd9..bda44375ad 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -8,35 +8,18 @@ import { defaultMaxMock, defaultMinMock, defaultPlaceholderMock, - InputSegmentValueMock, SegmentObjMock, } from './testutils.mocks'; -/* - * InputSegment Utils // TODO: remove this? - */ -export const setSegmentProps = (segment: SegmentObjMock) => { - return { - segment: segment, - charsPerSegment: charsPerSegmentMock[segment], - minSegmentValue: defaultMinMock[segment], - maxSegmentValue: defaultMaxMock[segment], - placeholder: defaultPlaceholderMock[segment], - }; -}; - interface RenderSegmentReturnType { getInput: () => HTMLInputElement; input: HTMLInputElement; rerenderSegment: ( - newProps: Partial>, + newProps: Partial>, ) => void; } -const defaultSegmentProps: InputSegmentProps< - SegmentObjMock, - InputSegmentValueMock -> = { +const defaultSegmentProps: InputSegmentProps = { segment: 'day', minSegmentValue: defaultMinMock['day'], maxSegmentValue: defaultMaxMock['day'], @@ -55,17 +38,17 @@ const defaultSegmentProps: InputSegmentProps< * Renders the InputSegment component for testing purposes. */ export const renderSegment = ( - props: Partial>, + props: Partial>, ): RenderResult & RenderSegmentReturnType => { const mergedProps = { ...defaultSegmentProps, ...props, - } as InputSegmentProps; + } as InputSegmentProps; const utils = render(); const rerenderSegment = ( - newProps: Partial>, + newProps: Partial>, ) => { utils.rerender(); }; diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 8edda55612..0466e233e3 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -5,8 +5,6 @@ import { DynamicRefGetter } from '@leafygreen-ui/hooks'; import { ExplicitSegmentRule } from '../utils'; -export type InputSegmentValueMock = string; - export const SegmentObjMock = { Month: 'month', Day: 'day', From 3792f8bf247f72c5440abd55f545bb37fcdc6a59 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 15:19:05 -0500 Subject: [PATCH 34/60] refactor(input-box): update InputSegment and InputBox types to include value prop and streamline segment handling --- packages/input-box/src/InputBox/InputBox.tsx | 4 +- .../input-box/src/InputBox/InputBox.types.ts | 16 +++++ packages/input-box/src/shared.types.ts | 29 +++----- packages/input-box/src/testutils/index.tsx | 69 ++++++++++--------- 4 files changed, 65 insertions(+), 53 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index b6ee5feee7..9435b8a9d0 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -244,8 +244,8 @@ export const InputBoxWithRef = ( onChange={handleSegmentInputChange} onBlur={handleSegmentInputBlur} segmentEnum={segmentEnum} - segments={segments} - segmentRefs={segmentRefs} + value={segments[part.type]} + ref={segmentRefs[part.type]} disabled={disabled} /> ); diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index db89a226fd..156a4c3b1a 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -31,6 +31,14 @@ export interface InputBoxProps */ labelledBy?: string; + /** + * An object containing the values of the segments + * + * @example + * { day: '1', month: '2', year: '2025' } + */ + segments: Record; + /** * A function that sets the value of a segment * @@ -88,6 +96,14 @@ export interface InputBoxProps * segmentComponent={DateInputSegment} */ segmentComponent: React.ComponentType>; + + /** + * An object that maps the segment names to their refs + * + * @example + * { day: ref, month: ref, year: ref } + */ + segmentRefs: Record>; } /** diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 41c8ac4c6c..1409a7a8cb 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -45,7 +45,10 @@ export function isInputSegment>( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit, 'onChange'>, + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'onChange' | 'value' | 'disabled' + >, SharedInputBoxTypes { /** * Which segment this input represents @@ -69,12 +72,14 @@ export interface InputSegmentComponentProps onChange: InputSegmentChangeEventHandler; /** - * The handler for the onBlur event that will be read by the InputSegment component + * The value of the segment * * @example - * (event: React.FocusEvent) => void + * '1' + * '2' + * '2025' */ - onBlur: React.FocusEventHandler; + value: string; } /** @@ -91,22 +96,6 @@ export interface SharedInputBoxTypes { */ segmentEnum: Record; - /** - * An object that maps the segment names to their refs - * - * @example - * { day: ref, month: ref, year: ref } - */ - segmentRefs: Record>; - - /** - * An object containing the values of the segments - * - * @example - * { day: '1', month: '2', year: '2025' } - */ - segments: Record; - /** * Whether the input box is disabled */ diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 2846fc4fdb..9072b1f0ce 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -35,37 +35,44 @@ export const defaultProps: Partial> = { * @param segment - The segment to render * @returns */ -export const InputSegmentWrapper = ({ - segment, - segments, - onChange = () => {}, - onBlur = () => {}, - segmentEnum = SegmentObjMock, - disabled = false, - segmentRefs = segmentRefsMock, -}: InputSegmentComponentProps) => { - return ( - - ); -}; +export const InputSegmentWrapper = React.forwardRef< + HTMLInputElement, + InputSegmentComponentProps +>( + ( + { + segment, + value, + onChange = () => {}, + onBlur = () => {}, + segmentEnum = SegmentObjMock, + disabled = false, + }, + ref, + ) => { + return ( + + ); + }, +); /** * This component is used to render the InputBox component for testing purposes. From 7b1db769c2fb305c3fd6ba6347a5a686fdb75ca0 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:15:54 -0500 Subject: [PATCH 35/60] refactor(input-box): update InputSegment types to include value prop and remove unused segmentRefs and segments properties for improved clarity --- .../src/InputSegment/InputSegment.stories.tsx | 4 +-- .../src/InputSegment/InputSegment.tsx | 1 - packages/input-box/src/shared.types.ts | 29 ++++++------------- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 6cb56d500b..a548edda4c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -72,11 +72,11 @@ const meta: StoryMetaType = { darkMode: [false, true], segment: ['day', 'year'], size: Object.values(Size), - value: ['', '2', '0', '00', '2025', '0000'], + value: ['', '2', '02', '0', '00', '2025', '0000'], }, excludeCombinations: [ { - value: ['2', '0', '00'], + value: ['2', '02', '0', '00'], segment: 'year', }, { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index cc592b8598..f907525f95 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -180,7 +180,6 @@ const InputSegmentWithRef = ( <> >( * Extend this interface to define props for custom segment implementations. */ export interface InputSegmentComponentProps - extends Omit, 'onChange'>, + extends Omit< + React.ComponentPropsWithRef<'input'>, + 'onChange' | 'value' | 'disabled' + >, SharedInputBoxTypes { /** * Which segment this input represents @@ -69,12 +72,14 @@ export interface InputSegmentComponentProps onChange: InputSegmentChangeEventHandler; /** - * The handler for the onBlur event that will be read by the InputSegment component + * The value of the segment * * @example - * (event: React.FocusEvent) => void + * '1' + * '2' + * '2025' */ - onBlur: React.FocusEventHandler; + value: string; } /** @@ -91,22 +96,6 @@ export interface SharedInputBoxTypes { */ segmentEnum: Record; - /** - * An object that maps the segment names to their refs - * - * @example - * { day: ref, month: ref, year: ref } - */ - segmentRefs: Record>; - - /** - * An object containing the values of the segments - * - * @example - * { day: '1', month: '2', year: '2025' } - */ - segments: Record; - /** * Whether the input box is disabled */ From 20da91918fa2822bcdb8aefee493aca6c3fb2241 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:16:52 -0500 Subject: [PATCH 36/60] refactor(input-box): remove unused Size import from InputBox.spec.tsx for cleaner code --- packages/input-box/src/InputBox/InputBox.spec.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index a6e4c4f516..dfe2c7f376 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -3,8 +3,6 @@ import { jest } from '@jest/globals'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Size } from '@leafygreen-ui/tokens'; - import { InputSegmentChangeEventHandler } from '../shared.types'; import { InputBoxWithState, From 6942348825f3476545c057fe76d9ad759b9758a1 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 12 Nov 2025 16:48:51 -0500 Subject: [PATCH 37/60] refactor(input-box): enhance InputBox and InputSegment documentation, update props for clarity, and streamline type exports --- packages/input-box/README.md | 139 +++++++++++++-------- packages/input-box/src/index.ts | 8 +- packages/input-box/src/testutils/index.tsx | 4 +- 3 files changed, 97 insertions(+), 54 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 03d9832031..c60cd696e9 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -29,11 +29,28 @@ import { InputBox, InputSegment } from '@leafygreen-ui/input-box'; import { Size } from '@leafygreen-ui/tokens'; // 1. Create a custom segment component -const MySegment = ({ segment, ...props }) => ( +// InputBox will pass: segment, value, onChange, onBlur, segmentEnum, disabled, ref, aria-labelledby +// You add: minSegmentValue, maxSegmentValue, charsPerSegment, size, and any other InputSegment props +const MySegment = ({ + segment, + value, + onChange, + onBlur, + segmentEnum, + disabled, + ...props +}) => ( ); @@ -54,77 +71,101 @@ const MySegment = ({ segment, ...props }) => ( charsPerSegment={{ day: 2, month: 2, year: 4 }} segmentRefs={{ day: dayRef, month: monthRef, year: yearRef }} segmentRules={{ - day: { maxChars: 2, minExplicitValue: 1 }, - month: { maxChars: 2, minExplicitValue: 4 }, + day: { maxChars: 2, minExplicitValue: 4 }, + month: { maxChars: 2, minExplicitValue: 2 }, year: { maxChars: 4, minExplicitValue: 1970 }, }} disabled={false} - size={Size.Default} />; ``` -Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for an implementation example. +Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for a full implementation example. ## Overview -An internal component intended to be used by any date or time component, such as `DatePicker`, `TimeInput`, etc. +An internal component for building date or time inputs with multiple segments (e.g., `DatePicker`, `TimeInput`). -This package provides two main components that work together to create segmented input experiences. +### How It Works + +`InputBox` handles the high-level coordination (navigation, formatting, focus management), while `InputSegment` handles individual segment behavior (validation, arrow key increments). + +**The `segmentComponent` Pattern:** + +`InputBox` doesn't directly render `InputSegment` components. Instead, you provide a custom `segmentComponent` that acts as a wrapper: + +1. **InputBox automatically passes** these props to your `segmentComponent`: + + - `segment` - the segment identifier (e.g., `'day'`, `'month'`) + - `value` - the current segment value + - `onChange` - change handler for the segment + - `onBlur` - blur handler for the segment + - `segmentEnum` - the segment enum object + - `disabled` - whether the segment is disabled + - `ref` - ref for the input element + - `aria-labelledby` - accessibility label reference + +2. **Your `segmentComponent` adds** segment-specific configuration: + - `minSegmentValue` / `maxSegmentValue` - validation ranges + - `charsPerSegment` - character length + - `size` - input size + - `step`, `shouldWrap`, `shouldValidate` - optional behavior customization + +This pattern allows you to define segment-specific rules (like min/max values that vary by segment) while keeping the core InputBox logic generic and reusable. ### InputBox -A generic controlled input box component that renders an input with multiple segments separated by literals. +A generic controlled input component that renders multiple segments separated by literals (e.g., `MM/DD/YYYY`). **Key Features:** -- **Auto-format**: Automatically pads segment values with leading zeros (based on `charsPerSegment`) when they become explicit/unambiguous. A value is explicit when it either: (1) reaches the maximum character length, or (2) meets or exceeds the `minExplicitValue` threshold (e.g., typing "5" for day → "05", but typing "2" stays "2" since it could be 20-29). Also formats on blur. -- **Auto-focus**: Automatically advances focus to the next segment when the current segment is complete -- **Keyboard navigation**: Handles left/right arrow key navigation between segments -- **Segment management**: Renders segments and separators based on `formatParts` (from `Intl.DateTimeFormat`) - -The component handles high-level interactions like moving between segments, while delegating segment-specific logic to the `InputSegment` component. Internally, it uses `InputBoxContext` to share state and handlers across all segments. +- **Auto-format**: Pads values with leading zeros when explicit (reaches max length or `minExplicitValue` threshold) +- **Auto-advance**: Moves focus to next segment when current segment is complete +- **Keyboard navigation**: Arrow keys move between segments, backspace navigates back when empty #### Props -| Prop | Type | Description | Default | -| ------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | -| `segments` | `Record` | An object containing the values of the segments.

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

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

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

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

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

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

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

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

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

Example: `'date-input-label'` | | - -\+ other HTML `div` element props +| Prop | Type | Description | Default | +| ------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | ------- | +| `segments` | `Record` | Current values for all segments | | +| `setSegment` | `(segment: Segment, value: string) => void` | Callback to update a segment's value | | +| `segmentEnum` | `Record` | Maps segment names to values (e.g., `{ Day: 'day' }`) | | +| `segmentComponent` | `React.ComponentType>` | Custom wrapper component that renders InputSegment with segment-specific props | | +| `formatParts` | `Array` | Defines segment order and separators | | +| `charsPerSegment` | `Record` | Max characters per segment (e.g., `{ day: 2, year: 4 }`) | | +| `segmentRefs` | `Record>` | Refs for each segment input | | +| `segmentRules` | `Record` | Rules for auto-formatting (`maxChars`, `minExplicitValue`) | | +| `disabled` | `boolean` | Disables all segments | | +| `onSegmentChange` | `InputSegmentChangeEventHandler` | Callback fired on any segment change | | +| `labelledBy` | `string` | ID of labelling element for accessibility | | + +\+ other HTML `div` props ### InputSegment -A controlled input segment component that renders a single input field within an `InputBox`. +A generic controlled input field for a single segment within `InputBox`. **Key Features:** -- **Up/down arrow key navigation**: Increment/decrement segment values using arrow keys -- **Value validation**: Validates input against configurable min/max ranges -- **Auto-formatting**: Formats values with leading zeros based on character length -- **Rollover support**: Optionally rolls over values (e.g., 31 → 1 for days, or stops at boundaries) -- **Keyboard interaction**: Handles backspace and space keys to clear values -- **onChange/onBlur events**: Fires custom change events with segment metadata +- **Arrow key increment/decrement**: Up/down arrows adjust values with optional wrapping +- **Value validation**: Validates against min/max ranges +- **Keyboard shortcuts**: Backspace/Space clears the value #### Props -| Prop | Type | Description | Default | -| ---------------------- | --------- | --------------------------------------------------------------------------------------------------------- | ------- | -| `segment` | `string` | The segment identifier.

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

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

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

Example: `true` to wrap 31 → 1 for days | | -| `shouldSkipValidation` | `boolean` | Skips validation for segments that allow extended ranges | | - -\+ native HTML `input` element props +| Prop | Type | Description | Default | +| ----------------- | ------------------------------------------------- | -------------------------------------------- | ------- | +| `segment` | `Segment` | Segment identifier (e.g., `'day'`) | | +| `value` | `string` | Current segment value | | +| `minSegmentValue` | `number` | Minimum valid value | | +| `maxSegmentValue` | `number` | Maximum valid value | | +| `charsPerSegment` | `number` | Max character length | | +| `size` | `Size` | Input size | | +| `segmentEnum` | `Record` | Segment enum from InputBox | | +| `onChange` | `InputSegmentChangeEventHandler` | Change handler | | +| `onBlur` | `FocusEventHandler` | Blur handler | | +| `disabled` | `boolean` | Disables the segment | | +| `step` | `number` | Arrow key increment/decrement step | `1` | +| `shouldWrap` | `boolean` | Whether to wrap at boundaries (e.g., 31 → 1) | `true` | +| `shouldValidate` | `boolean` | Whether to validate against min/max | `true` | + +\+ native HTML `input` props diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index 845c076be5..f3e9605536 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,5 +1,9 @@ export { InputBox, type InputBoxProps } from './InputBox'; export { InputSegment, type InputSegmentProps } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + isInputSegment, +} from './shared.types'; export { createExplicitSegmentValidator, type ExplicitSegmentRule, @@ -11,7 +15,3 @@ export { isValidSegmentName, isValidSegmentValue, } from './utils/isValidSegment/isValidSegment'; -export { - type InputSegmentChangeEventHandler, - isInputSegment, -} from './shared.types'; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 9072b1f0ce..c565201007 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -5,6 +5,7 @@ import { Size } from '@leafygreen-ui/tokens'; import { InputBox, InputBoxProps } from '../InputBox'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; +import { InputSegmentComponentProps } from '../shared.types'; import { charsPerSegmentMock, @@ -18,7 +19,6 @@ import { segmentsMock, segmentWidthStyles, } from './testutils.mocks'; -import { InputSegmentComponentProps } from '../shared.types'; export const defaultProps: Partial> = { segments: segmentsMock, @@ -74,6 +74,8 @@ export const InputSegmentWrapper = React.forwardRef< }, ); +InputSegmentWrapper.displayName = 'InputSegmentWrapper'; + /** * This component is used to render the InputBox component for testing purposes. * Includes segment state management and a default renderSegment function. From 2dc01343a4ac0f5f9a64e4caf298f0b660ef7a1b Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 10:47:29 -0500 Subject: [PATCH 38/60] testing From b4dd84daef19a91493916518615520cde26998de Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:07:27 -0500 Subject: [PATCH 39/60] 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 40/60] 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 41/60] testing From 73ea2738631ac4a1847145b2bfae11273fec263f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 11:58:48 -0500 Subject: [PATCH 42/60] testing build From a55bf242b17244d39346895674c47b363817f67c Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 12:31:41 -0500 Subject: [PATCH 43/60] testing build From 67d8f9f0183125cb6627f1836c42a3f2cb205a39 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 14:43:23 -0500 Subject: [PATCH 44/60] test(input-segment): add test for resetting value with complete zeros and update InputSegment story with segmentEnum --- .../src/InputSegment/InputSegment.spec.tsx | 18 ++++++++++++++++++ .../src/InputSegment/InputSegment.stories.tsx | 1 + .../src/InputSegment/InputSegment.tsx | 9 +++------ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index f9888c6dc8..04c9fbf185 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -160,6 +160,24 @@ describe('packages/input-segment', () => { expect.objectContaining({ value: '4' }), ); }); + + test('resets the value when the value is complete with zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + value: '00', + maxSegmentValue: 31, + onChange: onChangeHandler, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); }); describe('keyboard events', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 80ea1054ac..316d71d127 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -42,6 +42,7 @@ const meta: StoryMetaType = { step: 1, darkMode: false, charsPerSegment: charsPerSegmentMock[SegmentObjMock.Day], + segmentEnum: SegmentObjMock, }, argTypes: { size: { diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index f907525f95..4a46fa7c59 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -90,13 +90,10 @@ const InputSegmentWithRef = ( target: HTMLInputElement; }; - // A key press can be an `arrow`, `enter`, `space`, etc so we check for number presses - // We also check for `space` because Number(' ') returns true - const isNumber = Number(key) && key !== keyMap.Space; - + // If the value is a number, we check if the input is full and reset it if it is. The number will be inserted into the input when onChange is called. + // This is to handle the case where the user tries to type a number when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked + const isNumber = /^[0-9]$/.test(key); if (isNumber) { - // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called. - if (target.value.length === charsPerSegment) { target.value = ''; } From d7c1fc2f066ecff639794069214870c34017f889 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 14:48:35 -0500 Subject: [PATCH 45/60] refactor(input-box): update separator literal styles to use new token-based approach --- .../input-box/src/InputBox/InputBox.styles.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.styles.ts b/packages/input-box/src/InputBox/InputBox.styles.ts index 53e3de972e..d5df050331 100644 --- a/packages/input-box/src/InputBox/InputBox.styles.ts +++ b/packages/input-box/src/InputBox/InputBox.styles.ts @@ -1,6 +1,6 @@ import { css, cx } from '@leafygreen-ui/emotion'; import { Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; +import { color, InteractionState, Variant } from '@leafygreen-ui/tokens'; export const segmentPartsWrapperStyles = css` display: flex; @@ -12,14 +12,10 @@ export const separatorLiteralStyles = css` user-select: none; `; -export const separatorLiteralDisabledStyles: Record = { - [Theme.Dark]: css` - color: ${palette.gray.dark2}; - `, - [Theme.Light]: css` - color: ${palette.gray.base}; - `, -}; +export const getSeparatorLiteralDisabledStyles = (theme: Theme) => + css` + color: ${color[theme].text[Variant.Disabled][InteractionState.Default]}; + `; export const getSeparatorLiteralStyles = ({ theme, @@ -29,7 +25,7 @@ export const getSeparatorLiteralStyles = ({ disabled?: boolean; }) => { return cx(separatorLiteralStyles, { - [separatorLiteralDisabledStyles[theme]]: disabled, + [getSeparatorLiteralDisabledStyles(theme)]: disabled, }); }; From f52ed19053ad1d8a1e6948dcda0f8b7167f0de45 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Thu, 13 Nov 2025 15:59:43 -0500 Subject: [PATCH 46/60] fix(input-segment): add missing line to check for number input handling --- packages/input-box/src/InputSegment/InputSegment.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 4a46fa7c59..9977d45b56 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -93,6 +93,7 @@ const InputSegmentWithRef = ( // If the value is a number, we check if the input is full and reset it if it is. The number will be inserted into the input when onChange is called. // This is to handle the case where the user tries to type a number when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked const isNumber = /^[0-9]$/.test(key); + if (isNumber) { if (target.value.length === charsPerSegment) { target.value = ''; From 68e5f2c7b334b74f26b8067fac665f456689bfdc Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 09:56:19 -0500 Subject: [PATCH 47/60] refactor(input-segment): update comments and variable name for clarity in digit input handling --- packages/input-box/src/InputSegment/InputSegment.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index ee965bca0e..936893770f 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -90,11 +90,11 @@ const InputSegmentWithRef = ( target: HTMLInputElement; }; - // If the value is a number, we check if the input is full and reset it if it is. The number will be inserted into the input when onChange is called. - // This is to handle the case where the user tries to type a number when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked - const isNumber = /^[0-9]$/.test(key); + // If the value is a single digit, we check if the input is full and reset it if it is. The digit will be inserted into the input when onChange is called. + // This is to handle the case where the user tries to type a single digit when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked + const isSingleDigit = /^[0-9]$/.test(key); - if (isNumber) { + if (isSingleDigit) { if (target.value.length === charsCount) { target.value = ''; } From 3896c9c51f3ee439057b08701859613b94c16fd4 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 09:57:52 -0500 Subject: [PATCH 48/60] refactor(input-box): update comment to reflect correct component responsible for increment/decrement logic --- packages/input-box/src/InputBox/InputBox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 9435b8a9d0..a1fef41a83 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -182,7 +182,7 @@ export const InputBoxWithRef = ( case keyMap.ArrowUp: case keyMap.ArrowDown: { - // increment/decrement logic implemented by DateInputSegment + // increment/decrement logic implemented by InputSegment break; } From 410813e146e6f602518ad01e6e9326d5ad178129 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 10:13:47 -0500 Subject: [PATCH 49/60] refactor(input-segment): utilize isSingleDigit utility for digit input handling --- .../input-box/src/InputSegment/InputSegment.tsx | 5 ++--- packages/input-box/src/utils/index.ts | 1 + .../src/utils/isSingleDigit/isSingleDigit.spec.ts | 14 ++++++++++++++ .../src/utils/isSingleDigit/isSingleDigit.ts | 7 +++++++ 4 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts create mode 100644 packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 936893770f..fa9cd00443 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -14,6 +14,7 @@ import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, getValueFormatter, + isSingleDigit, } from '../utils'; import { getInputSegmentStyles } from './InputSegment.styles'; @@ -92,9 +93,7 @@ const InputSegmentWithRef = ( // If the value is a single digit, we check if the input is full and reset it if it is. The digit will be inserted into the input when onChange is called. // This is to handle the case where the user tries to type a single digit when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked - const isSingleDigit = /^[0-9]$/.test(key); - - if (isSingleDigit) { + if (isSingleDigit(key)) { if (target.value.length === charsCount) { target.value = ''; } diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index 9754f2fa90..ab8267d6dd 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -15,3 +15,4 @@ export { isValidSegmentValue, } from './isValidSegment/isValidSegment'; export { isValidValueForSegment } from './isValidValueForSegment/isValidValueForSegment'; +export { isSingleDigit } from './isSingleDigit/isSingleDigit'; diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts new file mode 100644 index 0000000000..cffc7ae307 --- /dev/null +++ b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts @@ -0,0 +1,14 @@ +import range from 'lodash/range'; + +import { isSingleDigit } from './isSingleDigit'; +import { keyMap } from '@leafygreen-ui/lib'; + +describe('packages/input-box/utils/isSingleDigit', () => { + test.each(range(10))('returns true for %i character', i => { + expect(isSingleDigit(`${i}`)).toBe(true); + }); + + test.each(Object.values(keyMap))('returns false for %s', key => { + expect(isSingleDigit(key)).toBe(false); + }); +}); diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts new file mode 100644 index 0000000000..4d0670df47 --- /dev/null +++ b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts @@ -0,0 +1,7 @@ +/** + * Checks if the key is a single digit. + * + * @param key - The key to check. + * @returns True if the key is a single digit, false otherwise. + */ +export const isSingleDigit = (key: string): boolean => /^[0-9]$/.test(key); From ed31fdc88bc67aba8ca7e5eda2e82c95a1edcd05 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 10:23:36 -0500 Subject: [PATCH 50/60] refactor(input-box): enhance documentation for InputBox component to clarify functionality and usage --- packages/input-box/src/InputBox/InputBox.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index a1fef41a83..e69d28d26d 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -25,13 +25,7 @@ import { } from './InputBox.styles'; import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; -/** - * Generic controlled input box component - * Renders an input box with appropriate segment order & separator characters. - * - * @internal - */ -export const InputBoxWithRef = ( +const InputBoxWithRef = ( { className, labelledBy, @@ -256,6 +250,12 @@ export const InputBoxWithRef = ( ); }; +/** + * Generic controlled input box component that renders multiple input segments with separators. + * + * Supports auto-formatting, auto-advance focus, keyboard navigation (arrow keys), value increment/decrement, + * validation, and blur formatting. It is designed primarily for date and time inputs. + */ export const InputBox = React.forwardRef( InputBoxWithRef, ) as InputBoxComponentType; From adaa3b6f52f1f2f041b7944fa52d5048afceeb3f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 11:45:18 -0500 Subject: [PATCH 51/60] refactor(input-box): integrate size prop into InputBox and InputSegment components for enhanced customization --- packages/input-box/src/InputBox.stories.tsx | 7 +++- .../input-box/src/InputBox/InputBox.spec.tsx | 3 +- packages/input-box/src/InputBox/InputBox.tsx | 10 ++++-- .../input-box/src/InputBox/InputBox.types.ts | 8 ----- .../src/InputSegment/InputSegment.spec.tsx | 5 ++- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.tsx | 3 +- .../src/InputSegment/InputSegment.types.ts | 33 ++----------------- packages/input-box/src/shared.types.ts | 20 ++++++++++- packages/input-box/src/testutils/index.tsx | 11 ++++--- .../src/testutils/testutils.mocks.ts | 11 ++----- packages/input-box/src/utils/index.ts | 2 +- .../utils/isSingleDigit/isSingleDigit.spec.ts | 3 +- 13 files changed, 53 insertions(+), 65 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 5b52faf9af..e3175d9930 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,6 +7,7 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; +import { Size } from '@leafygreen-ui/tokens'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; @@ -35,7 +36,6 @@ const meta: StoryMetaType = { 'segmentObj', 'segmentRefs', 'setSegment', - 'charsPerSegment', 'formatParts', 'segmentRules', 'labelledBy', @@ -50,9 +50,14 @@ const meta: StoryMetaType = { disabled: { control: 'boolean', }, + size: { + control: 'select', + options: Object.values(Size), + }, }, args: { disabled: false, + size: Size.Default, }, }; export default meta; diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index dfe2c7f376..3f484858b2 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -10,7 +10,6 @@ import { renderInputBox, } from '../testutils'; import { - charsPerSegmentMock, SegmentObjMock, segmentRefsMock, segmentRulesMock, @@ -528,10 +527,10 @@ describe('packages/input-box', () => { segmentRefs={segmentRefsMock} segments={segmentsMock} setSegment={() => {}} - charsPerSegment={charsPerSegmentMock} segmentRules={segmentRulesMock} segmentComponent={InputSegmentWrapper} disabled={false} + size={'default'} />; }); }); diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index e69d28d26d..adef396b40 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -6,6 +6,7 @@ import React, { import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentChangeEventHandler, @@ -34,12 +35,12 @@ const InputBoxWithRef = ( onKeyDown, setSegment, disabled, - charsPerSegment, formatParts, segmentEnum, segmentRules, segmentComponent, segments, + size = Size.Default, ...rest }: InputBoxProps, fwdRef: ForwardedRef, @@ -51,6 +52,9 @@ const InputBoxWithRef = ( rules: segmentRules, }); + const getCharsPerSegment = (segment: Segment) => + segmentRules[segment].maxChars; + /** Formats and sets the segment value. */ const getFormattedSegmentValue = ( segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], @@ -58,7 +62,7 @@ const InputBoxWithRef = ( allowZero: boolean, ): string => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment[segmentName], + charsPerSegment: getCharsPerSegment(segmentName), allowZero, }); const formattedValue = formatter(segmentValue); @@ -241,6 +245,8 @@ const InputBoxWithRef = ( value={segments[part.type]} ref={segmentRefs[part.type]} disabled={disabled} + charsCount={getCharsPerSegment(part.type)} + size={size} /> ); } diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 156a4c3b1a..84b71f0a1d 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -61,14 +61,6 @@ export interface InputBoxProps */ formatParts?: Array; - /** - * The number of characters per segment - * - * @example - * { day: 2, month: 2, year: 4 } - */ - charsPerSegment: Record; - /** * An object that maps the segment names to their rules. * diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 90749592c5..0ba99616e9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -5,7 +5,6 @@ import { axe } from 'jest-axe'; import { type InputSegmentChangeEventHandler } from '../shared.types'; import { renderSegment } from '../testutils'; import { - charsPerSegmentMock, defaultMaxMock, defaultMinMock, SegmentObjMock, @@ -183,8 +182,8 @@ describe('packages/input-segment', () => { describe('keyboard events', () => { describe('Arrow keys', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegmentMock['day'], - allowZero: defaultMinMock['day'] === 0, + charsPerSegment: 2, + allowZero: true, }); describe('Up arrow', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 1811d2ac32..5802410cac 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -58,7 +58,7 @@ const meta: StoryMetaType = { 'segment', 'value', 'onChange', - 'charsPerSegment', + 'charsCount', 'segmentEnum', 'shouldValidate', 'step', diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index fa9cd00443..eb9d40249c 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -8,6 +8,7 @@ import React, { import { VisuallyHidden } from '@leafygreen-ui/a11y'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; import { @@ -33,10 +34,10 @@ const InputSegmentWithRef = ( onChange, onBlur, segmentEnum, - size, disabled, value, charsCount, + size = Size.Default, step = 1, shouldWrap = true, shouldValidate = true, diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 9e436dd943..a9d724d5b9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,18 +1,9 @@ -import React, { ForwardedRef, ReactElement } from 'react'; - -import { Size } from '@leafygreen-ui/tokens'; +import { ForwardedRef, ReactElement } from 'react'; import { InputSegmentComponentProps } from '../shared.types'; export interface InputSegmentProps - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' - >, - Pick< - InputSegmentComponentProps, - 'onChange' | 'onBlur' | 'segment' | 'segmentEnum' - > { + extends InputSegmentComponentProps { /** * Minimum value for the segment */ @@ -43,26 +34,6 @@ export interface InputSegmentProps * @default true */ shouldValidate?: boolean; - - /** - * The value of the segment - */ - value: string; - - /** - * The number of characters per segment - */ - charsCount: number; - - /** - * The size of the input box - * - * @example - * Size.Default - * Size.Small - * Size.Large - */ - size: Size; } /** diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7e49b4b154..ea8c8774f4 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,4 +1,5 @@ import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; /** * SharedInput Segment Types @@ -47,7 +48,7 @@ export function isInputSegment>( export interface InputSegmentComponentProps extends Omit< React.ComponentPropsWithRef<'input'>, - 'onChange' | 'value' | 'disabled' + 'onChange' | 'value' | 'disabled' | 'size' | 'step' >, SharedInputBoxTypes { /** @@ -67,6 +68,11 @@ export interface InputSegmentComponentProps * The value of the segment */ value: string; + + /** + * The number of characters per segment + */ + charsCount: number; } /** @@ -87,4 +93,16 @@ export interface SharedInputBoxTypes { * Whether the input box is disabled */ disabled: boolean; + + /** + * The size of the input box + * + * @example + * Size.Default + * Size.Small + * Size.Large + * + * @default Size.Default + */ + size?: Size; } diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 2b97033a7e..49f95233dc 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -8,7 +8,6 @@ import { InputSegment, type InputSegmentProps } from '../InputSegment'; import { InputSegmentComponentProps } from '../shared.types'; import { - charsPerSegmentMock, defaultFormatPartsMock, defaultMaxMock, defaultMinMock, @@ -25,7 +24,6 @@ export const defaultProps: Partial> = { segmentEnum: SegmentObjMock, segmentRefs: segmentRefsMock, setSegment: () => {}, - charsPerSegment: charsPerSegmentMock, formatParts: defaultFormatPartsMock, segmentRules: segmentRulesMock, }; @@ -47,6 +45,8 @@ export const InputSegmentWrapper = React.forwardRef< onBlur = () => {}, segmentEnum = SegmentObjMock, disabled = false, + charsCount, + size, }, ref, ) => { @@ -62,10 +62,10 @@ export const InputSegmentWrapper = React.forwardRef< minSegmentValue={defaultMinMock[segment]} maxSegmentValue={defaultMaxMock[segment]} value={value} - charsCount={charsPerSegmentMock[segment]} + charsCount={charsCount} onChange={onChange} onBlur={onBlur} - size={Size.Default} + size={size} segmentEnum={segmentEnum} ref={ref} disabled={disabled} @@ -89,6 +89,7 @@ export const InputBoxWithState = ({ }, setSegment: setSegmentProp, disabled = false, + size = Size.Default, ...props }: Partial> & { segments?: Record; @@ -120,11 +121,11 @@ export const InputBoxWithState = ({ segmentRefs={segmentRefs} segments={effectiveSegments} setSegment={effectiveSetSegment} - charsPerSegment={charsPerSegmentMock} formatParts={defaultFormatPartsMock} segmentRules={segmentRulesMock} segmentComponent={InputSegmentWrapper} disabled={disabled} + size={size} {...props} /> ); diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts index 0466e233e3..b945838c43 100644 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ b/packages/input-box/src/testutils/testutils.mocks.ts @@ -29,11 +29,6 @@ export const segmentsMock: Record = { day: '02', year: '2025', }; -export const charsPerSegmentMock: Record = { - month: 2, - day: 2, - year: 4, -}; export const segmentRulesMock: Record = { month: { maxChars: 2, minExplicitValue: 2 }, day: { maxChars: 2, minExplicitValue: 4 }, @@ -74,12 +69,12 @@ export const characterWidth = { export const segmentWidthStyles: Record = { day: css` - width: ${charsPerSegmentMock.day * characterWidth.D}ch; + width: ${segmentRulesMock['day'].maxChars * characterWidth.D}ch; `, month: css` - width: ${charsPerSegmentMock.month * characterWidth.M}ch; + width: ${segmentRulesMock['month'].maxChars * characterWidth.M}ch; `, year: css` - width: ${charsPerSegmentMock.year * characterWidth.Y}ch; + width: ${segmentRulesMock['year'].maxChars * characterWidth.Y}ch; `, }; diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index ab8267d6dd..ab1d6778d8 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -10,9 +10,9 @@ export { } from './getRelativeSegment/getRelativeSegment'; export { getValueFormatter } from './getValueFormatter/getValueFormatter'; export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment'; +export { isSingleDigit } from './isSingleDigit/isSingleDigit'; export { isValidSegmentName, isValidSegmentValue, } from './isValidSegment/isValidSegment'; export { isValidValueForSegment } from './isValidValueForSegment/isValidValueForSegment'; -export { isSingleDigit } from './isSingleDigit/isSingleDigit'; diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts index cffc7ae307..4649204712 100644 --- a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts +++ b/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts @@ -1,8 +1,9 @@ import range from 'lodash/range'; -import { isSingleDigit } from './isSingleDigit'; import { keyMap } from '@leafygreen-ui/lib'; +import { isSingleDigit } from './isSingleDigit'; + describe('packages/input-box/utils/isSingleDigit', () => { test.each(range(10))('returns true for %i character', i => { expect(isSingleDigit(`${i}`)).toBe(true); From a106f7153bd922f533bf97f704c85a695c478d75 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 13:17:10 -0500 Subject: [PATCH 52/60] refactor(input-box): migrate Size import to shared.types for consistent usage across components --- packages/input-box/src/InputBox.stories.tsx | 2 +- packages/input-box/src/InputBox/InputBox.tsx | 2 +- packages/input-box/src/InputSegment/InputSegment.stories.tsx | 2 +- packages/input-box/src/InputSegment/InputSegment.tsx | 2 +- packages/input-box/src/index.ts | 1 + packages/input-box/src/shared.types.ts | 2 ++ packages/input-box/src/testutils/index.tsx | 4 +--- 7 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index e3175d9930..75b01b256a 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,7 +7,7 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; -import { Size } from '@leafygreen-ui/tokens'; +import { Size } from './shared.types'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index adef396b40..16671707bb 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -6,10 +6,10 @@ import React, { import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; import { InputSegmentChangeEventHandler, + Size, isInputSegment, } from '../shared.types'; import { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 5802410cac..ed15580d49 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -6,7 +6,7 @@ import { import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; -import { Size } from '@leafygreen-ui/tokens'; +import { Size } from '../shared.types'; import { defaultPlaceholderMock, diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index eb9d40249c..bf8abc9fec 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -8,7 +8,7 @@ import React, { import { VisuallyHidden } from '@leafygreen-ui/a11y'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '@leafygreen-ui/tokens'; +import { Size } from '../shared.types'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; import { diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index f3e9605536..56f7ff51cf 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -3,6 +3,7 @@ export { InputSegment, type InputSegmentProps } from './InputSegment'; export { type InputSegmentChangeEventHandler, isInputSegment, + Size, } from './shared.types'; export { createExplicitSegmentValidator, diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index ea8c8774f4..f866fce8fa 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,6 +1,8 @@ import { keyMap } from '@leafygreen-ui/lib'; import { Size } from '@leafygreen-ui/tokens'; +export { Size }; + /** * SharedInput Segment Types */ diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index 49f95233dc..a265a99d26 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,11 +1,9 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; -import { Size } from '@leafygreen-ui/tokens'; - import { InputBox, InputBoxProps } from '../InputBox'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; -import { InputSegmentComponentProps } from '../shared.types'; +import { InputSegmentComponentProps, Size } from '../shared.types'; import { defaultFormatPartsMock, From df546c1397d7216a2729a1cd2a36590f7d0044ac Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 14 Nov 2025 16:21:46 -0500 Subject: [PATCH 53/60] refactor(input-box): enhance InputBox and InputSegment tests with segmentRefs integration for improved focus handling --- packages/input-box/src/InputBox.stories.tsx | 2 +- .../input-box/src/InputBox/InputBox.spec.tsx | 191 ++++++++++-------- packages/input-box/src/InputBox/InputBox.tsx | 11 +- .../input-box/src/InputBox/InputBox.types.ts | 2 +- .../src/InputSegment/InputSegment.stories.tsx | 2 +- .../src/InputSegment/InputSegment.tsx | 2 +- packages/input-box/src/hooks/index.ts | 1 + .../useSegmentRefs/useSegmentRefs.spec.ts | 166 +++++++++++++++ .../hooks/useSegmentRefs/useSegmentRefs.ts | 29 +++ packages/input-box/src/testutils/index.tsx | 11 - 10 files changed, 318 insertions(+), 99 deletions(-) create mode 100644 packages/input-box/src/hooks/index.ts create mode 100644 packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts create mode 100644 packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 75b01b256a..1c9336cd45 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -7,10 +7,10 @@ import { StoryFn } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; -import { Size } from './shared.types'; import { SegmentObjMock } from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; +import { Size } from './shared.types'; import { InputBoxWithState } from './testutils'; const meta: StoryMetaType = { diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index 3f484858b2..a45ecfe632 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -125,35 +125,48 @@ describe('packages/input-box', () => { }); describe('auto-focus', () => { - test('focuses the next segment when an explicit value is entered', () => { - const { dayInput, monthInput } = renderInputBox({}); - - userEvent.type(monthInput, '02'); - expect(dayInput).toHaveFocus(); - expect(monthInput.value).toBe('02'); - }); + describe.each([undefined, segmentRefsMock])( + 'when segmentRefs are %p', + segmentRefs => { + test('focuses the next segment when an explicit value is entered', () => { + const { dayInput, monthInput } = renderInputBox({ + segmentRefs, + }); + + userEvent.type(monthInput, '02'); + expect(dayInput).toHaveFocus(); + expect(monthInput.value).toBe('02'); + }); - test('focus remains in the current segment when an ambiguous value is entered', () => { - const { dayInput } = renderInputBox({}); + test('focus remains in the current segment when an ambiguous value is entered', () => { + const { dayInput } = renderInputBox({ + segmentRefs, + }); - userEvent.type(dayInput, '2'); - expect(dayInput).toHaveFocus(); - }); + userEvent.type(dayInput, '2'); + expect(dayInput).toHaveFocus(); + }); - test('focuses the previous segment when a backspace is pressed and the current segment is empty', () => { - const { dayInput, monthInput } = renderInputBox({}); + test('focuses the previous segment when a backspace is pressed and the current segment is empty', () => { + const { dayInput, monthInput } = renderInputBox({ + segmentRefs, + }); - userEvent.type(dayInput, '{backspace}'); - expect(monthInput).toHaveFocus(); - }); + userEvent.type(dayInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); - test('focus remains in the current segment when a backspace is pressed and the current segment is not empty', () => { - const { monthInput } = renderInputBox({}); + test('focus remains in the current segment when a backspace is pressed and the current segment is not empty', () => { + const { monthInput } = renderInputBox({ + segmentRefs, + }); - userEvent.type(monthInput, '2'); - userEvent.type(monthInput, '{backspace}'); - expect(monthInput).toHaveFocus(); - }); + userEvent.type(monthInput, '2'); + userEvent.type(monthInput, '{backspace}'); + expect(monthInput).toHaveFocus(); + }); + }, + ); }); describe('Mouse interaction', () => { @@ -182,64 +195,6 @@ describe('packages/input-box', () => { expect(yearInput).toHaveFocus(); }); - describe('Right arrow', () => { - test('Right arrow key moves focus to next segment when the segment is empty', () => { - const { dayInput, monthInput, yearInput } = renderInputBox({}); - userEvent.click(monthInput); - userEvent.type(monthInput, '{arrowright}'); - expect(dayInput).toHaveFocus(); - userEvent.type(dayInput, '{arrowright}'); - expect(yearInput).toHaveFocus(); - }); - - test('Right arrow key moves focus to next segment when the segment is not empty', () => { - const { dayInput, monthInput, yearInput } = renderInputBox({ - segments: { day: '20', month: '02', year: '1990' }, - }); - userEvent.click(monthInput); - userEvent.type(monthInput, '{arrowright}'); - expect(dayInput).toHaveFocus(); - userEvent.type(dayInput, '{arrowright}'); - expect(yearInput).toHaveFocus(); - }); - - test('Right arrow key moves focus to next segment when the value starts with 0', () => { - const { dayInput, monthInput } = renderInputBox({}); - userEvent.click(monthInput); - userEvent.type(monthInput, '0{arrowright}'); - expect(dayInput).toHaveFocus(); - }); - }); - - describe('Left arrow', () => { - test('Left arrow key moves focus to previous segment when the segment is empty', () => { - const { dayInput, monthInput, yearInput } = renderInputBox({}); - userEvent.click(yearInput); - userEvent.type(yearInput, '{arrowleft}'); - expect(dayInput).toHaveFocus(); - userEvent.type(dayInput, '{arrowleft}'); - expect(monthInput).toHaveFocus(); - }); - - test('Left arrow key moves focus to previous segment when the segment is not empty', () => { - const { dayInput, monthInput, yearInput } = renderInputBox({ - segments: { day: '20', month: '02', year: '1990' }, - }); - userEvent.click(yearInput); - userEvent.type(yearInput, '{arrowleft}'); - expect(dayInput).toHaveFocus(); - userEvent.type(dayInput, '{arrowleft}'); - expect(monthInput).toHaveFocus(); - }); - - test('Left arrow key moves focus to previous segment when the value starts with 0', () => { - const { dayInput, yearInput } = renderInputBox({}); - userEvent.click(yearInput); - userEvent.type(yearInput, '0{arrowleft}'); - expect(dayInput).toHaveFocus(); - }); - }); - describe('Up arrow', () => { test('keeps the focus in the current segment when the segment is empty', () => { const { dayInput } = renderInputBox({}); @@ -275,6 +230,79 @@ describe('packages/input-box', () => { expect(dayInput).toHaveFocus(); }); }); + + describe.each([undefined, segmentRefsMock])( + 'when segmentRefs are %p', + segmentRefs => { + describe('Right arrow', () => { + test('Right arrow key moves focus to next segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segmentRefs, + }); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowright}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('Right arrow key moves focus to next segment when the segment is not empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segmentRefs, + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(monthInput); + userEvent.type(monthInput, '{arrowright}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowright}'); + expect(yearInput).toHaveFocus(); + }); + + test('Right arrow key moves focus to next segment when the value starts with 0', () => { + const { dayInput, monthInput } = renderInputBox({ + segmentRefs, + }); + userEvent.click(monthInput); + userEvent.type(monthInput, '0{arrowright}'); + expect(dayInput).toHaveFocus(); + }); + }); + + describe('Left arrow', () => { + test('Left arrow key moves focus to previous segment when the segment is empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segmentRefs, + }); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('Left arrow key moves focus to previous segment when the segment is not empty', () => { + const { dayInput, monthInput, yearInput } = renderInputBox({ + segmentRefs, + segments: { day: '20', month: '02', year: '1990' }, + }); + userEvent.click(yearInput); + userEvent.type(yearInput, '{arrowleft}'); + expect(dayInput).toHaveFocus(); + userEvent.type(dayInput, '{arrowleft}'); + expect(monthInput).toHaveFocus(); + }); + + test('Left arrow key moves focus to previous segment when the value starts with 0', () => { + const { dayInput, yearInput } = renderInputBox({ + segmentRefs, + }); + userEvent.click(yearInput); + userEvent.type(yearInput, '0{arrowleft}'); + expect(dayInput).toHaveFocus(); + }); + }); + }, + ); }); describe('onBlur', () => { @@ -524,7 +552,6 @@ describe('packages/input-box', () => { test('With required props', () => { {}} segmentRules={segmentRulesMock} diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 16671707bb..ec32c0cbe0 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -7,10 +7,11 @@ import React, { import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; +import { useSegmentRefs } from '../hooks'; import { InputSegmentChangeEventHandler, - Size, isInputSegment, + Size, } from '../shared.types'; import { createExplicitSegmentValidator, @@ -30,7 +31,7 @@ const InputBoxWithRef = ( { className, labelledBy, - segmentRefs, + segmentRefs: segmentRefsProp, onSegmentChange, onKeyDown, setSegment, @@ -47,11 +48,17 @@ const InputBoxWithRef = ( ) => { const { theme } = useDarkMode(); + /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ + const internalSegmentRefs = useSegmentRefs(segments); + const segmentRefs = segmentRefsProp || internalSegmentRefs; + + /** Create a validator for explicit segment values. */ const isExplicitSegmentValue = createExplicitSegmentValidator({ segmentEnum, rules: segmentRules, }); + /** Get the maximum number of characters per segment. */ const getCharsPerSegment = (segment: Segment) => segmentRules[segment].maxChars; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts index 84b71f0a1d..7e8dc254cc 100644 --- a/packages/input-box/src/InputBox/InputBox.types.ts +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -95,7 +95,7 @@ export interface InputBoxProps * @example * { day: ref, month: ref, year: ref } */ - segmentRefs: Record>; + segmentRefs?: Record>; } /** diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index ed15580d49..0166d5b7f7 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -6,8 +6,8 @@ import { import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; -import { Size } from '../shared.types'; +import { Size } from '../shared.types'; import { defaultPlaceholderMock, SegmentObjMock, diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index bf8abc9fec..5883bcb9e4 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -8,9 +8,9 @@ import React, { import { VisuallyHidden } from '@leafygreen-ui/a11y'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; -import { Size } from '../shared.types'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; +import { Size } from '../shared.types'; import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, diff --git a/packages/input-box/src/hooks/index.ts b/packages/input-box/src/hooks/index.ts new file mode 100644 index 0000000000..9c97080e94 --- /dev/null +++ b/packages/input-box/src/hooks/index.ts @@ -0,0 +1 @@ +export { useSegmentRefs } from './useSegmentRefs/useSegmentRefs'; diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts new file mode 100644 index 0000000000..64a80422e1 --- /dev/null +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts @@ -0,0 +1,166 @@ +import { renderHook } from '@leafygreen-ui/testing-lib'; + +import { useSegmentRefs } from './useSegmentRefs'; + +describe('packages/input-box/hooks/useSegmentRefs', () => { + describe('basic functionality', () => { + test('returns an object with refs for each segment', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toHaveProperty('month'); + expect(result.current).toHaveProperty('day'); + expect(result.current).toHaveProperty('year'); + }); + + test('each returned value is a valid React ref object', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current.month).toHaveProperty('current'); + expect(result.current.day).toHaveProperty('current'); + expect(result.current.year).toHaveProperty('current'); + }); + + test('handles empty segments object', () => { + const segments = {}; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toEqual({}); + }); + + test('handles single segment', () => { + const segments = { input: 'input' }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toHaveProperty('input'); + expect(result.current.input).toHaveProperty('current'); + }); + }); + + describe('memoization', () => { + test('returns the same refs when rerendered with the same segments object', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result, rerender } = renderHook(() => useSegmentRefs(segments)); + + const initialMonthRef = result.current.month; + const initialDayRef = result.current.day; + const initialYearRef = result.current.year; + + rerender(); + + expect(result.current.month).toBe(initialMonthRef); + expect(result.current.day).toBe(initialDayRef); + expect(result.current.year).toBe(initialYearRef); + }); + + test('returns the same object structure when rerendered', () => { + const segments = { + month: 'month', + day: 'day', + }; + + const { result, rerender } = renderHook(() => useSegmentRefs(segments)); + + const initialResult = result.current; + + rerender(); + + // The object itself should be the same (memoized) + expect(result.current).toBe(initialResult); + }); + }); + + describe('with different segment configurations', () => { + test('returns new object when segments change', () => { + const initialSegments = { + month: 'month', + day: 'day', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs(segments), + { + initialProps: { segments: initialSegments }, + }, + ); + + const initialResult = result.current; + + const newSegments = { + month: 'month', + day: 'day', + year: 'year', + }; + + rerender({ segments: newSegments }); + + // Should return a new object when segments change + expect(result.current).not.toBe(initialResult); + expect(Object.keys(result.current)).toHaveLength(3); + }); + + test('works with different key types', () => { + const segments = { + hour: 'hour', + minute: 'minute', + second: 'second', + meridiem: 'meridiem', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current).toHaveProperty('hour'); + expect(result.current).toHaveProperty('minute'); + expect(result.current).toHaveProperty('second'); + expect(result.current).toHaveProperty('meridiem'); + expect(Object.keys(result.current)).toHaveLength(4); + }); + }); + + describe('ref uniqueness', () => { + test('each segment gets a unique ref', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs(segments)); + + expect(result.current.month).not.toBe(result.current.day); + expect(result.current.day).not.toBe(result.current.year); + expect(result.current.month).not.toBe(result.current.year); + }); + + test('different hook instances return different refs', () => { + const segments = { + month: 'month', + day: 'day', + }; + + const { result: result1 } = renderHook(() => useSegmentRefs(segments)); + const { result: result2 } = renderHook(() => useSegmentRefs(segments)); + + expect(result1.current.month).not.toBe(result2.current.month); + expect(result1.current.day).not.toBe(result2.current.day); + }); + }); +}); diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts new file mode 100644 index 0000000000..95ddba4537 --- /dev/null +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; + +import { useDynamicRefs } from '@leafygreen-ui/hooks'; + +/** + * Creates a memoized object of refs for each segment. + * @param segments - An object mapping segment names to their values. + * @returns An object mapping segment names to their refs. + * + * @example + * const segments = { day: 'day', month: 'month', year: 'year' }; + * const segmentRefs = useSegmentRefs(segments); + * // segmentRefs is { day: ref, month: ref, year: ref } + */ +export const useSegmentRefs = ( + segments: Record, +) => { + const getSegmentRef = useDynamicRefs(); + + const segmentRefs = useMemo( + () => + Object.fromEntries( + Object.entries(segments).map(([key]) => [key, getSegmentRef(key)]), + ) as Record>, + [getSegmentRef, segments], + ); + + return segmentRefs; +}; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index a265a99d26..fb930748bc 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -92,16 +92,6 @@ export const InputBoxWithState = ({ }: Partial> & { segments?: Record; }) => { - const dayRef = React.useRef(null); - const monthRef = React.useRef(null); - const yearRef = React.useRef(null); - - const segmentRefs = { - day: dayRef, - month: monthRef, - year: yearRef, - }; - const [segments, setSegments] = React.useState(segmentsProp); const defaultSetSegment = (segment: SegmentObjMock, value: string) => { @@ -116,7 +106,6 @@ export const InputBoxWithState = ({ return ( Date: Mon, 17 Nov 2025 16:09:47 -0500 Subject: [PATCH 54/60] feat(input-box): add comprehensive mocks for date and time segments in testutils for enhanced testing capabilities --- .../src/testutils/{testutils.mocks.ts => testutils.mocks.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/input-box/src/testutils/{testutils.mocks.ts => testutils.mocks.tsx} (100%) diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.tsx similarity index 100% rename from packages/input-box/src/testutils/testutils.mocks.ts rename to packages/input-box/src/testutils/testutils.mocks.tsx From 4d1030bead7fb687667e6ee732961d32567b0a6e Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 17 Nov 2025 16:10:31 -0500 Subject: [PATCH 55/60] feat(input-box): integrate lodash for utility functions and enhance InputBox stories with date and time segment examples --- packages/input-box/package.json | 3 +- packages/input-box/src/InputBox.stories.tsx | 91 +++++++++++++++- packages/input-box/src/InputBox/InputBox.tsx | 6 +- .../src/testutils/testutils.mocks.tsx | 100 +++++++++++++++++- pnpm-lock.yaml | 3 + 5 files changed, 195 insertions(+), 8 deletions(-) diff --git a/packages/input-box/package.json b/packages/input-box/package.json index cc5cb766c5..4e6fa66876 100644 --- a/packages/input-box/package.json +++ b/packages/input-box/package.json @@ -34,7 +34,8 @@ "@leafygreen-ui/hooks": "workspace:^", "@leafygreen-ui/date-utils": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", - "@leafygreen-ui/typography": "workspace:^" + "@leafygreen-ui/typography": "workspace:^", + "lodash": "^4.17.21" }, "peerDependencies": { "@leafygreen-ui/leafygreen-provider": "workspace:^" diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 1c9336cd45..8640e65765 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -1,29 +1,45 @@ +/* eslint-disable no-console */ import React from 'react'; import { storybookExcludedControlParams, StoryMetaType, } from '@lg-tools/storybook-utils'; -import { StoryFn } from '@storybook/react'; +import { StoryFn, StoryObj } from '@storybook/react'; import { css } from '@leafygreen-ui/emotion'; +import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; import { palette } from '@leafygreen-ui/palette'; -import { SegmentObjMock } from './testutils/testutils.mocks'; +import { + dateSegmentEmptyMock, + defaultFormatPartsMock, + SegmentObjMock, + segmentRulesMock, + segmentsMock, + timeFormatPartsMock, + TimeInputSegmentWrapper, + TimeSegmentObjMock, + timeSegmentRulesMock, + timeSegmentsEmptyMock, + timeSegmentsMock, +} from './testutils/testutils.mocks'; import { InputBox, InputBoxProps } from './InputBox'; import { Size } from './shared.types'; -import { InputBoxWithState } from './testutils'; +import { InputBoxWithState, InputSegmentWrapper } from './testutils'; const meta: StoryMetaType = { title: 'Components/Inputs/InputBox', component: InputBox, decorators: [ - StoryFn => ( + (StoryFn, context: any) => (
- + + +
), ], @@ -45,6 +61,19 @@ const meta: StoryMetaType = { 'segmentEnum', ], }, + generate: { + storyNames: ['Date', 'Time'], + combineArgs: { + disabled: [false, true], + size: Object.values(Size), + darkMode: [false, true], + }, + decorator: (StoryFn, context) => ( + + + + ), + }, }, argTypes: { disabled: { @@ -58,6 +87,13 @@ const meta: StoryMetaType = { args: { disabled: false, size: Size.Default, + setSegment: (segment: SegmentObjMock, value: string) => { + console.log('setSegment', segment, value); + }, + segmentComponent: InputSegmentWrapper, + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, + segmentEnum: SegmentObjMock, }, }; export default meta; @@ -67,3 +103,48 @@ export const LiveExample: StoryFn = props => { >)} /> ); }; +LiveExample.parameters = { + chromatic: { disableSnapshot: true }, +}; + +export const Date: StoryObj> = { + parameters: { + generate: { + combineArgs: { + segments: [segmentsMock, dateSegmentEmptyMock], + }, + }, + }, + args: { + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, + segmentEnum: SegmentObjMock, + setSegment: (segment: SegmentObjMock, value: string) => { + console.log('setSegment', segment, value); + }, + disabled: false, + size: Size.Default, + segmentComponent: InputSegmentWrapper, + }, +}; + +export const Time: StoryObj> = { + parameters: { + generate: { + combineArgs: { + segments: [timeSegmentsMock, timeSegmentsEmptyMock], + }, + }, + }, + args: { + formatParts: timeFormatPartsMock, + segmentRules: timeSegmentRulesMock, + segmentEnum: TimeSegmentObjMock, + setSegment: (segment: TimeSegmentObjMock, value: string) => { + console.log('setSegment', segment, value); + }, + disabled: false, + size: Size.Default, + segmentComponent: TimeInputSegmentWrapper, + }, +}; diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index ec32c0cbe0..df9ab2786b 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -3,9 +3,10 @@ import React, { ForwardedRef, KeyboardEventHandler, } from 'react'; +import isEmpty from 'lodash/isEmpty'; import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; -import { keyMap } from '@leafygreen-ui/lib'; +import { consoleOnce, keyMap } from '@leafygreen-ui/lib'; import { useSegmentRefs } from '../hooks'; import { @@ -48,6 +49,9 @@ const InputBoxWithRef = ( ) => { const { theme } = useDarkMode(); + if (isEmpty(segments)) + consoleOnce.error('Error in Leafygreen InputBox: segments is required'); + /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ const internalSegmentRefs = useSegmentRefs(segments); const segmentRefs = segmentRefsProp || internalSegmentRefs; diff --git a/packages/input-box/src/testutils/testutils.mocks.tsx b/packages/input-box/src/testutils/testutils.mocks.tsx index b945838c43..076ccb0df8 100644 --- a/packages/input-box/src/testutils/testutils.mocks.tsx +++ b/packages/input-box/src/testutils/testutils.mocks.tsx @@ -1,8 +1,10 @@ -import { createRef } from 'react'; +import React, { createRef, forwardRef } from 'react'; import { css } from '@leafygreen-ui/emotion'; import { DynamicRefGetter } from '@leafygreen-ui/hooks'; +import { InputSegment } from '../InputSegment'; +import { InputSegmentComponentProps } from '../shared.types'; import { ExplicitSegmentRule } from '../utils'; export const SegmentObjMock = { @@ -24,6 +26,12 @@ export const segmentRefsMock: SegmentRefsMock = { year: createRef(), }; +export const dateSegmentEmptyMock: Record = { + month: '', + day: '', + year: '', +}; + export const segmentsMock: Record = { month: '02', day: '02', @@ -65,6 +73,9 @@ export const characterWidth = { D: 46 / 40, M: 55 / 40, Y: 50 / 40, + H: 46 / 40, + MM: 55 / 40, + S: 46 / 40, } as const; export const segmentWidthStyles: Record = { @@ -78,3 +89,90 @@ export const segmentWidthStyles: Record = { width: ${segmentRulesMock['year'].maxChars * characterWidth.Y}ch; `, }; + +/** Mocks for time generate story */ +export const TimeSegmentObjMock = { + Hour: 'hour', + Minute: 'minute', + Second: 'second', +} as const; +export type TimeSegmentObjMock = + (typeof TimeSegmentObjMock)[keyof typeof TimeSegmentObjMock]; + +export const timeSegmentsMock: Record = { + hour: '23', + minute: '00', + second: '59', +}; + +export const timeSegmentsEmptyMock: Record = { + hour: '', + minute: '', + second: '', +}; + +export const timeSegmentRulesMock: Record< + TimeSegmentObjMock, + ExplicitSegmentRule +> = { + hour: { maxChars: 2, minExplicitValue: 3 }, + minute: { maxChars: 2, minExplicitValue: 6 }, + second: { maxChars: 2, minExplicitValue: 6 }, +}; + +export const timeMinMock: Record = { + hour: 0, + minute: 0, + second: 0, +}; +export const timeMaxMock: Record = { + hour: 23, + minute: 59, + second: 59, +}; + +export const timePlaceholderMock: Record = { + hour: 'HH', + minute: 'MM', + second: 'SS', +} as const; + +export const timeFormatPartsMock: Array = [ + { type: 'hour', value: '' }, + { type: 'literal', value: ':' }, + { type: 'minute', value: '' }, + { type: 'literal', value: ':' }, + { type: 'second', value: '' }, +]; + +export const timeSegmentWidthStyles: Record = { + hour: css` + width: ${timeSegmentRulesMock['hour'].maxChars * characterWidth.D}ch; + `, + minute: css` + width: ${timeSegmentRulesMock['minute'].maxChars * characterWidth.MM}ch; + `, + second: css` + width: ${timeSegmentRulesMock['second'].maxChars * characterWidth.Y}ch; + `, +}; + +export const TimeInputSegmentWrapper = forwardRef< + HTMLInputElement, + InputSegmentComponentProps +>((props, ref) => { + const { segment, ...rest } = props; + return ( + + ); +}); + +TimeInputSegmentWrapper.displayName = 'TimeInputSegmentWrapper'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d17f46d811..1ee880d083 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2204,6 +2204,9 @@ importers: '@leafygreen-ui/typography': specifier: workspace:^ version: link:../typography + lodash: + specifier: ^4.17.21 + version: 4.17.21 packages/input-option: dependencies: From f342d2f7da013943e0fab40ce6cfd8d359687a94 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 17 Nov 2025 16:29:26 -0500 Subject: [PATCH 56/60] refactor(input-box): remove unused props from InputBox stories and testutils for cleaner code --- packages/input-box/src/InputBox.stories.tsx | 7 ------- packages/input-box/src/testutils/index.tsx | 2 -- 2 files changed, 9 deletions(-) diff --git a/packages/input-box/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx index 8640e65765..889b6fbe8b 100644 --- a/packages/input-box/src/InputBox.stories.tsx +++ b/packages/input-box/src/InputBox.stories.tsx @@ -87,13 +87,6 @@ const meta: StoryMetaType = { args: { disabled: false, size: Size.Default, - setSegment: (segment: SegmentObjMock, value: string) => { - console.log('setSegment', segment, value); - }, - segmentComponent: InputSegmentWrapper, - formatParts: defaultFormatPartsMock, - segmentRules: segmentRulesMock, - segmentEnum: SegmentObjMock, }, }; export default meta; diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index fb930748bc..1da7a3dc5d 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -11,7 +11,6 @@ import { defaultMinMock, defaultPlaceholderMock, SegmentObjMock, - segmentRefsMock, segmentRulesMock, segmentsMock, segmentWidthStyles, @@ -20,7 +19,6 @@ import { export const defaultProps: Partial> = { segments: segmentsMock, segmentEnum: SegmentObjMock, - segmentRefs: segmentRefsMock, setSegment: () => {}, formatParts: defaultFormatPartsMock, segmentRules: segmentRulesMock, From f7f28eb00164d2467ad39383e770a1cbafaa4afb Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 17 Nov 2025 21:09:06 -0500 Subject: [PATCH 57/60] fix(input-box): ensure segments prop is required and handle error logging in InputBox component --- .../input-box/src/InputBox/InputBox.spec.tsx | 18 ++++++++++++++++++ packages/input-box/src/InputBox/InputBox.tsx | 10 ++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index a45ecfe632..bad6e9229c 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -3,6 +3,8 @@ import { jest } from '@jest/globals'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { consoleOnce } from '@leafygreen-ui/lib'; + import { InputSegmentChangeEventHandler } from '../shared.types'; import { InputBoxWithState, @@ -19,6 +21,22 @@ import { import { InputBox } from './InputBox'; describe('packages/input-box', () => { + describe('basic functionality', () => { + test('returns null when no segments are provided', () => { + const consoleOnceSpy = jest + .spyOn(consoleOnce, 'error') + .mockImplementation(() => {}); + + // @ts-expect-error - missing props + const { container } = render(); + + expect(container.firstChild).toBeNull(); + expect(consoleOnceSpy).toHaveBeenCalledWith( + 'Error in Leafygreen InputBox: segments is required', + ); + }); + }); + describe('Rendering', () => { describe.each(['day', 'month', 'year'])('%p', segment => { test('renders the correct aria attributes', () => { diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index df9ab2786b..88ac2e9045 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -49,13 +49,15 @@ const InputBoxWithRef = ( ) => { const { theme } = useDarkMode(); - if (isEmpty(segments)) - consoleOnce.error('Error in Leafygreen InputBox: segments is required'); - /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ - const internalSegmentRefs = useSegmentRefs(segments); + const internalSegmentRefs = useSegmentRefs(segments ?? {}); const segmentRefs = segmentRefsProp || internalSegmentRefs; + if (isEmpty(segmentRefs) || isEmpty(segments)) { + consoleOnce.error('Error in Leafygreen InputBox: segments is required'); + return null; + } + /** Create a validator for explicit segment values. */ const isExplicitSegmentValue = createExplicitSegmentValidator({ segmentEnum, From 349b6553545f8ffa0fb793905b848004e0988ae2 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 13:21:03 -0500 Subject: [PATCH 58/60] refactor(input-box): rename isSingleDigit to isSingleDigitKey and update README --- .../isSingleDigitKey.spec.ts} | 0 .../isSingleDigit.ts => isSingleDigitKey/isSingleDigitKey.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/input-box/src/utils/{isSingleDigit/isSingleDigit.spec.ts => isSingleDigitKey/isSingleDigitKey.spec.ts} (100%) rename packages/input-box/src/utils/{isSingleDigit/isSingleDigit.ts => isSingleDigitKey/isSingleDigitKey.ts} (100%) diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts similarity index 100% rename from packages/input-box/src/utils/isSingleDigit/isSingleDigit.spec.ts rename to packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts diff --git a/packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts similarity index 100% rename from packages/input-box/src/utils/isSingleDigit/isSingleDigit.ts rename to packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts From bdabf2a496d0fcf787465c2f736e3057b17d1936 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 13:22:03 -0500 Subject: [PATCH 59/60] refactor(input-box): update README and InputSegment to use charsCount instead of charsPerSegment --- packages/input-box/README.md | 12 +++++------- packages/input-box/src/InputSegment/InputSegment.tsx | 4 ++-- packages/input-box/src/utils/index.ts | 2 +- .../utils/isSingleDigitKey/isSingleDigitKey.spec.ts | 6 +++--- .../src/utils/isSingleDigitKey/isSingleDigitKey.ts | 2 +- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/input-box/README.md b/packages/input-box/README.md index c60cd696e9..b63395edee 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -30,7 +30,7 @@ import { Size } from '@leafygreen-ui/tokens'; // 1. Create a custom segment component // InputBox will pass: segment, value, onChange, onBlur, segmentEnum, disabled, ref, aria-labelledby -// You add: minSegmentValue, maxSegmentValue, charsPerSegment, size, and any other InputSegment props +// You add: minSegmentValue, maxSegmentValue, charsCount, size, and any other InputSegment props const MySegment = ({ segment, value, @@ -49,7 +49,7 @@ const MySegment = ({ disabled={disabled} minSegmentValue={minValues[segment]} maxSegmentValue={maxValues[segment]} - charsPerSegment={charsPerSegment[segment]} + charsCount={charsPerSegment[segment]} size={Size.Default} {...props} /> @@ -68,7 +68,6 @@ const MySegment = ({ { type: 'literal', value: '/' }, { type: 'year', value: '2025' }, ]} - charsPerSegment={{ day: 2, month: 2, year: 4 }} segmentRefs={{ day: dayRef, month: monthRef, year: yearRef }} segmentRules={{ day: { maxChars: 2, minExplicitValue: 4 }, @@ -103,11 +102,11 @@ An internal component for building date or time inputs with multiple segments (e - `disabled` - whether the segment is disabled - `ref` - ref for the input element - `aria-labelledby` - accessibility label reference + - `charsCount` - character length + - `size` - input size 2. **Your `segmentComponent` adds** segment-specific configuration: - `minSegmentValue` / `maxSegmentValue` - validation ranges - - `charsPerSegment` - character length - - `size` - input size - `step`, `shouldWrap`, `shouldValidate` - optional behavior customization This pattern allows you to define segment-specific rules (like min/max values that vary by segment) while keeping the core InputBox logic generic and reusable. @@ -131,7 +130,6 @@ A generic controlled input component that renders multiple segments separated by | `segmentEnum` | `Record` | Maps segment names to values (e.g., `{ Day: 'day' }`) | | | `segmentComponent` | `React.ComponentType>` | Custom wrapper component that renders InputSegment with segment-specific props | | | `formatParts` | `Array` | Defines segment order and separators | | -| `charsPerSegment` | `Record` | Max characters per segment (e.g., `{ day: 2, year: 4 }`) | | | `segmentRefs` | `Record>` | Refs for each segment input | | | `segmentRules` | `Record` | Rules for auto-formatting (`maxChars`, `minExplicitValue`) | | | `disabled` | `boolean` | Disables all segments | | @@ -158,7 +156,7 @@ A generic controlled input field for a single segment within `InputBox`. | `value` | `string` | Current segment value | | | `minSegmentValue` | `number` | Minimum valid value | | | `maxSegmentValue` | `number` | Maximum valid value | | -| `charsPerSegment` | `number` | Max character length | | +| `charsCount` | `number` | Max character length | | | `size` | `Size` | Input size | | | `segmentEnum` | `Record` | Segment enum from InputBox | | | `onChange` | `InputSegmentChangeEventHandler` | Change handler | | diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index 5883bcb9e4..a6c8b09cf6 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -15,7 +15,7 @@ import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, getValueFormatter, - isSingleDigit, + isSingleDigitKey, } from '../utils'; import { getInputSegmentStyles } from './InputSegment.styles'; @@ -94,7 +94,7 @@ const InputSegmentWithRef = ( // If the value is a single digit, we check if the input is full and reset it if it is. The digit will be inserted into the input when onChange is called. // This is to handle the case where the user tries to type a single digit when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked - if (isSingleDigit(key)) { + if (isSingleDigitKey(key)) { if (target.value.length === charsCount) { target.value = ''; } diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index ab1d6778d8..7aab16f5d5 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -10,7 +10,7 @@ export { } from './getRelativeSegment/getRelativeSegment'; export { getValueFormatter } from './getValueFormatter/getValueFormatter'; export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment'; -export { isSingleDigit } from './isSingleDigit/isSingleDigit'; +export { isSingleDigitKey } from './isSingleDigitKey/isSingleDigitKey'; export { isValidSegmentName, isValidSegmentValue, diff --git a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts index 4649204712..dd63466d13 100644 --- a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts +++ b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts @@ -2,14 +2,14 @@ import range from 'lodash/range'; import { keyMap } from '@leafygreen-ui/lib'; -import { isSingleDigit } from './isSingleDigit'; +import { isSingleDigitKey } from './isSingleDigitKey'; describe('packages/input-box/utils/isSingleDigit', () => { test.each(range(10))('returns true for %i character', i => { - expect(isSingleDigit(`${i}`)).toBe(true); + expect(isSingleDigitKey(`${i}`)).toBe(true); }); test.each(Object.values(keyMap))('returns false for %s', key => { - expect(isSingleDigit(key)).toBe(false); + expect(isSingleDigitKey(key)).toBe(false); }); }); diff --git a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts index 4d0670df47..b48112550b 100644 --- a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts +++ b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts @@ -4,4 +4,4 @@ * @param key - The key to check. * @returns True if the key is a single digit, false otherwise. */ -export const isSingleDigit = (key: string): boolean => /^[0-9]$/.test(key); +export const isSingleDigitKey = (key: string): boolean => /^[0-9]$/.test(key); From c666bb748b1a5878645f598baa8b218e35057333 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 19 Nov 2025 18:22:43 -0500 Subject: [PATCH 60/60] refactor(input-box): update useSegmentRefs to accept segmentRefs and improve test cases for segment handling --- .../input-box/src/InputBox/InputBox.spec.tsx | 1 - packages/input-box/src/InputBox/InputBox.tsx | 6 +- .../useSegmentRefs/useSegmentRefs.spec.ts | 200 ++++++++++++++---- .../hooks/useSegmentRefs/useSegmentRefs.ts | 30 ++- 4 files changed, 180 insertions(+), 57 deletions(-) diff --git a/packages/input-box/src/InputBox/InputBox.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx index bad6e9229c..17dd2679fd 100644 --- a/packages/input-box/src/InputBox/InputBox.spec.tsx +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -575,7 +575,6 @@ describe('packages/input-box', () => { segmentRules={segmentRulesMock} segmentComponent={InputSegmentWrapper} disabled={false} - size={'default'} />; }); }); diff --git a/packages/input-box/src/InputBox/InputBox.tsx b/packages/input-box/src/InputBox/InputBox.tsx index 88ac2e9045..1e6b531f0f 100644 --- a/packages/input-box/src/InputBox/InputBox.tsx +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -50,8 +50,10 @@ const InputBoxWithRef = ( const { theme } = useDarkMode(); /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ - const internalSegmentRefs = useSegmentRefs(segments ?? {}); - const segmentRefs = segmentRefsProp || internalSegmentRefs; + const segmentRefs = useSegmentRefs({ + segments, + segmentRefs: segmentRefsProp, + }); if (isEmpty(segmentRefs) || isEmpty(segments)) { consoleOnce.error('Error in Leafygreen InputBox: segments is required'); diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts index 64a80422e1..f225407b86 100644 --- a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts @@ -4,38 +4,27 @@ import { useSegmentRefs } from './useSegmentRefs'; describe('packages/input-box/hooks/useSegmentRefs', () => { describe('basic functionality', () => { - test('returns an object with refs for each segment', () => { + test('returns an object with segments with their refs', () => { const segments = { month: 'month', day: 'day', year: 'year', }; - const { result } = renderHook(() => useSegmentRefs(segments)); + const { result } = renderHook(() => useSegmentRefs({ segments })); expect(result.current).toHaveProperty('month'); - expect(result.current).toHaveProperty('day'); - expect(result.current).toHaveProperty('year'); - }); - - test('each returned value is a valid React ref object', () => { - const segments = { - month: 'month', - day: 'day', - year: 'year', - }; - - const { result } = renderHook(() => useSegmentRefs(segments)); - expect(result.current.month).toHaveProperty('current'); + expect(result.current).toHaveProperty('day'); expect(result.current.day).toHaveProperty('current'); + expect(result.current).toHaveProperty('year'); expect(result.current.year).toHaveProperty('current'); }); test('handles empty segments object', () => { const segments = {}; - const { result } = renderHook(() => useSegmentRefs(segments)); + const { result } = renderHook(() => useSegmentRefs({ segments })); expect(result.current).toEqual({}); }); @@ -43,7 +32,7 @@ describe('packages/input-box/hooks/useSegmentRefs', () => { test('handles single segment', () => { const segments = { input: 'input' }; - const { result } = renderHook(() => useSegmentRefs(segments)); + const { result } = renderHook(() => useSegmentRefs({ segments })); expect(result.current).toHaveProperty('input'); expect(result.current.input).toHaveProperty('current'); @@ -58,45 +47,106 @@ describe('packages/input-box/hooks/useSegmentRefs', () => { year: 'year', }; - const { result, rerender } = renderHook(() => useSegmentRefs(segments)); + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments }, + }, + ); const initialMonthRef = result.current.month; const initialDayRef = result.current.day; const initialYearRef = result.current.year; - rerender(); + rerender({ segments }); expect(result.current.month).toBe(initialMonthRef); expect(result.current.day).toBe(initialDayRef); expect(result.current.year).toBe(initialYearRef); }); - test('returns the same object structure when rerendered', () => { - const segments = { + test('returns the same refs when rerendered with a different segments object reference but the same values', () => { + const segments1 = { month: 'month', day: 'day', + year: 'year', }; - const { result, rerender } = renderHook(() => useSegmentRefs(segments)); + const segments2 = { + month: 'month', + day: 'day', + year: 'year', + }; - const initialResult = result.current; + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments: segments1 }, + }, + ); - rerender(); + const initialMonthRef = result.current.month; + const initialDayRef = result.current.day; + const initialYearRef = result.current.year; - // The object itself should be the same (memoized) - expect(result.current).toBe(initialResult); + rerender({ segments: segments2 }); + + expect(result.current.month).toBe(initialMonthRef); + expect(result.current.day).toBe(initialDayRef); + expect(result.current.year).toBe(initialYearRef); }); - }); - describe('with different segment configurations', () => { - test('returns new object when segments change', () => { + test('returns different refs when segments change', () => { + const segments1 = { + month: 'month', + day: 'day', + year: 'year', + }; + + const segments2 = { + hour: 'hour', + minute: 'minute', + second: 'second', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments: segments1 }, + }, + ); + + const initialMonthRef = result.current.month; + const initialDayRef = result.current.day; + const initialYearRef = result.current.year; + + // @ts-expect-error - segments2 has a different shape than segments1 + rerender({ segments: segments2 }); + + // After rerender with different keys, the result should have new properties + expect(result.current).toHaveProperty('hour'); + expect(result.current).toHaveProperty('minute'); + expect(result.current).toHaveProperty('second'); + + // Old properties should not exist + expect(result.current).not.toHaveProperty('month'); + expect(result.current).not.toHaveProperty('day'); + expect(result.current).not.toHaveProperty('year'); + + // The new refs should have different values + expect((result.current as any).hour).not.toBe(initialMonthRef); + expect((result.current as any).minute).not.toBe(initialDayRef); + expect((result.current as any).second).not.toBe(initialYearRef); + }); + + test('returns updated object when segments are added', () => { const initialSegments = { month: 'month', day: 'day', }; const { result, rerender } = renderHook( - ({ segments }) => useSegmentRefs(segments), + ({ segments }) => useSegmentRefs({ segments }), { initialProps: { segments: initialSegments }, }, @@ -115,23 +165,81 @@ describe('packages/input-box/hooks/useSegmentRefs', () => { // Should return a new object when segments change expect(result.current).not.toBe(initialResult); expect(Object.keys(result.current)).toHaveLength(3); + expect(result.current).toHaveProperty('year'); }); - test('works with different key types', () => { + test('returns updated object when segments are removed', () => { + const initialSegments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const newSegments = { + month: 'month', + day: 'day', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments: initialSegments }, + }, + ); + + const initialResult = result.current; + + // @ts-expect-error - newSegments has a different shape than initialSegments + rerender({ segments: newSegments }); + + expect(result.current).not.toBe(initialResult); + expect(Object.keys(result.current)).toHaveLength(2); + expect(result.current).not.toHaveProperty('year'); + }); + }); + + describe('with provided segmentRefs', () => { + test('returns provided segmentRefs instead of creating a new object', () => { const segments = { - hour: 'hour', - minute: 'minute', - second: 'second', - meridiem: 'meridiem', + month: 'month', + day: 'day', + year: 'year', }; - const { result } = renderHook(() => useSegmentRefs(segments)); + const providedRefs = { + month: { current: null }, + day: { current: null }, + year: { current: null }, + }; - expect(result.current).toHaveProperty('hour'); - expect(result.current).toHaveProperty('minute'); - expect(result.current).toHaveProperty('second'); - expect(result.current).toHaveProperty('meridiem'); - expect(Object.keys(result.current)).toHaveLength(4); + const { result } = renderHook(() => + useSegmentRefs({ segments, segmentRefs: providedRefs }), + ); + + // Should return the exact same ref objects that were provided + expect(result.current).toBe(providedRefs); + expect(result.current.month).toBe(providedRefs.month); + expect(result.current.day).toBe(providedRefs.day); + expect(result.current.year).toBe(providedRefs.year); + }); + + test('creates new segmentRefs object when provided segmentRefs is empty', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const providedRefs = {}; + + const { result } = renderHook(() => + // @ts-expect-error - providedRefs is empty but that's okay in this case + useSegmentRefs({ segments, segmentRefs: providedRefs }), + ); + + expect(result.current.month).toBeDefined(); + expect(result.current.day).toBeDefined(); + expect(result.current.year).toBeDefined(); }); }); @@ -143,7 +251,7 @@ describe('packages/input-box/hooks/useSegmentRefs', () => { year: 'year', }; - const { result } = renderHook(() => useSegmentRefs(segments)); + const { result } = renderHook(() => useSegmentRefs({ segments })); expect(result.current.month).not.toBe(result.current.day); expect(result.current.day).not.toBe(result.current.year); @@ -156,8 +264,12 @@ describe('packages/input-box/hooks/useSegmentRefs', () => { day: 'day', }; - const { result: result1 } = renderHook(() => useSegmentRefs(segments)); - const { result: result2 } = renderHook(() => useSegmentRefs(segments)); + const { result: result1 } = renderHook(() => + useSegmentRefs({ segments }), + ); + const { result: result2 } = renderHook(() => + useSegmentRefs({ segments }), + ); expect(result1.current.month).not.toBe(result2.current.month); expect(result1.current.day).not.toBe(result2.current.day); diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts index 95ddba4537..1b2d32904a 100644 --- a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts @@ -1,29 +1,39 @@ import { useMemo } from 'react'; +import { isEmpty } from 'lodash'; -import { useDynamicRefs } from '@leafygreen-ui/hooks'; +import { useDynamicRefs, useObjectDependency } from '@leafygreen-ui/hooks'; /** * Creates a memoized object of refs for each segment. * @param segments - An object mapping segment names to their values. - * @returns An object mapping segment names to their refs. + * @param segmentRefs - An optional object mapping segment names to their refs. + * @returns If segmentRefs are provided, return them. Otherwise, create a new object mapping segment names to their refs. * * @example * const segments = { day: 'day', month: 'month', year: 'year' }; - * const segmentRefs = useSegmentRefs(segments); + * const segmentRefs = useSegmentRefs({ segments }); * // segmentRefs is { day: ref, month: ref, year: ref } */ -export const useSegmentRefs = ( - segments: Record, -) => { +export const useSegmentRefs = ({ + segments, + segmentRefs, +}: { + segments: Record; + segmentRefs?: Record>; +}) => { + const hasProvidedSegmentRefs = segmentRefs && !isEmpty(segmentRefs); + + /** Use object dependency to avoid triggering re-render when the segments object reference changes and the values are the same */ + const segmentsObj = useObjectDependency(segments); const getSegmentRef = useDynamicRefs(); - const segmentRefs = useMemo( + const createdSegmentRefs = useMemo( () => Object.fromEntries( - Object.entries(segments).map(([key]) => [key, getSegmentRef(key)]), + Object.entries(segmentsObj).map(([key]) => [key, getSegmentRef(key)]), ) as Record>, - [getSegmentRef, segments], + [getSegmentRef, segmentsObj], ); - return segmentRefs; + return hasProvidedSegmentRefs ? segmentRefs : createdSegmentRefs; };