Added a bunch more React elements
This commit is contained in:
parent
3818dbb4d6
commit
09b636fd8d
10 changed files with 926 additions and 3 deletions
|
@ -6,8 +6,10 @@ export interface ButtonProperties {
|
||||||
color?: "green" | "blue" | "red" | "purple" | "brown" | "yellow" | "default";
|
color?: "green" | "blue" | "red" | "purple" | "brown" | "yellow" | "default";
|
||||||
type?: "normal" | "small" | "extra-small";
|
type?: "normal" | "small" | "extra-small";
|
||||||
|
|
||||||
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
||||||
|
hidden?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,12 +25,15 @@ export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
if(this.props.hidden)
|
||||||
|
return null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={this.classList(
|
className={this.classList(
|
||||||
cssStyle.button,
|
cssStyle.button,
|
||||||
cssStyle["color-" + this.props.color] || cssStyle["color-default"],
|
cssStyle["color-" + this.props.color] || cssStyle["color-default"],
|
||||||
cssStyle["type-" + this.props.type] || cssStyle["type-normal"]
|
cssStyle["type-" + this.props.type] || cssStyle["type-normal"],
|
||||||
|
this.props.className
|
||||||
)}
|
)}
|
||||||
disabled={this.state.disabled || this.props.disabled}
|
disabled={this.state.disabled || this.props.disabled}
|
||||||
onClick={this.props.onClick}
|
onClick={this.props.onClick}
|
||||||
|
|
75
shared/js/ui/react-elements/Checkbox.scss
Normal file
75
shared/js/ui/react-elements/Checkbox.scss
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
@import "../../../css/static/mixin";
|
||||||
|
@import "../../../css/static/properties";
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
width: 1.3em;
|
||||||
|
height: 1.3em;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
background-color: #272626;
|
||||||
|
border-radius: $border_radius_middle;
|
||||||
|
|
||||||
|
input {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
height: .5em;
|
||||||
|
width: .8em;
|
||||||
|
|
||||||
|
margin-left: 0.25em;
|
||||||
|
margin-top: .3em;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
border-bottom: .2em solid #46c0ec;
|
||||||
|
border-left: .2em solid #46c0ec;
|
||||||
|
|
||||||
|
transform: rotateY(0deg) rotate(-45deg); /* needs Y at 0 deg to behave properly*/
|
||||||
|
@include transition(.4s);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .mark {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
-webkit-box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
-moz-box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelCheckbox {
|
||||||
|
@include user-select(none);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-left: .5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label.disabled > .checkbox, .checkbox:disabled, .checkbox.disabled {
|
||||||
|
&.checkbox, > .checkbox {
|
||||||
|
pointer-events: none!important;
|
||||||
|
background-color: #1a1a1e;
|
||||||
|
}
|
||||||
|
}
|
46
shared/js/ui/react-elements/Checkbox.tsx
Normal file
46
shared/js/ui/react-elements/Checkbox.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import * as React from "react";
|
||||||
|
const cssStyle = require("./Checkbox.scss");
|
||||||
|
|
||||||
|
export interface CheckboxProperties {
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: (value: boolean) => void;
|
||||||
|
initialValue?: boolean;
|
||||||
|
|
||||||
|
children?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckboxState {
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Checkbox extends React.Component<CheckboxProperties, CheckboxState> {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
checked: this.props.initialValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const disabledClass = this.props.disabled ? cssStyle.disabled : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={cssStyle.labelCheckbox + " " + disabledClass}>
|
||||||
|
<div className={cssStyle.checkbox + " " + disabledClass}>
|
||||||
|
<input type={"checkbox"} checked={this.state.checked} disabled={this.props.disabled} onChange={() => this.onStateChange()} />
|
||||||
|
<div className={cssStyle.mark} />
|
||||||
|
</div>
|
||||||
|
{this.props.label ? <a>{this.props.label}</a> : undefined}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStateChange() {
|
||||||
|
if(this.props.onChange)
|
||||||
|
this.props.onChange(!this.state.checked);
|
||||||
|
|
||||||
|
this.setState({ checked: !this.state.checked });
|
||||||
|
}
|
||||||
|
}
|
207
shared/js/ui/react-elements/Modal.scss
Normal file
207
shared/js/ui/react-elements/Modal.scss
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
@import "../../../css/static/mixin";
|
||||||
|
@import "../../../css/static/properties";
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
color: #999999; /* base color */
|
||||||
|
|
||||||
|
overflow: auto; /* allow scrolling if a modal is too big */
|
||||||
|
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
|
||||||
|
padding-right: 5%;
|
||||||
|
padding-left: 5%;
|
||||||
|
|
||||||
|
z-index: 1000;
|
||||||
|
position: fixed;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: -1000vh;
|
||||||
|
|
||||||
|
$animation_length: .3s;
|
||||||
|
@include transition(opacity $animation_length ease-in, margin-top $animation_length ease-in);
|
||||||
|
&.shown {
|
||||||
|
margin-top: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
|
||||||
|
/* width calculations */
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
/* height stuff */
|
||||||
|
max-height: calc(100% - 3.5em);
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background: #19191b;
|
||||||
|
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: $border_radius_middle;
|
||||||
|
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 20em;
|
||||||
|
|
||||||
|
min-height: min-content;
|
||||||
|
|
||||||
|
/* align us in the center */
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-grow: 0; /* we dont want a grow over the limit set within the content, but we want to shrink the content if necessary */
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: #222224;
|
||||||
|
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
padding: .25em;
|
||||||
|
|
||||||
|
.icon, .buttonClose {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonClose {
|
||||||
|
height: 1.4em;
|
||||||
|
width: 1.4em;
|
||||||
|
|
||||||
|
padding: .2em;
|
||||||
|
border-radius: .2em;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #1b1b1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: .25em;
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title, {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
|
||||||
|
color: #9d9d9e;
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 20em; /* may adjust if needed */
|
||||||
|
|
||||||
|
max-height: calc(100vh - 8em);
|
||||||
|
min-height: 5em;
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* special modal types */
|
||||||
|
.modal {
|
||||||
|
&.header-error .header {
|
||||||
|
background-color: hsla(0, 100%, 25%, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.header-info .header {
|
||||||
|
background-color: hsla(199, 98%, 20%, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.header-warning .header,
|
||||||
|
&.header-info .header,
|
||||||
|
&.header-error .header {
|
||||||
|
border-top-left-radius: .125rem;
|
||||||
|
border-top-right-radius: .125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
.dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
&.modal-dialog-centered {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
/* max-height: 500px; */
|
||||||
|
min-height: 0; /* required for moz */
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* special general modals */
|
||||||
|
.modal {
|
||||||
|
/* TODO! */
|
||||||
|
.modal-body.modal-blue {
|
||||||
|
border-left: 2px solid #0a73d2;
|
||||||
|
}
|
||||||
|
.modal-body.modal-green {
|
||||||
|
border-left: 2px solid #00d400;
|
||||||
|
}
|
||||||
|
.modal-body.modal-red {
|
||||||
|
border: none;
|
||||||
|
border-left: 2px solid #d50000;
|
||||||
|
}
|
||||||
|
}
|
175
shared/js/ui/react-elements/Modal.tsx
Normal file
175
shared/js/ui/react-elements/Modal.tsx
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
import {ReactElement} from "react";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
|
||||||
|
const cssStyle = require("./Modal.scss");
|
||||||
|
|
||||||
|
export type ModalType = "error" | "warning" | "info" | "none";
|
||||||
|
|
||||||
|
export interface ModalEvents {
|
||||||
|
"open": {},
|
||||||
|
"close": {},
|
||||||
|
|
||||||
|
/* create is implicitly at object creation */
|
||||||
|
"destroy": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ModalState {
|
||||||
|
SHOWN,
|
||||||
|
HIDDEN,
|
||||||
|
DESTROYED
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ModalController {
|
||||||
|
readonly events: Registry<ModalEvents>;
|
||||||
|
readonly modalInstance: Modal;
|
||||||
|
|
||||||
|
private initializedPromise: Promise<void>;
|
||||||
|
|
||||||
|
private domElement: Element;
|
||||||
|
private refModal: React.RefObject<ModalImpl>;
|
||||||
|
private modalState_: ModalState = ModalState.HIDDEN;
|
||||||
|
|
||||||
|
constructor(instance: Modal) {
|
||||||
|
this.modalInstance = instance;
|
||||||
|
instance["__modal_controller"] = this;
|
||||||
|
|
||||||
|
this.events = new Registry<ModalEvents>();
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize() {
|
||||||
|
this.refModal = React.createRef();
|
||||||
|
this.domElement = document.createElement("div");
|
||||||
|
|
||||||
|
const element = <ModalImpl controller={this} ref={this.refModal} />;
|
||||||
|
document.body.appendChild(this.domElement);
|
||||||
|
this.initializedPromise = new Promise<void>(resolve => {
|
||||||
|
ReactDOM.render(element, this.domElement, () => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.modalInstance["onInitialize"]();
|
||||||
|
}
|
||||||
|
|
||||||
|
modalState() {
|
||||||
|
return this.modalState_;
|
||||||
|
}
|
||||||
|
|
||||||
|
async show() {
|
||||||
|
await this.initializedPromise;
|
||||||
|
if(this.modalState_ === ModalState.DESTROYED)
|
||||||
|
throw tr("modal has been destroyed");
|
||||||
|
else if(this.modalState_ === ModalState.SHOWN)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.refModal.current?.setState({ show: true });
|
||||||
|
this.modalState_ = ModalState.SHOWN;
|
||||||
|
this.modalInstance["onOpen"]();
|
||||||
|
this.events.fire("open");
|
||||||
|
}
|
||||||
|
|
||||||
|
async hide() : Promise<void> {
|
||||||
|
await this.initializedPromise;
|
||||||
|
if(this.modalState_ === ModalState.DESTROYED)
|
||||||
|
throw tr("modal has been destroyed");
|
||||||
|
else if(this.modalState_ === ModalState.HIDDEN)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.refModal.current?.setState({ show: false });
|
||||||
|
this.modalState_ = ModalState.HIDDEN;
|
||||||
|
this.modalInstance["onClose"]();
|
||||||
|
this.events.fire("close");
|
||||||
|
|
||||||
|
return new Promise<void>(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if(this.modalState_ === ModalState.SHOWN) {
|
||||||
|
this.hide().then(() => this.destroy());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.unmountComponentAtNode(this.domElement);
|
||||||
|
this.domElement.remove();
|
||||||
|
|
||||||
|
this.domElement = undefined;
|
||||||
|
this.modalState_ = ModalState.DESTROYED;
|
||||||
|
this.modalInstance["onDestroy"]();
|
||||||
|
this.events.fire("destroy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class Modal {
|
||||||
|
private __modal_controller: ModalController;
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
type() : ModalType { return "none"; }
|
||||||
|
abstract renderBody() : ReactElement;
|
||||||
|
abstract title() : string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will only return a modal controller when the modal has not been destroyed
|
||||||
|
*/
|
||||||
|
modalController() : ModalController | undefined {
|
||||||
|
return this.__modal_controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onInitialize() {}
|
||||||
|
protected onDestroy() {}
|
||||||
|
|
||||||
|
protected onClose() {}
|
||||||
|
protected onOpen() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModalImpl extends React.PureComponent<{ controller: ModalController }, { show: boolean }> {
|
||||||
|
private readonly refModal = React.createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = { show: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const modal = this.props.controller.modalInstance;
|
||||||
|
let modalExtraClass = "";
|
||||||
|
|
||||||
|
const type = modal.type();
|
||||||
|
if(typeof type === "string" && type !== "none")
|
||||||
|
modalExtraClass = cssStyle["modal-type-" + type];
|
||||||
|
|
||||||
|
const showClass = this.state.show ? cssStyle.shown : "";
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.modal + " " + modalExtraClass + " " + showClass} tabIndex={-1} role={"dialog"} aria-hidden={true} onClick={event => this.onBackdropClick(event)} ref={this.refModal}>
|
||||||
|
<div className={cssStyle.dialog}>
|
||||||
|
<div className={cssStyle.content}>
|
||||||
|
<div className={cssStyle.header}>
|
||||||
|
<div className={cssStyle.icon}>
|
||||||
|
<img src="img/favicon/teacup.png" alt={tr("Modal - Icon")} />
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.title}>{modal.title()}</div>
|
||||||
|
<div className={cssStyle.buttonClose} onClick={() => this.props.controller.destroy() }>
|
||||||
|
<div className="icon_em client-close_button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.body}>
|
||||||
|
{modal.renderBody()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onBackdropClick(event: React.MouseEvent) {
|
||||||
|
if(event.target !== this.refModal.current || event.isDefaultPrevented())
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.props.controller.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnReactModal<ModalClass extends Modal>(modalClass: new () => ModalClass) : ModalController {
|
||||||
|
return new ModalController(new modalClass());
|
||||||
|
}
|
75
shared/js/ui/react-elements/Slider.scss
Normal file
75
shared/js/ui/react-elements/Slider.scss
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
@import "../../../css/static/properties";
|
||||||
|
@import "../../../css/static/mixin";
|
||||||
|
|
||||||
|
/* slider */
|
||||||
|
$track_height: .6em;
|
||||||
|
|
||||||
|
$thumb_width: .6em;
|
||||||
|
$thumb_height: 2em;
|
||||||
|
|
||||||
|
$tooltip_width: 4em;
|
||||||
|
$tooltip_height: 1.8em;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
font-size: .8em;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
margin-top: $thumb_height / 2 - $track_height / 2;
|
||||||
|
margin-bottom: $thumb_height / 2 - $track_height / 2;
|
||||||
|
margin-right: $thumb_width / 2;
|
||||||
|
margin-left: $thumb_width / 2;
|
||||||
|
|
||||||
|
height: $track_height;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
background-color: #242527;
|
||||||
|
border-radius: $border_radius_large;
|
||||||
|
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.filler {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
background-color: #4370a2;
|
||||||
|
border-radius: $border_radius_large;
|
||||||
|
|
||||||
|
@include transition(background-color .15s ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
height: $thumb_height;
|
||||||
|
width: $thumb_width;
|
||||||
|
|
||||||
|
margin-left: -($thumb_width / 2);
|
||||||
|
margin-right: -($thumb_width / 2);
|
||||||
|
|
||||||
|
margin-top: -($thumb_height - $track_height) / 2;
|
||||||
|
margin-bottom: -($thumb_height - $track_height) / 2;
|
||||||
|
|
||||||
|
background-color: #808080;
|
||||||
|
@include transition(background-color .15s ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
background-color: #4d4d4d!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filler {
|
||||||
|
background-color: #3d618a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
153
shared/js/ui/react-elements/Slider.tsx
Normal file
153
shared/js/ui/react-elements/Slider.tsx
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {Tooltip} from "tc-shared/ui/react-elements/Tooltip";
|
||||||
|
import {ReactElement} from "react";
|
||||||
|
const cssStyle = require("./Slider.scss");
|
||||||
|
|
||||||
|
export interface SliderProperties {
|
||||||
|
minValue: number;
|
||||||
|
maxValue: number;
|
||||||
|
stepSize: number;
|
||||||
|
|
||||||
|
value: number;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
unit?: string;
|
||||||
|
tooltip?: (value: number) => ReactElement | string;
|
||||||
|
|
||||||
|
onInput?: (value: number) => void;
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
|
||||||
|
children?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SliderState {
|
||||||
|
value: number;
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Slider extends React.Component<SliderProperties, SliderState> {
|
||||||
|
private documentListenersRegistered = false;
|
||||||
|
private lastValue: number;
|
||||||
|
|
||||||
|
private readonly mouseListener;
|
||||||
|
private readonly mouseUpListener;
|
||||||
|
|
||||||
|
private readonly refTooltip = React.createRef<Tooltip>();
|
||||||
|
private readonly refSlider = React.createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
value: this.props.value,
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mouseListener = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if(!this.refSlider.current) return;
|
||||||
|
|
||||||
|
const container = this.refSlider.current;
|
||||||
|
|
||||||
|
const bounds = container.getBoundingClientRect();
|
||||||
|
const min = bounds.left;
|
||||||
|
const max = bounds.left + container.clientWidth;
|
||||||
|
const current = ('touches' in event && Array.isArray(event.touches) && event.touches.length > 0) ? event.touches[event.touches.length - 1].clientX : (event as MouseEvent).pageX;
|
||||||
|
|
||||||
|
const range = this.props.maxValue - this.props.minValue;
|
||||||
|
let offset = Math.round(((current - min) * (range / this.props.stepSize)) / (max - min)) * this.props.stepSize;
|
||||||
|
if(offset < 0)
|
||||||
|
offset = 0;
|
||||||
|
else if(offset > range)
|
||||||
|
offset = range;
|
||||||
|
|
||||||
|
this.refTooltip.current.setState({
|
||||||
|
pageX: bounds.left + offset * bounds.width / range,
|
||||||
|
});
|
||||||
|
|
||||||
|
//console.log("Min: %o | Max: %o | %o (%o)", min, max, current, offset);
|
||||||
|
this.setState({ value: this.lastValue = (this.props.minValue + offset) });
|
||||||
|
if(this.props.onInput) this.props.onInput(this.lastValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.mouseUpListener = event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.setState({ active: false });
|
||||||
|
this.unregisterDocumentListener();
|
||||||
|
if(this.props.onChange) this.props.onChange(this.lastValue);
|
||||||
|
this.refTooltip.current?.setState({ forceShow: false });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private unregisterDocumentListener() {
|
||||||
|
if(!this.documentListenersRegistered) return;
|
||||||
|
this.documentListenersRegistered = false;
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', this.mouseListener);
|
||||||
|
document.removeEventListener('touchmove', this.mouseListener);
|
||||||
|
|
||||||
|
document.removeEventListener('mouseup', this.mouseUpListener);
|
||||||
|
document.removeEventListener('mouseleave', this.mouseUpListener);
|
||||||
|
document.removeEventListener('touchend', this.mouseUpListener);
|
||||||
|
document.removeEventListener('touchcancel', this.mouseUpListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerDocumentListener() {
|
||||||
|
if(this.documentListenersRegistered) return;
|
||||||
|
this.documentListenersRegistered = true;
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', this.mouseListener);
|
||||||
|
document.addEventListener('touchmove', this.mouseListener);
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', this.mouseUpListener);
|
||||||
|
document.addEventListener('mouseleave', this.mouseUpListener);
|
||||||
|
document.addEventListener('touchend', this.mouseUpListener);
|
||||||
|
document.addEventListener('touchcancel', this.mouseUpListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount(): void {
|
||||||
|
this.unregisterDocumentListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : this.props.disabled;
|
||||||
|
const offset = (this.state.value - this.props.minValue) * 100 / (this.props.maxValue - this.props.minValue);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cssStyle.container + " " + (this.props.className || " ") + " " + (disabled ? cssStyle.disabled : "")}
|
||||||
|
ref={this.refSlider}
|
||||||
|
|
||||||
|
onMouseDown={e => this.enableSliderMode(e)}
|
||||||
|
onTouchStart={e => this.enableSliderMode(e)}
|
||||||
|
>
|
||||||
|
<div className={cssStyle.filler} style={{right: (100 - offset) + "%"}} />
|
||||||
|
<Tooltip ref={this.refTooltip} tooltip={() => this.props.tooltip ? this.props.tooltip(this.state.value) : this.renderTooltip()}>
|
||||||
|
<div className={cssStyle.thumb} style={{left: offset + "%"}} />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enableSliderMode(event: React.MouseEvent | React.TouchEvent) {
|
||||||
|
this.setState({ active: true });
|
||||||
|
this.registerDocumentListener();
|
||||||
|
this.mouseListener(event);
|
||||||
|
this.refTooltip.current?.setState({ forceShow: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderTooltip() {
|
||||||
|
return <a>{this.state.value + (this.props.unit || "")}</a>;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Readonly<SliderProperties>, prevState: Readonly<SliderState>, snapshot?: any): void {
|
||||||
|
if(this.state.disabled !== prevState.disabled) {
|
||||||
|
if(this.state.disabled) {
|
||||||
|
this.unregisterDocumentListener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
43
shared/js/ui/react-elements/Tooltip.scss
Normal file
43
shared/js/ui/react-elements/Tooltip.scss
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
@import "../../../css/static/mixin";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
color: #999999;
|
||||||
|
background-color: #232222;
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000000;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
padding: .25em;
|
||||||
|
transform: translate(-50%, -100%); /* translate up, center */
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
border-right: 3px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
@include transition(opacity .5s ease-in-out);
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
|
||||||
|
left: calc(50% - .5em);
|
||||||
|
bottom: -.4em;
|
||||||
|
|
||||||
|
border-style: solid;
|
||||||
|
border-width: .5em .5em 0 .5em;
|
||||||
|
border-color: #232222 transparent transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.shown {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
143
shared/js/ui/react-elements/Tooltip.tsx
Normal file
143
shared/js/ui/react-elements/Tooltip.tsx
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
import {ReactElement} from "react";
|
||||||
|
|
||||||
|
const cssStyle = require("./Tooltip.scss");
|
||||||
|
|
||||||
|
interface GlobalTooltipState {
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GlobalTooltip extends React.Component<{}, GlobalTooltipState> {
|
||||||
|
private currentTooltip_: Tooltip;
|
||||||
|
private isUnmount: boolean;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
pageX: 0,
|
||||||
|
pageY: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTooltip() {
|
||||||
|
return this.currentTooltip_;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTooltip(provider?: Tooltip) {
|
||||||
|
this.currentTooltip_ = provider;
|
||||||
|
this.forceUpdate();
|
||||||
|
this.isUnmount = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
unmountTooltip(element: Tooltip) {
|
||||||
|
if(element !== this.currentTooltip_)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.isUnmount = true;
|
||||||
|
this.forceUpdate(() => {
|
||||||
|
if(this.currentTooltip_ === element)
|
||||||
|
this.currentTooltip_ = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if(!this.currentTooltip_ || this.isUnmount) {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.container} style={{ top: this.state.pageY, left: this.state.pageX }}>
|
||||||
|
{this.currentTooltip_?.props.tooltip()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.container + " " + cssStyle.shown} style={{ top: this.state.pageY, left: this.state.pageX }}>
|
||||||
|
{this.currentTooltip_.props.tooltip()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TooltipState {
|
||||||
|
forceShow: boolean;
|
||||||
|
hovered: boolean;
|
||||||
|
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TooltipProperties {
|
||||||
|
tooltip: () => ReactElement | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Tooltip extends React.Component<TooltipProperties, TooltipState> {
|
||||||
|
private refContainer = React.createRef<HTMLSpanElement>();
|
||||||
|
private currentContainer: HTMLElement;
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
forceShow: false,
|
||||||
|
hovered: false,
|
||||||
|
pageX: 0,
|
||||||
|
pageY: 0
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillUnmount(): void {
|
||||||
|
globalTooltipRef.current?.unmountTooltip(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <span
|
||||||
|
ref={this.refContainer}
|
||||||
|
onMouseEnter={event => this.onMouseEnter(event)}
|
||||||
|
onMouseLeave={() => this.setState({ hovered: false })}
|
||||||
|
>{this.props.children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Readonly<TooltipProperties>, prevState: Readonly<TooltipState>, snapshot?: any): void {
|
||||||
|
if(this.state.forceShow || this.state.hovered) {
|
||||||
|
globalTooltipRef.current?.updateTooltip(this);
|
||||||
|
globalTooltipRef.current?.setState({
|
||||||
|
pageY: this.state.pageY,
|
||||||
|
pageX: this.state.pageX,
|
||||||
|
});
|
||||||
|
} else if(prevState.forceShow || prevState.hovered) {
|
||||||
|
globalTooltipRef.current?.unmountTooltip(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMouseEnter(event: React.MouseEvent) {
|
||||||
|
/* check if may only the span has been hovered, should not be the case! */
|
||||||
|
if(event.target === this.refContainer.current)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.setState({ hovered: true });
|
||||||
|
|
||||||
|
let container = event.target as HTMLElement;
|
||||||
|
while(container.parentElement !== this.refContainer.current)
|
||||||
|
container = container.parentElement;
|
||||||
|
this.currentContainer = container;
|
||||||
|
|
||||||
|
this.updatePosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePosition() {
|
||||||
|
const container = this.currentContainer || this.refContainer.current?.children.item(0) || this.refContainer.current;
|
||||||
|
if(!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
this.setState({
|
||||||
|
pageY: rect.top,
|
||||||
|
pageX: rect.left + rect.width / 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const globalTooltipRef = React.createRef<GlobalTooltip>();
|
||||||
|
const tooltipContainer = document.createElement("div");
|
||||||
|
document.body.appendChild(tooltipContainer);
|
||||||
|
ReactDOM.render(<GlobalTooltip ref={globalTooltipRef} />, tooltipContainer);
|
|
@ -1,10 +1,11 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
export class Translatable extends React.Component<{ message: string }, { translated: string }> {
|
export class Translatable extends React.Component<{ message: string, children?: never } | { children: string }, { translated: string }> {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
translated: /* @tr-ignore */ tr(props.message)
|
translated: /* @tr-ignore */ tr(props.message || props.children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue