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

Commit

Permalink
Improve accessibility of font slider (#10473)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
t3chguy authored Apr 12, 2023
1 parent bef6eca commit d2066ba
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 290 deletions.
190 changes: 104 additions & 86 deletions res/css/views/elements/_Slider.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -16,92 +16,110 @@ limitations under the License.

.mx_Slider {
position: relative;
margin: 0px;
flex-grow: 1;
}

.mx_Slider_dotContainer {
display: flex;
flex-direction: row;
justify-content: space-between;
}

.mx_Slider_bar {
display: flex;
box-sizing: border-box;
position: absolute;
height: 1em;
width: 100%;
padding: 0 0.5em; /* half the width of a dot. */
align-items: center;
}

.mx_Slider_bar > hr {
width: 100%;
height: 0.4em;
background-color: $slider-background-color;
border: 0;
}

.mx_Slider_selection {
display: flex;
align-items: center;
width: calc(100% - 1em); /* 2 * half the width of a dot */
height: 1em;
position: absolute;
pointer-events: none;
}

.mx_Slider_selectionDot {
position: absolute;
width: $slider-selection-dot-size;
height: $slider-selection-dot-size;
background-color: $accent;
border-radius: 50%;
z-index: 10;
}

.mx_Slider_selectionText {
color: $muted-fg-color;
font-size: $font-14px;
position: relative;
text-align: center;
top: 30px;
width: 100%;
}

.mx_Slider_selection > hr {
margin: 0;
border: 0.2em solid $accent;
}

.mx_Slider_dot {
height: $slider-dot-size;
width: $slider-dot-size;
border-radius: 50%;
background-color: $slider-background-color;
z-index: 0;
}

.mx_Slider_dotActive {
background-color: $accent;
}

.mx_Slider_dotValue {
display: flex;
flex-direction: column;
align-items: center;
color: $slider-background-color;
}

/* The following is a hack to center the labels without adding */
/* any width to the slider's dots. */
.mx_Slider_labelContainer {
width: 1em;
}
flex-grow: 1;

.mx_Slider_label {
position: relative;
width: fit-content;
left: -50%;
input[type="range"] {
height: 2.4em;
appearance: none;
width: 100%;
background: none;
font-size: 1em; // set base multiplier for em units applied later

--active-color: $accent;

&:disabled {
cursor: not-allowed;

--active-color: $slider-background-color;
}

&:focus:not(.focus-visible) {
outline: none;
}

&::-webkit-slider-runnable-track {
width: 100%;
height: 0.4em;
background: $slider-background-color;
border-radius: 5px;
border: 0 solid #000000;
}
&::-webkit-slider-thumb {
border: 0 solid #000000;
width: $slider-selection-dot-size;
height: $slider-selection-dot-size;
background: var(--active-color);
border-radius: 50%;
-webkit-appearance: none;
margin-top: calc(2px + 1.2em - $slider-selection-dot-size);
}
&:focus::-webkit-slider-runnable-track {
background: $slider-background-color;
}

&::-moz-range-track {
width: 100%;
height: 0.4em;
background: $slider-background-color;
border-radius: 5px;
border: 0 solid #000000;
}
&::-moz-range-progress {
height: 0.4em;
background: var(--active-color);
border-radius: 5px;
border: 0 solid #000000;
}
&::-moz-range-thumb {
border: 0 solid #000000;
width: $slider-selection-dot-size;
height: $slider-selection-dot-size;
background: var(--active-color);
border-radius: 50%;
}

&::-ms-track {
width: 100%;
height: 0.4em;
background: transparent;
border-color: transparent;
color: transparent;
}
&::-ms-fill-lower,
&::-ms-fill-upper {
background: $slider-background-color;
border: 0 solid #000000;
border-radius: 10px;
}
&::-ms-thumb {
margin-top: 1px;
width: $slider-selection-dot-size;
height: $slider-selection-dot-size;
background: var(--active-color);
border-radius: 50%;
}
&:focus::-ms-fill-upper {
background: $slider-background-color;
}
&::-ms-fill-lower,
&:focus::-ms-fill-lower {
background: var(--active-color);
}
}

output {
position: absolute;
left: 50%;
transform: translateX(-50%);

font-size: 1em; // set base multiplier for em units applied later
text-align: center;
top: 3em;

.mx_Slider_selection_label {
color: $muted-fg-color;
font-size: $font-14px;
}
}
}
140 changes: 37 additions & 103 deletions src/components/views/elements/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,137 +15,71 @@ limitations under the License.
*/

import * as React from "react";
import { ChangeEvent } from "react";

interface IProps {
// A callback for the selected value
onSelectionChange: (value: number) => void;
onChange: (value: number) => void;

// The current value of the slider
value: number;

// The range and values of the slider
// Currently only supports an ascending, constant interval range
values: number[];
// The min and max of the slider
min: number;
max: number;
// The step size of the slider, can be a number or "any"
step: number | "any";

// A function for formatting the the values
// A function for formatting the values
displayFunc: (value: number) => string;

// Whether the slider is disabled
disabled: boolean;
}

export default class Slider extends React.Component<IProps> {
// offset is a terrible inverse approximation.
// if the values represents some function f(x) = y where x is the
// index of the array and y = values[x] then offset(f, y) = x
// s.t f(x) = y.
// it assumes a monotonic function and interpolates linearly between
// y values.
// Offset is used for finding the location of a value on a
// non linear slider.
private offset(values: number[], value: number): number {
// the index of the first number greater than value.
const closest = values.reduce((prev, curr) => {
return value > curr ? prev + 1 : prev;
}, 0);

// Off the left
if (closest === 0) {
return 0;
}

// Off the right
if (closest === values.length) {
return 100;
}

// Now
const closestLessValue = values[closest - 1];
const closestGreaterValue = values[closest];

const intervalWidth = 1 / (values.length - 1);
const THUMB_SIZE = 2.4; // em

const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue);

return 100 * (closest - 1 + linearInterpolation) * intervalWidth;
export default class Slider extends React.Component<IProps> {
private get position(): number {
const { min, max, value } = this.props;
return Number(((value - min) * 100) / (max - min));
}

public render(): React.ReactNode {
const dots = this.props.values.map((v) => (
<Dot
active={v <= this.props.value}
label={this.props.displayFunc(v)}
onClick={this.props.disabled ? () => {} : () => this.props.onSelectionChange(v)}
key={v}
disabled={this.props.disabled}
/>
));
private onChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.props.onChange(parseInt(ev.target.value, 10));
};

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

if (!this.props.disabled) {
const offset = this.offset(this.props.values, this.props.value);
const position = this.position;
selection = (
<div className="mx_Slider_selection">
<div className="mx_Slider_selectionDot" style={{ left: "calc(-1.195em + " + offset + "%)" }}>
<div className="mx_Slider_selectionText">{this.props.value}</div>
</div>
<hr style={{ width: offset + "%" }} />
</div>
<output
className="mx_Slider_selection"
style={{
left: `calc(2px + ${position}% + ${THUMB_SIZE / 2}em - ${(position / 100) * THUMB_SIZE}em)`,
}}
>
<span className="mx_Slider_selection_label">{this.props.value}</span>
</output>
);
}

return (
<div className="mx_Slider">
<div>
<div className="mx_Slider_bar">
<hr onClick={this.props.disabled ? () => {} : this.onClick.bind(this)} />
{selection}
</div>
<div className="mx_Slider_dotContainer">{dots}</div>
</div>
<input
type="range"
min={this.props.min}
max={this.props.max}
value={this.props.value}
onChange={this.onChange}
disabled={this.props.disabled}
step={this.props.step}
autoComplete="off"
/>
{selection}
</div>
);
}

public onClick(event: React.MouseEvent): void {
const width = (event.target as HTMLElement).clientWidth;
// nativeEvent is safe to use because https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/offsetX
// is supported by all modern browsers
const relativeClick = event.nativeEvent.offsetX / width;
const nearestValue = this.props.values[Math.round(relativeClick * (this.props.values.length - 1))];
this.props.onSelectionChange(nearestValue);
}
}

interface IDotProps {
// Callback for behavior onclick
onClick: () => void;

// Whether the dot should appear active
active: boolean;

// The label on the dot
label: string;

// Whether the slider is disabled
disabled: boolean;
}

class Dot extends React.PureComponent<IDotProps> {
public render(): React.ReactNode {
let className = "mx_Slider_dot";
if (!this.props.disabled && this.props.active) {
className += " mx_Slider_dotActive";
}

return (
<span onClick={this.props.onClick} className="mx_Slider_dotValue">
<div className={className} />
<div className="mx_Slider_labelContainer">
<div className="mx_Slider_label">{this.props.label}</div>
</div>
</span>
);
}
}
Loading

0 comments on commit d2066ba

Please sign in to comment.