import _ from "lodash"
import classnames from "classnames"
import React, { CSSProperties, ReactNode, useState, useRef, useEffect, useImperativeHandle, forwardRef } from "react"
// Bootstrap components
/** Simple spinner */
export function Spinner() {
return
}
/** Standard button */
export class Button extends React.Component<{
/** "default" gets mapped to "secondary" */
type: string // e.g. "primary"
onClick?: () => void
disabled?: boolean
active?: boolean
/** xs is deprecated. TODO how to handle */
size?: "sm" | "xs" | "lg"
title?: string
children?: ReactNode
}> {
static defaultProps = { type: "secondary" }
render() {
const type = this.props.type == "default" ? "secondary" : this.props.type
const size = this.props.size == "xs" ? "sm" : this.props.size
return (
)
}
}
/** Icon, font-awesome v4 */
export class Icon extends React.Component<{ id: string, style?: CSSProperties }> {
render() {
if (this.props.id.match(/^fa-/)) {
return
} else {
return null
}
}
}
/** Indented form group with a label, optional help text. Label and indented contents */
export class FormGroup extends React.Component<{
/** Label to display */
label: ReactNode
/** True to mute label */
labelMuted?: boolean
/** Hint to append to label. Makes label faded if only hint presented */
hint?: ReactNode
/** Help block at bottom */
help?: ReactNode
/** Appends a red required star */
required?: boolean
/** @deprecated True to display as success */
hasSuccess?: boolean
/** @deprecated True to display as warning */
hasWarnings?: boolean
/** @deprecated True to display as error */
hasErrors?: boolean
/** True to display horizontal */
horizontal?: boolean
children?: ReactNode
}> {
render() {
const label = (
)
return (
{label}
{this.props.children}
{this.props.help && (
{this.props.help}
)}
)
}
}
/** Unique id sequence for making ids of elements in checkbox + radio */
let uniqueId = 0
export interface CheckboxProps {
value: boolean | null | undefined
onChange?: (value: boolean) => void
inline?: boolean
/** Uses null for false */
nullForFalse?: boolean
disabled?: boolean
displayAs?: "checkbox" | "switch"
children?: ReactNode
}
export class Checkbox extends React.Component {
id: string
constructor(props: CheckboxProps) {
super(props)
this.id = `id_${uniqueId}`
uniqueId += 1
}
handleChange = (ev: any) => {
if (this.props.nullForFalse) {
return this.props.onChange!(ev.target.checked || null)
} else {
return this.props.onChange!(ev.target.checked)
}
}
render() {
if (this.props.inline) {
return (
)
} else {
return (
)
}
}
}
export interface RadioProps {
/** Value to display */
value: any
/** Value that radio button represents. If equal to value, button is checked */
radioValue: any
/** Called with radio value */
onChange: (value: any) => void
/** Makes horizontal */
inline?: boolean
disabled?: boolean
children?: ReactNode
}
export class Radio extends React.Component {
id: string
constructor(props: RadioProps) {
super(props)
this.id = `id_${uniqueId}`
uniqueId += 1
}
render() {
if (this.props.inline) {
return (
)
}
}
/** Props of the button toggle */
export interface ToggleProps {
/** Currently selected value */
value: T | null
/** Called with the newly selected value (null when reset) */
onChange?: (value: T | null) => void
/** Options to show, in order */
options: Array<{ value: T | null; label: ReactNode }>
/** Button size. xs is deprecated */
size?: "xs" | "sm" | "lg"
/** True to allow clicking the selected option to clear the value */
allowReset?: boolean
/** True to disable all buttons */
disabled?: boolean
}
/** Pixels of slack before a Toggle changes between the joined and wrapped layouts */
const TOGGLE_WRAP_HYSTERESIS_PX = 2
/** Window in which repeated wrap flips count as a layout that cannot settle */
const TOGGLE_FLIP_WINDOW_MS = 1000
/** Wrap flips allowed inside that window before the Toggle stops re-laying out */
const TOGGLE_MAX_FLIPS = 4
/**
* Button toggle component. Renders as a joined button group when it fits its container and
* falls back to individually rounded buttons that wrap onto several lines when it does not.
*/
export class Toggle extends React.Component, { wrap: boolean }> {
/** The button container, measured against its parent */
groupRef = React.createRef()
/** Re-measures when the parent resizes */
resizeObserver?: ResizeObserver
state = { wrap: false }
componentDidMount() {
this.measure()
const parent = this.groupRef.current?.parentElement
if (parent && typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => this.measure())
this.resizeObserver.observe(parent)
}
}
componentDidUpdate() {
this.measure()
}
componentWillUnmount() {
this.resizeObserver?.disconnect()
}
/** Times of recent wrap flips, to detect a layout that can never settle */
flipTimes: number[] = []
/** Wraps when the buttons laid out in one row would not fit the parent's width */
measure = () => {
const group = this.groupRef.current
const parent = group?.parentElement
if (!group || !parent) {
return
}
// Fractional widths throughout. Integer offsetWidth/clientWidth round each button and the
// parent separately, so a group that exactly fills a shrink-to-fit parent can look one
// pixel too wide, wrap, widen the parent, unwrap, and repeat until React gives up.
const parentWidth = parent.getBoundingClientRect().width
if (parentWidth === 0) {
return
}
// Sum of button widths approximates the joined single-row width in both layouts
const needed = Array.from(group.children).reduce((sum, child) => sum + child.getBoundingClientRect().width, 0)
// Hysteresis: wrap only on a clear overflow and unwrap only when there is clearly room
const wrap = this.state.wrap ? needed > parentWidth - TOGGLE_WRAP_HYSTERESIS_PX : needed > parentWidth + TOGGLE_WRAP_HYSTERESIS_PX
if (wrap === this.state.wrap) {
return
}
// A parent whose width depends on the layout can flip forever; freeze rather than loop
const now = Date.now()
this.flipTimes = this.flipTimes.filter((t) => now - t < TOGGLE_FLIP_WINDOW_MS)
this.flipTimes.push(now)
if (this.flipTimes.length > TOGGLE_MAX_FLIPS) {
return
}
this.setState({ wrap })
}
renderOption = (option: { value: T | null; label: ReactNode }, index: number) => {
const size = this.props.size == "xs" ? "sm" : this.props.size
const selected = this.props.value === option.value
const value = selected && this.props.allowReset ? null : option.value
const btnClasses = classnames("btn", {
"btn-outline-primary": !selected,
"btn-primary": selected,
active: selected,
// Outside a btn-group each button carries its own size class
[`btn-${size}`]: this.state.wrap && size != null
})
const props: any = {
type: "button",
className: btnClasses,
// Wrapping creates awkward looking toggles
style: { whiteSpace: "nowrap" }
}
if (!selected || this.props.allowReset) {
props["onClick"] = this.props.onChange ? this.props.onChange.bind(null, value) : null
}
if (this.props.disabled) {
props["disabled"] = true
}
return
}
render() {
const size = this.props.size == "xs" ? "sm" : this.props.size
const className = this.state.wrap
? "d-inline-flex flex-wrap gap-1"
: `btn-group ${size ? `btn-group-${size}` : ""}`
return (
{_.map(this.props.options, this.renderOption)}
)
}
}
/** Panel that can be opened and closed */
export function CollapsiblePanel(props: {
title: ReactNode
hint?: ReactNode
children: any
/** Indicates if the panel is initially closed (for uncontrolled) */
initiallyClosed?: boolean
/** Indicates if the panel is currently closed (for controlled) */
closed?: boolean
/** Function to be called when the closed state changes (for controlled) */
onClosedChange?: (closed: boolean) => void
}) {
const [internalClosed, setInternalClosed] = useState(props.initiallyClosed ? true : false)
const closed = props.onClosedChange != null ? props.closed : internalClosed
const toggleClosed = () => {
if (props.onClosedChange != null) {
props.onClosedChange(!closed)
} else {
setInternalClosed(!internalClosed)
}
}
return