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
This commit is contained in:
Michael Telatynski 2023-04-12 11:57:31 +01:00 committed by GitHub
parent bef6eca484
commit d2066ba5f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 189 additions and 286 deletions

View file

@ -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;
}
const THUMB_SIZE = 2.4; // em
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 linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue);
return 100 * (closest - 1 + linearInterpolation) * intervalWidth;
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>
);
}
}

View file

@ -27,6 +27,7 @@ import { Layout } from "../../../settings/enums/Layout";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { SettingLevel } from "../../../settings/SettingLevel";
import { _t } from "../../../languageHandler";
import { clamp } from "../../../utils/numbers";
interface IProps {}
@ -103,6 +104,9 @@ export default class FontScalingPanel extends React.Component<IProps, IState> {
};
public render(): React.ReactNode {
const min = 13;
const max = 18;
return (
<div className="mx_SettingsTab_section mx_FontScalingPanel">
<span className="mx_SettingsTab_subheading">{_t("Font size")}</span>
@ -117,9 +121,11 @@ export default class FontScalingPanel extends React.Component<IProps, IState> {
<div className="mx_FontScalingPanel_fontSlider">
<div className="mx_FontScalingPanel_fontSlider_smallText">Aa</div>
<Slider
values={[13, 14, 15, 16, 18]}
min={min}
max={max}
step={1}
value={parseInt(this.state.fontSize, 10)}
onSelectionChange={this.onFontSizeChanged}
onChange={this.onFontSizeChanged}
displayFunc={(_) => ""}
disabled={this.state.useCustomFontSize}
/>
@ -129,7 +135,16 @@ export default class FontScalingPanel extends React.Component<IProps, IState> {
<SettingsFlag
name="useCustomFontSize"
level={SettingLevel.ACCOUNT}
onChange={(checked) => this.setState({ useCustomFontSize: checked })}
onChange={(checked) => {
this.setState({ useCustomFontSize: checked });
if (!checked) {
const size = parseInt(this.state.fontSize, 10);
const clamped = clamp(size, min, max);
if (clamped !== size) {
this.onFontSizeChanged(clamped);
}
}
}}
useCheckbox={true}
/>