lib/amd/src/comboboxsearch/search_combobox.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

import $ from 'jquery';
import CustomEvents from "core/custom_interaction_events";
import {debounce} from 'core/utils';
import Pending from 'core/pending';

/**
 * The class that manages the state of the search within a combobox.
 *
 * @module    core/comboboxsearch/search_combobox
 * @copyright 2023 Mathew May <mathew.solutions>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
// Reused variables for the class.
const events = [
    'keydown',
    CustomEvents.events.activate,
    CustomEvents.events.keyboardActivate
];
const UP = -1;
const DOWN = 1;

export default class {
    // Define our standard lookups.
    selectors = {
        component: this.componentSelector(),
        trigger: this.triggerSelector(),
        input: '[data-action="search"]',
        clearSearch: '[data-action="clearsearch"]',
        dropdown: this.dropdownSelector(),
        resultitems: '[role="option"]',
        viewall: '#select-all',
    };

    // The results from the called filter function.
    matchedResults = [];

    // What did the user search for?
    searchTerm = '';

    // What the user searched for as a lowercase.
    preppedSearchTerm = null;

    // The DOM nodes after the dropdown render.
    resultNodes = [];

    // Where does the user currently have focus?
    currentNode = null;

    // The current node for the view all link.
    currentViewAll = null;

    dataset = null;

    datasetSize = 0;

    // DOM nodes that persist.
    component = document.querySelector(this.selectors.component);
    searchInput = this.component.querySelector(this.selectors.input);
    searchDropdown = this.component.querySelector(this.selectors.dropdown);
    clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
    $component = $(this.component);

    constructor() {
        // If we have a search input, try to get the value otherwise fallback.
        this.setSearchTerms(this.searchInput?.value ?? '');
        // Begin handling the base search component.
        this.registerClickHandlers();
        this.registerKeyHandlers();
        // Conditionally set up the input handler since we don't know exactly how we were called.
        if (this.searchInput !== null) {
            this.registerInputHandlers();
        }
    }

    /**
     * Stub out a required function.
     */
    fetchDataset() {
        throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);
    }

    /**
     * Stub out a required function.
     * @param {Array} dataset
     */
    filterDataset(dataset) {
        throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);
    }

    /**
     * Stub out a required function.
     */
    filterMatchDataset() {
        throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);
    }

    /**
     * Stub out a required function.
     */
    renderDropdown() {
        throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);
    }

    /**
     * Stub out a required function.
     */
    componentSelector() {
        throw new Error(`componentSelector() must be implemented in ${this.constructor.name}`);
    }

    /**
     * Stub out a required function.
     */
    dropdownSelector() {
        throw new Error(`dropdownSelector() must be implemented in ${this.constructor.name}`);
    }

    /**
     * Stub out a required function.
     */
    triggerSelector() {
        throw new Error(`triggerSelector() must be implemented in ${this.constructor.name}`);
    }

    /**
     * Return the dataset that we will be searching upon.
     *
     * @returns {Promise<null>}
     */
    async getDataset() {
        if (!this.dataset) {
            this.dataset = await this.fetchDataset();
        }
        this.datasetSize = this.dataset.length;
        return this.dataset;
    }

    /**
     * Return the size of the dataset.
     *
     * @returns {number}
     */
    getDatasetSize() {
        return this.datasetSize;
    }

    /**
     * Return the results of the filter upon the dataset.
     *
     * @returns {Array}
     */
    getMatchedResults() {
        return this.matchedResults;
    }

    /**
     * Given a filter has been run across the dataset, store the matched results.
     *
     * @param {Array} result
     */
    setMatchedResults(result) {
        this.matchedResults = result;
    }

    /**
     * Get the value that the user entered.
     *
     * @returns {string}
     */
    getSearchTerm() {
        return this.searchTerm;
    }

    /**
     * Get the transformed search value.
     *
     * @returns {string}
     */
    getPreppedSearchTerm() {
        return this.preppedSearchTerm;
    }

    /**
     * When a user searches for something, set our variable to manage it.
     *
     * @param {string} result
     */
    setSearchTerms(result) {
        this.searchTerm = result;
        this.preppedSearchTerm = result.toLowerCase();
    }

    /**
     * Return an object containing a handfull of dom nodes that we sometimes need the value of.
     *
     * @returns {object}
     */
    getHTMLElements() {
        this.updateNodes();
        return {
            searchDropdown: this.searchDropdown,
            currentViewAll: this.currentViewAll,
            searchInput: this.searchInput,
            clearSearchButton: this.clearSearchButton,
            trigger: this.component.querySelector(this.selectors.trigger),
        };
    }

    /**
     * When called, close the dropdown and reset the input field attributes.
     *
     * @param {Boolean} clear Conditionality clear the input box.
     */
    closeSearch(clear = false) {
        this.toggleDropdown();
        // Hide the "clear" search button search bar.
        this.clearSearchButton.classList.add('d-none');
        if (clear) {
            // Clear the entered search query in the search bar and hide the search results container.
            this.setSearchTerms('');
            this.searchInput.value = "";
        }
    }

    /**
     * Check whether search results are currently visible.
     *
     * @returns {Boolean}
     */
    searchResultsVisible() {
        const {searchDropdown} = this.getHTMLElements();
        // If a Node is not visible, then the offsetParent is null.
        return searchDropdown.offsetParent !== null;
    }

    /**
     * When called, update the dropdown fields.
     *
     * @param {Boolean} on Flag to toggle hiding or showing values.
     */
    toggleDropdown(on = false) {
        this.$component.dropdown('toggle');
        if (on) {
            this.searchDropdown.classList.add('show');
            $(this.searchDropdown).show();
        } else {
            this.searchDropdown.classList.remove('show');
            $(this.searchDropdown).hide();
        }
    }

    /**
     * These class members change when a new result set is rendered. So update for fresh data.
     */
    updateNodes() {
        this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];
        this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);
        this.currentViewAll = this.component.querySelector(this.selectors.viewall);
        this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
        this.searchInput = this.component.querySelector(this.selectors.input);
        this.searchDropdown = this.component.querySelector(this.selectors.dropdown);
    }

    /**
     * Register clickable event listeners.
     */
    registerClickHandlers() {
        // Register click events within the component.
        this.component.addEventListener('click', this.clickHandler.bind(this));
    }

    /**
     * Register key event listeners.
     */
    registerKeyHandlers() {
        CustomEvents.define(document, events);

        // Register click events.
        events.forEach((event) => {
            this.component.addEventListener(event, this.keyHandler.bind(this));
        });
    }

    /**
     * Register input event listener for the text input area.
     */
    registerInputHandlers() {
        // Register & handle the text input.
        this.searchInput.addEventListener('input', debounce(async() => {
            if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {
                window.console.warn(`Search term matches input value - skipping`);
                // The debounce canhappen multiple times quickly. GRrargh
                return;
            }
            this.setSearchTerms(this.searchInput.value);
            // We can also require a set amount of input before search.
            if (this.getSearchTerm() === '') {
                this.toggleDropdown();
                // Hide the "clear" search button in the search bar.
                this.clearSearchButton.classList.add('d-none');
            } else {
                const pendingPromise = new Pending();
                await this.renderAndShow().then(() => {
                    // Display the "clear" search button in the search bar.
                    this.clearSearchButton.classList.remove('d-none');
                    return;
                }).then(() => {
                    pendingPromise.resolve();
                    return true;
                });
            }
        }, 300, {pending: true}));
    }

    /**
     * Update any changeable nodes, filter and then render the result.
     *
     * @returns {Promise<void>}
     */
    async filterrenderpipe() {
        this.updateNodes();
        this.setMatchedResults(await this.filterDataset(await this.getDataset()));
        this.filterMatchDataset();
        await this.renderDropdown();
    }

    /**
     * A combo method to take the matching fields and render out the results.
     *
     * @returns {Promise<void>}
     */
    async renderAndShow() {
        // User has given something for us to filter against.
        this.setMatchedResults(await this.filterDataset(await this.getDataset()));
        await this.filterMatchDataset();
        // Replace the dropdown node contents and show the results.
        await this.renderDropdown();
        // Set the dropdown to open.
        this.toggleDropdown(true);
    }

    /**
     * Set the current focus either on the preceding or next result item.
     *
     * @param {Number} direction Is the user moving up or down the resultset?
     * @param {KeyboardEvent} e The JS event from the event handler.
     */
    keyUpDown(direction, e) {
        e.preventDefault();
        // Stop Bootstrap from being clever.
        e.stopPropagation();
        // Current focus is on the input box so depending on direction, go to the top or the bottom of the displayed results.
        if (document.activeElement === this.searchInput && this.resultNodes.length > 0) {
            if (direction === UP) {
                this.moveToLastNode();
            } else {
                this.moveToFirstNode();
            }
        }
        const index = this.resultNodes.indexOf(this.currentNode);
        if (this.currentNode) {
            if (direction === UP) {
                if (index === 0) {
                    this.moveToLastNode();
                } else {
                    this.moveToNode(index - 1);
                }
            } else {
                if (index + 1 >= this.resultNodes.length) {
                    this.moveToFirstNode();
                } else {
                    this.moveToNode(index + 1);
                }
            }
        } else {
            if (direction === UP) {
                this.moveToLastNode();
            } else {
                this.moveToFirstNode();
            }
        }
    }

    /**
     * The handler for when a user interacts with the component.
     *
     * @param {MouseEvent} e The triggering event that we are working with.
     */
    async clickHandler(e) {
        this.updateNodes();

        // Prevent normal key presses activating this.
        if (e.target.closest('.dropdown-item') && e.button === 0) {
            window.location = e.target.closest('.dropdown-item').href;
        }
        // The "clear search" button is triggered.
        if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {
            this.closeSearch(true);
            this.searchInput.focus({preventScroll: true});
        }
        // User may have accidentally clicked off the dropdown and wants to reopen it.
        if (e.target.closest(this.selectors.input) && this.getSearchTerm() !== '' && e.button === 0) {
            await this.renderAndShow();
        }
    }

    /**
     * The handler for when a user presses a key within the component.
     *
     * @param {KeyboardEvent} e The triggering event that we are working with.
     */
    keyHandler(e) {
        this.updateNodes();
        // Switch the key presses to handle keyboard nav.
        switch (e.key) {
            case 'ArrowUp':
                this.keyUpDown(UP, e);
                break;
            case 'ArrowDown':
                this.keyUpDown(DOWN, e);
                break;
            case 'Home':
                e.preventDefault();
                this.moveToFirstNode();
                break;
            case 'End':
                e.preventDefault();
                this.moveToLastNode();
                break;
            case 'Tab':
                // If the current focus is on the view all link, then close the widget then set focus on the next tertiary nav item.
                if (e.target.closest(this.selectors.viewall)) {
                    this.closeSearch();
                }
                break;
        }
    }

    /**
     * Set focus on a given node after parsed through the calling functions.
     *
     * @param {HTMLElement} node The node to set focus upon.
     */
    selectNode = (node) => {
        node.focus({preventScroll: true});
        this.searchDropdown.scrollTop = node.offsetTop - (node.clientHeight / 2);
    };

    /**
     * Set the focus on the first node within the array.
     */
    moveToFirstNode = () => {
        if (this.resultNodes.length > 0) {
            this.selectNode(this.resultNodes[0]);
        }
    };

    /**
     * Set the focus to the final node within the array.
     */
    moveToLastNode = () => {
        if (this.resultNodes.length > 0) {
            this.selectNode(this.resultNodes[this.resultNodes.length - 1]);
        }
    };

    /**
     * Set focus on any given specified node within the node array.
     *
     * @param {Number} index Which item within the array to set focus upon.
     */
    moveToNode = (index) => {
        if (this.resultNodes.length > 0) {
            this.selectNode(this.resultNodes[index]);
        }
    };
}