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 {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
 */

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

    // 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);
    instance = this.component.dataset.instance;
    toggle = this.component.querySelector(this.selectors.toggle);
    searchInput = this.component.querySelector(this.selectors.input);
    searchDropdown = this.component.querySelector(this.selectors.dropdown);
    clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
    combobox = this.component.querySelector(this.selectors.combobox);
    $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();

        // Conditionally set up the input handler since we don't know exactly how we were called.
        // If the combobox is rendered later, then you'll need to call this.registerInputHandlers() manually.
        // An example of this is the collapse columns in the gradebook.
        if (this.searchInput !== null) {
            this.registerInputHandlers();
            this.registerChangeHandlers();
        }

        // If we have a search term, show the clear button.
        if (this.getSearchTerm() !== '') {
            this.clearSearchButton.classList.remove('d-none');
        }
    }

    /**
     * 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.
     * @deprecated since Moodle 4.4
     */
    triggerSelector() {
        window.console.warning('triggerSelector() is deprecated. Consider using this.selectors.toggle');
    }

    /**
     * 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();
        if (clear) {
            // Hide the "clear" search button search bar.
            this.clearSearchButton.classList.add('d-none');
            // 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) {
        if (on) {
            $(this.toggle).dropdown('show');
        } else {
            $(this.toggle).dropdown('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 change event listeners.
     */
    registerChangeHandlers() {
        const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
        valueElement.addEventListener('change', this.changeHandler.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);

            const pendingPromise = new Pending();
            if (this.getSearchTerm() === '') {
                this.toggleDropdown();
                this.clearSearchButton.classList.add('d-none');
                await this.filterrenderpipe();
            } else {
                this.clearSearchButton.classList.remove('d-none');
                await this.renderAndShow();
            }
            pendingPromise.resolve();
        }, 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);
    }

    /**
     * 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();
        // The "clear search" button is triggered.
        if (e.target.closest(this.selectors.clearSearch)) {
            this.closeSearch(true);
            this.searchInput.focus();
            // Remove aria-activedescendant when the available options change.
            this.searchInput.removeAttribute('aria-activedescendant');
        }
        // User may have accidentally clicked off the dropdown and wants to reopen it.
        if (
            this.getSearchTerm() !== ''
            && !this.getHTMLElements().searchDropdown.classList.contains('show')
            && e.target.closest(this.selectors.input)
        ) {
            await this.renderAndShow();
        }
    }

    /**
     * The handler for when a user changes the value of the component (selects an option from the dropdown).
     *
     * @param {Event} e The change event.
     */
    // eslint-disable-next-line no-unused-vars
    changeHandler(e) {
        // Components may override this method to do something.
    }
}