Skip to content

Commit 9499083

Browse files
CodySchaafoliviertassinarieps1lon
authored
[Slider] Allow mobile VO users to interact with Sliders (#23902)
Co-authored-by: Olivier Tassinari <[email protected]> Co-authored-by: eps1lon <[email protected]>
1 parent cd56c3a commit 9499083

File tree

10 files changed

+580
-284
lines changed

10 files changed

+580
-284
lines changed

docs/src/pages/components/slider/CustomizedSlider.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,21 @@ const AirbnbSlider = styled(Slider)({
160160
});
161161

162162
function AirbnbThumbComponent(props) {
163+
const { children, ...other } = props;
163164
return (
164-
<SliderThumb {...props}>
165+
<SliderThumb {...other}>
166+
{children}
165167
<span className="bar" />
166168
<span className="bar" />
167169
<span className="bar" />
168170
</SliderThumb>
169171
);
170172
}
171173

174+
AirbnbThumbComponent.propTypes = {
175+
children: PropTypes.node,
176+
};
177+
172178
export default function CustomizedSlider() {
173179
return (
174180
<Root>

docs/src/pages/components/slider/CustomizedSlider.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,13 @@ const AirbnbSlider = styled(Slider)({
158158
},
159159
});
160160

161-
function AirbnbThumbComponent(props: any) {
161+
interface AirbnbThumbComponentProps extends React.HTMLAttributes<unknown> {}
162+
163+
function AirbnbThumbComponent(props: AirbnbThumbComponentProps) {
164+
const { children, ...other } = props;
162165
return (
163-
<SliderThumb {...props}>
166+
<SliderThumb {...other}>
167+
{children}
164168
<span className="bar" />
165169
<span className="bar" />
166170
<span className="bar" />
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as React from 'react';
2+
import Box from '@material-ui/core/Box';
3+
import Typography from '@material-ui/core/Typography';
4+
import Slider from '@material-ui/core/Slider';
5+
6+
export default function VerticalSlider() {
7+
function preventHorizontalKeyboardNavigation(event) {
8+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
9+
event.preventDefault();
10+
}
11+
}
12+
13+
return (
14+
<React.Fragment>
15+
<Typography id="vertical-accessible-slider" gutterBottom>
16+
Temperature
17+
</Typography>
18+
<Box sx={{ height: 300 }}>
19+
<Slider
20+
sx={{
21+
'& input[type="range"]': {
22+
WebkitAppearance: 'slider-vertical',
23+
},
24+
}}
25+
orientation="vertical"
26+
defaultValue={30}
27+
aria-labelledby="vertical-accessible-slider"
28+
onKeyDown={preventHorizontalKeyboardNavigation}
29+
/>
30+
</Box>
31+
</React.Fragment>
32+
);
33+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as React from 'react';
2+
import Box from '@material-ui/core/Box';
3+
import Typography from '@material-ui/core/Typography';
4+
import Slider from '@material-ui/core/Slider';
5+
6+
export default function VerticalSlider() {
7+
function preventHorizontalKeyboardNavigation(event: React.KeyboardEvent) {
8+
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
9+
event.preventDefault();
10+
}
11+
}
12+
13+
return (
14+
<React.Fragment>
15+
<Typography id="vertical-accessible-slider" gutterBottom>
16+
Temperature
17+
</Typography>
18+
<Box sx={{ height: 300 }}>
19+
<Slider
20+
sx={{
21+
'& input[type="range"]': {
22+
WebkitAppearance: 'slider-vertical',
23+
},
24+
}}
25+
orientation="vertical"
26+
defaultValue={30}
27+
aria-labelledby="vertical-accessible-slider"
28+
onKeyDown={preventHorizontalKeyboardNavigation}
29+
/>
30+
</Box>
31+
</React.Fragment>
32+
);
33+
}

docs/src/pages/components/slider/slider.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ Here are some examples of customizing the component. You can learn more about th
7777

7878
{{"demo": "pages/components/slider/VerticalSlider.js"}}
7979

80+
**WARNING**: Chrome, Safari and newer Edge versions i.e. any browser based on WebKit exposes `<Slider orientation="vertical" />` as horizontal ([chromium issue #1158217](https://bugs.chromium.org/p/chromium/issues/detail?id=1158217)).
81+
By applying `-webkit-appearance: slider-vertical;` the slider is exposed as vertical.
82+
83+
However, by applying `-webkit-appearance: slider-vertical;` keyboard navigation for horizontal keys (<kbd>Arrow Left</kbd>, <kbd>Arrow Right</kbd>) is reversed ([chromium issue #1162640](https://bugs.chromium.org/p/chromium/issues/detail?id=1162640)).
84+
Usually, up and right should increase and left and down should decrease the value.
85+
If you apply `-webkit-appearance` you could prevent keyboard navigation for horizontal arrow keys for a truly vertical slider.
86+
This might be less confusing to users compared to a change in direction.
87+
88+
{{"demo": "pages/components/slider/VerticalAccessibleSlider.js"}}
89+
8090
## Track
8191

8292
The track shows the range available for user selection.

packages/material-ui-unstyled/src/SliderUnstyled/SliderUnstyled.js

Lines changed: 85 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
unstable_useEventCallback as useEventCallback,
1010
unstable_useForkRef as useForkRef,
1111
unstable_useControlled as useControlled,
12+
visuallyHidden,
1213
} from '@material-ui/utils';
1314
import isHostComponent from '../utils/isHostComponent';
1415
import sliderUnstyledClasses from './sliderUnstyledClasses';
@@ -19,6 +20,9 @@ function asc(a, b) {
1920
}
2021

2122
function clamp(value, min, max) {
23+
if (value == null) {
24+
return min;
25+
}
2226
return Math.min(Math.max(min, value), max);
2327
}
2428

@@ -102,7 +106,7 @@ function focusThumb({ sliderRef, activeIndex, setActive }) {
102106
!sliderRef.current.contains(doc.activeElement) ||
103107
Number(doc.activeElement.getAttribute('data-index')) !== activeIndex
104108
) {
105-
sliderRef.current.querySelector(`[role="slider"][data-index="${activeIndex}"]`).focus();
109+
sliderRef.current.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus();
106110
}
107111

108112
if (setActive) {
@@ -209,7 +213,7 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
209213
isRtl = false,
210214
components = {},
211215
componentsProps = {},
212-
/* eslint-disable react/prop-types */
216+
/* eslint-disable-next-line react/prop-types */
213217
theme,
214218
...other
215219
} = props;
@@ -223,7 +227,7 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
223227

224228
const [valueDerived, setValueState] = useControlled({
225229
controlled: valueProp,
226-
default: defaultValue,
230+
default: defaultValue ?? min,
227231
name: 'Slider',
228232
});
229233

@@ -304,62 +308,30 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
304308
setFocusVisible(-1);
305309
}
306310

307-
const handleKeyDown = useEventCallback((event) => {
311+
const handleHiddenInputChange = useEventCallback((event) => {
308312
const index = Number(event.currentTarget.getAttribute('data-index'));
309313
const value = values[index];
310-
const tenPercents = (max - min) / 10;
311314
const marksValues = marks.map((mark) => mark.value);
312315
const marksIndex = marksValues.indexOf(value);
313-
let newValue;
314-
const increaseKey = isRtl ? 'ArrowLeft' : 'ArrowRight';
315-
const decreaseKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
316-
317-
switch (event.key) {
318-
case 'Home':
319-
newValue = min;
320-
break;
321-
case 'End':
322-
newValue = max;
323-
break;
324-
case 'PageUp':
325-
if (step) {
326-
newValue = value + tenPercents;
327-
}
328-
break;
329-
case 'PageDown':
330-
if (step) {
331-
newValue = value - tenPercents;
332-
}
333-
break;
334-
case increaseKey:
335-
case 'ArrowUp':
336-
if (step) {
337-
newValue = value + step;
338-
} else {
339-
newValue = marksValues[marksIndex + 1] || marksValues[marksValues.length - 1];
340-
}
341-
break;
342-
case decreaseKey:
343-
case 'ArrowDown':
344-
if (step) {
345-
newValue = value - step;
346-
} else {
347-
newValue = marksValues[marksIndex - 1] || marksValues[0];
348-
}
349-
break;
350-
default:
351-
return;
352-
}
353316

354-
// Prevent scroll of the page
355-
event.preventDefault();
317+
let newValue = event.target.valueAsNumber;
356318

357-
if (step) {
358-
newValue = roundValueToStep(newValue, step, min);
319+
if (marks && step == null) {
320+
newValue = newValue < value ? marksValues[marksIndex - 1] : marksValues[marksIndex + 1];
359321
}
360322

361323
newValue = clamp(newValue, min, max);
362324

325+
if (marks && step == null) {
326+
const markValues = marks.map((mark) => mark.value);
327+
const currentMarkIndex = markValues.indexOf(values[index]);
328+
329+
newValue =
330+
newValue < values[index]
331+
? markValues[currentMarkIndex - 1]
332+
: markValues[currentMarkIndex + 1];
333+
}
334+
363335
if (range) {
364336
const previousValue = newValue;
365337
newValue = setValueIndex({
@@ -377,6 +349,7 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
377349
if (handleChange) {
378350
handleChange(event, newValue);
379351
}
352+
380353
if (onChangeCommitted) {
381354
onChangeCommitted(event, newValue);
382355
}
@@ -650,7 +623,6 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
650623
className={clsx(utilityClasses.track, trackProps.className)}
651624
style={{ ...trackStyle, ...trackProps.style }}
652625
/>
653-
<input value={values.join(',')} name={name} type="hidden" />
654626
{marks.map((mark, index) => {
655627
const percent = valueToPercent(mark.value, min, max);
656628
const style = axisProps[axis].offset(percent);
@@ -715,55 +687,72 @@ const SliderUnstyled = React.forwardRef(function SliderUnstyled(props, ref) {
715687
const ValueLabelComponent = valueLabelDisplay === 'off' ? Forward : ValueLabel;
716688

717689
return (
718-
<ValueLabelComponent
719-
key={index}
720-
valueLabelFormat={valueLabelFormat}
721-
valueLabelDisplay={valueLabelDisplay}
722-
value={
723-
typeof valueLabelFormat === 'function'
724-
? valueLabelFormat(scale(value), index)
725-
: valueLabelFormat
726-
}
727-
index={index}
728-
open={open === index || active === index || valueLabelDisplay === 'on'}
729-
disabled={disabled}
730-
{...valueLabelProps}
731-
className={clsx(utilityClasses.valueLabel, valueLabelProps.className)}
732-
{...(!isHostComponent(ValueLabel) && {
733-
styleProps: { ...styleProps, ...valueLabelProps.styleProps },
734-
theme,
735-
})}
736-
>
737-
<Thumb
738-
tabIndex={disabled ? null : 0}
739-
role="slider"
740-
data-index={index}
741-
aria-label={getAriaLabel ? getAriaLabel(index) : ariaLabel}
742-
aria-labelledby={ariaLabelledby}
743-
aria-orientation={orientation}
744-
aria-valuemax={scale(max)}
745-
aria-valuemin={scale(min)}
746-
aria-valuenow={scale(value)}
747-
aria-valuetext={
748-
getAriaValueText ? getAriaValueText(scale(value), index) : ariaValuetext
690+
<React.Fragment key={index}>
691+
<ValueLabelComponent
692+
valueLabelFormat={valueLabelFormat}
693+
valueLabelDisplay={valueLabelDisplay}
694+
value={
695+
typeof valueLabelFormat === 'function'
696+
? valueLabelFormat(scale(value), index)
697+
: valueLabelFormat
749698
}
750-
onKeyDown={handleKeyDown}
751-
onFocus={handleFocus}
752-
onBlur={handleBlur}
753-
onMouseOver={handleMouseOver}
754-
onMouseLeave={handleMouseLeave}
755-
{...thumbProps}
756-
className={clsx(utilityClasses.thumb, thumbProps.className, {
757-
[utilityClasses.active]: active === index,
758-
[utilityClasses.focusVisible]: focusVisible === index,
759-
})}
760-
{...(!isHostComponent(Thumb) && {
761-
styleProps: { ...styleProps, ...thumbProps.styleProps },
699+
index={index}
700+
open={open === index || active === index || valueLabelDisplay === 'on'}
701+
disabled={disabled}
702+
{...valueLabelProps}
703+
className={clsx(utilityClasses.valueLabel, valueLabelProps.className)}
704+
{...(!isHostComponent(ValueLabel) && {
705+
styleProps: { ...styleProps, ...valueLabelProps.styleProps },
762706
theme,
763707
})}
764-
style={{ ...style, ...thumbProps.style }}
765-
/>
766-
</ValueLabelComponent>
708+
>
709+
<Thumb
710+
data-index={index}
711+
onMouseOver={handleMouseOver}
712+
onMouseLeave={handleMouseLeave}
713+
{...thumbProps}
714+
className={clsx(utilityClasses.thumb, thumbProps.className, {
715+
[utilityClasses['active']]: active === index,
716+
[utilityClasses['focusVisible']]: focusVisible === index,
717+
})}
718+
{...(!isHostComponent(Thumb) && {
719+
styleProps: { ...styleProps, ...thumbProps.styleProps },
720+
theme,
721+
})}
722+
style={{ ...style, ...thumbProps.style }}
723+
>
724+
<input
725+
data-index={index}
726+
aria-label={getAriaLabel ? getAriaLabel(index) : ariaLabel}
727+
aria-labelledby={ariaLabelledby}
728+
aria-orientation={orientation}
729+
aria-valuemax={scale(max)}
730+
aria-valuemin={scale(min)}
731+
aria-valuenow={scale(value)}
732+
aria-valuetext={
733+
getAriaValueText ? getAriaValueText(scale(value), index) : ariaValuetext
734+
}
735+
onFocus={handleFocus}
736+
onBlur={handleBlur}
737+
name={name}
738+
type="range"
739+
min={props.min}
740+
max={props.max}
741+
step={props.step}
742+
disabled={disabled}
743+
value={values[index]}
744+
onChange={handleHiddenInputChange}
745+
style={{
746+
...visuallyHidden,
747+
direction: isRtl ? 'rtl' : 'ltr',
748+
// So that VoiceOver's focus indicator matches the thumb's dimensions
749+
width: '100%',
750+
height: '100%',
751+
}}
752+
/>
753+
</Thumb>
754+
</ValueLabelComponent>
755+
</React.Fragment>
767756
);
768757
})}
769758
</Root>

0 commit comments

Comments
 (0)