Files
mia-accounting/src/accounting/static/js/base-combobox.js
T

238 lines
6.0 KiB
JavaScript

/* The Mia! Accounting Project
* base-combobox.js: The JavaScript for the base abstract combobox
*/
/* Copyright (c) 2026 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2026/4/16
*/
"use strict";
/**
* The base abstract combobox.
*
* @abstract
* @template {BaseOption} T
*/
class BaseCombobox {
/**
* The query input
* @type {HTMLInputElement}
*/
query;
/**
* The options
* @type {T[]}
*/
options;
/**
* The options that are shown
* @type {T[]}
*/
shownOptions;
/**
* Constructs a base abstract combobox.
*
* @param query {HTMLInputElement} the query input
* @param options {T[]} the options
*/
constructor(query, options) {
this.query = query;
this.query.oninput = () => this.filterOptions();
this.query.onkeydown = this.onQueryKeyDown.bind(this);
this.options = options;
this.shownOptions = [];
}
/**
* Actions when keys are pressed on the query input.
*
* @param event {KeyboardEvent} the key event
*/
onQueryKeyDown(event) {
if (this.shownOptions.length === 0) {
return;
}
const currentID = this.query.getAttribute("aria-activedescendant");
const currentIndex = this.shownOptions.findIndex((option) => option.elementID === currentID);
let newIndex;
switch (event.key) {
case "ArrowUp":
if (currentIndex === -1) {
newIndex = this.shownOptions.length - 1;
} else {
newIndex = (currentIndex - 1 + this.shownOptions.length) % this.shownOptions.length;
}
break;
case "ArrowDown":
if (currentIndex === -1) {
newIndex = 0;
} else {
newIndex = (currentIndex + 1) % this.shownOptions.length;
}
break;
case "Home":
if (this.query.value !== "") {
return;
}
newIndex = 0;
break;
case "End":
if (this.query.value !== "") {
return;
}
newIndex = this.shownOptions.length - 1;
break;
case "PageUp":
if (currentIndex === -1) {
newIndex = this.shownOptions.length - 1;
} else {
newIndex = Math.max(currentIndex - 10, 0);
}
break;
case "PageDown":
if (currentIndex === -1) {
newIndex = 0;
} else {
newIndex = Math.min(currentIndex + 10, this.shownOptions.length - 1);
}
break;
case "Enter":
event.preventDefault();
if (currentIndex !== -1) {
this.shownOptions[currentIndex].click();
}
return;
case "Escape":
if (this.query.value !== "") {
event.preventDefault();
event.stopPropagation();
this.query.value = "";
this.filterOptions();
}
return;
default:
return;
}
event.preventDefault();
this.selectOption(this.shownOptions[newIndex]);
}
/**
* Filters the options.
*
* @abstract
*/
filterOptions() {
throw new Error("Method not implemented");
}
/**
* Selects an option.
*
* @param option {T|undefined} the option.
*/
selectOption(option) {
this.options.forEach((opt) => opt.setActive(false));
if (option === undefined) {
return;
}
option.setActive(true);
this.query.setAttribute("aria-activedescendant", option.elementID);
option.scrollIntoView();
}
}
/**
* The base abstract option
*
* @abstract
*/
class BaseOption {
/**
* The element
* @type {HTMLLIElement}
*/
#element;
/**
* The element ID
* @type {string}
*/
elementID;
/**
* Constructs the base abstract option.
*
* @param element {HTMLLIElement} the element
*/
constructor(element) {
this.#element = element;
this.elementID = element.id;
}
/**
* Sets whether the option is shown.
*
* @param isShown {boolean} true to show, or false otherwise
*/
setShown(isShown) {
if (isShown) {
this.#element.classList.remove("d-none");
} else {
this.#element.classList.add("d-none");
}
}
/**
* Sets whether the option is active.
*
* @param isActive {boolean} true if active, or false otherwise
*/
setActive(isActive) {
if (isActive) {
this.#element.classList.add("active");
this.#element.ariaSelected = "true";
} else {
this.#element.classList.remove("active");
this.#element.ariaSelected = "false";
}
}
/**
* Clicks the option.
*
*/
click() {
this.#element.click();
}
/**
* Scrolls the option into view.
*
*/
scrollIntoView() {
this.#element.scrollIntoView({block: "nearest"});
}
}