diff --git a/CHANGELOG.md b/CHANGELOG.md index c3307d44..b93759e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed - Update `` and `` core components to truncate long strings +- Fix `` core component to trigger `onChange` callback on + / - button click ## [1.0.50] - 2024-10-23 diff --git a/src/core/components/forms/hooks/useNumberMask.test.ts b/src/core/components/forms/hooks/useNumberMask.test.ts index 3c1203b5..cb182704 100644 --- a/src/core/components/forms/hooks/useNumberMask.test.ts +++ b/src/core/components/forms/hooks/useNumberMask.test.ts @@ -12,7 +12,7 @@ describe('useNumberMask hook', () => { const formatNumberMock = jest.spyOn(formatterUtils, 'formatNumber'); beforeEach(() => { - const maskResult = { setValue: jest.fn() } as unknown as IUseNumberMaskResult; + const maskResult = { setUnmaskedValue: jest.fn() } as unknown as IUseNumberMaskResult; maskMock.mockReturnValue(maskResult); }); @@ -22,7 +22,7 @@ describe('useNumberMask hook', () => { }); it('returns the result of the useIMask hook', () => { - const maskResult = { setValue: jest.fn(), setUnmaskedValue: jest.fn() } as unknown as IUseNumberMaskResult; + const maskResult = { setUnmaskedValue: jest.fn() } as unknown as IUseNumberMaskResult; maskMock.mockReturnValue(maskResult); const { result } = renderHook(() => useNumberMask({})); expect(result.current).toEqual(maskResult); @@ -77,18 +77,18 @@ describe('useNumberMask hook', () => { expect(maskMock).toHaveBeenCalledWith(expect.objectContaining({ mask: `num ${suffix}` }), expect.anything()); }); - it('updates the mask value on value property change for controlled inputs', () => { + it('updates the unmasked value on value property change for controlled inputs', () => { const value = '100'; - const setValue = jest.fn(); - const maskResult = { setValue } as unknown as IUseNumberMaskResult; + const setUnmaskedValue = jest.fn(); + const maskResult = { setUnmaskedValue } as unknown as IUseNumberMaskResult; maskMock.mockReturnValue(maskResult); const { rerender } = renderHook((props) => useNumberMask(props), { initialProps: { value } }); - expect(setValue).toHaveBeenCalledWith(value); + expect(setUnmaskedValue).toHaveBeenCalledWith(value); - const newValue = '101'; - rerender({ value: newValue }); - expect(setValue).toHaveBeenCalledWith(newValue); + const unmaskedValue = '101'; + rerender({ value: unmaskedValue }); + expect(setUnmaskedValue).toHaveBeenCalledWith(unmaskedValue); }); it('calls the onChange property with the unmasked value when value is valid', () => { diff --git a/src/core/components/forms/hooks/useNumberMask.ts b/src/core/components/forms/hooks/useNumberMask.ts index 51dd7188..0f3cf3cf 100644 --- a/src/core/components/forms/hooks/useNumberMask.ts +++ b/src/core/components/forms/hooks/useNumberMask.ts @@ -62,13 +62,13 @@ export const useNumberMask = (props: IUseNumberMaskProps): IUseNumberMaskResult { onAccept: handleMaskAccept }, ); - const { setValue } = result; + const { setUnmaskedValue } = result; // Update the masked value on value property change useEffect(() => { const parsedValue = value?.toString() ?? ''; - setValue(parsedValue); - }, [setValue, value]); + setUnmaskedValue(parsedValue); + }, [setUnmaskedValue, value]); return result; }; diff --git a/src/core/components/forms/inputNumber/inputNumber.stories.tsx b/src/core/components/forms/inputNumber/inputNumber.stories.tsx index 4164fcf3..a312a226 100644 --- a/src/core/components/forms/inputNumber/inputNumber.stories.tsx +++ b/src/core/components/forms/inputNumber/inputNumber.stories.tsx @@ -35,4 +35,15 @@ export const Controlled: Story = { }, }; +/** + * Usage example with min and max values. + */ +export const MinMax: Story = { + args: { + min: 10, + max: 20, + placeholder: '0', + }, +}; + export default meta; diff --git a/src/core/components/forms/inputNumber/inputNumber.test.tsx b/src/core/components/forms/inputNumber/inputNumber.test.tsx index 608603e6..6fe1c0bd 100644 --- a/src/core/components/forms/inputNumber/inputNumber.test.tsx +++ b/src/core/components/forms/inputNumber/inputNumber.test.tsx @@ -30,15 +30,17 @@ describe(' component', () => { }) => { const { props, expectedValue, type } = values ?? {}; const user = userEvent.setup(); + const setUnmaskedValue = jest.fn(); + const onChange = jest.fn(); const hookResult = { setUnmaskedValue, - unmaskedValue: props?.value, + unmaskedValue: props?.value ?? '', } as unknown as InputHooks.IUseNumberMaskResult; useNumberMaskMock.mockReturnValue(hookResult); - render(createTestComponent({ ...props })); + render(createTestComponent({ ...props, onChange })); const [decrementButton, incrementButton] = screen.getAllByRole('button'); @@ -49,11 +51,11 @@ describe(' component', () => { } expect(setUnmaskedValue).toHaveBeenCalledWith(expectedValue); + expect(onChange).toHaveBeenCalledWith(expectedValue); }; it('renders an input with increment and decrement buttons', () => { render(createTestComponent()); - expect(screen.getByRole('textbox')).toBeInTheDocument(); expect(screen.getAllByRole('button').length).toEqual(2); expect(screen.getByTestId(IconType.PLUS)).toBeInTheDocument(); @@ -62,29 +64,28 @@ describe(' component', () => { it('renders a disabled input with no spin buttons when disabled is set to true', () => { render(createTestComponent({ disabled: true })); - expect(screen.getByRole('textbox')).toBeDisabled(); expect(screen.queryAllByRole('button').length).toEqual(0); }); - it('should default step to 1 when given value less than zero', () => { + it('defaults step to 1 when given value less than zero', () => { const step = -15; render(createTestComponent({ step })); expect(screen.getByRole('textbox')).toHaveAttribute('step', '1'); }); - it('should default step to 1 when given value is zero', () => { + it('defaults step to 1 when given value is zero', () => { const step = 0; render(createTestComponent({ step })); expect(screen.getByRole('textbox')).toHaveAttribute('step', '1'); }); describe('increment button', () => { - it('should increment by one (1) with default parameters', async () => { + it('increments by 1 with default parameters', async () => { await testChangeValueLogic({ type: 'increment', expectedValue: '1' }); }); - it('should return the maximum when the newly generated value exceeds the maximum', async () => { + it('returns max value when new value is greater than max value', async () => { const max = 5; const step = 2; const value = '4'; @@ -92,49 +93,60 @@ describe(' component', () => { await testChangeValueLogic({ type: 'increment', props, expectedValue: max.toString() }); }); - it('should increment by floating point value when the step is a float', async () => { + it('increments by floating point value when the step is a float', async () => { const value = '1'; const step = 0.5; const props = { step, value }; await testChangeValueLogic({ type: 'increment', props, expectedValue: (Number(value) + step).toString() }); }); - it('should round down to the nearest multiple of the step before incrementing by the step value', async () => { + it('increments by provided step', async () => { + const value = '10'; + const step = 2; + const props = { value, step }; + await testChangeValueLogic({ type: 'increment', props, expectedValue: '12' }); + }); + + it('rounds down to the nearest multiple of the step before incrementing by the step value', async () => { const value = '1'; const step = 0.3; const props = { value, step }; await testChangeValueLogic({ type: 'increment', props, expectedValue: '1.2' }); }); - - it('should increment to the minimum when no value is provided', async () => { - const step = 6; - const min = 5; - const max = 10; - const props = { step, min, max }; - await testChangeValueLogic({ type: 'increment', props, expectedValue: min.toString() }); - }); }); describe('decrement button', () => { - it('should decrement by step', async () => { - const value = '10'; - const step = 2; - const props = { value, step }; - await testChangeValueLogic({ type: 'decrement', props, expectedValue: (10 - 2).toString() }); + it('decrements by 1 with default parameters', async () => { + await testChangeValueLogic({ type: 'decrement', expectedValue: '-1' }); }); - it('should decrement to the minimum when no value provided', async () => { - const step = 2; - const min = 1; - const props = { step, min }; + it('returns min value when new value is less than min value', async () => { + const min = 3; + const step = 3; + const value = '5'; + const props = { min, step, value }; await testChangeValueLogic({ type: 'decrement', props, expectedValue: min.toString() }); }); - it('should decrement to the closest multiple of the step smaller than the value', async () => { + it('decrements by floating point value when the step is a float', async () => { + const value = '1'; + const step = 0.5; + const props = { step, value }; + await testChangeValueLogic({ type: 'decrement', props, expectedValue: (Number(value) - step).toString() }); + }); + + it('decrements by provided step', async () => { const value = '10'; - const step = 3; + const step = 2; + const props = { value, step }; + await testChangeValueLogic({ type: 'decrement', props, expectedValue: '8' }); + }); + + it('rounds up to the nearest multiple of the step before decrementing by the step value', async () => { + const value = '1.3'; + const step = 0.3; const props = { value, step }; - await testChangeValueLogic({ type: 'decrement', props, expectedValue: '9' }); + await testChangeValueLogic({ type: 'decrement', props, expectedValue: '1.2' }); }); }); }); diff --git a/src/core/components/forms/inputNumber/inputNumber.tsx b/src/core/components/forms/inputNumber/inputNumber.tsx index dbe29de4..2766e01c 100644 --- a/src/core/components/forms/inputNumber/inputNumber.tsx +++ b/src/core/components/forms/inputNumber/inputNumber.tsx @@ -70,44 +70,26 @@ export const InputNumber = forwardRef((prop const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowUp') { - handleIncrement(); + handleStepChange(1); } else if (e.key === 'ArrowDown') { - handleDecrement(); + handleStepChange(-1); } onKeyDown?.(e); }; - const handleIncrement = () => { - const parsedValue = Number(unmaskedValue ?? 0); + const handleStepChange = (change: number) => { + const parsedValue = Number(unmaskedValue); - // increment directly to the minimum if value is less than the minimum - if (parsedValue < min) { - setUnmaskedValue(min.toString()); - return; - } - - // ensure value is multiple of step - const newValue = (Math.floor(parsedValue / step) + 1) * step; - - // ensure the new value is than the max - setUnmaskedValue(Math.min(max, newValue).toString()); - }; - - const handleDecrement = () => { - const parsedValue = Number(unmaskedValue ?? 0); - - // decrement directly to the maximum if value is greater than the maximum - if (parsedValue > max) { - setUnmaskedValue(max.toString()); - return; - } + // Ensure value is multiple of step + const multipleValue = change > 0 ? Math.floor(parsedValue / step) : Math.ceil(parsedValue / step); + const stepValue = (multipleValue + change) * step; - // ensure value is multiple of step - const newValue = (Math.ceil(parsedValue / step) - 1) * step; + // Ensure value between min and max + const processedValue = Math.min(max, Math.max(min, stepValue)).toString(); - // ensure the new value is than the max - setUnmaskedValue(Math.max(min, newValue).toString()); + setUnmaskedValue(processedValue); + onChange?.(processedValue); }; return ( @@ -116,7 +98,7 @@ export const InputNumber = forwardRef((prop