Added a bunch more React elements

This commit is contained in:
WolverinDEV 2020-05-20 20:46:59 +02:00
parent 3818dbb4d6
commit 09b636fd8d
10 changed files with 926 additions and 3 deletions

View file

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

View 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;
}
}

View 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 });
}
}

View 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;
}
}

View 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());
}

View 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;
}
}
}

View 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();
}
}
}
}

View 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;
}
}

View 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);

View file

@ -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)
}
}