refactor: migrate more shared utility functions and components
This commit is contained in:
@@ -1,106 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { notNullStr } from "../../../Data/List"
|
||||
import { bindF, ensure, isJust, isNothing, Maybe, normalize, Nothing } from "../../../Data/Maybe"
|
||||
import { InputKeyEvent } from "../../Models/Hero/heroTypeHelpers"
|
||||
import { addKeybinding, removeKeybinding } from "../../Utilities/Keybindings"
|
||||
import { pipe_ } from "../../Utilities/pipe"
|
||||
import { Dialog } from "../Universal/Dialog"
|
||||
import { TextField } from "../Universal/TextField"
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
value: string | undefined
|
||||
invalid?: Maybe<string>
|
||||
acceptLabel: string
|
||||
rejectLabel: string
|
||||
rejectDisabled?: boolean
|
||||
onClose: () => void
|
||||
onAccept: () => void
|
||||
onReject?: () => void
|
||||
onChange: (new_text: string) => void
|
||||
}
|
||||
|
||||
export const BasicInputDialog: React.FC<Props> = props => {
|
||||
const {
|
||||
id,
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
invalid = Nothing,
|
||||
acceptLabel,
|
||||
rejectLabel,
|
||||
rejectDisabled = false,
|
||||
onClose,
|
||||
onAccept,
|
||||
onReject = () => undefined,
|
||||
onChange,
|
||||
} = props
|
||||
|
||||
const input_empty = pipe_ (value, normalize, bindF (ensure (notNullStr)), isNothing)
|
||||
|
||||
const accept_disabled = input_empty || isJust (invalid)
|
||||
|
||||
const handleKeyUp = React.useCallback (
|
||||
(event: InputKeyEvent) => {
|
||||
if (event.key === "Enter" && !accept_disabled) {
|
||||
onAccept ()
|
||||
onClose ()
|
||||
}
|
||||
else if (event.key === "Enter" && input_empty && !rejectDisabled) {
|
||||
onReject ()
|
||||
onClose ()
|
||||
}
|
||||
else if (event.key === "Escape") {
|
||||
onClose ()
|
||||
}
|
||||
},
|
||||
[ accept_disabled, input_empty, rejectDisabled, onAccept, onReject, onClose ]
|
||||
)
|
||||
|
||||
React.useEffect (() => {
|
||||
addKeybinding ("esc", () => {
|
||||
onClose ()
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeKeybinding ("esc")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id={id}
|
||||
close={onClose}
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
buttons={[
|
||||
{
|
||||
autoWidth: true,
|
||||
label: acceptLabel,
|
||||
disabled: accept_disabled,
|
||||
onClick: onAccept,
|
||||
},
|
||||
{
|
||||
autoWidth: true,
|
||||
label: rejectLabel,
|
||||
disabled: rejectDisabled,
|
||||
onClick: onReject,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<p className="description">{description}</p>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
error={invalid}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
.btn {
|
||||
text-align: center;
|
||||
padding: 6px 16px;
|
||||
letter-spacing: 0.1em;
|
||||
display: inline-block;
|
||||
font: bold 13px/19px "Alegreya SC";
|
||||
text-transform: uppercase;
|
||||
min-width: 140px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover, &:active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--headings-color);
|
||||
color: var(--headings-color);
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-color: var(--accent-color-intense);
|
||||
color: var(--accent-color-intense);
|
||||
}
|
||||
|
||||
// &.btn-primary {
|
||||
// border-image: linear-gradient(to bottom, #0abdb7 0%, #0abdb7 60%, #0c4550 100%) 1 1 1 1;
|
||||
// color: #a3c7c7;
|
||||
// background-color: transparentize(#101721, .5);
|
||||
// min-width: 160px;
|
||||
|
||||
// &:hover {
|
||||
// border-image: linear-gradient(to bottom, #b4fbfb 0%, #4bb8d9 100%) 1 1 1 1;
|
||||
// color: #caf8f7;
|
||||
// text-shadow: 0 0 3px #4be3de;
|
||||
// box-shadow: 0 0 4px transparentize(#4be3de, .4), 0 0 4px transparentize(#4be3de, .4) inset;
|
||||
// background-image: linear-gradient(transparentize(#4bb8d9, 1) 0%, transparentize(#4bb8d9, .75) 100%);
|
||||
// }
|
||||
|
||||
// &:active {
|
||||
// background-color: transparent;
|
||||
// color: #0d404c;
|
||||
// text-shadow: none;
|
||||
// border: 2px solid #0c4550;
|
||||
// box-shadow: 0 0 4px transparentize(#4be3de, .8), 0 0 4px transparentize(#4be3de, .8) inset;
|
||||
// background-image: linear-gradient(transparentize(#4bb8d9, 1) 0%, transparentize(#4bb8d9, .875) 100%);
|
||||
// }
|
||||
// }
|
||||
|
||||
&.fullWidth {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { notNullStrUndef } from "../../../Data/List"
|
||||
import { Maybe } from "../../../Data/Maybe"
|
||||
import { Button } from "./Button"
|
||||
import { Text } from "./Text"
|
||||
|
||||
interface Props {
|
||||
active?: boolean
|
||||
autoWidth?: boolean
|
||||
className?: string
|
||||
disabled?: boolean | Maybe<boolean>
|
||||
flat?: boolean
|
||||
fullWidth?: boolean
|
||||
hint?: Maybe<string>
|
||||
label: string | undefined
|
||||
primary?: boolean
|
||||
onClick? (): void
|
||||
}
|
||||
|
||||
export const BorderButton: React.FC<Props> = props => {
|
||||
const {
|
||||
active,
|
||||
autoWidth,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
flat,
|
||||
fullWidth,
|
||||
hint,
|
||||
primary,
|
||||
onClick,
|
||||
label,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Button
|
||||
active={active}
|
||||
autoWidth={autoWidth}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
flat={flat}
|
||||
fullWidth={fullWidth}
|
||||
hint={hint}
|
||||
primary={primary}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Text>{notNullStrUndef (label) ? label : children}</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
.modal-backdrop {
|
||||
--modal-padding: 24px;
|
||||
position: fixed;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
display: flex;
|
||||
background: var(--background-color-transparent06);
|
||||
z-index: 10000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.modal-container {
|
||||
height: auto;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--accent-color);
|
||||
position: relative;
|
||||
|
||||
&:before, &:after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: "";
|
||||
border: 1px solid var(--accent-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:before {
|
||||
top: -5px;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% + 8px);
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
left: -5px;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% - 8px);
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--accent-color);
|
||||
right: -21px;
|
||||
top: -21px;
|
||||
z-index: 1100;
|
||||
|
||||
&::before, &::after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
content: "";
|
||||
border: 1px solid var(--accent-color);
|
||||
z-index: 1099;
|
||||
}
|
||||
|
||||
&::before {
|
||||
height: 11px;
|
||||
width: 22px;
|
||||
top: 11px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
height: 22px;
|
||||
width: 11px;
|
||||
top: 16px;
|
||||
right: 11px;
|
||||
}
|
||||
|
||||
> div {
|
||||
border: 2px solid var(--accent-color);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-color);
|
||||
margin: 2px;
|
||||
font: 18px/26px Material;
|
||||
text-align: center;
|
||||
background: var(--background-color);
|
||||
position: absolute;
|
||||
z-index: 1100;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
> div {
|
||||
border-color: var(--headings-color);
|
||||
color: var(--headings-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--modal-padding) var(--modal-padding) calc(var(--modal-padding) * 5 / 8);
|
||||
|
||||
.modal-header-inner {
|
||||
font: bold 20px/24px Alegreya SC;
|
||||
color: var(--headings-color);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close + .modal-content, .modal-content:nth-child(1) {
|
||||
width: 298px;
|
||||
|
||||
.modal-content-inner {
|
||||
padding: 16px 0 47px;
|
||||
font: bold 16px/24px Alegreya SC;
|
||||
color: var(--headings-color);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
height: calc(100% - 77px);
|
||||
width: 378px;
|
||||
|
||||
.modal-content-inner {
|
||||
color: var(--text-color);
|
||||
font: 500 11px/16px Alegreya Sans;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0 var(--modal-padding) 55px;
|
||||
|
||||
p {
|
||||
margin-top: 14px;
|
||||
|
||||
span.link {
|
||||
color: #015175;
|
||||
display: block;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 22px;
|
||||
|
||||
&:hover {
|
||||
color: #3685a7;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
> div:first-child, > .checkbox + .checkbox {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
|
||||
> .dialog-buttons-inner {
|
||||
background: var(--background-color);
|
||||
position: relative;
|
||||
padding: 0 2px;
|
||||
margin: 0 24px;
|
||||
border-width: 0 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--accent-color);
|
||||
z-index: 10001;
|
||||
|
||||
> div {
|
||||
box-shadow: 0 none;
|
||||
margin: 0 1px;
|
||||
|
||||
&:first-child:before {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
right: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.more-buttons .dialog-buttons {
|
||||
height: auto;
|
||||
|
||||
> .dialog-buttons-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> div:not(:first-child) {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
> div:not(:last-child) {
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import { connect } from "react-redux"
|
||||
import { List, notNullStrUndef } from "../../../Data/List"
|
||||
import { guardReplace, Just, Maybe } from "../../../Data/Maybe"
|
||||
import { abs, max } from "../../../Data/Num"
|
||||
import { AppStateRecord } from "../../Models/AppState"
|
||||
import { Theme } from "../../Models/Config"
|
||||
import { getTheme } from "../../Selectors/uisettingsSelectors"
|
||||
import { classListMaybe } from "../../Utilities/CSS"
|
||||
import { DialogButtonProps } from "./DialogButton"
|
||||
import { DialogButtons } from "./DialogButtons"
|
||||
|
||||
const modals_root = document.querySelector ("#modals-root")
|
||||
|
||||
export interface DialogOwnProps {
|
||||
isOpen: boolean
|
||||
buttons?: DialogButtonProps[]
|
||||
className?: string
|
||||
id?: string
|
||||
noCloseButton?: boolean
|
||||
title?: string
|
||||
close (canceled: boolean): void
|
||||
onClose? (): void
|
||||
}
|
||||
|
||||
export interface DialogDispatchProps { }
|
||||
|
||||
export interface DialogStateProps {
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
type Props = DialogOwnProps & DialogDispatchProps & DialogStateProps
|
||||
|
||||
export const DialogComp: React.FC<Props> = props => {
|
||||
const {
|
||||
buttons = [],
|
||||
className,
|
||||
close,
|
||||
noCloseButton,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
isOpen,
|
||||
id,
|
||||
theme,
|
||||
} = props
|
||||
|
||||
const element = React.useMemo (() => document.createElement ("div"), [])
|
||||
|
||||
React.useEffect (
|
||||
() => {
|
||||
if (modals_root !== null) {
|
||||
modals_root.appendChild (element)
|
||||
|
||||
return () => {
|
||||
modals_root.removeChild (element)
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
[ element ]
|
||||
)
|
||||
|
||||
const handleButtonClick = React.useCallback (
|
||||
(f: () => void) => {
|
||||
if (typeof f === "function") {
|
||||
f ()
|
||||
}
|
||||
|
||||
if (typeof onClose === "function") {
|
||||
onClose ()
|
||||
}
|
||||
|
||||
close (false)
|
||||
},
|
||||
[ close, onClose ]
|
||||
)
|
||||
|
||||
const contentStyle: React.CSSProperties = buttons.length === 0 ? { paddingBottom: 26 } : {}
|
||||
const height_diff_base = 77
|
||||
const height_diff_add = 33
|
||||
const padding_base = 55
|
||||
const button_count = buttons .length
|
||||
const more_button_space = max (0) (button_count - 1) * height_diff_add
|
||||
const height_diff = button_count > 2 ? height_diff_base - more_button_space : height_diff_base
|
||||
const height_diff_abs = abs (height_diff)
|
||||
const height_diff_sign = height_diff < 0 ? "+" : "-"
|
||||
contentStyle.paddingBottom = button_count > 2 ? padding_base + more_button_space : padding_base
|
||||
|
||||
const handleCloseClick = React.useCallback (
|
||||
() => close (true),
|
||||
[ close ]
|
||||
)
|
||||
|
||||
return isOpen
|
||||
? ReactDOM.createPortal (
|
||||
<div
|
||||
className={
|
||||
classListMaybe (List (
|
||||
Just ("modal modal-backdrop"),
|
||||
Just (`theme-${theme}`),
|
||||
Maybe (className)
|
||||
))
|
||||
}
|
||||
id={id}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
classListMaybe (List (
|
||||
Just ("modal-container"),
|
||||
guardReplace (button_count > 2) ("more-buttons")
|
||||
))
|
||||
}
|
||||
>
|
||||
{noCloseButton === true
|
||||
? null
|
||||
: <div className="modal-close" onClick={handleCloseClick}><div>{"\uE5CD"}</div></div>}
|
||||
{notNullStrUndef (title)
|
||||
? (
|
||||
<div className="modal-header">
|
||||
<div className="modal-header-inner">{title}</div>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{ height: `calc(100% ${height_diff_sign} ${height_diff_abs}px)` }}
|
||||
>
|
||||
<div className="modal-content-inner" style={contentStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{buttons.length > 0
|
||||
? <DialogButtons list={buttons} onClickDefault={handleButtonClick} />
|
||||
: null}
|
||||
</div>
|
||||
</div>,
|
||||
element
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppStateRecord) => ({
|
||||
theme: getTheme (state),
|
||||
})
|
||||
|
||||
const connectDialog =
|
||||
connect<
|
||||
DialogStateProps,
|
||||
DialogDispatchProps,
|
||||
React.PropsWithChildren<DialogOwnProps>,
|
||||
AppStateRecord
|
||||
> (
|
||||
mapStateToProps
|
||||
)
|
||||
|
||||
export const Dialog = connectDialog (DialogComp)
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { List } from "../../../Data/List"
|
||||
import { guardReplace, Maybe, orN } from "../../../Data/Maybe"
|
||||
import { classListMaybe } from "../../Utilities/CSS"
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const Label: React.FC<Props> = props => {
|
||||
const { className, disabled, text } = props
|
||||
|
||||
return (
|
||||
<label
|
||||
className={
|
||||
classListMaybe (List (
|
||||
Maybe (className),
|
||||
guardReplace (orN (disabled)) ("disabled")
|
||||
))
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Maybe, maybe, normalize } from "../../../Data/Maybe"
|
||||
|
||||
interface Props {
|
||||
current?: Maybe<number> | number
|
||||
max?: Maybe<number> | number
|
||||
}
|
||||
|
||||
export const NumberBox: React.FC<Props> = ({ current, max }) => {
|
||||
const mcurrent = normalize (current)
|
||||
const mmax = normalize (max)
|
||||
|
||||
return (
|
||||
<div className="number-box">
|
||||
{maybe (<></>)
|
||||
((x: number) => (<span className="current">{x}</span>))
|
||||
(mcurrent)}
|
||||
{maybe (<></>)
|
||||
((x: number) => (<span className="max">{x}</span>))
|
||||
(mmax)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* The main axis is the axis that crosses both trigger and overlay:
|
||||
*
|
||||
* ```
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* [overlay]
|
||||
* |
|
||||
* [trigger]
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* ```
|
||||
*
|
||||
* The cross axis is orthogonal to the main axis.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { ident } from "../../../Data/Function"
|
||||
import { over } from "../../../Data/Lens"
|
||||
import { List } from "../../../Data/List"
|
||||
import { Just, Maybe } from "../../../Data/Maybe"
|
||||
import { fromDefault, makeLenses, Record, toObject } from "../../../Data/Record"
|
||||
import { classListMaybe } from "../../Utilities/CSS"
|
||||
|
||||
type Position = "top" | "bottom" | "left" | "right"
|
||||
|
||||
// Minimum offset from window border in pixel
|
||||
const MIN_WINDOW_OFFSET = 10
|
||||
|
||||
interface OverlayPosition {
|
||||
"@@name": "OverlayPosition"
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
const OverlayPosition = fromDefault ("OverlayPosition") <OverlayPosition> ({ top: 0, left: 0 })
|
||||
|
||||
const OPA = OverlayPosition.A
|
||||
const OPL = makeLenses (OverlayPosition)
|
||||
|
||||
const getTopGapMainAxis: (triggerCoord: ClientRect | DOMRect) =>
|
||||
(overlayCoord: ClientRect | DOMRect) =>
|
||||
(margin: number) => number =
|
||||
trg => ovl => m => trg .top - m - ovl .height - MIN_WINDOW_OFFSET
|
||||
|
||||
const getBottomGapMainAxis: (triggerCoord: ClientRect | DOMRect) =>
|
||||
(overlayCoord: ClientRect | DOMRect) =>
|
||||
(margin: number) => number =
|
||||
trg => ovl => m => window .innerHeight
|
||||
- (trg .top + trg .height + m + ovl .height + MIN_WINDOW_OFFSET)
|
||||
|
||||
const getLeftGapMainAxis: (triggerCoord: ClientRect | DOMRect) =>
|
||||
(overlayCoord: ClientRect | DOMRect) =>
|
||||
(margin: number) => number =
|
||||
trg => ovl => m => trg .left - m - ovl .width - MIN_WINDOW_OFFSET
|
||||
|
||||
const getRightGapMainAxis: (triggerCoord: ClientRect | DOMRect) =>
|
||||
(overlayCoord: ClientRect | DOMRect) =>
|
||||
(margin: number) => number =
|
||||
trg => ovl => m => window .innerWidth
|
||||
- (trg .left + trg .width + m + ovl .width + MIN_WINDOW_OFFSET)
|
||||
|
||||
const getPositionIfNotEnoughFreeSpace: (triggerCoord: ClientRect | DOMRect) =>
|
||||
(overlayCoord: ClientRect | DOMRect) =>
|
||||
(margin: number) =>
|
||||
(defPosition: Position) => Position =
|
||||
trg => ovl => m => pos => {
|
||||
switch (pos) {
|
||||
case "top":
|
||||
return getTopGapMainAxis (trg) (ovl) (m) < 0 && getBottomGapMainAxis (trg) (ovl) (m) >= 0
|
||||
? "bottom"
|
||||
: "top"
|
||||
|
||||
case "bottom":
|
||||
return getBottomGapMainAxis (trg) (ovl) (m) < 0 && getTopGapMainAxis (trg) (ovl) (m) >= 0
|
||||
? "top"
|
||||
: "bottom"
|
||||
|
||||
case "left":
|
||||
return getLeftGapMainAxis (trg) (ovl) (m) < 0 && getRightGapMainAxis (trg) (ovl) (m) >= 0
|
||||
? "right"
|
||||
: "left"
|
||||
|
||||
case "right":
|
||||
return getRightGapMainAxis (trg) (ovl) (m) < 0 && getLeftGapMainAxis (trg) (ovl) (m) >= 0
|
||||
? "left"
|
||||
: "right"
|
||||
|
||||
default:
|
||||
return pos
|
||||
}
|
||||
}
|
||||
|
||||
const getCenteredOverlayPosition: (triggerCoord: ClientRect | DOMRect) =>
|
||||
(overlayCoord: ClientRect | DOMRect) =>
|
||||
(margin: number) =>
|
||||
(position: Position) => Record<OverlayPosition> =
|
||||
trg => ovl => m => pos => {
|
||||
switch (pos) {
|
||||
case "top":
|
||||
return OverlayPosition ({
|
||||
left: trg .left + trg .width / 2 - ovl .width / 2,
|
||||
top: trg .top - m - ovl .height,
|
||||
})
|
||||
|
||||
case "bottom":
|
||||
return OverlayPosition ({
|
||||
left: trg .left + trg .width / 2 - ovl .width / 2,
|
||||
top: trg .top + trg .height + m,
|
||||
})
|
||||
|
||||
case "left":
|
||||
return OverlayPosition ({
|
||||
left: trg .left - m - ovl .width,
|
||||
top: trg .top + trg .height / 2 - ovl .height / 2,
|
||||
})
|
||||
|
||||
case "right":
|
||||
return OverlayPosition ({
|
||||
left: trg .left + trg .width + m,
|
||||
top: trg .top + trg .height / 2 - ovl .height / 2,
|
||||
})
|
||||
|
||||
default:
|
||||
return OverlayPosition.default
|
||||
}
|
||||
}
|
||||
|
||||
const adjustCrossAxis: (overlayCoord: ClientRect | DOMRect) =>
|
||||
(position: Position) =>
|
||||
(centeredCoord: Record<OverlayPosition>) => Record<OverlayPosition> =
|
||||
ovl => pos => {
|
||||
switch (pos) {
|
||||
case "top":
|
||||
case "bottom":
|
||||
return over (OPL.left)
|
||||
(left => left < MIN_WINDOW_OFFSET
|
||||
? MIN_WINDOW_OFFSET
|
||||
: left + ovl .width > window .innerWidth - MIN_WINDOW_OFFSET
|
||||
? window .innerWidth - MIN_WINDOW_OFFSET - ovl .width
|
||||
: left)
|
||||
|
||||
case "left":
|
||||
case "right":
|
||||
return over (OPL.top)
|
||||
(top => top < MIN_WINDOW_OFFSET
|
||||
? MIN_WINDOW_OFFSET
|
||||
: top + ovl .height > window .innerHeight - MIN_WINDOW_OFFSET
|
||||
? window .innerHeight - MIN_WINDOW_OFFSET - ovl .height
|
||||
: top)
|
||||
|
||||
default:
|
||||
return ident
|
||||
}
|
||||
}
|
||||
|
||||
const getArrowPosition: (arrow_size: number) =>
|
||||
(overlay_coords: ClientRect | DOMRect) =>
|
||||
(position: Position) =>
|
||||
(centered_coords: Record<OverlayPosition>) =>
|
||||
(adjusted_coords: Record<OverlayPosition>) => Record<OverlayPosition> =
|
||||
arrow_size => ovl => pos => centered => adjusted => {
|
||||
switch (pos) {
|
||||
case "top":
|
||||
return OverlayPosition ({
|
||||
left: ovl .width * 0.5 - arrow_size * 0.5 - 1 - OPA.left (adjusted) + OPA.left (centered),
|
||||
top: ovl .height - (arrow_size * 0.5 + 1.5),
|
||||
})
|
||||
|
||||
case "bottom":
|
||||
return OverlayPosition ({
|
||||
left: ovl .width * 0.5 - arrow_size * 0.5 - 1 - OPA.left (adjusted) + OPA.left (centered),
|
||||
top: -arrow_size * 0.5 - 0.5,
|
||||
})
|
||||
|
||||
case "left":
|
||||
return OverlayPosition ({
|
||||
left: ovl .width - (arrow_size * 0.5 + 1.5),
|
||||
top: ovl .height * 0.5 - arrow_size * 0.5 - 1 - OPA.top (adjusted) + OPA.top (centered),
|
||||
})
|
||||
|
||||
case "right":
|
||||
return OverlayPosition ({
|
||||
left: -arrow_size * 0.5 - 0.5,
|
||||
top: ovl .height * 0.5 - arrow_size * 0.5 - 1 - OPA.top (adjusted) + OPA.top (centered),
|
||||
})
|
||||
|
||||
default:
|
||||
return OverlayPosition.default
|
||||
}
|
||||
}
|
||||
|
||||
const alignToElement: (arrow_size: number) =>
|
||||
(ref: React.RefObject<HTMLDivElement>) =>
|
||||
(trigger: Element) =>
|
||||
(defPosition: Position) =>
|
||||
(margin: number) => [Position, React.CSSProperties, React.CSSProperties] =
|
||||
arrow_size => ref => trg => def_pos => m => {
|
||||
if (ref .current !== null) {
|
||||
const triggerCoord = trg .getBoundingClientRect ()
|
||||
const overlayCoord = ref .current .getBoundingClientRect ()
|
||||
|
||||
const pos = getPositionIfNotEnoughFreeSpace (triggerCoord) (overlayCoord) (m) (def_pos)
|
||||
|
||||
const centered_coords = getCenteredOverlayPosition (triggerCoord) (overlayCoord) (m) (pos)
|
||||
|
||||
const adjusted_coords = adjustCrossAxis (overlayCoord) (pos) (centered_coords)
|
||||
|
||||
const arrow_position = getArrowPosition (arrow_size)
|
||||
(overlayCoord)
|
||||
(pos)
|
||||
(centered_coords)
|
||||
(adjusted_coords)
|
||||
|
||||
return [ pos, toObject (adjusted_coords), toObject (arrow_position) ]
|
||||
}
|
||||
|
||||
return [ def_pos, {}, {} ]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
margin?: number
|
||||
position?: Position
|
||||
small?: boolean
|
||||
trigger: Element
|
||||
}
|
||||
|
||||
export const Overlay: React.FC<Props> = props => {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
margin = 0,
|
||||
position: defPosition = "top",
|
||||
small,
|
||||
trigger,
|
||||
} = props
|
||||
|
||||
const [ position, setPosition ] = React.useState<Position> (defPosition)
|
||||
const [ style, setStyle ] = React.useState<React.CSSProperties> ({ visibility: "hidden" })
|
||||
const [ arrowStyle, setArrowStyle ] = React.useState<React.CSSProperties> ({})
|
||||
const overlayRef = React.useRef<HTMLDivElement> (null)
|
||||
|
||||
const arrow_size = small === true ? 6 : 12
|
||||
|
||||
React.useEffect (
|
||||
() => {
|
||||
const [ new_pos, new_coords, new_arrow_coords ] =
|
||||
alignToElement (arrow_size) (overlayRef) (trigger) (defPosition) (margin)
|
||||
|
||||
setPosition (new_pos)
|
||||
setArrowStyle ({ height: arrow_size, width: arrow_size, ...new_arrow_coords })
|
||||
setStyle (new_coords)
|
||||
},
|
||||
[ setPosition, setStyle, defPosition, margin, trigger, setArrowStyle, arrow_size ]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={
|
||||
classListMaybe (List (
|
||||
Just (`overlay overlay-${position}`),
|
||||
Maybe (className)
|
||||
))
|
||||
}
|
||||
ref={overlayRef}
|
||||
>
|
||||
{children}
|
||||
<div className="arrow" style={arrowStyle} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// import { TextareaAutosize } from 'react-textarea-autosize'
|
||||
import * as React from "react"
|
||||
import { Either, isEither, isLeft } from "../../../Data/Either"
|
||||
import { List } from "../../../Data/List"
|
||||
import { guardReplace, isJust, isMaybe, Just, Maybe, orN } from "../../../Data/Maybe"
|
||||
import { classListMaybe } from "../../Utilities/CSS"
|
||||
import { TextFieldCounter } from "./TextFieldCounter"
|
||||
import { TextFieldError } from "./TextFieldError"
|
||||
import { TextFieldHint } from "./TextFieldHint"
|
||||
import { TextFieldLabel } from "./TextFieldLabel"
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
countCurrent?: number
|
||||
countMax?: number
|
||||
disabled?: boolean
|
||||
error?: Maybe<string> | Either<string, any>
|
||||
fullWidth?: boolean
|
||||
hint?: Maybe<string> | string
|
||||
isFieldEmpty: boolean
|
||||
label?: Maybe<string> | string
|
||||
valid?: boolean
|
||||
}
|
||||
|
||||
export const TextFieldContainer: React.FC<Props> = props => {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
countCurrent,
|
||||
countMax,
|
||||
disabled,
|
||||
error,
|
||||
fullWidth,
|
||||
hint,
|
||||
isFieldEmpty,
|
||||
label,
|
||||
valid,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classListMaybe (List (
|
||||
Just ("textfield"),
|
||||
Maybe (className),
|
||||
guardReplace (orN (fullWidth)) ("fullWidth"),
|
||||
guardReplace (orN (disabled)) ("disabled"),
|
||||
guardReplace (valid === false
|
||||
|| (isMaybe (error) && isJust (error))
|
||||
|| (isEither (error) && isLeft (error)))
|
||||
("invalid")
|
||||
))}
|
||||
>
|
||||
<TextFieldLabel label={label} />
|
||||
{children}
|
||||
<TextFieldHint hint={hint} isFieldEmpty={isFieldEmpty} />
|
||||
<TextFieldCounter current={countCurrent} max={countMax} />
|
||||
<TextFieldError error={error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { isNumber } from "../../Utilities/typeCheckUtils"
|
||||
|
||||
interface Props {
|
||||
current: number | undefined
|
||||
max: number | undefined
|
||||
}
|
||||
|
||||
export const TextFieldCounter: React.FC<Props> = ({ current, max }) =>
|
||||
isNumber (max)
|
||||
? (
|
||||
<div>
|
||||
{current}
|
||||
{" / "}
|
||||
{max}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
@@ -1,78 +0,0 @@
|
||||
// import { TextareaAutosize } from 'react-textarea-autosize'
|
||||
import * as React from "react"
|
||||
import { orN } from "../../../Data/Maybe"
|
||||
import { InputKeyEvent } from "../../Models/Hero/heroTypeHelpers"
|
||||
|
||||
interface Props {
|
||||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
type: string
|
||||
value: string
|
||||
onChange (newText: string): void
|
||||
onKeyDown? (event: InputKeyEvent): void
|
||||
onKeyUp? (event: InputKeyEvent): void
|
||||
}
|
||||
|
||||
export const TextFieldDeferred: React.FC<Props> = props => {
|
||||
const {
|
||||
autoFocus,
|
||||
disabled,
|
||||
type,
|
||||
value: defaultValue,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
} = props
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement | null> (null)
|
||||
|
||||
const [ value, setValue ] = React.useState (defaultValue)
|
||||
const [ prevValue, setPrevValue ] = React.useState (defaultValue)
|
||||
|
||||
if (prevValue !== defaultValue) {
|
||||
setValue (defaultValue)
|
||||
setPrevValue (defaultValue)
|
||||
}
|
||||
|
||||
React.useEffect (
|
||||
() => {
|
||||
if (orN (autoFocus) && inputRef.current !== null) {
|
||||
inputRef.current.focus ()
|
||||
}
|
||||
},
|
||||
[ autoFocus ]
|
||||
)
|
||||
|
||||
const handleChange =
|
||||
React.useCallback (
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!orN (disabled)) {
|
||||
setValue (e.target.value)
|
||||
}
|
||||
},
|
||||
[ disabled ]
|
||||
)
|
||||
|
||||
const handleBlur =
|
||||
React.useCallback (
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!orN (disabled) && defaultValue !== value) {
|
||||
onChange (e.target.value)
|
||||
}
|
||||
},
|
||||
[ defaultValue, disabled, onChange, value ]
|
||||
)
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyPress={orN (disabled) ? undefined : onKeyDown}
|
||||
onKeyUp={orN (disabled) ? undefined : onKeyUp}
|
||||
readOnly={disabled}
|
||||
ref={inputRef}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Either, eitherToMaybe, invertEither, isEither } from "../../../Data/Either"
|
||||
import { fmap } from "../../../Data/Functor"
|
||||
import { notNullStr } from "../../../Data/List"
|
||||
import { bindF, ensure, Maybe, maybeToNullable, normalize } from "../../../Data/Maybe"
|
||||
import { pipe_ } from "../../Utilities/pipe"
|
||||
|
||||
interface Props {
|
||||
error: Maybe<string> | Either<string, any> | undefined
|
||||
}
|
||||
|
||||
export const TextFieldError: React.FC<Props> =
|
||||
({ error }) => pipe_ (
|
||||
error,
|
||||
x => isEither (x) ? eitherToMaybe (invertEither (x)) : x,
|
||||
normalize,
|
||||
bindF (ensure (notNullStr)),
|
||||
fmap ((msg: string) => (
|
||||
<p className="error">
|
||||
{msg}
|
||||
</p>
|
||||
)),
|
||||
maybeToNullable
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { fmap } from "../../../Data/Functor"
|
||||
import { List } from "../../../Data/List"
|
||||
import { guardReplace, Just, Maybe, maybeToNullable, normalize } from "../../../Data/Maybe"
|
||||
import { classListMaybe } from "../../Utilities/CSS"
|
||||
import { pipe_ } from "../../Utilities/pipe"
|
||||
|
||||
interface Props {
|
||||
hint: Maybe<string> | string | undefined
|
||||
isFieldEmpty: boolean
|
||||
}
|
||||
|
||||
export const TextFieldHint: React.FC<Props> =
|
||||
({ hint: mhint, isFieldEmpty }) => pipe_ (
|
||||
mhint,
|
||||
normalize,
|
||||
fmap ((hint: string) => (
|
||||
<div
|
||||
className={
|
||||
classListMaybe (List (
|
||||
Just ("textfield-hint"),
|
||||
guardReplace (!isFieldEmpty) ("hide")
|
||||
))
|
||||
}
|
||||
>
|
||||
{hint}
|
||||
</div>
|
||||
)),
|
||||
maybeToNullable
|
||||
)
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { fmap } from "../../../Data/Functor"
|
||||
import { notNullStr } from "../../../Data/List"
|
||||
import { bindF, ensure, Maybe, maybeToNullable, normalize } from "../../../Data/Maybe"
|
||||
import { pipe_ } from "../../Utilities/pipe"
|
||||
import { Label } from "./Label"
|
||||
|
||||
interface Props {
|
||||
label: Maybe<string> | string | undefined
|
||||
}
|
||||
|
||||
export const TextFieldLabel: React.FC<Props> =
|
||||
({ label }) => pipe_ (
|
||||
label,
|
||||
normalize,
|
||||
bindF (ensure (notNullStr)),
|
||||
fmap ((l: string) => <Label text={l} />),
|
||||
maybeToNullable
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { List } from "../../../Data/List"
|
||||
import { guardReplace, Just, orN } from "../../../Data/Maybe"
|
||||
import { classListMaybe } from "../../Utilities/CSS"
|
||||
import { Overlay } from "./Overlay"
|
||||
|
||||
interface Props {
|
||||
content: React.ReactNode
|
||||
margin?: number
|
||||
small?: boolean
|
||||
position?: "top" | "bottom" | "left" | "right"
|
||||
target: JSX.Element
|
||||
}
|
||||
|
||||
export const TooltipToggle: React.FC<Props> = props => {
|
||||
const { content, margin, position = "top", small, target } = props
|
||||
|
||||
const [ isOpen, setIsOpen ] = React.useState (false)
|
||||
|
||||
const targetRef = React.useRef<Element> (null)
|
||||
|
||||
const handleMouseOver =
|
||||
React.useCallback (
|
||||
() => setIsOpen (true),
|
||||
[ setIsOpen ]
|
||||
)
|
||||
|
||||
const handleMouseOut =
|
||||
React.useCallback (
|
||||
() => setIsOpen (false),
|
||||
[ setIsOpen ]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{React.cloneElement (
|
||||
target,
|
||||
{
|
||||
ref: targetRef,
|
||||
onMouseOut: handleMouseOut,
|
||||
onMouseOver: handleMouseOver,
|
||||
}
|
||||
)}
|
||||
{isOpen && targetRef .current !== null
|
||||
? (
|
||||
<Overlay
|
||||
className={classListMaybe (List (
|
||||
Just (`tooltip`),
|
||||
guardReplace (orN (small)) ("tooltip-small")
|
||||
))}
|
||||
position={position}
|
||||
trigger={targetRef .current}
|
||||
margin={margin}
|
||||
small={small}
|
||||
>
|
||||
{content}
|
||||
</Overlay>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { FC, useCallback, useEffect } from "react"
|
||||
import { addKeybinding, removeKeybinding } from "../../../App/Utilities/Keybindings.ts"
|
||||
import { Dialog } from "../dialog/Dialog.tsx"
|
||||
import { InputKeyEvent, TextField } from "../textField/TextField.tsx"
|
||||
|
||||
type Props = {
|
||||
id: string
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
value: string | undefined
|
||||
invalid?: string
|
||||
acceptLabel: string
|
||||
rejectLabel: string
|
||||
rejectDisabled?: boolean
|
||||
onClose: () => void
|
||||
onAccept: () => void
|
||||
onReject?: () => void
|
||||
onChange: (newText: string) => void
|
||||
}
|
||||
|
||||
export const BasicInputDialog: FC<Props> = props => {
|
||||
const {
|
||||
id,
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
invalid,
|
||||
acceptLabel,
|
||||
rejectLabel,
|
||||
rejectDisabled = false,
|
||||
onClose,
|
||||
onAccept,
|
||||
onReject = () => undefined,
|
||||
onChange,
|
||||
} = props
|
||||
|
||||
const isInputEmpty = value === undefined || value === ""
|
||||
|
||||
const acceptDisabled = isInputEmpty || invalid !== undefined
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(event: InputKeyEvent) => {
|
||||
if (event.key === "Enter" && !acceptDisabled) {
|
||||
onAccept()
|
||||
onClose()
|
||||
}
|
||||
else if (event.key === "Enter" && isInputEmpty && !rejectDisabled) {
|
||||
onReject()
|
||||
onClose()
|
||||
}
|
||||
else if (event.key === "Escape") {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[ acceptDisabled, isInputEmpty, rejectDisabled, onAccept, onReject, onClose ]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
addKeybinding("esc", () => {
|
||||
onClose()
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeKeybinding("esc")
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
id={id}
|
||||
close={onClose}
|
||||
isOpen={isOpen}
|
||||
title={title}
|
||||
buttons={[
|
||||
{
|
||||
autoWidth: true,
|
||||
label: acceptLabel,
|
||||
disabled: acceptDisabled,
|
||||
onClick: onAccept,
|
||||
},
|
||||
{
|
||||
autoWidth: true,
|
||||
label: rejectLabel,
|
||||
disabled: rejectDisabled,
|
||||
onClick: onReject,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<p className="description">{description}</p>
|
||||
<TextField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
fullWidth
|
||||
autoFocus
|
||||
error={invalid}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,15 @@
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--accent-color);
|
||||
background: none;
|
||||
margin: 0;
|
||||
|
||||
&.btn--text {
|
||||
padding: 0 16px;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
font: bold 13px/19px "Alegreya SC";
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
@@ -22,7 +31,7 @@
|
||||
color: var(--accent-color-intense);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
&:disabled {
|
||||
border-color: var(--separator-color-transparent);
|
||||
color: var(--separator-color-halftransparent);
|
||||
pointer-events: none;
|
||||
@@ -33,7 +42,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
&.btn--active {
|
||||
background: var(--accent-color);
|
||||
color: var(--background-color);
|
||||
pointer-events: none;
|
||||
@@ -43,12 +52,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.autoWidth {
|
||||
&.btn--auto-width {
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
&.btn-flat {
|
||||
&.btn--flat {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&.btn--full-width {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,18 +33,18 @@ export const Button: FCC<Props> = props => {
|
||||
<button
|
||||
className={classList(
|
||||
"btn",
|
||||
round === true ? "btn-round" : "btn-text",
|
||||
round === true ? "btn--round" : "btn--text",
|
||||
className,
|
||||
{
|
||||
"btn-primary": primary === true,
|
||||
"btn-flat": flat === true,
|
||||
autoWidth,
|
||||
fullWidth,
|
||||
disabled,
|
||||
active,
|
||||
"btn--primary": primary === true,
|
||||
"btn--flat": flat === true,
|
||||
"btn--auto-width": autoWidth,
|
||||
"btn--full-width": fullWidth,
|
||||
"btn--active": active,
|
||||
}
|
||||
)}
|
||||
onClick={disabled === true ? undefined : onClick}
|
||||
disabled={disabled === true}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
.modal-backdrop {
|
||||
--modal-padding: 24px;
|
||||
position: fixed;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
display: flex;
|
||||
background: var(--background-color-transparent06);
|
||||
z-index: 10000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.modal-container {
|
||||
height: auto;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--accent-color);
|
||||
position: relative;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
content: "";
|
||||
border: 1px solid var(--accent-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:before {
|
||||
top: -5px;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% + 8px);
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
left: -5px;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% - 8px);
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--accent-color);
|
||||
right: -21px;
|
||||
top: -21px;
|
||||
z-index: 1100;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
content: "";
|
||||
border: 1px solid var(--accent-color);
|
||||
z-index: 1099;
|
||||
}
|
||||
|
||||
&::before {
|
||||
height: 11px;
|
||||
width: 22px;
|
||||
top: 11px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
height: 22px;
|
||||
width: 11px;
|
||||
top: 16px;
|
||||
right: 11px;
|
||||
}
|
||||
|
||||
> div {
|
||||
border: 2px solid var(--accent-color);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-color);
|
||||
margin: 2px;
|
||||
font: 18px/26px Material;
|
||||
text-align: center;
|
||||
background: var(--background-color);
|
||||
position: absolute;
|
||||
z-index: 1100;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
> div {
|
||||
border-color: var(--headings-color);
|
||||
color: var(--headings-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--modal-padding) var(--modal-padding)
|
||||
calc(var(--modal-padding) * 5 / 8);
|
||||
|
||||
.modal-header-inner {
|
||||
font: bold 20px/24px Alegreya SC;
|
||||
color: var(--headings-color);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close + .modal-content,
|
||||
.modal-content:nth-child(1) {
|
||||
width: 298px;
|
||||
|
||||
.modal-content-inner {
|
||||
padding: 16px 0 47px;
|
||||
font: bold 16px/24px Alegreya SC;
|
||||
color: var(--headings-color);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
height: calc(100% - 77px);
|
||||
width: 378px;
|
||||
|
||||
.modal-content-inner {
|
||||
color: var(--text-color);
|
||||
font: 500 11px/16px Alegreya Sans;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0 var(--modal-padding) 55px;
|
||||
|
||||
p {
|
||||
margin-top: 14px;
|
||||
|
||||
span.link {
|
||||
color: #015175;
|
||||
display: block;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 22px;
|
||||
|
||||
&:hover {
|
||||
color: #3685a7;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
> div:first-child,
|
||||
> .checkbox + .checkbox {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
|
||||
> .dialog-buttons-inner {
|
||||
background: var(--background-color);
|
||||
position: relative;
|
||||
padding: 0 2px;
|
||||
margin: 0 24px;
|
||||
border-width: 0 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--accent-color);
|
||||
z-index: 10001;
|
||||
|
||||
> div {
|
||||
box-shadow: 0 none;
|
||||
margin: 0 1px;
|
||||
|
||||
&:first-child:before {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
&:last-child:after {
|
||||
right: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.more-buttons .dialog-buttons {
|
||||
height: auto;
|
||||
|
||||
> .dialog-buttons-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> div:not(:first-child) {
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
> div:not(:last-child) {
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useCallback } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { useAppSelector } from "../../../main_window/hooks/redux.ts"
|
||||
import { selectTheme } from "../../../main_window/slices/settingsSlice.ts"
|
||||
import { classList } from "../../utils/classList.ts"
|
||||
import { FCC } from "../../utils/react.ts"
|
||||
import "./Dialog.scss"
|
||||
import { DialogButtonProps } from "./DialogButton.tsx"
|
||||
import { DialogButtons } from "./DialogButtons.tsx"
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
buttons?: DialogButtonProps[]
|
||||
className?: string
|
||||
id?: string
|
||||
noCloseButton?: boolean
|
||||
title?: string
|
||||
close (canceled: boolean): void
|
||||
onClose? (): void
|
||||
}
|
||||
|
||||
export const Dialog: FCC<Props> = props => {
|
||||
const {
|
||||
buttons = [],
|
||||
className,
|
||||
close,
|
||||
noCloseButton,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
isOpen,
|
||||
id,
|
||||
} = props
|
||||
|
||||
const theme = useAppSelector(selectTheme)
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(f: () => void) => {
|
||||
if (typeof f === "function") {
|
||||
f()
|
||||
}
|
||||
|
||||
if (typeof onClose === "function") {
|
||||
onClose()
|
||||
}
|
||||
|
||||
close(false)
|
||||
},
|
||||
[ close, onClose ]
|
||||
)
|
||||
|
||||
const contentStyle: React.CSSProperties = buttons.length === 0 ? { paddingBottom: 26 } : {}
|
||||
const height_diff_base = 77
|
||||
const height_diff_add = 33
|
||||
const padding_base = 55
|
||||
const button_count = buttons .length
|
||||
const more_button_space = Math.max(0, button_count - 1) * height_diff_add
|
||||
const height_diff = button_count > 2 ? height_diff_base - more_button_space : height_diff_base
|
||||
const height_diff_abs = Math.abs(height_diff)
|
||||
const height_diff_sign = height_diff < 0 ? "+" : "-"
|
||||
contentStyle.paddingBottom = button_count > 2 ? padding_base + more_button_space : padding_base
|
||||
|
||||
const handleCloseClick = useCallback(
|
||||
() => close(true),
|
||||
[ close ]
|
||||
)
|
||||
|
||||
return isOpen
|
||||
? createPortal(
|
||||
<div
|
||||
className={classList("modal", "modal-backdrop", `theme--${theme}`, className)}
|
||||
id={id}
|
||||
>
|
||||
<div
|
||||
className={classList("modal-container", { "more-buttons": button_count > 2 })}
|
||||
>
|
||||
{noCloseButton === true
|
||||
? null
|
||||
: <div className="modal-close" onClick={handleCloseClick}><div>{"\uE5CD"}</div></div>}
|
||||
{title !== undefined && title.length > 0
|
||||
? (
|
||||
<div className="modal-header">
|
||||
<div className="modal-header-inner">{title}</div>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{ height: `calc(100% ${height_diff_sign} ${height_diff_abs}px)` }}
|
||||
>
|
||||
<div className="modal-content-inner" style={contentStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{buttons.length > 0
|
||||
? <DialogButtons list={buttons} onClickDefault={handleButtonClick} />
|
||||
: null}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null
|
||||
}
|
||||
+17
-16
@@ -1,15 +1,14 @@
|
||||
import * as React from "react"
|
||||
import { Maybe } from "../../../Data/Maybe"
|
||||
import { BorderButton } from "./BorderButton"
|
||||
import { FC, useCallback } from "react"
|
||||
import { Button } from "../button/Button.tsx"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
active?: boolean
|
||||
autoWidth?: boolean
|
||||
className?: string
|
||||
disabled?: boolean | Maybe<boolean>
|
||||
disabled?: boolean
|
||||
flat?: boolean
|
||||
fullWidth?: boolean
|
||||
hint?: Maybe<string>
|
||||
hint?: string
|
||||
label: string | undefined
|
||||
primary?: boolean
|
||||
onClick? (): void
|
||||
@@ -18,7 +17,7 @@ interface Props {
|
||||
|
||||
export type DialogButtonProps = Omit<Props, "onClickDefault">
|
||||
|
||||
export const DialogButton: React.FC<Props> = props => {
|
||||
export const DialogButton: FC<Props> = props => {
|
||||
const {
|
||||
active,
|
||||
autoWidth,
|
||||
@@ -33,17 +32,18 @@ export const DialogButton: React.FC<Props> = props => {
|
||||
label,
|
||||
} = props
|
||||
|
||||
const handleClick = React.useCallback (
|
||||
() => typeof onClickDefault === "function"
|
||||
? onClickDefault (onClick)
|
||||
: typeof onClick === "function"
|
||||
? onClick ()
|
||||
: undefined,
|
||||
const handleClick = useCallback(
|
||||
() =>
|
||||
typeof onClickDefault === "function"
|
||||
? onClickDefault(onClick)
|
||||
: typeof onClick === "function"
|
||||
? onClick()
|
||||
: undefined,
|
||||
[ onClick, onClickDefault ]
|
||||
)
|
||||
|
||||
return (
|
||||
<BorderButton
|
||||
<Button
|
||||
active={active}
|
||||
autoWidth={autoWidth}
|
||||
className={className}
|
||||
@@ -52,9 +52,10 @@ export const DialogButton: React.FC<Props> = props => {
|
||||
fullWidth={fullWidth}
|
||||
hint={hint}
|
||||
primary={primary}
|
||||
label={label}
|
||||
onClick={handleClick}
|
||||
key={label}
|
||||
/>
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+6
-6
@@ -1,17 +1,17 @@
|
||||
import * as React from "react"
|
||||
import { DialogButton, DialogButtonProps } from "./DialogButton"
|
||||
import { FC } from "react"
|
||||
import { DialogButton, DialogButtonProps } from "./DialogButton.tsx"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
list: DialogButtonProps[]
|
||||
onClickDefault? (f?: () => void): void
|
||||
}
|
||||
|
||||
export const DialogButtons: React.FC<Props> = props => {
|
||||
export const DialogButtons: FC<Props> = props => {
|
||||
const { list, onClickDefault } = props
|
||||
|
||||
const buttons =
|
||||
Array.isArray (list) && list.length > 0
|
||||
? list.map (
|
||||
Array.isArray(list) && list.length > 0
|
||||
? list.map(
|
||||
({
|
||||
active,
|
||||
autoWidth,
|
||||
@@ -1,22 +1,22 @@
|
||||
.btn-round {
|
||||
width: 32px;
|
||||
color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
.btn--round {
|
||||
width: 32px;
|
||||
color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
|
||||
> div {
|
||||
font: 32px/30px TDE Heroes;
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
margin-left: -1px;
|
||||
}
|
||||
> div {
|
||||
font: 32px/30px TDE Heroes;
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--separator-color-transparent);
|
||||
&:disabled {
|
||||
color: var(--separator-color-transparent);
|
||||
|
||||
&:hover {
|
||||
color: var(--separator-color-transparent);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
color: var(--separator-color-transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FC } from "react"
|
||||
import { classList } from "../../utils/classList.ts"
|
||||
import "./Label.scss"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const Label: FC<Props> = props => {
|
||||
const { className, disabled, text } = props
|
||||
|
||||
return (
|
||||
<label className={classList(className, { disabled })} >
|
||||
{text}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FC } from "react"
|
||||
import "./NumberBox.scss"
|
||||
|
||||
type Props = {
|
||||
current?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export const NumberBox: FC<Props> = ({ current, max }) => (
|
||||
<div className="number-box">
|
||||
{current === undefined ? null : <span className="current">{current}</span>}
|
||||
{max === undefined ? null : <span className="max">{max}</span>}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* The main axis is the axis that crosses both trigger and overlay:
|
||||
*
|
||||
* ```
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* [overlay]
|
||||
* |
|
||||
* [trigger]
|
||||
* |
|
||||
* |
|
||||
* |
|
||||
* ```
|
||||
*
|
||||
* The cross axis is orthogonal to the main axis.
|
||||
*/
|
||||
|
||||
import { RefObject, useEffect, useRef, useState } from "react"
|
||||
import { classList } from "../../utils/classList.ts"
|
||||
import { FCC } from "../../utils/react.ts"
|
||||
import { assertExhaustive } from "../../utils/typeSafety.ts"
|
||||
import "./Overlay.scss"
|
||||
|
||||
type Position = "top" | "bottom" | "left" | "right"
|
||||
|
||||
// Minimum offset from window border in pixel
|
||||
const MIN_WINDOW_OFFSET = 10
|
||||
|
||||
type OverlayPosition = {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
type AxisGetter = (
|
||||
triggerRect: ClientRect | DOMRect,
|
||||
overlayRect: ClientRect | DOMRect,
|
||||
margin: number
|
||||
) => number
|
||||
|
||||
const getTitlebarHeight = () => {
|
||||
const rawStyleValue = getComputedStyle(document.body).getPropertyValue("--titlebar-height")
|
||||
const numericValue = /(?<number>\d+)/u.exec(rawStyleValue)?.groups?.["number"]
|
||||
return numericValue === undefined ? 0 : Number.parseInt(numericValue, 10)
|
||||
}
|
||||
|
||||
const getTopGapMainAxis: AxisGetter = (triggerRect, overlayRect, margin) =>
|
||||
triggerRect.top - margin - overlayRect.height - MIN_WINDOW_OFFSET - getTitlebarHeight()
|
||||
|
||||
const getBottomGapMainAxis: AxisGetter = (triggerRect, overlayRect, margin) =>
|
||||
window.innerHeight
|
||||
- (triggerRect.top + triggerRect.height + margin + overlayRect.height + MIN_WINDOW_OFFSET)
|
||||
|
||||
const getLeftGapMainAxis: AxisGetter = (triggerRect, overlayRect, margin) =>
|
||||
triggerRect.left - margin - overlayRect.width - MIN_WINDOW_OFFSET
|
||||
|
||||
const getRightGapMainAxis: AxisGetter = (triggerRect, overlayRect, margin) =>
|
||||
window.innerWidth
|
||||
- (triggerRect.left + triggerRect.width + margin + overlayRect.width + MIN_WINDOW_OFFSET)
|
||||
|
||||
const getPositionIfNotEnoughFreeSpace = (
|
||||
triggerRect: ClientRect | DOMRect,
|
||||
overlayRect: ClientRect | DOMRect,
|
||||
margin: number,
|
||||
pos: Position,
|
||||
): Position => {
|
||||
const switchPos = (
|
||||
ifBelowZero: AxisGetter,
|
||||
ifAtLeastZero: AxisGetter,
|
||||
ifBothMatch: Position,
|
||||
otherwise: Position,
|
||||
) =>
|
||||
ifBelowZero(triggerRect, overlayRect, margin) < 0
|
||||
&& ifAtLeastZero(triggerRect, overlayRect, margin) >= 0
|
||||
? ifBothMatch
|
||||
: otherwise
|
||||
|
||||
switch (pos) {
|
||||
case "top": return switchPos(getTopGapMainAxis, getBottomGapMainAxis, "bottom", "top")
|
||||
case "bottom": return switchPos(getBottomGapMainAxis, getTopGapMainAxis, "top", "bottom")
|
||||
case "left": return switchPos(getLeftGapMainAxis, getRightGapMainAxis, "right", "left")
|
||||
case "right": return switchPos(getRightGapMainAxis, getLeftGapMainAxis, "left", "right")
|
||||
default: return assertExhaustive(pos)
|
||||
}
|
||||
}
|
||||
|
||||
const getCenteredOverlayPosition = (
|
||||
triggerRect: ClientRect | DOMRect,
|
||||
overlayRect: ClientRect | DOMRect,
|
||||
margin: number,
|
||||
pos: Position,
|
||||
): OverlayPosition => {
|
||||
switch (pos) {
|
||||
case "top": return {
|
||||
left: triggerRect.left + triggerRect.width / 2 - overlayRect.width / 2,
|
||||
top: triggerRect.top - overlayRect.height - margin,
|
||||
}
|
||||
|
||||
case "bottom": return {
|
||||
left: triggerRect.left + triggerRect.width / 2 - overlayRect.width / 2,
|
||||
top: triggerRect.top + triggerRect.height + margin,
|
||||
}
|
||||
|
||||
case "left": return {
|
||||
left: triggerRect.left - overlayRect.width - margin,
|
||||
top: triggerRect.top + triggerRect.height / 2 - overlayRect.height / 2,
|
||||
}
|
||||
|
||||
case "right": return {
|
||||
left: triggerRect.left + triggerRect.width + margin,
|
||||
top: triggerRect.top + triggerRect.height / 2 - overlayRect.height / 2,
|
||||
}
|
||||
|
||||
default: return assertExhaustive(pos)
|
||||
}
|
||||
}
|
||||
|
||||
const adjustCrossAxis = (
|
||||
overlayRect: ClientRect | DOMRect,
|
||||
pos: Position,
|
||||
centeredRect: OverlayPosition,
|
||||
): OverlayPosition => {
|
||||
switch (pos) {
|
||||
case "top":
|
||||
case "bottom": {
|
||||
const { top, left } = centeredRect
|
||||
|
||||
return {
|
||||
top,
|
||||
left: left < MIN_WINDOW_OFFSET
|
||||
? MIN_WINDOW_OFFSET
|
||||
: left + overlayRect.width > window.innerWidth - MIN_WINDOW_OFFSET
|
||||
? window.innerWidth - MIN_WINDOW_OFFSET - overlayRect.width
|
||||
: left,
|
||||
}
|
||||
}
|
||||
|
||||
case "left":
|
||||
case "right": {
|
||||
const { top, left } = centeredRect
|
||||
|
||||
return {
|
||||
top: top < MIN_WINDOW_OFFSET
|
||||
? MIN_WINDOW_OFFSET
|
||||
: top + overlayRect.height > window.innerHeight - MIN_WINDOW_OFFSET
|
||||
? window.innerHeight - MIN_WINDOW_OFFSET - overlayRect.height
|
||||
: top,
|
||||
left,
|
||||
}
|
||||
}
|
||||
|
||||
default: return assertExhaustive(pos)
|
||||
}
|
||||
}
|
||||
|
||||
const getArrowPosition = (
|
||||
arrowSize: number,
|
||||
overlayCoords: ClientRect | DOMRect,
|
||||
position: Position,
|
||||
centered: OverlayPosition,
|
||||
adjusted: OverlayPosition,
|
||||
): OverlayPosition => {
|
||||
switch (position) {
|
||||
case "top": return {
|
||||
left: overlayCoords.width * 0.5 - arrowSize * 0.5 - 1 - adjusted.left + centered.left,
|
||||
top: overlayCoords.height - (arrowSize * 0.5 + 1.5),
|
||||
}
|
||||
|
||||
case "bottom": return {
|
||||
left: overlayCoords.width * 0.5 - arrowSize * 0.5 - 1 - adjusted.left + centered.left,
|
||||
top: -arrowSize * 0.5 - 0.5,
|
||||
}
|
||||
|
||||
case "left": return {
|
||||
left: overlayCoords.width - (arrowSize * 0.5 + 1.5),
|
||||
top: overlayCoords.height * 0.5 - arrowSize * 0.5 - 1 - adjusted.top + centered.top,
|
||||
}
|
||||
|
||||
case "right": return {
|
||||
left: -arrowSize * 0.5 - 0.5,
|
||||
top: overlayCoords.height * 0.5 - arrowSize * 0.5 - 1 - adjusted.top + centered.top,
|
||||
}
|
||||
|
||||
default: return assertExhaustive(position)
|
||||
}
|
||||
}
|
||||
|
||||
const alignToElement = (
|
||||
arrowSize: number,
|
||||
ref: RefObject<HTMLDivElement>,
|
||||
trigger: Element,
|
||||
defPosition: Position,
|
||||
margin: number,
|
||||
): {
|
||||
newPosition: Position
|
||||
newCoords: Partial<OverlayPosition>
|
||||
newArrowCoords: Partial<OverlayPosition>
|
||||
} => {
|
||||
if (ref.current !== null) {
|
||||
const triggerRect = trigger.getBoundingClientRect()
|
||||
const overlayRect = ref.current.getBoundingClientRect()
|
||||
const pos = getPositionIfNotEnoughFreeSpace(triggerRect, overlayRect, margin, defPosition)
|
||||
const centeredRect = getCenteredOverlayPosition(triggerRect, overlayRect, margin, pos)
|
||||
const adjustedRect = adjustCrossAxis(overlayRect, pos, centeredRect)
|
||||
const arrowPos = getArrowPosition(arrowSize, overlayRect, pos, centeredRect, adjustedRect)
|
||||
|
||||
return { newPosition: pos, newCoords: adjustedRect, newArrowCoords: arrowPos }
|
||||
}
|
||||
|
||||
return { newPosition: defPosition, newCoords: {}, newArrowCoords: {} }
|
||||
}
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
margin?: number
|
||||
position?: Position
|
||||
small?: boolean
|
||||
trigger: Element
|
||||
}
|
||||
|
||||
export const Overlay: FCC<Props> = props => {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
margin = 0,
|
||||
position: defPosition = "top",
|
||||
small,
|
||||
trigger,
|
||||
} = props
|
||||
|
||||
const [ position, setPosition ] = useState<Position>(defPosition)
|
||||
const [ style, setStyle ] = useState<React.CSSProperties>({ visibility: "hidden" })
|
||||
const [ arrowStyle, setArrowStyle ] = useState<React.CSSProperties>({})
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const arrow_size = small === true ? 6 : 12
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const { newPosition, newCoords, newArrowCoords } =
|
||||
alignToElement(arrow_size, overlayRef, trigger, defPosition, margin)
|
||||
|
||||
setPosition(newPosition)
|
||||
setArrowStyle({ height: arrow_size, width: arrow_size, ...newArrowCoords })
|
||||
setStyle(newCoords)
|
||||
},
|
||||
[ setPosition, setStyle, defPosition, margin, trigger, setArrowStyle, arrow_size ]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classList("overlay", `overlay-${position}`, className)}
|
||||
ref={overlayRef}
|
||||
>
|
||||
{children}
|
||||
<div className="arrow" style={arrowStyle} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+31
-26
@@ -26,7 +26,8 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 1px solid var(--accent-color);
|
||||
@@ -38,7 +39,9 @@
|
||||
outline: 0 none;
|
||||
background: none;
|
||||
|
||||
&:hover, &:focus, &:active {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: var(--headings-color);
|
||||
border-color: var(--headings-color);
|
||||
|
||||
@@ -63,7 +66,8 @@
|
||||
color: var(--separator-color-halftransparent);
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
input,
|
||||
textarea {
|
||||
border-color: var(--separator-color-transparent);
|
||||
color: var(--separator-color-halftransparent);
|
||||
}
|
||||
@@ -74,33 +78,34 @@
|
||||
color: var(--uncommon-bright-color);
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
input,
|
||||
textarea {
|
||||
border: 1px solid var(--uncommon-dark-color);
|
||||
color: var(--uncommon-bright-color);
|
||||
}
|
||||
}
|
||||
|
||||
// hr {
|
||||
// width: 100%;
|
||||
// height: 0px;
|
||||
// border: 0 none;
|
||||
// margin: 0;
|
||||
//
|
||||
// &.default {
|
||||
// border-bottom: 1px solid #182424;
|
||||
// }
|
||||
//
|
||||
// &.accent {
|
||||
// border-bottom: 2px solid $highlight;
|
||||
// transform: scaleX(0);
|
||||
// margin-top: -2px;
|
||||
// @include defaultEasing (all);
|
||||
// }
|
||||
// }
|
||||
// hr {
|
||||
// width: 100%;
|
||||
// height: 0px;
|
||||
// border: 0 none;
|
||||
// margin: 0;
|
||||
//
|
||||
// &.default {
|
||||
// border-bottom: 1px solid #182424;
|
||||
// }
|
||||
//
|
||||
// &.accent {
|
||||
// border-bottom: 2px solid $highlight;
|
||||
// transform: scaleX(0);
|
||||
// margin-top: -2px;
|
||||
// @include defaultEasing (all);
|
||||
// }
|
||||
// }
|
||||
|
||||
// &.focused {
|
||||
// hr.accent {
|
||||
// transform: scaleX(1);
|
||||
// }
|
||||
// }
|
||||
// &.focused {
|
||||
// hr.accent {
|
||||
// transform: scaleX(1);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
+24
-24
@@ -1,28 +1,28 @@
|
||||
// import { TextareaAutosize } from 'react-textarea-autosize'
|
||||
import * as React from "react"
|
||||
import { Either } from "../../../Data/Either"
|
||||
import { Maybe, orN } from "../../../Data/Maybe"
|
||||
import { InputKeyEvent } from "../../Models/Hero/heroTypeHelpers"
|
||||
import { TextFieldContainer } from "./TextFieldContainer"
|
||||
import { ChangeEvent, KeyboardEvent, useCallback, useEffect, useRef } from "react"
|
||||
import "./TextField.scss"
|
||||
import { TextFieldContainer } from "./TextFieldContainer.tsx"
|
||||
|
||||
export type InputTextEvent = ChangeEvent<HTMLInputElement>
|
||||
export type InputKeyEvent = KeyboardEvent<HTMLInputElement>
|
||||
|
||||
export interface TextFieldProps {
|
||||
autoFocus?: boolean | Maybe<boolean>
|
||||
autoFocus?: boolean
|
||||
className?: string
|
||||
countCurrent?: number
|
||||
countMax?: number
|
||||
disabled?: boolean
|
||||
error?: Maybe<string> | Either<string, any>
|
||||
error?: string
|
||||
fullWidth?: boolean
|
||||
hint?: Maybe<string> | string
|
||||
label?: Maybe<string> | string
|
||||
hint?: string
|
||||
label?: string
|
||||
max?: string
|
||||
min?: string
|
||||
type?: string
|
||||
value?: string
|
||||
valid?: boolean
|
||||
onChange (newText: string): void
|
||||
onKeyDown? (event: InputKeyEvent): void
|
||||
onKeyUp? (event: InputKeyEvent): void
|
||||
onChange(newText: string): void
|
||||
onKeyDown?(event: InputKeyEvent): void
|
||||
onKeyUp?(event: InputKeyEvent): void
|
||||
}
|
||||
|
||||
export const TextField: React.FC<TextFieldProps> = props => {
|
||||
@@ -46,22 +46,22 @@ export const TextField: React.FC<TextFieldProps> = props => {
|
||||
hint,
|
||||
} = props
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement | null> (null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
React.useEffect (
|
||||
useEffect(
|
||||
() => {
|
||||
if (Maybe.elem (true) (Maybe.normalize (autoFocus)) && inputRef.current !== null) {
|
||||
inputRef.current.focus ()
|
||||
if (autoFocus === true && inputRef.current !== null) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
},
|
||||
[ autoFocus ]
|
||||
)
|
||||
|
||||
const handleChange =
|
||||
React.useCallback (
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!orN (disabled)) {
|
||||
onChange (e.target.value)
|
||||
useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled !== true) {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
},
|
||||
[ disabled, onChange ]
|
||||
@@ -86,9 +86,9 @@ export const TextField: React.FC<TextFieldProps> = props => {
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyPress={orN (disabled) ? undefined : onKeyDown}
|
||||
onKeyUp={orN (disabled) ? undefined : onKeyUp}
|
||||
readOnly={disabled}
|
||||
onKeyDown={disabled === true ? undefined : onKeyDown}
|
||||
onKeyUp={disabled === true ? undefined : onKeyUp}
|
||||
disabled={disabled}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</TextFieldContainer>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { classList } from "../../utils/classList.ts"
|
||||
import { FCC } from "../../utils/react.ts"
|
||||
import { TextFieldCounter } from "./TextFieldCounter.tsx"
|
||||
import { TextFieldError } from "./TextFieldError.tsx"
|
||||
import { TextFieldHint } from "./TextFieldHint.tsx"
|
||||
import { TextFieldLabel } from "./TextFieldLabel.tsx"
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
countCurrent?: number
|
||||
countMax?: number
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
fullWidth?: boolean
|
||||
hint?: string
|
||||
isFieldEmpty: boolean
|
||||
label?: string
|
||||
valid?: boolean
|
||||
}
|
||||
|
||||
export const TextFieldContainer: FCC<Props> = props => {
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
countCurrent,
|
||||
countMax,
|
||||
disabled,
|
||||
error,
|
||||
fullWidth,
|
||||
hint,
|
||||
isFieldEmpty,
|
||||
label,
|
||||
valid,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classList(
|
||||
"textfield",
|
||||
className,
|
||||
{
|
||||
fullWidth,
|
||||
disabled,
|
||||
invalid: valid === false || error !== undefined,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<TextFieldLabel label={label} />
|
||||
{children}
|
||||
<TextFieldHint hint={hint} isFieldEmpty={isFieldEmpty} />
|
||||
<TextFieldCounter current={countCurrent} max={countMax} />
|
||||
<TextFieldError error={error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FC } from "react"
|
||||
|
||||
type Props = {
|
||||
current: number | undefined
|
||||
max: number | undefined
|
||||
}
|
||||
|
||||
export const TextFieldCounter: FC<Props> = ({ current = 0, max }) =>
|
||||
typeof max === "number"
|
||||
? (
|
||||
<div>
|
||||
{current}
|
||||
{" / "}
|
||||
{max}
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
@@ -0,0 +1,76 @@
|
||||
import { FC, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { InputKeyEvent } from "./TextField.tsx"
|
||||
|
||||
type Props = {
|
||||
autoFocus?: boolean
|
||||
disabled?: boolean
|
||||
type: string
|
||||
value: string
|
||||
onChange(newText: string): void
|
||||
onKeyDown?(event: InputKeyEvent): void
|
||||
onKeyUp?(event: InputKeyEvent): void
|
||||
}
|
||||
|
||||
export const TextFieldDeferred: FC<Props> = props => {
|
||||
const {
|
||||
autoFocus,
|
||||
disabled,
|
||||
type,
|
||||
value: defaultValue,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
} = props
|
||||
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [ value, setValue ] = useState(defaultValue)
|
||||
const [ prevValue, setPrevValue ] = useState(defaultValue)
|
||||
|
||||
if (prevValue !== defaultValue) {
|
||||
setValue(defaultValue)
|
||||
setPrevValue(defaultValue)
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (autoFocus === true && inputRef.current !== null) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
},
|
||||
[ autoFocus ]
|
||||
)
|
||||
|
||||
const handleChange =
|
||||
useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled !== true) {
|
||||
setValue(e.target.value)
|
||||
}
|
||||
},
|
||||
[ disabled ]
|
||||
)
|
||||
|
||||
const handleBlur =
|
||||
useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (disabled !== true && defaultValue !== value) {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
},
|
||||
[ defaultValue, disabled, onChange, value ]
|
||||
)
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={disabled === true ? undefined : onKeyDown}
|
||||
onKeyUp={disabled === true ? undefined : onKeyUp}
|
||||
readOnly={disabled}
|
||||
ref={inputRef}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FC } from "react"
|
||||
|
||||
type Props = {
|
||||
error: string | undefined
|
||||
}
|
||||
|
||||
export const TextFieldError: FC<Props> = ({ error }) =>
|
||||
error !== undefined && error !== ""
|
||||
? (
|
||||
<p className="error">
|
||||
{error}
|
||||
</p>
|
||||
)
|
||||
: null
|
||||
@@ -0,0 +1,16 @@
|
||||
import { FC } from "react"
|
||||
import { classList } from "../../utils/classList.ts"
|
||||
|
||||
type Props = {
|
||||
hint: string | undefined
|
||||
isFieldEmpty: boolean
|
||||
}
|
||||
|
||||
export const TextFieldHint: FC<Props> = ({ hint, isFieldEmpty }) =>
|
||||
hint === undefined
|
||||
? null
|
||||
: (
|
||||
<div className={classList("textfield-hint", { hide: !isFieldEmpty })} >
|
||||
{hint}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FC } from "react"
|
||||
import { Label } from "../label/Label.tsx"
|
||||
|
||||
type Props = {
|
||||
label: string | undefined
|
||||
}
|
||||
|
||||
export const TextFieldLabel: FC<Props> = ({ label }) =>
|
||||
label !== undefined && label !== ""
|
||||
? <Label text={label} />
|
||||
: null
|
||||
+27
-30
@@ -1,29 +1,26 @@
|
||||
// import { TextareaAutosize } from 'react-textarea-autosize'
|
||||
import * as React from "react"
|
||||
import { Either } from "../../../Data/Either"
|
||||
import { Maybe, orN } from "../../../Data/Maybe"
|
||||
import { InputKeyEvent } from "../../Models/Hero/heroTypeHelpers"
|
||||
import { TextFieldContainer } from "./TextFieldContainer"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { InputKeyEvent } from "./TextField.tsx"
|
||||
import { TextFieldContainer } from "./TextFieldContainer.tsx"
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
autoFocus?: boolean
|
||||
className?: string
|
||||
countCurrent?: number
|
||||
countMax?: number
|
||||
disabled?: boolean
|
||||
error?: Maybe<string> | Either<string, any>
|
||||
error?: string
|
||||
fullWidth?: boolean
|
||||
hint?: Maybe<string> | string
|
||||
label?: Maybe<string> | string
|
||||
hint?: string
|
||||
label?: string
|
||||
max?: string
|
||||
min?: string
|
||||
type?: string
|
||||
value?: string
|
||||
valid?: boolean
|
||||
checkDirectInput?: (text: string) => boolean
|
||||
onChange (newText: string): void
|
||||
onKeyDown? (event: InputKeyEvent): void
|
||||
onKeyUp? (event: InputKeyEvent): void
|
||||
onChange(newText: string): void
|
||||
onKeyDown?(event: InputKeyEvent): void
|
||||
onKeyUp?(event: InputKeyEvent): void
|
||||
}
|
||||
|
||||
export const TextFieldLazy: React.FC<Props> = props => {
|
||||
@@ -48,43 +45,43 @@ export const TextFieldLazy: React.FC<Props> = props => {
|
||||
value: defaultValue = "",
|
||||
} = props
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement | null> (null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [ value, setValue ] = React.useState (defaultValue)
|
||||
const [ prevValue, setPrevValue ] = React.useState (defaultValue)
|
||||
const [ value, setValue ] = useState(defaultValue)
|
||||
const [ prevValue, setPrevValue ] = useState(defaultValue)
|
||||
|
||||
if (prevValue !== defaultValue) {
|
||||
setValue (defaultValue)
|
||||
setPrevValue (defaultValue)
|
||||
setValue(defaultValue)
|
||||
setPrevValue(defaultValue)
|
||||
}
|
||||
|
||||
React.useEffect (
|
||||
useEffect(
|
||||
() => {
|
||||
if (orN (autoFocus) && inputRef.current !== null) {
|
||||
inputRef.current.focus ()
|
||||
if (autoFocus === true && inputRef.current !== null) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
},
|
||||
[ autoFocus ]
|
||||
)
|
||||
|
||||
const handleChange =
|
||||
React.useCallback (
|
||||
useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (
|
||||
!orN (disabled)
|
||||
&& (typeof checkDirectInput !== "function" || checkDirectInput (e.target.value))
|
||||
disabled !== true
|
||||
&& (typeof checkDirectInput !== "function" || checkDirectInput(e.target.value))
|
||||
) {
|
||||
setValue (e.target.value)
|
||||
setValue(e.target.value)
|
||||
}
|
||||
},
|
||||
[ disabled, checkDirectInput ]
|
||||
)
|
||||
|
||||
const handleBlur =
|
||||
React.useCallback (
|
||||
useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!orN (disabled) && defaultValue !== value) {
|
||||
onChange (e.target.value)
|
||||
if (disabled !== true && defaultValue !== value) {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
},
|
||||
[ defaultValue, disabled, onChange, value ]
|
||||
@@ -109,8 +106,8 @@ export const TextFieldLazy: React.FC<Props> = props => {
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyPress={orN (disabled) ? undefined : onKeyDown}
|
||||
onKeyUp={orN (disabled) ? undefined : onKeyUp}
|
||||
onKeyDown={disabled === true ? undefined : onKeyDown}
|
||||
onKeyUp={disabled === true ? undefined : onKeyUp}
|
||||
readOnly={disabled}
|
||||
ref={inputRef}
|
||||
onBlur={handleBlur}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FC, ReactNode, cloneElement, useCallback, useRef, useState } from "react"
|
||||
import { classList } from "../../utils/classList.ts"
|
||||
import { Overlay } from "../overlay/Overlay.tsx"
|
||||
import "./TooltipToggle.scss"
|
||||
|
||||
type Props = {
|
||||
content: ReactNode
|
||||
margin?: number
|
||||
small?: boolean
|
||||
position?: "top" | "bottom" | "left" | "right"
|
||||
target: JSX.Element
|
||||
}
|
||||
|
||||
export const TooltipToggle: FC<Props> = props => {
|
||||
const { content, margin, position = "top", small, target } = props
|
||||
|
||||
const [ isOpen, setIsOpen ] = useState(false)
|
||||
|
||||
const targetRef = useRef<Element>(null)
|
||||
|
||||
const handleMouseOver =
|
||||
useCallback(
|
||||
() => setIsOpen(true),
|
||||
[ setIsOpen ]
|
||||
)
|
||||
|
||||
const handleMouseOut =
|
||||
useCallback(
|
||||
() => setIsOpen(false),
|
||||
[ setIsOpen ]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{cloneElement(
|
||||
target,
|
||||
{
|
||||
ref: targetRef,
|
||||
onMouseOut: handleMouseOut,
|
||||
onMouseOver: handleMouseOver,
|
||||
}
|
||||
)}
|
||||
{isOpen && targetRef.current !== null
|
||||
? (
|
||||
<Overlay
|
||||
className={classList("tooltip", { "tooltip-small": small })}
|
||||
position={position}
|
||||
trigger={targetRef.current}
|
||||
margin={margin}
|
||||
small={small}
|
||||
>
|
||||
{content}
|
||||
</Overlay>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isInteger, isNaturalNumber } from "./regex.ts"
|
||||
|
||||
/**
|
||||
* The minus sign.
|
||||
*/
|
||||
export const minus = "−"
|
||||
|
||||
/**
|
||||
* Forces signing on the given number, returns the specified string when the
|
||||
* number is `0`.
|
||||
*/
|
||||
export const signNullCustom =
|
||||
(zero: string) =>
|
||||
(x: number): string =>
|
||||
x > 0 ? `+${x}` : x < 0 ? `${minus}\u2060${Math.abs(x)}` : zero
|
||||
|
||||
/**
|
||||
* Forces signing on the given number.
|
||||
*/
|
||||
export const sign = (x: number): string =>
|
||||
x > 0 ? `+${x}` : x < 0 ? `${minus}\u2060${Math.abs(x)}` : "0"
|
||||
|
||||
/**
|
||||
* Converts a string to an integer. If the string is not a valid integer, it
|
||||
* returns `undefined`.
|
||||
*/
|
||||
export const parseInt =
|
||||
(str: string): number | undefined =>
|
||||
str.length > 0 && isInteger(str) ? Number.parseInt(str, 10) : undefined
|
||||
|
||||
/**
|
||||
* Converts a string to a natural number. If the string is not a valid natural
|
||||
* number, it returns `undefined`.
|
||||
*/
|
||||
export const parseNat =
|
||||
(str: string): number | undefined =>
|
||||
str.length > 0 && isNaturalNumber(str) ? Number.parseInt(str, 10) : undefined
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { isInteger, isNaturalNumber } from "./regex.ts"
|
||||
|
||||
describe("isNaturalNumber", () => {
|
||||
it("returns if the passed string represents a natural number", () => {
|
||||
assert.equal(isNaturalNumber("0"), true)
|
||||
assert.equal(isNaturalNumber("1"), true)
|
||||
assert.equal(isNaturalNumber("39570"), true)
|
||||
assert.equal(isNaturalNumber("01"), false)
|
||||
assert.equal(isNaturalNumber("1.4"), false)
|
||||
assert.equal(isNaturalNumber("-1"), false)
|
||||
assert.equal(isNaturalNumber("NaN"), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isInteger", () => {
|
||||
it("returns if the passed string represents an integer", () => {
|
||||
assert.equal(isInteger("0"), true)
|
||||
assert.equal(isInteger("1"), true)
|
||||
assert.equal(isInteger("39570"), true)
|
||||
assert.equal(isInteger("01"), false)
|
||||
assert.equal(isInteger("1.4"), false)
|
||||
assert.equal(isInteger("-1"), true)
|
||||
assert.equal(isInteger("NaN"), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Checks if the provided string is a string representation of a natural number.
|
||||
* @param string The string to test.
|
||||
*/
|
||||
export const isNaturalNumber = (test: string) => /^(?:0|[1-9][0-9]*)$/u.test(test)
|
||||
|
||||
/**
|
||||
* Checks if the provided string is a string representation of an integer.
|
||||
* @param string The string to test.
|
||||
*/
|
||||
export const isInteger = (test: string) => /^(?:0|-?[1-9][0-9]*)$/u.test(test)
|
||||
Reference in New Issue
Block a user