diff --git a/packages/input-box/package.json b/packages/input-box/package.json
index 3030c6e71e..cc5cb766c5 100644
--- a/packages/input-box/package.json
+++ b/packages/input-box/package.json
@@ -28,11 +28,11 @@
"access": "public"
},
"dependencies": {
+ "@leafygreen-ui/a11y": "workspace:^",
"@leafygreen-ui/emotion": "workspace:^",
"@leafygreen-ui/lib": "workspace:^",
"@leafygreen-ui/hooks": "workspace:^",
"@leafygreen-ui/date-utils": "workspace:^",
- "@leafygreen-ui/palette": "workspace:^",
"@leafygreen-ui/tokens": "workspace:^",
"@leafygreen-ui/typography": "workspace:^"
},
diff --git a/packages/input-box/src/InputSegment/InputSegment.spec.tsx b/packages/input-box/src/InputSegment/InputSegment.spec.tsx
new file mode 100644
index 0000000000..8d779b8809
--- /dev/null
+++ b/packages/input-box/src/InputSegment/InputSegment.spec.tsx
@@ -0,0 +1,816 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import { axe } from 'jest-axe';
+
+import { type InputSegmentChangeEventHandler } from '../shared.types';
+import { renderSegment } from '../testutils';
+import {
+ charsPerSegmentMock,
+ defaultMaxMock,
+ defaultMinMock,
+ SegmentObjMock,
+} from '../testutils/testutils.mocks';
+import { getValueFormatter } from '../utils';
+
+import { InputSegment } from '.';
+
+describe('packages/input-segment', () => {
+ describe('aria attributes', () => {
+ test('does not have basic accessibility issues when tooltip is not open', async () => {
+ const { container } = renderSegment({
+ segment: 'day',
+ });
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ test(`segment has aria-label`, () => {
+ const { input } = renderSegment({
+ segment: 'day',
+ });
+ expect(input).toHaveAttribute('aria-label', 'day');
+ });
+
+ test('has role="spinbutton"', () => {
+ const { input } = renderSegment({});
+ expect(input).toHaveAttribute('role', 'spinbutton');
+ });
+
+ test('has min and max attributes', () => {
+ const { input } = renderSegment({
+ segment: 'day',
+ minSegmentValue: defaultMinMock['day'],
+ maxSegmentValue: defaultMaxMock['day'],
+ });
+ expect(input).toHaveAttribute('min', String(defaultMinMock['day']));
+ expect(input).toHaveAttribute('max', String(defaultMaxMock['day']));
+ });
+ });
+
+ describe('rendering', () => {
+ test('Rendering with undefined sets the value to empty string', () => {
+ const { input } = renderSegment({
+ segment: 'day',
+ value: '',
+ });
+ expect(input.value).toBe('');
+ });
+
+ test('Rendering with a value sets the input value', () => {
+ const { input } = renderSegment({
+ segment: 'day',
+ value: '12',
+ });
+ expect(input.value).toBe('12');
+ });
+
+ test('rerendering updates the value', () => {
+ const { getInput, rerenderSegment } = renderSegment({
+ segment: 'day',
+ value: '12',
+ });
+
+ rerenderSegment({
+ value: '08',
+ });
+ expect(getInput().value).toBe('08');
+ });
+ });
+
+ describe('typing', () => {
+ describe('into an empty segment', () => {
+ test('calls the change handler', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ });
+
+ userEvent.type(input, '8');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '8' }),
+ );
+ });
+
+ test('allows zero character', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ });
+
+ userEvent.type(input, '0');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '0' }),
+ );
+ });
+
+ test('does not allow non-number characters', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ });
+ userEvent.type(input, 'aB$/');
+ expect(onChangeHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('into a segment with a value', () => {
+ test('allows typing additional characters if the current value is incomplete', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ value: '2',
+ onChange: onChangeHandler,
+ });
+
+ userEvent.type(input, '6');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '26' }),
+ );
+ });
+
+ test('resets the value when the value is complete', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ value: '26',
+ 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,
+ });
+
+ describe('Up arrow', () => {
+ test('calls handler with value default +1 step', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: formatter(15),
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(16),
+ }),
+ );
+ });
+
+ test('calls handler with custom `step`', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ step: 2,
+ onChange: onChangeHandler,
+ value: formatter(15),
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(17),
+ }),
+ );
+ });
+
+ test('calls handler with `min`', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '',
+ maxSegmentValue: 31,
+ minSegmentValue: 0,
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(0),
+ }),
+ );
+ });
+
+ test('rolls value over to `min` value if value exceeds `max`', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: formatter(31),
+ maxSegmentValue: 31,
+ minSegmentValue: 0,
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(0),
+ }),
+ );
+ });
+
+ test('does not wrap if `shouldWrap` is false', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ shouldWrap: false,
+ onChange: onChangeHandler,
+ value: formatter(31),
+ maxSegmentValue: 31,
+ minSegmentValue: 0,
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(31 + 1),
+ }),
+ );
+ });
+
+ test('does not wrap if `shouldWrap` is false and value is less than min', () => {
+ const formatter = getValueFormatter({
+ charsPerSegment: 4,
+ allowZero: false,
+ });
+
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'year',
+ minSegmentValue: 1970,
+ maxSegmentValue: 2038,
+ charsCount: 4,
+ shouldWrap: false,
+ onChange: onChangeHandler,
+ value: '3',
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ segment: 'year',
+ value: formatter(3 + 1),
+ }),
+ );
+ });
+
+ test('formats value with leading zero', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '06',
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '07' }),
+ );
+ });
+
+ test('formats values without leading zeros', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '3',
+ });
+
+ userEvent.type(input, '{arrowup}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: formatter(3 + 1) }),
+ );
+ });
+ });
+
+ describe('Down arrow', () => {
+ test('calls handler with value default -1 step', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: formatter(15),
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(15 - 1),
+ }),
+ );
+ });
+
+ test('calls handler with custom `step`', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ step: 2,
+ onChange: onChangeHandler,
+ value: formatter(15),
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(15 - 2),
+ }),
+ );
+ });
+
+ test('calls handler with `max`', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ maxSegmentValue: 31,
+ minSegmentValue: 0,
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(31),
+ }),
+ );
+ });
+
+ test('rolls value over to `max` value if value exceeds `min`', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: formatter(0),
+ maxSegmentValue: 31,
+ minSegmentValue: 0,
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(31),
+ }),
+ );
+ });
+
+ /* eslint-disable jest/no-disabled-tests */
+ test.skip('does not wrap if `shouldWrap` is false', () => {
+ // TODO: this should not wrap the min value
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ shouldWrap: false,
+ onChange: onChangeHandler,
+ value: formatter(0),
+ maxSegmentValue: 31,
+ minSegmentValue: 0,
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ value: formatter(0 - 1),
+ }),
+ );
+ });
+
+ test('does not wrap if `shouldWrap` is false and value is less than min', () => {
+ const formatter = getValueFormatter({
+ charsPerSegment: 4,
+ allowZero: false,
+ });
+
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'year',
+ minSegmentValue: 1970,
+ maxSegmentValue: 2038,
+ charsCount: 4,
+ shouldWrap: false,
+ onChange: onChangeHandler,
+ value: '3',
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ segment: 'year',
+ value: formatter(3 - 1),
+ }),
+ );
+ });
+
+ test('formats value with leading zero', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '06',
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '05' }),
+ );
+ });
+
+ test('formats values without leading zeros', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '3',
+ });
+
+ userEvent.type(input, '{arrowdown}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: formatter(3 - 1) }),
+ );
+ });
+ });
+
+ describe('Backspace', () => {
+ test('clears the input when there is a value', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '12',
+ });
+
+ userEvent.type(input, '{backspace}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '' }),
+ );
+ });
+
+ test('does not call the onChangeHandler when the value is initially empty', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ });
+
+ userEvent.type(input, '{backspace}');
+ expect(onChangeHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Space', () => {
+ describe('on a single SPACE', () => {
+ test('does not call the onChangeHandler when the value is initially empty', () => {
+ const onChangeHandler =
+ jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ });
+
+ userEvent.type(input, '{space}');
+ expect(onChangeHandler).not.toHaveBeenCalled();
+ });
+
+ test('calls the onChangeHandler when the value is present', () => {
+ const onChangeHandler =
+ jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '12',
+ });
+
+ userEvent.type(input, '{space}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '' }),
+ );
+ });
+ });
+
+ describe('on a double SPACE', () => {
+ test('does not call the onChangeHandler when the value is initially empty', () => {
+ const onChangeHandler =
+ jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ });
+
+ userEvent.type(input, '{space}{space}');
+ expect(onChangeHandler).not.toHaveBeenCalled();
+ });
+
+ test('calls the onChangeHandler when the value is present', () => {
+ const onChangeHandler =
+ jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '12',
+ });
+
+ userEvent.type(input, '{space}{space}');
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '' }),
+ );
+ });
+ });
+ });
+ });
+ });
+
+ describe('min/max range', () => {
+ test('does not allow values outside max range', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ onChange: onChangeHandler,
+ value: '3',
+ maxSegmentValue: 31,
+ minSegmentValue: 0,
+ });
+ userEvent.type(input, '2');
+
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '2' }),
+ );
+ });
+
+ test('allows values below min range', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ // min is 1. We allow values below min range.
+ const { input } = renderSegment({
+ segment: 'month',
+ minSegmentValue: 1,
+ maxSegmentValue: 12,
+ onChange: onChangeHandler,
+ value: '',
+ });
+ userEvent.type(input, '0');
+
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '0' }),
+ );
+ });
+
+ test('allows values above max range when shouldValidate is false', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+
+ const { input } = renderSegment({
+ segment: 'year',
+ charsCount: 4,
+ maxSegmentValue: 2038,
+ shouldValidate: false,
+ onChange: onChangeHandler,
+ value: '203',
+ });
+ userEvent.type(input, '9');
+
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ value: '2039' }),
+ );
+ });
+ });
+ });
+
+ describe('onBlur handler', () => {
+ test('calls the onBlur handler when the input is blurred', () => {
+ const onBlurHandler = jest.fn();
+ const { input } = renderSegment({
+ segment: 'day',
+ onBlur: onBlurHandler,
+ });
+
+ input.focus();
+ input.blur();
+
+ expect(onBlurHandler).toHaveBeenCalled();
+ });
+ });
+
+ describe('onKeyDown handler', () => {
+ test('calls the onKeyDown handler when a key is pressed', () => {
+ const onKeyDownHandler = jest.fn();
+ const { input } = renderSegment({
+ segment: 'day',
+ onKeyDown: onKeyDownHandler,
+ });
+
+ userEvent.type(input, '5');
+
+ expect(onKeyDownHandler).toHaveBeenCalled();
+ });
+ });
+
+ describe('disabled state', () => {
+ test('input is disabled when disabled context prop is true', () => {
+ const { input } = renderSegment({
+ segment: 'day',
+ disabled: true,
+ });
+
+ expect(input).toBeDisabled();
+ });
+
+ test('does not call onChange when disabled and typed into', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ disabled: true,
+ onChange: onChangeHandler,
+ });
+
+ userEvent.type(input, '5');
+
+ expect(onChangeHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('shouldSkipValidation prop', () => {
+ test('allows values outside min/max range when shouldValidate is false', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ shouldValidate: false,
+ onChange: onChangeHandler,
+ value: '9',
+ });
+
+ userEvent.type(input, '9');
+
+ expect(onChangeHandler).toHaveBeenCalledWith(
+ expect.objectContaining({ segment: 'day', value: '99' }),
+ );
+ });
+
+ test('does not allows values outside min/max range when shouldValidate is true', () => {
+ const onChangeHandler = jest.fn() as InputSegmentChangeEventHandler<
+ SegmentObjMock,
+ string
+ >;
+ const { input } = renderSegment({
+ segment: 'day',
+ shouldValidate: true,
+ onChange: onChangeHandler,
+ value: '9',
+ });
+
+ userEvent.type(input, '9');
+
+ expect(onChangeHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ /* eslint-disable jest/no-disabled-tests */
+ describe.skip('types behave as expected', () => {
+ test('InputSegment throws error when no required props are provided', () => {
+ // @ts-expect-error - missing required props
+ ;
+ });
+
+ test('With required props', () => {
+ {}}
+ onBlur={() => {}}
+ onKeyDown={() => {}}
+ disabled={false}
+ size={'default'}
+ segmentEnum={SegmentObjMock}
+ />;
+ });
+
+ test('With all props', () => {
+ {}}
+ onBlur={() => {}}
+ onKeyDown={() => {}}
+ disabled={false}
+ size={'default'}
+ segmentEnum={SegmentObjMock}
+ data-testid="test-id"
+ id="day"
+ ref={React.createRef()}
+ />;
+ });
+ });
+});
diff --git a/packages/input-box/src/InputSegment/InputSegment.stories.tsx b/packages/input-box/src/InputSegment/InputSegment.stories.tsx
new file mode 100644
index 0000000000..95b6e7bc78
--- /dev/null
+++ b/packages/input-box/src/InputSegment/InputSegment.stories.tsx
@@ -0,0 +1,120 @@
+import React, { useState } from 'react';
+import {
+ storybookExcludedControlParams,
+ StoryMetaType,
+} from '@lg-tools/storybook-utils';
+import { StoryFn } from '@storybook/react';
+
+import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
+import { Size } from '@leafygreen-ui/tokens';
+
+import {
+ defaultPlaceholderMock,
+ SegmentObjMock,
+} from '../testutils/testutils.mocks';
+
+import { InputSegment } from '.';
+
+interface InputSegmentStoryProps {
+ darkMode: boolean;
+}
+
+const meta: StoryMetaType = {
+ title: 'Components/Inputs/InputBox/InputSegment',
+ component: InputSegment,
+ decorators: [
+ (StoryFn, context: any) => (
+
+
+
+ ),
+ ],
+ args: {
+ segment: SegmentObjMock.Day,
+ minSegmentValue: 0,
+ maxSegmentValue: 31,
+ size: Size.Default,
+ placeholder: 'DD',
+ shouldWrap: true,
+ step: 1,
+ darkMode: false,
+ charsCount: 2,
+ },
+ argTypes: {
+ size: {
+ control: 'select',
+ options: Object.values(Size),
+ },
+ darkMode: {
+ control: 'boolean',
+ },
+ },
+ parameters: {
+ default: 'LiveExample',
+ controls: {
+ exclude: [
+ ...storybookExcludedControlParams,
+ 'segment',
+ 'value',
+ 'onChange',
+ 'charsPerSegment',
+ 'segmentEnum',
+ 'shouldValidate',
+ 'step',
+ 'placeholder',
+ ],
+ },
+ generate: {
+ combineArgs: {
+ darkMode: [false, true],
+ segment: ['day', 'year'],
+ size: Object.values(Size),
+ value: ['', '2', '02', '0', '00', '2025', '0000'],
+ },
+ excludeCombinations: [
+ {
+ value: ['2', '02', '0', '00'],
+ segment: 'year',
+ },
+ {
+ value: ['2025', '0000'],
+ segment: ['day'],
+ },
+ ],
+ decorator: (StoryFn, context) => (
+
+
+
+ ),
+ },
+ },
+};
+export default meta;
+
+export const LiveExample: StoryFn = ({
+ // @ts-ignore - darkMode is not a valid prop for InputSegment
+ darkMode: _darkMode,
+ ...rest
+}) => {
+ const [value, setValue] = useState('');
+
+ return (
+ {
+ setValue(value);
+ }}
+ />
+ );
+};
+LiveExample.parameters = {
+ chromatic: { disableSnapshot: true },
+};
+
+export const Generated = () => {};
diff --git a/packages/input-box/src/InputSegment/InputSegment.styles.ts b/packages/input-box/src/InputSegment/InputSegment.styles.ts
new file mode 100644
index 0000000000..39f4f3da64
--- /dev/null
+++ b/packages/input-box/src/InputSegment/InputSegment.styles.ts
@@ -0,0 +1,97 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { Theme } from '@leafygreen-ui/lib';
+import {
+ BaseFontSize,
+ color,
+ fontFamilies,
+ InteractionState,
+ Size,
+ typeScales,
+ Variant,
+} from '@leafygreen-ui/tokens';
+
+export const baseStyles = css`
+ font-family: ${fontFamilies.default};
+ font-size: ${BaseFontSize.Body1}px;
+ font-variant: tabular-nums;
+ text-align: center;
+ border: none;
+ border-radius: 0;
+ padding: 0;
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ appearance: none;
+ margin: 0;
+ }
+ -moz-appearance: textfield; /* Firefox */
+ appearance: textfield;
+
+ &:focus {
+ outline: none;
+ }
+`;
+
+export const getSegmentThemeStyles = (theme: Theme) => {
+ return css`
+ background-color: transparent;
+ color: ${color[theme].text[Variant.Primary][InteractionState.Default]};
+
+ &::placeholder {
+ color: ${color[theme].text[Variant.Placeholder][
+ InteractionState.Default
+ ]};
+ }
+
+ &:focus {
+ background-color: ${color[theme].background[Variant.Primary][
+ InteractionState.Focus
+ ]};
+ }
+ `;
+};
+
+export const fontSizeStyles: Record = {
+ [BaseFontSize.Body1]: css`
+ --base-font-size: ${BaseFontSize.Body1}px;
+ `,
+ [BaseFontSize.Body2]: css`
+ --base-font-size: ${BaseFontSize.Body2}px;
+ `,
+};
+
+export const segmentSizeStyles: Record = {
+ [Size.XSmall]: css`
+ font-size: ${typeScales.body1.fontSize}px;
+ `,
+ [Size.Small]: css`
+ font-size: ${typeScales.body1.fontSize}px;
+ `,
+ [Size.Default]: css`
+ font-size: var(--base-font-size, ${typeScales.body1.fontSize}px);
+ `,
+ [Size.Large]: css`
+ font-size: 18px; // Intentionally off-token
+ `,
+};
+
+export const getInputSegmentStyles = ({
+ className,
+ baseFontSize,
+ theme,
+ size,
+}: {
+ className?: string;
+ baseFontSize: BaseFontSize;
+ theme: Theme;
+ size: Size;
+}) => {
+ return cx(
+ baseStyles,
+ fontSizeStyles[baseFontSize],
+ getSegmentThemeStyles(theme),
+ segmentSizeStyles[size],
+ className,
+ );
+};
diff --git a/packages/input-box/src/InputSegment/InputSegment.tsx b/packages/input-box/src/InputSegment/InputSegment.tsx
new file mode 100644
index 0000000000..ad45c11948
--- /dev/null
+++ b/packages/input-box/src/InputSegment/InputSegment.tsx
@@ -0,0 +1,245 @@
+import React, {
+ ChangeEventHandler,
+ FocusEvent,
+ ForwardedRef,
+ KeyboardEventHandler,
+} from 'react';
+
+import { VisuallyHidden } from '@leafygreen-ui/a11y';
+import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
+import { keyMap } from '@leafygreen-ui/lib';
+import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography';
+
+import {
+ getNewSegmentValueFromArrowKeyPress,
+ getNewSegmentValueFromInputValue,
+ getValueFormatter,
+} from '../utils';
+
+import { getInputSegmentStyles } from './InputSegment.styles';
+import {
+ InputSegmentComponentType,
+ InputSegmentProps,
+} from './InputSegment.types';
+
+const InputSegmentWithRef = (
+ {
+ segment,
+ onKeyDown,
+ minSegmentValue,
+ maxSegmentValue,
+ className,
+ onChange,
+ onBlur,
+ segmentEnum,
+ size,
+ disabled,
+ value,
+ charsCount,
+ step = 1,
+ shouldWrap = true,
+ shouldValidate = true,
+ ...rest
+ }: InputSegmentProps,
+ fwdRef: ForwardedRef,
+) => {
+ const { theme } = useDarkMode();
+ const baseFontSize = useUpdatedBaseFontSize();
+ const formatter = getValueFormatter({
+ charsPerSegment: charsCount,
+ allowZero: minSegmentValue === 0,
+ });
+ const pattern = `[0-9]{${charsCount}}`;
+
+ /**
+ * Receives native input events,
+ * determines whether the input value is valid and should change,
+ * and fires a custom `InputSegmentChangeEvent`.
+ */
+ const handleChange: ChangeEventHandler = e => {
+ const { target } = e;
+
+ const newValue = getNewSegmentValueFromInputValue({
+ segmentName: segment,
+ currentValue: value,
+ incomingValue: target.value,
+ charsPerSegment: charsCount,
+ defaultMin: minSegmentValue,
+ defaultMax: maxSegmentValue,
+ segmentEnum,
+ shouldValidate,
+ });
+
+ const hasValueChanged = newValue !== value;
+
+ if (hasValueChanged) {
+ onChange({
+ segment,
+ value: newValue,
+ meta: { min: minSegmentValue },
+ });
+ } else {
+ // If the value has not changed, ensure the input value is reset
+ target.value = value;
+ }
+ };
+
+ /** Handle keydown presses that don't natively fire a change event */
+ const handleKeyDown: KeyboardEventHandler = e => {
+ const { key, target } = e as React.KeyboardEvent & {
+ target: HTMLInputElement;
+ };
+
+ // A key press can be an `arrow`, `enter`, `space`, etc so we check for number presses
+ // We also check for `space` because Number(' ') returns true
+ const isNumber = Number(key) && key !== keyMap.Space;
+
+ if (isNumber) {
+ // if the value length is equal to the maxLength, reset the input. This will clear the input and the number will be inserted into the input when onChange is called.
+
+ if (target.value.length === charsCount) {
+ target.value = '';
+ }
+ }
+
+ switch (key) {
+ case keyMap.ArrowUp:
+ case keyMap.ArrowDown: {
+ e.preventDefault();
+
+ const newValue = getNewSegmentValueFromArrowKeyPress({
+ key,
+ value,
+ min: minSegmentValue,
+ max: maxSegmentValue,
+ step,
+ shouldWrap,
+ });
+ const valueString = formatter(newValue);
+
+ /** Fire a custom change event when the up/down arrow keys are pressed */
+ onChange({
+ segment,
+ value: valueString,
+ meta: { key, min: minSegmentValue },
+ });
+ break;
+ }
+
+ // On backspace the value is reset
+ case keyMap.Backspace: {
+ // Don't fire change event if the input is initially empty
+ if (value) {
+ // Stop propagation to prevent parent handlers from firing
+ e.stopPropagation();
+
+ /** Fire a custom change event when the backspace key is pressed */
+ onChange({
+ segment,
+ value: '',
+ meta: { key, min: minSegmentValue },
+ });
+ }
+
+ break;
+ }
+
+ // On space the value is reset
+ case keyMap.Space: {
+ e.preventDefault();
+
+ // Don't fire change event if the input is initially empty
+ if (value) {
+ /** Fire a custom change event when the space key is pressed */
+ onChange({
+ segment,
+ value: '',
+ meta: { key, min: minSegmentValue },
+ });
+ }
+
+ break;
+ }
+
+ default: {
+ break;
+ }
+ }
+
+ onKeyDown?.(e);
+ };
+
+ const handleBlur = (e: FocusEvent) => {
+ onBlur?.(e);
+ };
+
+ // Note: Using a text input with pattern attribute due to Firefox
+ // stripping leading zeros on number inputs - Thanks @matt-d-rat
+ // Number inputs also don't support the `selectionStart`/`End` API
+ return (
+ <>
+
+
+ {value && `${segment} ${value}`}
+
+ >
+ );
+};
+
+/**
+ * Generic controlled input segment component to be used within the InputBox component.
+ *
+ * This component renders a single input segment from an array of format parts (typically `Intl.DateTimeFormatPart`)
+ * passed to the InputBox component. It is designed primarily for date and time input segments, where each segment
+ * represents a distinct part of the date/time format (e.g., month, day, year, hour, minute).
+ *
+ * Each segment is configurable with character padding, validation, and formatting rules.
+ *
+ * @example
+ * // Used internally by InputBox to render segments from formatParts:
+ *
+ * // Date format:
+ * // [
+ * // { type: 'month', value: '02' },
+ * // { type: 'literal', value: '-' },
+ * // { type: 'day', value: '02' },
+ * // { type: 'literal', value: '-' },
+ * // { type: 'year', value: '2025' },
+ * // ]
+ *
+ * // Time format:
+ * // [
+ * // { type: 'hour', value: '14' },
+ * // { type: 'literal', value: ':' },
+ * // { type: 'minute', value: '30' },
+ * // { type: 'literal', value: ':' },
+ * // { type: 'second', value: '45' },
+ * // ]
+ */
+export const InputSegment = React.forwardRef(
+ InputSegmentWithRef,
+) as InputSegmentComponentType;
+
+InputSegment.displayName = 'InputSegment';
diff --git a/packages/input-box/src/InputSegment/InputSegment.types.ts b/packages/input-box/src/InputSegment/InputSegment.types.ts
new file mode 100644
index 0000000000..9e436dd943
--- /dev/null
+++ b/packages/input-box/src/InputSegment/InputSegment.types.ts
@@ -0,0 +1,82 @@
+import React, { ForwardedRef, ReactElement } from 'react';
+
+import { Size } from '@leafygreen-ui/tokens';
+
+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'
+ > {
+ /**
+ * Minimum value for the segment
+ */
+ minSegmentValue: number;
+
+ /**
+ * Maximum value for the segment
+ */
+ maxSegmentValue: number;
+
+ /**
+ * The step value for the arrow keys
+ *
+ * @default 1
+ */
+ step?: number;
+
+ /**
+ * Whether the segment should wrap at max boundaries when using the up arrow key.
+ *
+ * @default true
+ */
+ shouldWrap?: boolean;
+
+ /**
+ * Whether the segment should validate. Skipping validation is useful for segments that allow values outside of the default range.
+ *
+ * @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;
+}
+
+/**
+ * Type definition for the InputSegment component that maintains generic type safety with forwardRef.
+ *
+ * Interface with a generic call signature that preserves type parameters() when using forwardRef.
+ * React.forwardRef loses type parameters, so this interface is used to restore them.
+ *
+ * @see https://stackoverflow.com/a/58473012
+ */
+export interface InputSegmentComponentType {
+ (
+ props: InputSegmentProps,
+ ref: ForwardedRef,
+ ): ReactElement | null;
+ displayName?: string;
+}
diff --git a/packages/input-box/src/InputSegment/index.ts b/packages/input-box/src/InputSegment/index.ts
new file mode 100644
index 0000000000..1def69f1c7
--- /dev/null
+++ b/packages/input-box/src/InputSegment/index.ts
@@ -0,0 +1,2 @@
+export { InputSegment } from './InputSegment';
+export { type InputSegmentProps } from './InputSegment.types';
diff --git a/packages/input-box/src/shared.types.ts b/packages/input-box/src/shared.types.ts
new file mode 100644
index 0000000000..7e49b4b154
--- /dev/null
+++ b/packages/input-box/src/shared.types.ts
@@ -0,0 +1,90 @@
+import { keyMap } from '@leafygreen-ui/lib';
+
+/**
+ * SharedInput Segment Types
+ */
+
+/**
+ * Shared Input Segment Change Event
+ */
+export interface InputSegmentChangeEvent<
+ Segment extends string,
+ Value extends string,
+> {
+ segment: Segment;
+ value: Value;
+ meta?: {
+ key?: (typeof keyMap)[keyof typeof keyMap];
+ min: number;
+ [key: string]: any;
+ };
+}
+
+/**
+ * The type for the onChange handler
+ */
+export type InputSegmentChangeEventHandler<
+ Segment extends string,
+ Value extends string,
+> = (inputSegmentChangeEvent: InputSegmentChangeEvent) => void;
+
+/**
+ * Returns whether the given string is a valid segment
+ */
+export function isInputSegment>(
+ segment: unknown,
+ segmentObj: T,
+): segment is T[keyof T] {
+ if (typeof segment !== 'string') return false;
+ return Object.values(segmentObj).includes(segment);
+}
+
+/**
+ * Base props for custom segment components passed to InputBox.
+ *
+ * Extend this interface to define props for custom segment implementations.
+ */
+export interface InputSegmentComponentProps
+ extends Omit<
+ React.ComponentPropsWithRef<'input'>,
+ 'onChange' | 'value' | 'disabled'
+ >,
+ SharedInputBoxTypes {
+ /**
+ * Which segment this input represents
+ */
+ segment: Segment;
+
+ /**
+ * The handler for the onChange event that will be read in the InputSegment component
+ *
+ * @example
+ * (event: InputSegmentChangeEvent) => void
+ */
+ onChange: InputSegmentChangeEventHandler;
+
+ /**
+ * The value of the segment
+ */
+ value: string;
+}
+
+/**
+ * Shared Input Box Types
+ *
+ * These types are shared between the InputBox and the segmentComponent.
+ */
+export interface SharedInputBoxTypes {
+ /**
+ * An enumerable object that maps the segment names to their values
+ *
+ * @example
+ * { Day: 'day', Month: 'month', Year: 'year' }
+ */
+ segmentEnum: Record;
+
+ /**
+ * Whether the input box is disabled
+ */
+ disabled: boolean;
+}
diff --git a/packages/input-box/src/testutils/index.tsx b/packages/input-box/src/testutils/index.tsx
new file mode 100644
index 0000000000..c33f3e4573
--- /dev/null
+++ b/packages/input-box/src/testutils/index.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import { render, RenderResult } from '@testing-library/react';
+
+import { InputSegment, type InputSegmentProps } from '../InputSegment';
+
+import { SegmentObjMock } from './testutils.mocks';
+
+interface RenderSegmentReturnType {
+ getInput: () => HTMLInputElement;
+ input: HTMLInputElement;
+ rerenderSegment: (
+ newProps: Partial>,
+ ) => void;
+}
+
+const defaultSegmentProps: InputSegmentProps = {
+ segment: 'day',
+ minSegmentValue: 0,
+ maxSegmentValue: 31,
+ shouldWrap: true,
+ placeholder: 'DD',
+ onChange: () => {},
+ onBlur: () => {},
+ value: '',
+ charsCount: 2,
+ segmentEnum: SegmentObjMock,
+ // @ts-expect-error - data-testid
+ ['data-testid']: 'lg-input-segment',
+};
+
+/**
+ * Renders the InputSegment component for testing purposes.
+ */
+export const renderSegment = (
+ props: Partial>,
+): RenderResult & RenderSegmentReturnType => {
+ const mergedProps = {
+ ...defaultSegmentProps,
+ ...props,
+ } as InputSegmentProps;
+
+ const utils = render();
+
+ const rerenderSegment = (
+ newProps: Partial>,
+ ) => {
+ utils.rerender();
+ };
+
+ const getInput = () =>
+ utils.getByTestId('lg-input-segment') as HTMLInputElement;
+ return { ...utils, getInput, input: getInput(), rerenderSegment };
+};
diff --git a/packages/input-box/src/testutils/testutils.mocks.ts b/packages/input-box/src/testutils/testutils.mocks.ts
new file mode 100644
index 0000000000..0466e233e3
--- /dev/null
+++ b/packages/input-box/src/testutils/testutils.mocks.ts
@@ -0,0 +1,85 @@
+import { createRef } from 'react';
+
+import { css } from '@leafygreen-ui/emotion';
+import { DynamicRefGetter } from '@leafygreen-ui/hooks';
+
+import { ExplicitSegmentRule } from '../utils';
+
+export const SegmentObjMock = {
+ Month: 'month',
+ Day: 'day',
+ Year: 'year',
+} as const;
+export type SegmentObjMock =
+ (typeof SegmentObjMock)[keyof typeof SegmentObjMock];
+
+export type SegmentRefsMock = Record<
+ SegmentObjMock,
+ ReturnType>
+>;
+
+export const segmentRefsMock: SegmentRefsMock = {
+ month: createRef(),
+ day: createRef(),
+ year: createRef(),
+};
+
+export const segmentsMock: Record = {
+ month: '02',
+ day: '02',
+ year: '2025',
+};
+export const charsPerSegmentMock: Record = {
+ month: 2,
+ day: 2,
+ year: 4,
+};
+export const segmentRulesMock: Record = {
+ month: { maxChars: 2, minExplicitValue: 2 },
+ day: { maxChars: 2, minExplicitValue: 4 },
+ year: { maxChars: 4, minExplicitValue: 1970 },
+};
+export const defaultMinMock: Record = {
+ month: 1,
+ day: 0,
+ year: 1970,
+};
+export const defaultMaxMock: Record = {
+ month: 12,
+ day: 31,
+ year: 2038,
+};
+
+export const defaultPlaceholderMock: Record = {
+ day: 'DD',
+ month: 'MM',
+ year: 'YYYY',
+} as const;
+
+export const defaultFormatPartsMock: Array = [
+ { type: 'month', value: '' },
+ { type: 'literal', value: '-' },
+ { type: 'day', value: '' },
+ { type: 'literal', value: '-' },
+ { type: 'year', value: '' },
+];
+
+/** The percentage of 1ch these specific characters take up */
+export const characterWidth = {
+ // Standard font
+ D: 46 / 40,
+ M: 55 / 40,
+ Y: 50 / 40,
+} as const;
+
+export const segmentWidthStyles: Record = {
+ day: css`
+ width: ${charsPerSegmentMock.day * characterWidth.D}ch;
+ `,
+ month: css`
+ width: ${charsPerSegmentMock.month * characterWidth.M}ch;
+ `,
+ year: css`
+ width: ${charsPerSegmentMock.year * characterWidth.Y}ch;
+ `,
+};
diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts
index 535e7096a1..df950376f3 100644
--- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts
+++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts
@@ -24,115 +24,362 @@ const isExplicitSegmentValue = createExplicitSegmentValidator({
describe('packages/input-box/utils/createExplicitSegmentValidator', () => {
describe('day segment', () => {
test('returns false for single digit below minExplicitValue', () => {
- expect(isExplicitSegmentValue('day', '1')).toBe(false);
- expect(isExplicitSegmentValue('day', '2')).toBe(false);
- expect(isExplicitSegmentValue('day', '3')).toBe(false);
+ expect(isExplicitSegmentValue({ segment: 'day', value: '1' })).toBe(
+ false,
+ );
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '2',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '3',
+ }),
+ ).toBe(false);
});
test('returns true for single digit at or above minExplicitValue', () => {
- expect(isExplicitSegmentValue('day', '4')).toBe(true);
- expect(isExplicitSegmentValue('day', '5')).toBe(true);
- expect(isExplicitSegmentValue('day', '9')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '4',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '5',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '9',
+ }),
+ ).toBe(true);
});
test('returns true for two-digit values (maxChars)', () => {
- expect(isExplicitSegmentValue('day', '01')).toBe(true);
- expect(isExplicitSegmentValue('day', '10')).toBe(true);
- expect(isExplicitSegmentValue('day', '22')).toBe(true);
- expect(isExplicitSegmentValue('day', '31')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '01',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '10',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '22',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '31',
+ }),
+ ).toBe(true);
});
test('returns false for invalid values', () => {
- expect(isExplicitSegmentValue('day', '0')).toBe(false);
- expect(isExplicitSegmentValue('day', '')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '0',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({ segment: 'day', value: '', allowZero: false }),
+ ).toBe(false);
});
});
describe('month segment', () => {
test('returns false for single digit below minExplicitValue', () => {
- expect(isExplicitSegmentValue('month', '1')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '1',
+ }),
+ ).toBe(false);
});
test('returns true for single digit at or above minExplicitValue', () => {
- expect(isExplicitSegmentValue('month', '2')).toBe(true);
- expect(isExplicitSegmentValue('month', '3')).toBe(true);
- expect(isExplicitSegmentValue('month', '9')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '2',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '3',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '9',
+ }),
+ ).toBe(true);
});
test('returns true for two-digit values (maxChars)', () => {
- expect(isExplicitSegmentValue('month', '01')).toBe(true);
- expect(isExplicitSegmentValue('month', '12')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '01',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '12',
+ }),
+ ).toBe(true);
});
test('returns false for invalid values', () => {
- expect(isExplicitSegmentValue('month', '0')).toBe(false);
- expect(isExplicitSegmentValue('month', '')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '0',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'month',
+ value: '',
+ }),
+ ).toBe(false);
});
});
describe('year segment', () => {
test('returns false for values shorter than maxChars', () => {
- expect(isExplicitSegmentValue('year', '1')).toBe(false);
- expect(isExplicitSegmentValue('year', '20')).toBe(false);
- expect(isExplicitSegmentValue('year', '200')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '1',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '20',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '200',
+ }),
+ ).toBe(false);
});
test('returns true for four-digit values (maxChars)', () => {
- expect(isExplicitSegmentValue('year', '1970')).toBe(true);
- expect(isExplicitSegmentValue('year', '2000')).toBe(true);
- expect(isExplicitSegmentValue('year', '2023')).toBe(true);
- expect(isExplicitSegmentValue('year', '0001')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '1970',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '2000',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '2023',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '0001',
+ }),
+ ).toBe(true);
});
test('returns false for invalid values', () => {
- expect(isExplicitSegmentValue('year', '0')).toBe(false);
- expect(isExplicitSegmentValue('year', '')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '0',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'year',
+ value: '',
+ }),
+ ).toBe(false);
});
});
describe('hour segment', () => {
test('returns false for single digit below minExplicitValue', () => {
- expect(isExplicitSegmentValue('hour', '1')).toBe(false);
- expect(isExplicitSegmentValue('hour', '0')).toBe(false);
- expect(isExplicitSegmentValue('hour', '2')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '1',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '0',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '2',
+ }),
+ ).toBe(false);
});
test('returns true for single digit at or above minExplicitValue', () => {
- expect(isExplicitSegmentValue('hour', '3')).toBe(true);
- expect(isExplicitSegmentValue('hour', '9')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '3',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '9',
+ }),
+ ).toBe(true);
});
test('returns true for two-digit values at or above minExplicitValue', () => {
- expect(isExplicitSegmentValue('hour', '12')).toBe(true);
- expect(isExplicitSegmentValue('hour', '23')).toBe(true);
- expect(isExplicitSegmentValue('hour', '05')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '12',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '23',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'hour',
+ value: '05',
+ }),
+ ).toBe(true);
});
});
describe('minute segment', () => {
test('returns false for single digit below minExplicitValue', () => {
- expect(isExplicitSegmentValue('minute', '0')).toBe(false);
- expect(isExplicitSegmentValue('minute', '1')).toBe(false);
- expect(isExplicitSegmentValue('minute', '5')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'minute',
+ value: '0',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'minute',
+ value: '1',
+ }),
+ ).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'minute',
+ value: '5',
+ }),
+ ).toBe(false);
});
test('returns true for single digit at or above minExplicitValue', () => {
- expect(isExplicitSegmentValue('minute', '6')).toBe(true);
- expect(isExplicitSegmentValue('minute', '7')).toBe(true);
- expect(isExplicitSegmentValue('minute', '9')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'minute',
+ value: '6',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'minute',
+ value: '7',
+ }),
+ ).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'minute',
+ value: '9',
+ }),
+ ).toBe(true);
});
test('returns true for two-digit values at or above minExplicitValue', () => {
- expect(isExplicitSegmentValue('minute', '59')).toBe(true);
+ expect(
+ isExplicitSegmentValue({
+ segment: 'minute',
+ value: '59',
+ }),
+ ).toBe(true);
+ });
+ });
+
+ describe('allowZero', () => {
+ test('returns false when allowZero is false', () => {
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '00',
+ allowZero: false,
+ }),
+ ).toBe(false);
+ });
+
+ test('returns true when allowZero is true', () => {
+ expect(
+ isExplicitSegmentValue({
+ segment: 'day',
+ value: '00',
+ allowZero: true,
+ }),
+ ).toBe(true);
});
});
describe('invalid segment names', () => {
test('returns false for unknown segment names', () => {
- // @ts-expect-error Testing invalid segment
- expect(isExplicitSegmentValue('invalid', '10')).toBe(false);
- // @ts-expect-error Testing invalid segment
- expect(isExplicitSegmentValue('millisecond', '12')).toBe(false);
+ expect(
+ isExplicitSegmentValue({
+ // @ts-expect-error Testing invalid segment
+ segment: 'invalid',
+ value: '10',
+ }),
+ ).toBe(false);
+
+ expect(
+ isExplicitSegmentValue({
+ // @ts-expect-error Testing invalid segment
+ segment: 'millisecond',
+ value: '12',
+ }),
+ ).toBe(false);
});
});
});
diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts
index 3c0ed0b910..cd3396a34c 100644
--- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts
+++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts
@@ -25,7 +25,11 @@ export interface ExplicitSegmentRule {
*
* @param segmentEnum - The segment enum/object containing the segment names and their corresponding values to validate against
* @param rules - Rules for each segment type
- * @returns A function that checks if a segment value is explicit
+ * @returns A function that checks if a segment value is explicit and accepts the segment, value, and allowZero parameters
+ *
+ * @param segment - The segment to validate
+ * @param value - The value to validate
+ * @param allowZero - Whether to allow zero values
*
* @example
* const segmentObj = {
@@ -39,7 +43,6 @@ export interface ExplicitSegmentRule {
* day: { maxChars: 2, minExplicitValue: 4 },
* month: { maxChars: 2, minExplicitValue: 2 },
* year: { maxChars: 4 },
- * hour: { maxChars: 2, minExplicitValue: 3 },
* minute: { maxChars: 2, minExplicitValue: 6 },
* };
*
@@ -52,17 +55,14 @@ export interface ExplicitSegmentRule {
* rules,
* });
*
- * isExplicitSegmentValue('day', '1'); // false (Ambiguous - below min value and max length)
- * isExplicitSegmentValue('day', '01'); // true (Explicit - meets max length)
- * isExplicitSegmentValue('day', '4'); // true (Explicit - meets min value)
- * isExplicitSegmentValue('year', '2000'); // true (Explicit - meets max length)
- * isExplicitSegmentValue('year', '1'); // false (Ambiguous - below max length)
- * isExplicitSegmentValue('hour', '05'); // true (Explicit - meets min value)
- * isExplicitSegmentValue('hour', '23'); // true (Explicit - meets max length)
- * isExplicitSegmentValue('hour', '2'); // false (Ambiguous - below min value)
- * isExplicitSegmentValue('minute', '07'); // true (Explicit - meets min value)
- * isExplicitSegmentValue('minute', '59'); // true (Explicit - meets max length)
- * isExplicitSegmentValue('minute', '5'); // false (Ambiguous - below min value)
+ * isExplicitSegmentValue({ segment: 'day', value: '1', allowZero: false }); // false (Ambiguous - below min value and max length)
+ * isExplicitSegmentValue({ segment: 'day', value: '01', allowZero: false }); // true (Explicit - meets max length)
+ * isExplicitSegmentValue({ segment: 'day', value: '4', allowZero: false }); // true (Explicit - meets min value)
+ * isExplicitSegmentValue({ segment: 'year', value: '2000', allowZero: false }); // true (Explicit - meets max length)
+ * isExplicitSegmentValue({ segment: 'year', value: '1', allowZero: false }); // false (Ambiguous - below max length)
+ * isExplicitSegmentValue({ segment: 'minute', value: '07', allowZero: false }); // true (Explicit - meets min value)
+ * isExplicitSegmentValue({ segment: 'minute', value: '59', allowZero: false }); // true (Explicit - meets max length)
+ * isExplicitSegmentValue({ segment: 'minute', value: '5', allowZero: false }); // false (Ambiguous - below min value)
*/
export function createExplicitSegmentValidator<
SegmentEnum extends Record,
@@ -73,11 +73,23 @@ export function createExplicitSegmentValidator<
segmentEnum: SegmentEnum;
rules: Record;
}) {
- return (segment: SegmentEnum[keyof SegmentEnum], value: string): boolean => {
+ return ({
+ segment,
+ value,
+ allowZero = false,
+ }: {
+ segment: SegmentEnum[keyof SegmentEnum];
+ value: string;
+ allowZero?: boolean;
+ }): boolean => {
if (
- !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment))
- )
+ !(
+ isValidSegmentValue(value, allowZero) &&
+ isValidSegmentName(segmentEnum, segment)
+ )
+ ) {
return false;
+ }
const rule = rules[segment];
if (!rule) return false;
diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts
index b6645ed8f2..c1a37e1153 100644
--- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts
+++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts
@@ -196,7 +196,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => {
expect(newValue).toEqual(`00`);
});
- test('accepts 00 as input when shouldSkipValidation is true and value is less than defaultMin', () => {
+ test('accepts 00 as input when shouldValidate is false and value is less than defaultMin', () => {
const newValue = getNewSegmentValueFromInputValue({
segmentName: 'day',
currentValue: '0',
@@ -205,7 +205,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => {
defaultMin: 1,
defaultMax: 15,
segmentEnum: segmentObj,
- shouldSkipValidation: true,
+ shouldValidate: false,
});
expect(newValue).toEqual(`00`);
});
@@ -239,7 +239,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => {
expect(newValue).toEqual('2024');
});
- test('truncates from start when shouldSkipValidation is true and value exceeds charsPerSegment', () => {
+ test('truncates from start when shouldValidate is false and value exceeds charsPerSegment', () => {
const newValue = getNewSegmentValueFromInputValue({
segmentName: 'year',
currentValue: '000',
@@ -248,7 +248,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => {
defaultMin: 1970,
defaultMax: 2099,
segmentEnum: segmentObj,
- shouldSkipValidation: true,
+ shouldValidate: false,
});
expect(newValue).toEqual('0001');
});
diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts
index f8b8398407..5057b3abea 100644
--- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts
+++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts
@@ -15,7 +15,7 @@ interface GetNewSegmentValueFromInputValue<
defaultMin: number;
defaultMax: number;
segmentEnum: Readonly>;
- shouldSkipValidation?: boolean;
+ shouldValidate?: boolean;
}
/**
@@ -33,7 +33,7 @@ interface GetNewSegmentValueFromInputValue<
* @param defaultMin - The default minimum value for the segment
* @param defaultMax - The default maximum value for the segment
* @param segmentEnum - The segment enum/object containing the segment names and their corresponding values to validate against
- * @param shouldSkipValidation - Whether the segment should skip validation. This is useful for segments that allow values outside of the default range.
+ * @param shouldValidate - Whether the segment should validate. Skipping validation is useful for segments that allow values outside of the default range.
* @returns The new value for the segment
* @example
* // The segmentEnum is the object that contains the segment names and their corresponding values
@@ -78,7 +78,7 @@ interface GetNewSegmentValueFromInputValue<
* defaultMin: 1970,
* defaultMax: 2038,
* segmentEnum,
- * shouldSkipValidation: true,
+ * shouldValidate: false,
* }); // '000'
* * * getNewSegmentValueFromInputValue({
* segmentName: 'minute',
@@ -101,7 +101,7 @@ export const getNewSegmentValueFromInputValue = <
defaultMin,
defaultMax,
segmentEnum,
- shouldSkipValidation = false,
+ shouldValidate = true,
}: GetNewSegmentValueFromInputValue): Value => {
// If the incoming value is not a valid number
const isIncomingValueNumber = !isNaN(Number(incomingValue));
@@ -129,7 +129,7 @@ export const getNewSegmentValueFromInputValue = <
segmentEnum,
});
- if (isIncomingValueValid || shouldSkipValidation) {
+ if (isIncomingValueValid || !shouldValidate) {
const newValue = truncateStart(incomingValue, {
length: charsPerSegment,
});
diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json
index cba2152d8f..7f78ef8970 100644
--- a/packages/input-box/tsconfig.json
+++ b/packages/input-box/tsconfig.json
@@ -18,6 +18,9 @@
"**/*.stories.*"
],
"references": [
+ {
+ "path": "../a11y"
+ },
{
"path": "../emotion"
},
diff --git a/packages/time-input/src/TimeInput/TimeInput.tsx b/packages/time-input/src/TimeInput/TimeInput.tsx
index 44a8b67970..b97481ed36 100644
--- a/packages/time-input/src/TimeInput/TimeInput.tsx
+++ b/packages/time-input/src/TimeInput/TimeInput.tsx
@@ -1,5 +1,6 @@
import React, { forwardRef } from 'react';
+import { DateType } from '@leafygreen-ui/date-utils';
import { useControlled } from '@leafygreen-ui/hooks';
import LeafyGreenProvider, {
useDarkMode,
@@ -17,7 +18,6 @@ import {
import { TimeInputContent } from '../TimeInputContent';
import { TimeInputProps } from './TimeInput.types';
-import { DateType } from '@leafygreen-ui/date-utils';
export const TimeInput = forwardRef(
(
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c9a5bfe69c..d17f46d811 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2180,6 +2180,9 @@ importers:
packages/input-box:
dependencies:
+ '@leafygreen-ui/a11y':
+ specifier: workspace:^
+ version: link:../a11y
'@leafygreen-ui/date-utils':
specifier: workspace:^
version: link:../date-utils
@@ -2195,9 +2198,6 @@ importers:
'@leafygreen-ui/lib':
specifier: workspace:^
version: link:../lib
- '@leafygreen-ui/palette':
- specifier: workspace:^
- version: link:../palette
'@leafygreen-ui/tokens':
specifier: workspace:^
version: link:../tokens