Improve accessibility of font slider ()

* 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
pull/28788/head^2
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
res/css/views/elements
src/components/views
test/components/views/settings

View File

@ -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;
}
flex-grow: 1;
.mx_Slider_dot {
height: $slider-dot-size;
width: $slider-dot-size;
border-radius: 50%;
background-color: $slider-background-color;
z-index: 0;
}
input[type="range"] {
height: 2.4em;
appearance: none;
width: 100%;
background: none;
font-size: 1em; // set base multiplier for em units applied later
.mx_Slider_dotActive {
background-color: $accent;
}
--active-color: $accent;
.mx_Slider_dotValue {
display: flex;
flex-direction: column;
align-items: center;
color: $slider-background-color;
}
&:disabled {
cursor: not-allowed;
/* The following is a hack to center the labels without adding */
/* any width to the slider's dots. */
.mx_Slider_labelContainer {
width: 1em;
}
--active-color: $slider-background-color;
}
.mx_Slider_label {
position: relative;
width: fit-content;
left: -50%;
&: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;
}
}
}

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}
/>

View File

@ -15,10 +15,11 @@ limitations under the License.
*/
import React from "react";
import { render } from "@testing-library/react";
import { fireEvent, render, waitFor } from "@testing-library/react";
import * as TestUtils from "../../../test-utils";
import FontScalingPanel from "../../../../src/components/views/settings/FontScalingPanel";
import SettingsStore from "../../../../src/settings/SettingsStore";
// Fake random strings to give a predictable snapshot
jest.mock("matrix-js-sdk/src/randomstring", () => {
@ -33,4 +34,19 @@ describe("FontScalingPanel", () => {
const { asFragment } = render(<FontScalingPanel />);
expect(asFragment()).toMatchSnapshot();
});
it("should clamp custom font size when disabling it", async () => {
jest.spyOn(SettingsStore, "setValue").mockResolvedValue(undefined);
TestUtils.stubClient();
const { container, getByText } = render(<FontScalingPanel />);
fireEvent.click(getByText("Use custom size"));
await waitFor(() => {
expect(container.querySelector("input[checked]")).toBeDefined();
});
fireEvent.change(container.querySelector("#font_size_field")!, { target: { value: "20" } });
fireEvent.click(getByText("Use custom size"));
await waitFor(() => {
expect(container.querySelector("#font_size_field")).toHaveValue(18);
});
});
});

View File

@ -36,104 +36,24 @@ exports[`FontScalingPanel renders the font scaling UI 1`] = `
<div
class="mx_Slider"
>
<div>
<div
class="mx_Slider_bar"
<input
autocomplete="off"
max="18"
min="13"
step="1"
type="range"
value="15"
/>
<output
class="mx_Slider_selection"
style="left: calc(2px + 40% + 1.2em - 0.96em);"
>
<span
class="mx_Slider_selection_label"
>
<hr />
<div
class="mx_Slider_selection"
>
<div
class="mx_Slider_selectionDot"
style="left: calc(-1.195em + 50%);"
>
<div
class="mx_Slider_selectionText"
>
15
</div>
</div>
<hr
style="width: 50%;"
/>
</div>
</div>
<div
class="mx_Slider_dotContainer"
>
<span
class="mx_Slider_dotValue"
>
<div
class="mx_Slider_dot mx_Slider_dotActive"
/>
<div
class="mx_Slider_labelContainer"
>
<div
class="mx_Slider_label"
/>
</div>
</span>
<span
class="mx_Slider_dotValue"
>
<div
class="mx_Slider_dot mx_Slider_dotActive"
/>
<div
class="mx_Slider_labelContainer"
>
<div
class="mx_Slider_label"
/>
</div>
</span>
<span
class="mx_Slider_dotValue"
>
<div
class="mx_Slider_dot mx_Slider_dotActive"
/>
<div
class="mx_Slider_labelContainer"
>
<div
class="mx_Slider_label"
/>
</div>
</span>
<span
class="mx_Slider_dotValue"
>
<div
class="mx_Slider_dot"
/>
<div
class="mx_Slider_labelContainer"
>
<div
class="mx_Slider_label"
/>
</div>
</span>
<span
class="mx_Slider_dotValue"
>
<div
class="mx_Slider_dot"
/>
<div
class="mx_Slider_labelContainer"
>
<div
class="mx_Slider_label"
/>
</div>
</span>
</div>
</div>
15
</span>
</output>
</div>
<div
class="mx_FontScalingPanel_fontSlider_largeText"