From ef0f0a481f658581f26bf71691bddd7187d9c2c9 Mon Sep 17 00:00:00 2001 From: Nathan Brudnik Date: Mon, 3 Jul 2023 19:10:56 +0300 Subject: [PATCH] Redesign Slider and add a11y support (#282) * Redesign Slider * Added a11y support * Code review - ts fixes, support for keyboard focus * Small styling fix - margins and input focus --- src/components/inputs/Slider/Handle.tsx | 117 ++++------- src/components/inputs/Slider/Slider.style.ts | 60 +++--- src/components/inputs/Slider/Slider.test.tsx | 122 ++++++++++++ src/components/inputs/Slider/Slider.tsx | 193 +++++++++++++++---- src/utils/events/KeyCodes.ts | 4 + 5 files changed, 360 insertions(+), 136 deletions(-) create mode 100644 src/components/inputs/Slider/Slider.test.tsx diff --git a/src/components/inputs/Slider/Handle.tsx b/src/components/inputs/Slider/Handle.tsx index d550590ff..b7e85554a 100755 --- a/src/components/inputs/Slider/Handle.tsx +++ b/src/components/inputs/Slider/Handle.tsx @@ -1,79 +1,46 @@ -import React, { useEffect, useRef } from 'react'; - -import { - HandleStyled, - Hidden, - LabelInput, - Label, -} from './Slider.style'; +import React from 'react'; +import { HandleStyled, Hidden } from './Slider.style'; import { Focus } from '../../../utils'; -export function Handle({ - disabled, - dragging, - editableLabel, - focused, - formatter, - handle, - max, - min, - onChange, - onDragEnd, - setDragging, - setFocus, - setValue, - value, - ...props -}) { - const ref = useRef(null); - - useEffect(() => { - const event = new Event('change', { bubbles: true }); - ref?.current?.dispatchEvent(event); - onChange && onChange(event); - }, [onChange, value]); - - return ( - { - !disabled && setDragging(handle); - }} - {...props} - > - - - - - - ); +interface Props { + disabled: boolean; + handle: string; + min: number; + max: number; + setDragging: (value: string) => void; + setFocus: (value: string) => void; + value: number; } + +export const Handle = React.forwardRef( + ( + { disabled, handle, min, max, setDragging, setFocus, value }, + ref + ) => { + return ( + { + !disabled && setDragging(handle); + }} + > + { + setFocus(handle); + }} + onBlur={() => { + setFocus(null); + }} + ref={ref} + readOnly + /> + + + ); + } +); diff --git a/src/components/inputs/Slider/Slider.style.ts b/src/components/inputs/Slider/Slider.style.ts index 7808d52a0..07cb9658e 100755 --- a/src/components/inputs/Slider/Slider.style.ts +++ b/src/components/inputs/Slider/Slider.style.ts @@ -1,36 +1,35 @@ -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; -import { white } from '../../../color'; +import { blue, white } from '../../../color'; import { rgba } from 'polished'; +export const SliderContainer = styled.div<{ range: boolean }>` + display: flex; + align-items: center; + gap: 16px; + margin: 0.75rem 0; + margin-left: ${({ range }) => (range ? 0 : '0.75rem')}; +`; + export const Label = styled.div<{ focused: boolean }>` - position: absolute; - top: 100%; - transform: translate(-30%, 0.5rem); - padding: 0.5rem; - width: 3rem; + padding: 0.2125rem; + width: 3.125rem; text-align: center; border-radius: 0.25rem; - border: 1px solid ${({ theme }) => rgba(theme.content.color, 0.334)}; - transition: 100ms ease-in-out; + border: 1px solid + ${({ theme, focused }) => + focused ? blue(500) : rgba(theme.content.color, 0.334)}; background: ${({ theme }) => theme.item.bg}; color: ${({ theme }) => theme.content.color}; - - ${(p) => - p.focused && - css` - transform: translate(-25%, 0.75rem) scale(1.075); - `} + font-size: 0.75rem; `; export const LabelInput = styled.input.attrs({ type: 'number' })<{ value: number; onChange: any; onFocus: any; - focused: boolean; }>` width: 100%; - font-size: 1rem; padding: 0; margin: 0; background: ${({ theme }) => theme.item.bg}; @@ -40,6 +39,7 @@ export const LabelInput = styled.input.attrs({ type: 'number' })<{ overflow: visible; appearance: none; text-align: center; + font-size: inherit; &::-webkit-inner-spin-button { appearance: none; @@ -55,8 +55,8 @@ export const HandleStyled = styled.div<{ focused?: boolean }>` border-radius: 50%; background: ${white}; position: absolute; - top: -0.5rem; - transform: translateX(-50%); + top: 0; + transform: translate(-50%, -45%); cursor: pointer; border: 1px solid ${({ theme }) => rgba(theme.content.color, 0.1)}; z-index: 2; @@ -75,12 +75,22 @@ export const Background = styled.div` export const ActiveRange = styled.div.attrs<{ values?: number[]; max?: number; -}>(({ values, max }) => ({ - style: { - width: ((values[1] - values[0]) / max) * 100 + '%', - left: (values[0] / max) * 100 + '%', - }, -}))<{ values?: number[]; max?: number }>` + range?: boolean; +}>(({ values, max, range }) => + range + ? { + style: { + width: ((values[1] - values[0]) / max) * 100 + '%', + left: (values[0] / max) * 100 + '%', + }, + } + : { + style: { + left: 0, + width: (values[0] / max) * 100 + '%', + }, + } +)<{ values?: number[]; max?: number; range?: boolean }>` pointer-events: none; position: absolute; height: 100%; diff --git a/src/components/inputs/Slider/Slider.test.tsx b/src/components/inputs/Slider/Slider.test.tsx new file mode 100644 index 000000000..54972a7b7 --- /dev/null +++ b/src/components/inputs/Slider/Slider.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ThemeProvider } from 'styled-components'; +import { themes } from '../../../themes'; +import { Slider } from './Slider'; + +describe('Slider', () => { + it('Renders slider', () => { + render( + + + + ); + + const slider = screen.getByLabelText('slider'); + expect(slider).toBeInTheDocument(); + }); + + it('Renders slider with 50% value', async () => { + render( + + + + ); + + const input = screen.getByRole('start-input'); + await userEvent.click(input); + fireEvent.change(input, { target: { value: 50 } }); + + const handle = screen.getByLabelText('startHandle'); + expect(handle).toHaveStyle({ left: '50%' }); + }); + + it('Renders slider with min and max', async () => { + render( + + + + ); + + const endInput = screen.getByRole('end-input'); + await userEvent.click(endInput); + fireEvent.change(endInput, { target: { value: 120 } }); + + const endHandle = screen.getByLabelText('endHandle'); + expect(endHandle).toHaveStyle({ left: '100%' }); + + const startInput = screen.getByRole('start-input'); + await userEvent.click(startInput); + fireEvent.change(startInput, { target: { value: -10 } }); + + const startHandle = screen.getByLabelText('startHandle'); + expect(startHandle).toHaveStyle({ left: '0%' }); + }); + + it('Renders slider and fire onChange', async () => { + const onChange = jest.fn(); + + render( + + + + ); + + const input = screen.getByRole('start-input'); + await userEvent.click(input); + fireEvent.change(input, { target: { value: 50 } }); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('Cannot change value of disabled slider', async () => { + render( + + + + ); + + const input = screen.getByRole('start-input'); + expect(input).toHaveAttribute('disabled'); + }); + + it('Limit values based on range', async () => { + render( + + + + ); + + const endInput = screen.getByRole('end-input'); + await userEvent.click(endInput); + fireEvent.change(endInput, { target: { value: 80 } }); + + const startInput = screen.getByRole('start-input'); + await userEvent.click(startInput); + fireEvent.change(startInput, { target: { value: 90 } }); + + const startHandle = screen.getByLabelText('startHandle'); + expect(startHandle).toHaveStyle({ left: '79%' }); + + const endHandle = screen.getByLabelText('endHandle'); + expect(endHandle).toHaveStyle({ left: '80%' }); + + await userEvent.click(endInput); + fireEvent.change(endInput, { target: { value: 20 } }); + + expect(endHandle).toHaveStyle({ left: '80%' }); + }); +}); diff --git a/src/components/inputs/Slider/Slider.tsx b/src/components/inputs/Slider/Slider.tsx index 2f9505139..90375cf61 100755 --- a/src/components/inputs/Slider/Slider.tsx +++ b/src/components/inputs/Slider/Slider.tsx @@ -10,16 +10,34 @@ import React, { import { Props } from './Slider.types'; import { initialState, reducer } from './Slider.state'; -import { Background, ActiveRange } from './Slider.style'; +import { + Background, + ActiveRange, + Label, + LabelInput, + SliderContainer, +} from './Slider.style'; import { Handle } from './Handle'; -import { white } from '../../../color'; -import { geometry } from '../../../utils'; +import { geometry, useOutsideClick } from '../../../utils'; +import { + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + end, + home, +} from '../../../utils/events/KeyCodes'; interface State { values: number[]; trackRect: any; - focused: boolean; + focused: + | 'startHandle' + | 'endHandle' + | 'startInput' + | 'endInput' + | null; dragging: 'startHandle' | 'endHandle'; } @@ -35,50 +53,101 @@ export function Slider({ range, ...props }: Props) { - const ref = useRef(null); + const trackRef = useRef(null); + const startHandleRef = useRef(null); + const endHandleRef = useRef(null); + + useOutsideClick([trackRef], () => { + setFocus(null); + }); + const [state, dispatch] = useReducer( reducer, initialState(initialValues) ); + const { values, trackRect, focused, dragging }: State = state; + function dispatchChangeEvent(callback) { + const currentInput = + focused === 'startHandle' ? startHandleRef : endHandleRef; + + const event = new Event('change', { bubbles: true }); + currentInput?.current?.dispatchEvent(event); + callback && callback(event); + } + function setDragging(payload) { - setFocus(payload); + if (payload) setFocus(payload); return dispatch({ type: 'SET_DRAGGING', payload }); } function setValue(payload) { + dispatchChangeEvent(onChange); return dispatch({ type: 'SET_VALUES', payload }); } function setFocus(payload) { - return () => dispatch({ type: 'SET_FOCUS', payload }); + return dispatch({ type: 'SET_FOCUS', payload }); } function setStartValue(value) { - const newValue = constrain(min, max)(value); + const newValue = constrain( + min, + range ? values[1] - 1 : max + )(value); setValue([newValue, values[1]]); } function setEndValue(value) { - const newValue = constrain(min, max)(value); + const newValue = constrain(values[0] + 1, max)(value); setValue([values[0], newValue]); } useLayoutEffect(() => { - const { left, width } = geometry(ref.current); + const { left, width } = geometry(trackRef.current); dispatch({ type: 'SET_TRACK_RECT', payload: { left, width } }); }, [dragging]); useEffect(() => { - const mouseup = (event) => { + const mouseup = () => { + dispatchChangeEvent(onDragEnd); dragging && setDragging(false); - onDragEnd && onDragEnd(event); + }; + + const keydown = (event) => { + if (!focused || !isHandleFocused(focused)) return; + + const action = + focused === 'startHandle' ? setStartValue : setEndValue; + const currentValue = + focused === 'startHandle' ? values[0] : values[1]; + + switch (event.keyCode) { + case arrowDown: + case arrowLeft: + action(currentValue - 1); + break; + case arrowUp: + case arrowRight: + action(currentValue + 1); + break; + case home: + action(min); + break; + case end: + action(max); + break; + } }; document && document.addEventListener('mouseup', mouseup); - return () => + document && document.addEventListener('keydown', keydown); + + return () => { document && document.removeEventListener('mouseup', mouseup); + document && document.removeEventListener('keydown', keydown); + }; }); useEffect(() => { @@ -97,45 +166,88 @@ export function Slider({ }); return ( -
- + + {range && ( + + )} + {range && ( )} -
+ + ); } @@ -145,19 +257,28 @@ interface TrackProps { ref: Ref; values: number[]; max: number; + range: boolean; } const Track = forwardRef( - ({ children, values, max, ...props }: TrackProps, ref) => { + ({ children, values, max, range, ...props }: TrackProps, ref) => { return ( {children} - + ); } ); +function isHandleFocused(focusedElement: State['focused']) { + return focusedElement.includes('Handle'); +} + function constrainedPosition(event, element, min, max) { const left = event.clientX - element.left; const pos = Math.round((left / element.width) * max); diff --git a/src/utils/events/KeyCodes.ts b/src/utils/events/KeyCodes.ts index 63ebfe8af..0fa2e8686 100644 --- a/src/utils/events/KeyCodes.ts +++ b/src/utils/events/KeyCodes.ts @@ -1,5 +1,9 @@ export const arrowRight = 39; export const arrowLeft = 37; +export const arrowUp = 38; +export const arrowDown = 40; +export const home = 36; +export const end = 35; export const enter = 13; export const esc = 27; export const spacebar = 32;