import _ from "lodash" import classnames from "classnames" import React, { CSSProperties, ReactNode, useState } from "react" const R = React.createElement // 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" }> { 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 R( "button", { type: "button", className: classnames( "btn", `btn-${type}`, { active: this.props.active }, { [`btn-${size}`]: this.props.size != null } ), onClick: this.props.onClick, disabled: this.props.disabled }, this.props.children ) } } /** Icon, font-awesome v4 */ export class Icon extends React.Component<{ id: string }> { render() { if (this.props.id.match(/^fa-/)) { return R("i", { className: `fa ${this.props.id}` }) } 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 }> { 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" } 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 R( "div", { className: `form-check form-check-inline ${this.props.displayAs === "switch" ? "form-switch" : ""}` }, R("input", { type: "checkbox", id: this.id, disabled: this.props.disabled, className: "form-check-input", checked: this.props.value || false, onChange: this.props.onChange ? this.handleChange : undefined }), R("label", { className: "form-check-label", htmlFor: this.id }, this.props.children) ) } else { return R( "div", { className: `form-check ${this.props.displayAs === "switch" ? "form-switch" : ""}` }, R("input", { type: "checkbox", id: this.id, disabled: this.props.disabled, className: "form-check-input", checked: this.props.value || false, onChange: this.props.onChange ? this.handleChange : undefined }), R("label", { className: "form-check-label", htmlFor: this.id }, this.props.children) ) } } } 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 } 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 R( "div", { className: "form-check form-check-inline" }, R("input", { type: "radio", className: "form-check-input", id: this.id, disabled: this.props.disabled, checked: this.props.value === this.props.radioValue, onChange() { }, // Do nothing onClick: this.props.onChange ? (ev) => this.props.onChange(this.props.radioValue) : undefined }), R("label", { className: "form-check-label", htmlFor: this.id }, this.props.children) ) } else { return R( "div", { className: "form-check" }, R("input", { type: "radio", className: "form-check-input", id: this.id, disabled: this.props.disabled, checked: this.props.value === this.props.radioValue, onChange() { }, // Do nothing onClick: this.props.onChange ? (ev) => this.props.onChange(this.props.radioValue) : undefined }), R("label", { className: "form-check-label", htmlFor: this.id }, this.props.children) ) } } } /** 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 }> { 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 R( "select", { style, disabled: this.props.onChange == null, className: classnames( "form-select", { "form-select-sm": this.props.size === "sm" }, { "form-select-lg": this.props.size === "lg" } ), value: JSON.stringify(this.props.value != null ? this.props.value : null), onChange: this.props.onChange ? this.handleChange : function () { } }, _.map(options, (option) => R("option", { key: JSON.stringify(option.value), value: JSON.stringify(option.value) }, option.label) ) ) } } export type TextInputProps = TextInputPropsNull | TextInputPropsNoNull export interface TextInputPropsNoNull { value: string | null onChange?: (value: string) => void placeholder?: string size?: "sm" | "lg" emptyNull?: false style?: object } export interface TextInputPropsNull { value: string | null onChange?: (value: string | null) => void placeholder?: string size?: "sm" | "lg" emptyNull: true style?: object } 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() { return R("input", { ref: (c: HTMLInputElement | null) => { return (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 }) } } export interface NumberInputProps { decimal: boolean value?: number | null onChange?: (value: number | null) => void style?: CSSProperties 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 } // Number input component that handles parsing and maintains state when number is invalid export class NumberInput extends React.Component { input?: HTMLInputElement | null constructor(props: NumberInputProps) { super(props) // Parsing happens on blur this.state = { inputText: this.formatInput(props) } } componentWillReceiveProps(nextProps: any) { // If different, override text if (nextProps.value !== this.props.value) { return this.setState({ inputText: this.formatInput(nextProps) }) } } // Format the input based on props formatInput(props: any) { // Blank if (props.value == null) { return "" } // Integer if (!props.decimal) { return "" + Math.floor(props.value) } // Decimal if (props.decimalPlaces != null) { return props.value.toFixed(props.decimalPlaces) } return "" + props.value } focus() { return this.input?.focus() } handleKeyDown = (ev: any) => { // When pressing ENTER or TAB if (ev.keyCode === 13) { // First parse value as if blur will be done this.handleBlur() if (this.props.onEnter) { this.props.onEnter(ev) ev.preventDefault() } } if (ev.keyCode === 9) { // First parse value as if blur will be done this.handleBlur() if (this.props.onTab) { this.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() } } } handleBlur = () => { // Parse and set value if valid if (this.isValid()) { const val = this.getNumericValue() if (val !== this.props.value) { return this.props.onChange?.(val) } } } getNumericValue = () => { let val = this.props.decimal ? parseFloat(this.state.inputText) : parseInt(this.state.inputText) if (isNaN(val)) { return null } else { // Round if necessary if (this.props.decimalPlaces != null) { val = parseFloat(val.toFixed(this.props.decimalPlaces)) } } return val } // Check regex matching of numbers isValid() { let valid if (this.state.inputText.length === 0) { return true } if (this.props.decimal) { valid = this.state.inputText.match(/^-?[0-9]*\.?[0-9]+$/) && !isNaN(parseFloat(this.state.inputText)) if (!valid) { return false } } else { valid = this.state.inputText.match(/^-?\d+$/) if (!valid) { return false } } const val = this.getNumericValue() if (val && this.props.max != null && val > this.props.max) { return false } if (val && this.props.min != null && val < this.props.min) { return false } return true } render() { // Display red border if not valid const style = _.clone(this.props.style || {}) style.width = style.width || "8em" if (!this.isValid()) { style.borderColor = "#a94442" style.boxShadow = "inset 0 1px 1px rgba(0,0,0,.075)" style.backgroundColor = "rgba(132, 53, 52, 0.12)" // Faded red } let inputType = this.props.decimal ? "number" : "tel" // Special problem with Galaxy Tab 3V (SM-T116NU) missing decimal place if (this.props.decimal && navigator.userAgent.match(/SM-T1/)) { inputType = "text" } return R("input", { ref: (c) => { return (this.input = c) }, type: inputType, className: `form-control ${this.props.size ? `form-control-${this.props.size}` : ""}`, lang: "en", style, value: this.state.inputText, onChange: this.props.onChange ? (ev) => this.setState({ inputText: ev.target.value }) : function () { }, onBlur: this.handleBlur, onKeyDown: this.handleKeyDown, placeholder: this.props.placeholder, disabled: this.props.onChange == null }) } } 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 } /** 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 R( "div", { className: "mb-3" }, R( "div", { key: "label", onClick: this.handleToggle, style: { cursor: "pointer", fontWeight: this.props.labelPlain ? "normal" : "bold", display: "inline-block" } }, this.state.open ? R("i", { className: `fa fa-fw fa-caret-down ${this.props.labelMuted ? "text-muted" : undefined}` }) : R("i", { className: `fa fa-fw fa-caret-right ${this.props.labelMuted ? "text-muted" : undefined}` }), this.props.labelMuted ? R("span", { className: "text-muted" }, this.props.label) : this.props.label, this.props.hint ? R( "span", { className: "text-muted", style: { fontWeight: this.props.label ? "normal" : undefined } }, this.props.label ? " - " : undefined, this.props.hint ) : undefined ), this.state.open ? R("div", { key: "contents", style: { marginLeft: 5 } }, 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 R( "ul", { className: "nav nav-pills" }, _.map(this.props.pills, (pill) => { return R( "li", { key: pill.id, className: "nav-item" }, R( "a", { href: pill.href, onClick: () => this.props.onPillClick?.(pill.id), className: pill.id === this.props.activePill ? "nav-link active" : "nav-link" }, pill.label ) ) }) ) } } /** Button toggle component */ export class Toggle extends React.Component<{ value: T | null onChange?: (value: T | null) => void options: Array<{ value: T | null; label: ReactNode }> /** xs is deprecated */ size?: "xs" | "sm" | "lg" allowReset?: boolean disabled?: boolean }> { renderOption = (option: any, index: any) => { const value = this.props.value === option.value && this.props.allowReset ? null : option.value const btnClasses = classnames("btn", { "btn-outline-primary": !(this.props.value === option.value), "btn-primary": this.props.value === option.value, active: this.props.value === option.value }) const props: any = { key: index, type: "button", className: btnClasses, // Wrapping creates awkward looking toggles style: { whiteSpace: "nowrap" } } if (!(this.props.value === option.value) || this.props.allowReset) { props["onClick"] = this.props.onChange ? this.props.onChange.bind(null, value) : null } if (this.props.disabled) { props["disabled"] = true } return R("button", props, option.label) } render() { const size = this.props.size == "xs" ? "sm" : this.props.size return R( "div", { className: `btn-group ${size ? `btn-group-${size}` : ""}` }, _.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}
}