diff --git a/packages/input-box/README.md b/packages/input-box/README.md index 67bcec1d73..b63395edee 100644 --- a/packages/input-box/README.md +++ b/packages/input-box/README.md @@ -1,4 +1,169 @@ -# 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 +// InputBox will pass: segment, value, onChange, onBlur, segmentEnum, disabled, ref, aria-labelledby +// You add: minSegmentValue, maxSegmentValue, charsCount, size, and any other InputSegment props +const MySegment = ({ + segment, + value, + onChange, + onBlur, + segmentEnum, + disabled, + ...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' }, + ]} + segmentRefs={{ day: dayRef, month: monthRef, year: yearRef }} + segmentRules={{ + day: { maxChars: 2, minExplicitValue: 4 }, + month: { maxChars: 2, minExplicitValue: 2 }, + year: { maxChars: 4, minExplicitValue: 1970 }, + }} + disabled={false} +/>; +``` + +Refer to `DateInputBox` in the `@leafygreen-ui/date-picker` package for a full implementation example. + +## Overview + +An internal component for building date or time inputs with multiple segments (e.g., `DatePicker`, `TimeInput`). + +### 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 + - `charsCount` - character length + - `size` - input size + +2. **Your `segmentComponent` adds** segment-specific configuration: + - `minSegmentValue` / `maxSegmentValue` - validation ranges + - `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 component that renders multiple segments separated by literals (e.g., `MM/DD/YYYY`). + +**Key Features:** + +- **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` | 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 | | +| `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 generic controlled input field for a single segment within `InputBox`. + +**Key Features:** + +- **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` | `Segment` | Segment identifier (e.g., `'day'`) | | +| `value` | `string` | Current segment value | | +| `minSegmentValue` | `number` | Minimum valid value | | +| `maxSegmentValue` | `number` | Maximum valid value | | +| `charsCount` | `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/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 new file mode 100644 index 0000000000..889b6fbe8b --- /dev/null +++ b/packages/input-box/src/InputBox.stories.tsx @@ -0,0 +1,143 @@ +/* eslint-disable no-console */ +import React from 'react'; +import { + storybookExcludedControlParams, + StoryMetaType, +} from '@lg-tools/storybook-utils'; +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 { + 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, InputSegmentWrapper } from './testutils'; + +const meta: StoryMetaType = { + title: 'Components/Inputs/InputBox', + component: InputBox, + decorators: [ + (StoryFn, context: any) => ( +
+ + + +
+ ), + ], + parameters: { + default: 'LiveExample', + controls: { + exclude: [ + ...storybookExcludedControlParams, + 'segments', + 'segmentObj', + 'segmentRefs', + 'setSegment', + 'formatParts', + 'segmentRules', + 'labelledBy', + 'onSegmentChange', + 'renderSegment', + 'segmentComponent', + 'segmentEnum', + ], + }, + generate: { + storyNames: ['Date', 'Time'], + combineArgs: { + disabled: [false, true], + size: Object.values(Size), + darkMode: [false, true], + }, + decorator: (StoryFn, context) => ( + + + + ), + }, + }, + argTypes: { + disabled: { + control: 'boolean', + }, + size: { + control: 'select', + options: Object.values(Size), + }, + }, + args: { + disabled: false, + size: Size.Default, + }, +}; +export default meta; + +export const LiveExample: StoryFn = props => { + return ( + >)} /> + ); +}; +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.spec.tsx b/packages/input-box/src/InputBox/InputBox.spec.tsx new file mode 100644 index 0000000000..17dd2679fd --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.spec.tsx @@ -0,0 +1,580 @@ +import React from 'react'; +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, + InputSegmentWrapper, + renderInputBox, +} from '../testutils'; +import { + SegmentObjMock, + segmentRefsMock, + segmentRulesMock, + segmentsMock, +} from '../testutils/testutils.mocks'; + +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', () => { + 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', () => { + 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({ + segmentRefs, + }); + + 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({ + segmentRefs, + }); + + 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({ + segmentRefs, + }); + + 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('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.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', () => { + 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 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', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '26'); + expect(dayInput.value).toBe('26'); + }); + + test('segment value is immediately formatted', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '5'); + expect(dayInput.value).toBe('05'); + }); + + test('allows leading zeros', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '02'); + expect(dayInput.value).toBe('02'); + }); + + test('allows 00 as a valid value if min value is 0', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '00'); + expect(dayInput.value).toBe('00'); + }); + + test('allows leading zeros if min value is 0', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '0'); + expect(dayInput.value).toBe('0'); + }); + }); + + describe('ambiguous value', () => { + test('segment value is not immediately formatted', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + expect(dayInput.value).toBe('2'); + }); + + test('value is formatted on segment blur', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + userEvent.tab(); + expect(dayInput.value).toBe('02'); + }); + + test('allows leading zeros if min value is 0', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '0'); + expect(dayInput.value).toBe('0'); + }); + + test('allows leading zeros if min value is greater than 0', () => { + // value is 1 - 12 + const { monthInput } = renderInputBox({}); + userEvent.type(monthInput, '0'); + expect(monthInput.value).toBe('0'); + }); + + test('allows backspace to delete the value', () => { + // value is 0 - 31 + const { dayInput } = renderInputBox({}); + userEvent.type(dayInput, '2'); + userEvent.type(dayInput, '{backspace}'); + 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', () => { + {}} + segmentRules={segmentRulesMock} + segmentComponent={InputSegmentWrapper} + 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..d5df050331 --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.styles.ts @@ -0,0 +1,38 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { Theme } from '@leafygreen-ui/lib'; +import { color, InteractionState, Variant } from '@leafygreen-ui/tokens'; + +export const segmentPartsWrapperStyles = css` + display: flex; + align-items: center; + gap: 1px; +`; + +export const separatorLiteralStyles = css` + user-select: none; +`; + +export const getSeparatorLiteralDisabledStyles = (theme: Theme) => + css` + color: ${color[theme].text[Variant.Disabled][InteractionState.Default]}; + `; + +export const getSeparatorLiteralStyles = ({ + theme, + disabled = false, +}: { + theme: Theme; + disabled?: boolean; +}) => { + return cx(separatorLiteralStyles, { + [getSeparatorLiteralDisabledStyles(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..1e6b531f0f --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.tsx @@ -0,0 +1,284 @@ +import React, { + FocusEventHandler, + ForwardedRef, + KeyboardEventHandler, +} from 'react'; +import isEmpty from 'lodash/isEmpty'; + +import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; +import { consoleOnce, keyMap } from '@leafygreen-ui/lib'; + +import { useSegmentRefs } from '../hooks'; +import { + InputSegmentChangeEventHandler, + isInputSegment, + Size, +} from '../shared.types'; +import { + createExplicitSegmentValidator, + getRelativeSegment, + getRelativeSegmentRef, + getValueFormatter, + isElementInputSegment, +} from '../utils'; + +import { + getSegmentPartsWrapperStyles, + getSeparatorLiteralStyles, +} from './InputBox.styles'; +import { InputBoxComponentType, InputBoxProps } from './InputBox.types'; + +const InputBoxWithRef = ( + { + className, + labelledBy, + segmentRefs: segmentRefsProp, + onSegmentChange, + onKeyDown, + setSegment, + disabled, + formatParts, + segmentEnum, + segmentRules, + segmentComponent, + segments, + size = Size.Default, + ...rest + }: InputBoxProps, + fwdRef: ForwardedRef, +) => { + const { theme } = useDarkMode(); + + /** If segmentRefs are provided, use them. Otherwise, create them using the segments. */ + const segmentRefs = useSegmentRefs({ + segments, + segmentRefs: segmentRefsProp, + }); + + 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, + rules: segmentRules, + }); + + /** Get the maximum number of characters per segment. */ + const getCharsPerSegment = (segment: Segment) => + segmentRules[segment].maxChars; + + /** Formats and sets the segment value. */ + const getFormattedSegmentValue = ( + segmentName: (typeof segmentEnum)[keyof typeof segmentEnum], + segmentValue: string, + allowZero: boolean, + ): string => { + const formatter = getValueFormatter({ + charsPerSegment: getCharsPerSegment(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({ + segment: segmentName, + value: 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 InputSegment + break; + } + + case keyMap.Backspace: { + if (isSegmentEmpty) { + // prevent the backspace in the previous segment + e.preventDefault(); + + const segmentToFocus = getRelativeSegmentRef('prev', { + segment: target, + formatParts, + segmentRefs, + }); + segmentToFocus?.current?.focus(); + segmentToFocus?.current?.select(); + } + break; + } + + case keyMap.Space: + case keyMap.Enter: + case keyMap.Escape: + case keyMap.Tab: + // Behavior handled by parent or menu + break; + } + + // call any handler that was passed in + onKeyDown?.(e); + }; + + return ( + <> + {/* We want to allow keydown events to be captured by the parent so that the parent can handle the event. */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ {formatParts?.map((part, i) => { + if (part.type === 'literal') { + return ( + + {part.value} + + ); + } else if (isInputSegment(part.type, segmentEnum)) { + const Segment = segmentComponent; + return ( + + ); + } + })} +
+ + ); +}; + +/** + * Generic controlled input box component that renders multiple input segments with separators. + * + * Supports auto-formatting, auto-advance focus, keyboard navigation (arrow keys), value increment/decrement, + * validation, and blur formatting. It is designed primarily for date and time inputs. + */ +export const InputBox = React.forwardRef( + InputBoxWithRef, +) as InputBoxComponentType; + +InputBox.displayName = 'InputBox'; diff --git a/packages/input-box/src/InputBox/InputBox.types.ts b/packages/input-box/src/InputBox/InputBox.types.ts new file mode 100644 index 0000000000..7e8dc254cc --- /dev/null +++ b/packages/input-box/src/InputBox/InputBox.types.ts @@ -0,0 +1,115 @@ +import React, { ForwardedRef, ReactElement } from 'react'; + +import { DateType } from '@leafygreen-ui/date-utils'; + +import { + InputSegmentChangeEventHandler, + InputSegmentComponentProps, + SharedInputBoxTypes, +} from '../shared.types'; +import { ExplicitSegmentRule } from '../utils'; + +export interface InputChangeEvent { + value: DateType; + segments: Record; +} + +export type InputChangeEventHandler = ( + changeEvent: InputChangeEvent, +) => void; + +export interface InputBoxProps + extends Omit, 'onChange' | 'children'>, + SharedInputBoxTypes { + /** + * Callback fired when any segment changes, but not necessarily a full value + */ + onSegmentChange?: InputSegmentChangeEventHandler; + + /** + * id of the labelling element + */ + labelledBy?: string; + + /** + * An object containing the values of the segments + * + * @example + * { day: '1', month: '2', year: '2025' } + */ + segments: Record; + + /** + * A function that sets the value of a segment + * + * @example + * (segment: 'day', value: '1') => void; + */ + setSegment: (segment: Segment, value: string) => void; + + /** + * The format parts of the date + * + * @example + * [ + * { type: 'month', value: '02' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '02' }, + * { type: 'literal', value: '-' }, + * { type: 'year', value: '2025' }, + * ] + */ + formatParts?: Array; + + /** + * An object that maps the segment names to their rules. + * + * maxChars: the maximum number of characters for the segment + * minExplicitValue: the minimum explicit value for the segment + * + * @example + * { + * day: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 4 }, + * year: { maxChars: 4, minExplicitValue: 1970 }, + * } + * + * Explicit: Day = 5, 02 + * Ambiguous: Day = 2 (could be 20-29) + * + */ + segmentRules: Record; + + /** + * The component that renders a segment. When mapping over the formatParts, we will render the segment component for each part using this component. + * This should be a React component that accepts the InputSegmentComponentProps type. + * + * @example + * segmentComponent={DateInputSegment} + */ + segmentComponent: React.ComponentType>; + + /** + * An object that maps the segment names to their refs + * + * @example + * { day: ref, month: ref, year: ref } + */ + segmentRefs?: Record>; +} + +/** + * Type definition for the InputBox component that maintains generic type safety with forwardRef. + * + * Interface with a generic call signature that preserves type parameters() when using forwardRef. + * React.forwardRef loses type parameters, so this interface is used to restore them. + * + * @see https://stackoverflow.com/a/58473012 + */ +export interface InputBoxComponentType { + ( + props: InputBoxProps, + ref: ForwardedRef, + ): ReactElement | null; + displayName?: string; +} diff --git a/packages/input-box/src/InputBox/index.ts b/packages/input-box/src/InputBox/index.ts new file mode 100644 index 0000000000..5b2e30901f --- /dev/null +++ b/packages/input-box/src/InputBox/index.ts @@ -0,0 +1,2 @@ +export { InputBox } from './InputBox'; +export { type InputBoxProps } from './InputBox.types'; diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx index 8d779b8809..0ba99616e9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.spec.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx @@ -5,7 +5,6 @@ import { axe } from 'jest-axe'; import { type InputSegmentChangeEventHandler } from '../shared.types'; import { renderSegment } from '../testutils'; import { - charsPerSegmentMock, defaultMaxMock, defaultMinMock, SegmentObjMock, @@ -160,13 +159,31 @@ describe('packages/input-segment', () => { expect.objectContaining({ value: '4' }), ); }); + + test('resets the value when the value is complete with zeros', () => { + const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler< + SegmentObjMock, + string + >; + const { input } = renderSegment({ + segment: 'day', + value: '00', + maxSegmentValue: 31, + onChange: onChangeHandler, + }); + + userEvent.type(input, '4'); + expect(onChangeHandler).toHaveBeenCalledWith( + expect.objectContaining({ value: '4' }), + ); + }); }); describe('keyboard events', () => { describe('Arrow keys', () => { const formatter = getValueFormatter({ - charsPerSegment: charsPerSegmentMock['day'], - allowZero: defaultMinMock['day'] === 0, + charsPerSegment: 2, + allowZero: true, }); describe('Up arrow', () => { diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx index 95b6e7bc78..0166d5b7f7 100644 --- a/packages/input-box/src/InputSegment/InputSegment.stories.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx @@ -6,8 +6,8 @@ import { import { StoryFn } from '@storybook/react'; import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider'; -import { Size } from '@leafygreen-ui/tokens'; +import { Size } from '../shared.types'; import { defaultPlaceholderMock, SegmentObjMock, @@ -39,6 +39,7 @@ const meta: StoryMetaType = { step: 1, darkMode: false, charsCount: 2, + segmentEnum: SegmentObjMock, }, argTypes: { size: { @@ -57,7 +58,7 @@ const meta: StoryMetaType = { 'segment', 'value', 'onChange', - 'charsPerSegment', + 'charsCount', 'segmentEnum', 'shouldValidate', 'step', diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx index ad45c11948..a6c8b09cf6 100644 --- a/packages/input-box/src/InputSegment/InputSegment.tsx +++ b/packages/input-box/src/InputSegment/InputSegment.tsx @@ -10,10 +10,12 @@ import { useDarkMode } from '@leafygreen-ui/leafygreen-provider'; import { keyMap } from '@leafygreen-ui/lib'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; +import { Size } from '../shared.types'; import { getNewSegmentValueFromArrowKeyPress, getNewSegmentValueFromInputValue, getValueFormatter, + isSingleDigitKey, } from '../utils'; import { getInputSegmentStyles } from './InputSegment.styles'; @@ -32,10 +34,10 @@ const InputSegmentWithRef = ( onChange, onBlur, segmentEnum, - size, disabled, value, charsCount, + size = Size.Default, step = 1, shouldWrap = true, shouldValidate = true, @@ -90,13 +92,9 @@ const InputSegmentWithRef = ( target: HTMLInputElement; }; - // A key press can be an `arrow`, `enter`, `space`, etc so we check for number presses - // We also check for `space` because Number(' ') returns true - const isNumber = Number(key) && key !== keyMap.Space; - - if (isNumber) { - // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called. - + // If the value is a single digit, we check if the input is full and reset it if it is. The digit will be inserted into the input when onChange is called. + // This is to handle the case where the user tries to type a single digit when the input is already full. Usually this happens when the focus is moved to the next segment or a segment is clicked + if (isSingleDigitKey(key)) { if (target.value.length === charsCount) { target.value = ''; } diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts index 9e436dd943..a9d724d5b9 100644 --- a/packages/input-box/src/InputSegment/InputSegment.types.ts +++ b/packages/input-box/src/InputSegment/InputSegment.types.ts @@ -1,18 +1,9 @@ -import React, { ForwardedRef, ReactElement } from 'react'; - -import { Size } from '@leafygreen-ui/tokens'; +import { ForwardedRef, ReactElement } from 'react'; import { InputSegmentComponentProps } from '../shared.types'; export interface InputSegmentProps - extends Omit< - React.ComponentPropsWithRef<'input'>, - 'size' | 'step' | 'value' | 'onBlur' | 'onChange' | 'min' | 'max' - >, - Pick< - InputSegmentComponentProps, - 'onChange' | 'onBlur' | 'segment' | 'segmentEnum' - > { + extends InputSegmentComponentProps { /** * Minimum value for the segment */ @@ -43,26 +34,6 @@ export interface InputSegmentProps * @default true */ shouldValidate?: boolean; - - /** - * The value of the segment - */ - value: string; - - /** - * The number of characters per segment - */ - charsCount: number; - - /** - * The size of the input box - * - * @example - * Size.Default - * Size.Small - * Size.Large - */ - size: Size; } /** diff --git a/packages/input-box/src/hooks/index.ts b/packages/input-box/src/hooks/index.ts new file mode 100644 index 0000000000..9c97080e94 --- /dev/null +++ b/packages/input-box/src/hooks/index.ts @@ -0,0 +1 @@ +export { useSegmentRefs } from './useSegmentRefs/useSegmentRefs'; diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts new file mode 100644 index 0000000000..f225407b86 --- /dev/null +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.spec.ts @@ -0,0 +1,278 @@ +import { renderHook } from '@leafygreen-ui/testing-lib'; + +import { useSegmentRefs } from './useSegmentRefs'; + +describe('packages/input-box/hooks/useSegmentRefs', () => { + describe('basic functionality', () => { + test('returns an object with segments with their refs', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs({ segments })); + + expect(result.current).toHaveProperty('month'); + expect(result.current.month).toHaveProperty('current'); + expect(result.current).toHaveProperty('day'); + expect(result.current.day).toHaveProperty('current'); + expect(result.current).toHaveProperty('year'); + expect(result.current.year).toHaveProperty('current'); + }); + + test('handles empty segments object', () => { + const segments = {}; + + const { result } = renderHook(() => useSegmentRefs({ segments })); + + expect(result.current).toEqual({}); + }); + + test('handles single segment', () => { + const segments = { input: 'input' }; + + const { result } = renderHook(() => useSegmentRefs({ segments })); + + expect(result.current).toHaveProperty('input'); + expect(result.current.input).toHaveProperty('current'); + }); + }); + + describe('memoization', () => { + test('returns the same refs when rerendered with the same segments object', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments }, + }, + ); + + const initialMonthRef = result.current.month; + const initialDayRef = result.current.day; + const initialYearRef = result.current.year; + + rerender({ segments }); + + expect(result.current.month).toBe(initialMonthRef); + expect(result.current.day).toBe(initialDayRef); + expect(result.current.year).toBe(initialYearRef); + }); + + test('returns the same refs when rerendered with a different segments object reference but the same values', () => { + const segments1 = { + month: 'month', + day: 'day', + year: 'year', + }; + + const segments2 = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments: segments1 }, + }, + ); + + const initialMonthRef = result.current.month; + const initialDayRef = result.current.day; + const initialYearRef = result.current.year; + + rerender({ segments: segments2 }); + + expect(result.current.month).toBe(initialMonthRef); + expect(result.current.day).toBe(initialDayRef); + expect(result.current.year).toBe(initialYearRef); + }); + + test('returns different refs when segments change', () => { + const segments1 = { + month: 'month', + day: 'day', + year: 'year', + }; + + const segments2 = { + hour: 'hour', + minute: 'minute', + second: 'second', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments: segments1 }, + }, + ); + + const initialMonthRef = result.current.month; + const initialDayRef = result.current.day; + const initialYearRef = result.current.year; + + // @ts-expect-error - segments2 has a different shape than segments1 + rerender({ segments: segments2 }); + + // After rerender with different keys, the result should have new properties + expect(result.current).toHaveProperty('hour'); + expect(result.current).toHaveProperty('minute'); + expect(result.current).toHaveProperty('second'); + + // Old properties should not exist + expect(result.current).not.toHaveProperty('month'); + expect(result.current).not.toHaveProperty('day'); + expect(result.current).not.toHaveProperty('year'); + + // The new refs should have different values + expect((result.current as any).hour).not.toBe(initialMonthRef); + expect((result.current as any).minute).not.toBe(initialDayRef); + expect((result.current as any).second).not.toBe(initialYearRef); + }); + + test('returns updated object when segments are added', () => { + const initialSegments = { + month: 'month', + day: 'day', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments: initialSegments }, + }, + ); + + const initialResult = result.current; + + const newSegments = { + month: 'month', + day: 'day', + year: 'year', + }; + + rerender({ segments: newSegments }); + + // Should return a new object when segments change + expect(result.current).not.toBe(initialResult); + expect(Object.keys(result.current)).toHaveLength(3); + expect(result.current).toHaveProperty('year'); + }); + + test('returns updated object when segments are removed', () => { + const initialSegments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const newSegments = { + month: 'month', + day: 'day', + }; + + const { result, rerender } = renderHook( + ({ segments }) => useSegmentRefs({ segments }), + { + initialProps: { segments: initialSegments }, + }, + ); + + const initialResult = result.current; + + // @ts-expect-error - newSegments has a different shape than initialSegments + rerender({ segments: newSegments }); + + expect(result.current).not.toBe(initialResult); + expect(Object.keys(result.current)).toHaveLength(2); + expect(result.current).not.toHaveProperty('year'); + }); + }); + + describe('with provided segmentRefs', () => { + test('returns provided segmentRefs instead of creating a new object', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const providedRefs = { + month: { current: null }, + day: { current: null }, + year: { current: null }, + }; + + const { result } = renderHook(() => + useSegmentRefs({ segments, segmentRefs: providedRefs }), + ); + + // Should return the exact same ref objects that were provided + expect(result.current).toBe(providedRefs); + expect(result.current.month).toBe(providedRefs.month); + expect(result.current.day).toBe(providedRefs.day); + expect(result.current.year).toBe(providedRefs.year); + }); + + test('creates new segmentRefs object when provided segmentRefs is empty', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const providedRefs = {}; + + const { result } = renderHook(() => + // @ts-expect-error - providedRefs is empty but that's okay in this case + useSegmentRefs({ segments, segmentRefs: providedRefs }), + ); + + expect(result.current.month).toBeDefined(); + expect(result.current.day).toBeDefined(); + expect(result.current.year).toBeDefined(); + }); + }); + + describe('ref uniqueness', () => { + test('each segment gets a unique ref', () => { + const segments = { + month: 'month', + day: 'day', + year: 'year', + }; + + const { result } = renderHook(() => useSegmentRefs({ segments })); + + expect(result.current.month).not.toBe(result.current.day); + expect(result.current.day).not.toBe(result.current.year); + expect(result.current.month).not.toBe(result.current.year); + }); + + test('different hook instances return different refs', () => { + const segments = { + month: 'month', + day: 'day', + }; + + const { result: result1 } = renderHook(() => + useSegmentRefs({ segments }), + ); + const { result: result2 } = renderHook(() => + useSegmentRefs({ segments }), + ); + + expect(result1.current.month).not.toBe(result2.current.month); + expect(result1.current.day).not.toBe(result2.current.day); + }); + }); +}); diff --git a/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts new file mode 100644 index 0000000000..1b2d32904a --- /dev/null +++ b/packages/input-box/src/hooks/useSegmentRefs/useSegmentRefs.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { isEmpty } from 'lodash'; + +import { useDynamicRefs, useObjectDependency } from '@leafygreen-ui/hooks'; + +/** + * Creates a memoized object of refs for each segment. + * @param segments - An object mapping segment names to their values. + * @param segmentRefs - An optional object mapping segment names to their refs. + * @returns If segmentRefs are provided, return them. Otherwise, create a new object mapping segment names to their refs. + * + * @example + * const segments = { day: 'day', month: 'month', year: 'year' }; + * const segmentRefs = useSegmentRefs({ segments }); + * // segmentRefs is { day: ref, month: ref, year: ref } + */ +export const useSegmentRefs = ({ + segments, + segmentRefs, +}: { + segments: Record; + segmentRefs?: Record>; +}) => { + const hasProvidedSegmentRefs = segmentRefs && !isEmpty(segmentRefs); + + /** Use object dependency to avoid triggering re-render when the segments object reference changes and the values are the same */ + const segmentsObj = useObjectDependency(segments); + const getSegmentRef = useDynamicRefs(); + + const createdSegmentRefs = useMemo( + () => + Object.fromEntries( + Object.entries(segmentsObj).map(([key]) => [key, getSegmentRef(key)]), + ) as Record>, + [getSegmentRef, segmentsObj], + ); + + return hasProvidedSegmentRefs ? segmentRefs : createdSegmentRefs; +}; diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts index f70976968b..56f7ff51cf 100644 --- a/packages/input-box/src/index.ts +++ b/packages/input-box/src/index.ts @@ -1,3 +1,10 @@ +export { InputBox, type InputBoxProps } from './InputBox'; +export { InputSegment, type InputSegmentProps } from './InputSegment'; +export { + type InputSegmentChangeEventHandler, + isInputSegment, + Size, +} from './shared.types'; export { createExplicitSegmentValidator, type ExplicitSegmentRule, diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts index 7e49b4b154..f866fce8fa 100644 --- a/packages/input-box/src/shared.types.ts +++ b/packages/input-box/src/shared.types.ts @@ -1,4 +1,7 @@ import { keyMap } from '@leafygreen-ui/lib'; +import { Size } from '@leafygreen-ui/tokens'; + +export { Size }; /** * SharedInput Segment Types @@ -47,7 +50,7 @@ export function isInputSegment>( export interface InputSegmentComponentProps extends Omit< React.ComponentPropsWithRef<'input'>, - 'onChange' | 'value' | 'disabled' + 'onChange' | 'value' | 'disabled' | 'size' | 'step' >, SharedInputBoxTypes { /** @@ -67,6 +70,11 @@ export interface InputSegmentComponentProps * The value of the segment */ value: string; + + /** + * The number of characters per segment + */ + charsCount: number; } /** @@ -87,4 +95,16 @@ export interface SharedInputBoxTypes { * Whether the input box is disabled */ disabled: boolean; + + /** + * The size of the input box + * + * @example + * Size.Default + * Size.Small + * Size.Large + * + * @default Size.Default + */ + size?: Size; } diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx index c33f3e4573..1da7a3dc5d 100644 --- a/packages/input-box/src/testutils/index.tsx +++ b/packages/input-box/src/testutils/index.tsx @@ -1,9 +1,165 @@ import React from 'react'; import { render, RenderResult } from '@testing-library/react'; +import { InputBox, InputBoxProps } from '../InputBox'; import { InputSegment, type InputSegmentProps } from '../InputSegment'; +import { InputSegmentComponentProps, Size } from '../shared.types'; -import { SegmentObjMock } from './testutils.mocks'; +import { + defaultFormatPartsMock, + defaultMaxMock, + defaultMinMock, + defaultPlaceholderMock, + SegmentObjMock, + segmentRulesMock, + segmentsMock, + segmentWidthStyles, +} from './testutils.mocks'; + +export const defaultProps: Partial> = { + segments: segmentsMock, + segmentEnum: SegmentObjMock, + setSegment: () => {}, + formatParts: defaultFormatPartsMock, + segmentRules: segmentRulesMock, +}; + +/** + * This component is used to render the InputSegment component for testing purposes. + * @param segment - The segment to render + * @returns + */ +export const InputSegmentWrapper = React.forwardRef< + HTMLInputElement, + InputSegmentComponentProps +>( + ( + { + segment, + value, + onChange = () => {}, + onBlur = () => {}, + segmentEnum = SegmentObjMock, + disabled = false, + charsCount, + size, + }, + ref, + ) => { + return ( + + ); + }, +); + +InputSegmentWrapper.displayName = 'InputSegmentWrapper'; + +/** + * This component is used to render the InputBox component for testing purposes. + * Includes segment state management and a default renderSegment function. + * Props can override the internal state management. + */ +export const InputBoxWithState = ({ + segments: segmentsProp = { + day: '', + month: '', + year: '', + }, + setSegment: setSegmentProp, + disabled = false, + size = Size.Default, + ...props +}: Partial> & { + segments?: Record; +}) => { + const [segments, setSegments] = React.useState(segmentsProp); + + const defaultSetSegment = (segment: SegmentObjMock, value: string) => { + setSegments(prev => ({ ...prev, [segment]: value })); + }; + + // If setSegment is provided, use controlled mode with the provided segments + // Otherwise, use internal state management + const effectiveSegments = setSegmentProp ? segmentsProp : segments; + const effectiveSetSegment = setSegmentProp ?? defaultSetSegment; + + return ( + + ); +}; + +interface RenderInputBoxReturnType { + dayInput: HTMLInputElement; + monthInput: HTMLInputElement; + yearInput: HTMLInputElement; + rerenderInputBox: (props: Partial>) => void; + getDayInput: () => HTMLInputElement; + getMonthInput: () => HTMLInputElement; + getYearInput: () => HTMLInputElement; +} + +/** + * Renders InputBox with internal state management for testing purposes. + * Props can be passed to override the default state behavior. + */ +export const renderInputBox = ({ + ...props +}: Partial> = {}): RenderResult & + RenderInputBoxReturnType => { + const result = render(); + + const getDayInput = () => + result.getByTestId('input-segment-day') as HTMLInputElement; + const getMonthInput = () => + result.getByTestId('input-segment-month') as HTMLInputElement; + const getYearInput = () => + result.getByTestId('input-segment-year') as HTMLInputElement; + + const rerenderInputBox = ( + newProps: Partial>, + ) => { + result.rerender(); + }; + + return { + ...result, + rerenderInputBox, + dayInput: getDayInput(), + monthInput: getMonthInput(), + yearInput: getYearInput(), + getDayInput, + getMonthInput, + getYearInput, + }; +}; interface RenderSegmentReturnType { getInput: () => HTMLInputElement; diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts deleted file mode 100644 index 0466e233e3..0000000000 --- a/packages/input-box/src/testutils/testutils.mocks.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { createRef } from 'react'; - -import { css } from '@leafygreen-ui/emotion'; -import { DynamicRefGetter } from '@leafygreen-ui/hooks'; - -import { ExplicitSegmentRule } from '../utils'; - -export const SegmentObjMock = { - Month: 'month', - Day: 'day', - Year: 'year', -} as const; -export type SegmentObjMock = - (typeof SegmentObjMock)[keyof typeof SegmentObjMock]; - -export type SegmentRefsMock = Record< - SegmentObjMock, - ReturnType> ->; - -export const segmentRefsMock: SegmentRefsMock = { - month: createRef(), - day: createRef(), - year: createRef(), -}; - -export const segmentsMock: Record = { - month: '02', - day: '02', - year: '2025', -}; -export const charsPerSegmentMock: Record = { - month: 2, - day: 2, - year: 4, -}; -export const segmentRulesMock: Record = { - month: { maxChars: 2, minExplicitValue: 2 }, - day: { maxChars: 2, minExplicitValue: 4 }, - year: { maxChars: 4, minExplicitValue: 1970 }, -}; -export const defaultMinMock: Record = { - month: 1, - day: 0, - year: 1970, -}; -export const defaultMaxMock: Record = { - month: 12, - day: 31, - year: 2038, -}; - -export const defaultPlaceholderMock: Record = { - day: 'DD', - month: 'MM', - year: 'YYYY', -} as const; - -export const defaultFormatPartsMock: Array = [ - { type: 'month', value: '' }, - { type: 'literal', value: '-' }, - { type: 'day', value: '' }, - { type: 'literal', value: '-' }, - { type: 'year', value: '' }, -]; - -/** The percentage of 1ch these specific characters take up */ -export const characterWidth = { - // Standard font - D: 46 / 40, - M: 55 / 40, - Y: 50 / 40, -} as const; - -export const segmentWidthStyles: Record = { - day: css` - width: ${charsPerSegmentMock.day * characterWidth.D}ch; - `, - month: css` - width: ${charsPerSegmentMock.month * characterWidth.M}ch; - `, - year: css` - width: ${charsPerSegmentMock.year * characterWidth.Y}ch; - `, -}; diff --git a/packages/input-box/src/testutils/testutils.mocks.tsx b/packages/input-box/src/testutils/testutils.mocks.tsx new file mode 100644 index 0000000000..076ccb0df8 --- /dev/null +++ b/packages/input-box/src/testutils/testutils.mocks.tsx @@ -0,0 +1,178 @@ +import React, { createRef, forwardRef } from 'react'; + +import { css } from '@leafygreen-ui/emotion'; +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +import { InputSegment } from '../InputSegment'; +import { InputSegmentComponentProps } from '../shared.types'; +import { ExplicitSegmentRule } from '../utils'; + +export const SegmentObjMock = { + Month: 'month', + Day: 'day', + Year: 'year', +} as const; +export type SegmentObjMock = + (typeof SegmentObjMock)[keyof typeof SegmentObjMock]; + +export type SegmentRefsMock = Record< + SegmentObjMock, + ReturnType> +>; + +export const segmentRefsMock: SegmentRefsMock = { + month: createRef(), + day: createRef(), + year: createRef(), +}; + +export const dateSegmentEmptyMock: Record = { + month: '', + day: '', + year: '', +}; + +export const segmentsMock: Record = { + month: '02', + day: '02', + year: '2025', +}; +export const segmentRulesMock: Record = { + month: { maxChars: 2, minExplicitValue: 2 }, + day: { maxChars: 2, minExplicitValue: 4 }, + year: { maxChars: 4, minExplicitValue: 1970 }, +}; +export const defaultMinMock: Record = { + month: 1, + day: 0, + year: 1970, +}; +export const defaultMaxMock: Record = { + month: 12, + day: 31, + year: 2038, +}; + +export const defaultPlaceholderMock: Record = { + day: 'DD', + month: 'MM', + year: 'YYYY', +} as const; + +export const defaultFormatPartsMock: Array = [ + { type: 'month', value: '' }, + { type: 'literal', value: '-' }, + { type: 'day', value: '' }, + { type: 'literal', value: '-' }, + { type: 'year', value: '' }, +]; + +/** The percentage of 1ch these specific characters take up */ +export const characterWidth = { + // Standard font + D: 46 / 40, + M: 55 / 40, + Y: 50 / 40, + H: 46 / 40, + MM: 55 / 40, + S: 46 / 40, +} as const; + +export const segmentWidthStyles: Record = { + day: css` + width: ${segmentRulesMock['day'].maxChars * characterWidth.D}ch; + `, + month: css` + width: ${segmentRulesMock['month'].maxChars * characterWidth.M}ch; + `, + year: css` + width: ${segmentRulesMock['year'].maxChars * characterWidth.Y}ch; + `, +}; + +/** Mocks for time generate story */ +export const TimeSegmentObjMock = { + Hour: 'hour', + Minute: 'minute', + Second: 'second', +} as const; +export type TimeSegmentObjMock = + (typeof TimeSegmentObjMock)[keyof typeof TimeSegmentObjMock]; + +export const timeSegmentsMock: Record = { + hour: '23', + minute: '00', + second: '59', +}; + +export const timeSegmentsEmptyMock: Record = { + hour: '', + minute: '', + second: '', +}; + +export const timeSegmentRulesMock: Record< + TimeSegmentObjMock, + ExplicitSegmentRule +> = { + hour: { maxChars: 2, minExplicitValue: 3 }, + minute: { maxChars: 2, minExplicitValue: 6 }, + second: { maxChars: 2, minExplicitValue: 6 }, +}; + +export const timeMinMock: Record = { + hour: 0, + minute: 0, + second: 0, +}; +export const timeMaxMock: Record = { + hour: 23, + minute: 59, + second: 59, +}; + +export const timePlaceholderMock: Record = { + hour: 'HH', + minute: 'MM', + second: 'SS', +} as const; + +export const timeFormatPartsMock: Array = [ + { type: 'hour', value: '' }, + { type: 'literal', value: ':' }, + { type: 'minute', value: '' }, + { type: 'literal', value: ':' }, + { type: 'second', value: '' }, +]; + +export const timeSegmentWidthStyles: Record = { + hour: css` + width: ${timeSegmentRulesMock['hour'].maxChars * characterWidth.D}ch; + `, + minute: css` + width: ${timeSegmentRulesMock['minute'].maxChars * characterWidth.MM}ch; + `, + second: css` + width: ${timeSegmentRulesMock['second'].maxChars * characterWidth.Y}ch; + `, +}; + +export const TimeInputSegmentWrapper = forwardRef< + HTMLInputElement, + InputSegmentComponentProps +>((props, ref) => { + const { segment, ...rest } = props; + return ( + + ); +}); + +TimeInputSegmentWrapper.displayName = 'TimeInputSegmentWrapper'; diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts index 9754f2fa90..7aab16f5d5 100644 --- a/packages/input-box/src/utils/index.ts +++ b/packages/input-box/src/utils/index.ts @@ -10,6 +10,7 @@ export { } from './getRelativeSegment/getRelativeSegment'; export { getValueFormatter } from './getValueFormatter/getValueFormatter'; export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment'; +export { isSingleDigitKey } from './isSingleDigitKey/isSingleDigitKey'; export { isValidSegmentName, isValidSegmentValue, diff --git a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts new file mode 100644 index 0000000000..dd63466d13 --- /dev/null +++ b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.spec.ts @@ -0,0 +1,15 @@ +import range from 'lodash/range'; + +import { keyMap } from '@leafygreen-ui/lib'; + +import { isSingleDigitKey } from './isSingleDigitKey'; + +describe('packages/input-box/utils/isSingleDigit', () => { + test.each(range(10))('returns true for %i character', i => { + expect(isSingleDigitKey(`${i}`)).toBe(true); + }); + + test.each(Object.values(keyMap))('returns false for %s', key => { + expect(isSingleDigitKey(key)).toBe(false); + }); +}); diff --git a/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts new file mode 100644 index 0000000000..b48112550b --- /dev/null +++ b/packages/input-box/src/utils/isSingleDigitKey/isSingleDigitKey.ts @@ -0,0 +1,7 @@ +/** + * Checks if the key is a single digit. + * + * @param key - The key to check. + * @returns True if the key is a single digit, false otherwise. + */ +export const isSingleDigitKey = (key: string): boolean => /^[0-9]$/.test(key); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d17f46d811..1ee880d083 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2204,6 +2204,9 @@ importers: '@leafygreen-ui/typography': specifier: workspace:^ version: link:../typography + lodash: + specifier: ^4.17.21 + version: 4.17.21 packages/input-option: dependencies: