Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
import $ from 'jquery';
17
import {debounce} from 'core/utils';
18
import Pending from 'core/pending';
19
 
20
/**
21
 * The class that manages the state of the search within a combobox.
22
 *
23
 * @module    core/comboboxsearch/search_combobox
24
 * @copyright 2023 Mathew May <mathew.solutions>
25
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26
 */
27
 
28
export default class {
29
    // Define our standard lookups.
30
    selectors = {
31
        component: this.componentSelector(),
32
        toggle: '[data-toggle="dropdown"]',
33
        instance: '[data-region="instance"]',
34
        input: '[data-action="search"]',
35
        clearSearch: '[data-action="clearsearch"]',
36
        dropdown: this.dropdownSelector(),
37
        resultitems: '[role="option"]',
38
        viewall: '#select-all',
39
        combobox: '[role="combobox"]',
40
    };
41
 
42
    // The results from the called filter function.
43
    matchedResults = [];
44
 
45
    // What did the user search for?
46
    searchTerm = '';
47
 
48
    // What the user searched for as a lowercase.
49
    preppedSearchTerm = null;
50
 
51
    // The DOM nodes after the dropdown render.
52
    resultNodes = [];
53
 
54
    // Where does the user currently have focus?
55
    currentNode = null;
56
 
57
    // The current node for the view all link.
58
    currentViewAll = null;
59
 
60
    dataset = null;
61
 
62
    datasetSize = 0;
63
 
64
    // DOM nodes that persist.
65
    component = document.querySelector(this.selectors.component);
66
    instance = this.component.dataset.instance;
67
    toggle = this.component.querySelector(this.selectors.toggle);
68
    searchInput = this.component.querySelector(this.selectors.input);
69
    searchDropdown = this.component.querySelector(this.selectors.dropdown);
70
    clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
71
    combobox = this.component.querySelector(this.selectors.combobox);
72
    $component = $(this.component);
73
 
74
    constructor() {
75
        // If we have a search input, try to get the value otherwise fallback.
76
        this.setSearchTerms(this.searchInput?.value ?? '');
77
        // Begin handling the base search component.
78
        this.registerClickHandlers();
79
 
80
        // Conditionally set up the input handler since we don't know exactly how we were called.
81
        // If the combobox is rendered later, then you'll need to call this.registerInputHandlers() manually.
82
        // An example of this is the collapse columns in the gradebook.
83
        if (this.searchInput !== null) {
84
            this.registerInputHandlers();
85
            this.registerChangeHandlers();
86
        }
87
 
88
        // If we have a search term, show the clear button.
89
        if (this.getSearchTerm() !== '') {
90
            this.clearSearchButton.classList.remove('d-none');
91
        }
92
    }
93
 
94
    /**
95
     * Stub out a required function.
96
     */
97
    fetchDataset() {
98
        throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);
99
    }
100
 
101
    /**
102
     * Stub out a required function.
103
     * @param {Array} dataset
104
     */
105
    filterDataset(dataset) {
106
        throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);
107
    }
108
 
109
    /**
110
     * Stub out a required function.
111
     */
112
    filterMatchDataset() {
113
        throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);
114
    }
115
 
116
    /**
117
     * Stub out a required function.
118
     */
119
    renderDropdown() {
120
        throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);
121
    }
122
 
123
    /**
124
     * Stub out a required function.
125
     */
126
    componentSelector() {
127
        throw new Error(`componentSelector() must be implemented in ${this.constructor.name}`);
128
    }
129
 
130
    /**
131
     * Stub out a required function.
132
     */
133
    dropdownSelector() {
134
        throw new Error(`dropdownSelector() must be implemented in ${this.constructor.name}`);
135
    }
136
 
137
    /**
138
     * Stub out a required function.
139
     * @deprecated since Moodle 4.4
140
     */
141
    triggerSelector() {
142
        window.console.warning('triggerSelector() is deprecated. Consider using this.selectors.toggle');
143
    }
144
 
145
    /**
146
     * Return the dataset that we will be searching upon.
147
     *
148
     * @returns {Promise<null>}
149
     */
150
    async getDataset() {
151
        if (!this.dataset) {
152
            this.dataset = await this.fetchDataset();
153
        }
154
        this.datasetSize = this.dataset.length;
155
        return this.dataset;
156
    }
157
 
158
    /**
159
     * Return the size of the dataset.
160
     *
161
     * @returns {number}
162
     */
163
    getDatasetSize() {
164
        return this.datasetSize;
165
    }
166
 
167
    /**
168
     * Return the results of the filter upon the dataset.
169
     *
170
     * @returns {Array}
171
     */
172
    getMatchedResults() {
173
        return this.matchedResults;
174
    }
175
 
176
    /**
177
     * Given a filter has been run across the dataset, store the matched results.
178
     *
179
     * @param {Array} result
180
     */
181
    setMatchedResults(result) {
182
        this.matchedResults = result;
183
    }
184
 
185
    /**
186
     * Get the value that the user entered.
187
     *
188
     * @returns {string}
189
     */
190
    getSearchTerm() {
191
        return this.searchTerm;
192
    }
193
 
194
    /**
195
     * Get the transformed search value.
196
     *
197
     * @returns {string}
198
     */
199
    getPreppedSearchTerm() {
200
        return this.preppedSearchTerm;
201
    }
202
 
203
    /**
204
     * When a user searches for something, set our variable to manage it.
205
     *
206
     * @param {string} result
207
     */
208
    setSearchTerms(result) {
209
        this.searchTerm = result;
210
        this.preppedSearchTerm = result.toLowerCase();
211
    }
212
 
213
    /**
214
     * Return an object containing a handfull of dom nodes that we sometimes need the value of.
215
     *
216
     * @returns {object}
217
     */
218
    getHTMLElements() {
219
        this.updateNodes();
220
        return {
221
            searchDropdown: this.searchDropdown,
222
            currentViewAll: this.currentViewAll,
223
            searchInput: this.searchInput,
224
            clearSearchButton: this.clearSearchButton,
225
            trigger: this.component.querySelector(this.selectors.trigger),
226
        };
227
    }
228
 
229
    /**
230
     * When called, close the dropdown and reset the input field attributes.
231
     *
232
     * @param {Boolean} clear Conditionality clear the input box.
233
     */
234
    closeSearch(clear = false) {
235
        this.toggleDropdown();
236
        if (clear) {
237
            // Hide the "clear" search button search bar.
238
            this.clearSearchButton.classList.add('d-none');
239
            // Clear the entered search query in the search bar and hide the search results container.
240
            this.setSearchTerms('');
241
            this.searchInput.value = "";
242
        }
243
    }
244
 
245
    /**
246
     * Check whether search results are currently visible.
247
     *
248
     * @returns {Boolean}
249
     */
250
    searchResultsVisible() {
251
        const {searchDropdown} = this.getHTMLElements();
252
        // If a Node is not visible, then the offsetParent is null.
253
        return searchDropdown.offsetParent !== null;
254
    }
255
 
256
    /**
257
     * When called, update the dropdown fields.
258
     *
259
     * @param {Boolean} on Flag to toggle hiding or showing values.
260
     */
261
    toggleDropdown(on = false) {
262
        if (on) {
263
            $(this.toggle).dropdown('show');
264
        } else {
265
            $(this.toggle).dropdown('hide');
266
        }
267
    }
268
 
269
    /**
270
     * These class members change when a new result set is rendered. So update for fresh data.
271
     */
272
    updateNodes() {
273
        this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];
274
        this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);
275
        this.currentViewAll = this.component.querySelector(this.selectors.viewall);
276
        this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
277
        this.searchInput = this.component.querySelector(this.selectors.input);
278
        this.searchDropdown = this.component.querySelector(this.selectors.dropdown);
279
    }
280
 
281
    /**
282
     * Register clickable event listeners.
283
     */
284
    registerClickHandlers() {
285
        // Register click events within the component.
286
        this.component.addEventListener('click', this.clickHandler.bind(this));
287
    }
288
 
289
    /**
290
     * Register change event listeners.
291
     */
292
    registerChangeHandlers() {
293
        const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
294
        valueElement.addEventListener('change', this.changeHandler.bind(this));
295
    }
296
 
297
    /**
298
     * Register input event listener for the text input area.
299
     */
300
    registerInputHandlers() {
301
        // Register & handle the text input.
302
        this.searchInput.addEventListener('input', debounce(async() => {
303
            if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {
304
                window.console.warn(`Search term matches input value - skipping`);
305
                // The debounce canhappen multiple times quickly. GRrargh
306
                return;
307
            }
308
            this.setSearchTerms(this.searchInput.value);
309
 
310
            const pendingPromise = new Pending();
311
            if (this.getSearchTerm() === '') {
312
                this.toggleDropdown();
313
                this.clearSearchButton.classList.add('d-none');
314
                await this.filterrenderpipe();
315
            } else {
316
                this.clearSearchButton.classList.remove('d-none');
317
                await this.renderAndShow();
318
            }
319
            pendingPromise.resolve();
320
        }, 300, {pending: true}));
321
    }
322
 
323
    /**
324
     * Update any changeable nodes, filter and then render the result.
325
     *
326
     * @returns {Promise<void>}
327
     */
328
    async filterrenderpipe() {
329
        this.updateNodes();
330
        this.setMatchedResults(await this.filterDataset(await this.getDataset()));
331
        this.filterMatchDataset();
332
        await this.renderDropdown();
333
    }
334
 
335
    /**
336
     * A combo method to take the matching fields and render out the results.
337
     *
338
     * @returns {Promise<void>}
339
     */
340
    async renderAndShow() {
341
        // User has given something for us to filter against.
342
        this.setMatchedResults(await this.filterDataset(await this.getDataset()));
343
        await this.filterMatchDataset();
344
        // Replace the dropdown node contents and show the results.
345
        await this.renderDropdown();
346
        // Set the dropdown to open.
347
        this.toggleDropdown(true);
348
    }
349
 
350
    /**
351
     * The handler for when a user interacts with the component.
352
     *
353
     * @param {MouseEvent} e The triggering event that we are working with.
354
     */
355
    async clickHandler(e) {
356
        this.updateNodes();
357
        // The "clear search" button is triggered.
358
        if (e.target.closest(this.selectors.clearSearch)) {
359
            this.closeSearch(true);
360
            this.searchInput.focus();
361
            // Remove aria-activedescendant when the available options change.
362
            this.searchInput.removeAttribute('aria-activedescendant');
363
        }
364
        // User may have accidentally clicked off the dropdown and wants to reopen it.
365
        if (
366
            this.getSearchTerm() !== ''
367
            && !this.getHTMLElements().searchDropdown.classList.contains('show')
368
            && e.target.closest(this.selectors.input)
369
        ) {
370
            await this.renderAndShow();
371
        }
372
    }
373
 
374
    /**
375
     * The handler for when a user changes the value of the component (selects an option from the dropdown).
376
     *
377
     * @param {Event} e The change event.
378
     */
379
    // eslint-disable-next-line no-unused-vars
380
    changeHandler(e) {
381
        // Components may override this method to do something.
382
    }
383
}