From 40a106d98e31582d077f99484f5927d0ac580179 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 29 Oct 2025 15:28:51 -0400 Subject: [PATCH 01/11] feat(input-box): adds input-box package and utils --- packages/input-box/README.md | 4 + packages/input-box/package.json | 50 +++ packages/input-box/src/index.ts | 11 + .../createExplicitSegmentValidator.spec.ts | 97 ++++++ .../createExplicitSegmentValidator.ts | 51 +++ ...etNewSegmentValueFromArrowKeyPress.spec.ts | 328 ++++++++++++++++++ .../getNewSegmentValueFromArrowKeyPress.ts | 50 +++ .../getNewSegmentValueFromInputValue.spec.ts | 300 ++++++++++++++++ .../getNewSegmentValueFromInputValue.ts | 86 +++++ .../getRelativeSegment.spec.tsx | 193 +++++++++++ .../getRelativeSegment/getRelativeSegment.ts | 164 +++++++++ .../getValueFormatter/getValueFormatter.ts | 43 +++ .../getValueFormatter/valueFormatter.spec.ts | 66 ++++ packages/input-box/src/utils/index.ts | 17 + .../isElementInputSegment.spec.ts | 95 +++++ .../isElementInputSegment.ts | 28 ++ .../isValidSegment/isValidSegment.spec.ts | 75 ++++ .../utils/isValidSegment/isValidSegment.ts | 51 +++ .../isValidValueForSegment.spec.ts | 75 ++++ .../isValidValueForSegment.ts | 49 +++ packages/input-box/tsconfig.json | 46 +++ pnpm-lock.yaml | 27 ++ 22 files changed, 1906 insertions(+) create mode 100644 packages/input-box/README.md create mode 100644 packages/input-box/package.json create mode 100644 packages/input-box/src/index.ts create mode 100644 packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts create mode 100644 packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts create mode 100644 packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts create mode 100644 packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx create mode 100644 packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts create mode 100644 packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts create mode 100644 packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts create mode 100644 packages/input-box/src/utils/index.ts create mode 100644 packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts create mode 100644 packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts create mode 100644 packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts create mode 100644 packages/input-box/src/utils/isValidSegment/isValidSegment.ts create mode 100644 packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts create mode 100644 packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts create mode 100644 packages/input-box/tsconfig.json diff --git a/packages/input-box/README.md b/packages/input-box/README.md new file mode 100644 index 0000000000..67bcec1d73 --- /dev/null +++ b/packages/input-box/README.md @@ -0,0 +1,4 @@ +# Internal Input Box + +An internal component intended to be used by any date or time component. +I.e. `DatePicker`, `TimeInput` etc. diff --git a/packages/input-box/package.json b/packages/input-box/package.json new file mode 100644 index 0000000000..3030c6e71e --- /dev/null +++ b/packages/input-box/package.json @@ -0,0 +1,50 @@ + +{ + "name": "@leafygreen-ui/input-box", + "version": "0.0.1", + "description": "LeafyGreen UI Kit Input Box", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "license": "Apache-2.0", + "exports": { + ".": { + "require": "./dist/umd/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts" + }, + "./testing": { + "require": "./dist/umd/testing/index.js", + "import": "./dist/esm/testing/index.js", + "types": "./dist/types/testing/index.d.ts" + } + }, + "scripts": { + "build": "lg-build bundle", + "tsc": "lg-build tsc", + "docs": "lg-build docs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@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:^" + }, + "peerDependencies": { + "@leafygreen-ui/leafygreen-provider": "workspace:^" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/input-box", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/LG/summary" + } +} diff --git a/packages/input-box/src/index.ts b/packages/input-box/src/index.ts new file mode 100644 index 0000000000..f70976968b --- /dev/null +++ b/packages/input-box/src/index.ts @@ -0,0 +1,11 @@ +export { + createExplicitSegmentValidator, + type ExplicitSegmentRule, + isElementInputSegment, + isValidValueForSegment, +} from './utils'; +export { getValueFormatter } from './utils/getValueFormatter/getValueFormatter'; +export { + isValidSegmentName, + isValidSegmentValue, +} from './utils/isValidSegment/isValidSegment'; diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts new file mode 100644 index 0000000000..9acad385b9 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -0,0 +1,97 @@ +import { createExplicitSegmentValidator } from './createExplicitSegmentValidator'; + +const segmentObj = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; + +const rules = { + day: { maxChars: 2, minExplicitValue: 4 }, + month: { maxChars: 2, minExplicitValue: 2 }, + year: { maxChars: 4 }, +}; + +const isExplicitSegmentValue = createExplicitSegmentValidator( + segmentObj, + rules, +); + +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); + }); + + 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); + }); + + 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); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('day', '0')).toBe(false); + expect(isExplicitSegmentValue('day', '')).toBe(false); + }); + }); + + describe('month segment', () => { + test('returns false for single digit below minExplicitValue', () => { + expect(isExplicitSegmentValue('month', '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); + }); + + test('returns true for two-digit values (maxChars)', () => { + expect(isExplicitSegmentValue('month', '01')).toBe(true); + expect(isExplicitSegmentValue('month', '12')).toBe(true); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('month', '0')).toBe(false); + expect(isExplicitSegmentValue('month', '')).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); + }); + + 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); + }); + + test('returns false for invalid values', () => { + expect(isExplicitSegmentValue('year', '0')).toBe(false); + expect(isExplicitSegmentValue('year', '')).toBe(false); + }); + }); + + 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('hour', '12')).toBe(false); + }); + }); +}); diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts new file mode 100644 index 0000000000..200d832632 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -0,0 +1,51 @@ +import { + isValidSegmentName, + isValidSegmentValue, +} from '../isValidSegment/isValidSegment'; + +/** + * Configuration for determining if a segment value is explicit + */ +export interface ExplicitSegmentRule { + /** Maximum characters for this segment */ + maxChars: number; + /** Minimum numeric value that makes the input explicit (optional) */ + minExplicitValue?: number; +} + +/** + * Factory function that creates a segment value validator + * @param segmentEnum - The segment enum/object to validate against + * @param rules - Rules for each segment type + * @returns A function that checks if a segment value is explicit + * + * @example + * const segmentObj = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * const rules = { + * day: { maxChars: 2, minExplicitValue: 1 }, + * month: { maxChars: 2, minExplicitValue: 1 }, + */ +export function createExplicitSegmentValidator< + T extends Record, +>(segmentEnum: T, rules: Record) { + return (segment: T[keyof T], value: string): boolean => { + if ( + !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) + ) + return false; + + const rule = rules[segment]; + if (!rule) return false; + + const isMaxLength = value.length === rule.maxChars; + const meetsMinValue = rule.minExplicitValue + ? Number(value) >= rule.minExplicitValue + : false; + + return isMaxLength || meetsMinValue; + }; +} diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts new file mode 100644 index 0000000000..331dcf7561 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts @@ -0,0 +1,328 @@ +import { keyMap } from '@leafygreen-ui/lib'; + +import { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress'; + +describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { + describe('ArrowUp key', () => { + test('increments value by 1 when step is not provided', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(6); + }); + + test('increments value by custom step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(10); + }); + + test('rolls over from max to min', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('does not rollover when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(2039); + }); + + test('rolls over when shouldNotRollover is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '12', + key: keyMap.ArrowUp, + min: 1, + max: 12, + shouldNotRollover: false, + }); + expect(result).toBe(1); + }); + + test('defaults to min when value is empty', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('handles value at min boundary', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(2); + }); + + test('handles mid-range value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '15', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(16); + }); + + test('handles value at max boundary with rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + + test('handles large step increments', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 10, + }); + expect(result).toBe(15); + }); + }); + + describe('ArrowDown key', () => { + test('decrements value by 1 when step is not provided', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(4); + }); + + test('decrements value by custom step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '10', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(5); + }); + + test('rolls over from min to max', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(31); + }); + + test('rolls over from min to max for month range', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 12, + }); + expect(result).toBe(12); + }); + + test('does not rollover when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(1969); + }); + + test('rolls over when shouldNotRollover is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 31, + shouldNotRollover: false, + }); + expect(result).toBe(31); + }); + + test('defaults to max when value is empty', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(31); + }); + + test('handles value at max boundary', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(30); + }); + + test('handles mid-range value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '15', + key: keyMap.ArrowDown, + min: 1, + max: 31, + }); + expect(result).toBe(14); + }); + + test('handles large step decrements', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '20', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 10, + }); + expect(result).toBe(10); + }); + }); + + describe('edge cases', () => { + test('handles step larger than range with rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 12, + step: 20, + }); + expect(result).toBe(2); // 25 rolls over to 2 + }); + + test('handles step larger than range without rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '5', + key: keyMap.ArrowUp, + min: 1, + max: 12, + step: 20, + shouldNotRollover: true, + }); + expect(result).toBe(25); + }); + + test('handles negative values when not rolling over', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '-5', + key: keyMap.ArrowDown, + min: -10, + max: 10, + }); + expect(result).toBe(-6); + }); + + test('handles rollover with negative range', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '-10', + key: keyMap.ArrowDown, + min: -10, + max: 10, + }); + expect(result).toBe(10); + }); + + test('handles zero as min value', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '0', + key: keyMap.ArrowDown, + min: 0, + max: 23, + }); + expect(result).toBe(23); + }); + + test('handles rollover at boundary with step', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '30', + key: keyMap.ArrowUp, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(4); // 35 rolls to 4 + }); + + test('handles going below min with step and rollover', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '3', + key: keyMap.ArrowDown, + min: 1, + max: 31, + step: 5, + }); + expect(result).toBe(29); // -2 rolls to 29 + }); + }); + + describe('shouldNotRollover behavior', () => { + test('allows exceeding max when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(2039); + }); + + test('allows going below min when shouldNotRollover is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldNotRollover: true, + }); + expect(result).toBe(1969); + }); + + test('respects rollover by default', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '31', + key: keyMap.ArrowUp, + min: 1, + max: 31, + }); + expect(result).toBe(1); + }); + }); +}); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts new file mode 100644 index 0000000000..6d2e2e9dc7 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -0,0 +1,50 @@ +import { keyMap, rollover } from '@leafygreen-ui/lib'; + +interface GetNewSegmentValueFromArrowKeyPress { + value: V; + key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; + min: number; + max: number; + step?: number; + shouldNotRollover?: boolean; +} + +/** + * Returns a new segment value given the current state + * + * @param value - The current value of the segment + * @param key - The key pressed + * @param min - The minimum value for the segment + * @param max - The maximum value for the segment + * @param step - The step value for the arrow keys + * @param shouldNotRollover - The segments that should not rollover + * @returns The new value for the segment + * @example + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 31, step: 1}); // 2 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 31, step: 1}); // 31 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 12, step: 1}); // 2 + * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 12, step: 1}); // 12 + * getNewSegmentValueFromArrowKeyPress({ value: '1970', key: 'ArrowUp', min: 1970, max: 2038, step: 1 }); // 1971 + * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldNotRollover: true }); // 2039 + */ +export const getNewSegmentValueFromArrowKeyPress = ({ + value, + key, + min, + max, + shouldNotRollover, + step = 1, +}: GetNewSegmentValueFromArrowKeyPress): number => { + const valueDiff = key === keyMap.ArrowUp ? step : -step; + const defaultVal = key === keyMap.ArrowUp ? min : max; + + const incrementedValue: number = value + ? Number(value) + valueDiff + : defaultVal; + + const newValue = shouldNotRollover + ? incrementedValue + : rollover(incrementedValue, min, max); + + return newValue; +}; diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts new file mode 100644 index 0000000000..3eaba47e20 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -0,0 +1,300 @@ +import range from 'lodash/range'; + +import { getValueFormatter } from '../getValueFormatter/getValueFormatter'; + +import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; + +const charsPerSegment = { + day: 2, + month: 2, + year: 4, +}; + +const defaultMin = { + day: 1, + month: 1, + year: 1970, +}; + +const defaultMax = { + day: 31, + month: 12, + year: new Date().getFullYear(), +}; + +const segmentObj = { + day: 'day', + month: 'month', + year: 'year', +}; + +describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { + describe.each(['day', 'month', 'year'])('For segment %p', _segment => { + const segment = _segment as 'day' | 'month' | 'year'; + describe('when current value is empty', () => { + test.each(range(10))('accepts %i character as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`${i}`); + }); + + const validValues = [defaultMin[segment], defaultMax[segment]]; + test.each(validValues)(`accepts value "%i" as input`, v => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `${v}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`${v}`); + }); + + test('does not accept non-numeric characters', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `b`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(''); + }); + + test('does not accept input with a period/decimal', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '', + `2.`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(''); + }); + }); + + describe('when current value is 0', () => { + if (segment !== 'year') { + test('rejects additional 0 as input', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `00`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`0`); + }); + } + + if (segment === 'year') { + test('accepts 0000 as input', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `0000`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + true, + ); + expect(newValue).toEqual(`0000`); + }); + } + test.each(range(1, 10))('accepts 0%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + `0${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`0${i}`); + }); + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '0', + ``, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(``); + }); + }); + + describe('when current value is 1', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + ``, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(``); + }); + + if (segment === 'month') { + test.each(range(0, 3))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`1${i}`); + }); + describe.each(range(3, 10))('rejects 1%i', i => { + test(`and sets input "${i}"`, () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + } else { + test.each(range(10))('accepts 1%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '1', + `1${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + segment === 'year', + ); + expect(newValue).toEqual(`1${i}`); + }); + } + }); + + describe('when current value is 3', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + ``, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(``); + }); + + switch (segment) { + case 'day': { + test.each(range(0, 2))('accepts 3%i as input', i => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + `3${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`3${i}`); + }); + describe.each(range(3, 10))('rejects 3%i', i => { + test(`and sets input to ${i}`, () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + `3${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + break; + } + + case 'month': { + describe.each(range(10))('rejects 3%i', i => { + test(`and sets input "${i}"`, () => { + const newValue = getNewSegmentValueFromInputValue( + segment, + '3', + `3${i}`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(`${i}`); + }); + }); + break; + } + + default: + break; + } + }); + + describe('when current value is a full formatted value', () => { + const formatter = getValueFormatter(charsPerSegment[segment]); + const testValues = [defaultMin[segment], defaultMax[segment]].map( + formatter, + ); + test.each(testValues)( + 'when current value is %p, rejects additional input', + val => { + const newValue = getNewSegmentValueFromInputValue( + segment, + val, + `${val}1`, + charsPerSegment[segment], + defaultMin[segment], + defaultMax[segment], + segmentObj, + ); + expect(newValue).toEqual(val); + }, + ); + }); + }); +}); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts new file mode 100644 index 0000000000..0c1644a73e --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -0,0 +1,86 @@ +import last from 'lodash/last'; + +import { truncateStart } from '@leafygreen-ui/lib'; + +import { isValidValueForSegment } from '..'; + +/** + * Calculates the new value for the segment given an incoming change. + * + * Does not allow incoming values that + * - are not valid numbers + * - include a period + * - would cause the segment to overflow + * + * @param segmentName - The name of the segment + * @param currentValue - The current value of the segment + * @param incomingValue - The incoming value to set + * @param charsPerSegment - The number of characters per segment + * @param defaultMin - The default minimum value for the segment + * @param defaultMax - The default maximum value for the segment + * @param segmentEnum - The segment object + * @param shouldSkipValidation - Whether the segment should skip validation. This 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 + * const segmentEnum = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * getNewSegmentValueFromInputValue('day', '1', '2', segmentEnum['day'], 1, 31, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('month', '1', '2', segmentEnum['month'], 1, 12, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('year', '1', '2', segmentEnum['year'], 1970, 2038, segmentEnum); // '2' + * getNewSegmentValueFromInputValue('day', '1', '.', segmentEnum['day'], 1, 31, segmentEnum); // '1' + */ +export const getNewSegmentValueFromInputValue = < + T extends string, + V extends string, +>( + segmentName: T, + currentValue: V, + incomingValue: V, + charsPerSegment: number, + defaultMin: number, + defaultMax: number, + segmentEnum: Readonly>, + shouldSkipValidation = false, +): V => { + // If the incoming value is not a valid number + const isIncomingValueNumber = !isNaN(Number(incomingValue)); + // macOS adds a period when pressing SPACE twice inside a text input. + const doesIncomingValueContainPeriod = /\./.test(incomingValue); + + // if the current value is "full", do not allow any additional characters to be entered + const wouldCauseOverflow = + currentValue.length === charsPerSegment && + incomingValue.length > charsPerSegment; + + if ( + !isIncomingValueNumber || + doesIncomingValueContainPeriod || + wouldCauseOverflow + ) { + return currentValue; + } + + const isIncomingValueValid = isValidValueForSegment( + segmentName, + incomingValue, + defaultMin, + defaultMax, + segmentEnum, + ); + + if (isIncomingValueValid || shouldSkipValidation) { + const newValue = truncateStart(incomingValue, { + length: charsPerSegment, + }); + + return newValue as V; + } + + const typedChar = last(incomingValue.split('')); + const newValue = typedChar === '0' ? '0' : typedChar ?? ''; + return newValue as V; +}; diff --git a/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx new file mode 100644 index 0000000000..872820347b --- /dev/null +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.spec.tsx @@ -0,0 +1,193 @@ +import React, { createRef } from 'react'; +import { render } from '@testing-library/react'; + +import { DynamicRefGetter } from '@leafygreen-ui/hooks'; + +type Segment = 'day' | 'month' | 'year'; + +type SegmentRefs = Record< + Segment, + ReturnType> +>; + +const segmentRefsMock: SegmentRefs = { + day: createRef(), + month: createRef(), + year: createRef(), +}; + +import { getRelativeSegmentRef } from './getRelativeSegment'; + +const renderTestComponent = () => { + const result = render( + <> + + + + , + ); + + const elements = { + day: result.getByTestId('day'), + month: result.getByTestId('month'), + year: result.getByTestId('year'), + } as { + day: HTMLInputElement; + month: HTMLInputElement; + year: HTMLInputElement; + }; + + return { + ...result, + segmentRefs: segmentRefsMock, + elements, + }; +}; + +describe('packages/input-box/utils/getRelativeSegment', () => { + const formatParts: Array = [ + { type: 'year', value: '2023' }, + { type: 'literal', value: '-' }, + { type: 'month', value: '10' }, + { type: 'literal', value: '-' }, + { type: 'day', value: '31' }, + ]; + + describe('from ref', () => { + let segmentRefs: SegmentRefs; + beforeEach(() => { + segmentRefs = renderTestComponent().segmentRefs; + }); + test('next from year => month', () => { + expect( + getRelativeSegmentRef('next', { + segment: segmentRefs.year, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.month); + }); + test('next from month => day', () => { + expect( + getRelativeSegmentRef('next', { + segment: segmentRefs.month, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.day); + }); + + test('prev from day => month', () => { + expect( + getRelativeSegmentRef('prev', { + segment: segmentRefs.day, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.month); + }); + + test('prev from month => year', () => { + expect( + getRelativeSegmentRef('prev', { + segment: segmentRefs.month, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.year); + }); + + test('first = year', () => { + expect( + getRelativeSegmentRef('first', { + segment: segmentRefs.day, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.year); + }); + + test('last = day', () => { + expect( + getRelativeSegmentRef('last', { + segment: segmentRefs.year, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.day); + }); + }); + + describe('from element', () => { + let segmentRefs: SegmentRefs; + + let elements: { + day: HTMLInputElement; + month: HTMLInputElement; + year: HTMLInputElement; + }; + beforeEach(() => { + const result = renderTestComponent(); + segmentRefs = result.segmentRefs; + elements = result.elements; + }); + test('next from year => month', () => { + expect( + getRelativeSegmentRef('next', { + segment: elements.year, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.month); + }); + test('next from month => day', () => { + expect( + getRelativeSegmentRef('next', { + segment: elements.month, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.day); + }); + + test('prev from day => month', () => { + expect( + getRelativeSegmentRef('prev', { + segment: elements.day, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.month); + }); + + test('prev from month => year', () => { + expect( + getRelativeSegmentRef('prev', { + segment: elements.month, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.year); + }); + + test('first = year', () => { + expect( + getRelativeSegmentRef('first', { + segment: elements.day, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.year); + }); + + test('last = day', () => { + expect( + getRelativeSegmentRef('last', { + segment: elements.year, + formatParts, + segmentRefs, + }), + ).toBe(segmentRefs.day); + }); + }); +}); diff --git a/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts new file mode 100644 index 0000000000..578bf6ddb4 --- /dev/null +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts @@ -0,0 +1,164 @@ +import isUndefined from 'lodash/isUndefined'; +import last from 'lodash/last'; + +type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; + +/** + * Given a direction, starting segment name & format + * returns the segment name in the given direction + * + * @param direction - The direction to get the relative segment from + * @param segment - The starting segment name + * @param formatParts - The format parts of the date + * @returns The segment name in the given direction + * @example + * const formatParts = [ + * { type: 'year', value: '2023' }, + * { type: 'literal', value: '-' }, + * { type: 'month', value: '10' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '31' }, + * ]; + * getRelativeSegment('next', { segment: 'year', formatParts }); // 'month' + * getRelativeSegment('next', { segment: 'month', formatParts }); // 'day' + * getRelativeSegment('prev', { segment: 'day', formatParts }); // 'month' + * getRelativeSegment('prev', { segment: 'month', formatParts }); // 'year' + * getRelativeSegment('first', { segment: 'day', formatParts }); // 'year' + * getRelativeSegment('last', { segment: 'year', formatParts }); // 'day' + */ +export const getRelativeSegment = ( + direction: RelativeDirection, + { + segment, + formatParts, + }: { + segment: V; + formatParts?: Array; + }, +): V | undefined => { + if ( + isUndefined(direction) || + isUndefined(segment) || + isUndefined(formatParts) + ) { + return; + } + + // only the relevant segments, not separators + const formatSegments: Array = formatParts + .filter(part => part.type !== 'literal') + .map(part => part.type as V); + + /** The index of the reference segment relative to formatParts */ + const currentSegmentIndex: number | undefined = + formatSegments.indexOf(segment); + + switch (direction) { + case 'first': { + return formatSegments[0]; + } + + case 'last': { + const lastSegmentName = last(formatSegments); + return lastSegmentName; + } + + case 'next': { + if ( + !isUndefined(currentSegmentIndex) && + currentSegmentIndex >= 0 && + currentSegmentIndex + 1 < formatSegments.length + ) { + return formatSegments[currentSegmentIndex + 1]; + } + + break; + } + + case 'prev': { + if (!isUndefined(currentSegmentIndex) && currentSegmentIndex > 0) { + return formatSegments[currentSegmentIndex - 1]; + } + + break; + } + + default: + break; + } +}; + +interface GetRelativeSegmentContext< + T extends Record>, +> { + segment: HTMLInputElement | React.RefObject; + formatParts?: Array; + segmentRefs: T; +} + +/** + * Given a direction, staring segment, and segment refs, + * returns the segment ref in the given direction + * + * @param direction - The direction to get the relative segment from + * @param segment - The starting segment ref + * @param formatParts - The format parts of the date + * @param segmentRefs - The segment refs + * @returns The segment ref in the given direction + * @example + * const formatParts = [ + * { type: 'year', value: '2023' }, + * { type: 'literal', value: '-' }, + * { type: 'month', value: '10' }, + * { type: 'literal', value: '-' }, + * { type: 'day', value: '31' }, + * ]; + * const segmentRefs = { + * year: yearRef, + * month: monthRef, + * day: dayRef, + * }; + * getRelativeSegmentRef('next', { segment: yearRef, formatParts, segmentRefs }); // monthRef + * getRelativeSegmentRef('prev', { segment: dayRef, formatParts, segmentRefs }); // monthRef + * getRelativeSegmentRef('first', { segment: monthRef, formatParts, segmentRefs }); // yearRef + * getRelativeSegmentRef('last', { segment: monthRef, formatParts, segmentRefs }); // dayRef + */ +export const getRelativeSegmentRef = < + T extends Record>, + V extends string, +>( + direction: RelativeDirection, + { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, +): React.RefObject | undefined => { + if ( + isUndefined(direction) || + isUndefined(segment) || + isUndefined(formatParts) || + isUndefined(segmentRefs) + ) { + return; + } + + // only the relevant segments, not separators + const formatSegments: Array = formatParts + .filter(part => part.type !== 'literal') + .map(part => part.type as V); + + const currentSegmentName: V | undefined = formatSegments.find(segmentName => { + return ( + segmentRefs[segmentName] === segment || + segmentRefs[segmentName].current === segment + ); + }); + + if (currentSegmentName) { + const relativeSegmentName = getRelativeSegment(direction, { + segment: currentSegmentName, + formatParts, + }); + + if (relativeSegmentName) { + return segmentRefs[relativeSegmentName]; + } + } +}; diff --git a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts new file mode 100644 index 0000000000..f2c6d822e6 --- /dev/null +++ b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts @@ -0,0 +1,43 @@ +import padStart from 'lodash/padStart'; + +import { isZeroLike } from '@leafygreen-ui/lib'; + +/** + * If the value is any form of zero, we set it to an empty string + * otherwise, pad the string with 0s, or trim it to n chars + * + * @param charsPerSegment - the number of characters per segment + * @param allowsZero - + * @param val - the value to format + * @returns a value formatter function for the provided segment + * + * @example + * const charsPerSegment = { + * day: 2, + * month: 2, + * year: 4, + * }; + * const formatter = getValueFormatter(charsPerSegment['day']); + * formatter('0'); // '' + * formatter('1'); // '01' + * formatter('12'); // '12' + * formatter('123'); // '23' + */ +export const getValueFormatter = + (charsPerSegment: number, allowZero = false) => + (val: string | number | undefined) => { + // If the value is empty, do not format it + if (val === '') return ''; + + // Return empty string for zero-like values when disallowed (e.g., '00') + if (!allowZero && isZeroLike(val)) return ''; + + // otherwise, pad the string with 0s, or trim it to n chars + const padded = padStart(Number(val).toString(), charsPerSegment, '0'); + const trimmed = padded.slice( + padded.length - charsPerSegment, + padded.length, + ); + + return trimmed; + }; diff --git a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts new file mode 100644 index 0000000000..7e5436fe01 --- /dev/null +++ b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts @@ -0,0 +1,66 @@ +import { getValueFormatter } from './getValueFormatter'; + +type Segment = 'day' | 'month' | 'year'; +const charsPerSegment: Record = { + day: 2, + month: 2, + year: 4, +}; + +describe('packages/input-box/utils/valueFormatter', () => { + describe.each(['day', 'month'] as Array)('', segment => { + const formatter = getValueFormatter(charsPerSegment[segment]); + + test('formats 2 digit values', () => { + expect(formatter('12')).toEqual('12'); + }); + + test('pads 1 digit value', () => { + expect(formatter('2')).toEqual('02'); + }); + + test('truncates 3+ digit values', () => { + expect(formatter('123')).toEqual('23'); + }); + + test('truncates 3+ digit padded values', () => { + expect(formatter('012')).toEqual('12'); + }); + + test('sets 0 to empty string', () => { + expect(formatter('0')).toEqual(''); + }); + + test('sets undefined to empty string', () => { + expect(formatter(undefined)).toEqual(''); + }); + }); + + describe('year', () => { + const formatter = getValueFormatter(charsPerSegment['year']); + + test('formats 4 digit values', () => { + expect(formatter('2023')).toEqual('2023'); + }); + + test('pads < 4 digit value', () => { + expect(formatter('123')).toEqual('0123'); + }); + + test('truncates 5+ digit values', () => { + expect(formatter('12345')).toEqual('2345'); + }); + + test('truncates 5+ digit padded values', () => { + expect(formatter('02345')).toEqual('2345'); + }); + + test('sets 0 to empty string', () => { + expect(formatter('0')).toEqual(''); + }); + + test('sets undefined to empty string', () => { + expect(formatter(undefined)).toEqual(''); + }); + }); +}); diff --git a/packages/input-box/src/utils/index.ts b/packages/input-box/src/utils/index.ts new file mode 100644 index 0000000000..9754f2fa90 --- /dev/null +++ b/packages/input-box/src/utils/index.ts @@ -0,0 +1,17 @@ +export { + createExplicitSegmentValidator, + ExplicitSegmentRule, +} from './createExplicitSegmentValidator/createExplicitSegmentValidator'; +export { getNewSegmentValueFromArrowKeyPress } from './getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress'; +export { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue'; +export { + getRelativeSegment, + getRelativeSegmentRef, +} from './getRelativeSegment/getRelativeSegment'; +export { getValueFormatter } from './getValueFormatter/getValueFormatter'; +export { isElementInputSegment } from './isElementInputSegment/isElementInputSegment'; +export { + isValidSegmentName, + isValidSegmentValue, +} from './isValidSegment/isValidSegment'; +export { isValidValueForSegment } from './isValidValueForSegment/isValidValueForSegment'; diff --git a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts new file mode 100644 index 0000000000..9dbc50deda --- /dev/null +++ b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.spec.ts @@ -0,0 +1,95 @@ +import React from 'react'; + +import { isElementInputSegment } from './isElementInputSegment'; + +describe('packages/input-box/utils/isElementInputSegment', () => { + describe('isElementInputSegment', () => { + let dayInput: HTMLInputElement; + let monthInput: HTMLInputElement; + let yearInput: HTMLInputElement; + let unrelatedInput: HTMLInputElement; + let segmentRefs: Record>; + + beforeEach(() => { + // Create input elements + dayInput = document.createElement('input'); + dayInput.setAttribute('data-segment', 'day'); + + monthInput = document.createElement('input'); + monthInput.setAttribute('data-segment', 'month'); + + yearInput = document.createElement('input'); + yearInput.setAttribute('data-segment', 'year'); + + unrelatedInput = document.createElement('input'); + unrelatedInput.setAttribute('data-testid', 'unrelated'); + + // Create segment refs + segmentRefs = { + day: { current: dayInput }, + month: { current: monthInput }, + year: { current: yearInput }, + }; + }); + + test('returns true when element is the day segment', () => { + expect(isElementInputSegment(dayInput, segmentRefs)).toBe(true); + }); + + test('returns true when element is the month segment', () => { + expect(isElementInputSegment(monthInput, segmentRefs)).toBe(true); + }); + + test('returns true when element is the year segment', () => { + expect(isElementInputSegment(yearInput, segmentRefs)).toBe(true); + }); + + test('returns false when element is not in segment refs', () => { + expect(isElementInputSegment(unrelatedInput, segmentRefs)).toBe(false); + }); + + test('returns false when segmentRefs is empty', () => { + const emptySegmentRefs = {}; + expect(isElementInputSegment(dayInput, emptySegmentRefs)).toBe(false); + }); + + test('returns false when all segment refs are null', () => { + const nullSegmentRefs = { + day: { current: null }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(dayInput, nullSegmentRefs)).toBe(false); + }); + + test('returns true when element matches one of the non-null refs', () => { + const partialSegmentRefs = { + day: { current: dayInput }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(dayInput, partialSegmentRefs)).toBe(true); + }); + + test('returns false when element does not match the only non-null ref', () => { + const partialSegmentRefs = { + day: { current: dayInput }, + month: { current: null }, + year: { current: null }, + }; + expect(isElementInputSegment(monthInput, partialSegmentRefs)).toBe(false); + }); + + test('returns false when checking a div element not in segment refs', () => { + const divElement = document.createElement('div'); + expect(isElementInputSegment(divElement, segmentRefs)).toBe(false); + }); + + test('returns true when segment has a single input', () => { + const singleSegmentRefs = { + hour: { current: dayInput }, + }; + expect(isElementInputSegment(dayInput, singleSegmentRefs)).toBe(true); + }); + }); +}); diff --git a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts new file mode 100644 index 0000000000..411237f8cb --- /dev/null +++ b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts @@ -0,0 +1,28 @@ +/** + * Returns whether the given element is a segment + * @param element - The element to check + * @param segmentObj - The segment object + * @returns Whether the element is a segment + * @example + * // In the segmentRefs object, the key is the segment name and the value is the ref object + * const segmentRefs = { + * day: { current: document.querySelector('input[data-segment="day"]') }, + * month: { current: document.querySelector('input[data-segment="month"]') }, + * year: { current: document.querySelector('input[data-segment="year"]') }, + * }; + * isElementInputSegment(document.querySelector('input[data-segment="day"]'), segmentRefs); // true + * isElementInputSegment(document.querySelector('input[data-segment="month"]'), segmentRefs); // true + * isElementInputSegment(document.querySelector('input[data-segment="year"]'), segmentRefs); // true + */ +export const isElementInputSegment = < + T extends Record>, +>( + element: HTMLElement, + segmentRefs: T, +): element is HTMLInputElement => { + const segmentsArray = Object.values(segmentRefs).map( + ref => ref.current, + ) as Array; + const isSegment = segmentsArray.includes(element); + return isSegment; +}; diff --git a/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts new file mode 100644 index 0000000000..64929a3f56 --- /dev/null +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts @@ -0,0 +1,75 @@ +import { isValidSegmentName, isValidSegmentValue } from './isValidSegment'; + +const Segment = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; +type SegmentValue = string; + +describe('packages/input-box/utils/isValidSegment', () => { + describe('isValidSegment', () => { + test('undefined returns false', () => { + expect(isValidSegmentValue()).toBeFalsy(); + }); + + test('a string returns false', () => { + expect(isValidSegmentValue('')).toBeFalsy(); + }); + + test('NaN returns false', () => { + /// @ts-expect-error + expect(isValidSegmentValue(NaN)).toBeFalsy(); + }); + + test('0 returns false', () => { + expect(isValidSegmentValue('0')).toBeFalsy(); + }); + + test('0 with allowZero returns true', () => { + expect(isValidSegmentValue('0', true)).toBeTruthy(); + }); + + test('negative returns false', () => { + expect(isValidSegmentValue('-1')).toBeFalsy(); + }); + + test('1970 returns true', () => { + expect(isValidSegmentValue('1970')).toBeTruthy(); + }); + + test('1 returns true', () => { + expect(isValidSegmentValue('1')).toBeTruthy(); + }); + + test('2038 returns true', () => { + expect(isValidSegmentValue('2038')).toBeTruthy(); + }); + }); + + describe('isValidSegmentName', () => { + test('undefined returns false', () => { + expect(isValidSegmentName(Segment)).toBeFalsy(); + }); + + test('random string returns false', () => { + expect(isValidSegmentName(Segment, '123')).toBeFalsy(); + }); + + test('empty string returns false', () => { + expect(isValidSegmentName(Segment, '')).toBeFalsy(); + }); + + test('day string returns true', () => { + expect(isValidSegmentName(Segment, 'day')).toBeTruthy(); + }); + + test('month string returns true', () => { + expect(isValidSegmentName(Segment, 'month')).toBeTruthy(); + }); + + test('year string returns true', () => { + expect(isValidSegmentName(Segment, 'year')).toBeTruthy(); + }); + }); +}); diff --git a/packages/input-box/src/utils/isValidSegment/isValidSegment.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts new file mode 100644 index 0000000000..3cae5afb58 --- /dev/null +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts @@ -0,0 +1,51 @@ +import isUndefined from 'lodash/isUndefined'; + +/** + * Returns whether a given value is a valid segment value + * + * @param segment - The segment value to validate + * @param allowZero - Whether to allow zero as a valid segment value + * @returns Whether the segment value is valid + * + * @example + * isValidSegmentValue('1'); // true + * isValidSegmentValue('0'); // false + * isValidSegmentValue('0', true); // true + * isValidSegmentValue('00', true); // true + */ +export const isValidSegmentValue = ( + segment?: T, + allowZero = false, +): segment is T => + !isUndefined(segment) && + !isNaN(Number(segment)) && + (Number(segment) > 0 || allowZero); + +/** + * A generic type predicate function that checks if a given string is one + * of the values in the provided segment object. + * + * @param segmentEnum The runtime object containing the valid string segments + * @param name The string to validate + * @returns A boolean and a type predicate (name is T[keyof T]) + * + * @example + * const segmentEnum = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * isValidSegmentName(segmentEnum, 'day'); // true + * isValidSegmentName(segmentEnum, 'month'); // true + * isValidSegmentName(segmentEnum, 'year'); // true + * isValidSegmentName(segmentEnum, 'seconds'); // false + */ +export const isValidSegmentName = >>( + segmentEnum: T, + name?: string, +): name is T[keyof T] => { + return ( + !isUndefined(name) && + Object.values(segmentEnum).includes(name as T[keyof T]) + ); +}; diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts new file mode 100644 index 0000000000..5d7d72dd8a --- /dev/null +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -0,0 +1,75 @@ +import inRange from 'lodash/inRange'; + +import { isValidValueForSegment } from './isValidValueForSegment'; + +const SegmentObj = { + Day: 'day', + Month: 'month', + Year: 'year', +} as const; + +type SegmentObj = (typeof SegmentObj)[keyof typeof SegmentObj]; + +const defaultMin = { + day: 1, + month: 1, + year: 1970, +} as const; + +const defaultMax = { + day: 31, + month: 12, + year: 2038, +} as const; + +const isValidValueForSegmentWrapper = (segment: SegmentObj, value: string) => { + return isValidValueForSegment( + segment, + value, + defaultMin[segment], + defaultMax[segment], + SegmentObj, + segment === 'year' + ? (value: string) => inRange(Number(value), 1000, 9999 + 1) + : undefined, + ); +}; + +describe('packages/input-box/utils/isValidSegmentValue', () => { + test('day', () => { + expect(isValidValueForSegmentWrapper('day', '1')).toBe(true); + expect(isValidValueForSegmentWrapper('day', '15')).toBe(true); + expect(isValidValueForSegmentWrapper('day', '31')).toBe(true); + + expect(isValidValueForSegmentWrapper('day', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('day', '32')).toBe(false); + }); + + test('month', () => { + expect(isValidValueForSegmentWrapper('month', '1')).toBe(true); + expect(isValidValueForSegmentWrapper('month', '9')).toBe(true); + expect(isValidValueForSegmentWrapper('month', '12')).toBe(true); + + expect(isValidValueForSegmentWrapper('month', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('month', '28')).toBe(false); + }); + + test('year with custom validation', () => { + expect(isValidValueForSegmentWrapper('year', '1970')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2000')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2038')).toBe(true); + + // All positive numbers 4-digit are considered valid years by default + expect(isValidValueForSegmentWrapper('year', '1000')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '1945')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '2048')).toBe(true); + expect(isValidValueForSegmentWrapper('year', '9999')).toBe(true); + + expect(isValidValueForSegmentWrapper('year', '0')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '20')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '200')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '999')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '10000')).toBe(false); + expect(isValidValueForSegmentWrapper('year', '-2000')).toBe(false); + }); +}); diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts new file mode 100644 index 0000000000..7a8df1593e --- /dev/null +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts @@ -0,0 +1,49 @@ +import inRange from 'lodash/inRange'; + +import { + isValidSegmentName, + isValidSegmentValue, +} from '../isValidSegment/isValidSegment'; + +/** + * Returns whether a value is valid for a given segment type + * @param segment - The segment type + * @param value - The value to check + * @param defaultMin - The default minimum value for the segment + * @param defaultMax - The default maximum value for the segment + * @param segmentEnum - The segment object + * @param customValidation - A custom validation function for the segment. This is useful for segments that allow values outside of the default range. + * @returns Whether the value is valid for the segment + * @example + * // The segmentEnum is the object that contains the segment names and their corresponding values + * const segmentEnum = { + * Day: 'day', + * Month: 'month', + * Year: 'year', + * }; + * isValidValueForSegment('day', '1', 1, 31, segmentEnum); // true + * isValidValueForSegment('day', '32', 1, 31, segmentEnum); // false + * isValidValueForSegment('month', '1', 1, 12, segmentEnum); // true + * isValidValueForSegment('month', '13', 1, 12, segmentEnum); // false + * isValidValueForSegment('year', '1970', 1000, 9999, segmentEnum); // true + */ +export const isValidValueForSegment = ( + segment: T, + value: V, + defaultMin: number, + defaultMax: number, + segmentEnum: Readonly>, + customValidation?: (value: V) => boolean, +): boolean => { + const isValidSegmentAndValue = + isValidSegmentValue(value, defaultMin === 0) && + isValidSegmentName(segmentEnum, segment); + + if (customValidation) { + return isValidSegmentAndValue && customValidation(value); + } + + const isInRange = inRange(Number(value), defaultMin, defaultMax + 1); + + return isValidSegmentAndValue && isInRange; +}; diff --git a/packages/input-box/tsconfig.json b/packages/input-box/tsconfig.json new file mode 100644 index 0000000000..cba2152d8f --- /dev/null +++ b/packages/input-box/tsconfig.json @@ -0,0 +1,46 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": [ + "../icon/src/generated/*" + ], + "@leafygreen-ui/*": [ + "../*/src" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/*.spec.*", + "**/*.stories.*" + ], + "references": [ + { + "path": "../emotion" + }, + { + "path": "../lib" + }, + { + "path": "../hooks" + }, + { + "path": "../date-utils" + }, + { + "path": "../palette" + }, + { + "path": "../tokens" + }, + { + "path": "../typography" + }, + { + "path": "../leafygreen-provider" + } + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1297838ca..3de735ab2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2253,6 +2253,33 @@ importers: specifier: workspace:^ version: link:../../tools/build + packages/input-box: + dependencies: + '@leafygreen-ui/date-utils': + specifier: workspace:^ + version: link:../date-utils + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/hooks': + specifier: workspace:^ + version: link:../hooks + '@leafygreen-ui/leafygreen-provider': + specifier: workspace:^ + version: link:../leafygreen-provider + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/palette': + specifier: workspace:^ + version: link:../palette + '@leafygreen-ui/tokens': + specifier: workspace:^ + version: link:../tokens + '@leafygreen-ui/typography': + specifier: workspace:^ + version: link:../typography + packages/input-option: dependencies: '@leafygreen-ui/a11y': From ceb124e33cfd36ca908c2e6474ce54d86ae5c822 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 08:07:47 -0500 Subject: [PATCH 02/11] refactor(input-box): update segment validator and input value utilities for improved type safety and consistency --- .../createExplicitSegmentValidator.spec.ts | 6 +- .../createExplicitSegmentValidator.ts | 12 +- .../getNewSegmentValueFromArrowKeyPress.ts | 8 +- .../getNewSegmentValueFromInputValue.spec.ts | 326 +++++++++--------- .../getNewSegmentValueFromInputValue.ts | 90 +++-- .../getRelativeSegment/getRelativeSegment.ts | 41 ++- .../getValueFormatter/getValueFormatter.ts | 25 +- .../getValueFormatter/valueFormatter.spec.ts | 78 ++++- .../isElementInputSegment.ts | 4 +- .../utils/isValidSegment/isValidSegment.ts | 16 +- .../isValidValueForSegment.spec.ts | 17 +- .../isValidValueForSegment.ts | 73 +++- 12 files changed, 429 insertions(+), 267 deletions(-) diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts index 9acad385b9..cf4e706d72 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -12,10 +12,10 @@ const rules = { year: { maxChars: 4 }, }; -const isExplicitSegmentValue = createExplicitSegmentValidator( - segmentObj, +const isExplicitSegmentValue = createExplicitSegmentValidator({ + segmentEnum: segmentObj, rules, -); +}); describe('packages/input-box/utils/createExplicitSegmentValidator', () => { describe('day segment', () => { diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 200d832632..0f5a7a14b6 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -30,9 +30,15 @@ export interface ExplicitSegmentRule { * month: { maxChars: 2, minExplicitValue: 1 }, */ export function createExplicitSegmentValidator< - T extends Record, ->(segmentEnum: T, rules: Record) { - return (segment: T[keyof T], value: string): boolean => { + SegmentEnum extends Record, +>({ + segmentEnum, + rules, +}: { + segmentEnum: SegmentEnum; + rules: Record; +}) { + return (segment: SegmentEnum[keyof SegmentEnum], value: string): boolean => { if ( !(isValidSegmentValue(value) && isValidSegmentName(segmentEnum, segment)) ) diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts index 6d2e2e9dc7..0af8892928 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -1,7 +1,7 @@ import { keyMap, rollover } from '@leafygreen-ui/lib'; -interface GetNewSegmentValueFromArrowKeyPress { - value: V; +interface GetNewSegmentValueFromArrowKeyPress { + value: Value; key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; min: number; max: number; @@ -27,14 +27,14 @@ interface GetNewSegmentValueFromArrowKeyPress { * getNewSegmentValueFromArrowKeyPress({ value: '1970', key: 'ArrowUp', min: 1970, max: 2038, step: 1 }); // 1971 * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldNotRollover: true }); // 2039 */ -export const getNewSegmentValueFromArrowKeyPress = ({ +export const getNewSegmentValueFromArrowKeyPress = ({ value, key, min, max, shouldNotRollover, step = 1, -}: GetNewSegmentValueFromArrowKeyPress): number => { +}: GetNewSegmentValueFromArrowKeyPress): number => { const valueDiff = key === keyMap.ArrowUp ? step : -step; const defaultVal = key === keyMap.ArrowUp ? min : max; diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index 3eaba47e20..b143a5f21e 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -33,59 +33,59 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { const segment = _segment as 'day' | 'month' | 'year'; describe('when current value is empty', () => { test.each(range(10))('accepts %i character as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - segment === 'year', - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '', + incomingValue: `${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: segment === 'year', + }); expect(newValue).toEqual(`${i}`); }); const validValues = [defaultMin[segment], defaultMax[segment]]; test.each(validValues)(`accepts value "%i" as input`, v => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `${v}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - segment === 'year', - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '', + incomingValue: `${v}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: segment === 'year', + }); expect(newValue).toEqual(`${v}`); }); test('does not accept non-numeric characters', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `b`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - segment === 'year', - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '', + incomingValue: `b`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: segment === 'year', + }); expect(newValue).toEqual(''); }); test('does not accept input with a period/decimal', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '', - `2.`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - segment === 'year', - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '', + incomingValue: `2.`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: segment === 'year', + }); expect(newValue).toEqual(''); }); }); @@ -93,115 +93,115 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { describe('when current value is 0', () => { if (segment !== 'year') { test('rejects additional 0 as input', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - `00`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '0', + incomingValue: `00`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(`0`); }); } if (segment === 'year') { test('accepts 0000 as input', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - `0000`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - true, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '0', + incomingValue: `0000`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: true, + }); expect(newValue).toEqual(`0000`); }); } test.each(range(1, 10))('accepts 0%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - `0${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - segment === 'year', - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '0', + incomingValue: `0${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: segment === 'year', + }); expect(newValue).toEqual(`0${i}`); }); test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '0', - ``, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - segment === 'year', - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '0', + incomingValue: ``, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: segment === 'year', + }); expect(newValue).toEqual(``); }); }); describe('when current value is 1', () => { test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - ``, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '1', + incomingValue: ``, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(``); }); if (segment === 'month') { test.each(range(0, 3))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '1', + incomingValue: `1${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(`1${i}`); }); describe.each(range(3, 10))('rejects 1%i', i => { test(`and sets input "${i}"`, () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '1', + incomingValue: `1${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(`${i}`); }); }); } else { test.each(range(10))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '1', - `1${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - segment === 'year', - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '1', + incomingValue: `1${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + shouldSkipValidation: segment === 'year', + }); expect(newValue).toEqual(`1${i}`); }); } @@ -209,43 +209,43 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { describe('when current value is 3', () => { test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - ``, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '3', + incomingValue: ``, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(``); }); switch (segment) { case 'day': { test.each(range(0, 2))('accepts 3%i as input', i => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - `3${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '3', + incomingValue: `3${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(`3${i}`); }); describe.each(range(3, 10))('rejects 3%i', i => { test(`and sets input to ${i}`, () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - `3${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '3', + incomingValue: `3${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(`${i}`); }); }); @@ -255,15 +255,15 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { case 'month': { describe.each(range(10))('rejects 3%i', i => { test(`and sets input "${i}"`, () => { - const newValue = getNewSegmentValueFromInputValue( - segment, - '3', - `3${i}`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: '3', + incomingValue: `3${i}`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(`${i}`); }); }); @@ -276,22 +276,24 @@ describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { }); describe('when current value is a full formatted value', () => { - const formatter = getValueFormatter(charsPerSegment[segment]); + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment[segment], + }); const testValues = [defaultMin[segment], defaultMax[segment]].map( formatter, ); test.each(testValues)( 'when current value is %p, rejects additional input', val => { - const newValue = getNewSegmentValueFromInputValue( - segment, - val, - `${val}1`, - charsPerSegment[segment], - defaultMin[segment], - defaultMax[segment], - segmentObj, - ); + const newValue = getNewSegmentValueFromInputValue({ + segmentName: segment, + currentValue: val, + incomingValue: `${val}1`, + charsPerSegment: charsPerSegment[segment], + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: segmentObj, + }); expect(newValue).toEqual(val); }, ); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts index 0c1644a73e..5d37a5fac1 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -4,6 +4,20 @@ import { truncateStart } from '@leafygreen-ui/lib'; import { isValidValueForSegment } from '..'; +interface GetNewSegmentValueFromInputValue< + SegmentName extends string, + Value extends string, +> { + segmentName: SegmentName; + currentValue: Value; + incomingValue: Value; + charsPerSegment: number; + defaultMin: number; + defaultMax: number; + segmentEnum: Readonly>; + shouldSkipValidation?: boolean; +} + /** * Calculates the new value for the segment given an incoming change. * @@ -28,24 +42,58 @@ import { isValidValueForSegment } from '..'; * Month: 'month', * Year: 'year', * }; - * getNewSegmentValueFromInputValue('day', '1', '2', segmentEnum['day'], 1, 31, segmentEnum); // '2' - * getNewSegmentValueFromInputValue('month', '1', '2', segmentEnum['month'], 1, 12, segmentEnum); // '2' - * getNewSegmentValueFromInputValue('year', '1', '2', segmentEnum['year'], 1970, 2038, segmentEnum); // '2' - * getNewSegmentValueFromInputValue('day', '1', '.', segmentEnum['day'], 1, 31, segmentEnum); // '1' + * + * getNewSegmentValueFromInputValue({ + * segmentName: 'day', + * currentValue: '0', + * incomingValue: '1', + * charsPerSegment: 2, + * defaultMin: 1, + * defaultMax: 31, + * segmentEnum + * }); // '1' + * getNewSegmentValueFromInputValue({ + * segmentName: 'day', + * currentValue: '1', + * incomingValue: '12', + * charsPerSegment: 2, + * defaultMin: 1, + * defaultMax: 31, + * segmentEnum + * }); // '12' + * getNewSegmentValueFromInputValue({ + * segmentName: 'day', + * currentValue: '1', + * incomingValue: '.', + * charsPerSegment: 2, + * defaultMin: 1, + * defaultMax: 31, + * segmentEnum + * }); // '1' + * getNewSegmentValueFromInputValue({ + * segmentName: 'year', + * currentValue: '00', + * incomingValue: '000', + * charsPerSegment: 4, + * defaultMin: 1970, + * defaultMax: 2038, + * segmentEnum, + * shouldSkipValidation: true, + * }); // '000' */ export const getNewSegmentValueFromInputValue = < - T extends string, - V extends string, ->( - segmentName: T, - currentValue: V, - incomingValue: V, - charsPerSegment: number, - defaultMin: number, - defaultMax: number, - segmentEnum: Readonly>, + SegmentName extends string, + Value extends string, +>({ + segmentName, + currentValue, + incomingValue, + charsPerSegment, + defaultMin, + defaultMax, + segmentEnum, shouldSkipValidation = false, -): V => { +}: GetNewSegmentValueFromInputValue): Value => { // If the incoming value is not a valid number const isIncomingValueNumber = !isNaN(Number(incomingValue)); // macOS adds a period when pressing SPACE twice inside a text input. @@ -64,23 +112,23 @@ export const getNewSegmentValueFromInputValue = < return currentValue; } - const isIncomingValueValid = isValidValueForSegment( - segmentName, - incomingValue, + const isIncomingValueValid = isValidValueForSegment({ + segment: segmentName, + value: incomingValue, defaultMin, defaultMax, segmentEnum, - ); + }); if (isIncomingValueValid || shouldSkipValidation) { const newValue = truncateStart(incomingValue, { length: charsPerSegment, }); - return newValue as V; + return newValue as Value; } const typedChar = last(incomingValue.split('')); const newValue = typedChar === '0' ? '0' : typedChar ?? ''; - return newValue as V; + return newValue as Value; }; diff --git a/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts index 578bf6ddb4..fee0cbcbfe 100644 --- a/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts @@ -26,16 +26,16 @@ type RelativeDirection = 'next' | 'prev' | 'first' | 'last'; * getRelativeSegment('first', { segment: 'day', formatParts }); // 'year' * getRelativeSegment('last', { segment: 'year', formatParts }); // 'day' */ -export const getRelativeSegment = ( +export const getRelativeSegment = ( direction: RelativeDirection, { segment, formatParts, }: { - segment: V; + segment: Segment; formatParts?: Array; }, -): V | undefined => { +): Segment | undefined => { if ( isUndefined(direction) || isUndefined(segment) || @@ -45,9 +45,9 @@ export const getRelativeSegment = ( } // only the relevant segments, not separators - const formatSegments: Array = formatParts + const formatSegments: Array = formatParts .filter(part => part.type !== 'literal') - .map(part => part.type as V); + .map(part => part.type as Segment); /** The index of the reference segment relative to formatParts */ const currentSegmentIndex: number | undefined = @@ -89,11 +89,11 @@ export const getRelativeSegment = ( }; interface GetRelativeSegmentContext< - T extends Record>, + SegmentRefs extends Record>, > { segment: HTMLInputElement | React.RefObject; formatParts?: Array; - segmentRefs: T; + segmentRefs: SegmentRefs; } /** @@ -124,11 +124,10 @@ interface GetRelativeSegmentContext< * getRelativeSegmentRef('last', { segment: monthRef, formatParts, segmentRefs }); // dayRef */ export const getRelativeSegmentRef = < - T extends Record>, - V extends string, + SegmentRefs extends Record>, >( direction: RelativeDirection, - { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, + { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, ): React.RefObject | undefined => { if ( isUndefined(direction) || @@ -139,17 +138,21 @@ export const getRelativeSegmentRef = < return; } + type SegmentName = keyof SegmentRefs & string; + // only the relevant segments, not separators - const formatSegments: Array = formatParts + const formatSegments: Array = formatParts .filter(part => part.type !== 'literal') - .map(part => part.type as V); - - const currentSegmentName: V | undefined = formatSegments.find(segmentName => { - return ( - segmentRefs[segmentName] === segment || - segmentRefs[segmentName].current === segment - ); - }); + .map(part => part.type as SegmentName); + + const currentSegmentName: SegmentName | undefined = formatSegments.find( + segmentName => { + return ( + segmentRefs[segmentName] === segment || + segmentRefs[segmentName].current === segment + ); + }, + ); if (currentSegmentName) { const relativeSegmentName = getRelativeSegment(direction, { diff --git a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts index f2c6d822e6..e396fd44fb 100644 --- a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts +++ b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts @@ -7,24 +7,31 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * otherwise, pad the string with 0s, or trim it to n chars * * @param charsPerSegment - the number of characters per segment - * @param allowsZero - + * @param allowsZero - whether to allow zero-like values * @param val - the value to format * @returns a value formatter function for the provided segment * * @example - * const charsPerSegment = { - * day: 2, - * month: 2, - * year: 4, - * }; - * const formatter = getValueFormatter(charsPerSegment['day']); + * const formatter = getValueFormatter({ charsPerSegment: 2 }); * formatter('0'); // '' - * formatter('1'); // '01' + * formatter('1'); // '1' + * formatter('12'); // '12' + * formatter('123'); // '23' + * + * const formatter = getValueFormatter({ charsPerSegment: 2, allowZero: true }); + * formatter('00'); // '00' + * formatter('01'); // '01' * formatter('12'); // '12' * formatter('123'); // '23' */ export const getValueFormatter = - (charsPerSegment: number, allowZero = false) => + ({ + charsPerSegment, + allowZero = false, + }: { + charsPerSegment: number; + allowZero?: boolean; + }) => (val: string | number | undefined) => { // If the value is empty, do not format it if (val === '') return ''; diff --git a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts index 7e5436fe01..8f22456d15 100644 --- a/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts +++ b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts @@ -1,15 +1,35 @@ import { getValueFormatter } from './getValueFormatter'; -type Segment = 'day' | 'month' | 'year'; +type Segment = 'one' | 'two' | 'three'; const charsPerSegment: Record = { - day: 2, - month: 2, - year: 4, + one: 1, + two: 2, + three: 3, }; describe('packages/input-box/utils/valueFormatter', () => { - describe.each(['day', 'month'] as Array)('', segment => { - const formatter = getValueFormatter(charsPerSegment[segment]); + describe('one segment', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['one'], + }); + + test('returns the value as is', () => { + expect(formatter('1')).toEqual('1'); + }); + + test('sets 0 to empty string', () => { + expect(formatter('0')).toEqual(''); + }); + + test('sets undefined to empty string', () => { + expect(formatter(undefined)).toEqual(''); + }); + }); + + describe('two segments', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['two'], + }); test('formats 2 digit values', () => { expect(formatter('12')).toEqual('12'); @@ -36,23 +56,25 @@ describe('packages/input-box/utils/valueFormatter', () => { }); }); - describe('year', () => { - const formatter = getValueFormatter(charsPerSegment['year']); + describe('three segments', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['three'], + }); test('formats 4 digit values', () => { - expect(formatter('2023')).toEqual('2023'); + expect(formatter('202')).toEqual('202'); }); - test('pads < 4 digit value', () => { - expect(formatter('123')).toEqual('0123'); + test('pads < 3 digit value', () => { + expect(formatter('12')).toEqual('012'); }); - test('truncates 5+ digit values', () => { - expect(formatter('12345')).toEqual('2345'); + test('truncates 4+ digit values', () => { + expect(formatter('1234')).toEqual('234'); }); - test('truncates 5+ digit padded values', () => { - expect(formatter('02345')).toEqual('2345'); + test('truncates 4+ digit padded values', () => { + expect(formatter('02345')).toEqual('345'); }); test('sets 0 to empty string', () => { @@ -63,4 +85,30 @@ describe('packages/input-box/utils/valueFormatter', () => { expect(formatter(undefined)).toEqual(''); }); }); + + describe('with allowZero allows leading zeros', () => { + test('with one segment', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['one'], + allowZero: true, + }); + expect(formatter('0')).toEqual('0'); + }); + + test('with two segments', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['two'], + allowZero: true, + }); + expect(formatter('0')).toEqual('00'); + }); + + test('with three segments', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['three'], + allowZero: true, + }); + expect(formatter('0')).toEqual('000'); + }); + }); }); diff --git a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts index 411237f8cb..e8cf8c6503 100644 --- a/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts +++ b/packages/input-box/src/utils/isElementInputSegment/isElementInputSegment.ts @@ -15,10 +15,10 @@ * isElementInputSegment(document.querySelector('input[data-segment="year"]'), segmentRefs); // true */ export const isElementInputSegment = < - T extends Record>, + SegmentRefs extends Record>, >( element: HTMLElement, - segmentRefs: T, + segmentRefs: SegmentRefs, ): element is HTMLInputElement => { const segmentsArray = Object.values(segmentRefs).map( ref => ref.current, diff --git a/packages/input-box/src/utils/isValidSegment/isValidSegment.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts index 3cae5afb58..08dcda6d0d 100644 --- a/packages/input-box/src/utils/isValidSegment/isValidSegment.ts +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts @@ -13,10 +13,10 @@ import isUndefined from 'lodash/isUndefined'; * isValidSegmentValue('0', true); // true * isValidSegmentValue('00', true); // true */ -export const isValidSegmentValue = ( - segment?: T, +export const isValidSegmentValue = ( + segment?: SegmentValue, allowZero = false, -): segment is T => +): segment is SegmentValue => !isUndefined(segment) && !isNaN(Number(segment)) && (Number(segment) > 0 || allowZero); @@ -40,12 +40,14 @@ export const isValidSegmentValue = ( * isValidSegmentName(segmentEnum, 'year'); // true * isValidSegmentName(segmentEnum, 'seconds'); // false */ -export const isValidSegmentName = >>( - segmentEnum: T, +export const isValidSegmentName = < + SegmentEnum extends Readonly>, +>( + segmentEnum: SegmentEnum, name?: string, -): name is T[keyof T] => { +): name is SegmentEnum[keyof SegmentEnum] => { return ( !isUndefined(name) && - Object.values(segmentEnum).includes(name as T[keyof T]) + Object.values(segmentEnum).includes(name as SegmentEnum[keyof SegmentEnum]) ); }; diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts index 5d7d72dd8a..a9b3bf0d88 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -23,16 +23,17 @@ const defaultMax = { } as const; const isValidValueForSegmentWrapper = (segment: SegmentObj, value: string) => { - return isValidValueForSegment( + return isValidValueForSegment({ segment, value, - defaultMin[segment], - defaultMax[segment], - SegmentObj, - segment === 'year' - ? (value: string) => inRange(Number(value), 1000, 9999 + 1) - : undefined, - ); + defaultMin: defaultMin[segment], + defaultMax: defaultMax[segment], + segmentEnum: SegmentObj, + customValidation: + segment === 'year' + ? (value: string) => inRange(Number(value), 1000, 9999 + 1) + : undefined, + }); }; describe('packages/input-box/utils/isValidSegmentValue', () => { diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts index 7a8df1593e..4b18195ba2 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts @@ -5,9 +5,21 @@ import { isValidSegmentValue, } from '../isValidSegment/isValidSegment'; +interface IsValidValueForSegmentProps< + SegmentName extends string, + Value extends string, +> { + segment: SegmentName; + value: Value; + defaultMin: number; + defaultMax: number; + segmentEnum: Readonly>; + customValidation?: (value: Value) => boolean; +} + /** * Returns whether a value is valid for a given segment type - * @param segment - The segment type + * @param segment - The segment name * @param value - The value to check * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment @@ -21,20 +33,53 @@ import { * Month: 'month', * Year: 'year', * }; - * isValidValueForSegment('day', '1', 1, 31, segmentEnum); // true - * isValidValueForSegment('day', '32', 1, 31, segmentEnum); // false - * isValidValueForSegment('month', '1', 1, 12, segmentEnum); // true - * isValidValueForSegment('month', '13', 1, 12, segmentEnum); // false - * isValidValueForSegment('year', '1970', 1000, 9999, segmentEnum); // true + * isValidValueForSegment({ + * segment: 'day', + * value: '1', + * defaultMin: 1, + * defaultMax: 31, + * segmentEnum + * }); // true + * isValidValueForSegment({ + * segment: 'day', + * value: '32', + * defaultMin: 1, + * defaultMax: 31, + * segmentEnum + * }); // false + * isValidValueForSegment({ + * segment: 'month', + * value: '1', + * defaultMin: 1, + * defaultMax: 12, + * segmentEnum + * }); // true + * isValidValueForSegment({ + * segment: 'month', + * value: '13', + * defaultMin: 1, + * defaultMax: 12, + * segmentEnum + * }); // false + * isValidValueForSegment({ + * segment: 'year', + * value: '1970', + * defaultMin: 1000, + * defaultMax: 9999, + * segmentEnum + * }); // true */ -export const isValidValueForSegment = ( - segment: T, - value: V, - defaultMin: number, - defaultMax: number, - segmentEnum: Readonly>, - customValidation?: (value: V) => boolean, -): boolean => { +export const isValidValueForSegment = < + SegmentName extends string, + Value extends string, +>({ + segment, + value, + defaultMin, + defaultMax, + segmentEnum, + customValidation, +}: IsValidValueForSegmentProps): boolean => { const isValidSegmentAndValue = isValidSegmentValue(value, defaultMin === 0) && isValidSegmentName(segmentEnum, segment); From bde44d58f2966da00508c7b62315da0ce2e9a5b9 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 08:16:19 -0500 Subject: [PATCH 03/11] fix(input-box): update explicit segment validation rules for day, month, and year --- .../createExplicitSegmentValidator.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 0f5a7a14b6..429d1b53ee 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -26,8 +26,28 @@ export interface ExplicitSegmentRule { * Year: 'year', * }; * const rules = { - * day: { maxChars: 2, minExplicitValue: 1 }, - * month: { maxChars: 2, minExplicitValue: 1 }, + * day: { maxChars: 2, minExplicitValue: 4 }, + * month: { maxChars: 2, minExplicitValue: 2 }, + * year: { maxChars: 4 }, + * }; + * + * const isExplicitSegmentValue = createExplicitSegmentValidator({ + * segmentEnum, + * rules, + * }); + * + * isExplicitSegmentValue('day', '1'); // false + * isExplicitSegmentValue('day', '01'); // true + * isExplicitSegmentValue('day', '4'); // true + * isExplicitSegmentValue('day', '10'); // true + * isExplicitSegmentValue('month', '1'); // false + * isExplicitSegmentValue('month', '01'); // true + * isExplicitSegmentValue('month', '2'); // true + * isExplicitSegmentValue('month', '12'); // true + * isExplicitSegmentValue('year', '2000'); // true + * isExplicitSegmentValue('year', '0001'); // true + * isExplicitSegmentValue('year', '1'); // false + * isExplicitSegmentValue('year', '200'); // false */ export function createExplicitSegmentValidator< SegmentEnum extends Record, From ae8aec010a4ed359f296bd9a8ca008da4c3d3ea7 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 08:27:28 -0500 Subject: [PATCH 04/11] docs(input-box): enhance JSDoc comments for explicit segment validator with detailed explanations and examples --- .../createExplicitSegmentValidator.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 429d1b53ee..4434ebfb14 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -4,7 +4,7 @@ import { } from '../isValidSegment/isValidSegment'; /** - * Configuration for determining if a segment value is explicit + * Configuration for determining if a segment value is an explicit, unique value for a given segment. */ export interface ExplicitSegmentRule { /** Maximum characters for this segment */ @@ -14,7 +14,15 @@ export interface ExplicitSegmentRule { } /** - * Factory function that creates a segment value validator + * Factory function that creates a segment value validator that checks if a segment value is an explicit, unique value for a given segment. + * + * An "explicit" segment value is one that is complete and unambiguous, eliminating the possibility that it is a partial input. + * A value is considered explicit if it meets one of two conditions: + * 1. **Maximum Length:** The value has been padded (e.g., with leading zeros) to reach the segment's maximum character length (`maxChars`). + * *(Example: For `maxChars: 2`, '01' is explicit, but '1' is not).* + * 2. **Minimum Value Threshold:** The value, while shorter than `maxChars`, is numerically equal to or greater than the segment's defined `minExplicitValue`. This ensures single-digit inputs are treated as final values rather than the start of a multi-digit entry. + * *(Example: For `minExplicitValue: 4`, '4' is explicit, but '1' is potentially ambiguous).* + * * @param segmentEnum - The segment enum/object to validate against * @param rules - Rules for each segment type * @returns A function that checks if a segment value is explicit @@ -31,23 +39,20 @@ export interface ExplicitSegmentRule { * year: { maxChars: 4 }, * }; * + * // Contrast this with an ambiguous segment value: + * // Explicit: Day = '4' (meets min value), '02' (meets max length) + * // Ambiguous: Day = '2' (does not meet max length and is less than min value) + * * const isExplicitSegmentValue = createExplicitSegmentValidator({ * segmentEnum, * rules, * }); * - * isExplicitSegmentValue('day', '1'); // false - * isExplicitSegmentValue('day', '01'); // true - * isExplicitSegmentValue('day', '4'); // true - * isExplicitSegmentValue('day', '10'); // true - * isExplicitSegmentValue('month', '1'); // false - * isExplicitSegmentValue('month', '01'); // true - * isExplicitSegmentValue('month', '2'); // true - * isExplicitSegmentValue('month', '12'); // true - * isExplicitSegmentValue('year', '2000'); // true - * isExplicitSegmentValue('year', '0001'); // true - * isExplicitSegmentValue('year', '1'); // false - * isExplicitSegmentValue('year', '200'); // false + * 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) */ export function createExplicitSegmentValidator< SegmentEnum extends Record, From 628097c90fb9c57484b46f035f9849691e580797 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 08:35:05 -0500 Subject: [PATCH 05/11] refactor(input-box): rename shouldNotRollover to shouldRollover for clarity in segment value handling --- ...etNewSegmentValueFromArrowKeyPress.spec.ts | 28 +++++++++---------- .../getNewSegmentValueFromArrowKeyPress.ts | 14 +++++----- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts index 331dcf7561..dd697ec112 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts @@ -35,24 +35,24 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { expect(result).toBe(1); }); - test('does not rollover when shouldNotRollover is true', () => { + test('does not rollover when shouldRollover is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '2038', key: keyMap.ArrowUp, min: 1970, max: 2038, - shouldNotRollover: true, + shouldRollover: false, }); expect(result).toBe(2039); }); - test('rolls over when shouldNotRollover is false', () => { + test('rolls over when shouldRollover is true', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '12', key: keyMap.ArrowUp, min: 1, max: 12, - shouldNotRollover: false, + shouldRollover: true, }); expect(result).toBe(1); }); @@ -151,24 +151,24 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { expect(result).toBe(12); }); - test('does not rollover when shouldNotRollover is true', () => { + test('does not rollover when shouldRollover is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '1970', key: keyMap.ArrowDown, min: 1970, max: 2038, - shouldNotRollover: true, + shouldRollover: false, }); expect(result).toBe(1969); }); - test('rolls over when shouldNotRollover is false', () => { + test('rolls over when shouldRollover is true', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '1', key: keyMap.ArrowDown, min: 1, max: 31, - shouldNotRollover: false, + shouldRollover: true, }); expect(result).toBe(31); }); @@ -234,7 +234,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { min: 1, max: 12, step: 20, - shouldNotRollover: true, + shouldRollover: false, }); expect(result).toBe(25); }); @@ -292,25 +292,25 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { }); }); - describe('shouldNotRollover behavior', () => { - test('allows exceeding max when shouldNotRollover is true', () => { + describe('shouldRollover behavior', () => { + test('allows exceeding max when shouldRollover is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '2038', key: keyMap.ArrowUp, min: 1970, max: 2038, - shouldNotRollover: true, + shouldRollover: false, }); expect(result).toBe(2039); }); - test('allows going below min when shouldNotRollover is true', () => { + test('allows going below min when shouldRollover is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '1970', key: keyMap.ArrowDown, min: 1970, max: 2038, - shouldNotRollover: true, + shouldRollover: false, }); expect(result).toBe(1969); }); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts index 0af8892928..afa42efe50 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -6,7 +6,7 @@ interface GetNewSegmentValueFromArrowKeyPress { min: number; max: number; step?: number; - shouldNotRollover?: boolean; + shouldRollover?: boolean; } /** @@ -17,7 +17,7 @@ interface GetNewSegmentValueFromArrowKeyPress { * @param min - The minimum value for the segment * @param max - The maximum value for the segment * @param step - The step value for the arrow keys - * @param shouldNotRollover - The segments that should not rollover + * @param shouldRollover - If the segment should rollover when the value is at the min or max boundary * @returns The new value for the segment * @example * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 31, step: 1}); // 2 @@ -25,14 +25,14 @@ interface GetNewSegmentValueFromArrowKeyPress { * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 12, step: 1}); // 2 * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 12, step: 1}); // 12 * getNewSegmentValueFromArrowKeyPress({ value: '1970', key: 'ArrowUp', min: 1970, max: 2038, step: 1 }); // 1971 - * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldNotRollover: true }); // 2039 + * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldRollover: false }); // 2039 */ export const getNewSegmentValueFromArrowKeyPress = ({ value, key, min, max, - shouldNotRollover, + shouldRollover = true, step = 1, }: GetNewSegmentValueFromArrowKeyPress): number => { const valueDiff = key === keyMap.ArrowUp ? step : -step; @@ -42,9 +42,9 @@ export const getNewSegmentValueFromArrowKeyPress = ({ ? Number(value) + valueDiff : defaultVal; - const newValue = shouldNotRollover - ? incrementedValue - : rollover(incrementedValue, min, max); + const newValue = shouldRollover + ? rollover(incrementedValue, min, max) + : incrementedValue; return newValue; }; From 674514795ce1605c16f10fc7c004ede3f07e3796 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 09:17:26 -0500 Subject: [PATCH 06/11] feat(input-box): add hour and minute segments to explicit segment validator with corresponding validation rules and tests --- .../createExplicitSegmentValidator.spec.ts | 45 ++++++++++++++++++- .../createExplicitSegmentValidator.ts | 10 +++++ .../isValidSegment/isValidSegment.spec.ts | 4 ++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts index cf4e706d72..535e7096a1 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -4,12 +4,16 @@ const segmentObj = { Day: 'day', Month: 'month', Year: 'year', + Hour: 'hour', + Minute: 'minute', } as const; const rules = { day: { maxChars: 2, minExplicitValue: 4 }, month: { maxChars: 2, minExplicitValue: 2 }, - year: { maxChars: 4 }, + year: { maxChars: 4 }, // any 4-digit year + hour: { maxChars: 2, minExplicitValue: 3 }, + minute: { maxChars: 2, minExplicitValue: 6 }, }; const isExplicitSegmentValue = createExplicitSegmentValidator({ @@ -86,12 +90,49 @@ describe('packages/input-box/utils/createExplicitSegmentValidator', () => { }); }); + 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); + }); + + test('returns true for single digit at or above minExplicitValue', () => { + expect(isExplicitSegmentValue('hour', '3')).toBe(true); + expect(isExplicitSegmentValue('hour', '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); + }); + }); + + 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); + }); + + 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); + }); + + test('returns true for two-digit values at or above minExplicitValue', () => { + expect(isExplicitSegmentValue('minute', '59')).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('hour', '12')).toBe(false); + expect(isExplicitSegmentValue('millisecond', '12')).toBe(false); }); }); }); diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index 4434ebfb14..eb05db96ee 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -32,11 +32,15 @@ export interface ExplicitSegmentRule { * Day: 'day', * Month: 'month', * Year: 'year', + * Hour: 'hour', + * Minute: 'minute', * }; * const rules = { * day: { maxChars: 2, minExplicitValue: 4 }, * month: { maxChars: 2, minExplicitValue: 2 }, * year: { maxChars: 4 }, + * hour: { maxChars: 2, minExplicitValue: 3 }, + * minute: { maxChars: 2, minExplicitValue: 6 }, * }; * * // Contrast this with an ambiguous segment value: @@ -53,6 +57,12 @@ export interface ExplicitSegmentRule { * 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) */ export function createExplicitSegmentValidator< SegmentEnum extends Record, diff --git a/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts index 64929a3f56..1766cc32af 100644 --- a/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts @@ -30,6 +30,10 @@ describe('packages/input-box/utils/isValidSegment', () => { expect(isValidSegmentValue('0', true)).toBeTruthy(); }); + test('00 with allowZero returns true', () => { + expect(isValidSegmentValue('00', true)).toBeTruthy(); + }); + test('negative returns false', () => { expect(isValidSegmentValue('-1')).toBeFalsy(); }); From f976e42b8b4b166b764e86e796befc3426c420da Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 09:22:32 -0500 Subject: [PATCH 07/11] refactor(input-box): rename shouldRollover to shouldWrap for clarity in segment value handling --- ...etNewSegmentValueFromArrowKeyPress.spec.ts | 28 +++++++++---------- .../getNewSegmentValueFromArrowKeyPress.ts | 10 +++---- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts index dd697ec112..32cfee670e 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.spec.ts @@ -35,24 +35,24 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { expect(result).toBe(1); }); - test('does not rollover when shouldRollover is false', () => { + test('does not rollover when shouldWrap is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '2038', key: keyMap.ArrowUp, min: 1970, max: 2038, - shouldRollover: false, + shouldWrap: false, }); expect(result).toBe(2039); }); - test('rolls over when shouldRollover is true', () => { + test('rolls over when shouldWrap is true', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '12', key: keyMap.ArrowUp, min: 1, max: 12, - shouldRollover: true, + shouldWrap: true, }); expect(result).toBe(1); }); @@ -151,24 +151,24 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { expect(result).toBe(12); }); - test('does not rollover when shouldRollover is false', () => { + test('does not rollover when shouldWrap is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '1970', key: keyMap.ArrowDown, min: 1970, max: 2038, - shouldRollover: false, + shouldWrap: false, }); expect(result).toBe(1969); }); - test('rolls over when shouldRollover is true', () => { + test('rolls over when shouldWrap is true', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '1', key: keyMap.ArrowDown, min: 1, max: 31, - shouldRollover: true, + shouldWrap: true, }); expect(result).toBe(31); }); @@ -234,7 +234,7 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { min: 1, max: 12, step: 20, - shouldRollover: false, + shouldWrap: false, }); expect(result).toBe(25); }); @@ -292,25 +292,25 @@ describe('packages/input-box/utils/getNewSegmentValueFromArrowKeyPress', () => { }); }); - describe('shouldRollover behavior', () => { - test('allows exceeding max when shouldRollover is false', () => { + describe('shouldWrap behavior', () => { + test('allows exceeding max when shouldWrap is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '2038', key: keyMap.ArrowUp, min: 1970, max: 2038, - shouldRollover: false, + shouldWrap: false, }); expect(result).toBe(2039); }); - test('allows going below min when shouldRollover is false', () => { + test('allows going below min when shouldWrap is false', () => { const result = getNewSegmentValueFromArrowKeyPress({ value: '1970', key: keyMap.ArrowDown, min: 1970, max: 2038, - shouldRollover: false, + shouldWrap: false, }); expect(result).toBe(1969); }); diff --git a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts index afa42efe50..4e81125d0b 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromArrowKeyPress/getNewSegmentValueFromArrowKeyPress.ts @@ -6,7 +6,7 @@ interface GetNewSegmentValueFromArrowKeyPress { min: number; max: number; step?: number; - shouldRollover?: boolean; + shouldWrap?: boolean; } /** @@ -17,7 +17,7 @@ interface GetNewSegmentValueFromArrowKeyPress { * @param min - The minimum value for the segment * @param max - The maximum value for the segment * @param step - The step value for the arrow keys - * @param shouldRollover - If the segment should rollover when the value is at the min or max boundary + * @param shouldWrap - If the segment value should wrap around when the value is at the min or max boundary * @returns The new value for the segment * @example * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 31, step: 1}); // 2 @@ -25,14 +25,14 @@ interface GetNewSegmentValueFromArrowKeyPress { * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowUp', min: 1, max: 12, step: 1}); // 2 * getNewSegmentValueFromArrowKeyPress({ value: '1', key: 'ArrowDown', min: 1, max: 12, step: 1}); // 12 * getNewSegmentValueFromArrowKeyPress({ value: '1970', key: 'ArrowUp', min: 1970, max: 2038, step: 1 }); // 1971 - * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldRollover: false }); // 2039 + * getNewSegmentValueFromArrowKeyPress({ value: '2038', key: 'ArrowUp', min: 1970, max: 2038, step: 1, shouldWrap: false }); // 2039 */ export const getNewSegmentValueFromArrowKeyPress = ({ value, key, min, max, - shouldRollover = true, + shouldWrap = true, step = 1, }: GetNewSegmentValueFromArrowKeyPress): number => { const valueDiff = key === keyMap.ArrowUp ? step : -step; @@ -42,7 +42,7 @@ export const getNewSegmentValueFromArrowKeyPress = ({ ? Number(value) + valueDiff : defaultVal; - const newValue = shouldRollover + const newValue = shouldWrap ? rollover(incrementedValue, min, max) : incrementedValue; From fbf613109346de1416eb6ad31a6677b19cb34b45 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 09:38:23 -0500 Subject: [PATCH 08/11] docs(input-box): improve JSDoc comment for segmentEnum parameter in createExplicitSegmentValidator --- .../createExplicitSegmentValidator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts index eb05db96ee..3c0ed0b910 100644 --- a/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -23,7 +23,7 @@ export interface ExplicitSegmentRule { * 2. **Minimum Value Threshold:** The value, while shorter than `maxChars`, is numerically equal to or greater than the segment's defined `minExplicitValue`. This ensures single-digit inputs are treated as final values rather than the start of a multi-digit entry. * *(Example: For `minExplicitValue: 4`, '4' is explicit, but '1' is potentially ambiguous).* * - * @param segmentEnum - The segment enum/object to validate against + * @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 * From c61bbb23a233ccb962863aa1a314185a85969b03 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 12:19:09 -0500 Subject: [PATCH 09/11] feat(input-box): enhance segment value handling by adding minute segment and updating validation rules for day and year segments --- .../getNewSegmentValueFromInputValue.spec.ts | 468 ++++++++---------- .../getNewSegmentValueFromInputValue.ts | 14 +- 2 files changed, 223 insertions(+), 259 deletions(-) diff --git a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts index b143a5f21e..b6645ed8f2 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -1,302 +1,256 @@ import range from 'lodash/range'; -import { getValueFormatter } from '../getValueFormatter/getValueFormatter'; - import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; -const charsPerSegment = { - day: 2, - month: 2, - year: 4, -}; - -const defaultMin = { - day: 1, - month: 1, - year: 1970, -}; - -const defaultMax = { - day: 31, - month: 12, - year: new Date().getFullYear(), -}; - const segmentObj = { day: 'day', - month: 'month', year: 'year', + minute: 'minute', }; describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { - describe.each(['day', 'month', 'year'])('For segment %p', _segment => { - const segment = _segment as 'day' | 'month' | 'year'; - describe('when current value is empty', () => { - test.each(range(10))('accepts %i character as input', i => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '', - incomingValue: `${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - shouldSkipValidation: segment === 'year', - }); - expect(newValue).toEqual(`${i}`); + describe('when segment is empty', () => { + // accepts 0-9 characters as input + test.each(range(10))('accepts %i character as input', i => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '', + incomingValue: `${i}`, + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, }); + expect(newValue).toEqual(`${i}`); + }); - const validValues = [defaultMin[segment], defaultMax[segment]]; - test.each(validValues)(`accepts value "%i" as input`, v => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '', - incomingValue: `${v}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - shouldSkipValidation: segment === 'year', - }); - expect(newValue).toEqual(`${v}`); + test.each(range(9))('accepts 1%i character as input', i => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '1', + incomingValue: `1${i}`, + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 19, + segmentEnum: segmentObj, }); + expect(newValue).toEqual(`1${i}`); + }); - test('does not accept non-numeric characters', () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '', - incomingValue: `b`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - shouldSkipValidation: segment === 'year', - }); - expect(newValue).toEqual(''); + test('does not accept non-numeric characters', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '', + incomingValue: `b`, + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, }); + expect(newValue).toEqual(''); + }); - test('does not accept input with a period/decimal', () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '', - incomingValue: `2.`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - shouldSkipValidation: segment === 'year', - }); - expect(newValue).toEqual(''); + test('does not accept input with a period/decimal', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '', + incomingValue: `2.`, + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, }); + expect(newValue).toEqual(''); }); - describe('when current value is 0', () => { - if (segment !== 'year') { - test('rejects additional 0 as input', () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '0', - incomingValue: `00`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(`0`); - }); - } + test('returns the current value when the incoming value is not a number', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '1', + incomingValue: 'a', + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('1'); + }); + }); - if (segment === 'year') { - test('accepts 0000 as input', () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '0', - incomingValue: `0000`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - shouldSkipValidation: true, - }); - expect(newValue).toEqual(`0000`); - }); - } - test.each(range(1, 10))('accepts 0%i as input', i => { + describe('when segment is not empty', () => { + test('value can be deleted', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '1', + incomingValue: '', + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual(''); + }); + + test('does not accept value that would cause overflow', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '15', + incomingValue: '150', + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('15'); + }); + + test('does not accept value that would cause overflow with leading 0', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '05', + incomingValue: '050', + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('05'); + }); + + test('accepts a value between defaultMin and defaultMax', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '1', + incomingValue: '34', + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 35, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('34'); + }); + + test('accepts defaultMax', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '1', + incomingValue: '35', + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 35, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('35'); + }); + + test('accepts defaultMin', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '2', + incomingValue: '1', + charsPerSegment: 2, + defaultMin: 1, + defaultMax: 35, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('1'); + }); + + test('does not accept a value greater than defaultMax', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '1', + incomingValue: '36', + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 35, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('6'); + }); + + describe('when current value is 0', () => { + test('rejects additional 0 as input when min value is not 0', () => { const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, + segmentName: 'day', currentValue: '0', - incomingValue: `0${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], + incomingValue: `00`, + charsPerSegment: 2, + defaultMin: 1, + defaultMax: 15, segmentEnum: segmentObj, - shouldSkipValidation: segment === 'year', }); - expect(newValue).toEqual(`0${i}`); + expect(newValue).toEqual(`0`); }); - test('value can be deleted', () => { + + test('accepts 00 as input when min value is 0', () => { const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, + segmentName: 'day', currentValue: '0', - incomingValue: ``, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], + incomingValue: `00`, + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, segmentEnum: segmentObj, - shouldSkipValidation: segment === 'year', }); - expect(newValue).toEqual(``); + expect(newValue).toEqual(`00`); }); - }); - describe('when current value is 1', () => { - test('value can be deleted', () => { + test('accepts 00 as input when shouldSkipValidation is true and value is less than defaultMin', () => { const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '1', - incomingValue: ``, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], + segmentName: 'day', + currentValue: '0', + incomingValue: `00`, + charsPerSegment: 2, + defaultMin: 1, + defaultMax: 15, segmentEnum: segmentObj, + shouldSkipValidation: true, }); - expect(newValue).toEqual(``); + expect(newValue).toEqual(`00`); }); - - if (segment === 'month') { - test.each(range(0, 3))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '1', - incomingValue: `1${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(`1${i}`); - }); - describe.each(range(3, 10))('rejects 1%i', i => { - test(`and sets input "${i}"`, () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '1', - incomingValue: `1${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(`${i}`); - }); - }); - } else { - test.each(range(10))('accepts 1%i as input', i => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '1', - incomingValue: `1${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - shouldSkipValidation: segment === 'year', - }); - expect(newValue).toEqual(`1${i}`); - }); - } }); + }); - describe('when current value is 3', () => { - test('value can be deleted', () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '3', - incomingValue: ``, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(``); + describe('multi-character segments (4 digits)', () => { + test('accepts valid 4-digit value', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'year', + currentValue: '202', + incomingValue: '2024', + charsPerSegment: 4, + defaultMin: 1970, + defaultMax: 2099, + segmentEnum: segmentObj, }); + expect(newValue).toEqual('2024'); + }); - switch (segment) { - case 'day': { - test.each(range(0, 2))('accepts 3%i as input', i => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '3', - incomingValue: `3${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(`3${i}`); - }); - describe.each(range(3, 10))('rejects 3%i', i => { - test(`and sets input to ${i}`, () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '3', - incomingValue: `3${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(`${i}`); - }); - }); - break; - } - - case 'month': { - describe.each(range(10))('rejects 3%i', i => { - test(`and sets input "${i}"`, () => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: '3', - incomingValue: `3${i}`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(`${i}`); - }); - }); - break; - } - - default: - break; - } + test('prevents overflow on 4-digit segment', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'year', + currentValue: '2024', + incomingValue: '20245', + charsPerSegment: 4, + defaultMin: 1970, + defaultMax: 2099, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual('2024'); }); - describe('when current value is a full formatted value', () => { - const formatter = getValueFormatter({ - charsPerSegment: charsPerSegment[segment], + test('truncates from start when shouldSkipValidation is true and value exceeds charsPerSegment', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'year', + currentValue: '000', + incomingValue: '00001', + charsPerSegment: 4, + defaultMin: 1970, + defaultMax: 2099, + segmentEnum: segmentObj, + shouldSkipValidation: true, }); - const testValues = [defaultMin[segment], defaultMax[segment]].map( - formatter, - ); - test.each(testValues)( - 'when current value is %p, rejects additional input', - val => { - const newValue = getNewSegmentValueFromInputValue({ - segmentName: segment, - currentValue: val, - incomingValue: `${val}1`, - charsPerSegment: charsPerSegment[segment], - defaultMin: defaultMin[segment], - defaultMax: defaultMax[segment], - segmentEnum: segmentObj, - }); - expect(newValue).toEqual(val); - }, - ); + 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 5d37a5fac1..f8b8398407 100644 --- a/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -32,15 +32,15 @@ interface GetNewSegmentValueFromInputValue< * @param charsPerSegment - The number of characters per segment * @param defaultMin - The default minimum value for the segment * @param defaultMax - The default maximum value for the segment - * @param segmentEnum - The segment object + * @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. * @returns The new value for the segment * @example * // The segmentEnum is the object that contains the segment names and their corresponding values * const segmentEnum = { * Day: 'day', - * Month: 'month', * Year: 'year', + * Minute: 'minute', * }; * * getNewSegmentValueFromInputValue({ @@ -80,6 +80,15 @@ interface GetNewSegmentValueFromInputValue< * segmentEnum, * shouldSkipValidation: true, * }); // '000' + * * * getNewSegmentValueFromInputValue({ + * segmentName: 'minute', + * currentValue: '0', + * incomingValue: '00', + * charsPerSegment: 2, + * defaultMin: 0, + * defaultMax: 59, + * segmentEnum, + * }); // '00' */ export const getNewSegmentValueFromInputValue = < SegmentName extends string, @@ -130,5 +139,6 @@ export const getNewSegmentValueFromInputValue = < const typedChar = last(incomingValue.split('')); const newValue = typedChar === '0' ? '0' : typedChar ?? ''; + return newValue as Value; }; From 3e515d7abff46997fda5a85317424815bd919cef Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 3 Nov 2025 12:27:00 -0500 Subject: [PATCH 10/11] feat(input-box): update validation rules for year segment to allow values from 1970 to 2038 and add custom validation for specific range --- .../isValidValueForSegment/isValidValueForSegment.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts index 4b18195ba2..257e34e976 100644 --- a/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts @@ -64,10 +64,18 @@ interface IsValidValueForSegmentProps< * isValidValueForSegment({ * segment: 'year', * value: '1970', - * defaultMin: 1000, - * defaultMax: 9999, + * defaultMin: 1970, + * defaultMax: 2038, * segmentEnum * }); // true + * isValidValueForSegment({ + * segment: 'year', + * value: '1000', + * defaultMin: 1970, + * defaultMax: 2038, + * segmentEnum, + * customValidation: (value: string) => inRange(Number(value), 1000, 9999 + 1), + * }); // true */ export const isValidValueForSegment = < SegmentName extends string, From aff81dd05e64564319d09e497508c8fdc302a17d Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Wed, 5 Nov 2025 16:37:03 -0500 Subject: [PATCH 11/11] docs(input-box): clarify JSDoc comments for getValueFormatter parameters --- .../src/utils/getValueFormatter/getValueFormatter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts index e396fd44fb..4bffe299ac 100644 --- a/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts +++ b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts @@ -7,9 +7,9 @@ import { isZeroLike } from '@leafygreen-ui/lib'; * otherwise, pad the string with 0s, or trim it to n chars * * @param charsPerSegment - the number of characters per segment - * @param allowsZero - whether to allow zero-like values - * @param val - the value to format + * @param allowZero - whether to allow zero-like values * @returns a value formatter function for the provided segment + * - @param val - the value to format (string, number, or undefined) * * @example * const formatter = getValueFormatter({ charsPerSegment: 2 });