import { markEventAsHandled, stopPropagation } from "./eventHandling"; export type InputBoxStyle = "underlined" | "rounded" | "pill"; /// A text input field providing the following features and more: /// /// - A floating label /// - prefix text /// - suffix text export class InputBox { public outerElement: HTMLElement; private prefixTextElement: HTMLElement; private suffixElementContainer: HTMLElement; private suffixTextElement: HTMLElement; private labelWidthReserverElement: HTMLElement; // Ensures enough width for the label private labelElement: HTMLElement; private _accessibilityLabel: string | null; // NOTE: The input element can also be a textarea, but for some reason the // typing gets really wonky if this is a union. I don't like it, but I think // lying about the type is our best option. private _inputElement: HTMLInputElement; constructor({ inputElement = undefined, labelIsAlwaysSmall = false, connectClickHandlers = true, }: { inputElement?: HTMLInputElement | HTMLTextAreaElement; labelIsAlwaysSmall?: boolean; connectClickHandlers?: boolean; } = {}) { this.outerElement = document.createElement("div"); this.outerElement.classList.add( "rio-input-box", "rio-input-box-style-underlined" ); this.outerElement.innerHTML = `
`; this.prefixTextElement = this.outerElement.querySelector( ".rio-input-box-prefix-text" ) as HTMLElement; this.suffixElementContainer = this.outerElement.querySelector( ".rio-input-box-suffix-element > *" ) as HTMLElement; this.suffixTextElement = this.outerElement.querySelector( ".rio-input-box-suffix-text" ) as HTMLElement; this.labelWidthReserverElement = this.outerElement.querySelector( ".rio-input-box-label-width-reserver" ) as HTMLElement; this.labelElement = this.outerElement.querySelector( ".rio-input-box-label" ) as HTMLElement; this._inputElement = this.outerElement.querySelector( "input" ) as HTMLInputElement; if (inputElement !== undefined) { this._inputElement.parentElement!.insertBefore( inputElement, this._inputElement ); this._inputElement.remove(); this._inputElement = inputElement as HTMLInputElement; } if (labelIsAlwaysSmall) { this.outerElement.classList.add("label-is-always-small"); } if (connectClickHandlers) { this.connectClickHandlers(); } // Eat keyboard events that have an effect on the input field this._inputElement.addEventListener( "keypress", (event: KeyboardEvent) => { if (this._hasDefaultHandler(event)) { // Don't `.preventDefault()` because then the user can't type event.stopPropagation(); event.stopImmediatePropagation(); } } ); // When keyboard focus is lost, check if the input is empty so that the // floating label can position itself accordingly this._inputElement.addEventListener("blur", () => { if (this._inputElement.value) { this.outerElement.classList.add("has-value"); } else { this.outerElement.classList.remove("has-value"); } }); // Assign defaults this.prefixText = null; this.suffixText = null; this.label = null; } private connectClickHandlers(): void { // Consider any clicks on the input box as handled. This prevents e.g. // drag events when trying to select something. this.prefixTextElement.addEventListener( "pointerdown", markEventAsHandled ); this.suffixElementContainer.addEventListener( "pointerdown", markEventAsHandled ); // When clicked, focus the text element and move the cursor accordingly. let selectStart = (event: Event) => { this._inputElement.focus(); this._inputElement.setSelectionRange(0, 0); markEventAsHandled(event); }; this.suffixElementContainer; this.prefixTextElement.addEventListener("click", selectStart); let selectEnd = (event: Event) => { this._inputElement.focus(); this._inputElement.setSelectionRange( this._inputElement.value.length, this._inputElement.value.length ); markEventAsHandled(event); }; this.suffixElementContainer.addEventListener("click", selectEnd); this.suffixTextElement.addEventListener("click", selectEnd); let [paddingLeft, paddingRight] = this.outerElement.querySelectorAll( ".rio-input-box-padding" ); paddingLeft.addEventListener("click", selectStart); paddingRight.addEventListener("click", selectEnd); // Pointer down events select the input element and/or text in it (via // dragging), so let them do their default behavior but then stop them // from propagating to other elements this._inputElement.addEventListener("pointerdown", stopPropagation); } private _hasDefaultHandler(event: KeyboardEvent): boolean { // Letters are simply inputted into the text field if (event.key.length === 1) { return true; } if ( [ "Backspace", "Delete", "Home", "End", "Tab", "ArrowLeft", "ArrowRight", ].includes(event.key) ) { return true; } if ( (event.ctrlKey || event.metaKey) && ["a", "c", "x", "v", "z", "y"].includes(event.key) ) { return true; } // Multi-line inputs have some extra hotkeys if ( this._inputElement.tagName === "TEXTAREA" && ["ArrowUp", "ArrowDown", "Enter"].includes(event.key) ) { return true; } return false; } get inputElement(): HTMLInputElement { return this._inputElement; } get value(): string { return this._inputElement.value; } set value(value: string) { this._inputElement.value = value; if (value) { this.outerElement.classList.add("has-value"); } else { this.outerElement.classList.remove("has-value"); } } get label(): string | null { return this.labelElement.textContent; } set label(label: string | null) { this.labelElement.textContent = label; this.labelWidthReserverElement.textContent = label; if (label) { this.outerElement.classList.add("has-label"); } else { this.outerElement.classList.remove("has-label"); } this.updateAccessibilityLabel(); } set accessibilityLabel(accessibilityLabel: string | null) { this._accessibilityLabel = accessibilityLabel; this.updateAccessibilityLabel(); } private updateAccessibilityLabel(): void { this.inputElement.ariaLabel = this._accessibilityLabel ? this._accessibilityLabel : this.label; } get prefixText(): string | null { return this.prefixTextElement.textContent; } set prefixText(prefixText: string | null) { this.prefixTextElement.textContent = prefixText; } get suffixText(): string | null { return this.suffixTextElement.textContent; } set suffixText(suffixText: string | null) { this.suffixTextElement.textContent = suffixText; } get suffixElement(): HTMLElement | null { // @ts-ignore return this.suffixElementContainer.firstChild; } set suffixElement(suffixElement: HTMLElement | null) { this.suffixElementContainer.firstChild?.remove(); if (suffixElement !== null) { this.suffixElementContainer.appendChild(suffixElement); } } set style(style: InputBoxStyle) { this.outerElement.classList.remove( "rio-input-box-style-underlined", "rio-input-box-style-rounded", "rio-input-box-style-pill" ); this.outerElement.classList.add(`rio-input-box-style-${style}`); } get isSensitive(): boolean { return !this._inputElement.disabled; } set isSensitive(isSensitive: boolean) { this._inputElement.disabled = !isSensitive; this.outerElement.classList.toggle("rio-disabled-input", !isSensitive); } set isValid(isValid: boolean) { if (isValid) { this.outerElement.style.removeProperty("--rio-local-text-color"); } else { this.outerElement.style.setProperty( "--rio-local-text-color", "var(--rio-global-danger-bg)" ); } } public focus(): void { this._inputElement.focus(); } public unfocus(): void { this._inputElement.blur(); } }