Proyectos de Subversion Moodle

Rev

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