Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NoJira][BpkSlider] - Fix native event handling for the Slider #3645

Merged
merged 14 commits into from
Oct 25, 2024
6 changes: 6 additions & 0 deletions packages/bpk-component-slider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,9 @@ const Slider = () => (
## Props

Check out the full list of props on Skyscanner's [design system documentation website](https://www.skyscanner.design/latest/components/slider/web-aNXvlY7y#section-props-1e).


## Native Events

Just like a `input` `type="range"` the BpkSlider will fire a change event from the hidden `input` `type="number"` for each thumb. These behave similarly where user can drag the thumb and will fire a change event on `mouseup`/`click`.
As for the keyboard events the change event will fire on `keyup` rather than on every keystroke registered like the `input` `type=range` does.
80 changes: 68 additions & 12 deletions packages/bpk-component-slider/src/BpkSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ import {
useRef,
useEffect,
type ComponentPropsWithRef,
type RefObject,
} from 'react';

import { useComposedRefs } from '@radix-ui/react-compose-refs';
import * as Slider from '@radix-ui/react-slider';
import { usePrevious } from '@radix-ui/react-use-previous';

import { cssModules, isRTL, setNativeValue } from '../../bpk-react-utils';

Expand All @@ -42,7 +42,9 @@ export type Props = {
value: number[] | number;
ariaLabel: string[];
ariaValuetext?: string[];
inputProps?: [{ [key: string]: any }];
inputProps?:
| [{ [key: string]: any }]
| [{ [key: string]: any }, { [key: string]: any }];
[rest: string]: any;
};

Expand Down Expand Up @@ -72,6 +74,8 @@ const BpkSlider = ({
}
};

const thumbRefs = [useRef(null), useRef(null)];

const handleOnChange = (sliderValues: number[]) => {
processSliderValues(sliderValues, onChange);
};
Expand Down Expand Up @@ -103,11 +107,16 @@ const BpkSlider = ({
aria-valuetext={ariaValuetext ? ariaValuetext[index] : val.toString()}
className={getClassName('bpk-slider__thumb')}
aria-valuenow={currentValue[index]}
ref={thumbRefs[index]}
asChild
>
{/* custom thumb with child input */}
<span>
<BubbleInput value={currentValue[index]} {...(inputProps && inputProps[index])} />
<BubbleInput
value={currentValue[index]}
thumbRef={thumbRefs[index]}
{...(inputProps && inputProps[index])}
/>
</span>
</Slider.Thumb>
))}
Expand All @@ -117,20 +126,67 @@ const BpkSlider = ({

// Work around until radix-ui/react-slider is updated to accept an inputRef prop https://github.com/radix-ui/primitives/pull/3033
const BubbleInput = forwardRef(
(props: ComponentPropsWithRef<'input'>, forwardedRef) => {
const { value, ...inputProps } = props;
(
props: ComponentPropsWithRef<'input'> & {
thumbRef: RefObject<HTMLElement>;
},
forwardedRef,
) => {
const { thumbRef, value, ...inputProps } = props;
const ref = useRef<HTMLInputElement>();
const composedRefs = useComposedRefs(forwardedRef, ref);

const prevValue = usePrevious(value);

// Bubble value change to parents (e.g form change event)
// Provide native behaviour that the input range type provides.
useEffect(() => {
metalix2 marked this conversation as resolved.
Show resolved Hide resolved
const input = ref.current!;
if (prevValue !== value) {
setNativeValue(input, `${value}`);
const thumb = thumbRef.current;
const input = ref.current;
if (thumb) {
const interactionEndHandler = (prevValue: number) => {
if (input) {
const newValue = parseFloat(input.getAttribute('value') as string);
if (prevValue !== newValue) {
setNativeValue(input, newValue);
}
mungodewar marked this conversation as resolved.
Show resolved Hide resolved
}
};

const focusInHandler = () => {
// Controller needed as we may click more than once when in focus. On focusout we can then abort the event listeners
const controller = new AbortController();
mungodewar marked this conversation as resolved.
Show resolved Hide resolved
thumb.addEventListener('focusout', () => controller.abort(), {
once: true,
metalix2 marked this conversation as resolved.
Show resolved Hide resolved
});
// needed on document as users can drag the thumb while out of the thumb elements mouse area
document.addEventListener(
'click',
() => interactionEndHandler(value as number),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to cast the value? Shouldn't interactionEndHandler expect to handle all the diferent types of values that come from the event listener?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment on this area of the code is that the nested nature of the options/functions etc make it a little difficult to read, is there a way to make it easier? I think, if we are able to not have to pass the casting there is a small increase in readability, eg:

document.addEventListener('click', interactionEndHandler, options)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to cast the value? Shouldn't interactionEndHandler expect to handle all the diferent types of values that come from the event listener?

I thought we had originally needed them but the value remains the same once instanciated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment on this area of the code is that the nested nature of the options/functions etc make it a little difficult to read, is there a way to make it easier? I think, if we are able to not have to pass the casting there is a small increase in readability, eg:

I'll see If I can refactor it a little.

{
signal: controller.signal,
},
);
thumb.addEventListener(
'keyup',
(e: KeyboardEvent) => {
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(
e.key,
) && interactionEndHandler(value as number);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this logic not be within the handler itself?
Even if there are different types of handlers with shared common logic:

// common & shared
function interactionValueParsing(){}

function keyupInteractionHandler (e){
   if(!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes( e.key)) return;
   const value = interactionValueParsing(e)
   // stuff
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well It would mean checking for key events when it was a click event. This just sort of wraps and takes care of the custom keyboard logic.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would mean checking for key events when it was a click event

I don't understand. Why? Don't we attach the handler twice, once for click and once for keyup - these could just be different handlers, no?

},
{
signal: controller.signal,
},
);
};

thumb.addEventListener('focusin', focusInHandler);

return () => {
if (thumb) {
thumb.removeEventListener('focusin', focusInHandler);
}
};
}
}, [prevValue, value]);
return () => {};
}, [thumbRef, ref, value]);

/**
* We purposefully do not use `type="hidden"` here otherwise forms that
Expand Down
29 changes: 18 additions & 11 deletions packages/bpk-component-slider/src/form-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* limitations under the License.
*/

import { useState } from 'react';
import { useEffect, useState } from 'react';

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
Expand All @@ -34,7 +34,7 @@ window.ResizeObserver =
describe('BpkSlider form-test for single-thumb slider', () => {
it('should work as a form component in a form', async () => {
const Wrap = () => {
const [sliderValue, setSliderValue] = useState<number>(50); // single-thumb slider with only max value
const [sliderValue, setSliderValue] = useState<number>(50); // single-thumb slider with only max value
return (
<form data-testid="form">
<BpkSlider
Expand Down Expand Up @@ -67,9 +67,12 @@ describe('BpkSlider form-test for single-thumb slider', () => {
});

it('should emit change event when both sides of slider value are changed for two-thumb slider', async () => {
const handleChange = jest.fn();
const formValidation = jest.fn();
const Wrap = () => {
const [sliderValue, setSliderValue] = useState<number[]>([0, 100]); // two-thumb slider with min and max value
const [sliderValue, setSliderValue] = useState<number[]>([0, 100]); // two-thumb slider with min and max value
useEffect(() => {
document.addEventListener('change', formValidation);
}, []);
metalix2 marked this conversation as resolved.
Show resolved Hide resolved
return (
<form data-testid="form">
<BpkSlider
Expand All @@ -80,8 +83,8 @@ describe('BpkSlider form-test for single-thumb slider', () => {
ariaLabel={['min', 'max']}
ariaValuetext={['0', '100']}
value={sliderValue}
inputProps={[{ 'data-testid': 'sliderInputMin' }, { 'data-testid': 'sliderInputMax' }]}
onChange={(value) => {
handleChange(value as number[]);
setSliderValue(value as number[]);
}}
/>
Expand All @@ -94,16 +97,20 @@ describe('BpkSlider form-test for single-thumb slider', () => {
expect(sliderThumbMin).toBeInTheDocument();

await sliderThumbMin.focus();
await userEvent.keyboard('{ArrowRight}');
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith([1, 100]);
// Ideally we could use the mouse and drag but not currently possible in jsdom
// Moving the slider over multiple steps should result in one change event
await userEvent.keyboard('{ArrowRight>5}{/ArrowRight}');

expect(screen.getByTestId('sliderInputMin').getAttribute('value')).toBe('5');
expect(formValidation).toHaveBeenCalledTimes(1);

const sliderThumbMax = screen.getByLabelText('max');
expect(sliderThumbMax).toBeInTheDocument();

await sliderThumbMax.focus();
await userEvent.keyboard('{ArrowLeft}');
expect(handleChange).toHaveBeenCalledTimes(2);
expect(handleChange).toHaveBeenCalledWith([1, 99]);
await userEvent.keyboard('{ArrowLeft>25}{/ArrowLeft}');

expect(screen.getByTestId('sliderInputMax').getAttribute('value')).toBe('75');
expect(formValidation).toHaveBeenCalledTimes(2);
});
});
15 changes: 0 additions & 15 deletions packages/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"@popperjs/core": "^2.11.8",
"@radix-ui/react-compose-refs": "^1.1.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-use-previous": "^1.1.0",
"@react-google-maps/api": "^2.19.3",
"@skyscanner/bpk-foundations-web": "^18.1.0",
"@skyscanner/bpk-svgs": "^19.3.0",
Expand Down
Loading