Skip to content

Commit 3ec0fbb

Browse files
committed
feat(slider): enable keyboard controls
1 parent 7af4faa commit 3ec0fbb

File tree

4 files changed

+492
-26
lines changed

4 files changed

+492
-26
lines changed

src/components/Slider/Slider.js

Lines changed: 161 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// helper functions and event handling basically copied from Material UI (https://github.com/mui-org/material-ui) Slider component
12
import React, { useRef } from 'react';
23
import propTypes from 'prop-types';
34

@@ -10,9 +11,10 @@ import {
1011
createHatchedBackground
1112
} from '../common';
1213
import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled';
14+
import useForkRef from '../common/hooks/useForkRef';
15+
import { useIsFocusVisible } from '../common/hooks/focusVisible';
1316
import Cutout from '../Cutout/Cutout';
1417

15-
// helper functions and event handling basically copied from Material UI (https://github.com/mui-org/material-ui) Slider component
1618
function trackFinger(event, touchId) {
1719
if (touchId.current !== undefined && event.changedTouches) {
1820
for (let i = 0; i < event.changedTouches.length; i += 1) {
@@ -82,19 +84,51 @@ function roundValueToStep(value, step, min) {
8284
const nearest = Math.round((value - min) / step) * step + min;
8385
return Number(nearest.toFixed(getDecimalPrecision(step)));
8486
}
87+
function focusThumb(sliderRef) {
88+
if (!sliderRef.current.contains(document.activeElement)) {
89+
sliderRef.current.querySelector(`#swag`).focus();
90+
}
91+
}
8592
const Wrapper = styled.div`
8693
display: inline-block;
8794
position: relative;
8895
touch-action: none;
96+
&:before {
97+
content: '';
98+
display: inline-block;
99+
position: absolute;
100+
top: -2px;
101+
left: -15px;
102+
width: calc(100% + 30px);
103+
height: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')};
104+
${({ isFocused, theme }) =>
105+
isFocused &&
106+
`
107+
outline: 2px dotted ${theme.text};
108+
`}
109+
}
110+
89111
${({ vertical, size }) =>
90112
vertical
91113
? css`
92114
height: ${size};
93115
margin-right: 1.5rem;
116+
&:before {
117+
left: -2px;
118+
top: -15px;
119+
height: calc(100% + 30px);
120+
width: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')};
121+
}
94122
`
95123
: css`
96124
width: ${size};
97125
margin-bottom: 1.5rem;
126+
&:before {
127+
top: -2px;
128+
left: -15px;
129+
width: calc(100% + 30px);
130+
height: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')};
131+
}
98132
`}
99133
100134
pointer-events: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')};
@@ -220,38 +254,124 @@ const Mark = styled.div`
220254
`}
221255
`;
222256

223-
const Slider = ({
224-
value,
225-
defaultValue,
226-
step,
227-
min,
228-
max,
229-
size,
230-
marks: marksProp,
231-
onChange,
232-
onChangeCommitted,
233-
onMouseDown,
234-
name,
235-
vertical,
236-
variant,
237-
disabled,
238-
...otherProps
239-
}) => {
257+
const Slider = React.forwardRef(function Slider(props, ref) {
258+
const {
259+
value,
260+
defaultValue,
261+
step,
262+
min,
263+
max,
264+
size,
265+
marks: marksProp,
266+
onChange,
267+
onChangeCommitted,
268+
onMouseDown,
269+
name,
270+
vertical,
271+
variant,
272+
disabled,
273+
...otherProps
274+
} = props;
240275
const Groove = variant === 'flat' ? StyledFlatGroove : StyledGroove;
276+
241277
const [valueDerived, setValueState] = useControlledOrUncontrolled({
242278
value,
243279
defaultValue
244280
});
245281

282+
const {
283+
isFocusVisible,
284+
onBlurVisible,
285+
ref: focusVisibleRef
286+
} = useIsFocusVisible();
287+
const [focusVisible, setFocusVisible] = React.useState(false);
246288
const sliderRef = useRef();
289+
const handleFocusRef = useForkRef(focusVisibleRef, sliderRef);
290+
const handleRef = useForkRef(ref, handleFocusRef);
291+
292+
const handleFocus = useEventCallback(event => {
293+
if (isFocusVisible(event)) {
294+
setFocusVisible(true);
295+
}
296+
});
297+
const handleBlur = useEventCallback(() => {
298+
if (focusVisible !== false) {
299+
setFocusVisible(false);
300+
onBlurVisible();
301+
}
302+
});
303+
247304
const touchId = React.useRef();
248305

249306
const marks =
250-
marksProp === true
251-
? Array(1 + (max - min) / step)
252-
.fill({ label: null })
253-
.map((mark, i) => ({ ...mark, value: i * step }))
254-
: marksProp;
307+
marksProp === true && step !== null
308+
? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({
309+
value: min + step * index
310+
}))
311+
: marksProp || [];
312+
313+
const handleKeyDown = useEventCallback(event => {
314+
const tenPercents = (max - min) / 10;
315+
const marksValues = marks.map(mark => mark.value);
316+
const marksIndex = marksValues.indexOf(valueDerived);
317+
let newValue;
318+
319+
switch (event.key) {
320+
case 'Home':
321+
newValue = min;
322+
break;
323+
case 'End':
324+
newValue = max;
325+
break;
326+
case 'PageUp':
327+
if (step) {
328+
newValue = valueDerived + tenPercents;
329+
}
330+
break;
331+
case 'PageDown':
332+
if (step) {
333+
newValue = valueDerived - tenPercents;
334+
}
335+
break;
336+
case 'ArrowRight':
337+
case 'ArrowUp':
338+
if (step) {
339+
newValue = valueDerived + step;
340+
} else {
341+
newValue =
342+
marksValues[marksIndex + 1] || marksValues[marksValues.length - 1];
343+
}
344+
break;
345+
case 'ArrowLeft':
346+
case 'ArrowDown':
347+
if (step) {
348+
newValue = valueDerived - step;
349+
} else {
350+
newValue = marksValues[marksIndex - 1] || marksValues[0];
351+
}
352+
break;
353+
default:
354+
return;
355+
}
356+
357+
// Prevent scroll of the page
358+
event.preventDefault();
359+
if (step) {
360+
newValue = roundValueToStep(newValue, step, min);
361+
}
362+
363+
newValue = clamp(newValue, min, max);
364+
365+
setValueState(newValue);
366+
setFocusVisible(true);
367+
368+
if (onChange) {
369+
onChange(newValue);
370+
}
371+
if (onChangeCommitted) {
372+
onChangeCommitted(newValue);
373+
}
374+
});
255375

256376
const getNewValue = React.useCallback(
257377
finger => {
@@ -288,7 +408,9 @@ const Slider = ({
288408
}
289409
const newValue = getNewValue(finger);
290410

411+
focusThumb(sliderRef);
291412
setValueState(newValue);
413+
setFocusVisible(true);
292414

293415
if (onChange) {
294416
onChange(newValue);
@@ -302,6 +424,7 @@ const Slider = ({
302424
}
303425

304426
const newValue = getNewValue(finger);
427+
305428
if (onChangeCommitted) {
306429
onChangeCommitted(newValue);
307430
}
@@ -322,8 +445,11 @@ const Slider = ({
322445
event.preventDefault();
323446
const finger = trackFinger(event, touchId);
324447
const newValue = getNewValue(finger);
448+
focusThumb(sliderRef);
325449

326450
setValueState(newValue);
451+
setFocusVisible(true);
452+
327453
if (onChange) {
328454
onChange(newValue);
329455
}
@@ -341,7 +467,10 @@ const Slider = ({
341467
}
342468
const finger = trackFinger(event, touchId);
343469
const newValue = getNewValue(finger);
470+
focusThumb(sliderRef);
471+
344472
setValueState(newValue);
473+
setFocusVisible(true);
345474

346475
if (onChange) {
347476
onChange(newValue);
@@ -371,7 +500,9 @@ const Slider = ({
371500
vertical={vertical}
372501
size={size}
373502
onMouseDown={handleMouseDown}
374-
ref={sliderRef}
503+
ref={handleRef}
504+
isFocused={focusVisible}
505+
hasMarks={marks.length}
375506
{...otherProps}
376507
>
377508
{/* should we keep the hidden input ? */}
@@ -403,10 +534,12 @@ const Slider = ({
403534
<Groove vertical={vertical} variant={variant} />
404535
<Thumb
405536
role='slider'
537+
id='swag'
406538
style={{
407539
[vertical ? 'bottom' : 'left']: `${(vertical ? -100 : 0) +
408540
(100 * valueDerived) / (max - min)}%`
409541
}}
542+
tabIndex={disabled ? null : 0}
410543
vertical={vertical}
411544
variant={variant}
412545
isDisabled={disabled}
@@ -415,10 +548,13 @@ const Slider = ({
415548
aria-valuemax={max}
416549
aria-valuemin={min}
417550
aria-valuenow={valueDerived}
551+
onKeyDown={handleKeyDown}
552+
onFocus={handleFocus}
553+
onBlur={handleBlur}
418554
/>
419555
</Wrapper>
420556
);
421-
};
557+
});
422558

423559
Slider.defaultProps = {
424560
defaultValue: undefined,

0 commit comments

Comments
 (0)