Files
rio/frontend/code/components/dropdown.ts
2024-09-26 19:37:51 +02:00

586 lines
19 KiB
TypeScript

import { ComponentBase, ComponentState } from "./componentBase";
import { applyIcon } from "../designApplication";
import { pixelsPerRem } from "../app";
import { InputBox } from "../inputBox";
import { markEventAsHandled } from "../eventHandling";
import { PopupManager } from "../popupManager";
export type DropdownState = ComponentState & {
_type_: "Dropdown-builtin";
optionNames?: string[];
label?: string;
accessibility_label?: string;
selectedName?: string;
is_sensitive?: boolean;
is_valid?: boolean;
};
export class DropdownComponent extends ComponentBase {
state: Required<DropdownState>;
private inputBox: InputBox;
private hiddenOptionsElement: HTMLElement;
private popupElement: HTMLElement;
private popupOptionsElement: HTMLElement;
private popupManager: PopupManager;
// The currently highlighted option, if any
private highlightedOptionElement: HTMLElement | null = null;
createElement(): HTMLElement {
// Create root element
let element = document.createElement("div");
element.classList.add("rio-dropdown");
// The dropdown is styled as an input box. Use the InputBox abstraction.
this.inputBox = new InputBox({
labelIsAlwaysSmall: true,
// Don't make any elements clickable because they steal away the
// focus from the <input> for a short while, which causes the
// dropdown to close and immediately reopen
connectClickHandlers: false,
});
element.appendChild(this.inputBox.outerElement);
// In order to ensure the dropdown can actually fit its options, add a
// hidden element that will contain a copy of all options. This element
// will have no height, but but its width will push the dropdown to be
// wide enough to fit all options.
this.hiddenOptionsElement = document.createElement("div");
this.hiddenOptionsElement.classList.add("rio-dropdown-options");
element.appendChild(this.hiddenOptionsElement);
// Add an arrow icon
let arrowElement = document.createElement("div");
arrowElement.classList.add("rio-dropdown-arrow");
applyIcon(arrowElement, "material/expand_more");
this.inputBox.suffixElement = arrowElement;
// Create the popup
this.popupElement = document.createElement("div");
this.popupElement.tabIndex = -999; // Required for Chrome, sets `FocusEvent.relatedTarget`
this.popupElement.classList.add("rio-dropdown-popup");
this.popupOptionsElement = document.createElement("div");
this.popupOptionsElement.classList.add("rio-dropdown-options");
this.popupElement.appendChild(this.popupOptionsElement);
document.body.appendChild(this.popupElement);
// Connect events
element.addEventListener(
"mousedown",
this._onMouseDown.bind(this),
true
);
this.inputBox.inputElement.addEventListener(
"keydown",
this._onKeyDown.bind(this)
);
this.inputBox.inputElement.addEventListener(
"input",
this._onInputValueChange.bind(this)
);
this.inputBox.inputElement.addEventListener(
"focusin",
this._onFocusIn.bind(this)
);
this.inputBox.inputElement.addEventListener(
"focusout",
this._onFocusOut.bind(this)
);
// Initialize the popup manager
this.popupManager = new PopupManager(
this.element,
this.popupElement,
this._positionPopup.bind(this)
);
return element;
}
private _positionPopup(
anchor: HTMLElement,
popup: HTMLElement
): { [key: string]: string } {
// Position & Animate
let anchorRect = this.element.getBoundingClientRect();
let popupHeight = this.popupElement.scrollHeight;
let windowWidth = window.innerWidth - 1; // innerWidth is rounded
let windowHeight = window.innerHeight - 1; // innerHeight is rounded
const DESKTOP_WINDOW_MARGIN = 0.5 * pixelsPerRem;
const MOBILE_WINDOW_MARGIN = 2 * pixelsPerRem;
const GAP_IF_ENTIRELY_ABOVE = 0.5 * pixelsPerRem;
// On small screens, such as phones, go fullscreen
//
// TODO: Adjust these thresholds. Maybe have a global variable which
// keeps track of whether we're on mobile?
if (
windowWidth < 60 * pixelsPerRem ||
windowHeight < 40 * pixelsPerRem
) {
// Make sure mobile browsers don't display a keyboard
this.inputBox.inputElement.readOnly = true;
// Style the popup
this.popupElement.classList.add("rio-dropdown-popup-fullscreen");
if (popupHeight >= windowHeight - 2 * MOBILE_WINDOW_MARGIN) {
return {
"max-height": `${
windowHeight - 2 * MOBILE_WINDOW_MARGIN
}px`,
"overflow-y": "scroll",
};
} else {
return {
"max-height": `${popupHeight}px`,
"overflow-y": "hidden",
};
}
}
this.inputBox.inputElement.readOnly = false;
this.popupElement.classList.remove("rio-dropdown-popup-fullscreen");
// Popup is larger than the window. Give it all the space that's
// available.
if (popupHeight >= windowHeight - 2 * DESKTOP_WINDOW_MARGIN) {
return {
left: `${anchorRect.left}px`,
top: `${DESKTOP_WINDOW_MARGIN}px`,
width: `${anchorRect.width}px`,
"max-height": `${windowHeight - 2 * DESKTOP_WINDOW_MARGIN}px`,
"overflow-y": "scroll",
"border-radius": "var(--rio-global-corner-radius-small)",
};
}
// Popup fits below the dropdown
if (
anchorRect.bottom + popupHeight + DESKTOP_WINDOW_MARGIN <=
windowHeight
) {
return {
left: `${anchorRect.left}px`,
top: `${anchorRect.bottom}px`,
width: `${anchorRect.width}px`,
"max-height": `${popupHeight}px`,
"overflow-y": "hidden",
};
}
// Popup fits above the dropdown
else if (
anchorRect.top - popupHeight >=
GAP_IF_ENTIRELY_ABOVE + DESKTOP_WINDOW_MARGIN
) {
return {
left: `${anchorRect.left}px`,
bottom: `${
windowHeight - anchorRect.top + GAP_IF_ENTIRELY_ABOVE
}px`,
width: `${anchorRect.width}px`,
"max-height": `${popupHeight}px`,
"overflow-y": "hidden",
"border-radius": "var(--rio-global-corner-radius-small)",
};
}
// Popup doesn't fit above or below the dropdown. Center it as much
// as possible
else {
let top = anchorRect.top + anchorRect.height / 2 - popupHeight / 2;
if (top < DESKTOP_WINDOW_MARGIN) {
top = DESKTOP_WINDOW_MARGIN;
} else if (
top + popupHeight + DESKTOP_WINDOW_MARGIN >
windowHeight
) {
top = windowHeight - popupHeight - DESKTOP_WINDOW_MARGIN;
}
return {
left: `${anchorRect.left}px`,
top: `${top}px`,
width: `${anchorRect.width}px`,
"max-height": `${popupHeight}px`,
"overflow-y": "hidden",
"border-radius": "var(--rio-global-corner-radius-small)",
};
}
// Unreachable
console.error("Unreachable");
}
private _onFocusIn(): void {
// Clear the input text so that all options are shown in the dropdown
this.inputBox.value = "";
// Show the popup
this.showPopup();
}
private _onFocusOut(event: FocusEvent): void {
// When the input element loses focus, that means the user is done
// entering input. Depending on whether they have put in a valid option,
// either save it or reset.
//
// Careful: Clicking on a dropdown option with the mouse also causes us
// to lose focus. If we close the popup too early, the click won't hit
// anything.
//
// In Firefox the click is triggered before the focusout, so
// that's no problem. But in Chrome, we have to check whether the focus
// went to our popup element.
if (
event.relatedTarget instanceof HTMLElement &&
event.relatedTarget.classList.contains("rio-dropdown-popup")
) {
return;
}
this.trySubmitInput();
}
private trySubmitInput(): void {
this.inputBox.unfocus();
this.hidePopup();
// Check if the inputted text corresponds to a valid option
let inputText = this.inputBox.value;
if (inputText === this.state.selectedName) {
return;
}
if (this.state.optionNames.includes(inputText)) {
this.state.selectedName = inputText;
this.sendMessageToBackend({
name: inputText,
});
} else {
this.inputBox.value = this.state.selectedName;
}
}
/// Close the popup without applying the selected option
private cancelInput(): void {
this.inputBox.value = this.state.selectedName;
this.trySubmitInput();
}
/// Close the popup and apply the selected option
private submitInput(optionName: string): void {
this.inputBox.value = optionName;
this.trySubmitInput();
}
/// Open the dropdown and show all options
private _onMouseDown(event: MouseEvent): void {
// Do we care?
if (!this.state.is_sensitive || event.button !== 0) {
return;
}
// Eat the event
markEventAsHandled(event);
// Already open?
if (this.popupManager.isOpen) {
this.cancelInput();
return;
}
// Make the text input appear as active
this.inputBox.focus();
// Open the dropdown
this.popupManager.isOpen = true;
}
_onKeyDown(event: KeyboardEvent): void {
// Close the dropdown on escape
if (event.key === "Escape") {
this.cancelInput();
}
// Enter -> select the highlighted option
else if (event.key === "Enter") {
if (this.highlightedOptionElement !== null) {
let mouseDownEvent = new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
view: window,
});
this.highlightedOptionElement.dispatchEvent(mouseDownEvent);
}
}
// Move highlight up
else if (event.key === "ArrowDown") {
let nextOption;
if (this.highlightedOptionElement === null) {
nextOption = this.popupOptionsElement.firstElementChild;
} else {
nextOption = this.highlightedOptionElement.nextElementSibling;
if (nextOption === null) {
nextOption = this.popupOptionsElement.firstElementChild;
}
}
this._highlightOption(nextOption as HTMLElement);
}
// Move highlight down
else if (event.key === "ArrowUp") {
let nextOption: Element | null;
if (this.highlightedOptionElement === null) {
nextOption = this.popupOptionsElement.lastElementChild;
} else {
nextOption =
this.highlightedOptionElement.previousElementSibling;
if (nextOption === null) {
nextOption = this.popupOptionsElement.lastElementChild;
}
}
this._highlightOption(nextOption as HTMLElement);
}
// Any other key -> let the event propagate
else {
return;
}
markEventAsHandled(event);
}
private _onInputValueChange(): void {
this._updateOptionsElement("popup");
}
private _highlightOption(optionElement: HTMLElement | null): void {
// Remove the highlight from the previous option
if (this.highlightedOptionElement !== null) {
this.highlightedOptionElement.classList.remove(
"rio-dropdown-option-highlighted"
);
}
// Remember the new option and highlight it
this.highlightedOptionElement = optionElement;
if (optionElement !== null) {
optionElement.classList.add("rio-dropdown-option-highlighted");
}
}
private showPopup(): void {
if (this.popupManager.isOpen) {
return;
}
this.popupManager.isOpen = true;
this._updateOptionsElement("popup");
}
private hidePopup(): void {
this.popupManager.isOpen = false;
}
onDestruction(): void {
super.onDestruction();
this.popupElement.remove();
}
/// Find needle in haystack, returning a HTMLElement with the matched
/// sections highlighted. If no match is found, return null. Needle must be
/// lowercase.
_highlightMatches(
haystack: string,
needleLower: string
): HTMLElement | null {
// Special case: Empty needle matches everything, and would cause a hang
// in the `while` loop below
if (needleLower.length === 0) {
const container = document.createElement("div");
container.textContent = haystack;
return container;
}
// Create a div element to hold the highlighted content
const container = document.createElement("div");
// Start searching
let startIndex = 0;
let haystackLower = haystack.toLowerCase();
let index = haystackLower.indexOf(needleLower, startIndex);
while (index !== -1) {
// Add the text before the match as a text node
container.appendChild(
document.createTextNode(haystack.substring(startIndex, index))
);
// Add the matched portion as a highlighted span
const span = document.createElement("span");
span.className = "rio-dropdown-option-highlighted";
span.textContent = haystack.substring(
index,
index + needleLower.length
);
container.appendChild(span);
// Update the start index for the next search
startIndex = index + needleLower.length;
// Find the next occurrence of needle in haystack
index = haystackLower.indexOf(needleLower, startIndex);
}
// Add any remaining text after the last match
container.appendChild(
document.createTextNode(haystack.substring(startIndex))
);
// Was anything found?
return container.children.length === 0 ? null : container;
}
/// Update the visible options based on everything matching the search
/// filter
_updateOptionsElement(which: "hidden" | "popup"): void {
// Prepare
let element: HTMLElement;
let needleLower: string;
if (which == "hidden") {
element = this.hiddenOptionsElement;
needleLower = "";
} else {
element = this.popupOptionsElement;
needleLower = this.inputBox.value.toLowerCase();
}
// Clean up
element.innerHTML = "";
// Find matching options
for (let optionName of this.state.optionNames) {
let match = this._highlightMatches(optionName, needleLower);
if (match === null) {
continue;
}
match.classList.add("rio-dropdown-option");
element.appendChild(match);
match.addEventListener("mouseenter", () => {
this._highlightOption(match);
});
// With a `click` handler, the <input> element loses focus for a
// little while, which is noticeable because the floating label will
// quickly move down and then back up. To avoid this, we use
// `mousedown` instead.
match.addEventListener("mousedown", (event) => {
this.submitInput(optionName);
markEventAsHandled(event);
});
}
// If this is the hidden element, we're done
if (which == "hidden") {
return;
}
// If only one option was found, highlight it
if (element.children.length === 1) {
this._highlightOption(element.firstElementChild as HTMLElement);
}
// Display an icon and resize the element
//
// For some reason the SVG has an explicit opacity set. Because of that,
// using CSS isn't possible. Overwrite the opacity here.
if (element.children.length === 0) {
applyIcon(element, "material/error").then(() => {
(element.firstElementChild as SVGElement).style.opacity = "0.2";
});
this.popupElement.style.height = "7rem";
} else {
this.popupElement.style.height = `${element.scrollHeight}px`;
}
}
updateElement(
deltaState: DropdownState,
latentComponents: Set<ComponentBase>
): void {
super.updateElement(deltaState, latentComponents);
// If the options have changed update the options element, and also
// store its width
if (deltaState.optionNames !== undefined) {
// Store the new value, because it is about to be accessed
this.state.optionNames = deltaState.optionNames;
// Update the hidden options element
this._updateOptionsElement("hidden");
this.hiddenOptionsElement.style.height = "0";
// Update the visible options element
if (this.popupManager.isOpen) {
this._updateOptionsElement("popup");
}
}
if (deltaState.label !== undefined) {
this.inputBox.label = deltaState.label;
}
if (deltaState.accessibility_label !== undefined) {
this.inputBox.accessibilityLabel = deltaState.accessibility_label;
}
if (deltaState.selectedName !== undefined) {
this.inputBox.value = deltaState.selectedName;
}
if (deltaState.is_sensitive === true) {
this.inputBox.isSensitive = true;
this.element.classList.remove(
"rio-disabled-input",
"rio-switcheroo-disabled"
);
} else if (deltaState.is_sensitive === false) {
this.inputBox.isSensitive = false;
this.element.classList.add(
"rio-disabled-input",
"rio-switcheroo-disabled"
);
this.hidePopup();
}
if (deltaState.is_valid === false) {
this.element.style.setProperty(
"--rio-local-text-color",
"var(--rio-global-danger-bg)"
);
} else if (deltaState.is_valid === true) {
this.element.style.removeProperty("--rio-local-text-color");
}
}
}