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 (
{}} // Do nothing onClick={this.props.onChange ? (ev) => this.props.onChange(this.props.radioValue) : undefined} />
) } else { return (
{}} // Do nothing onClick={this.props.onChange ? (ev) => this.props.onChange(this.props.radioValue) : undefined} />
) } } } /** Select dropdown. Note: stringifies the value of the option so that null, strings, numbers, booleans etc. * all work as possible options.*/ export class Select extends React.Component<{ value: T | null onChange?: (value: T | null) => void options: Array<{ value: T | null; label: string }> size?: "lg" | "sm" nullLabel?: string style?: object inline?: boolean disabled?: boolean }> { handleChange = (ev: any) => { const value = JSON.parse(ev.target.value) return this.props.onChange!(value) } render() { const options = this.props.options.slice() if (this.props.nullLabel != null) { options.unshift({ value: null, label: this.props.nullLabel }) } let style = {} if (this.props.inline) { style = { width: "auto", display: "inline-block" } } _.extend(style, this.props.style || {}) return ( ) } } export type TextInputProps = TextInputPropsNull | TextInputPropsNoNull export interface TextInputPropsNoNull { value: string | null onChange?: (value: string) => void placeholder?: string size?: "sm" | "lg" emptyNull?: false style?: object /** Show a search magnifying glass icon */ searchIcon?: boolean } export interface TextInputPropsNull { value: string | null onChange?: (value: string | null) => void placeholder?: string size?: "sm" | "lg" emptyNull: true style?: object /** Show a search magnifying glass icon */ searchIcon?: boolean } export class TextInput extends React.Component { input?: HTMLInputElement | null handleChange = (ev: any) => { let { value } = ev.target if (this.props.emptyNull) { value = value || null } return this.props.onChange!(value) } focus() { return this.input?.focus() } render() { const input = ( { this.input = c }} type="text" className={classnames( "form-control", { "form-control-sm": this.props.size === "sm" }, { "form-control-lg": this.props.size === "lg" } )} value={this.props.value || ""} style={this.props.style} onChange={this.props.onChange ? this.handleChange : undefined} placeholder={this.props.placeholder} disabled={this.props.onChange == null} /> ) if (this.props.searchIcon) { return (
{input}
) } return input } } export type RestrictedStyle = Pick export interface NumberInputProps { decimal: boolean value?: number | null onChange?: (value: number | null) => void style?: RestrictedStyle size?: "sm" | "lg" onTab?: (ev: React.KeyboardEvent) => void onEnter?: (ev: React.KeyboardEvent) => void /** Force an exact number of decimal places, rounding value as necessary */ decimalPlaces?: number placeholder?: string min?: number // The minimum number allowed max?: number } /** Regex to detect mobile devices that need sign toggle button since their numeric keyboards lack minus key */ const mobileNeedsSignToggleRegex = /iPad|iPhone|iPod|Android/i /** Imperative handle interface for NumberInput component */ export interface NumberInputHandle { focus: () => void isValid: () => boolean } /** Number input component that handles parsing and maintains state when number is invalid */ export const NumberInput = forwardRef(function NumberInput(props, ref) { const inputRef = useRef(null) // Format the input based on props const formatInput = (propsToFormat: NumberInputProps) => { // Blank if (propsToFormat.value == null) { return "" } // Integer if (!propsToFormat.decimal) { return "" + Math.floor(propsToFormat.value) } // Decimal if (propsToFormat.decimalPlaces != null) { return propsToFormat.value.toFixed(propsToFormat.decimalPlaces) } return "" + propsToFormat.value } // Parsing happens on blur const [inputText, setInputText] = useState(formatInput(props)) // Update inputText when props.value changes useEffect(() => { setInputText(formatInput(props)) }, [props.value]) const getNumericValue = (text = inputText) => { // Normalize comma to period for locales that use comma as decimal separator const normalizedText = props.decimal ? text.replace(",", ".") : text let val = props.decimal ? parseFloat(normalizedText) : parseInt(normalizedText) if (isNaN(val)) { return null } else { // Round if necessary if (props.decimalPlaces != null) { val = parseFloat(val.toFixed(props.decimalPlaces)) } } return val } // Check regex matching of numbers const isValid = (text = inputText) => { let valid if (text.length === 0) { return true } if (props.decimal) { // Accept both period and comma as decimal separator (for different locales) valid = text.match(/^-?[0-9]*[.,]?[0-9]+$/) && !isNaN(parseFloat(text.replace(",", "."))) if (!valid) { return false } } else { valid = text.match(/^-?\d+$/) if (!valid) { return false } } const val = getNumericValue(text) if (val && props.max != null && val > props.max) { return false } if (val && props.min != null && val < props.min) { return false } return true } // Expose imperative methods to parent via ref useImperativeHandle(ref, () => ({ focus: () => { inputRef.current?.focus() }, isValid: () => { return isValid() } })) const handleBlur = () => { // Parse and set value if valid if (isValid()) { const val = getNumericValue() if (val !== props.value) { return props.onChange?.(val) } } } const handleKeyDown = (ev: React.KeyboardEvent) => { // When pressing ENTER or TAB if (ev.keyCode === 13) { // First parse value as if blur will be done handleBlur() if (props.onEnter) { props.onEnter(ev) ev.preventDefault() } } if (ev.keyCode === 9) { // First parse value as if blur will be done handleBlur() if (props.onTab) { props.onTab(ev) // It's important to prevent the default behavior when handling tabs (or else the tab is applied after the focus change) return ev.preventDefault() } } } const toggleSign = () => { if (props.onChange == null) { return } const newText = inputText.startsWith("-") ? inputText.slice(1) : `-${inputText || ""}` setInputText(newText) if (isValid(newText)) { const val = getNumericValue(newText) if (val !== props.value) { props.onChange?.(val) } } } const inputStyle: CSSProperties = {} if (props.style?.backgroundColor != null) { inputStyle.backgroundColor = props.style.backgroundColor } if (props.style?.textAlign != null) { inputStyle.textAlign = props.style.textAlign } if (props.style?.color != null) { inputStyle.color = props.style.color } const containerStyle: CSSProperties = { width: props.style?.width ?? "8em" } if (props.style?.maxWidth != null) { containerStyle.maxWidth = props.style.maxWidth } const marginLeft = props.style?.marginLeft const marginRight = props.style?.marginRight const outerStyle: CSSProperties = {} if (props.style?.display != null) { outerStyle.display = props.style.display if (marginLeft != null) { outerStyle.marginLeft = marginLeft } if (marginRight != null) { outerStyle.marginRight = marginRight } } else { if (marginLeft != null) { containerStyle.marginLeft = marginLeft } if (marginRight != null) { containerStyle.marginRight = marginRight } } // Display red border if not valid if (!isValid()) { inputStyle.borderColor = "#a94442" inputStyle.boxShadow = "inset 0 1px 1px rgba(0,0,0,.075)" inputStyle.backgroundColor = "rgba(132, 53, 52, 0.12)" // Faded red } const inputType = "text" // Hint mobile keyboards without relying on type="number" (avoids iOS bugs like certain digits not entering) const inputMode = props.decimal ? "decimal" : "numeric" // Accept both period and comma as decimal separator (for different locales) const pattern = props.decimal ? "-?[0-9]*[.,]?[0-9]*" : "-?[0-9]*" const needsSignToggle = mobileNeedsSignToggleRegex.test(navigator.userAgent) const inputElement = ( setInputText(ev.target.value) : function () { }} onBlur={handleBlur} onKeyDown={handleKeyDown} placeholder={props.placeholder} disabled={props.onChange == null} /> ) const toggleButtonLabel = inputText.startsWith("-") ? "+" : "-" const content = needsSignToggle ? (
{inputElement}
) : (
{inputElement}
) if (Object.keys(outerStyle).length > 0) { return (
{content}
) } return content }) export interface CollapsibleSectionProps { initiallyOpen?: boolean /** 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 /** True to not make label bold */ labelPlain?: boolean children?: ReactNode } /** Indented section than can be opened and closed. Defaults closed */ export class CollapsibleSection extends React.Component { constructor(props: CollapsibleSectionProps) { super(props) this.state = { open: props.initiallyOpen || false } } handleToggle = () => { return this.setState({ open: !this.state.open }) } render() { return (
{this.state.open ? : } {this.props.labelMuted ? {this.props.label} : this.props.label} {this.props.hint ? {this.props.label ? " - " : undefined} {this.props.hint} : undefined}
{this.state.open ?
{this.props.children}
: undefined}
) } } // Displays bootstrap pills with one active export class NavPills extends React.Component<{ pills: { id: string // Id of the tab label: ReactNode // Label of the tab href?: string // href optional }[] activePill?: string onPillClick?: (id: string) => void }> { render() { 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
{!closed ? : }
{props.title} {props.hint ? {" - "}{props.hint} : null}
{!closed ?
{props.children}
: null}
}