refactor: migrate more shared utility functions and components

This commit is contained in:
Lukas Obermann
2023-06-11 22:11:02 +02:00
parent d14536695d
commit 44357e1387
43 changed files with 1183 additions and 1326 deletions
@@ -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>
)
}
-52
View File
@@ -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;
}
}
-50
View File
@@ -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>
)
}
-211
View File
@@ -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;
}
}
}
}
}
-159
View File
@@ -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)
-27
View File
@@ -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>
)
}
-23
View File
@@ -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>
)
}
-276
View File
@@ -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
)
-30
View File
@@ -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
)
-62
View File
@@ -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>
)
}
+17 -4
View File
@@ -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;
}
}
+7 -7
View File
@@ -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>
+216
View File
@@ -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;
}
}
}
}
}
+103
View File
@@ -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
}
@@ -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>
)
}
@@ -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);
}
}
}
+19
View File
@@ -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>
)
+260
View File
@@ -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>
)
}
@@ -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);
// }
// }
}
@@ -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
@@ -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}
</>
)
}
+37
View File
@@ -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
+27
View File
@@ -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)
})
})
+11
View File
@@ -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)