Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit d2066ba

Browse files
authored
Improve accessibility of font slider (#10473)
* Clamp font size when disabling "Use custom size" * Switch Slider to use a semantic input range element * Iterate * delint * delint * snapshot * Iterate * Iterate * Fix step size * Add focus outline to slider * Derp
1 parent bef6eca commit d2066ba

File tree

5 files changed

+193
-290
lines changed

5 files changed

+193
-290
lines changed

res/css/views/elements/_Slider.pcss

Lines changed: 104 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -16,92 +16,110 @@ limitations under the License.
1616

1717
.mx_Slider {
1818
position: relative;
19-
margin: 0px;
20-
flex-grow: 1;
21-
}
22-
23-
.mx_Slider_dotContainer {
24-
display: flex;
25-
flex-direction: row;
26-
justify-content: space-between;
27-
}
28-
29-
.mx_Slider_bar {
30-
display: flex;
31-
box-sizing: border-box;
32-
position: absolute;
33-
height: 1em;
34-
width: 100%;
35-
padding: 0 0.5em; /* half the width of a dot. */
36-
align-items: center;
37-
}
38-
39-
.mx_Slider_bar > hr {
40-
width: 100%;
41-
height: 0.4em;
42-
background-color: $slider-background-color;
43-
border: 0;
44-
}
45-
46-
.mx_Slider_selection {
47-
display: flex;
48-
align-items: center;
49-
width: calc(100% - 1em); /* 2 * half the width of a dot */
50-
height: 1em;
51-
position: absolute;
52-
pointer-events: none;
53-
}
54-
55-
.mx_Slider_selectionDot {
56-
position: absolute;
57-
width: $slider-selection-dot-size;
58-
height: $slider-selection-dot-size;
59-
background-color: $accent;
60-
border-radius: 50%;
61-
z-index: 10;
62-
}
63-
64-
.mx_Slider_selectionText {
65-
color: $muted-fg-color;
66-
font-size: $font-14px;
67-
position: relative;
68-
text-align: center;
69-
top: 30px;
70-
width: 100%;
71-
}
72-
73-
.mx_Slider_selection > hr {
7419
margin: 0;
75-
border: 0.2em solid $accent;
76-
}
77-
78-
.mx_Slider_dot {
79-
height: $slider-dot-size;
80-
width: $slider-dot-size;
81-
border-radius: 50%;
82-
background-color: $slider-background-color;
83-
z-index: 0;
84-
}
85-
86-
.mx_Slider_dotActive {
87-
background-color: $accent;
88-
}
89-
90-
.mx_Slider_dotValue {
91-
display: flex;
92-
flex-direction: column;
93-
align-items: center;
94-
color: $slider-background-color;
95-
}
96-
97-
/* The following is a hack to center the labels without adding */
98-
/* any width to the slider's dots. */
99-
.mx_Slider_labelContainer {
100-
width: 1em;
101-
}
20+
flex-grow: 1;
10221

103-
.mx_Slider_label {
104-
position: relative;
105-
width: fit-content;
106-
left: -50%;
22+
input[type="range"] {
23+
height: 2.4em;
24+
appearance: none;
25+
width: 100%;
26+
background: none;
27+
font-size: 1em; // set base multiplier for em units applied later
28+
29+
--active-color: $accent;
30+
31+
&:disabled {
32+
cursor: not-allowed;
33+
34+
--active-color: $slider-background-color;
35+
}
36+
37+
&:focus:not(.focus-visible) {
38+
outline: none;
39+
}
40+
41+
&::-webkit-slider-runnable-track {
42+
width: 100%;
43+
height: 0.4em;
44+
background: $slider-background-color;
45+
border-radius: 5px;
46+
border: 0 solid #000000;
47+
}
48+
&::-webkit-slider-thumb {
49+
border: 0 solid #000000;
50+
width: $slider-selection-dot-size;
51+
height: $slider-selection-dot-size;
52+
background: var(--active-color);
53+
border-radius: 50%;
54+
-webkit-appearance: none;
55+
margin-top: calc(2px + 1.2em - $slider-selection-dot-size);
56+
}
57+
&:focus::-webkit-slider-runnable-track {
58+
background: $slider-background-color;
59+
}
60+
61+
&::-moz-range-track {
62+
width: 100%;
63+
height: 0.4em;
64+
background: $slider-background-color;
65+
border-radius: 5px;
66+
border: 0 solid #000000;
67+
}
68+
&::-moz-range-progress {
69+
height: 0.4em;
70+
background: var(--active-color);
71+
border-radius: 5px;
72+
border: 0 solid #000000;
73+
}
74+
&::-moz-range-thumb {
75+
border: 0 solid #000000;
76+
width: $slider-selection-dot-size;
77+
height: $slider-selection-dot-size;
78+
background: var(--active-color);
79+
border-radius: 50%;
80+
}
81+
82+
&::-ms-track {
83+
width: 100%;
84+
height: 0.4em;
85+
background: transparent;
86+
border-color: transparent;
87+
color: transparent;
88+
}
89+
&::-ms-fill-lower,
90+
&::-ms-fill-upper {
91+
background: $slider-background-color;
92+
border: 0 solid #000000;
93+
border-radius: 10px;
94+
}
95+
&::-ms-thumb {
96+
margin-top: 1px;
97+
width: $slider-selection-dot-size;
98+
height: $slider-selection-dot-size;
99+
background: var(--active-color);
100+
border-radius: 50%;
101+
}
102+
&:focus::-ms-fill-upper {
103+
background: $slider-background-color;
104+
}
105+
&::-ms-fill-lower,
106+
&:focus::-ms-fill-lower {
107+
background: var(--active-color);
108+
}
109+
}
110+
111+
output {
112+
position: absolute;
113+
left: 50%;
114+
transform: translateX(-50%);
115+
116+
font-size: 1em; // set base multiplier for em units applied later
117+
text-align: center;
118+
top: 3em;
119+
120+
.mx_Slider_selection_label {
121+
color: $muted-fg-color;
122+
font-size: $font-14px;
123+
}
124+
}
107125
}

src/components/views/elements/Slider.tsx

Lines changed: 37 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -15,137 +15,71 @@ limitations under the License.
1515
*/
1616

1717
import * as React from "react";
18+
import { ChangeEvent } from "react";
1819

1920
interface IProps {
2021
// A callback for the selected value
21-
onSelectionChange: (value: number) => void;
22+
onChange: (value: number) => void;
2223

2324
// The current value of the slider
2425
value: number;
2526

26-
// The range and values of the slider
27-
// Currently only supports an ascending, constant interval range
28-
values: number[];
27+
// The min and max of the slider
28+
min: number;
29+
max: number;
30+
// The step size of the slider, can be a number or "any"
31+
step: number | "any";
2932

30-
// A function for formatting the the values
33+
// A function for formatting the values
3134
displayFunc: (value: number) => string;
3235

3336
// Whether the slider is disabled
3437
disabled: boolean;
3538
}
3639

37-
export default class Slider extends React.Component<IProps> {
38-
// offset is a terrible inverse approximation.
39-
// if the values represents some function f(x) = y where x is the
40-
// index of the array and y = values[x] then offset(f, y) = x
41-
// s.t f(x) = y.
42-
// it assumes a monotonic function and interpolates linearly between
43-
// y values.
44-
// Offset is used for finding the location of a value on a
45-
// non linear slider.
46-
private offset(values: number[], value: number): number {
47-
// the index of the first number greater than value.
48-
const closest = values.reduce((prev, curr) => {
49-
return value > curr ? prev + 1 : prev;
50-
}, 0);
51-
52-
// Off the left
53-
if (closest === 0) {
54-
return 0;
55-
}
56-
57-
// Off the right
58-
if (closest === values.length) {
59-
return 100;
60-
}
61-
62-
// Now
63-
const closestLessValue = values[closest - 1];
64-
const closestGreaterValue = values[closest];
65-
66-
const intervalWidth = 1 / (values.length - 1);
40+
const THUMB_SIZE = 2.4; // em
6741

68-
const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue);
69-
70-
return 100 * (closest - 1 + linearInterpolation) * intervalWidth;
42+
export default class Slider extends React.Component<IProps> {
43+
private get position(): number {
44+
const { min, max, value } = this.props;
45+
return Number(((value - min) * 100) / (max - min));
7146
}
7247

73-
public render(): React.ReactNode {
74-
const dots = this.props.values.map((v) => (
75-
<Dot
76-
active={v <= this.props.value}
77-
label={this.props.displayFunc(v)}
78-
onClick={this.props.disabled ? () => {} : () => this.props.onSelectionChange(v)}
79-
key={v}
80-
disabled={this.props.disabled}
81-
/>
82-
));
48+
private onChange = (ev: ChangeEvent<HTMLInputElement>): void => {
49+
this.props.onChange(parseInt(ev.target.value, 10));
50+
};
8351

52+
public render(): React.ReactNode {
8453
let selection: JSX.Element | undefined;
8554

8655
if (!this.props.disabled) {
87-
const offset = this.offset(this.props.values, this.props.value);
56+
const position = this.position;
8857
selection = (
89-
<div className="mx_Slider_selection">
90-
<div className="mx_Slider_selectionDot" style={{ left: "calc(-1.195em + " + offset + "%)" }}>
91-
<div className="mx_Slider_selectionText">{this.props.value}</div>
92-
</div>
93-
<hr style={{ width: offset + "%" }} />
94-
</div>
58+
<output
59+
className="mx_Slider_selection"
60+
style={{
61+
left: `calc(2px + ${position}% + ${THUMB_SIZE / 2}em - ${(position / 100) * THUMB_SIZE}em)`,
62+
}}
63+
>
64+
<span className="mx_Slider_selection_label">{this.props.value}</span>
65+
</output>
9566
);
9667
}
9768

9869
return (
9970
<div className="mx_Slider">
100-
<div>
101-
<div className="mx_Slider_bar">
102-
<hr onClick={this.props.disabled ? () => {} : this.onClick.bind(this)} />
103-
{selection}
104-
</div>
105-
<div className="mx_Slider_dotContainer">{dots}</div>
106-
</div>
71+
<input
72+
type="range"
73+
min={this.props.min}
74+
max={this.props.max}
75+
value={this.props.value}
76+
onChange={this.onChange}
77+
disabled={this.props.disabled}
78+
step={this.props.step}
79+
autoComplete="off"
80+
/>
81+
{selection}
10782
</div>
10883
);
10984
}
110-
111-
public onClick(event: React.MouseEvent): void {
112-
const width = (event.target as HTMLElement).clientWidth;
113-
// nativeEvent is safe to use because https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/offsetX
114-
// is supported by all modern browsers
115-
const relativeClick = event.nativeEvent.offsetX / width;
116-
const nearestValue = this.props.values[Math.round(relativeClick * (this.props.values.length - 1))];
117-
this.props.onSelectionChange(nearestValue);
118-
}
119-
}
120-
121-
interface IDotProps {
122-
// Callback for behavior onclick
123-
onClick: () => void;
124-
125-
// Whether the dot should appear active
126-
active: boolean;
127-
128-
// The label on the dot
129-
label: string;
130-
131-
// Whether the slider is disabled
132-
disabled: boolean;
133-
}
134-
135-
class Dot extends React.PureComponent<IDotProps> {
136-
public render(): React.ReactNode {
137-
let className = "mx_Slider_dot";
138-
if (!this.props.disabled && this.props.active) {
139-
className += " mx_Slider_dotActive";
140-
}
141-
142-
return (
143-
<span onClick={this.props.onClick} className="mx_Slider_dotValue">
144-
<div className={className} />
145-
<div className="mx_Slider_labelContainer">
146-
<div className="mx_Slider_label">{this.props.label}</div>
147-
</div>
148-
</span>
149-
);
150-
}
15185
}

0 commit comments

Comments
 (0)