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 * Derppull/28788/head^2
parent
bef6eca484
commit
d2066ba5f5
res/css/views/elements
src/components/views
elements
settings
test/components/views/settings
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue