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