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";
|
||||
type?: "normal" | "small" | "extra-small";
|
||||
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
@ -23,12 +25,15 @@ export class Button extends ReactComponentBase<ButtonProperties, ButtonState> {
|
|||
}
|
||||
|
||||
render() {
|
||||
if(this.props.hidden)
|
||||
return null;
|
||||
return (
|
||||
<button
|
||||
className={this.classList(
|
||||
cssStyle.button,
|
||||
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}
|
||||
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";
|
||||
|
||||
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) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
translated: /* @tr-ignore */ tr(props.message)
|
||||
translated: /* @tr-ignore */ tr(props.message || props.children)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue