Skip to content

Commit

Permalink
Redesign Slider and add a11y support (#282)
Browse files Browse the repository at this point in the history
* Redesign Slider

* Added a11y support

* Code review - ts fixes, support for keyboard focus

* Small styling fix - margins and input focus
  • Loading branch information
nathanbrud authored Jul 3, 2023
1 parent 408b27c commit ef0f0a4
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 136 deletions.
117 changes: 42 additions & 75 deletions src/components/inputs/Slider/Handle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HandleStyled
focused={focused === handle || dragging === handle}
style={{ left: (value / max) * 100 + '%' }}
onMouseDown={() => {
!disabled && setDragging(handle);
}}
{...props}
>
<Hidden
min={min}
max={max}
value={value}
onFocus={setFocus(handle)}
onBlur={setFocus(false)}
ref={ref}
readOnly
/>
<Focus
parent={Hidden}
radius={50}
focused={focused === handle || dragging === handle}
/>

<Label focused={focused}>
{editableLabel ? (
<>
<LabelInput
value={value}
onChange={(e) => setValue(e.target.value)}
onFocus={setFocus(handle)}
onBlur={setFocus(false)}
focused={focused === handle}
/>
<Focus parent={LabelInput} distance={1} />
</>
) : (
formatter(value)
)}
</Label>
</HandleStyled>
);
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<HTMLInputElement, Props>(
(
{ disabled, handle, min, max, setDragging, setFocus, value },
ref
) => {
return (
<HandleStyled
style={{ left: (value / max) * 100 + '%' }}
aria-label={handle}
onMouseDown={() => {
!disabled && setDragging(handle);
}}
>
<Hidden
min={min}
max={max}
value={value}
onFocus={() => {
setFocus(handle);
}}
onBlur={() => {
setFocus(null);
}}
ref={ref}
readOnly
/>
<Focus parent={Hidden} radius={50} isKeyboardOnly />
</HandleStyled>
);
}
);
60 changes: 35 additions & 25 deletions src/components/inputs/Slider/Slider.style.ts
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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%;
Expand Down
122 changes: 122 additions & 0 deletions src/components/inputs/Slider/Slider.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider theme={themes['light']}>
<Slider />
</ThemeProvider>
);

const slider = screen.getByLabelText('slider');
expect(slider).toBeInTheDocument();
});

it('Renders slider with 50% value', async () => {
render(
<ThemeProvider theme={themes['light']}>
<Slider initialValues={[0, 100]} editableLabel />
</ThemeProvider>
);

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(
<ThemeProvider theme={themes['light']}>
<Slider
initialValues={[0, 100]}
editableLabel
min={0}
max={100}
range
/>
</ThemeProvider>
);

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(
<ThemeProvider theme={themes['light']}>
<Slider
onChange={onChange}
initialValues={[0, 100]}
editableLabel
/>
</ThemeProvider>
);

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(
<ThemeProvider theme={themes['light']}>
<Slider initialValues={[0, 100]} disabled editableLabel />
</ThemeProvider>
);

const input = screen.getByRole('start-input');
expect(input).toHaveAttribute('disabled');
});

it('Limit values based on range', async () => {
render(
<ThemeProvider theme={themes['light']}>
<Slider initialValues={[0, 100]} range editableLabel />
</ThemeProvider>
);

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%' });
});
});
Loading

0 comments on commit ef0f0a4

Please sign in to comment.