diff --git a/e2e/components/Slider/Slider-test.avt.e2e.js b/e2e/components/Slider/Slider-test.avt.e2e.js index 3f9cc4d586ef..5cc3467ae7be 100644 --- a/e2e/components/Slider/Slider-test.avt.e2e.js +++ b/e2e/components/Slider/Slider-test.avt.e2e.js @@ -50,6 +50,17 @@ test.describe('Slider @avt', () => { ); }); + test('accessibility-checker two handle slider', async ({ page }) => { + await visitStory(page, { + component: 'Slider', + id: 'components-slider--two-handle-slider', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('Slider-two-handle-slider'); + }); + test('accessibility-checker skeleton', async ({ page }) => { await visitStory(page, { component: 'Slider', @@ -61,6 +72,17 @@ test.describe('Slider @avt', () => { await expect(page).toHaveNoACViolations('Slider-skeleton'); }); + test('accessibility-checker two handle skeleton', async ({ page }) => { + await visitStory(page, { + component: 'Slider', + id: 'components-slider--two-handle-skeleton', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('Slider-two-handle-skeleton'); + }); + test('accessibility-checker slider with layer', async ({ page }) => { await visitStory(page, { component: 'Slider', diff --git a/e2e/components/Slider/Slider-test.e2e.js b/e2e/components/Slider/Slider-test.e2e.js index c7a180f34918..49e8d4086eca 100644 --- a/e2e/components/Slider/Slider-test.e2e.js +++ b/e2e/components/Slider/Slider-test.e2e.js @@ -29,6 +29,14 @@ test.describe('Slider', () => { theme, }); }); + + test('two handle slider @vrt', async ({ page }) => { + await snapshotStory(page, { + component: 'Slider', + id: 'components-slider--two-handle-slider', + theme, + }); + }); }); }); }); diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 8fb6a6a07696..b6f8fd33a77b 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -7288,6 +7288,9 @@ Map { "hideLabel": Object { "type": "bool", }, + "twoHandles": Object { + "type": "bool", + }, }, }, "Stack" => Object { diff --git a/packages/react/src/components/Slider/Slider-test.js b/packages/react/src/components/Slider/Slider-test.js index aef02c459349..0f58440a59fb 100644 --- a/packages/react/src/components/Slider/Slider-test.js +++ b/packages/react/src/components/Slider/Slider-test.js @@ -13,7 +13,15 @@ import { fireEvent, render, screen } from '@testing-library/react'; const prefix = 'cds'; const inputAriaValue = 'slider-input-aria-label-value'; const initialValue = 50; +const initialValueLower = 10; +const initialValueUpper = 90; const defaultSliderValue = 1; +const defaultSliderValueUpper = 3; +const defaultMin = 1; +const defaultMax = 3; +const defaultStep = 1; +const defaultAriaLabelInput = 'Lower bound'; +const defaultAriaLabelInputUpper = 'Upper bound'; const onBlur = jest.fn(); const onChange = jest.fn(); const onClick = jest.fn(); @@ -22,9 +30,9 @@ const onKeyDown = jest.fn(); const renderSlider = ({ value = defaultSliderValue, - min = 1, - max = 3, - step = 1, + min = defaultMin, + max = defaultMax, + step = defaultStep, ...rest } = {}) => render( @@ -40,6 +48,19 @@ const renderSlider = ({ /> ); +const renderTwoHandleSlider = ({ + unstable_valueUpper = defaultSliderValueUpper, + ariaLabelInput = defaultAriaLabelInput, + unstable_ariaLabelInputUpper = defaultAriaLabelInputUpper, + ...rest +} = {}) => + renderSlider({ + unstable_valueUpper, + ariaLabelInput, + unstable_ariaLabelInputUpper, + ...rest, + }); + describe('Slider', () => { beforeEach(() => { jest.clearAllMocks(); @@ -112,8 +133,7 @@ describe('Slider', () => { ); }); - // ArrowRight is not triggering change in slider, possibly due to userEvent bug - it.skip('should change the value upon interacting with the slider', async () => { + it('should change the value upon interacting with the slider', async () => { const { keyboard, click } = userEvent; renderSlider({ onClick, @@ -179,7 +199,7 @@ describe('Slider', () => { expect(onChange).toHaveBeenLastCalledWith({ value: 12 }); }); - it('should check for the invalid class on the input', async () => { + it('should check for auto-correct on the input', async () => { const { type, tab } = userEvent; renderSlider({ ariaLabelInput: inputAriaValue, @@ -191,7 +211,8 @@ describe('Slider', () => { await tab(); // Brings focus to input await type(inputElement, '{selectall}101'); await tab(); // Need to tab away from input for invalid class to be applied - expect(inputElement).toHaveClass(`${prefix}--text-input--invalid`); + expect(inputElement).not.toHaveClass(`${prefix}--text-input--invalid`); + expect(parseInt(inputElement.getAttribute('value'))).toEqual(100); }); it('should apply the given id to the element with role of slider', () => { @@ -331,8 +352,7 @@ describe('Slider', () => { expect(onKeyDown).toHaveBeenCalledTimes(2); }); - // skipping until userEvent fixes bug https://github.com/testing-library/user-event/issues/966 - it.skip('should call onKeyDown and properly handle the stepMultiplier prop', async () => { + it('should call onKeyDown and properly handle the stepMultiplier prop', async () => { const { keyboard, click } = userEvent; renderSlider({ ariaLabelInput: inputAriaValue, @@ -342,7 +362,7 @@ describe('Slider', () => { }); const theSlider = screen.getByRole('slider'); await click(theSlider); - await keyboard('{Shift>}{ArrowRight}{/ArrowRight}{/Shift}'); + await keyboard('{Shift>}{ArrowRight}{/Shift}'); expect(onChange).toHaveBeenLastCalledWith({ value: 11, }); @@ -445,8 +465,7 @@ describe('Slider', () => { }); describe('Key/mouse event processing', () => { - // ArrowRight is not triggering change in slider, possibly due to userEvent bug - it.skip('sets correct state from event with arrow keys', async () => { + it('sets correct state from event with arrow keys', async () => { const { type, click } = userEvent; renderSlider({ onClick, @@ -459,24 +478,24 @@ describe('Slider', () => { await click(theSlider); expect(onClick).toHaveBeenCalledTimes(1); await type(theSlider, '{ArrowRight}'); - expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenLastCalledWith({ - value: 1, + value: 2, }); await type(theSlider, '{ArrowLeft}'); - expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenLastCalledWith({ - value: 0, + value: 1, }); await type(theSlider, '{ArrowUp}'); - expect(onChange).toHaveBeenCalledTimes(4); + expect(onChange).toHaveBeenCalledTimes(3); expect(onChange).toHaveBeenLastCalledWith({ - value: 1, + value: 2, }); await type(theSlider, '{ArrowDown}'); - expect(onChange).toHaveBeenCalledTimes(5); + expect(onChange).toHaveBeenCalledTimes(4); expect(onChange).toHaveBeenLastCalledWith({ - value: 0, + value: 1, }); }); @@ -524,4 +543,585 @@ describe('Slider', () => { }); }); }); + + describe('behaves as expected - Two Handle Slider Component API', () => { + it('should render children as expected', () => { + renderTwoHandleSlider(); + const lowerElems = screen.getAllByLabelText(defaultAriaLabelInput); + expect(lowerElems).toHaveLength(2); + const upperElems = screen.getAllByLabelText(defaultAriaLabelInputUpper); + expect(upperElems).toHaveLength(2); + }); + + it('should apply the expected classes', () => { + const { container } = renderTwoHandleSlider(); + + expect(container.firstChild).toHaveClass(`${prefix}--form-item`); + + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + [lowerThumb, upperThumb].forEach((elem) => { + expect(elem).toHaveClass(`${prefix}--slider__thumb`); + }); + expect(lowerThumb).toHaveClass(`${prefix}--slider__thumb--lower`); + expect(upperThumb).toHaveClass(`${prefix}--slider__thumb--upper`); + + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + [lowerInput, upperInput].forEach((elem) => + expect(elem).toHaveClass( + `${prefix}--text-input`, + `${prefix}--slider-text-input` + ) + ); + expect(lowerInput).toHaveClass(`${prefix}--slider-text-input--lower`); + expect(upperInput).toHaveClass(`${prefix}--slider-text-input--upper`); + }); + + it('should be able to apply a disabled state', () => { + renderTwoHandleSlider({ disabled: true }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + [lowerInput, upperInput].forEach((elem) => expect(elem).toBeDisabled()); + }); + + // @todo depends on what we want for warn in a two handle scenario. + // @see https://github.com/carbon-design-system/carbon/pull/14297#issuecomment-1690593533 + it.todo('should be able to apply a warning state'); + + // @todo depends on what we want for invalid in a two handle scenario. + // @see https://github.com/carbon-design-system/carbon/pull/14297#issuecomment-1690593533 + it.todo('should be able to apply a invalid state'); + + it('should be able to set value via props', () => { + renderTwoHandleSlider({ + ariaLabelInput: 'Lower bound', + unstable_ariaLabelInputUpper: 'Upper bound', + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + }); + const lowerInput = screen.getByLabelText(/lower bound/i, { + selector: 'input', + }); + const lowerSlider = screen.getByLabelText(/lower bound/i, { + selector: '[role=slider]', + }); + const upperInput = screen.getByLabelText(/upper bound/i, { + selector: 'input', + }); + const upperSlider = screen.getByLabelText(/upper bound/i, { + selector: '[role=slider]', + }); + + expect(parseInt(lowerInput.getAttribute('value'))).toEqual( + initialValueLower + ); + expect(parseInt(lowerSlider.getAttribute('aria-valuenow'))).toEqual( + initialValueLower + ); + expect(parseInt(upperInput.getAttribute('value'))).toEqual( + initialValueUpper + ); + expect(parseInt(upperSlider.getAttribute('aria-valuenow'))).toEqual( + initialValueUpper + ); + }); + + it('should change the value upon interacting with the slider', async () => { + const { keyboard, click } = userEvent; + renderTwoHandleSlider({ + onClick, + onChange, + value: 10, + unstable_valueUpper: 90, + min: 0, + max: 100, + }); + + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + // Keyboard interactions on the lower thumb. + await click(lowerThumb); + expect(lowerThumb).toHaveFocus(); + expect(onClick).toHaveBeenCalledTimes(1); + await keyboard('{ArrowRight}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 11, valueUpper: 90 }); + expect(lowerThumb).toHaveAttribute('aria-valuenow', '11'); + expect(upperThumb).toHaveAttribute('aria-valuemin', '11'); + expect(lowerInput).toHaveValue(11); + await keyboard('{ArrowLeft}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 10, valueUpper: 90 }); + expect(lowerThumb).toHaveAttribute('aria-valuenow', '10'); + expect(upperThumb).toHaveAttribute('aria-valuemin', '10'); + expect(lowerInput).toHaveValue(10); + await keyboard('{Shift>}{ArrowRight}{/Shift}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 14, valueUpper: 90 }); + expect(lowerThumb).toHaveAttribute('aria-valuenow', '14'); + expect(upperThumb).toHaveAttribute('aria-valuemin', '14'); + expect(lowerInput).toHaveValue(14); + await keyboard('{Shift>}{ArrowLeft}{/Shift}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 10, valueUpper: 90 }); + expect(lowerThumb).toHaveAttribute('aria-valuenow', '10'); + expect(upperThumb).toHaveAttribute('aria-valuemin', '10'); + expect(lowerInput).toHaveValue(10); + + // Keyboard interactions on the upper thumb, lets mix it up and do the up + // and down arrow keys this time. + await click(upperThumb); + expect(upperThumb).toHaveFocus(); + await keyboard('{ArrowUp}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 10, valueUpper: 91 }); + expect(upperThumb).toHaveAttribute('aria-valuenow', '91'); + expect(lowerThumb).toHaveAttribute('aria-valuemax', '91'); + expect(upperInput).toHaveValue(91); + await keyboard('{ArrowDown}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 10, valueUpper: 90 }); + expect(upperThumb).toHaveAttribute('aria-valuenow', '90'); + expect(lowerThumb).toHaveAttribute('aria-valuemax', '90'); + expect(upperInput).toHaveValue(90); + await keyboard('{Shift>}{ArrowUp}{/Shift}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 10, valueUpper: 94 }); + expect(upperThumb).toHaveAttribute('aria-valuenow', '94'); + expect(lowerThumb).toHaveAttribute('aria-valuemax', '94'); + expect(upperInput).toHaveValue(94); + await keyboard('{Shift>}{ArrowDown}{/Shift}'); + expect(onChange).toHaveBeenLastCalledWith({ value: 10, valueUpper: 90 }); + expect(upperThumb).toHaveAttribute('aria-valuenow', '90'); + expect(lowerThumb).toHaveAttribute('aria-valuemax', '90'); + expect(upperInput).toHaveValue(90); + }); + + it('should accurately position handles on mount', () => { + renderTwoHandleSlider({ + value: 50, + unstable_valueUpper: 50, + min: 0, + max: 100, + }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + expect(lowerThumb).toHaveStyle({ insetInlineStart: '50%' }); + expect(upperThumb).toHaveStyle({ insetInlineStart: '50%' }); + }); + + it('marks input field as hidden if hidden via props', () => { + const { container } = renderTwoHandleSlider({ + hideTextInput: true, + }); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + const [lowerInput, upperInput] = container.querySelectorAll( + `.${prefix}--text-input.${prefix}--slider-text-input` + ); + expect(lowerInput).toHaveAttribute('type', 'hidden'); + expect(upperInput).toHaveAttribute('type', 'hidden'); + }); + + it('allows user to set invalid value when typing in input field', async () => { + const { type } = userEvent; + renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange, + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const [lowerThumb] = screen.getAllByRole('slider'); + + await userEvent.clear(lowerInput); + await type(lowerInput, '999'); + expect(parseInt(lowerThumb.getAttribute('aria-valuenow'))).toEqual(999); + expect(onChange).toHaveBeenLastCalledWith({ + value: 999, + valueUpper: initialValueUpper, + }); + }); + + it('sets correct state when typing a valid value in input field', async () => { + const { type, clear } = userEvent; + renderTwoHandleSlider({ + value: initialValue, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange, + }); + + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + + await clear(lowerInput); + await type(lowerInput, '12'); + expect(onChange).toHaveBeenLastCalledWith({ + value: 12, + valueUpper: initialValueUpper, + }); + + await clear(upperInput); + await type(upperInput, '60'); + expect(onChange).toHaveBeenLastCalledWith({ value: 12, valueUpper: 60 }); + }); + + it('should check for auto-correct on the input', async () => { + const { type, tab, keyboard, clear } = userEvent; + renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange, + }); + + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + + // Test the lower input by tabbing away to trigger autocorrect. + await clear(lowerInput); + await type(lowerInput, '999'); + await tab(); + expect(lowerThumb).toHaveAttribute('aria-valuenow', '90'); + expect(onChange).toHaveBeenLastCalledWith({ + value: 90, + valueUpper: initialValueUpper, + }); + expect(lowerInput).not.toHaveClass(`${prefix}--text-input--invalid`); + expect(lowerInput).toHaveValue(90); + + // Test the upper input by hitting Enter to trigger autocorrect. + await clear(upperInput); + await type(upperInput, '999'); + await keyboard('{Enter}'); + expect(upperThumb).toHaveAttribute('aria-valuenow', '100'); + expect(onChange).toHaveBeenLastCalledWith({ + value: 90, + valueUpper: 100, + }); + expect(upperInput).not.toHaveClass(`${prefix}--text-input--invalid`); + expect(upperInput).toHaveValue(100); + }); + + it('should not apply the given id to the elements with role of slider', () => { + const testId = 'slider-test-custom-id'; + renderTwoHandleSlider({ id: testId }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + expect(lowerThumb).not.toHaveAttribute('id'); + expect(upperThumb).not.toHaveAttribute('id'); + }); + + it('should apply a custom input type', () => { + const customInputType = 'text'; + renderTwoHandleSlider({ + inputType: customInputType, + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + expect(lowerInput).toHaveAttribute('type', customInputType); + expect(upperInput).toHaveAttribute('type', customInputType); + }); + + it('should apply a custom input name', () => { + const customInputNameLower = 'myLowerBound'; + const customInputNameUpper = 'myUpperBound'; + renderTwoHandleSlider({ + name: customInputNameLower, + unstable_nameUpper: customInputNameUpper, + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + expect(lowerInput).toHaveAttribute('name', customInputNameLower); + expect(upperInput).toHaveAttribute('name', customInputNameUpper); + }); + + it('should mark an empty input as invalid when using the required prop', async () => { + const { tab, clear } = userEvent; + renderTwoHandleSlider({ + required: true, + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + + expect(lowerInput).toBeRequired(); + expect(upperInput).toBeRequired(); + await clear(lowerInput); + await tab(); + expect(lowerInput).toHaveClass(`${prefix}--text-input--invalid`); + }); + + it('should respect readOnly prop', async () => { + const { click, keyboard, type } = userEvent; + renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + onClick, + onChange, + readOnly: true, + }); + + // Click events should fire + const [lowerThumb] = screen.getAllByRole('slider'); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + + await click(lowerThumb); + await keyboard('{ArrowRight}'); + expect(lowerThumb).toHaveFocus(); + expect(lowerInput).toHaveValue(initialValueLower); + expect(lowerThumb).toHaveAttribute( + 'aria-valuenow', + `${initialValueLower}` + ); + await type(lowerInput, '{selectall}20'); + expect(onChange).not.toHaveBeenCalled(); + }); + + describe('Error handling, expected behavior from event handlers', () => { + it('handles non-number typed into input field', async () => { + const { type, tab } = userEvent; + renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange, + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + await type(lowerInput, '{Space}'); + await tab(); // Brings focus out of input + expect(onChange).not.toHaveBeenCalled(); + await type(upperInput, '{Space}'); + await tab(); // Brings focus out of input + expect(onChange).not.toHaveBeenCalled(); + }); + + it('gracefully tolerates empty event passed to _onDrag', () => { + const { mouseDown, mouseUp, mouseMove } = fireEvent; + const { container } = renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange, + }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + mouseDown(lowerThumb, { clientX: 0 }); + mouseMove(container.firstChild, { clientX: 0 }); + mouseUp(lowerThumb); + expect(onChange).not.toHaveBeenCalled(); + mouseDown(upperThumb, { clientX: 0 }); + mouseMove(container.firstChild, { clientX: 0 }); + mouseUp(upperThumb); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should call onBlur as expected', async () => { + const { type, tab } = userEvent; + renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onBlur, + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + await type(lowerInput, '{Space}'); + await tab(); // Brings focus out of input + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + it('should call onKeyDown as expected', async () => { + const { click, keyboard } = userEvent; + renderTwoHandleSlider({ + onKeyDown, + }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + await click(lowerThumb); + await keyboard('{ArrowRight}'); + await keyboard('{ArrowRight}'); + await click(upperThumb); + await keyboard('{ArrowLeft}'); + await keyboard('{ArrowLeft}'); + expect(onKeyDown).toHaveBeenCalledTimes(4); + }); + + it('should gracefully handle non-numeric keys', async () => { + const { type } = userEvent; + renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange, + inputType: 'text', + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + await type(lowerInput, '{selectall}a'); + expect(onChange).not.toHaveBeenCalled(); + await type(upperInput, '{selectall}a'); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('Disabled state', () => { + it('should do nothing when trying to type in the input', async () => { + const { type } = userEvent; + renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange: onChange, + disabled: true, + }); + const lowerInput = screen.getByLabelText(defaultAriaLabelInput, { + selector: 'input', + }); + const upperInput = screen.getByLabelText(defaultAriaLabelInputUpper, { + selector: 'input', + }); + await type(lowerInput, '1'); + expect(onChange).not.toHaveBeenCalled(); + await type(upperInput, '99'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should do nothing when trying to drag', () => { + const { mouseDown, mouseMove, mouseUp } = fireEvent; + const { container } = renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange: onChange, + disabled: true, + }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + mouseDown(lowerThumb, { clientX: 0 }); + mouseMove(container.firstChild, { clientX: 0 }); + mouseUp(lowerThumb); + mouseDown(upperThumb, { clientX: 0 }); + mouseMove(container.firstChild, { clientX: 0 }); + mouseUp(upperThumb); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should not change slider value when using arrow key', async () => { + const { click, type } = userEvent; + renderTwoHandleSlider({ disabled: true }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + await click(lowerThumb); + await type(lowerThumb, '{ArrowRight}'); + expect(lowerThumb).toHaveAttribute( + 'aria-valuenow', + `${defaultSliderValue}` + ); + await click(upperThumb); + await type(upperThumb, '{ArrowLeft}'); + expect(upperThumb).toHaveAttribute( + 'aria-valuenow', + `${defaultSliderValueUpper}` + ); + }); + }); + + describe('Key/mouse event processing', () => { + it('sets correct state from event with a clientX in a mousemove', () => { + const { mouseDown, mouseUp, mouseMove } = fireEvent; + const { container } = renderTwoHandleSlider({ + value: initialValueLower, + unstable_valueUpper: initialValueUpper, + min: 0, + max: 100, + onChange, + }); + const [lowerThumb] = screen.getAllByRole('slider'); + mouseDown(lowerThumb, { clientX: 100 }); + mouseMove(container.firstChild, { clientX: 1000 }); + mouseUp(lowerThumb); + expect(onChange).toHaveBeenLastCalledWith({ + value: 90, + valueUpper: initialValueUpper, + }); + }); + + it('should call release', () => { + const { mouseDown, mouseUp, mouseMove } = fireEvent; + const { container } = renderTwoHandleSlider({ + onRelease, + }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + mouseDown(lowerThumb, { clientX: 10 }); + mouseMove(container.firstChild, { clientX: 1000 }); + mouseUp(lowerThumb); + expect(onRelease).toHaveBeenCalledTimes(1); + mouseDown(upperThumb, { clientX: 10 }); + mouseMove(container.firstChild, { clientX: 1000 }); + mouseUp(upperThumb); + expect(onRelease).toHaveBeenCalledTimes(2); + }); + + it('should not call onRelease', () => { + const { mouseDown, mouseMove } = fireEvent; + const { container } = renderTwoHandleSlider({ + onRelease, + }); + const [lowerThumb, upperThumb] = screen.getAllByRole('slider'); + mouseDown(lowerThumb, { clientX: 10 }); + mouseMove(container.firstChild, { clientX: 1000 }); + expect(onRelease).not.toHaveBeenCalled(); + + mouseDown(upperThumb, { clientX: 10 }); + mouseMove(container.firstChild, { clientX: 1000 }); + expect(onRelease).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/react/src/components/Slider/Slider.Skeleton.tsx b/packages/react/src/components/Slider/Slider.Skeleton.tsx index 6378063e86ff..d3fad4cc19a7 100644 --- a/packages/react/src/components/Slider/Slider.Skeleton.tsx +++ b/packages/react/src/components/Slider/Slider.Skeleton.tsx @@ -9,6 +9,7 @@ import PropTypes from 'prop-types'; import React, { HTMLAttributes } from 'react'; import cx from 'classnames'; import { usePrefix } from '../../internal/usePrefix'; +import classNames from 'classnames'; export interface SliderSkeletonProps extends HTMLAttributes { /** @@ -20,25 +21,45 @@ export interface SliderSkeletonProps extends HTMLAttributes { * Specify whether the label should be hidden, or not */ hideLabel?: boolean; + + /** + * Turn the slider into a range slider. + */ + twoHandles?: boolean; } const SliderSkeleton = ({ hideLabel, className, + twoHandles, ...rest }: SliderSkeletonProps) => { const prefix = usePrefix(); + const containerClasses = classNames( + `${prefix}--slider-container`, + `${prefix}--skeleton`, + { + [`${prefix}--slider-container--two-handles`]: twoHandles, + } + ); + const lowerThumbClasses = classNames(`${prefix}--slider__thumb`, { + [`${prefix}--slider__thumb--lower`]: twoHandles, + }); + const upperThumbClasses = classNames(`${prefix}--slider__thumb`, { + [`${prefix}--slider__thumb--upper`]: twoHandles, + }); return (
{!hideLabel && ( )} -
+
-
+
+ {twoHandles ?
: undefined}
@@ -56,6 +77,11 @@ SliderSkeleton.propTypes = { * Specify whether the label should be hidden, or not */ hideLabel: PropTypes.bool, + + /** + * Turn the slider into a range slider. + */ + twoHandles: PropTypes.bool, }; export default SliderSkeleton; diff --git a/packages/react/src/components/Slider/Slider.mdx b/packages/react/src/components/Slider/Slider.mdx index 2e75db02d356..cfae9262dd34 100644 --- a/packages/react/src/components/Slider/Slider.mdx +++ b/packages/react/src/components/Slider/Slider.mdx @@ -36,6 +36,14 @@ input must adhere to the step value. For example, if the `step` is `5`, with a range of values from `0-100`, `40` would be valid, and `42` would be considered `invalid`. +### `unstable_valueUpper` Prop + +The Slider component can support two handles to specify a range. To enable this +behavior pass a value for the `unstable_valueUpper` prop. This sets the upper +bound of the range in addition to the `value` prop, which specifies the lower +bound. When in two handle mode, both `value` and `valueUpper` are provided as +keys to `onChange` function prop that is passed to the component. + diff --git a/packages/react/src/components/Slider/Slider.stories.js b/packages/react/src/components/Slider/Slider.stories.js index 147e770ec8fd..c1ec2c749f39 100644 --- a/packages/react/src/components/Slider/Slider.stories.js +++ b/packages/react/src/components/Slider/Slider.stories.js @@ -28,13 +28,14 @@ export default { export const Default = () => ( ); @@ -94,8 +95,25 @@ export const ControlledSliderWithLayer = () => { ); }; +export const TwoHandleSlider = () => ( + +); + export const Skeleton = () => ; +export const TwoHandleSkeleton = () => ; + export const Playground = (args) => ( ( ); Playground.argTypes = { + ariaLabelInput: { + control: { type: 'text' }, + }, + unstable_ariaLabelInputUpper: { + control: { type: 'text' }, + }, light: { table: { disable: true, @@ -157,6 +181,12 @@ Playground.argTypes = { max: { control: { type: 'number' }, }, + name: { + control: { type: 'text' }, + }, + unstable_nameUpper: { + control: { type: 'text' }, + }, readOnly: { control: { type: 'boolean', @@ -176,6 +206,9 @@ Playground.argTypes = { value: { control: { type: 'number' }, }, + unstable_valueUpper: { + control: { type: 'number' }, + }, onBlur: { table: { disable: true, @@ -209,6 +242,8 @@ Playground.argTypes = { }; Playground.args = { + ariaLabelInput: 'Lower bound', + unstable_ariaLabelInputUpper: 'Upper bound', disabled: false, hideTextInput: false, invalid: false, @@ -220,6 +255,7 @@ Playground.args = { step: 5, stepMultiplier: 5, value: 50, + unstable_valueUpper: undefined, warn: false, warnText: 'Warning message goes here', }; diff --git a/packages/react/src/components/Slider/Slider.tsx b/packages/react/src/components/Slider/Slider.tsx index bb5430ff99af..f1007a74874a 100644 --- a/packages/react/src/components/Slider/Slider.tsx +++ b/packages/react/src/components/Slider/Slider.tsx @@ -12,13 +12,93 @@ import classNames from 'classnames'; import throttle from 'lodash.throttle'; import * as keys from '../../internal/keyboard/keys'; -import { matches } from '../../internal/keyboard/match'; +import { matches } from '../../internal/keyboard'; import { PrefixContext } from '../../internal/usePrefix'; import deprecate from '../../prop-types/deprecate'; import { FeatureFlagContext } from '../FeatureFlags'; import { WarningFilled, WarningAltFilled } from '@carbon/icons-react'; import { Text } from '../Text'; +const LowerHandle = () => ( + + {(prefix) => ( + + + + + )} + +); + +const LowerHandleFocus = () => ( + + {(prefix) => ( + + + + + + + + )} + +); + +const UpperHandle = () => ( + + {(prefix) => ( + + + + + )} + +); + +const UpperHandleFocus = () => ( + + {(prefix) => ( + + + + + + + + )} + +); + +const translationIds = { + autoCorrectAnnouncement: 'carbon.slider.auto-correct-announcement', +}; + +function translateWithId( + translationId, + translationState?: { correctedValue?: string } +) { + if ( + translationId === translationIds.autoCorrectAnnouncement && + translationState?.correctedValue + ) { + const { correctedValue } = translationState; + return `The inputted value "${correctedValue}" was corrected to the nearest allowed digit.`; + } + return ''; +} + const defaultFormatLabel = (value, label) => { return typeof label === 'function' ? label(value) : `${value}${label}`; }; @@ -38,6 +118,14 @@ const DRAG_EVENT_TYPES = new Set(['mousemove', 'touchmove']); */ const DRAG_STOP_EVENT_TYPES = new Set(['mouseup', 'touchend', 'touchcancel']); +/** + * Distinguish two handles by lower and upper positions. + */ +enum HandlePosition { + LOWER = 'lower', + UPPER = 'upper', +} + type ExcludedAttributes = 'onChange' | 'onBlur'; export interface SliderProps extends Omit< @@ -49,13 +137,18 @@ export interface SliderProps */ ariaLabelInput?: string; + /** + * The `ariaLabel` for the upper bound `` and handle when there are two handles. + */ + unstable_ariaLabelInputUpper?: string; + /** * The child nodes. */ children?: ReactNodeLike; /** - * The CSS class name for the slider. + * The CSS class name for the slider, set on the wrapping div. */ className?: string; @@ -130,28 +223,41 @@ export interface SliderProps */ name?: string; + /** + * The `name` attribute of the upper bound `` when there are two handles. + */ + unstable_nameUpper?: string; + /** * Provide an optional function to be called when the input element * loses focus */ - onBlur?: (data: { value: string }) => void; + onBlur?: (data: { + value: string; + handlePosition: HandlePosition | undefined; + }) => void; /** * The callback to get notified of change in value. - * `({ value}) => void` - // * @param {{ value }} + * `({ value: number, valueUpper?: number }) => void` */ - onChange?: (data: { value: SliderProps['value'] }) => void; + onChange?: (data: { + value: SliderProps['value']; + valueUpper: SliderProps['unstable_valueUpper']; + }) => void; /** - * Provide an optional function to be called when a key is pressed in the number input + * Provide an optional function to be called when a key is pressed in the number input. When there are two handles, you can obtain the relevant handle position by using `event.target.dataset.handlePosition`. */ onInputKeyUp?: KeyboardEventHandler; /** * The callback to get notified of value on handle release. */ - onRelease?: (data: { value: SliderProps['value'] }) => void; + onRelease?: (data: { + value: SliderProps['value']; + valueUpper: SliderProps['unstable_valueUpper']; + }) => void; /** * Whether the slider should be read-only @@ -164,7 +270,7 @@ export interface SliderProps required?: boolean; /** - * A value determining how much the value should increase/decrease by moving the thumb by mouse. If a value other than 1 is provided and the input is *not* hidden, the new step requirement should be added to a visible label. Values outside of the `step` increment will be considered invalid. + * A value determining how much the value should increase/decrease by moving the thumb by mouse. If a value other than 1 is provided and the input is *not* hidden, the new step requirement should be added to a visible label. Values outside the `step` increment will be considered invalid. */ step?: number; @@ -175,10 +281,26 @@ export interface SliderProps stepMultiplier?: number; /** - * The value. + * Supply a method to translate internal strings with your i18n tool of + * choice. Translation keys are available on the `translationIds` field for + * this component. + */ + translateWithId?: ( + translationId: string, + translationState: { correctedValue?: string } + ) => string; + + /** + * The value of the slider. When there are two handles, value is the lower + * bound. */ value: number; + /** + * The upper bound when there are two handles. + */ + unstable_valueUpper?: number; + /** * Specify whether the control is currently in warning state */ @@ -196,7 +318,13 @@ interface CalcValueProps { useRawValue?: boolean; } -export default class Slider extends PureComponent { +interface CalcLeftPercentProps { + clientX?: number; + value?: number; + range?: number; +} + +class Slider extends PureComponent { static propTypes = { /** * The `ariaLabel` for the ``. @@ -319,7 +447,7 @@ export default class Slider extends PureComponent { required: PropTypes.bool, /** - * A value determining how much the value should increase/decrease by moving the thumb by mouse. If a value other than 1 is provided and the input is *not* hidden, the new step requirement should be added to a visible label. Values outside of the `step` increment will be considered invalid. + * A value determining how much the value should increase/decrease by moving the thumb by mouse. If a value other than 1 is provided and the input is *not* hidden, the new step requirement should be added to a visible label. Values outside the `step` increment will be considered invalid. */ step: PropTypes.number, @@ -330,7 +458,30 @@ export default class Slider extends PureComponent { stepMultiplier: PropTypes.number, /** - * The value. + * Supply a method to translate internal strings with your i18n tool of + * choice. Translation keys are available on the `translationIds` field for + * this component. + */ + translateWithId: PropTypes.func, + + /** + * The `ariaLabel` for the upper bound `` when there are two handles. + */ + unstable_ariaLabelInputUpper: PropTypes.string, + + /** + * The `name` attribute of the upper bound `` when there are two handles. + */ + unstable_nameUpper: PropTypes.string, + + /** + * The upper bound when there are two handles. + */ + unstable_valueUpper: PropTypes.number, + + /** + * The value of the slider. When there are two handles, value is the lower + * bound. */ value: PropTypes.number.isRequired, @@ -340,7 +491,7 @@ export default class Slider extends PureComponent { warn: PropTypes.bool, /** - * Provide the text that is displayed when the Slider is in an warn state + * Provide the text that is displayed when the Slider is in a warn state */ warnText: PropTypes.node, }; @@ -354,19 +505,27 @@ export default class Slider extends PureComponent { maxLabel: '', inputType: 'number', readOnly: false, + translateWithId, }; static contextType = FeatureFlagContext; state = { value: this.props.value, + valueUpper: this.props.unstable_valueUpper, left: 0, + leftUpper: 0, needsOnRelease: false, isValid: true, + isValidUpper: true, + activeHandle: null, + correctedValue: null, + correctedPosition: null, isRtl: false, }; thumbRef: React.RefObject; + thumbRefUpper: React.RefObject; filledTrackRef: React.RefObject; element: HTMLDivElement | null = null; inputId = ''; @@ -375,6 +534,7 @@ export default class Slider extends PureComponent { constructor(props) { super(props); this.thumbRef = React.createRef(); + this.thumbRefUpper = React.createRef(); this.filledTrackRef = React.createRef(); } @@ -383,10 +543,24 @@ export default class Slider extends PureComponent { */ componentDidMount() { if (this.element) { - const { value, left } = this.calcValue({ - useRawValue: true, - }); - this.setState({ value, left, isRtl: document?.dir === 'rtl' }); + const isRtl = document?.dir === 'rtl'; + if (this.hasTwoHandles()) { + const { value, left } = this.calcValue({ + value: this.state.value, + useRawValue: true, + }); + const { value: valueUpper, left: leftUpper } = this.calcValue({ + value: this.state.valueUpper, + useRawValue: true, + }); + this.setState({ isRtl, value, left, valueUpper, leftUpper }); + } else { + const { value, left } = this.calcValue({ + value: this.state.value, + useRawValue: true, + }); + this.setState({ isRtl, value, left }); + } } } @@ -401,31 +575,46 @@ export default class Slider extends PureComponent { componentDidUpdate(prevProps, prevState) { // Fire onChange event handler if present, if there's a usable value, and // if the value is different from the last one - - // Set alternative positioning if direction is 'rtl' - if (this.thumbRef.current) { - if (this.state.isRtl) { - this.thumbRef.current.style.insetInlineStart = `calc(${this.state.left}% - 14px)`; - } else { + if (this.hasTwoHandles()) { + if (this.thumbRef.current) { this.thumbRef.current.style.insetInlineStart = `${this.state.left}%`; + if (this.state.isRtl) { + this.thumbRef.current.style.transform = `translate(100%, -50%)`; + } } - } - if (this.filledTrackRef.current) { - if (this.state.isRtl) { - this.filledTrackRef.current.style.transform = `translate(100%, -50%) scaleX(-${ - this.state.left / 100 - })`; - } else { - this.filledTrackRef.current.style.transform = `translate(0%, -50%) scaleX(${ - this.state.left / 100 - })`; + if (this.thumbRefUpper.current) { + this.thumbRefUpper.current.style.insetInlineStart = `${this.state.leftUpper}%`; + } + if (this.filledTrackRef.current) { + this.filledTrackRef.current.style.transform = this.state.isRtl + ? `translate(${100 - this.state.leftUpper}%, -50%) scaleX(${ + (this.state.leftUpper - this.state.left) / 100 + })` + : `translate(${this.state.left}%, -50%) scaleX(${ + (this.state.leftUpper - this.state.left) / 100 + })`; + } + } else { + if (this.thumbRef.current) { + this.thumbRef.current.style.insetInlineStart = this.state.isRtl + ? `calc(${this.state.left}% - 14px)` + : `${this.state.left}%`; + } + if (this.filledTrackRef.current) { + this.filledTrackRef.current.style.transform = this.state.isRtl + ? `translate(100%, -50%) scaleX(-${this.state.left / 100})` + : `translate(0%, -50%) scaleX(${this.state.left / 100})`; } } if ( - prevState.value !== this.state.value && + (prevState.value !== this.state.value || + prevState.valueUpper !== this.state.valueUpper) && typeof this.props.onChange === 'function' ) { - this.props.onChange({ value: this.state.value }); + this.props.onChange({ + value: this.state.value, + valueUpper: this.state.valueUpper, + }); } // Fire onRelease event handler if present and if needed @@ -433,7 +622,10 @@ export default class Slider extends PureComponent { this.state.needsOnRelease && typeof this.props.onRelease === 'function' ) { - this.props.onRelease({ value: this.state.value }); + this.props.onRelease({ + value: this.state.value, + valueUpper: this.state.valueUpper, + }); // Reset the flag this.setState({ needsOnRelease: false }); } @@ -442,6 +634,7 @@ export default class Slider extends PureComponent { // Otherwise, do prop -> state sync without "value capping". if ( prevProps.value === this.props.value && + prevProps.unstable_valueUpper === this.props.unstable_valueUpper && prevProps.max === this.props.max && prevProps.min === this.props.min ) { @@ -453,6 +646,18 @@ export default class Slider extends PureComponent { useRawValue: true, }) ); + if (this.props.unstable_valueUpper !== undefined) { + const { value: valueUpper, left: leftUpper } = this.calcValue({ + value: this.props.unstable_valueUpper, + useRawValue: true, + }); + this.setState({ + valueUpper, + leftUpper, + }); + } else { + this.setState({ valueUpper: undefined, leftUpper: undefined }); + } } /** @@ -508,9 +713,29 @@ export default class Slider extends PureComponent { this.element?.ownerDocument.addEventListener(element, this.onDrag); }); + const clientX = this.getClientXFromEvent(evt); + + let activeHandle; + if (this.hasTwoHandles()) { + const distanceToLower = this.calcDistanceToHandle( + HandlePosition.LOWER, + clientX + ); + const distanceToUpper = this.calcDistanceToHandle( + HandlePosition.UPPER, + clientX + ); + if (distanceToLower <= distanceToUpper) { + activeHandle = HandlePosition.LOWER; + } else { + activeHandle = HandlePosition.UPPER; + } + } + this.setState({ activeHandle }); + // Perform first recalculation since we probably didn't click exactly in the - // middle of the thumb - this.onDrag(evt); + // middle of the thumb. + this.onDrag(evt, activeHandle); }; /** @@ -533,8 +758,12 @@ export default class Slider extends PureComponent { this.element?.ownerDocument.removeEventListener(element, this.onDrag); }); - // Set needsOnRelease flag so event fires on next update - this.setState({ needsOnRelease: true, isValid: true }); + // Set needsOnRelease flag so event fires on next update. + this.setState({ + needsOnRelease: true, + isValid: true, + isValidUpper: true, + }); }; /** @@ -542,29 +771,38 @@ export default class Slider extends PureComponent { * accordingly. * * @param {Event} evt The event. + * @param activeHandle + * The first drag event call, we may have an explicit activeHandle value, + * which is to be used before state is used. */ - _onDrag = (evt) => { - // Do nothing if component is disabled or we have no event + _onDrag = (evt, activeHandle: HandlePosition | null = null) => { + activeHandle = activeHandle ?? this.state.activeHandle; + // Do nothing if component is disabled, or we have no event. if (this.props.disabled || this.props.readOnly || !evt) { return; } - let clientX; - if ('clientX' in evt) { - clientX = evt.clientX; - } else if ( - 'touches' in evt && - 0 in evt.touches && - 'clientX' in evt.touches[0] - ) { - clientX = evt.touches[0].clientX; + const clientX = this.getClientXFromEvent(evt); + + const { value, left } = this.calcValue({ + clientX, + value: this.state.value, + }); + // If we're set to two handles, negotiate which drag handle is closest to + // the users' interaction. + if (this.hasTwoHandles() && activeHandle) { + this.setValueLeftForHandle(activeHandle, { + value: this.nearestStepValue(value), + left, + }); } else { - // Do nothing if we have no valid clientX - return; + this.setState({ + value: this.nearestStepValue(value), + left, + isValid: true, + }); } - - const { value, left } = this.calcValue({ clientX }); - this.setState({ value: this.nearestStepValue(value), left, isValid: true }); + this.setState({ correctedValue: null, correctedPosition: null }); }; /** @@ -583,15 +821,15 @@ export default class Slider extends PureComponent { * @param {Event} evt The event. */ onKeyDown = (evt) => { - // Do nothing if component is disabled or we don't have a valid event - if (this.props.disabled || this.props.readOnly || !('which' in evt)) { + // Do nothing if component is disabled, or we don't have a valid event + if (this.props.disabled || this.props.readOnly) { return; } let delta = 0; - if (matches(evt.which, [keys.ArrowDown, keys.ArrowLeft])) { + if (matches(evt, [keys.ArrowDown, keys.ArrowLeft])) { delta = -(this.props.step ?? Slider.defaultProps.step); - } else if (matches(evt.which, [keys.ArrowUp, keys.ArrowRight])) { + } else if (matches(evt, [keys.ArrowUp, keys.ArrowRight])) { delta = this.props.step ?? Slider.defaultProps.step; } else { // Ignore keys we don't want to handle @@ -604,21 +842,34 @@ export default class Slider extends PureComponent { delta *= stepMultiplier ?? Slider.defaultProps.stepMultiplier; } - Math.floor( - this.state.value / (this.props.step ?? Slider.defaultProps.step) - ) * (this.props.step ?? Slider.defaultProps.step); - const { value, left } = this.calcValue({ - // Ensures custom value from `` won't cause skipping next stepping point with right arrow key, - // e.g. Typing 51 in ``, moving focus onto the thumb and the hitting right arrow key should yield 52 instead of 54 - value: - (delta > 0 - ? Math.floor( - this.state.value / (this.props.step ?? Slider.defaultProps.step) - ) * (this.props.step ?? Slider.defaultProps.step) - : this.state.value) + delta, - }); + if (this.hasTwoHandles() && this.state.activeHandle) { + const currentValue = + this.state.activeHandle === HandlePosition.LOWER + ? this.state.value + : this.state.valueUpper; + const { value, left } = this.calcValue({ + value: this.calcValueForDelta(currentValue, delta, this.props.step), + }); + this.setValueLeftForHandle(this.state.activeHandle, { + value: this.nearestStepValue(value), + left, + }); + } else { + const { value, left } = this.calcValue({ + // Ensures custom value from `` won't cause skipping next stepping + // point with right arrow key, e.g. Typing 51 in ``, moving focus + // onto the thumb and the hitting right arrow key should yield 52 instead + // of 54. + value: this.calcValueForDelta(this.state.value, delta, this.props.step), + }); + this.setState({ + value: this.nearestStepValue(value), + left, + isValid: true, + }); + } - this.setState({ value: this.nearestStepValue(value), left, isValid: true }); + this.setState({ correctedValue: null, correctedPosition: null }); }; /** @@ -640,20 +891,40 @@ export default class Slider extends PureComponent { return; } + // Avoid calling calcValue for invalid numbers, but still update the state. + const activeHandle = + evt.target.dataset.handlePosition ?? HandlePosition.LOWER; const targetValue = Number.parseFloat(evt.target.value); - // Avoid calling calcValue for invalid numbers, but still update the state - if (isNaN(targetValue)) { - this.setState({ value: evt.target.value }); + if (this.hasTwoHandles()) { + if (isNaN(targetValue)) { + this.setValueForHandle(activeHandle, evt.target.value); + } else if ( + this.isValidValueForPosition({ + handle: activeHandle, + value: targetValue, + min: this.props.min, + max: this.props.max, + }) + ) { + this.processNewInputValue(evt.target); + } else { + this.setValueForHandle(activeHandle, targetValue); + } } else { - const { value, left } = this.calcValue({ - value: targetValue, - useRawValue: true, - }); - this.setState({ - value, - left, - }); + if (isNaN(targetValue)) { + this.setState({ value: evt.target.value }); + } else if ( + this.isValidValue({ + value: targetValue, + min: this.props.min, + max: this.props.max, + }) + ) { + this.processNewInputValue(evt.target); + } else { + this.setState({ value: targetValue }); + } } }; @@ -669,12 +940,131 @@ export default class Slider extends PureComponent { return; } - // determine validity of input change after clicking out of input - const validity = evt.target.checkValidity(); - const { value } = evt.target; + const { value: targetValue } = evt.target; + + this.processNewInputValue(evt.target); + + this.props.onBlur?.({ + value: targetValue, + handlePosition: evt.target?.dataset?.handlePosition as + | HandlePosition + | undefined, + }); + }; + + onInputKeyDown = (evt) => { + // Do nothing if component is disabled, or we don't have a valid event. + if (this.props.disabled || this.props.readOnly) { + return; + } + + // Do nothing if we have no valid event, target, or value. + if (!evt || !('target' in evt) || typeof evt.target.value !== 'string') { + return; + } + + if (matches(evt, [keys.Enter])) { + this.processNewInputValue(evt.target); + } + }; + processNewInputValue = (input: HTMLInputElement) => { + const targetValue = Number.parseFloat(input.value); + const validity = !isNaN(targetValue); + + // When there are two handles, we'll also have the data-handle-position + // attribute to consider the other value before settling on the validity to + // set. + const handlePosition = input?.dataset?.handlePosition as + | HandlePosition + | undefined; + + if (handlePosition === HandlePosition.LOWER) { + this.setState({ isValid: validity }); + } else if (handlePosition === HandlePosition.UPPER) { + this.setState({ isValidUpper: validity }); + } this.setState({ isValid: validity }); - this.props.onBlur?.({ value }); + + if (validity) { + const adjustedValue = handlePosition + ? this.getAdjustedValueForPosition({ + handle: handlePosition, + value: targetValue, + min: this.props.min, + max: this.props.max, + }) + : this.getAdjustedValue({ + value: targetValue, + min: this.props.min, + max: this.props.max, + }); + + if (adjustedValue !== targetValue) { + this.setState({ + correctedValue: targetValue, + correctedPosition: handlePosition, + }); + } else { + this.setState({ correctedValue: null, correctedPosition: null }); + } + + const { value, left } = this.calcValue({ + value: adjustedValue, + useRawValue: true, + }); + + if (handlePosition) { + this.setValueLeftForHandle(handlePosition, { value, left }); + } else { + this.setState({ + value, + left, + }); + } + } + }; + + calcLeftPercent = ({ clientX, value, range }: CalcLeftPercentProps) => { + const boundingRect = this.element?.getBoundingClientRect?.(); + let width = boundingRect ? boundingRect.right - boundingRect.left : 0; + + // Enforce a minimum width of at least 1 for calculations + if (width <= 0) { + width = 1; + } + + // If a clientX is specified, use it to calculate the leftPercent. If not, + // use the provided value to calculate it instead. + if (clientX) { + const leftOffset = this.state.isRtl + ? (boundingRect?.right ?? 0) - clientX + : clientX - (boundingRect?.left ?? 0); + return leftOffset / width; + } else if (value && range) { + // Prevent NaN calculation if the range is 0. + return range === 0 ? 0 : (value - this.props.min) / range; + } + // We should never end up in this scenario, but in case we do, and to + // re-assure Typescript, return 0. + return 0; + }; + + calcSteppedValuePercent = ({ leftPercent, range }) => { + const totalSteps = range / (this.props.step ?? Slider.defaultProps.step); + + let steppedValue = + Math.round(leftPercent * totalSteps) * + (this.props.step ?? Slider.defaultProps.step); + const steppedPercent = this.clamp(steppedValue / range, 0, 1); + + steppedValue = this.clamp( + steppedValue + this.props.min, + this.props.min, + this.props.max + ); + + return [steppedValue, steppedPercent]; }; /** @@ -696,33 +1086,15 @@ export default class Slider extends PureComponent { * clientX is not provided. * @param {boolean} [params.useRawValue=false] `true` to use the given value as-is. */ - calcValue = ({ clientX, value, useRawValue = false }: CalcValueProps) => { const range = this.props.max - this.props.min; - const boundingRect = this.element?.getBoundingClientRect?.(); - const totalSteps = range / (this.props.step ?? Slider.defaultProps.step); - let width = boundingRect ? boundingRect.right - boundingRect.left : 0; - - // Enforce a minimum width of at least 1 for calculations - if (width <= 0) { - width = 1; - } - // If a clientX is specified, use it to calculate the leftPercent. If not, - // use the provided value or state's value to calculate it instead. - let leftPercent; - if (clientX != null) { - const leftOffset = this.state.isRtl - ? (boundingRect?.right ?? 0) - clientX - : clientX - (boundingRect?.left ?? 0); - leftPercent = leftOffset / width; - } else { - if (value == null) { - value = this.state.value; - } - // prevent NaN calculation if the range is 0 - leftPercent = range === 0 ? 0 : (value - this.props.min) / range; - } + // @todo solve for rtl. + const leftPercent = this.calcLeftPercent({ + clientX, + value, + range, + }); if (useRawValue) { // Adjusts only for min/max of thumb position @@ -732,42 +1104,212 @@ export default class Slider extends PureComponent { }; } - let steppedValue = - Math.round(leftPercent * totalSteps) * - (this.props.step ?? Slider.defaultProps.step); - const steppedPercent = this.clamp(steppedValue / range, 0, 1); - - steppedValue = this.clamp( - steppedValue + this.props.min, - this.props.min, - this.props.max - ); + const [steppedValue, steppedPercent] = this.calcSteppedValuePercent({ + leftPercent, + range, + }); return { value: steppedValue, left: steppedPercent * 100 }; }; - // syncs invalid state and prop - static getDerivedStateFromProps(props, state) { - const { isValid } = state; - // will override state in favor of invalid prop - if (props.invalid === true && isValid === true) { - return { - isValid: false, - }; + calcDistanceToHandle = (handle: HandlePosition, clientX) => { + const handleBoundingRect = this.getHandleBoundingRect(handle); + // x co-ordinate of the midpoint. + const handleX = handleBoundingRect.left + handleBoundingRect.width / 2; + return Math.abs(handleX - clientX); + }; + + /** + * Given the current value, delta and step, calculate the new value. + * + * @param {number} currentValue + * Current value user is moving from. + * @param {number} delta + * Movement from the current value. Can be positive or negative. + * @param {number} step + * A value determining how much the value should increase/decrease by moving + * the thumb by mouse. + */ + calcValueForDelta = ( + currentValue, + delta, + step = Slider.defaultProps.step + ) => { + return ( + (delta > 0 ? Math.floor(currentValue / step) * step : currentValue) + + delta + ); + }; + + /** + * Sets state relevant to the given handle position. + * + * Guards against setting either lower or upper values beyond its counterpart. + */ + setValueLeftForHandle = ( + handle: HandlePosition, + { value: newValue, left: newLeft } + ) => { + const { value, valueUpper, left, leftUpper } = this.state; + if (handle === HandlePosition.LOWER) { + // Don't allow higher than the upper handle. + this.setState({ + value: valueUpper && newValue > valueUpper ? valueUpper : newValue, + left: valueUpper && newValue > valueUpper ? leftUpper : newLeft, + isValid: true, + }); + } else { + this.setState({ + valueUpper: value && newValue < value ? value : newValue, + leftUpper: value && newValue < value ? left : newLeft, + isValidUpper: true, + }); } + }; - if (props.invalid === false && isValid === false) { - return { + setValueForHandle = (handle: HandlePosition, value) => { + if (handle === HandlePosition.LOWER) { + this.setState({ + value, isValid: true, - }; + }); + } else { + this.setState({ + valueUpper: value, + isValidUpper: true, + }); + } + }; + + isValidValueForPosition = ({ handle, value: newValue, min, max }) => { + const { value, valueUpper } = this.state; + + if (!this.isValidValue({ value: newValue, min, max })) { + return false; + } + + if (handle === HandlePosition.LOWER) { + return !valueUpper || newValue <= valueUpper; + } else if (handle === HandlePosition.UPPER) { + return !value || newValue >= value; + } + + return false; + }; + + isValidValue = ({ value, min, max }) => { + return !(value < min || value > max); + }; + + getAdjustedValueForPosition = ({ handle, value: newValue, min, max }) => { + const { value, valueUpper } = this.state; + + newValue = this.getAdjustedValue({ value: newValue, min, max }); + + // Next adjust to the opposite handle. + if (handle === HandlePosition.LOWER && valueUpper) { + newValue = newValue > valueUpper ? valueUpper : newValue; + } else if (handle === HandlePosition.UPPER && value) { + newValue = newValue < value ? value : newValue; + } + return newValue; + }; + + getAdjustedValue = ({ value, min, max }) => { + if (value < min) { + value = min; + } + if (value > max) { + value = max; + } + return value; + }; + + /** + * Get the bounding rect for the requested handles' DOM element. + * + * If the bounding rect is not available, a new, empty DOMRect is returned. + */ + getHandleBoundingRect = (handle: HandlePosition): DOMRect => { + let boundingRect; + if (handle === HandlePosition.LOWER) { + boundingRect = this?.thumbRef?.current?.getBoundingClientRect(); + } else { + boundingRect = this?.thumbRefUpper?.current?.getBoundingClientRect(); + } + return boundingRect ?? new DOMRect(); + }; + + getClientXFromEvent(event: MouseEvent | TouchEvent) { + let clientX; + if ('clientX' in event) { + clientX = event.clientX; + } else if ( + 'touches' in event && + 0 in event.touches && + 'clientX' in event.touches[0] + ) { + clientX = event.touches[0].clientX; } - //if invalid prop is not provided, state will remain the same - return null; + return clientX; + } + + hasTwoHandles() { + return this.state.valueUpper !== undefined; + } + + // syncs invalid state and prop + static getDerivedStateFromProps(props, state) { + const { isValid, isValidUpper } = state; + let derivedState = {}; + + // Will override state in favor of invalid prop + if (props.invalid === true) { + if (isValid === true) { + derivedState = { + ...derivedState, + isValid: false, + }; + } + if (isValid === true) { + derivedState = { + ...derivedState, + isValid: false, + }; + } + if (isValidUpper === true) { + derivedState = { + ...derivedState, + isValidUpper: false, + }; + } + } else if (props.invalid === false) { + if (isValid === false) { + derivedState = { + ...derivedState, + isValid: true, + }; + } + if (isValid === false) { + derivedState = { + ...derivedState, + isValid: true, + }; + } + if (isValidUpper === false) { + derivedState = { + ...derivedState, + isValidUpper: true, + }; + } + } + return Object.entries(derivedState).length > 0 ? derivedState : null; } render() { const { ariaLabelInput, + unstable_ariaLabelInputUpper: ariaLabelInputUpper, className, hideTextInput, id = (this.inputId = @@ -786,17 +1328,39 @@ export default class Slider extends PureComponent { required, disabled, name, + unstable_nameUpper: nameUpper, light, readOnly, warn, warnText, + translateWithId: t = translateWithId, ...other } = this.props; + const twoHandles = this.hasTwoHandles(); delete other.onRelease; delete other.invalid; + delete other.unstable_valueUpper; - const { value, isValid } = this.state; + const { + value, + valueUpper, + isValid, + isValidUpper, + correctedValue, + correctedPosition, + isRtl, + } = this.state; + + const showWarning = + (!readOnly && warn && isValid) || + (typeof correctedValue !== null && + correctedPosition === HandlePosition.LOWER); + const showWarningUpper = + (!readOnly && warn && (twoHandles ? isValidUpper : isValid)) || + (typeof correctedValue !== null && + correctedPosition === + (twoHandles ? HandlePosition.UPPER : HandlePosition.LOWER)); return ( @@ -806,34 +1370,112 @@ export default class Slider extends PureComponent { [`${prefix}--label--disabled`]: disabled, }); - const sliderClasses = classNames( - `${prefix}--slider`, - { [`${prefix}--slider--disabled`]: disabled }, - { [`${prefix}--slider--readonly`]: readOnly } - ); + const containerClasses = classNames(`${prefix}--slider-container`, { + [`${prefix}--slider-container--two-handles`]: twoHandles, + [`${prefix}--slider-container--disabled`]: disabled, + [`${prefix}--slider-container--readonly`]: readOnly, + }); + const sliderClasses = classNames(`${prefix}--slider`, { + [`${prefix}--slider--disabled`]: disabled, + [`${prefix}--slider--readonly`]: readOnly, + }); - const inputClasses = classNames( + const fixedInputClasses = [ `${prefix}--text-input`, `${prefix}--slider-text-input`, + ]; + const conditionalInputClasses = { + [`${prefix}--text-input--light`]: light, + [`${prefix}--slider-text-input--hidden`]: hideTextInput, + }; + const lowerInputClasses = classNames([ + ...fixedInputClasses, + `${prefix}--slider-text-input--lower`, + conditionalInputClasses, + { + [`${prefix}--text-input--invalid`]: !readOnly && !isValid, + [`${prefix}--slider-text-input--warn`]: showWarning, + }, + ]); + const upperInputClasses = classNames([ + ...fixedInputClasses, + `${prefix}--slider-text-input--upper`, + conditionalInputClasses, { - [`${prefix}--text-input--light`]: light, [`${prefix}--text-input--invalid`]: - !readOnly && isValid === false, - [`${prefix}--slider-text-input--hidden`]: hideTextInput, - [`${prefix}--slider-text-input--warn`]: !readOnly && warn, - } - ); + !readOnly && (twoHandles ? !isValidUpper : !isValid), + [`${prefix}--slider-text-input--warn`]: showWarningUpper, + }, + ]); + const lowerInputWrapperClasses = classNames([ + `${prefix}--text-input-wrapper`, + `${prefix}--slider-text-input-wrapper`, + `${prefix}--slider-text-input-wrapper--lower`, + { + [`${prefix}--text-input-wrapper--readonly`]: readOnly, + }, + ]); + const upperInputWrapperClasses = classNames([ + `${prefix}--text-input-wrapper`, + `${prefix}--slider-text-input-wrapper`, + `${prefix}--slider-text-input-wrapper--upper`, + { + [`${prefix}--text-input-wrapper--readonly`]: readOnly, + }, + ]); + const lowerThumbClasses = classNames(`${prefix}--slider__thumb`, { + [`${prefix}--slider__thumb--lower`]: twoHandles, + }); + const upperThumbClasses = classNames(`${prefix}--slider__thumb`, { + [`${prefix}--slider__thumb--upper`]: twoHandles, + }); return (
{labelText} -
+
+ {twoHandles ? ( +
+ + {!readOnly && !isValid && ( + + )} + + {showWarning && ( + + )} +
+ ) : null} {formatLabel(min, minLabel)} @@ -848,19 +1490,51 @@ export default class Slider extends PureComponent { onKeyDown={this.onKeyDown} role="presentation" tabIndex={-1} - data-invalid={!isValid && !readOnly ? true : null} + data-invalid={ + (twoHandles ? !isValid || !isValidUpper : !isValid) && + !readOnly + ? true + : null + } {...other}>
+ onFocus={() => + this.setState({ activeHandle: HandlePosition.LOWER }) + }> + {twoHandles && !isRtl && } + {twoHandles && !isRtl && } + {twoHandles && isRtl && } + {twoHandles && isRtl && } +
+ {twoHandles ? ( +
+ this.setState({ activeHandle: HandlePosition.UPPER }) + }> + {twoHandles && !isRtl && } + {twoHandles && !isRtl && } + {twoHandles && isRtl && } + {twoHandles && isRtl && } +
+ ) : null}
{ @@ -875,39 +1549,62 @@ export default class Slider extends PureComponent { {formatLabel(max, maxLabel)} - - {!readOnly && isValid === false && ( - - )} - {!readOnly && warn && isValid && ( - + - )} + {!readOnly && (twoHandles ? !isValidUpper : !isValid) && ( + + )} + + {showWarningUpper && ( + + )} +
- {!readOnly && isValid === false && ( + {!readOnly && (!isValid || !isValidUpper) && ( { {invalidText} )} - {!readOnly && warn && isValid && ( + {!readOnly && warn && isValid && isValidUpper && ( { {warnText} )} + {correctedValue && ( + + {t(translationIds.autoCorrectAnnouncement, { + correctedValue, + })} + + )}
); }} @@ -935,3 +1645,7 @@ export default class Slider extends PureComponent { ); } } + +(Slider as any).translationIds = Object.values(translationIds); + +export default Slider; diff --git a/packages/styles/scss/components/slider/_slider.scss b/packages/styles/scss/components/slider/_slider.scss index 4b4a80e97917..4bcddcdd71ba 100644 --- a/packages/styles/scss/components/slider/_slider.scss +++ b/packages/styles/scss/components/slider/_slider.scss @@ -30,17 +30,21 @@ position: relative; display: flex; align-items: center; + gap: 1rem; user-select: none; } .#{$prefix}--slider { position: relative; padding: $spacing-05 0; - margin: 0 $spacing-05; cursor: pointer; inline-size: 100%; max-inline-size: convert.to-rem(640px); min-inline-size: convert.to-rem(200px); + + .#{$prefix}--slider-container--two-handles & { + margin-inline: rem(4px); + } } .#{$prefix}--slider__range-label { @@ -48,10 +52,6 @@ color: $text-primary; white-space: nowrap; - - &:last-of-type { - margin-inline-end: $spacing-05; - } } .#{$prefix}--slider__track { @@ -117,10 +117,102 @@ } } + .#{$prefix}--slider__thumb-icon { + fill: $layer-selected-inverse; + } + + .#{$prefix}--slider__thumb-icon--focus { + display: none; + fill: $interactive; + } + + .#{$prefix}--slider__thumb--lower, + .#{$prefix}--slider__thumb--upper { + display: block; + /* stylelint-disable-next-line declaration-property-value-disallowed-list */ + border-radius: unset; + background-color: transparent; + block-size: 24px; + box-shadow: none; + inline-size: 16px; + transform: translate(-100%, -50%); + transition: none; + + &::before { + position: absolute; + z-index: -1; + display: block; + background: $background; + block-size: convert.to-rem(2px); + content: ''; + inline-size: convert.to-rem(6px); + inset-block-start: calc(50% - #{convert.to-rem(2px) / 2}); + inset-inline-end: 0; + } + + &:hover { + transform: translate(-100%, -50%); + + .#{$prefix}--slider__thumb-icon { + fill: $text-secondary; + } + } + + &:active { + box-shadow: none; + transform: translate(-100%, -50%); + } + + &:focus { + background-color: transparent; + box-shadow: none; + transform: translate(-100%, -50%); + + .#{$prefix}--slider__thumb-icon { + display: none; + fill: $interactive; + } + + .#{$prefix}--slider__thumb-icon--focus { + display: block; + } + } + } + + .#{$prefix}--slider__thumb--lower:focus::before, + .#{$prefix}--slider__thumb--upper:focus::before { + inline-size: 100%; + } + + .#{$prefix}--slider__thumb--upper { + transform: translate(0, -50%); + + &::before { + inset-inline-end: auto; + inset-inline-start: 0; + } + + &:hover { + transform: translate(0, -50%); + } + + &:active { + transform: translate(0, -50%); + } + + &:focus { + transform: translate(0, -50%); + } + } + .#{$prefix}--slider__input { display: none; } + .#{$prefix}--slider-text-input-wrapper { + position: relative; + } + .#{$prefix}--slider-text-input, .#{$prefix}-slider-text-input { -moz-appearance: textfield; @@ -139,7 +231,7 @@ } .#{$prefix}--slider__thumb:focus ~ .#{$prefix}--slider__filled-track { - background-color: $interactive; + background-color: $border-interactive; } // Invalid & warn state @@ -161,7 +253,6 @@ .#{$prefix}--slider__validation-msg.#{$prefix}--form-requirement { display: block; overflow: visible; - margin-block-start: 0; max-block-size: 100%; } @@ -241,15 +332,26 @@ cursor: default; } - .#{$prefix}--slider--readonly .#{$prefix}--slider__thumb { + .#{$prefix}--slider-container--readonly .#{$prefix}--slider__thumb { block-size: 0; inline-size: 0; + + &::before, + &::after { + display: none; + } } - .#{$prefix}--slider--readonly ~ .#{$prefix}--slider-text-input { + .#{$prefix}--slider-container--readonly .#{$prefix}--slider-text-input { background-color: transparent; } + .#{$prefix}--slider__status-msg.#{$prefix}--form-requirement { + display: block; + overflow: visible; + max-block-size: 100%; + } + // Skeleton state .#{$prefix}--slider-container.#{$prefix}--skeleton .#{$prefix}--slider__range-label { @@ -265,11 +367,20 @@ pointer-events: none; } - .#{$prefix}--slider-container.#{$prefix}--skeleton + .#{$prefix}--slider-container.#{$prefix}--skeleton { .#{$prefix}--slider__thumb { - cursor: default; - inset-inline-start: 50%; - pointer-events: none; + cursor: default; + inset-inline-start: 50%; + pointer-events: none; + } + + .#{$prefix}--slider__thumb--lower { + inset-inline-start: 0; + } + + .#{$prefix}--slider__thumb--upper { + inset-inline-start: 100%; + } } // Windows HCM fix