Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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