diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 67bcec1d73..03d9832031 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/src/InputBox.stories.tsx b/packages/input-box/src/InputBox.stories.tsx new file mode 100644 index 0000000000..55e1c5400f --- /dev/null +++ b/packages/input-box/src/InputBox.stories.tsx @@ -0,0 +1,66 @@ +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 { Size } from '@leafygreen-ui/tokens'; + +import { SegmentObjMock } from './testutils/testutils.mocks'; +import { InputBox, InputBoxProps } from './InputBox'; +import { InputBoxWithState } from './testutils'; + +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), + }, + }, + args: { + size: Size.Default, + }, +}; +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/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 84f6997a39..7cbeaa34db 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -15,6 +15,7 @@ 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/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 index 4ef3b1941b..80cee23566 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,20 +1,162 @@ 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 { 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 */