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..535e7096a1 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.spec.ts @@ -0,0 +1,138 @@ +import { createExplicitSegmentValidator } from './createExplicitSegmentValidator'; + +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 }, // any 4-digit year + hour: { maxChars: 2, minExplicitValue: 3 }, + minute: { maxChars: 2, minExplicitValue: 6 }, +}; + +const isExplicitSegmentValue = createExplicitSegmentValidator({ + segmentEnum: 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('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('millisecond', '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..3c0ed0b910 --- /dev/null +++ b/packages/input-box/src/utils/createExplicitSegmentValidator/createExplicitSegmentValidator.ts @@ -0,0 +1,92 @@ +import { + isValidSegmentName, + isValidSegmentValue, +} from '../isValidSegment/isValidSegment'; + +/** + * Configuration for determining if a segment value is an explicit, unique value for a given segment. + */ +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 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 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 + * + * @example + * const segmentObj = { + * 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: + * // 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 (Ambiguous - below min value and max length) + * isExplicitSegmentValue('day', '01'); // true (Explicit - meets max length) + * isExplicitSegmentValue('day', '4'); // true (Explicit - meets min value) + * isExplicitSegmentValue('year', '2000'); // true (Explicit - meets max length) + * isExplicitSegmentValue('year', '1'); // false (Ambiguous - below max length) + * isExplicitSegmentValue('hour', '05'); // true (Explicit - meets min value) + * isExplicitSegmentValue('hour', '23'); // true (Explicit - meets max length) + * isExplicitSegmentValue('hour', '2'); // false (Ambiguous - below min value) + * isExplicitSegmentValue('minute', '07'); // true (Explicit - meets min value) + * isExplicitSegmentValue('minute', '59'); // true (Explicit - meets max length) + * isExplicitSegmentValue('minute', '5'); // false (Ambiguous - below min value) + */ +export function createExplicitSegmentValidator< + SegmentEnum extends Record, +>({ + segmentEnum, + rules, +}: { + segmentEnum: SegmentEnum; + rules: Record; +}) { + return (segment: SegmentEnum[keyof SegmentEnum], 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..32cfee670e --- /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 shouldWrap is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldWrap: false, + }); + expect(result).toBe(2039); + }); + + test('rolls over when shouldWrap is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '12', + key: keyMap.ArrowUp, + min: 1, + max: 12, + shouldWrap: true, + }); + 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 shouldWrap is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldWrap: false, + }); + expect(result).toBe(1969); + }); + + test('rolls over when shouldWrap is true', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1', + key: keyMap.ArrowDown, + min: 1, + max: 31, + shouldWrap: true, + }); + 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, + shouldWrap: false, + }); + 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('shouldWrap behavior', () => { + test('allows exceeding max when shouldWrap is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '2038', + key: keyMap.ArrowUp, + min: 1970, + max: 2038, + shouldWrap: false, + }); + expect(result).toBe(2039); + }); + + test('allows going below min when shouldWrap is false', () => { + const result = getNewSegmentValueFromArrowKeyPress({ + value: '1970', + key: keyMap.ArrowDown, + min: 1970, + max: 2038, + shouldWrap: false, + }); + 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..4e81125d0b --- /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: Value; + key: typeof keyMap.ArrowUp | typeof keyMap.ArrowDown; + min: number; + max: number; + step?: number; + shouldWrap?: 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 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 + * 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, shouldWrap: false }); // 2039 + */ +export const getNewSegmentValueFromArrowKeyPress = ({ + value, + key, + min, + max, + shouldWrap = true, + 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 = shouldWrap + ? rollover(incrementedValue, min, max) + : incrementedValue; + + 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..b6645ed8f2 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.spec.ts @@ -0,0 +1,256 @@ +import range from 'lodash/range'; + +import { getNewSegmentValueFromInputValue } from './getNewSegmentValueFromInputValue'; + +const segmentObj = { + day: 'day', + year: 'year', + minute: 'minute', +}; + +describe('packages/input-box/utils/getNewSegmentValueFromInputValue', () => { + 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}`); + }); + + 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: '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: 'day', + currentValue: '', + incomingValue: `2.`, + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual(''); + }); + + 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'); + }); + }); + + 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: 'day', + currentValue: '0', + incomingValue: `00`, + charsPerSegment: 2, + defaultMin: 1, + defaultMax: 15, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual(`0`); + }); + + test('accepts 00 as input when min value is 0', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '0', + incomingValue: `00`, + charsPerSegment: 2, + defaultMin: 0, + defaultMax: 15, + segmentEnum: segmentObj, + }); + expect(newValue).toEqual(`00`); + }); + + test('accepts 00 as input when shouldSkipValidation is true and value is less than defaultMin', () => { + const newValue = getNewSegmentValueFromInputValue({ + segmentName: 'day', + currentValue: '0', + incomingValue: `00`, + charsPerSegment: 2, + defaultMin: 1, + defaultMax: 15, + segmentEnum: segmentObj, + shouldSkipValidation: true, + }); + expect(newValue).toEqual(`00`); + }); + }); + }); + + 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'); + }); + + 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'); + }); + + 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, + }); + expect(newValue).toEqual('0001'); + }); + }); +}); 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..f8b8398407 --- /dev/null +++ b/packages/input-box/src/utils/getNewSegmentValueFromInputValue/getNewSegmentValueFromInputValue.ts @@ -0,0 +1,144 @@ +import last from 'lodash/last'; + +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. + * + * 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 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', + * Year: 'year', + * Minute: 'minute', + * }; + * + * 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' + * * * getNewSegmentValueFromInputValue({ + * segmentName: 'minute', + * currentValue: '0', + * incomingValue: '00', + * charsPerSegment: 2, + * defaultMin: 0, + * defaultMax: 59, + * segmentEnum, + * }); // '00' + */ +export const getNewSegmentValueFromInputValue = < + SegmentName extends string, + Value extends string, +>({ + segmentName, + currentValue, + incomingValue, + charsPerSegment, + defaultMin, + defaultMax, + segmentEnum, + shouldSkipValidation = false, +}: 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. + 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({ + segment: segmentName, + value: incomingValue, + defaultMin, + defaultMax, + segmentEnum, + }); + + if (isIncomingValueValid || shouldSkipValidation) { + const newValue = truncateStart(incomingValue, { + length: charsPerSegment, + }); + + return newValue as Value; + } + + const typedChar = last(incomingValue.split('')); + const newValue = typedChar === '0' ? '0' : typedChar ?? ''; + + return newValue as Value; +}; 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..fee0cbcbfe --- /dev/null +++ b/packages/input-box/src/utils/getRelativeSegment/getRelativeSegment.ts @@ -0,0 +1,167 @@ +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: Segment; + formatParts?: Array; + }, +): Segment | 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 Segment); + + /** 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< + SegmentRefs extends Record>, +> { + segment: HTMLInputElement | React.RefObject; + formatParts?: Array; + segmentRefs: SegmentRefs; +} + +/** + * 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 = < + SegmentRefs extends Record>, +>( + direction: RelativeDirection, + { segment, formatParts, segmentRefs }: GetRelativeSegmentContext, +): React.RefObject | undefined => { + if ( + isUndefined(direction) || + isUndefined(segment) || + isUndefined(formatParts) || + isUndefined(segmentRefs) + ) { + return; + } + + type SegmentName = keyof SegmentRefs & string; + + // only the relevant segments, not separators + const formatSegments: Array = formatParts + .filter(part => part.type !== 'literal') + .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, { + 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..4bffe299ac --- /dev/null +++ b/packages/input-box/src/utils/getValueFormatter/getValueFormatter.ts @@ -0,0 +1,50 @@ +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 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 }); + * formatter('0'); // '' + * 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, + allowZero = false, + }: { + charsPerSegment: number; + allowZero?: boolean; + }) => + (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..8f22456d15 --- /dev/null +++ b/packages/input-box/src/utils/getValueFormatter/valueFormatter.spec.ts @@ -0,0 +1,114 @@ +import { getValueFormatter } from './getValueFormatter'; + +type Segment = 'one' | 'two' | 'three'; +const charsPerSegment: Record = { + one: 1, + two: 2, + three: 3, +}; + +describe('packages/input-box/utils/valueFormatter', () => { + 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'); + }); + + 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('three segments', () => { + const formatter = getValueFormatter({ + charsPerSegment: charsPerSegment['three'], + }); + + test('formats 4 digit values', () => { + expect(formatter('202')).toEqual('202'); + }); + + test('pads < 3 digit value', () => { + expect(formatter('12')).toEqual('012'); + }); + + test('truncates 4+ digit values', () => { + expect(formatter('1234')).toEqual('234'); + }); + + test('truncates 4+ digit padded values', () => { + expect(formatter('02345')).toEqual('345'); + }); + + test('sets 0 to empty string', () => { + expect(formatter('0')).toEqual(''); + }); + + test('sets undefined to empty string', () => { + 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/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..e8cf8c6503 --- /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 = < + SegmentRefs extends Record>, +>( + element: HTMLElement, + segmentRefs: SegmentRefs, +): 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..1766cc32af --- /dev/null +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.spec.ts @@ -0,0 +1,79 @@ +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('00 with allowZero returns true', () => { + expect(isValidSegmentValue('00', 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..08dcda6d0d --- /dev/null +++ b/packages/input-box/src/utils/isValidSegment/isValidSegment.ts @@ -0,0 +1,53 @@ +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?: SegmentValue, + allowZero = false, +): segment is SegmentValue => + !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 extends Readonly>, +>( + segmentEnum: SegmentEnum, + name?: string, +): name is SegmentEnum[keyof SegmentEnum] => { + return ( + !isUndefined(name) && + 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 new file mode 100644 index 0000000000..a9b3bf0d88 --- /dev/null +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.spec.ts @@ -0,0 +1,76 @@ +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: 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', () => { + 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..257e34e976 --- /dev/null +++ b/packages/input-box/src/utils/isValidValueForSegment/isValidValueForSegment.ts @@ -0,0 +1,102 @@ +import inRange from 'lodash/inRange'; + +import { + isValidSegmentName, + 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 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 + * @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({ + * 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: 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, + Value extends string, +>({ + segment, + value, + defaultMin, + defaultMax, + segmentEnum, + customValidation, +}: IsValidValueForSegmentProps): 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':