From 541e136a1478cd315893ed1d48725e660af9d11d Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Thu, 17 Oct 2024 19:28:54 +0100 Subject: [PATCH 01/11] Fix native event handling for the Slider --- .../bpk-component-slider/src/BpkSlider.tsx | 64 +++++++++++++++---- packages/package-lock.json | 15 ----- packages/package.json | 1 - 3 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index b56ba9dff6..12a9d98ac2 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -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'; @@ -72,6 +72,8 @@ const BpkSlider = ({ } }; + const thumbRefs = [useRef(null), useRef(null)]; + const handleOnChange = (sliderValues: number[]) => { processSliderValues(sliderValues, onChange); }; @@ -103,11 +105,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> ))} @@ -117,20 +124,55 @@ 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(() => { - 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); + } + } + }; + + 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(); + thumb.addEventListener('focusout', () => controller.abort(), { + once: true, + }); + // needed on document as users can drag the thumb while out of the thumb elements mouse area + document.addEventListener('click', () => interactionEndHandler(value as number), { + signal: controller.signal, + }); + thumb.addEventListener('keyup', () => interactionEndHandler(value as number), { + 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 diff --git a/packages/package-lock.json b/packages/package-lock.json index b072af78cc..d1fcd5e13f 100644 --- a/packages/package-lock.json +++ b/packages/package-lock.json @@ -13,7 +13,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", @@ -424,20 +423,6 @@ } } }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", - "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-use-size": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz", diff --git a/packages/package.json b/packages/package.json index 63d60d8cb7..eeb33f7baf 100644 --- a/packages/package.json +++ b/packages/package.json @@ -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", From 46a75942abed7931b260fbaf5ad34c423f2af26a Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Fri, 18 Oct 2024 10:02:10 +0100 Subject: [PATCH 02/11] Updated tests --- .../bpk-component-slider/src/BpkSlider.tsx | 4 ++- .../bpk-component-slider/src/form-test.tsx | 29 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index 12a9d98ac2..2beccfffb8 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -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; }; diff --git a/packages/bpk-component-slider/src/form-test.tsx b/packages/bpk-component-slider/src/form-test.tsx index 43079da8e0..238cd3f7cb 100644 --- a/packages/bpk-component-slider/src/form-test.tsx +++ b/packages/bpk-component-slider/src/form-test.tsx @@ -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'; @@ -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 @@ -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); + }, []); return ( <form data-testid="form"> <BpkSlider @@ -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[]); }} /> @@ -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); }); }); From eba0ab8cdce62f267942f01d60abe9b81bf91273 Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Fri, 18 Oct 2024 10:20:45 +0100 Subject: [PATCH 03/11] restrict keyup to arrow keys --- .../bpk-component-slider/src/BpkSlider.tsx | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index 2beccfffb8..dfcc8acf10 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -43,8 +43,8 @@ export type Props = { ariaLabel: string[]; ariaValuetext?: string[]; inputProps?: - | [{ [key: string]: any }] - | [{ [key: string]: any }, { [key: string]: any }]; + | [{ [key: string]: any }] + | [{ [key: string]: any }, { [key: string]: any }]; [rest: string]: any; }; @@ -157,12 +157,24 @@ const BubbleInput = forwardRef( once: true, }); // needed on document as users can drag the thumb while out of the thumb elements mouse area - document.addEventListener('click', () => interactionEndHandler(value as number), { - signal: controller.signal, - }); - thumb.addEventListener('keyup', () => interactionEndHandler(value as number), { - signal: controller.signal, - }); + document.addEventListener( + 'click', + () => interactionEndHandler(value as number), + { + signal: controller.signal, + }, + ); + thumb.addEventListener( + 'keyup', + (e: KeyboardEvent) => { + ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes( + e.key, + ) && interactionEndHandler(value as number); + }, + { + signal: controller.signal, + }, + ); }; thumb.addEventListener('focusin', focusInHandler); From ee1167e3861dfd0742e48604946a94c4c69b349d Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Fri, 18 Oct 2024 10:24:42 +0100 Subject: [PATCH 04/11] update readme --- packages/bpk-component-slider/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/bpk-component-slider/README.md b/packages/bpk-component-slider/README.md index 4457fff4fc..7ee7576d67 100644 --- a/packages/bpk-component-slider/README.md +++ b/packages/bpk-component-slider/README.md @@ -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. \ No newline at end of file From 78189ed9d5284dc11f0c1e10ee7a316e7c33e81c Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Fri, 18 Oct 2024 15:25:59 +0100 Subject: [PATCH 05/11] refactor and comments --- .../bpk-component-slider/src/BpkSlider.tsx | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index dfcc8acf10..f53ff21147 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -135,58 +135,65 @@ const BubbleInput = forwardRef( const { thumbRef, value, ...inputProps } = props; const ref = useRef<HTMLInputElement>(); const composedRefs = useComposedRefs(forwardedRef, ref); + + const thumb = thumbRef.current; + const input = ref.current; - // Provide native behaviour that the input range type provides. + // This Hook Provides the native behaviour that the input range type would have around the "change" event. + // When a user changes the value of the slider. The change event is emitted. useEffect(() => { - const thumb = thumbRef.current; - const input = ref.current; + // thumb should be rendered before adding any eventListeners if (thumb) { - const interactionEndHandler = (prevValue: number) => { - if (input) { + // The interactionEndHandler is used to ensure that the input value is updated + // and change event fired when the user stops interacting with the thumb + const interactionEndHandler = (event: MouseEvent | KeyboardEvent) => { + if ( + input && + // if it's a mouse event or arrow key event + ((event as MouseEvent).button > -1 || + ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes( + (event as KeyboardEvent).key, + )) + ) { + // parseFloat works for both integers and floats and the Slider supports decimal stepping. e.g. 0 - 1 const newValue = parseFloat(input.getAttribute('value') as string); - if (prevValue !== newValue) { + if (value !== newValue) { // newValue is changed if the slider has moved, clicking away from the thumb will not fire the change event setNativeValue(input, newValue); } } }; + // the focusInHandler is used to add event listeners to the document when the thumb is in focus + // Allows us to track at which moment the user const focusInHandler = () => { - // Controller needed as we may click more than once when in focus. On focusout we can then abort the event listeners + // The Controller is needed as we may click more than once when in focus (clicking along the line of slider to move the thumb to that position). const controller = new AbortController(); + // On focusout we can then abort the event listeners attached to the thumb and document thumb.addEventListener('focusout', () => controller.abort(), { - once: true, + once: true, // not necessary to fire more than once and will restart on the next focusin + }); + + // These two EventListeners signal the end of the interaction with the thumb + document.addEventListener('click', interactionEndHandler, { // needed on document as users can drag the thumb while out of the thumb elements mouse area + signal: controller.signal, + }); + thumb.addEventListener('keyup', interactionEndHandler, { + signal: controller.signal, }); - // needed on document as users can drag the thumb while out of the thumb elements mouse area - document.addEventListener( - 'click', - () => interactionEndHandler(value as number), - { - signal: controller.signal, - }, - ); - thumb.addEventListener( - 'keyup', - (e: KeyboardEvent) => { - ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes( - e.key, - ) && interactionEndHandler(value as number); - }, - { - signal: controller.signal, - }, - ); }; + // Add event listeners to the thumb for when the user starts interacting with the thumb thumb.addEventListener('focusin', focusInHandler); return () => { + // clean up event listeners if (thumb) { thumb.removeEventListener('focusin', focusInHandler); } }; } return () => {}; - }, [thumbRef, ref, value]); + }, [thumb, input, value]); /** * We purposefully do not use `type="hidden"` here otherwise forms that From de69c930e1c6f6352aacb79afcc864a468e12422 Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Fri, 18 Oct 2024 15:58:10 +0100 Subject: [PATCH 06/11] fix tests --- packages/bpk-component-slider/src/BpkSlider.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index f53ff21147..4eb5cffa43 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -135,13 +135,13 @@ const BubbleInput = forwardRef( const { thumbRef, value, ...inputProps } = props; const ref = useRef<HTMLInputElement>(); const composedRefs = useComposedRefs(forwardedRef, ref); - - const thumb = thumbRef.current; - const input = ref.current; // This Hook Provides the native behaviour that the input range type would have around the "change" event. // When a user changes the value of the slider. The change event is emitted. useEffect(() => { + // for test works where ref is passed into the useEffect + const thumb = thumbRef.current; + const input = ref.current; // thumb should be rendered before adding any eventListeners if (thumb) { // The interactionEndHandler is used to ensure that the input value is updated @@ -193,7 +193,7 @@ const BubbleInput = forwardRef( }; } return () => {}; - }, [thumb, input, value]); + }, [thumbRef, ref, value]); /** * We purposefully do not use `type="hidden"` here otherwise forms that From 376b3590b7b2ab168a535e8cc399cb1c039fe7a4 Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Tue, 22 Oct 2024 09:51:28 +0100 Subject: [PATCH 07/11] support touch events for mobile and update tests --- examples/bpk-component-modal-v2/examples.tsx | 3 ++ examples/bpk-component-modal/examples.tsx | 3 ++ examples/bpk-component-slider/examples.js | 2 +- .../bpk-component-checkbox/src/form-test.js | 8 ++-- .../src/form-test.tsx | 6 +-- .../bpk-component-input/src/form-test.tsx | 4 +- .../bpk-component-nudger/src/form-test.tsx | 12 ++--- .../src/form-test.js | 6 +-- packages/bpk-component-radio/src/form-test.js | 40 ++++++----------- .../bpk-component-select/src/form-test.js | 44 ++++++++----------- .../bpk-component-slider/src/BpkSlider.tsx | 13 +++--- .../bpk-component-slider/src/form-test.tsx | 5 +-- .../src/form-test.tsx | 8 +--- .../bpk-component-switch/src/form-test.tsx | 8 ++-- 14 files changed, 66 insertions(+), 96 deletions(-) diff --git a/examples/bpk-component-modal-v2/examples.tsx b/examples/bpk-component-modal-v2/examples.tsx index 2a680ce7bb..a15ae158f6 100644 --- a/examples/bpk-component-modal-v2/examples.tsx +++ b/examples/bpk-component-modal-v2/examples.tsx @@ -22,6 +22,7 @@ import { BpkButtonV2 } from '../../packages/bpk-component-button'; import { BpkModalV2, MODAL_STYLING } from '../../packages/bpk-component-modal'; import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text'; import { cssModules, withDefaultProps } from '../../packages/bpk-react-utils'; +import { RangeSliderExample } from '../bpk-component-slider/examples'; import type { Props as BpkModalProps } from '../../packages/bpk-component-modal/src/BpkModalV2/BpkModal'; @@ -124,6 +125,8 @@ const DefaultExample = (initiallyOpen: boolean) => ( This is a default modal using the HTML dialog element. You can put anything you want in here. </Paragraph> + <RangeSliderExample /> + <input type="range" min="1" max="100" /> </ModalContainer> ); diff --git a/examples/bpk-component-modal/examples.tsx b/examples/bpk-component-modal/examples.tsx index b3bce1cab8..0b534d3ce1 100644 --- a/examples/bpk-component-modal/examples.tsx +++ b/examples/bpk-component-modal/examples.tsx @@ -35,6 +35,7 @@ import BpkModal, { import { BpkNavigationBarButtonLink } from '../../packages/bpk-component-navigation-bar'; import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text'; import { cssModules, withDefaultProps } from '../../packages/bpk-react-utils'; +import { RangeSliderExample } from '../bpk-component-slider/examples'; import STYLES from './examples.module.scss'; @@ -145,6 +146,8 @@ const ModalContainer = (props: ContainerProps) => { const DefaultExample = (isOpen: boolean) => ( <ModalContainer title="Modal title" closeLabel="Close modal" isOpen={isOpen} > This is a default modal. You can put anything you want in here. + <RangeSliderExample /> + <input type="range" min="1" max="100" /> </ModalContainer> ); diff --git a/examples/bpk-component-slider/examples.js b/examples/bpk-component-slider/examples.js index 5ae6c6fc38..27024cfe81 100644 --- a/examples/bpk-component-slider/examples.js +++ b/examples/bpk-component-slider/examples.js @@ -63,7 +63,7 @@ class SliderContainer extends Component { <BpkSlider min={min} max={time ? 59 : 100} - step={1} + step={0.5} onChange={this.handleChange} {...this.props} value={this.state.value} diff --git a/packages/bpk-component-checkbox/src/form-test.js b/packages/bpk-component-checkbox/src/form-test.js index 5906cf36a2..a75eb72c88 100644 --- a/packages/bpk-component-checkbox/src/form-test.js +++ b/packages/bpk-component-checkbox/src/form-test.js @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -57,10 +57,7 @@ describe('BpkCheckbox form test', () => { const formValidation = jest.fn(); const Wrap = () => { // state is required to force react to update and re-render the component. - const [isChecked, setIsChecked] = useState(false); - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); + const [isChecked, setIsChecked] = useState(false); return ( <form data-testid="form"> <BpkCheckbox @@ -75,6 +72,7 @@ describe('BpkCheckbox form test', () => { ); }; render(<Wrap />); + document.addEventListener('change', formValidation); const checkbox = screen.getByTestId('mycheckbox'); expect(checkbox).not.toBeChecked(); diff --git a/packages/bpk-component-datepicker/src/form-test.tsx b/packages/bpk-component-datepicker/src/form-test.tsx index 01e5f9cd87..fb9dc81cc8 100644 --- a/packages/bpk-component-datepicker/src/form-test.tsx +++ b/packages/bpk-component-datepicker/src/form-test.tsx @@ -17,7 +17,7 @@ */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -122,9 +122,6 @@ describe('BpkDatepicker form test', () => { const formValidation = jest.fn(); const Wrap = () => { const [calendarDate, setCalendarDate] = useState(new Date(2020, 2, 19)); - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); return ( <form data-testid="form"> <BpkDatepicker @@ -157,6 +154,7 @@ describe('BpkDatepicker form test', () => { }; render(<Wrap />); + document.addEventListener('change', formValidation); const inputField = screen.getByRole('textbox', { name: /Thursday, 19th March 2020/i, diff --git a/packages/bpk-component-input/src/form-test.tsx b/packages/bpk-component-input/src/form-test.tsx index 25f629d1e2..f5b50fb5fc 100644 --- a/packages/bpk-component-input/src/form-test.tsx +++ b/packages/bpk-component-input/src/form-test.tsx @@ -65,9 +65,6 @@ describe('BpkInput', () => { const Wrap = () => { // state is required to force react to update and re-render the component. const [inputTest, setInputTest] = useState(''); - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); return ( <form data-testid="form"> <BpkInput @@ -85,6 +82,7 @@ describe('BpkInput', () => { }; render(<Wrap />); + document.addEventListener('change', formValidation); const textInput = screen.getByTestId('myInput'); const form = screen.getByTestId('form'); diff --git a/packages/bpk-component-nudger/src/form-test.tsx b/packages/bpk-component-nudger/src/form-test.tsx index 96835b8f2a..a90826807a 100644 --- a/packages/bpk-component-nudger/src/form-test.tsx +++ b/packages/bpk-component-nudger/src/form-test.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -70,9 +70,6 @@ describe('BpkNudger form test', () => { const Wrap = () => { // state is required to force react to update and re-render the component. const [nudgerValue, setNudgerValue] = useState(5); - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); return ( <form data-testid="form"> <BpkNudger @@ -92,7 +89,8 @@ describe('BpkNudger form test', () => { }; render(<Wrap />); - + document.addEventListener('change', formValidation); + const minusButton = screen.getByRole('button', { name: 'Decrease' }); const numInput = screen.getByTestId('myNudger'); @@ -111,9 +109,6 @@ describe('BpkNudger form test', () => { const Wrap = () => { // state is required to force react to update and re-render the component. const [nudgerValue, setNudgerValue] = useState(5); - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); return ( <form data-testid="form"> <BpkNudger @@ -133,6 +128,7 @@ describe('BpkNudger form test', () => { }; render(<Wrap />); + document.addEventListener('change', formValidation); const numInput = screen.getByTestId('myNudger'); const button = screen.getByRole('button', { name: 'Submit' }); diff --git a/packages/bpk-component-phone-input/src/form-test.js b/packages/bpk-component-phone-input/src/form-test.js index a274a0d3bb..1fd7b356d6 100644 --- a/packages/bpk-component-phone-input/src/form-test.js +++ b/packages/bpk-component-phone-input/src/form-test.js @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -90,9 +90,6 @@ describe('BpkPhoneInput form test', () => { const Wrap = () => { // state is required to force react to update and re-render the component. const [inputValue, setInputValue] = useState(''); - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); return ( <form data-testid="form"> <BpkPhoneInput @@ -108,6 +105,7 @@ describe('BpkPhoneInput form test', () => { }; render(<Wrap />); + document.addEventListener('change', formValidation); const phoneInput = screen.getByTestId('myInput'); const form = screen.getByTestId('form'); diff --git a/packages/bpk-component-radio/src/form-test.js b/packages/bpk-component-radio/src/form-test.js index 5522857a63..356c668312 100644 --- a/packages/bpk-component-radio/src/form-test.js +++ b/packages/bpk-component-radio/src/form-test.js @@ -16,10 +16,6 @@ * limitations under the License. */ -/* @flow strict */ - -import { useEffect } from 'react'; - import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -51,29 +47,21 @@ describe('BpkRadio form test', () => { it('should emit change event when toggled', async () => { const formValidation = jest.fn(); - const Wrap = () => { - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); - return ( - <form data-testid="form"> - <BpkRadio - type="radio" - name="radio" - value="One" - data-testid="myradio" - /> - <BpkRadio - type="radio" - name="radio" - value="Two" - data-testid="myradio2" - /> - <button type="submit">Submit</button> - </form> - ); - }; + const Wrap = () => ( + <form data-testid="form"> + <BpkRadio type="radio" name="radio" value="One" data-testid="myradio" /> + <BpkRadio + type="radio" + name="radio" + value="Two" + data-testid="myradio2" + /> + <button type="submit">Submit</button> + </form> + ); + render(<Wrap />); + document.addEventListener('change', formValidation); const radio = screen.getByTestId('myradio'); const radio2 = screen.getByTestId('myradio2'); diff --git a/packages/bpk-component-select/src/form-test.js b/packages/bpk-component-select/src/form-test.js index fcb7a61bf3..bc14feec68 100644 --- a/packages/bpk-component-select/src/form-test.js +++ b/packages/bpk-component-select/src/form-test.js @@ -16,8 +16,6 @@ * limitations under the License. */ -import { useEffect } from 'react'; - import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -65,30 +63,24 @@ describe('BpkSelect form test', () => { it('should emit change event on option selection that calls formValidation', async () => { const formValidation = jest.fn(); - const Wrap = () => { - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); - return ( - <form data-testid="form"> - <BpkSelect - id="fruits" - name="fruits" - defaultValue="apples" - data-testid="myselect" - > - <option value="apples">Apples</option> - <option data-testid="select-option" value="oranges"> - Oranges - </option> - <option value="pears">Pears</option> - <option value="tomatoes">Tomatoes</option> - </BpkSelect> - <button type="submit">Submit</button> - </form> - ); - }; - render(<Wrap />); + + render(<form data-testid="form"> + <BpkSelect + id="fruits" + name="fruits" + defaultValue="apples" + data-testid="myselect" + > + <option value="apples">Apples</option> + <option data-testid="select-option" value="oranges"> + Oranges + </option> + <option value="pears">Pears</option> + <option value="tomatoes">Tomatoes</option> + </BpkSelect> + <button type="submit">Submit</button> + </form>); + document.addEventListener('change', formValidation); const select = screen.getByTestId('myselect'); diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index 4eb5cffa43..bd50e618b4 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -146,11 +146,11 @@ const BubbleInput = forwardRef( if (thumb) { // The interactionEndHandler is used to ensure that the input value is updated // and change event fired when the user stops interacting with the thumb - const interactionEndHandler = (event: MouseEvent | KeyboardEvent) => { + const interactionEndHandler = (event: MouseEvent | KeyboardEvent | TouchEvent) => { if ( input && - // if it's a mouse event or arrow key event - ((event as MouseEvent).button > -1 || + // if it's a mouse event, touch event or arrow key event + ((event as MouseEvent).button > -1 || (event as TouchEvent).touches.length > -1 || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes( (event as KeyboardEvent).key, )) @@ -164,7 +164,7 @@ const BubbleInput = forwardRef( }; // the focusInHandler is used to add event listeners to the document when the thumb is in focus - // Allows us to track at which moment the user + // Allows us to track at which moment the user focuses on the thumb const focusInHandler = () => { // The Controller is needed as we may click more than once when in focus (clicking along the line of slider to move the thumb to that position). const controller = new AbortController(); @@ -173,10 +173,13 @@ const BubbleInput = forwardRef( once: true, // not necessary to fire more than once and will restart on the next focusin }); - // These two EventListeners signal the end of the interaction with the thumb + // These three EventListeners signal the end of the interaction with the thumb document.addEventListener('click', interactionEndHandler, { // needed on document as users can drag the thumb while out of the thumb elements mouse area signal: controller.signal, }); + thumb.addEventListener('touchend', interactionEndHandler, { + signal: controller.signal, + }); thumb.addEventListener('keyup', interactionEndHandler, { signal: controller.signal, }); diff --git a/packages/bpk-component-slider/src/form-test.tsx b/packages/bpk-component-slider/src/form-test.tsx index 238cd3f7cb..b668a9cda9 100644 --- a/packages/bpk-component-slider/src/form-test.tsx +++ b/packages/bpk-component-slider/src/form-test.tsx @@ -70,9 +70,7 @@ describe('BpkSlider form-test for single-thumb slider', () => { const formValidation = jest.fn(); const Wrap = () => { const [sliderValue, setSliderValue] = useState<number[]>([0, 100]); // two-thumb slider with min and max value - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); + return ( <form data-testid="form"> <BpkSlider @@ -92,6 +90,7 @@ describe('BpkSlider form-test for single-thumb slider', () => { ); }; render(<Wrap />); + document.addEventListener('change', formValidation); const sliderThumbMin = screen.getByLabelText('min'); expect(sliderThumbMin).toBeInTheDocument(); diff --git a/packages/bpk-component-split-input/src/form-test.tsx b/packages/bpk-component-split-input/src/form-test.tsx index 957d9bfe33..a08c923e63 100644 --- a/packages/bpk-component-split-input/src/form-test.tsx +++ b/packages/bpk-component-split-input/src/form-test.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -71,11 +71,6 @@ it('should emit change event when text has been entered and blurred', async () = const formValidation = jest.fn(); const Wrap = () => { const [inputTest, setInputTest] = useState(''); - - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); - return ( <form data-testid="form"> <BpkSplitInput @@ -94,6 +89,7 @@ it('should emit change event when text has been entered and blurred', async () = }; render(<Wrap />); + document.addEventListener('change', formValidation); const splitInputs = screen.getAllByTestId('mySplitInput'); const form = screen.getByTestId('form'); diff --git a/packages/bpk-component-switch/src/form-test.tsx b/packages/bpk-component-switch/src/form-test.tsx index 6663054c86..6ecd466df5 100644 --- a/packages/bpk-component-switch/src/form-test.tsx +++ b/packages/bpk-component-switch/src/form-test.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -59,9 +59,6 @@ describe('BpkSwitch form test', () => { const Wrap = () => { // state is required to force react to update and re-render the component. const [isChecked, setIsChecked] = useState(false); - useEffect(() => { - document.addEventListener('change', formValidation); - }, []); return ( <form data-testid="form"> <BpkSwitch @@ -76,7 +73,8 @@ describe('BpkSwitch form test', () => { ); }; render(<Wrap />); - + document.addEventListener('change', formValidation); + const mySwitch = screen.getByTestId('myswitch'); expect(mySwitch).not.toBeChecked(); expect(formValidation).not.toHaveBeenCalled(); From c3544a838399e537257c74290c00e1ecf44dcc65 Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Tue, 22 Oct 2024 09:54:51 +0100 Subject: [PATCH 08/11] clean up --- packages/bpk-component-slider/src/BpkSlider.tsx | 17 +++++++++++------ packages/bpk-component-slider/src/form-test.tsx | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index bd50e618b4..6da9a48715 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -139,25 +139,29 @@ const BubbleInput = forwardRef( // This Hook Provides the native behaviour that the input range type would have around the "change" event. // When a user changes the value of the slider. The change event is emitted. useEffect(() => { - // for test works where ref is passed into the useEffect + // for tests to work the ref is passed into the useEffect rather than the variable of ref.current. const thumb = thumbRef.current; const input = ref.current; // thumb should be rendered before adding any eventListeners if (thumb) { // The interactionEndHandler is used to ensure that the input value is updated // and change event fired when the user stops interacting with the thumb - const interactionEndHandler = (event: MouseEvent | KeyboardEvent | TouchEvent) => { + const interactionEndHandler = ( + event: MouseEvent | KeyboardEvent | TouchEvent, + ) => { if ( input && // if it's a mouse event, touch event or arrow key event - ((event as MouseEvent).button > -1 || (event as TouchEvent).touches.length > -1 || + ((event as MouseEvent).button > -1 || + (event as TouchEvent).touches.length > -1 || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes( (event as KeyboardEvent).key, )) ) { // parseFloat works for both integers and floats and the Slider supports decimal stepping. e.g. 0 - 1 const newValue = parseFloat(input.getAttribute('value') as string); - if (value !== newValue) { // newValue is changed if the slider has moved, clicking away from the thumb will not fire the change event + if (value !== newValue) { + // newValue is changed if the slider has moved, clicking away from the thumb will not fire the change event setNativeValue(input, newValue); } } @@ -166,7 +170,7 @@ const BubbleInput = forwardRef( // the focusInHandler is used to add event listeners to the document when the thumb is in focus // Allows us to track at which moment the user focuses on the thumb const focusInHandler = () => { - // The Controller is needed as we may click more than once when in focus (clicking along the line of slider to move the thumb to that position). + // The Controller is needed as we may click more than once when in focus (clicking along the line of slider to move the thumb to that position). const controller = new AbortController(); // On focusout we can then abort the event listeners attached to the thumb and document thumb.addEventListener('focusout', () => controller.abort(), { @@ -174,7 +178,8 @@ const BubbleInput = forwardRef( }); // These three EventListeners signal the end of the interaction with the thumb - document.addEventListener('click', interactionEndHandler, { // needed on document as users can drag the thumb while out of the thumb elements mouse area + document.addEventListener('click', interactionEndHandler, { + // needed on document as users can drag the thumb while out of the thumb elements mouse area signal: controller.signal, }); thumb.addEventListener('touchend', interactionEndHandler, { diff --git a/packages/bpk-component-slider/src/form-test.tsx b/packages/bpk-component-slider/src/form-test.tsx index b668a9cda9..46b01bec47 100644 --- a/packages/bpk-component-slider/src/form-test.tsx +++ b/packages/bpk-component-slider/src/form-test.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; From a21757f80fa2b4a11f0e9badf1ec0b86e02258b5 Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Tue, 22 Oct 2024 09:55:47 +0100 Subject: [PATCH 09/11] undo example changes --- examples/bpk-component-modal-v2/examples.tsx | 3 --- examples/bpk-component-modal/examples.tsx | 3 --- 2 files changed, 6 deletions(-) diff --git a/examples/bpk-component-modal-v2/examples.tsx b/examples/bpk-component-modal-v2/examples.tsx index a15ae158f6..2a680ce7bb 100644 --- a/examples/bpk-component-modal-v2/examples.tsx +++ b/examples/bpk-component-modal-v2/examples.tsx @@ -22,7 +22,6 @@ import { BpkButtonV2 } from '../../packages/bpk-component-button'; import { BpkModalV2, MODAL_STYLING } from '../../packages/bpk-component-modal'; import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text'; import { cssModules, withDefaultProps } from '../../packages/bpk-react-utils'; -import { RangeSliderExample } from '../bpk-component-slider/examples'; import type { Props as BpkModalProps } from '../../packages/bpk-component-modal/src/BpkModalV2/BpkModal'; @@ -125,8 +124,6 @@ const DefaultExample = (initiallyOpen: boolean) => ( This is a default modal using the HTML dialog element. You can put anything you want in here. </Paragraph> - <RangeSliderExample /> - <input type="range" min="1" max="100" /> </ModalContainer> ); diff --git a/examples/bpk-component-modal/examples.tsx b/examples/bpk-component-modal/examples.tsx index 0b534d3ce1..b3bce1cab8 100644 --- a/examples/bpk-component-modal/examples.tsx +++ b/examples/bpk-component-modal/examples.tsx @@ -35,7 +35,6 @@ import BpkModal, { import { BpkNavigationBarButtonLink } from '../../packages/bpk-component-navigation-bar'; import BpkText, { TEXT_STYLES } from '../../packages/bpk-component-text'; import { cssModules, withDefaultProps } from '../../packages/bpk-react-utils'; -import { RangeSliderExample } from '../bpk-component-slider/examples'; import STYLES from './examples.module.scss'; @@ -146,8 +145,6 @@ const ModalContainer = (props: ContainerProps) => { const DefaultExample = (isOpen: boolean) => ( <ModalContainer title="Modal title" closeLabel="Close modal" isOpen={isOpen} > This is a default modal. You can put anything you want in here. - <RangeSliderExample /> - <input type="range" min="1" max="100" /> </ModalContainer> ); From 0f99e9772e27f8316d4f83240dc8a176c77f5fe1 Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Tue, 22 Oct 2024 09:56:10 +0100 Subject: [PATCH 10/11] revert step --- examples/bpk-component-slider/examples.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/bpk-component-slider/examples.js b/examples/bpk-component-slider/examples.js index 27024cfe81..5ae6c6fc38 100644 --- a/examples/bpk-component-slider/examples.js +++ b/examples/bpk-component-slider/examples.js @@ -63,7 +63,7 @@ class SliderContainer extends Component { <BpkSlider min={min} max={time ? 59 : 100} - step={0.5} + step={1} onChange={this.handleChange} {...this.props} value={this.state.value} From 542f09a967ba1af9e8379046c4778c6c5fb27d26 Mon Sep 17 00:00:00 2001 From: metalix2 <matthewr@skyscanner.net> Date: Tue, 22 Oct 2024 10:18:30 +0100 Subject: [PATCH 11/11] fix test --- packages/bpk-component-slider/src/BpkSlider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bpk-component-slider/src/BpkSlider.tsx b/packages/bpk-component-slider/src/BpkSlider.tsx index 6da9a48715..526f4d462f 100644 --- a/packages/bpk-component-slider/src/BpkSlider.tsx +++ b/packages/bpk-component-slider/src/BpkSlider.tsx @@ -153,7 +153,7 @@ const BubbleInput = forwardRef( input && // if it's a mouse event, touch event or arrow key event ((event as MouseEvent).button > -1 || - (event as TouchEvent).touches.length > -1 || + (event as TouchEvent).touches?.length > -1 || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes( (event as KeyboardEvent).key, ))