From 23dc8483d6bafa914b7a145aa94088d5b4c3f325 Mon Sep 17 00:00:00 2001 From: klovaaxel Date: Sun, 4 Feb 2024 00:44:21 +0100 Subject: [PATCH] Adds BiomeJS as linter and formatter --- biome.json | 23 + build.mjs | 16 +- bun.lockb | Bin 26583 -> 30093 bytes package.json | 6 +- src/combobox-framework.ts | 1444 ++++++++++++++++++++----------------- vite.config.js | 16 +- 6 files changed, 809 insertions(+), 696 deletions(-) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..3952fb2 --- /dev/null +++ b/biome.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.5.3/schema.json", + "organizeImports": { + "enabled": true + }, + "formatter": { + "enabled": true, + "formatWithErrors": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 80, + "ignore": ["**/node_modules/**", "**/dist/**"] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + } + } + } +} diff --git a/build.mjs b/build.mjs index 3da719b..922e63d 100644 --- a/build.mjs +++ b/build.mjs @@ -1,8 +1,8 @@ -import dts from "bun-plugin-dts"; - -await Bun.build({ - entrypoints: ["./src/combobox-framework.ts"], - outdir: "./dist", - minify: true, - plugins: [dts()], -}); +import dts from "bun-plugin-dts"; + +await Bun.build({ + entrypoints: ["./src/combobox-framework.ts"], + outdir: "./dist", + minify: true, + plugins: [dts()], +}); diff --git a/bun.lockb b/bun.lockb index f6fadced8396699382aa9bdf0e231a7751a35af1..846b09d89f176d78b4668cff6a356fd1fa44eecb 100644 GIT binary patch delta 5925 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 diff --git a/package.json b/package.json index ecdf35f..223b3d0 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,14 @@ }, "scripts": { "dev": "vite", - "build": "tsc && vite build; bun run build.mjs", + "build": "bun lint; tsc && vite build; bun run build.mjs; echo \"Done! Don't forget to update the version number in package.json\"", "build-demo": "tsc && vite build", "build-module": "bun run build.mjs", - "preview": "vite preview" + "preview": "vite preview", + "lint": "bunx @biomejs/biome check --apply *.json; bunx @biomejs/biome check --apply src/" }, "devDependencies": { + "@biomejs/biome": "^1.5.3", "bun-plugin-dts": "^0.2.1", "typescript": "^5.2.2", "vite": "^5.0.8" diff --git a/src/combobox-framework.ts b/src/combobox-framework.ts index 2a74716..7a4ca16 100644 --- a/src/combobox-framework.ts +++ b/src/combobox-framework.ts @@ -1,678 +1,766 @@ -import Fuse from "fuse.js"; - -export default class ComboboxFramework extends HTMLElement { - private _input: HTMLInputElement | null = null; - private _list: HTMLElement | null = null; - private _originalList: HTMLElement | null = null; - private _isAltModifierPressed = false; - private _forceValue = false; - private _lastValue: string | undefined = undefined; - private _limit: number = Infinity; - - // #region Fuzzy search Fuse.js - private _fuse: Fuse | null = null; - private _fuseOptions = { - keys: ["dataset.display", "dataset.value", "innerText"], - }; - // #endregion - - public constructor() { - super(); - } - - /** - * Returns an array of the names of the attributes to observe. - * @static - * @returns {string[]} - * @memberof ComboboxFramework - * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks - */ - static get observedAttributes(): string[] { - return ["data-value", "data-fuse-options", "data-listbox", "data-limit"]; - } - - /** - * Called when an attribute is changed, appended, removed, or replaced on the element. - * @param name {string} Name of the attribute that changed - * @param oldValue {string} Old value of the attribute - * @param newValue {string} New value of the attribute - * @returns {void} - * @memberof ComboboxFramework - * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks - */ - public attributeChangedCallback(name: string, oldValue: string, newValue: string): void { - if (oldValue === newValue) return; // If the value is the same, do nothing - - // #region Handle the attribute change - switch (name) { - case "data-value": - this.selectItemByValue(newValue, false); - break; - case "data-fuse-options": - if (!this._originalList) this.fetchOriginalList(); - - this._fuseOptions = JSON.parse(newValue); - this._fuse = new Fuse( - Array.from((this._originalList!.cloneNode(true) as HTMLElement).children), - this._fuseOptions - ); - this.searchList(); - break; - case "data-listbox": - this._forceValue = !!newValue; - break; - case "data-limit": - this._limit = parseInt(newValue); - break; - } - // #endregion - } - - /** - * Called when the element is inserted into a document, including into a shadow tree. - * @returns {void} - * @memberof ComboboxFramework - * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks - */ - public connectedCallback(): void { - // #region Create the shadow DOM - const shadow = this.attachShadow({ mode: "open" }); - shadow.innerHTML = ` - - - `; - // #endregion - - // #region Fetch the input and list elements - this.fetchInput(); - this.fetchList(); - // #endregion - - this.setBasicAttribbutes(); - - // #region Save the original list - // This is done to have a original copy of the list to later sort, filter, etc. - this.fetchOriginalList(); - // #endregion - - // #region Create the fuse object - this._fuse = new Fuse( - Array.from((this._originalList!.cloneNode(true) as HTMLElement).children), - this._fuseOptions - ); - // #endregion - - // #region Do initial search the list - this.searchList(); - // #endregion - - // #region Add event listeners - this.addEventListeners(); - // #endregion - - // #region If forceValue is true, select the first item in the list - this.forceValue(); - // #endregion - } - - /** - * Called when the element is removed from a document. Removes event listeners. - * @returns {void} - * @memberof ComboboxFramework - * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks - */ - public disconnectedCallback(): void { - // #region Remove event listeners - this.removeEventListener("focusout", this.handleBlur.bind(this)); - // #endregion - - // #region Remove event listeners from the input element - if (!this._input) this.fetchList(); - this._input!.removeEventListener("input", this.searchList.bind(this, true, true)); - this._input!.removeEventListener("focus", this.toggleList.bind(this, true)); - // #endregion - - // #region Remove event listeners from framework element - this._input!.removeEventListener("keydown", this.handleComboBoxKeyPress.bind(this)); - this._input!.removeEventListener("keyup", this.handleKeyUp.bind(this)); - // #endregion - - // #region Remove event listeners from the list element - this.removeEventListenersFromListItems(); - // #endregion - } - - /** - * Fetches the list element and stores it in `_list` - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private fetchList(): void { - this._list = this.querySelector('[slot="list"] [data-list]') as HTMLElement; - if (!this._list) this._list = this.querySelector('[slot="list"]') as HTMLElement; - if (!this._list) throw new Error("List element not found"); - } - - /** - * Fetches the input element and stores it in `_input` - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private fetchInput(): void { - this._input = this.querySelector('[slot="input"]') as HTMLInputElement; - if (!this._input) throw new Error("Input element not found"); - } - - /** - * Fetches the original list element and stores it in `_originalList` - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private fetchOriginalList(): void { - if (!this._list) this.fetchList(); - this._originalList = this._list!.cloneNode(true) as HTMLElement; - } - - /** - * Removes event listeners from the list item elements - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private removeEventListenersFromListItems(): void { - // #region Remove event listeners from the list item elements - if (!this._list) this.fetchList(); - const children = this._list!.children; - for (let i = 0; i < children.length; i++) { - const child = children[i] as HTMLElement; - child.removeEventListener("keydown", this.handleListKeyPress.bind(this)); - child.removeEventListener("keyup", this.handleKeyUp.bind(this)); - child.removeEventListener("click", this.selectItem.bind(this, child, true)); - } - // #endregion - } - - /** - * Set basic attributes for the input and list elements. - * Mutates the input and list elements that are stored in `_input` and `_list` - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private setBasicAttribbutes(): void { - // #region Set the ids of the input and list elements if they are not set - this._input!.id = this._input!.id.length != 0 ? this._input!.id : `input-${crypto.randomUUID()}`; - this._list!.id = this._list!.id.length != 0 ? this._list!.id : `list-${crypto.randomUUID()}`; - // #endregion - - // #region Basic attributes for the input element - this._input!.setAttribute("role", "combobox"); - this._input!.setAttribute("aria-controls", this._list!.id); - this._input!.setAttribute("aria-expanded", "false"); - this._input!.setAttribute("aria-autocomplete", "list"); // Maybe change this to both? - this._input!.setAttribute("autocomplete", "off"); - // #endregion - - // #region Basic attributes for the list element - this._list!.setAttribute("role", "listbox"); - this._list!.setAttribute("aria-multiselectable", "false"); - this._list!.setAttribute("anchor", this._input!.id); - this._list!.tabIndex = -1; - // #endregion - - // #region Basic attributes for the children of the list element - const children = this._list!.children; - for (let i = 0; i < children.length; i++) { - const child = children[i] as HTMLElement; - child.setAttribute("role", "option"); - child.setAttribute("aria-selected", "false"); - child.tabIndex = -1; - } - // #endregion - } - - /** - * Adds event listeners - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private addEventListeners(): void { - // #region Add event listeners to the framework element - this.addEventListener("focusout", this.handleBlur.bind(this)); - // #endregion - - // #region Add event listeners to the input element - if (!this._input) this.fetchInput(); - this._input!.addEventListener("input", this.searchList.bind(this, true, true)); - this._input!.addEventListener("focus", this.toggleList.bind(this, true)); - // #endregion - - // #region Add event listeners to framework element - this._input!.addEventListener("keydown", this.handleComboBoxKeyPress.bind(this)); - this._input!.addEventListener("keyup", this.handleKeyUp.bind(this)); - // #endregion - - // #region Add event listeners to the list element - this.addEventListenersToListItems(); - // #endregion - } - - /** - * Adds event listeners to the list item elements - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private addEventListenersToListItems(): void { - // #region Add event listeners to the list item elements - if (!this._list) this.fetchList(); - const children = this._list!.children; - for (let i = 0; i < children.length; i++) { - const child = children[i] as HTMLElement; - child.addEventListener("keydown", this.handleListKeyPress.bind(this)); - child.addEventListener("keyup", this.handleKeyUp.bind(this)); - child.addEventListener("click", this.selectItem.bind(this, child, true)); - } - // #endregion - } - - /** - * Search the list and update the list element with the new, filtered, and sorted list - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private searchList(openList: boolean = true, clearValue: boolean = true): void { - // #region Check if required variables are set - if (!this._fuse) throw new Error("Fuse object not found"); - if (!this._list) this.fetchList(); - if (!this._input) this.fetchInput(); - // #endregion - - // #region Clear the selected item - if (clearValue) { - this.dataset.value = ""; - this.sendChangeEvent(); - } - // #endregion - - // #region If the input is empty, show the original list and return - if (this._input!.value == "") { - this._list!.innerHTML = ""; - this._list!.append( - ...Array.from((this._originalList!.cloneNode(true) as HTMLElement).children).slice(0, this._limit) - ); - this.addEventListenersToListItems(); - return; - } - // #endregion - - // #region Search the list - const newList = this._fuse.search(this._input!.value).map((item: any) => item.item as HTMLElement); - // #endregion - - // #region Clear the list and add the new items - this._list!.innerHTML = ""; - this._list!.append(...newList.map((item) => item.cloneNode(true) as HTMLElement).slice(0, this._limit)); - // #endregion - - // #region Highlight the search string in the list items (or nested childrens) text content - const highlightTextContent = (node: Element) => { - if ( - node.nodeType === Node.TEXT_NODE && - node.textContent?.trim() != "" && - node.textContent?.trim() != "\n" - ) { - const text = node.textContent ?? ""; - const newNode = document.createElement("template"); - newNode.innerHTML = this.highlightText(text, this._input!.value); - node.replaceWith(newNode.content); - } else { - for (const childNode of node.childNodes) { - highlightTextContent(childNode as Element); - } - } - }; - - for (const item of this._list!.children) { - highlightTextContent(item); - } - // #endregion - - // #region Add event listeners to the list item elements - this.addEventListenersToListItems(); - // #endregion - - // #region Show the list after the search is complete - this.toggleList(openList); - // #endregion - } - - /** - * Highlights the search string in the text - * @private - * @param {string} text The text to highlight - * @param {string} searchString The search string - * @memberof ComboboxFramework - * @returns {string} - */ - private highlightText(text: string, searchString: string): string { - const regex = new RegExp(`[${searchString}]+`, "gmi"); - return text.replace(regex, "$&"); - } - - /** - * Toggles the expanded state of the combobox - * @private - * @param {boolean} [newValue] The new value of the expanded state - optional - defaults to the opposite of the current value - * @memberof ComboboxFramework - * @returns {void} - */ - private toggleList(newValue: boolean = this._input!.getAttribute("aria-expanded") === "true"): void { - this._input!.setAttribute("aria-expanded", `${newValue}`); - if (!newValue) this.unfocusAllItems(); - } - - /** - * Focuses an item in the list - * @private - * @param {HTMLElement} item The list item to focus - * @memberof ComboboxFramework - * @returns {void} - */ - private focusItem(item: HTMLElement): void { - if (!item) return; - item.focus(); - this._list!.querySelectorAll("[aria-selected]").forEach((i) => i.removeAttribute("aria-selected")); - item.setAttribute("aria-selected", "true"); - } - - /** - * Unfocuses all items in the list - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private unfocusAllItems(): void { - this._list!.querySelectorAll("[aria-selected]").forEach((i) => i.removeAttribute("aria-selected")); - } - - /** - * Selects an item in the list - * @private - * @param {HTMLElement} item The item to select - * @memberof ComboboxFramework - * @returns {void} - */ - private selectItem(item: HTMLElement, grabFocus = true): void { - if (!this._input) this.fetchInput(); - - // #region Set the value of the input element - // If the item has a data-display attribute, use that as the value - if (item.dataset.display) this._input!.value = item.dataset.display; - // Else If the element does not have any children or only has strong children, use the innerText as the value - else if (item.children.length || Array.from(item.children).every((c) => c.nodeName == "STRONG")) - this._input!.value = item.innerText; - // Else If the element has a data-value attribute, use that as the value - else if (item.dataset.value) this._input!.value = item.dataset.value; - // Else fallback to a empty string - else this._input!.value = ""; - // #endregion - - if (item.dataset.value) this.dataset.value = item.dataset.value; - if (grabFocus) this._input!.focus(); - this.toggleList(false); - this.searchList(false, false); - this.sendChangeEvent(); - } - - /** - * Sends a change event - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private sendChangeEvent(): void { - if (this.dataset.value === this._lastValue) return; - const event = new Event("change"); - this.dispatchEvent(event); - this._lastValue = this.dataset.value; - } - - /** - * Selects an item in the list by its value - * @private - * @param {string} value The value of the item to select - * @memberof ComboboxFramework - * @returns {void} - */ - selectItemByValue(value: string | null, grabFocus = true): void { - if (!value) return; - if (!this._list) this.fetchList(); - const item = this._list!.querySelector(`[data-value="${value}"]`) as HTMLElement; - if (!item) return; - this.selectItem(item, grabFocus); - } - - /** - * Clears the input element, focuses it and closes the list - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private clearInput(grabFocus: boolean = true): void { - // #region Check if required variables are set - if (!this._input) this.fetchInput(); - // #endregion - - // #region Clear the input element - this._input!.value = ""; - if (grabFocus) this._input!.focus(); - this.toggleList(false); - // #endregion - } - - /** - * Toggles the expanded state of the combobox if the focus is lost - * @param event {FocusEvent} The blur event - * @memberof ComboboxFramework - * @returns {void} - */ - private handleBlur(): void { - // Set a timeout to force the focus event on the list item to fire before the foucsout event on the input element - setTimeout(() => { - if (this.querySelector(":focus")) return; - - // #region If forceValue is true, select the first item in the list - this.forceValue(); - // #endregion - - this.toggleList(false); - }, 0); - } - - /** - * Forces the value of the input element to the first item in the list if the input element is not empty - * @private - * @memberof ComboboxFramework - * @returns {void} - */ - private forceValue(): void { - // #region Check if required variables are set - if (!this._input) this.fetchInput(); - if (!this._list) this.fetchList(); - // #endregion - - // #region If forceValue is true and we don't have a value selected, select the first item (best match) in the list or empty the input and value - if (this._forceValue && !!this._input?.value && !this.dataset.value) { - const bestMatch = this._list!.children[0] as HTMLElement; - if (bestMatch) this.selectItem(bestMatch, false); - else { - this.clearInput(false); // Clear the input - this.dataset.value = ""; // Clear the value - this.sendChangeEvent(); // Send a change event - } - } - // #endregion - } - - /** - * Handles the key press event on the input element - * @param event {KeyboardEvent} The key press event - * @memberof ComboboxFramework - * @returns {void} - * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#keyboardinteraction - */ - private handleComboBoxKeyPress(event: KeyboardEvent): void { - // #region Check if required variables are set - if (!this._input) this.fetchInput(); - if (!this._list) this.fetchList(); - // #endregion - - // #region Handle the key press - switch (event.key) { - case "ArrowDown": - // If the popup is available, moves focus into the popup: If the autocomplete behavior automatically selected a suggestion before Down Arrow was pressed, focus is placed on the suggestion following the automatically selected suggestion. Otherwise, places focus on the first focusable element in the popup. - if (this._input!.getAttribute("aria-expanded") !== "true") { - this.toggleList(true); - if (!this._isAltModifierPressed) this.focusItem(this._list!.children[0] as HTMLElement); - } else { - this.focusItem(this._list!.children[0] as HTMLElement); - } - event.preventDefault(); // prevent scrolling - break; - case "UpArrow": - // (Optional): If the popup is available, places focus on the last focusable element in the popup. - if (this._input!.getAttribute("aria-expanded") !== "true") { - this.toggleList(true); - this.focusItem(this._list!.children[this._list!.children.length - 1] as HTMLElement); - } - event.preventDefault(); // prevent scrolling - break; - case "Escape": - // Dismisses the popup if it is visible. Optionally, if the popup is hidden before Escape is pressed, clears the combobox. - if (this._input!.getAttribute("aria-expanded") === "true") { - this.toggleList(false); - } else { - this._input!.value = ""; - } - this._input!.focus(); - break; - case "Enter": - // Autocompletes the combobox with the first suggestion - if (this._input!.getAttribute("aria-expanded") === "true") { - this.selectItem(this._list!.children[0] as HTMLElement); - } - break; - case "Alt": - this._isAltModifierPressed = true; - break; - } - // #endregion - } - - /** - * Handles the key press event on the list element - * @param event {KeyboardEvent} The key press event - * @memberof ComboboxFramework - * @returns {void} - * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#keyboardinteraction - */ - private handleListKeyPress(event: KeyboardEvent): void { - // #region Check if required variables are set - if (!this._input) this.fetchInput(); - if (!this._list) this.fetchList(); - // #endregion - - // #region Handle the key press - const li = event.target as HTMLElement; - switch (event.key) { - case "Enter": - // Select the item and close the list - this.selectItem(li); - break; - case "Escape": - // Close the list and focus the input - this.clearInput(); - break; - case "ArrowDown": - // Move focus to the next item in the list - const nextLi = li.nextElementSibling as HTMLElement; - if (nextLi) this.focusItem(nextLi); - else this.focusItem(this._list!.children[0] as HTMLElement); - event.preventDefault(); // prevent scrolling - break; - case "ArrowUp": - // If alt is pressed, close the list and focus the input - if (this._isAltModifierPressed) { - this._input!.focus(); - this.toggleList(false); - event.preventDefault(); // prevent scrolling - break; - } - - // Move focus to the previous item in the list - const previousLi = li.previousElementSibling as HTMLElement; - if (previousLi) this.focusItem(previousLi); - else this.focusItem(this._list!.children[this._list!.children.length - 1] as HTMLElement); - event.preventDefault(); // prevent scrolling - break; - case "ArrowRight": - // returns focus to the combobox without closing the popup - this._input!.focus(); - break; - case "ArrowLeft": - // returns focus to the combobox without closing the popup - this._input!.focus(); - break; - case "Home": - // Move focus to the first item in the list - this._input!.focus(); - break; - case "End": - // Move focus to the last item in the list - this._input!.focus(); - break; - case "Backspace": - // Move focus to the last item in the list - this._input!.focus(); - break; - case "Delete": - // Move focus to the last item in the list - this._input!.focus(); - break; - case "Alt": - this._isAltModifierPressed = true; - break; - default: - // If the key is not handled, return focus to the input - this._input!.focus(); - break; - } - // #endregion - } - - /** - * Handles the key up event on the input element and list element - * @param event {KeyboardEvent} The key up event - * @memberof ComboboxFramework - * @returns {void} - */ - private handleKeyUp(event: KeyboardEvent): void { - // #region Handle the key press - switch (event.key) { - case "Alt": - this._isAltModifierPressed = false; - break; - } - // #endregion - } -} - -// #region Register the component -customElements.define("combobox-framework", ComboboxFramework); -// #endregion +import Fuse, { FuseResult } from "fuse.js"; + +export default class ComboboxFramework extends HTMLElement { + private _input: HTMLInputElement | null = null; + private _list: HTMLElement | null = null; + private _originalList: HTMLElement | null = null; + private _isAltModifierPressed = false; + private _forceValue = false; + private _lastValue: string | undefined = undefined; + private _limit: number = Infinity; + + // #region Fuzzy search Fuse.js + private _fuse: Fuse | null = null; + private _fuseOptions = { + keys: ["dataset.display", "dataset.value", "innerText"], + }; + // #endregion + + /** + * Returns an array of the names of the attributes to observe. + * @static + * @returns {string[]} + * @memberof ComboboxFramework + * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks + */ + static get observedAttributes(): string[] { + return [ + "data-value", + "data-fuse-options", + "data-listbox", + "data-limit", + ]; + } + + /** + * Called when an attribute is changed, appended, removed, or replaced on the element. + * @param name {string} Name of the attribute that changed + * @param oldValue {string} Old value of the attribute + * @param newValue {string} New value of the attribute + * @returns {void} + * @memberof ComboboxFramework + * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks + */ + public attributeChangedCallback( + name: string, + oldValue: string, + newValue: string, + ): void { + if (oldValue === newValue) return; // If the value is the same, do nothing + + // #region Handle the attribute change + switch (name) { + case "data-value": + this.selectItemByValue(newValue, false); + break; + case "data-fuse-options": + if (!this._originalList) this.fetchOriginalList(); + + this._fuseOptions = JSON.parse(newValue); + this._fuse = new Fuse( + Array.from( + (this._originalList!.cloneNode(true) as HTMLElement) + .children, + ), + this._fuseOptions, + ); + this.searchList(); + break; + case "data-listbox": + this._forceValue = !!newValue; + break; + case "data-limit": + this._limit = parseInt(newValue); + break; + } + // #endregion + } + + /** + * Called when the element is inserted into a document, including into a shadow tree. + * @returns {void} + * @memberof ComboboxFramework + * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks + */ + public connectedCallback(): void { + // #region Create the shadow DOM + const shadow = this.attachShadow({ mode: "open" }); + shadow.innerHTML = ` + + + `; + // #endregion + + // #region Fetch the input and list elements + this.fetchInput(); + this.fetchList(); + // #endregion + + this.setBasicAttribbutes(); + + // #region Save the original list + // This is done to have a original copy of the list to later sort, filter, etc. + this.fetchOriginalList(); + // #endregion + + // #region Create the fuse object + this._fuse = new Fuse( + Array.from( + (this._originalList!.cloneNode(true) as HTMLElement).children, + ), + this._fuseOptions, + ); + // #endregion + + // #region Do initial search the list + this.searchList(); + // #endregion + + // #region Add event listeners + this.addEventListeners(); + // #endregion + + // #region If forceValue is true, select the first item in the list + this.forceValue(); + // #endregion + } + + /** + * Called when the element is removed from a document. Removes event listeners. + * @returns {void} + * @memberof ComboboxFramework + * @see https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks + */ + public disconnectedCallback(): void { + // #region Remove event listeners + this.removeEventListener("focusout", this.handleBlur.bind(this)); + // #endregion + + // #region Remove event listeners from the input element + if (!this._input) this.fetchList(); + this._input!.removeEventListener( + "input", + this.searchList.bind(this, true, true), + ); + this._input!.removeEventListener( + "focus", + this.toggleList.bind(this, true), + ); + // #endregion + + // #region Remove event listeners from framework element + this._input!.removeEventListener( + "keydown", + this.handleComboBoxKeyPress.bind(this), + ); + this._input!.removeEventListener("keyup", this.handleKeyUp.bind(this)); + // #endregion + + // #region Remove event listeners from the list element + this.removeEventListenersFromListItems(); + // #endregion + } + + /** + * Fetches the list element and stores it in `_list` + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private fetchList(): void { + this._list = this.querySelector( + '[slot="list"] [data-list]', + ) as HTMLElement; + if (!this._list) + this._list = this.querySelector('[slot="list"]') as HTMLElement; + if (!this._list) throw new Error("List element not found"); + } + + /** + * Fetches the input element and stores it in `_input` + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private fetchInput(): void { + this._input = this.querySelector('[slot="input"]') as HTMLInputElement; + if (!this._input) throw new Error("Input element not found"); + } + + /** + * Fetches the original list element and stores it in `_originalList` + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private fetchOriginalList(): void { + if (!this._list) this.fetchList(); + this._originalList = this._list!.cloneNode(true) as HTMLElement; + } + + /** + * Removes event listeners from the list item elements + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private removeEventListenersFromListItems(): void { + // #region Remove event listeners from the list item elements + if (!this._list) this.fetchList(); + const children = this._list!.children; + for (let i = 0; i < children.length; i++) { + const child = children[i] as HTMLElement; + child.removeEventListener( + "keydown", + this.handleListKeyPress.bind(this), + ); + child.removeEventListener("keyup", this.handleKeyUp.bind(this)); + child.removeEventListener( + "click", + this.selectItem.bind(this, child, true), + ); + } + // #endregion + } + + /** + * Set basic attributes for the input and list elements. + * Mutates the input and list elements that are stored in `_input` and `_list` + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private setBasicAttribbutes(): void { + // #region Set the ids of the input and list elements if they are not set + this._input!.id = + this._input!.id.length !== 0 + ? this._input!.id + : `input-${crypto.randomUUID()}`; + this._list!.id = + this._list!.id.length !== 0 + ? this._list!.id + : `list-${crypto.randomUUID()}`; + // #endregion + + // #region Basic attributes for the input element + this._input!.setAttribute("role", "combobox"); + this._input!.setAttribute("aria-controls", this._list!.id); + this._input!.setAttribute("aria-expanded", "false"); + this._input!.setAttribute("aria-autocomplete", "list"); // Maybe change this to both? + this._input!.setAttribute("autocomplete", "off"); + // #endregion + + // #region Basic attributes for the list element + this._list!.setAttribute("role", "listbox"); + this._list!.setAttribute("aria-multiselectable", "false"); + this._list!.setAttribute("anchor", this._input!.id); + this._list!.tabIndex = -1; + // #endregion + + // #region Basic attributes for the children of the list element + const children = this._list!.children; + for (let i = 0; i < children.length; i++) { + const child = children[i] as HTMLElement; + child.setAttribute("role", "option"); + child.setAttribute("aria-selected", "false"); + child.tabIndex = -1; + } + // #endregion + } + + /** + * Adds event listeners + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private addEventListeners(): void { + // #region Add event listeners to the framework element + this.addEventListener("focusout", this.handleBlur.bind(this)); + // #endregion + + // #region Add event listeners to the input element + if (!this._input) this.fetchInput(); + this._input!.addEventListener( + "input", + this.searchList.bind(this, true, true), + ); + this._input!.addEventListener( + "focus", + this.toggleList.bind(this, true), + ); + // #endregion + + // #region Add event listeners to framework element + this._input!.addEventListener( + "keydown", + this.handleComboBoxKeyPress.bind(this), + ); + this._input!.addEventListener("keyup", this.handleKeyUp.bind(this)); + // #endregion + + // #region Add event listeners to the list element + this.addEventListenersToListItems(); + // #endregion + } + + /** + * Adds event listeners to the list item elements + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private addEventListenersToListItems(): void { + // #region Add event listeners to the list item elements + if (!this._list) this.fetchList(); + const children = this._list!.children; + for (let i = 0; i < children.length; i++) { + const child = children[i] as HTMLElement; + child.addEventListener( + "keydown", + this.handleListKeyPress.bind(this), + ); + child.addEventListener("keyup", this.handleKeyUp.bind(this)); + child.addEventListener( + "click", + this.selectItem.bind(this, child, true), + ); + } + // #endregion + } + + /** + * Search the list and update the list element with the new, filtered, and sorted list + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private searchList(openList = true, clearValue = true): void { + // #region Check if required variables are set + if (!this._fuse) throw new Error("Fuse object not found"); + if (!this._list) this.fetchList(); + if (!this._input) this.fetchInput(); + // #endregion + + // #region Clear the selected item + if (clearValue) { + this.dataset.value = ""; + this.sendChangeEvent(); + } + // #endregion + + // #region If the input is empty, show the original list and return + if (this._input!.value === "") { + this._list!.innerHTML = ""; + this._list!.append( + ...Array.from( + (this._originalList!.cloneNode(true) as HTMLElement) + .children, + ).slice(0, this._limit), + ); + this.addEventListenersToListItems(); + return; + } + // #endregion + + // #region Search the list + const newList = this._fuse + .search(this._input!.value) + .map((item: FuseResult) => item.item as HTMLElement); + // #endregion + + // #region Clear the list and add the new items + this._list!.innerHTML = ""; + this._list!.append( + ...newList + .map((item) => item.cloneNode(true) as HTMLElement) + .slice(0, this._limit), + ); + // #endregion + + // #region Highlight the search string in the list items (or nested childrens) text content + const highlightTextContent = (node: Element) => { + if ( + node.nodeType === Node.TEXT_NODE && + node.textContent?.trim() !== "" && + node.textContent?.trim() !== "\n" + ) { + const text = node.textContent ?? ""; + const newNode = document.createElement("template"); + newNode.innerHTML = this.highlightText( + text, + this._input!.value, + ); + node.replaceWith(newNode.content); + } else { + for (const childNode of node.childNodes) { + highlightTextContent(childNode as Element); + } + } + }; + + for (const item of this._list!.children) { + highlightTextContent(item); + } + // #endregion + + // #region Add event listeners to the list item elements + this.addEventListenersToListItems(); + // #endregion + + // #region Show the list after the search is complete + this.toggleList(openList); + // #endregion + } + + /** + * Highlights the search string in the text + * @private + * @param {string} text The text to highlight + * @param {string} searchString The search string + * @memberof ComboboxFramework + * @returns {string} + */ + private highlightText(text: string, searchString: string): string { + const regex = new RegExp(`[${searchString}]+`, "gmi"); + return text.replace(regex, "$&"); + } + + /** + * Toggles the expanded state of the combobox + * @private + * @param {boolean} [newValue] The new value of the expanded state - optional - defaults to the opposite of the current value + * @memberof ComboboxFramework + * @returns {void} + */ + private toggleList( + newValue: boolean = this._input!.getAttribute("aria-expanded") === + "true", + ): void { + this._input!.setAttribute("aria-expanded", `${newValue}`); + if (!newValue) this.unfocusAllItems(); + } + + /** + * Focuses an item in the list + * @private + * @param {HTMLElement} item The list item to focus + * @memberof ComboboxFramework + * @returns {void} + */ + private focusItem(item: HTMLElement): void { + if (!item) return; + item.focus(); + this.unfocusAllItems(); + item.setAttribute("aria-selected", "true"); + } + + /** + * Unfocuses all items in the list + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private unfocusAllItems(): void { + // #region Check if required variables are set + if (!this._list) this.fetchList(); + // #endregion + + // #region Unfocus all items in the list + for (const item of this._list!.querySelectorAll("[aria-selected]")) + item.removeAttribute("aria-selected"); + // #endregion + } + + /** + * Selects an item in the list + * @private + * @param {HTMLElement} item The item to select + * @memberof ComboboxFramework + * @returns {void} + */ + private selectItem(item: HTMLElement, grabFocus = true): void { + if (!this._input) this.fetchInput(); + + // #region Set the value of the input element + // If the item has a data-display attribute, use that as the value + if (item.dataset.display) this._input!.value = item.dataset.display; + // Else If the element does not have any children or only has strong children, use the innerText as the value + else if ( + item.children.length || + Array.from(item.children).every((c) => c.nodeName === "STRONG") + ) + this._input!.value = item.innerText; + // Else If the element has a data-value attribute, use that as the value + else if (item.dataset.value) this._input!.value = item.dataset.value; + // Else fallback to a empty string + else this._input!.value = ""; + // #endregion + + if (item.dataset.value) this.dataset.value = item.dataset.value; + if (grabFocus) this._input!.focus(); + this.toggleList(false); + this.searchList(false, false); + this.sendChangeEvent(); + } + + /** + * Sends a change event + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private sendChangeEvent(): void { + if (this.dataset.value === this._lastValue) return; + const event = new Event("change"); + this.dispatchEvent(event); + this._lastValue = this.dataset.value; + } + + /** + * Selects an item in the list by its value + * @private + * @param {string} value The value of the item to select + * @memberof ComboboxFramework + * @returns {void} + */ + selectItemByValue(value: string | null, grabFocus = true): void { + if (!value) return; + if (!this._list) this.fetchList(); + const item = this._list!.querySelector( + `[data-value="${value}"]`, + ) as HTMLElement; + if (!item) return; + this.selectItem(item, grabFocus); + } + + /** + * Clears the input element, focuses it and closes the list + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private clearInput(grabFocus = true): void { + // #region Check if required variables are set + if (!this._input) this.fetchInput(); + // #endregion + + // #region Clear the input element + this._input!.value = ""; + if (grabFocus) this._input!.focus(); + this.toggleList(false); + // #endregion + } + + /** + * Toggles the expanded state of the combobox if the focus is lost + * @param event {FocusEvent} The blur event + * @memberof ComboboxFramework + * @returns {void} + */ + private handleBlur(): void { + // Set a timeout to force the focus event on the list item to fire before the foucsout event on the input element + setTimeout(() => { + if (this.querySelector(":focus")) return; + + // #region If forceValue is true, select the first item in the list + this.forceValue(); + // #endregion + + this.toggleList(false); + }, 0); + } + + /** + * Forces the value of the input element to the first item in the list if the input element is not empty + * @private + * @memberof ComboboxFramework + * @returns {void} + */ + private forceValue(): void { + // #region Check if required variables are set + if (!this._input) this.fetchInput(); + if (!this._list) this.fetchList(); + // #endregion + + // #region If forceValue is true and we don't have a value selected, select the first item (best match) in the list or empty the input and value + if (this._forceValue && !!this._input?.value && !this.dataset.value) { + const bestMatch = this._list!.children[0] as HTMLElement; + if (bestMatch) this.selectItem(bestMatch, false); + else { + this.clearInput(false); // Clear the input + this.dataset.value = ""; // Clear the value + this.sendChangeEvent(); // Send a change event + } + } + // #endregion + } + + /** + * Handles the key press event on the input element + * @param event {KeyboardEvent} The key press event + * @memberof ComboboxFramework + * @returns {void} + * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#keyboardinteraction + */ + private handleComboBoxKeyPress(event: KeyboardEvent): void { + // #region Check if required variables are set + if (!this._input) this.fetchInput(); + if (!this._list) this.fetchList(); + // #endregion + + // #region Handle the key press + switch (event.key) { + case "ArrowDown": + // If the popup is available, moves focus into the popup: If the autocomplete behavior automatically selected a suggestion before Down Arrow was pressed, focus is placed on the suggestion following the automatically selected suggestion. Otherwise, places focus on the first focusable element in the popup. + if (this._input!.getAttribute("aria-expanded") !== "true") { + this.toggleList(true); + if (!this._isAltModifierPressed) + this.focusItem(this._list!.children[0] as HTMLElement); + } else { + this.focusItem(this._list!.children[0] as HTMLElement); + } + event.preventDefault(); // prevent scrolling + break; + case "UpArrow": + // (Optional): If the popup is available, places focus on the last focusable element in the popup. + if (this._input!.getAttribute("aria-expanded") !== "true") { + this.toggleList(true); + this.focusItem( + this._list!.children[ + this._list!.children.length - 1 + ] as HTMLElement, + ); + } + event.preventDefault(); // prevent scrolling + break; + case "Escape": + // Dismisses the popup if it is visible. Optionally, if the popup is hidden before Escape is pressed, clears the combobox. + if (this._input!.getAttribute("aria-expanded") === "true") { + this.toggleList(false); + } else { + this._input!.value = ""; + } + this._input!.focus(); + break; + case "Enter": + // Autocompletes the combobox with the first suggestion + if (this._input!.getAttribute("aria-expanded") === "true") { + this.selectItem(this._list!.children[0] as HTMLElement); + } + break; + case "Alt": + this._isAltModifierPressed = true; + break; + } + // #endregion + } + + /** + * Handles the key press event on the list element + * @param event {KeyboardEvent} The key press event + * @memberof ComboboxFramework + * @returns {void} + * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/#keyboardinteraction + */ + private handleListKeyPress(event: KeyboardEvent): void { + // #region Check if required variables are set + if (!this._input) this.fetchInput(); + if (!this._list) this.fetchList(); + // #endregion + + // #region Handle the key press + const li = event.target as HTMLElement; + switch (event.key) { + case "Enter": + // Select the item and close the list + this.selectItem(li); + break; + case "Escape": + // Close the list and focus the input + this.clearInput(); + break; + case "ArrowDown": { + // Move focus to the next item in the list + const nextLi = li.nextElementSibling as HTMLElement; + if (nextLi) this.focusItem(nextLi); + else this.focusItem(this._list!.children[0] as HTMLElement); + event.preventDefault(); // prevent scrolling + break; + } + case "ArrowUp": { + // If alt is pressed, close the list and focus the input + if (this._isAltModifierPressed) { + this._input!.focus(); + this.toggleList(false); + event.preventDefault(); // prevent scrolling + break; + } + + // Move focus to the previous item in the list + const previousLi = li.previousElementSibling as HTMLElement; + if (previousLi) this.focusItem(previousLi); + else + this.focusItem( + this._list!.children[ + this._list!.children.length - 1 + ] as HTMLElement, + ); + event.preventDefault(); // prevent scrolling + break; + } + case "ArrowRight": + // returns focus to the combobox without closing the popup + this._input!.focus(); + break; + case "ArrowLeft": + // returns focus to the combobox without closing the popup + this._input!.focus(); + break; + case "Home": + // Move focus to the first item in the list + this._input!.focus(); + break; + case "End": + // Move focus to the last item in the list + this._input!.focus(); + break; + case "Backspace": + // Move focus to the last item in the list + this._input!.focus(); + break; + case "Delete": + // Move focus to the last item in the list + this._input!.focus(); + break; + case "Alt": + this._isAltModifierPressed = true; + break; + default: + // If the key is not handled, return focus to the input + this._input!.focus(); + break; + } + // #endregion + } + + /** + * Handles the key up event on the input element and list element + * @param event {KeyboardEvent} The key up event + * @memberof ComboboxFramework + * @returns {void} + */ + private handleKeyUp(event: KeyboardEvent): void { + // #region Handle the key press + switch (event.key) { + case "Alt": + this._isAltModifierPressed = false; + break; + } + // #endregion + } +} + +// #region Register the component +customElements.define("combobox-framework", ComboboxFramework); +// #endregion diff --git a/vite.config.js b/vite.config.js index dd3d2b5..dec38b2 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,8 @@ -export default { - root: "", - base: "./", - build: { - outDir: "docs", - emptyOutDir: true, - }, -}; +export default { + root: "", + base: "./", + build: { + outDir: "docs", + emptyOutDir: true, + }, +};