-
Notifications
You must be signed in to change notification settings - Fork 72
[LG-5504] feat(input-box): add InputBox
#3285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 71 commits
09d117b
35d975b
2f81c18
86fbca9
7e6e4b4
1c69f5d
46746a5
691bde9
b2984f3
6be4fdf
b0d7bba
3986897
2eda96e
fff0557
959c5a1
e97d393
0ab86c9
ad1f017
81a943c
8cfadbe
68fc653
a04d5ec
0101c32
662f2dd
967b33b
a589e94
4a03f0b
e8a3705
4cf138e
a7062e2
dd132ea
0e9b9bd
5e73301
0baa5dc
6db5451
2d76c2c
d4ec60d
bf2eeda
5c05dc1
904fb8c
81e00e6
3792f8b
7b1db76
348faa3
20da919
6942348
3fe8f0b
2dc0134
b4dd84d
f2cfaa3
c269b96
73ea273
a55bf24
717daa7
67d8f9f
d7c1fc2
f52ed19
94f2900
68e5f2c
3896c9c
410813e
ed31fdc
adaa3b6
a106f71
df546c1
ea4d8b8
4d1030b
f342d2f
f7f28eb
349b655
bdabf2a
c666bb7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|  | ||
|
|
||
| ## 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 | ||
| }) => ( | ||
| <InputSegment | ||
| segment={segment} | ||
| value={value} | ||
| onChange={onChange} | ||
| onBlur={onBlur} | ||
| segmentEnum={segmentEnum} | ||
| disabled={disabled} | ||
| minSegmentValue={minValues[segment]} | ||
| maxSegmentValue={maxValues[segment]} | ||
| charsCount={charsPerSegment[segment]} | ||
| size={Size.Default} | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs to be updated |
||
| {...props} | ||
| /> | ||
| ); | ||
|
|
||
| // 2. Use InputBox with your segments | ||
| <InputBox | ||
| segments={{ day: '01', month: '02', year: '2025' }} | ||
| setSegment={(segment, value) => 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<Segment, string>` | Current values for all segments | | | ||
| | `setSegment` | `(segment: Segment, value: string) => void` | Callback to update a segment's value | | | ||
| | `segmentEnum` | `Record<string, Segment>` | Maps segment names to values (e.g., `{ Day: 'day' }`) | | | ||
| | `segmentComponent` | `React.ComponentType<InputSegmentComponentProps<Segment>>` | Custom wrapper component that renders InputSegment with segment-specific props | | | ||
| | `formatParts` | `Array<Intl.DateTimeFormatPart>` | Defines segment order and separators | | | ||
| | `segmentRefs` | `Record<Segment, React.RefObject<HTMLInputElement>>` | Refs for each segment input | | | ||
| | `segmentRules` | `Record<Segment, ExplicitSegmentRule>` | Rules for auto-formatting (`maxChars`, `minExplicitValue`) | | | ||
| | `disabled` | `boolean` | Disables all segments | | | ||
| | `onSegmentChange` | `InputSegmentChangeEventHandler<Segment, string>` | 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<string, Segment>` | Segment enum from InputBox | | | ||
| | `onChange` | `InputSegmentChangeEventHandler<Segment, string>` | Change handler | | | ||
| | `onBlur` | `FocusEventHandler<HTMLInputElement>` | 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof InputBox> = { | ||
| title: 'Components/Inputs/InputBox', | ||
| component: InputBox, | ||
| decorators: [ | ||
| (StoryFn, context: any) => ( | ||
| <div | ||
| className={css` | ||
| border: 1px solid ${palette.gray.base}; | ||
| `} | ||
| > | ||
| <LeafyGreenProvider darkMode={context?.args?.darkMode}> | ||
| <StoryFn /> | ||
| </LeafyGreenProvider> | ||
| </div> | ||
| ), | ||
| ], | ||
| 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) => ( | ||
| <LeafyGreenProvider darkMode={context?.args.darkMode}> | ||
| <StoryFn /> | ||
| </LeafyGreenProvider> | ||
| ), | ||
| }, | ||
| }, | ||
shaneeza marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| argTypes: { | ||
| disabled: { | ||
| control: 'boolean', | ||
| }, | ||
| size: { | ||
| control: 'select', | ||
| options: Object.values(Size), | ||
| }, | ||
| }, | ||
| args: { | ||
| disabled: false, | ||
| size: Size.Default, | ||
| }, | ||
| }; | ||
| export default meta; | ||
|
|
||
| export const LiveExample: StoryFn<typeof InputBox> = props => { | ||
| return ( | ||
| <InputBoxWithState {...(props as Partial<InputBoxProps<SegmentObjMock>>)} /> | ||
| ); | ||
| }; | ||
|
Comment on lines
+94
to
+98
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. similar to InputSegment, should we disable this snapshot and include a generated snapshot with some different combos?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I won't block on it, but I think new stories pair well with their related component code similar to when adding specs with their related code
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I missed this comment, but I added it! |
||
| LiveExample.parameters = { | ||
| chromatic: { disableSnapshot: true }, | ||
| }; | ||
|
|
||
| export const Date: StoryObj<InputBoxProps<SegmentObjMock>> = { | ||
| 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<InputBoxProps<TimeSegmentObjMock>> = { | ||
| 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, | ||
| }, | ||
| }; | ||
Uh oh!
There was an error while loading. Please reload this page.