From c7a05345b6279735c0f2532d856c7b0a299c5f4c Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 6 Sep 2023 11:46:31 +0800 Subject: [PATCH 1/3] MDL-78779 core: Wrap combobox debounce in pending Promise This addresses a random failure with the combobox search results where the debounce causes the results to be shown, and then the same search result is returned again, re-rendered, and replaced after Behat has moved on. --- .../comboboxsearch/search_combobox.min.js | 2 +- .../comboboxsearch/search_combobox.min.js.map | 2 +- lib/amd/src/comboboxsearch/search_combobox.js | 14 ++++++- lib/amd/src/utils.js | 40 +++++++++++++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/lib/amd/build/comboboxsearch/search_combobox.min.js b/lib/amd/build/comboboxsearch/search_combobox.min.js index 69e0f5eaa8a..b31c8c31cfe 100644 --- a/lib/amd/build/comboboxsearch/search_combobox.min.js +++ b/lib/amd/build/comboboxsearch/search_combobox.min.js @@ -5,6 +5,6 @@ define("core/comboboxsearch/search_combobox",["exports","jquery","core/custom_in * @module core/comboboxsearch/search_combobox * @copyright 2023 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_pending=_interopRequireDefault(_pending);const events=["keydown",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];return _exports.default=class{constructor(){var _this$searchInput$val,_this$searchInput;_defineProperty(this,"selectors",{component:this.componentSelector(),trigger:this.triggerSelector(),input:'[data-action="search"]',clearSearch:'[data-action="clearsearch"]',dropdown:this.dropdownSelector(),resultitems:'[role="option"]',viewall:"#select-all"}),_defineProperty(this,"matchedResults",[]),_defineProperty(this,"searchTerm",""),_defineProperty(this,"preppedSearchTerm",null),_defineProperty(this,"resultNodes",[]),_defineProperty(this,"currentNode",null),_defineProperty(this,"currentViewAll",null),_defineProperty(this,"dataset",null),_defineProperty(this,"datasetSize",0),_defineProperty(this,"component",document.querySelector(this.selectors.component)),_defineProperty(this,"searchInput",this.component.querySelector(this.selectors.input)),_defineProperty(this,"searchDropdown",this.component.querySelector(this.selectors.dropdown)),_defineProperty(this,"clearSearchButton",this.component.querySelector(this.selectors.clearSearch)),_defineProperty(this,"$component",(0,_jquery.default)(this.component)),_defineProperty(this,"selectNode",(node=>{node.focus({preventScroll:!0}),this.searchDropdown.scrollTop=node.offsetTop-node.clientHeight/2})),_defineProperty(this,"moveToFirstNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[0])})),_defineProperty(this,"moveToLastNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[this.resultNodes.length-1])})),_defineProperty(this,"moveToNode",(index=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[index])})),this.setSearchTerms(null!==(_this$searchInput$val=null===(_this$searchInput=this.searchInput)||void 0===_this$searchInput?void 0:_this$searchInput.value)&&void 0!==_this$searchInput$val?_this$searchInput$val:""),this.registerClickHandlers(),this.registerKeyHandlers(),null!==this.searchInput&&this.registerInputHandlers()}fetchDataset(){throw new Error("fetchDataset() must be implemented in ".concat(this.constructor.name))}filterDataset(dataset){throw new Error("filterDataset(".concat(dataset,") must be implemented in ").concat(this.constructor.name))}filterMatchDataset(){throw new Error("filterMatchDataset() must be implemented in ".concat(this.constructor.name))}renderDropdown(){throw new Error("renderDropdown() must be implemented in ".concat(this.constructor.name))}componentSelector(){throw new Error("componentSelector() must be implemented in ".concat(this.constructor.name))}dropdownSelector(){throw new Error("dropdownSelector() must be implemented in ".concat(this.constructor.name))}triggerSelector(){throw new Error("triggerSelector() must be implemented in ".concat(this.constructor.name))}async getDataset(){return this.dataset||(this.dataset=await this.fetchDataset()),this.datasetSize=this.dataset.length,this.dataset}getDatasetSize(){return this.datasetSize}getMatchedResults(){return this.matchedResults}setMatchedResults(result){this.matchedResults=result}getSearchTerm(){return this.searchTerm}getPreppedSearchTerm(){return this.preppedSearchTerm}setSearchTerms(result){this.searchTerm=result,this.preppedSearchTerm=result.toLowerCase()}getHTMLElements(){return this.updateNodes(),{searchDropdown:this.searchDropdown,currentViewAll:this.currentViewAll,searchInput:this.searchInput,clearSearchButton:this.clearSearchButton,trigger:this.component.querySelector(this.selectors.trigger)}}closeSearch(){let clear=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.toggleDropdown(),this.clearSearchButton.classList.add("d-none"),clear&&(this.setSearchTerms(""),this.searchInput.value="")}toggleDropdown(){let on=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.$component.dropdown("toggle"),on?(this.searchDropdown.classList.add("show"),(0,_jquery.default)(this.searchDropdown).show()):(this.searchDropdown.classList.remove("show"),(0,_jquery.default)(this.searchDropdown).hide())}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)}registerClickHandlers(){this.component.addEventListener("click",this.clickHandler.bind(this))}registerKeyHandlers(){_custom_interaction_events.default.define(document,events),events.forEach((event=>{this.component.addEventListener(event,this.keyHandler.bind(this))}))}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{if(this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm())this.toggleDropdown(),this.clearSearchButton.classList.add("d-none");else{const pendingPromise=new _pending.default;await this.renderAndShow().then((()=>{this.clearSearchButton.classList.remove("d-none")})).then((()=>(pendingPromise.resolve(),!0)))}}),300))}async filterrenderpipe(){this.updateNodes(),this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown()}async renderAndShow(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),await this.filterMatchDataset(),await this.renderDropdown(),this.toggleDropdown(!0)}keyUpDown(direction,e){e.preventDefault(),e.stopPropagation(),document.activeElement===this.searchInput&&this.resultNodes.length>0&&(-1===direction?this.moveToLastNode():this.moveToFirstNode());const index=this.resultNodes.indexOf(this.currentNode);this.currentNode?-1===direction?0===index?this.moveToLastNode():this.moveToNode(index-1):index+1>=this.resultNodes.length?this.moveToFirstNode():this.moveToNode(index+1):-1===direction?this.moveToLastNode():this.moveToFirstNode()}async clickHandler(e){this.updateNodes(),e.target.closest(".dropdown-item")&&0===e.button&&(window.location=e.target.closest(".dropdown-item").href),e.target.closest(this.selectors.clearSearch)&&0===e.button&&(this.closeSearch(!0),this.searchInput.focus({preventScroll:!0})),e.target.closest(this.selectors.input)&&""!==this.getSearchTerm()&&0===e.button&&await this.renderAndShow()}keyHandler(e){switch(this.updateNodes(),e.key){case"ArrowUp":this.keyUpDown(-1,e);break;case"ArrowDown":this.keyUpDown(1,e);break;case"Home":e.preventDefault(),this.moveToFirstNode();break;case"End":e.preventDefault(),this.moveToLastNode();break;case"Tab":e.target.closest(this.selectors.viewall)&&this.closeSearch()}}},_exports.default})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_pending=_interopRequireDefault(_pending);const events=["keydown",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];return _exports.default=class{constructor(){var _this$searchInput$val,_this$searchInput;_defineProperty(this,"selectors",{component:this.componentSelector(),trigger:this.triggerSelector(),input:'[data-action="search"]',clearSearch:'[data-action="clearsearch"]',dropdown:this.dropdownSelector(),resultitems:'[role="option"]',viewall:"#select-all"}),_defineProperty(this,"matchedResults",[]),_defineProperty(this,"searchTerm",""),_defineProperty(this,"preppedSearchTerm",null),_defineProperty(this,"resultNodes",[]),_defineProperty(this,"currentNode",null),_defineProperty(this,"currentViewAll",null),_defineProperty(this,"dataset",null),_defineProperty(this,"datasetSize",0),_defineProperty(this,"component",document.querySelector(this.selectors.component)),_defineProperty(this,"searchInput",this.component.querySelector(this.selectors.input)),_defineProperty(this,"searchDropdown",this.component.querySelector(this.selectors.dropdown)),_defineProperty(this,"clearSearchButton",this.component.querySelector(this.selectors.clearSearch)),_defineProperty(this,"$component",(0,_jquery.default)(this.component)),_defineProperty(this,"selectNode",(node=>{node.focus({preventScroll:!0}),this.searchDropdown.scrollTop=node.offsetTop-node.clientHeight/2})),_defineProperty(this,"moveToFirstNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[0])})),_defineProperty(this,"moveToLastNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[this.resultNodes.length-1])})),_defineProperty(this,"moveToNode",(index=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[index])})),this.setSearchTerms(null!==(_this$searchInput$val=null===(_this$searchInput=this.searchInput)||void 0===_this$searchInput?void 0:_this$searchInput.value)&&void 0!==_this$searchInput$val?_this$searchInput$val:""),this.registerClickHandlers(),this.registerKeyHandlers(),null!==this.searchInput&&this.registerInputHandlers()}fetchDataset(){throw new Error("fetchDataset() must be implemented in ".concat(this.constructor.name))}filterDataset(dataset){throw new Error("filterDataset(".concat(dataset,") must be implemented in ").concat(this.constructor.name))}filterMatchDataset(){throw new Error("filterMatchDataset() must be implemented in ".concat(this.constructor.name))}renderDropdown(){throw new Error("renderDropdown() must be implemented in ".concat(this.constructor.name))}componentSelector(){throw new Error("componentSelector() must be implemented in ".concat(this.constructor.name))}dropdownSelector(){throw new Error("dropdownSelector() must be implemented in ".concat(this.constructor.name))}triggerSelector(){throw new Error("triggerSelector() must be implemented in ".concat(this.constructor.name))}async getDataset(){return this.dataset||(this.dataset=await this.fetchDataset()),this.datasetSize=this.dataset.length,this.dataset}getDatasetSize(){return this.datasetSize}getMatchedResults(){return this.matchedResults}setMatchedResults(result){this.matchedResults=result}getSearchTerm(){return this.searchTerm}getPreppedSearchTerm(){return this.preppedSearchTerm}setSearchTerms(result){this.searchTerm=result,this.preppedSearchTerm=result.toLowerCase()}getHTMLElements(){return this.updateNodes(),{searchDropdown:this.searchDropdown,currentViewAll:this.currentViewAll,searchInput:this.searchInput,clearSearchButton:this.clearSearchButton,trigger:this.component.querySelector(this.selectors.trigger)}}closeSearch(){let clear=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.toggleDropdown(),this.clearSearchButton.classList.add("d-none"),clear&&(this.setSearchTerms(""),this.searchInput.value="")}searchResultsVisible(){const{searchDropdown:searchDropdown}=this.getHTMLElements();return null!==searchDropdown.offsetParent}toggleDropdown(){let on=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.$component.dropdown("toggle"),on?(this.searchDropdown.classList.add("show"),(0,_jquery.default)(this.searchDropdown).show()):(this.searchDropdown.classList.remove("show"),(0,_jquery.default)(this.searchDropdown).hide())}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)}registerClickHandlers(){this.component.addEventListener("click",this.clickHandler.bind(this))}registerKeyHandlers(){_custom_interaction_events.default.define(document,events),events.forEach((event=>{this.component.addEventListener(event,this.keyHandler.bind(this))}))}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{if(this.getSearchTerm()===this.searchInput.value&&this.searchResultsVisible())window.console.warn("Search term matches input value - skipping");else if(this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm())this.toggleDropdown(),this.clearSearchButton.classList.add("d-none");else{const pendingPromise=new _pending.default;await this.renderAndShow().then((()=>{this.clearSearchButton.classList.remove("d-none")})).then((()=>(pendingPromise.resolve(),!0)))}}),300))}async filterrenderpipe(){this.updateNodes(),this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown()}async renderAndShow(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),await this.filterMatchDataset(),await this.renderDropdown(),this.toggleDropdown(!0)}keyUpDown(direction,e){e.preventDefault(),e.stopPropagation(),document.activeElement===this.searchInput&&this.resultNodes.length>0&&(-1===direction?this.moveToLastNode():this.moveToFirstNode());const index=this.resultNodes.indexOf(this.currentNode);this.currentNode?-1===direction?0===index?this.moveToLastNode():this.moveToNode(index-1):index+1>=this.resultNodes.length?this.moveToFirstNode():this.moveToNode(index+1):-1===direction?this.moveToLastNode():this.moveToFirstNode()}async clickHandler(e){this.updateNodes(),e.target.closest(".dropdown-item")&&0===e.button&&(window.location=e.target.closest(".dropdown-item").href),e.target.closest(this.selectors.clearSearch)&&0===e.button&&(this.closeSearch(!0),this.searchInput.focus({preventScroll:!0})),e.target.closest(this.selectors.input)&&""!==this.getSearchTerm()&&0===e.button&&await this.renderAndShow()}keyHandler(e){switch(this.updateNodes(),e.key){case"ArrowUp":this.keyUpDown(-1,e);break;case"ArrowDown":this.keyUpDown(1,e);break;case"Home":e.preventDefault(),this.moveToFirstNode();break;case"End":e.preventDefault(),this.moveToLastNode();break;case"Tab":e.target.closest(this.selectors.viewall)&&this.closeSearch()}}},_exports.default})); //# sourceMappingURL=search_combobox.min.js.map \ No newline at end of file diff --git a/lib/amd/build/comboboxsearch/search_combobox.min.js.map b/lib/amd/build/comboboxsearch/search_combobox.min.js.map index e44c108b31b..4d346512970 100644 --- a/lib/amd/build/comboboxsearch/search_combobox.min.js.map +++ b/lib/amd/build/comboboxsearch/search_combobox.min.js.map @@ -1 +1 @@ -{"version":3,"file":"search_combobox.min.js","sources":["../../src/comboboxsearch/search_combobox.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport $ from 'jquery';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport {debounce} from 'core/utils';\nimport Pending from 'core/pending';\n\n/**\n * The class that manages the state of the search within a combobox.\n *\n * @module core/comboboxsearch/search_combobox\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n// Reused variables for the class.\nconst events = [\n 'keydown',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n];\nconst UP = -1;\nconst DOWN = 1;\n\nexport default class {\n // Define our standard lookups.\n selectors = {\n component: this.componentSelector(),\n trigger: this.triggerSelector(),\n input: '[data-action=\"search\"]',\n clearSearch: '[data-action=\"clearsearch\"]',\n dropdown: this.dropdownSelector(),\n resultitems: '[role=\"option\"]',\n viewall: '#select-all',\n };\n\n // The results from the called filter function.\n matchedResults = [];\n\n // What did the user search for?\n searchTerm = '';\n\n // What the user searched for as a lowercase.\n preppedSearchTerm = null;\n\n // The DOM nodes after the dropdown render.\n resultNodes = [];\n\n // Where does the user currently have focus?\n currentNode = null;\n\n // The current node for the view all link.\n currentViewAll = null;\n\n dataset = null;\n\n datasetSize = 0;\n\n // DOM nodes that persist.\n component = document.querySelector(this.selectors.component);\n searchInput = this.component.querySelector(this.selectors.input);\n searchDropdown = this.component.querySelector(this.selectors.dropdown);\n clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n $component = $(this.component);\n\n constructor() {\n // If we have a search input, try to get the value otherwise fallback.\n this.setSearchTerms(this.searchInput?.value ?? '');\n // Begin handling the base search component.\n this.registerClickHandlers();\n this.registerKeyHandlers();\n // Conditionally set up the input handler since we don't know exactly how we were called.\n if (this.searchInput !== null) {\n this.registerInputHandlers();\n }\n }\n\n /**\n * Stub out a required function.\n */\n fetchDataset() {\n throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n * @param {Array} dataset\n */\n filterDataset(dataset) {\n throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n filterMatchDataset() {\n throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n renderDropdown() {\n throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n componentSelector() {\n throw new Error(`componentSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n dropdownSelector() {\n throw new Error(`dropdownSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n triggerSelector() {\n throw new Error(`triggerSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Return the dataset that we will be searching upon.\n *\n * @returns {Promise}\n */\n async getDataset() {\n if (!this.dataset) {\n this.dataset = await this.fetchDataset();\n }\n this.datasetSize = this.dataset.length;\n return this.dataset;\n }\n\n /**\n * Return the size of the dataset.\n *\n * @returns {number}\n */\n getDatasetSize() {\n return this.datasetSize;\n }\n\n /**\n * Return the results of the filter upon the dataset.\n *\n * @returns {Array}\n */\n getMatchedResults() {\n return this.matchedResults;\n }\n\n /**\n * Given a filter has been run across the dataset, store the matched results.\n *\n * @param {Array} result\n */\n setMatchedResults(result) {\n this.matchedResults = result;\n }\n\n /**\n * Get the value that the user entered.\n *\n * @returns {string}\n */\n getSearchTerm() {\n return this.searchTerm;\n }\n\n /**\n * Get the transformed search value.\n *\n * @returns {string}\n */\n getPreppedSearchTerm() {\n return this.preppedSearchTerm;\n }\n\n /**\n * When a user searches for something, set our variable to manage it.\n *\n * @param {string} result\n */\n setSearchTerms(result) {\n this.searchTerm = result;\n this.preppedSearchTerm = result.toLowerCase();\n }\n\n /**\n * Return an object containing a handfull of dom nodes that we sometimes need the value of.\n *\n * @returns {object}\n */\n getHTMLElements() {\n this.updateNodes();\n return {\n searchDropdown: this.searchDropdown,\n currentViewAll: this.currentViewAll,\n searchInput: this.searchInput,\n clearSearchButton: this.clearSearchButton,\n trigger: this.component.querySelector(this.selectors.trigger),\n };\n }\n\n /**\n * When called, close the dropdown and reset the input field attributes.\n *\n * @param {Boolean} clear Conditionality clear the input box.\n */\n closeSearch(clear = false) {\n this.toggleDropdown();\n // Hide the \"clear\" search button search bar.\n this.clearSearchButton.classList.add('d-none');\n if (clear) {\n // Clear the entered search query in the search bar and hide the search results container.\n this.setSearchTerms('');\n this.searchInput.value = \"\";\n }\n }\n\n /**\n * When called, update the dropdown fields.\n *\n * @param {Boolean} on Flag to toggle hiding or showing values.\n */\n toggleDropdown(on = false) {\n this.$component.dropdown('toggle');\n if (on) {\n this.searchDropdown.classList.add('show');\n $(this.searchDropdown).show();\n } else {\n this.searchDropdown.classList.remove('show');\n $(this.searchDropdown).hide();\n }\n }\n\n /**\n * These class members change when a new result set is rendered. So update for fresh data.\n */\n updateNodes() {\n this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];\n this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);\n this.currentViewAll = this.component.querySelector(this.selectors.viewall);\n this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n this.searchInput = this.component.querySelector(this.selectors.input);\n this.searchDropdown = this.component.querySelector(this.selectors.dropdown);\n }\n\n /**\n * Register clickable event listeners.\n */\n registerClickHandlers() {\n // Register click events within the component.\n this.component.addEventListener('click', this.clickHandler.bind(this));\n }\n\n /**\n * Register key event listeners.\n */\n registerKeyHandlers() {\n CustomEvents.define(document, events);\n\n // Register click events.\n events.forEach((event) => {\n this.component.addEventListener(event, this.keyHandler.bind(this));\n });\n }\n\n /**\n * Register input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n this.toggleDropdown();\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n const pendingPromise = new Pending();\n await this.renderAndShow().then(() => {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n }).then(() => {\n pendingPromise.resolve();\n return true;\n });\n }\n }, 300));\n }\n\n /**\n * Update any changeable nodes, filter and then render the result.\n *\n * @returns {Promise}\n */\n async filterrenderpipe() {\n this.updateNodes();\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n await this.renderDropdown();\n }\n\n /**\n * A combo method to take the matching fields and render out the results.\n *\n * @returns {Promise}\n */\n async renderAndShow() {\n // User has given something for us to filter against.\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n await this.filterMatchDataset();\n // Replace the dropdown node contents and show the results.\n await this.renderDropdown();\n // Set the dropdown to open.\n this.toggleDropdown(true);\n }\n\n /**\n * Set the current focus either on the preceding or next result item.\n *\n * @param {Number} direction Is the user moving up or down the resultset?\n * @param {KeyboardEvent} e The JS event from the event handler.\n */\n keyUpDown(direction, e) {\n e.preventDefault();\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n // Current focus is on the input box so depending on direction, go to the top or the bottom of the displayed results.\n if (document.activeElement === this.searchInput && this.resultNodes.length > 0) {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n const index = this.resultNodes.indexOf(this.currentNode);\n if (this.currentNode) {\n if (direction === UP) {\n if (index === 0) {\n this.moveToLastNode();\n } else {\n this.moveToNode(index - 1);\n }\n } else {\n if (index + 1 >= this.resultNodes.length) {\n this.moveToFirstNode();\n } else {\n this.moveToNode(index + 1);\n }\n }\n } else {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n this.updateNodes();\n\n // Prevent normal key presses activating this.\n if (e.target.closest('.dropdown-item') && e.button === 0) {\n window.location = e.target.closest('.dropdown-item').href;\n }\n // The \"clear search\" button is triggered.\n if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {\n this.closeSearch(true);\n this.searchInput.focus({preventScroll: true});\n }\n // User may have accidentally clicked off the dropdown and wants to reopen it.\n if (e.target.closest(this.selectors.input) && this.getSearchTerm() !== '' && e.button === 0) {\n await this.renderAndShow();\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n this.updateNodes();\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'ArrowUp':\n this.keyUpDown(UP, e);\n break;\n case 'ArrowDown':\n this.keyUpDown(DOWN, e);\n break;\n case 'Home':\n e.preventDefault();\n this.moveToFirstNode();\n break;\n case 'End':\n e.preventDefault();\n this.moveToLastNode();\n break;\n case 'Tab':\n // If the current focus is on the view all link, then close the widget then set focus on the next tertiary nav item.\n if (e.target.closest(this.selectors.viewall)) {\n this.closeSearch();\n }\n break;\n }\n }\n\n /**\n * Set focus on a given node after parsed through the calling functions.\n *\n * @param {HTMLElement} node The node to set focus upon.\n */\n selectNode = (node) => {\n node.focus({preventScroll: true});\n this.searchDropdown.scrollTop = node.offsetTop - (node.clientHeight / 2);\n };\n\n /**\n * Set the focus on the first node within the array.\n */\n moveToFirstNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[0]);\n }\n };\n\n /**\n * Set the focus to the final node within the array.\n */\n moveToLastNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[this.resultNodes.length - 1]);\n }\n };\n\n /**\n * Set focus on any given specified node within the node array.\n *\n * @param {Number} index Which item within the array to set focus upon.\n */\n moveToNode = (index) => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[index]);\n }\n };\n}\n"],"names":["events","CustomEvents","activate","keyboardActivate","constructor","component","this","componentSelector","trigger","triggerSelector","input","clearSearch","dropdown","dropdownSelector","resultitems","viewall","document","querySelector","selectors","node","focus","preventScroll","searchDropdown","scrollTop","offsetTop","clientHeight","resultNodes","length","selectNode","index","setSearchTerms","searchInput","_this$searchInput","value","registerClickHandlers","registerKeyHandlers","registerInputHandlers","fetchDataset","Error","name","filterDataset","dataset","filterMatchDataset","renderDropdown","datasetSize","getDatasetSize","getMatchedResults","matchedResults","setMatchedResults","result","getSearchTerm","searchTerm","getPreppedSearchTerm","preppedSearchTerm","toLowerCase","getHTMLElements","updateNodes","currentViewAll","clearSearchButton","closeSearch","clear","toggleDropdown","classList","add","on","$component","show","remove","hide","querySelectorAll","currentNode","find","r","id","activeElement","addEventListener","clickHandler","bind","define","forEach","event","keyHandler","async","pendingPromise","Pending","renderAndShow","then","resolve","getDataset","keyUpDown","direction","e","preventDefault","stopPropagation","moveToLastNode","moveToFirstNode","indexOf","moveToNode","target","closest","button","window","location","href","key"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"search_combobox.min.js","sources":["../../src/comboboxsearch/search_combobox.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport $ from 'jquery';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport {debounce} from 'core/utils';\nimport Pending from 'core/pending';\n\n/**\n * The class that manages the state of the search within a combobox.\n *\n * @module core/comboboxsearch/search_combobox\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n// Reused variables for the class.\nconst events = [\n 'keydown',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n];\nconst UP = -1;\nconst DOWN = 1;\n\nexport default class {\n // Define our standard lookups.\n selectors = {\n component: this.componentSelector(),\n trigger: this.triggerSelector(),\n input: '[data-action=\"search\"]',\n clearSearch: '[data-action=\"clearsearch\"]',\n dropdown: this.dropdownSelector(),\n resultitems: '[role=\"option\"]',\n viewall: '#select-all',\n };\n\n // The results from the called filter function.\n matchedResults = [];\n\n // What did the user search for?\n searchTerm = '';\n\n // What the user searched for as a lowercase.\n preppedSearchTerm = null;\n\n // The DOM nodes after the dropdown render.\n resultNodes = [];\n\n // Where does the user currently have focus?\n currentNode = null;\n\n // The current node for the view all link.\n currentViewAll = null;\n\n dataset = null;\n\n datasetSize = 0;\n\n // DOM nodes that persist.\n component = document.querySelector(this.selectors.component);\n searchInput = this.component.querySelector(this.selectors.input);\n searchDropdown = this.component.querySelector(this.selectors.dropdown);\n clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n $component = $(this.component);\n\n constructor() {\n // If we have a search input, try to get the value otherwise fallback.\n this.setSearchTerms(this.searchInput?.value ?? '');\n // Begin handling the base search component.\n this.registerClickHandlers();\n this.registerKeyHandlers();\n // Conditionally set up the input handler since we don't know exactly how we were called.\n if (this.searchInput !== null) {\n this.registerInputHandlers();\n }\n }\n\n /**\n * Stub out a required function.\n */\n fetchDataset() {\n throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n * @param {Array} dataset\n */\n filterDataset(dataset) {\n throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n filterMatchDataset() {\n throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n renderDropdown() {\n throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n componentSelector() {\n throw new Error(`componentSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n dropdownSelector() {\n throw new Error(`dropdownSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n triggerSelector() {\n throw new Error(`triggerSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Return the dataset that we will be searching upon.\n *\n * @returns {Promise}\n */\n async getDataset() {\n if (!this.dataset) {\n this.dataset = await this.fetchDataset();\n }\n this.datasetSize = this.dataset.length;\n return this.dataset;\n }\n\n /**\n * Return the size of the dataset.\n *\n * @returns {number}\n */\n getDatasetSize() {\n return this.datasetSize;\n }\n\n /**\n * Return the results of the filter upon the dataset.\n *\n * @returns {Array}\n */\n getMatchedResults() {\n return this.matchedResults;\n }\n\n /**\n * Given a filter has been run across the dataset, store the matched results.\n *\n * @param {Array} result\n */\n setMatchedResults(result) {\n this.matchedResults = result;\n }\n\n /**\n * Get the value that the user entered.\n *\n * @returns {string}\n */\n getSearchTerm() {\n return this.searchTerm;\n }\n\n /**\n * Get the transformed search value.\n *\n * @returns {string}\n */\n getPreppedSearchTerm() {\n return this.preppedSearchTerm;\n }\n\n /**\n * When a user searches for something, set our variable to manage it.\n *\n * @param {string} result\n */\n setSearchTerms(result) {\n this.searchTerm = result;\n this.preppedSearchTerm = result.toLowerCase();\n }\n\n /**\n * Return an object containing a handfull of dom nodes that we sometimes need the value of.\n *\n * @returns {object}\n */\n getHTMLElements() {\n this.updateNodes();\n return {\n searchDropdown: this.searchDropdown,\n currentViewAll: this.currentViewAll,\n searchInput: this.searchInput,\n clearSearchButton: this.clearSearchButton,\n trigger: this.component.querySelector(this.selectors.trigger),\n };\n }\n\n /**\n * When called, close the dropdown and reset the input field attributes.\n *\n * @param {Boolean} clear Conditionality clear the input box.\n */\n closeSearch(clear = false) {\n this.toggleDropdown();\n // Hide the \"clear\" search button search bar.\n this.clearSearchButton.classList.add('d-none');\n if (clear) {\n // Clear the entered search query in the search bar and hide the search results container.\n this.setSearchTerms('');\n this.searchInput.value = \"\";\n }\n }\n\n /**\n * Check whether search results are currently visible.\n *\n * @returns {Boolean}\n */\n searchResultsVisible() {\n const {searchDropdown} = this.getHTMLElements();\n // If a Node is not visible, then the offsetParent is null.\n return searchDropdown.offsetParent !== null;\n }\n\n /**\n * When called, update the dropdown fields.\n *\n * @param {Boolean} on Flag to toggle hiding or showing values.\n */\n toggleDropdown(on = false) {\n this.$component.dropdown('toggle');\n if (on) {\n this.searchDropdown.classList.add('show');\n $(this.searchDropdown).show();\n } else {\n this.searchDropdown.classList.remove('show');\n $(this.searchDropdown).hide();\n }\n }\n\n /**\n * These class members change when a new result set is rendered. So update for fresh data.\n */\n updateNodes() {\n this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];\n this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);\n this.currentViewAll = this.component.querySelector(this.selectors.viewall);\n this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n this.searchInput = this.component.querySelector(this.selectors.input);\n this.searchDropdown = this.component.querySelector(this.selectors.dropdown);\n }\n\n /**\n * Register clickable event listeners.\n */\n registerClickHandlers() {\n // Register click events within the component.\n this.component.addEventListener('click', this.clickHandler.bind(this));\n }\n\n /**\n * Register key event listeners.\n */\n registerKeyHandlers() {\n CustomEvents.define(document, events);\n\n // Register click events.\n events.forEach((event) => {\n this.component.addEventListener(event, this.keyHandler.bind(this));\n });\n }\n\n /**\n * Register input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {\n window.console.warn(`Search term matches input value - skipping`);\n // The debounce canhappen multiple times quickly. GRrargh\n return;\n }\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n this.toggleDropdown();\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n const pendingPromise = new Pending();\n await this.renderAndShow().then(() => {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n return;\n }).then(() => {\n pendingPromise.resolve();\n return true;\n });\n }\n }, 300));\n }\n\n /**\n * Update any changeable nodes, filter and then render the result.\n *\n * @returns {Promise}\n */\n async filterrenderpipe() {\n this.updateNodes();\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n await this.renderDropdown();\n }\n\n /**\n * A combo method to take the matching fields and render out the results.\n *\n * @returns {Promise}\n */\n async renderAndShow() {\n // User has given something for us to filter against.\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n await this.filterMatchDataset();\n // Replace the dropdown node contents and show the results.\n await this.renderDropdown();\n // Set the dropdown to open.\n this.toggleDropdown(true);\n }\n\n /**\n * Set the current focus either on the preceding or next result item.\n *\n * @param {Number} direction Is the user moving up or down the resultset?\n * @param {KeyboardEvent} e The JS event from the event handler.\n */\n keyUpDown(direction, e) {\n e.preventDefault();\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n // Current focus is on the input box so depending on direction, go to the top or the bottom of the displayed results.\n if (document.activeElement === this.searchInput && this.resultNodes.length > 0) {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n const index = this.resultNodes.indexOf(this.currentNode);\n if (this.currentNode) {\n if (direction === UP) {\n if (index === 0) {\n this.moveToLastNode();\n } else {\n this.moveToNode(index - 1);\n }\n } else {\n if (index + 1 >= this.resultNodes.length) {\n this.moveToFirstNode();\n } else {\n this.moveToNode(index + 1);\n }\n }\n } else {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n this.updateNodes();\n\n // Prevent normal key presses activating this.\n if (e.target.closest('.dropdown-item') && e.button === 0) {\n window.location = e.target.closest('.dropdown-item').href;\n }\n // The \"clear search\" button is triggered.\n if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {\n this.closeSearch(true);\n this.searchInput.focus({preventScroll: true});\n }\n // User may have accidentally clicked off the dropdown and wants to reopen it.\n if (e.target.closest(this.selectors.input) && this.getSearchTerm() !== '' && e.button === 0) {\n await this.renderAndShow();\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n this.updateNodes();\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'ArrowUp':\n this.keyUpDown(UP, e);\n break;\n case 'ArrowDown':\n this.keyUpDown(DOWN, e);\n break;\n case 'Home':\n e.preventDefault();\n this.moveToFirstNode();\n break;\n case 'End':\n e.preventDefault();\n this.moveToLastNode();\n break;\n case 'Tab':\n // If the current focus is on the view all link, then close the widget then set focus on the next tertiary nav item.\n if (e.target.closest(this.selectors.viewall)) {\n this.closeSearch();\n }\n break;\n }\n }\n\n /**\n * Set focus on a given node after parsed through the calling functions.\n *\n * @param {HTMLElement} node The node to set focus upon.\n */\n selectNode = (node) => {\n node.focus({preventScroll: true});\n this.searchDropdown.scrollTop = node.offsetTop - (node.clientHeight / 2);\n };\n\n /**\n * Set the focus on the first node within the array.\n */\n moveToFirstNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[0]);\n }\n };\n\n /**\n * Set the focus to the final node within the array.\n */\n moveToLastNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[this.resultNodes.length - 1]);\n }\n };\n\n /**\n * Set focus on any given specified node within the node array.\n *\n * @param {Number} index Which item within the array to set focus upon.\n */\n moveToNode = (index) => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[index]);\n }\n };\n}\n"],"names":["events","CustomEvents","activate","keyboardActivate","constructor","component","this","componentSelector","trigger","triggerSelector","input","clearSearch","dropdown","dropdownSelector","resultitems","viewall","document","querySelector","selectors","node","focus","preventScroll","searchDropdown","scrollTop","offsetTop","clientHeight","resultNodes","length","selectNode","index","setSearchTerms","searchInput","_this$searchInput","value","registerClickHandlers","registerKeyHandlers","registerInputHandlers","fetchDataset","Error","name","filterDataset","dataset","filterMatchDataset","renderDropdown","datasetSize","getDatasetSize","getMatchedResults","matchedResults","setMatchedResults","result","getSearchTerm","searchTerm","getPreppedSearchTerm","preppedSearchTerm","toLowerCase","getHTMLElements","updateNodes","currentViewAll","clearSearchButton","closeSearch","clear","toggleDropdown","classList","add","searchResultsVisible","offsetParent","on","$component","show","remove","hide","querySelectorAll","currentNode","find","r","id","activeElement","addEventListener","clickHandler","bind","define","forEach","event","keyHandler","async","window","console","warn","pendingPromise","Pending","renderAndShow","then","resolve","getDataset","keyUpDown","direction","e","preventDefault","stopPropagation","moveToLastNode","moveToFirstNode","indexOf","moveToNode","target","closest","button","location","href","key"],"mappings":"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"} \ No newline at end of file diff --git a/lib/amd/src/comboboxsearch/search_combobox.js b/lib/amd/src/comboboxsearch/search_combobox.js index 7056eefda4e..d408c27b6a8 100644 --- a/lib/amd/src/comboboxsearch/search_combobox.js +++ b/lib/amd/src/comboboxsearch/search_combobox.js @@ -237,6 +237,17 @@ export default class { } } + /** + * 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. * @@ -302,12 +313,13 @@ export default class { 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)); + }, 300, {pending: true})); } /** diff --git a/lib/amd/src/utils.js b/lib/amd/src/utils.js index 7813eb9271b..55102bb3d2e 100644 --- a/lib/amd/src/utils.js +++ b/lib/amd/src/utils.js @@ -21,6 +21,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +import Pending from 'core/pending'; + /** * Create a wrapper function to throttle the execution of the given * @@ -70,6 +72,11 @@ export const throttle = (func, wait) => { return run; }; +/** + * @property {Map} debounceMap A map of functions to their debounced pending promises. + */ +const debounceMap = new Map(); + /** * Create a wrapper function to debounce the execution of the given * function. Each attempt to execute the function will reset the cooldown @@ -78,16 +85,41 @@ export const throttle = (func, wait) => { * @method * @param {Function} func The function to debounce * @param {Number} wait The number of milliseconds to wait after the final attempt to execute + * @param {Object} [options] + * @param {boolean} [options.pending=false] Whether to wrap the debounced method in a pending promise * @return {Function} */ -export const debounce = (func, wait) => { +export const debounce = ( + func, + wait, + { + pending = false, + } = {}, +) => { let timeout = null; - return function(...args) { + + const returnedFunction = (...args) => { + if (pending && !debounceMap.has(returnedFunction)) { + debounceMap.set(returnedFunction, new Pending('core/utils:debounce')); + } clearTimeout(timeout); - timeout = setTimeout(() => { - func.apply(this, args); + timeout = setTimeout(async () => { + // Get the current pending promise and immediately empty it. + // This is important to allow the function to be debounced again as soon as possible. + // We do not resolve it until later - but that's fine because the promise is appropriately scoped. + const pendingPromise = debounceMap.get(returnedFunction); + debounceMap.delete(returnedFunction); + + // Allow the debounced function to return a Promise. + // This ensures that Behat will not continue until the function has finished executing. + await func.apply(this, args); + + // Resolve the pending promise if it exists. + pendingPromise?.resolve(); }, wait); }; + + return returnedFunction; }; /** From 4bfd20983d0c84e772f19f35ccfd66658cc5475e Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 6 Sep 2023 12:28:36 +0800 Subject: [PATCH 2/3] MDL-78779 core: Do not re-render combo searchresults if term unchanged --- lib/amd/build/comboboxsearch/search_combobox.min.js | 2 +- lib/amd/build/comboboxsearch/search_combobox.min.js.map | 2 +- lib/amd/build/utils.min.js | 2 +- lib/amd/build/utils.min.js.map | 2 +- lib/amd/src/comboboxsearch/search_combobox.js | 5 +++++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/amd/build/comboboxsearch/search_combobox.min.js b/lib/amd/build/comboboxsearch/search_combobox.min.js index b31c8c31cfe..bd4e5d878ae 100644 --- a/lib/amd/build/comboboxsearch/search_combobox.min.js +++ b/lib/amd/build/comboboxsearch/search_combobox.min.js @@ -5,6 +5,6 @@ define("core/comboboxsearch/search_combobox",["exports","jquery","core/custom_in * @module core/comboboxsearch/search_combobox * @copyright 2023 Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_pending=_interopRequireDefault(_pending);const events=["keydown",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];return _exports.default=class{constructor(){var _this$searchInput$val,_this$searchInput;_defineProperty(this,"selectors",{component:this.componentSelector(),trigger:this.triggerSelector(),input:'[data-action="search"]',clearSearch:'[data-action="clearsearch"]',dropdown:this.dropdownSelector(),resultitems:'[role="option"]',viewall:"#select-all"}),_defineProperty(this,"matchedResults",[]),_defineProperty(this,"searchTerm",""),_defineProperty(this,"preppedSearchTerm",null),_defineProperty(this,"resultNodes",[]),_defineProperty(this,"currentNode",null),_defineProperty(this,"currentViewAll",null),_defineProperty(this,"dataset",null),_defineProperty(this,"datasetSize",0),_defineProperty(this,"component",document.querySelector(this.selectors.component)),_defineProperty(this,"searchInput",this.component.querySelector(this.selectors.input)),_defineProperty(this,"searchDropdown",this.component.querySelector(this.selectors.dropdown)),_defineProperty(this,"clearSearchButton",this.component.querySelector(this.selectors.clearSearch)),_defineProperty(this,"$component",(0,_jquery.default)(this.component)),_defineProperty(this,"selectNode",(node=>{node.focus({preventScroll:!0}),this.searchDropdown.scrollTop=node.offsetTop-node.clientHeight/2})),_defineProperty(this,"moveToFirstNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[0])})),_defineProperty(this,"moveToLastNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[this.resultNodes.length-1])})),_defineProperty(this,"moveToNode",(index=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[index])})),this.setSearchTerms(null!==(_this$searchInput$val=null===(_this$searchInput=this.searchInput)||void 0===_this$searchInput?void 0:_this$searchInput.value)&&void 0!==_this$searchInput$val?_this$searchInput$val:""),this.registerClickHandlers(),this.registerKeyHandlers(),null!==this.searchInput&&this.registerInputHandlers()}fetchDataset(){throw new Error("fetchDataset() must be implemented in ".concat(this.constructor.name))}filterDataset(dataset){throw new Error("filterDataset(".concat(dataset,") must be implemented in ").concat(this.constructor.name))}filterMatchDataset(){throw new Error("filterMatchDataset() must be implemented in ".concat(this.constructor.name))}renderDropdown(){throw new Error("renderDropdown() must be implemented in ".concat(this.constructor.name))}componentSelector(){throw new Error("componentSelector() must be implemented in ".concat(this.constructor.name))}dropdownSelector(){throw new Error("dropdownSelector() must be implemented in ".concat(this.constructor.name))}triggerSelector(){throw new Error("triggerSelector() must be implemented in ".concat(this.constructor.name))}async getDataset(){return this.dataset||(this.dataset=await this.fetchDataset()),this.datasetSize=this.dataset.length,this.dataset}getDatasetSize(){return this.datasetSize}getMatchedResults(){return this.matchedResults}setMatchedResults(result){this.matchedResults=result}getSearchTerm(){return this.searchTerm}getPreppedSearchTerm(){return this.preppedSearchTerm}setSearchTerms(result){this.searchTerm=result,this.preppedSearchTerm=result.toLowerCase()}getHTMLElements(){return this.updateNodes(),{searchDropdown:this.searchDropdown,currentViewAll:this.currentViewAll,searchInput:this.searchInput,clearSearchButton:this.clearSearchButton,trigger:this.component.querySelector(this.selectors.trigger)}}closeSearch(){let clear=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.toggleDropdown(),this.clearSearchButton.classList.add("d-none"),clear&&(this.setSearchTerms(""),this.searchInput.value="")}searchResultsVisible(){const{searchDropdown:searchDropdown}=this.getHTMLElements();return null!==searchDropdown.offsetParent}toggleDropdown(){let on=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.$component.dropdown("toggle"),on?(this.searchDropdown.classList.add("show"),(0,_jquery.default)(this.searchDropdown).show()):(this.searchDropdown.classList.remove("show"),(0,_jquery.default)(this.searchDropdown).hide())}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)}registerClickHandlers(){this.component.addEventListener("click",this.clickHandler.bind(this))}registerKeyHandlers(){_custom_interaction_events.default.define(document,events),events.forEach((event=>{this.component.addEventListener(event,this.keyHandler.bind(this))}))}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{if(this.getSearchTerm()===this.searchInput.value&&this.searchResultsVisible())window.console.warn("Search term matches input value - skipping");else if(this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm())this.toggleDropdown(),this.clearSearchButton.classList.add("d-none");else{const pendingPromise=new _pending.default;await this.renderAndShow().then((()=>{this.clearSearchButton.classList.remove("d-none")})).then((()=>(pendingPromise.resolve(),!0)))}}),300))}async filterrenderpipe(){this.updateNodes(),this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown()}async renderAndShow(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),await this.filterMatchDataset(),await this.renderDropdown(),this.toggleDropdown(!0)}keyUpDown(direction,e){e.preventDefault(),e.stopPropagation(),document.activeElement===this.searchInput&&this.resultNodes.length>0&&(-1===direction?this.moveToLastNode():this.moveToFirstNode());const index=this.resultNodes.indexOf(this.currentNode);this.currentNode?-1===direction?0===index?this.moveToLastNode():this.moveToNode(index-1):index+1>=this.resultNodes.length?this.moveToFirstNode():this.moveToNode(index+1):-1===direction?this.moveToLastNode():this.moveToFirstNode()}async clickHandler(e){this.updateNodes(),e.target.closest(".dropdown-item")&&0===e.button&&(window.location=e.target.closest(".dropdown-item").href),e.target.closest(this.selectors.clearSearch)&&0===e.button&&(this.closeSearch(!0),this.searchInput.focus({preventScroll:!0})),e.target.closest(this.selectors.input)&&""!==this.getSearchTerm()&&0===e.button&&await this.renderAndShow()}keyHandler(e){switch(this.updateNodes(),e.key){case"ArrowUp":this.keyUpDown(-1,e);break;case"ArrowDown":this.keyUpDown(1,e);break;case"Home":e.preventDefault(),this.moveToFirstNode();break;case"End":e.preventDefault(),this.moveToLastNode();break;case"Tab":e.target.closest(this.selectors.viewall)&&this.closeSearch()}}},_exports.default})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_jquery=_interopRequireDefault(_jquery),_custom_interaction_events=_interopRequireDefault(_custom_interaction_events),_pending=_interopRequireDefault(_pending);const events=["keydown",_custom_interaction_events.default.events.activate,_custom_interaction_events.default.events.keyboardActivate];return _exports.default=class{constructor(){var _this$searchInput$val,_this$searchInput;_defineProperty(this,"selectors",{component:this.componentSelector(),trigger:this.triggerSelector(),input:'[data-action="search"]',clearSearch:'[data-action="clearsearch"]',dropdown:this.dropdownSelector(),resultitems:'[role="option"]',viewall:"#select-all"}),_defineProperty(this,"matchedResults",[]),_defineProperty(this,"searchTerm",""),_defineProperty(this,"preppedSearchTerm",null),_defineProperty(this,"resultNodes",[]),_defineProperty(this,"currentNode",null),_defineProperty(this,"currentViewAll",null),_defineProperty(this,"dataset",null),_defineProperty(this,"datasetSize",0),_defineProperty(this,"component",document.querySelector(this.selectors.component)),_defineProperty(this,"searchInput",this.component.querySelector(this.selectors.input)),_defineProperty(this,"searchDropdown",this.component.querySelector(this.selectors.dropdown)),_defineProperty(this,"clearSearchButton",this.component.querySelector(this.selectors.clearSearch)),_defineProperty(this,"$component",(0,_jquery.default)(this.component)),_defineProperty(this,"selectNode",(node=>{node.focus({preventScroll:!0}),this.searchDropdown.scrollTop=node.offsetTop-node.clientHeight/2})),_defineProperty(this,"moveToFirstNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[0])})),_defineProperty(this,"moveToLastNode",(()=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[this.resultNodes.length-1])})),_defineProperty(this,"moveToNode",(index=>{this.resultNodes.length>0&&this.selectNode(this.resultNodes[index])})),this.setSearchTerms(null!==(_this$searchInput$val=null===(_this$searchInput=this.searchInput)||void 0===_this$searchInput?void 0:_this$searchInput.value)&&void 0!==_this$searchInput$val?_this$searchInput$val:""),this.registerClickHandlers(),this.registerKeyHandlers(),null!==this.searchInput&&this.registerInputHandlers()}fetchDataset(){throw new Error("fetchDataset() must be implemented in ".concat(this.constructor.name))}filterDataset(dataset){throw new Error("filterDataset(".concat(dataset,") must be implemented in ").concat(this.constructor.name))}filterMatchDataset(){throw new Error("filterMatchDataset() must be implemented in ".concat(this.constructor.name))}renderDropdown(){throw new Error("renderDropdown() must be implemented in ".concat(this.constructor.name))}componentSelector(){throw new Error("componentSelector() must be implemented in ".concat(this.constructor.name))}dropdownSelector(){throw new Error("dropdownSelector() must be implemented in ".concat(this.constructor.name))}triggerSelector(){throw new Error("triggerSelector() must be implemented in ".concat(this.constructor.name))}async getDataset(){return this.dataset||(this.dataset=await this.fetchDataset()),this.datasetSize=this.dataset.length,this.dataset}getDatasetSize(){return this.datasetSize}getMatchedResults(){return this.matchedResults}setMatchedResults(result){this.matchedResults=result}getSearchTerm(){return this.searchTerm}getPreppedSearchTerm(){return this.preppedSearchTerm}setSearchTerms(result){this.searchTerm=result,this.preppedSearchTerm=result.toLowerCase()}getHTMLElements(){return this.updateNodes(),{searchDropdown:this.searchDropdown,currentViewAll:this.currentViewAll,searchInput:this.searchInput,clearSearchButton:this.clearSearchButton,trigger:this.component.querySelector(this.selectors.trigger)}}closeSearch(){let clear=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.toggleDropdown(),this.clearSearchButton.classList.add("d-none"),clear&&(this.setSearchTerms(""),this.searchInput.value="")}searchResultsVisible(){const{searchDropdown:searchDropdown}=this.getHTMLElements();return null!==searchDropdown.offsetParent}toggleDropdown(){let on=arguments.length>0&&void 0!==arguments[0]&&arguments[0];this.$component.dropdown("toggle"),on?(this.searchDropdown.classList.add("show"),(0,_jquery.default)(this.searchDropdown).show()):(this.searchDropdown.classList.remove("show"),(0,_jquery.default)(this.searchDropdown).hide())}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)}registerClickHandlers(){this.component.addEventListener("click",this.clickHandler.bind(this))}registerKeyHandlers(){_custom_interaction_events.default.define(document,events),events.forEach((event=>{this.component.addEventListener(event,this.keyHandler.bind(this))}))}registerInputHandlers(){this.searchInput.addEventListener("input",(0,_utils.debounce)((async()=>{if(this.getSearchTerm()===this.searchInput.value&&this.searchResultsVisible())window.console.warn("Search term matches input value - skipping");else if(this.setSearchTerms(this.searchInput.value),""===this.getSearchTerm())this.toggleDropdown(),this.clearSearchButton.classList.add("d-none");else{const pendingPromise=new _pending.default;await this.renderAndShow().then((()=>{this.clearSearchButton.classList.remove("d-none")})).then((()=>(pendingPromise.resolve(),!0)))}}),300,{pending:!0}))}async filterrenderpipe(){this.updateNodes(),this.setMatchedResults(await this.filterDataset(await this.getDataset())),this.filterMatchDataset(),await this.renderDropdown()}async renderAndShow(){this.setMatchedResults(await this.filterDataset(await this.getDataset())),await this.filterMatchDataset(),await this.renderDropdown(),this.toggleDropdown(!0)}keyUpDown(direction,e){e.preventDefault(),e.stopPropagation(),document.activeElement===this.searchInput&&this.resultNodes.length>0&&(-1===direction?this.moveToLastNode():this.moveToFirstNode());const index=this.resultNodes.indexOf(this.currentNode);this.currentNode?-1===direction?0===index?this.moveToLastNode():this.moveToNode(index-1):index+1>=this.resultNodes.length?this.moveToFirstNode():this.moveToNode(index+1):-1===direction?this.moveToLastNode():this.moveToFirstNode()}async clickHandler(e){this.updateNodes(),e.target.closest(".dropdown-item")&&0===e.button&&(window.location=e.target.closest(".dropdown-item").href),e.target.closest(this.selectors.clearSearch)&&0===e.button&&(this.closeSearch(!0),this.searchInput.focus({preventScroll:!0})),e.target.closest(this.selectors.input)&&""!==this.getSearchTerm()&&0===e.button&&await this.renderAndShow()}keyHandler(e){switch(this.updateNodes(),e.key){case"ArrowUp":this.keyUpDown(-1,e);break;case"ArrowDown":this.keyUpDown(1,e);break;case"Home":e.preventDefault(),this.moveToFirstNode();break;case"End":e.preventDefault(),this.moveToLastNode();break;case"Tab":e.target.closest(this.selectors.viewall)&&this.closeSearch()}}},_exports.default})); //# sourceMappingURL=search_combobox.min.js.map \ No newline at end of file diff --git a/lib/amd/build/comboboxsearch/search_combobox.min.js.map b/lib/amd/build/comboboxsearch/search_combobox.min.js.map index 4d346512970..e11058c8b90 100644 --- a/lib/amd/build/comboboxsearch/search_combobox.min.js.map +++ b/lib/amd/build/comboboxsearch/search_combobox.min.js.map @@ -1 +1 @@ -{"version":3,"file":"search_combobox.min.js","sources":["../../src/comboboxsearch/search_combobox.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport $ from 'jquery';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport {debounce} from 'core/utils';\nimport Pending from 'core/pending';\n\n/**\n * The class that manages the state of the search within a combobox.\n *\n * @module core/comboboxsearch/search_combobox\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n// Reused variables for the class.\nconst events = [\n 'keydown',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n];\nconst UP = -1;\nconst DOWN = 1;\n\nexport default class {\n // Define our standard lookups.\n selectors = {\n component: this.componentSelector(),\n trigger: this.triggerSelector(),\n input: '[data-action=\"search\"]',\n clearSearch: '[data-action=\"clearsearch\"]',\n dropdown: this.dropdownSelector(),\n resultitems: '[role=\"option\"]',\n viewall: '#select-all',\n };\n\n // The results from the called filter function.\n matchedResults = [];\n\n // What did the user search for?\n searchTerm = '';\n\n // What the user searched for as a lowercase.\n preppedSearchTerm = null;\n\n // The DOM nodes after the dropdown render.\n resultNodes = [];\n\n // Where does the user currently have focus?\n currentNode = null;\n\n // The current node for the view all link.\n currentViewAll = null;\n\n dataset = null;\n\n datasetSize = 0;\n\n // DOM nodes that persist.\n component = document.querySelector(this.selectors.component);\n searchInput = this.component.querySelector(this.selectors.input);\n searchDropdown = this.component.querySelector(this.selectors.dropdown);\n clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n $component = $(this.component);\n\n constructor() {\n // If we have a search input, try to get the value otherwise fallback.\n this.setSearchTerms(this.searchInput?.value ?? '');\n // Begin handling the base search component.\n this.registerClickHandlers();\n this.registerKeyHandlers();\n // Conditionally set up the input handler since we don't know exactly how we were called.\n if (this.searchInput !== null) {\n this.registerInputHandlers();\n }\n }\n\n /**\n * Stub out a required function.\n */\n fetchDataset() {\n throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n * @param {Array} dataset\n */\n filterDataset(dataset) {\n throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n filterMatchDataset() {\n throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n renderDropdown() {\n throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n componentSelector() {\n throw new Error(`componentSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n dropdownSelector() {\n throw new Error(`dropdownSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n triggerSelector() {\n throw new Error(`triggerSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Return the dataset that we will be searching upon.\n *\n * @returns {Promise}\n */\n async getDataset() {\n if (!this.dataset) {\n this.dataset = await this.fetchDataset();\n }\n this.datasetSize = this.dataset.length;\n return this.dataset;\n }\n\n /**\n * Return the size of the dataset.\n *\n * @returns {number}\n */\n getDatasetSize() {\n return this.datasetSize;\n }\n\n /**\n * Return the results of the filter upon the dataset.\n *\n * @returns {Array}\n */\n getMatchedResults() {\n return this.matchedResults;\n }\n\n /**\n * Given a filter has been run across the dataset, store the matched results.\n *\n * @param {Array} result\n */\n setMatchedResults(result) {\n this.matchedResults = result;\n }\n\n /**\n * Get the value that the user entered.\n *\n * @returns {string}\n */\n getSearchTerm() {\n return this.searchTerm;\n }\n\n /**\n * Get the transformed search value.\n *\n * @returns {string}\n */\n getPreppedSearchTerm() {\n return this.preppedSearchTerm;\n }\n\n /**\n * When a user searches for something, set our variable to manage it.\n *\n * @param {string} result\n */\n setSearchTerms(result) {\n this.searchTerm = result;\n this.preppedSearchTerm = result.toLowerCase();\n }\n\n /**\n * Return an object containing a handfull of dom nodes that we sometimes need the value of.\n *\n * @returns {object}\n */\n getHTMLElements() {\n this.updateNodes();\n return {\n searchDropdown: this.searchDropdown,\n currentViewAll: this.currentViewAll,\n searchInput: this.searchInput,\n clearSearchButton: this.clearSearchButton,\n trigger: this.component.querySelector(this.selectors.trigger),\n };\n }\n\n /**\n * When called, close the dropdown and reset the input field attributes.\n *\n * @param {Boolean} clear Conditionality clear the input box.\n */\n closeSearch(clear = false) {\n this.toggleDropdown();\n // Hide the \"clear\" search button search bar.\n this.clearSearchButton.classList.add('d-none');\n if (clear) {\n // Clear the entered search query in the search bar and hide the search results container.\n this.setSearchTerms('');\n this.searchInput.value = \"\";\n }\n }\n\n /**\n * Check whether search results are currently visible.\n *\n * @returns {Boolean}\n */\n searchResultsVisible() {\n const {searchDropdown} = this.getHTMLElements();\n // If a Node is not visible, then the offsetParent is null.\n return searchDropdown.offsetParent !== null;\n }\n\n /**\n * When called, update the dropdown fields.\n *\n * @param {Boolean} on Flag to toggle hiding or showing values.\n */\n toggleDropdown(on = false) {\n this.$component.dropdown('toggle');\n if (on) {\n this.searchDropdown.classList.add('show');\n $(this.searchDropdown).show();\n } else {\n this.searchDropdown.classList.remove('show');\n $(this.searchDropdown).hide();\n }\n }\n\n /**\n * These class members change when a new result set is rendered. So update for fresh data.\n */\n updateNodes() {\n this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];\n this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);\n this.currentViewAll = this.component.querySelector(this.selectors.viewall);\n this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n this.searchInput = this.component.querySelector(this.selectors.input);\n this.searchDropdown = this.component.querySelector(this.selectors.dropdown);\n }\n\n /**\n * Register clickable event listeners.\n */\n registerClickHandlers() {\n // Register click events within the component.\n this.component.addEventListener('click', this.clickHandler.bind(this));\n }\n\n /**\n * Register key event listeners.\n */\n registerKeyHandlers() {\n CustomEvents.define(document, events);\n\n // Register click events.\n events.forEach((event) => {\n this.component.addEventListener(event, this.keyHandler.bind(this));\n });\n }\n\n /**\n * Register input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {\n window.console.warn(`Search term matches input value - skipping`);\n // The debounce canhappen multiple times quickly. GRrargh\n return;\n }\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n this.toggleDropdown();\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n const pendingPromise = new Pending();\n await this.renderAndShow().then(() => {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n return;\n }).then(() => {\n pendingPromise.resolve();\n return true;\n });\n }\n }, 300));\n }\n\n /**\n * Update any changeable nodes, filter and then render the result.\n *\n * @returns {Promise}\n */\n async filterrenderpipe() {\n this.updateNodes();\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n await this.renderDropdown();\n }\n\n /**\n * A combo method to take the matching fields and render out the results.\n *\n * @returns {Promise}\n */\n async renderAndShow() {\n // User has given something for us to filter against.\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n await this.filterMatchDataset();\n // Replace the dropdown node contents and show the results.\n await this.renderDropdown();\n // Set the dropdown to open.\n this.toggleDropdown(true);\n }\n\n /**\n * Set the current focus either on the preceding or next result item.\n *\n * @param {Number} direction Is the user moving up or down the resultset?\n * @param {KeyboardEvent} e The JS event from the event handler.\n */\n keyUpDown(direction, e) {\n e.preventDefault();\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n // Current focus is on the input box so depending on direction, go to the top or the bottom of the displayed results.\n if (document.activeElement === this.searchInput && this.resultNodes.length > 0) {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n const index = this.resultNodes.indexOf(this.currentNode);\n if (this.currentNode) {\n if (direction === UP) {\n if (index === 0) {\n this.moveToLastNode();\n } else {\n this.moveToNode(index - 1);\n }\n } else {\n if (index + 1 >= this.resultNodes.length) {\n this.moveToFirstNode();\n } else {\n this.moveToNode(index + 1);\n }\n }\n } else {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n this.updateNodes();\n\n // Prevent normal key presses activating this.\n if (e.target.closest('.dropdown-item') && e.button === 0) {\n window.location = e.target.closest('.dropdown-item').href;\n }\n // The \"clear search\" button is triggered.\n if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {\n this.closeSearch(true);\n this.searchInput.focus({preventScroll: true});\n }\n // User may have accidentally clicked off the dropdown and wants to reopen it.\n if (e.target.closest(this.selectors.input) && this.getSearchTerm() !== '' && e.button === 0) {\n await this.renderAndShow();\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n this.updateNodes();\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'ArrowUp':\n this.keyUpDown(UP, e);\n break;\n case 'ArrowDown':\n this.keyUpDown(DOWN, e);\n break;\n case 'Home':\n e.preventDefault();\n this.moveToFirstNode();\n break;\n case 'End':\n e.preventDefault();\n this.moveToLastNode();\n break;\n case 'Tab':\n // If the current focus is on the view all link, then close the widget then set focus on the next tertiary nav item.\n if (e.target.closest(this.selectors.viewall)) {\n this.closeSearch();\n }\n break;\n }\n }\n\n /**\n * Set focus on a given node after parsed through the calling functions.\n *\n * @param {HTMLElement} node The node to set focus upon.\n */\n selectNode = (node) => {\n node.focus({preventScroll: true});\n this.searchDropdown.scrollTop = node.offsetTop - (node.clientHeight / 2);\n };\n\n /**\n * Set the focus on the first node within the array.\n */\n moveToFirstNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[0]);\n }\n };\n\n /**\n * Set the focus to the final node within the array.\n */\n moveToLastNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[this.resultNodes.length - 1]);\n }\n };\n\n /**\n * Set focus on any given specified node within the node array.\n *\n * @param {Number} index Which item within the array to set focus upon.\n */\n moveToNode = (index) => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[index]);\n }\n };\n}\n"],"names":["events","CustomEvents","activate","keyboardActivate","constructor","component","this","componentSelector","trigger","triggerSelector","input","clearSearch","dropdown","dropdownSelector","resultitems","viewall","document","querySelector","selectors","node","focus","preventScroll","searchDropdown","scrollTop","offsetTop","clientHeight","resultNodes","length","selectNode","index","setSearchTerms","searchInput","_this$searchInput","value","registerClickHandlers","registerKeyHandlers","registerInputHandlers","fetchDataset","Error","name","filterDataset","dataset","filterMatchDataset","renderDropdown","datasetSize","getDatasetSize","getMatchedResults","matchedResults","setMatchedResults","result","getSearchTerm","searchTerm","getPreppedSearchTerm","preppedSearchTerm","toLowerCase","getHTMLElements","updateNodes","currentViewAll","clearSearchButton","closeSearch","clear","toggleDropdown","classList","add","searchResultsVisible","offsetParent","on","$component","show","remove","hide","querySelectorAll","currentNode","find","r","id","activeElement","addEventListener","clickHandler","bind","define","forEach","event","keyHandler","async","window","console","warn","pendingPromise","Pending","renderAndShow","then","resolve","getDataset","keyUpDown","direction","e","preventDefault","stopPropagation","moveToLastNode","moveToFirstNode","indexOf","moveToNode","target","closest","button","location","href","key"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"search_combobox.min.js","sources":["../../src/comboboxsearch/search_combobox.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\nimport $ from 'jquery';\nimport CustomEvents from \"core/custom_interaction_events\";\nimport {debounce} from 'core/utils';\nimport Pending from 'core/pending';\n\n/**\n * The class that manages the state of the search within a combobox.\n *\n * @module core/comboboxsearch/search_combobox\n * @copyright 2023 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n// Reused variables for the class.\nconst events = [\n 'keydown',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n];\nconst UP = -1;\nconst DOWN = 1;\n\nexport default class {\n // Define our standard lookups.\n selectors = {\n component: this.componentSelector(),\n trigger: this.triggerSelector(),\n input: '[data-action=\"search\"]',\n clearSearch: '[data-action=\"clearsearch\"]',\n dropdown: this.dropdownSelector(),\n resultitems: '[role=\"option\"]',\n viewall: '#select-all',\n };\n\n // The results from the called filter function.\n matchedResults = [];\n\n // What did the user search for?\n searchTerm = '';\n\n // What the user searched for as a lowercase.\n preppedSearchTerm = null;\n\n // The DOM nodes after the dropdown render.\n resultNodes = [];\n\n // Where does the user currently have focus?\n currentNode = null;\n\n // The current node for the view all link.\n currentViewAll = null;\n\n dataset = null;\n\n datasetSize = 0;\n\n // DOM nodes that persist.\n component = document.querySelector(this.selectors.component);\n searchInput = this.component.querySelector(this.selectors.input);\n searchDropdown = this.component.querySelector(this.selectors.dropdown);\n clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n $component = $(this.component);\n\n constructor() {\n // If we have a search input, try to get the value otherwise fallback.\n this.setSearchTerms(this.searchInput?.value ?? '');\n // Begin handling the base search component.\n this.registerClickHandlers();\n this.registerKeyHandlers();\n // Conditionally set up the input handler since we don't know exactly how we were called.\n if (this.searchInput !== null) {\n this.registerInputHandlers();\n }\n }\n\n /**\n * Stub out a required function.\n */\n fetchDataset() {\n throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n * @param {Array} dataset\n */\n filterDataset(dataset) {\n throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n filterMatchDataset() {\n throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n renderDropdown() {\n throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n componentSelector() {\n throw new Error(`componentSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n dropdownSelector() {\n throw new Error(`dropdownSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Stub out a required function.\n */\n triggerSelector() {\n throw new Error(`triggerSelector() must be implemented in ${this.constructor.name}`);\n }\n\n /**\n * Return the dataset that we will be searching upon.\n *\n * @returns {Promise}\n */\n async getDataset() {\n if (!this.dataset) {\n this.dataset = await this.fetchDataset();\n }\n this.datasetSize = this.dataset.length;\n return this.dataset;\n }\n\n /**\n * Return the size of the dataset.\n *\n * @returns {number}\n */\n getDatasetSize() {\n return this.datasetSize;\n }\n\n /**\n * Return the results of the filter upon the dataset.\n *\n * @returns {Array}\n */\n getMatchedResults() {\n return this.matchedResults;\n }\n\n /**\n * Given a filter has been run across the dataset, store the matched results.\n *\n * @param {Array} result\n */\n setMatchedResults(result) {\n this.matchedResults = result;\n }\n\n /**\n * Get the value that the user entered.\n *\n * @returns {string}\n */\n getSearchTerm() {\n return this.searchTerm;\n }\n\n /**\n * Get the transformed search value.\n *\n * @returns {string}\n */\n getPreppedSearchTerm() {\n return this.preppedSearchTerm;\n }\n\n /**\n * When a user searches for something, set our variable to manage it.\n *\n * @param {string} result\n */\n setSearchTerms(result) {\n this.searchTerm = result;\n this.preppedSearchTerm = result.toLowerCase();\n }\n\n /**\n * Return an object containing a handfull of dom nodes that we sometimes need the value of.\n *\n * @returns {object}\n */\n getHTMLElements() {\n this.updateNodes();\n return {\n searchDropdown: this.searchDropdown,\n currentViewAll: this.currentViewAll,\n searchInput: this.searchInput,\n clearSearchButton: this.clearSearchButton,\n trigger: this.component.querySelector(this.selectors.trigger),\n };\n }\n\n /**\n * When called, close the dropdown and reset the input field attributes.\n *\n * @param {Boolean} clear Conditionality clear the input box.\n */\n closeSearch(clear = false) {\n this.toggleDropdown();\n // Hide the \"clear\" search button search bar.\n this.clearSearchButton.classList.add('d-none');\n if (clear) {\n // Clear the entered search query in the search bar and hide the search results container.\n this.setSearchTerms('');\n this.searchInput.value = \"\";\n }\n }\n\n /**\n * Check whether search results are currently visible.\n *\n * @returns {Boolean}\n */\n searchResultsVisible() {\n const {searchDropdown} = this.getHTMLElements();\n // If a Node is not visible, then the offsetParent is null.\n return searchDropdown.offsetParent !== null;\n }\n\n /**\n * When called, update the dropdown fields.\n *\n * @param {Boolean} on Flag to toggle hiding or showing values.\n */\n toggleDropdown(on = false) {\n this.$component.dropdown('toggle');\n if (on) {\n this.searchDropdown.classList.add('show');\n $(this.searchDropdown).show();\n } else {\n this.searchDropdown.classList.remove('show');\n $(this.searchDropdown).hide();\n }\n }\n\n /**\n * These class members change when a new result set is rendered. So update for fresh data.\n */\n updateNodes() {\n this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];\n this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);\n this.currentViewAll = this.component.querySelector(this.selectors.viewall);\n this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);\n this.searchInput = this.component.querySelector(this.selectors.input);\n this.searchDropdown = this.component.querySelector(this.selectors.dropdown);\n }\n\n /**\n * Register clickable event listeners.\n */\n registerClickHandlers() {\n // Register click events within the component.\n this.component.addEventListener('click', this.clickHandler.bind(this));\n }\n\n /**\n * Register key event listeners.\n */\n registerKeyHandlers() {\n CustomEvents.define(document, events);\n\n // Register click events.\n events.forEach((event) => {\n this.component.addEventListener(event, this.keyHandler.bind(this));\n });\n }\n\n /**\n * Register input event listener for the text input area.\n */\n registerInputHandlers() {\n // Register & handle the text input.\n this.searchInput.addEventListener('input', debounce(async() => {\n if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {\n window.console.warn(`Search term matches input value - skipping`);\n // The debounce canhappen multiple times quickly. GRrargh\n return;\n }\n this.setSearchTerms(this.searchInput.value);\n // We can also require a set amount of input before search.\n if (this.getSearchTerm() === '') {\n this.toggleDropdown();\n // Hide the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.add('d-none');\n } else {\n const pendingPromise = new Pending();\n await this.renderAndShow().then(() => {\n // Display the \"clear\" search button in the search bar.\n this.clearSearchButton.classList.remove('d-none');\n return;\n }).then(() => {\n pendingPromise.resolve();\n return true;\n });\n }\n }, 300, {pending: true}));\n }\n\n /**\n * Update any changeable nodes, filter and then render the result.\n *\n * @returns {Promise}\n */\n async filterrenderpipe() {\n this.updateNodes();\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n this.filterMatchDataset();\n await this.renderDropdown();\n }\n\n /**\n * A combo method to take the matching fields and render out the results.\n *\n * @returns {Promise}\n */\n async renderAndShow() {\n // User has given something for us to filter against.\n this.setMatchedResults(await this.filterDataset(await this.getDataset()));\n await this.filterMatchDataset();\n // Replace the dropdown node contents and show the results.\n await this.renderDropdown();\n // Set the dropdown to open.\n this.toggleDropdown(true);\n }\n\n /**\n * Set the current focus either on the preceding or next result item.\n *\n * @param {Number} direction Is the user moving up or down the resultset?\n * @param {KeyboardEvent} e The JS event from the event handler.\n */\n keyUpDown(direction, e) {\n e.preventDefault();\n // Stop Bootstrap from being clever.\n e.stopPropagation();\n // Current focus is on the input box so depending on direction, go to the top or the bottom of the displayed results.\n if (document.activeElement === this.searchInput && this.resultNodes.length > 0) {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n const index = this.resultNodes.indexOf(this.currentNode);\n if (this.currentNode) {\n if (direction === UP) {\n if (index === 0) {\n this.moveToLastNode();\n } else {\n this.moveToNode(index - 1);\n }\n } else {\n if (index + 1 >= this.resultNodes.length) {\n this.moveToFirstNode();\n } else {\n this.moveToNode(index + 1);\n }\n }\n } else {\n if (direction === UP) {\n this.moveToLastNode();\n } else {\n this.moveToFirstNode();\n }\n }\n }\n\n /**\n * The handler for when a user interacts with the component.\n *\n * @param {MouseEvent} e The triggering event that we are working with.\n */\n async clickHandler(e) {\n this.updateNodes();\n\n // Prevent normal key presses activating this.\n if (e.target.closest('.dropdown-item') && e.button === 0) {\n window.location = e.target.closest('.dropdown-item').href;\n }\n // The \"clear search\" button is triggered.\n if (e.target.closest(this.selectors.clearSearch) && e.button === 0) {\n this.closeSearch(true);\n this.searchInput.focus({preventScroll: true});\n }\n // User may have accidentally clicked off the dropdown and wants to reopen it.\n if (e.target.closest(this.selectors.input) && this.getSearchTerm() !== '' && e.button === 0) {\n await this.renderAndShow();\n }\n }\n\n /**\n * The handler for when a user presses a key within the component.\n *\n * @param {KeyboardEvent} e The triggering event that we are working with.\n */\n keyHandler(e) {\n this.updateNodes();\n // Switch the key presses to handle keyboard nav.\n switch (e.key) {\n case 'ArrowUp':\n this.keyUpDown(UP, e);\n break;\n case 'ArrowDown':\n this.keyUpDown(DOWN, e);\n break;\n case 'Home':\n e.preventDefault();\n this.moveToFirstNode();\n break;\n case 'End':\n e.preventDefault();\n this.moveToLastNode();\n break;\n case 'Tab':\n // If the current focus is on the view all link, then close the widget then set focus on the next tertiary nav item.\n if (e.target.closest(this.selectors.viewall)) {\n this.closeSearch();\n }\n break;\n }\n }\n\n /**\n * Set focus on a given node after parsed through the calling functions.\n *\n * @param {HTMLElement} node The node to set focus upon.\n */\n selectNode = (node) => {\n node.focus({preventScroll: true});\n this.searchDropdown.scrollTop = node.offsetTop - (node.clientHeight / 2);\n };\n\n /**\n * Set the focus on the first node within the array.\n */\n moveToFirstNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[0]);\n }\n };\n\n /**\n * Set the focus to the final node within the array.\n */\n moveToLastNode = () => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[this.resultNodes.length - 1]);\n }\n };\n\n /**\n * Set focus on any given specified node within the node array.\n *\n * @param {Number} index Which item within the array to set focus upon.\n */\n moveToNode = (index) => {\n if (this.resultNodes.length > 0) {\n this.selectNode(this.resultNodes[index]);\n }\n };\n}\n"],"names":["events","CustomEvents","activate","keyboardActivate","constructor","component","this","componentSelector","trigger","triggerSelector","input","clearSearch","dropdown","dropdownSelector","resultitems","viewall","document","querySelector","selectors","node","focus","preventScroll","searchDropdown","scrollTop","offsetTop","clientHeight","resultNodes","length","selectNode","index","setSearchTerms","searchInput","_this$searchInput","value","registerClickHandlers","registerKeyHandlers","registerInputHandlers","fetchDataset","Error","name","filterDataset","dataset","filterMatchDataset","renderDropdown","datasetSize","getDatasetSize","getMatchedResults","matchedResults","setMatchedResults","result","getSearchTerm","searchTerm","getPreppedSearchTerm","preppedSearchTerm","toLowerCase","getHTMLElements","updateNodes","currentViewAll","clearSearchButton","closeSearch","clear","toggleDropdown","classList","add","searchResultsVisible","offsetParent","on","$component","show","remove","hide","querySelectorAll","currentNode","find","r","id","activeElement","addEventListener","clickHandler","bind","define","forEach","event","keyHandler","async","window","console","warn","pendingPromise","Pending","renderAndShow","then","resolve","pending","getDataset","keyUpDown","direction","e","preventDefault","stopPropagation","moveToLastNode","moveToFirstNode","indexOf","moveToNode","target","closest","button","location","href","key"],"mappings":"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"} \ No newline at end of file diff --git a/lib/amd/build/utils.min.js b/lib/amd/build/utils.min.js index 76de5a1ada2..d4d77f50f66 100644 --- a/lib/amd/build/utils.min.js +++ b/lib/amd/build/utils.min.js @@ -1,3 +1,3 @@ -define("core/utils",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.throttle=_exports.getNormalisedComponent=_exports.debounce=void 0;_exports.throttle=(func,wait)=>{let onCooldown=!1,runAgain=null;const run=function(){for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++)args[_key]=arguments[_key];runAgain=null!==runAgain,onCooldown||(func.apply(this,args),onCooldown=!0,setTimeout((()=>{const recurse=runAgain;onCooldown=!1,runAgain=null,recurse&&run(args)}),wait))};return run};_exports.debounce=(func,wait)=>{let timeout=null;return function(){for(var _len2=arguments.length,args=new Array(_len2),_key2=0;_key2<_len2;_key2++)args[_key2]=arguments[_key2];clearTimeout(timeout),timeout=setTimeout((()=>{func.apply(this,args)}),wait)}};_exports.getNormalisedComponent=component=>component&&"moodle"!==component&&"core"!==component?component:"core"})); +define("core/utils",["exports","core/pending"],(function(_exports,_pending){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.throttle=_exports.getNormalisedComponent=_exports.debounce=void 0,_pending=(obj=_pending)&&obj.__esModule?obj:{default:obj};_exports.throttle=(func,wait)=>{let onCooldown=!1,runAgain=null;const run=function(){for(var _len=arguments.length,args=new Array(_len),_key=0;_key<_len;_key++)args[_key]=arguments[_key];runAgain=null!==runAgain,onCooldown||(func.apply(this,args),onCooldown=!0,setTimeout((()=>{const recurse=runAgain;onCooldown=!1,runAgain=null,recurse&&run(args)}),wait))};return run};const debounceMap=new Map;_exports.debounce=function(func,wait){let{pending:pending=!1}=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},timeout=null;const returnedFunction=function(){for(var _len2=arguments.length,args=new Array(_len2),_key2=0;_key2<_len2;_key2++)args[_key2]=arguments[_key2];pending&&!debounceMap.has(returnedFunction)&&debounceMap.set(returnedFunction,new _pending.default("core/utils:debounce")),clearTimeout(timeout),timeout=setTimeout((async()=>{const pendingPromise=debounceMap.get(returnedFunction);debounceMap.delete(returnedFunction),await func.apply(undefined,args),null==pendingPromise||pendingPromise.resolve()}),wait)};return returnedFunction};_exports.getNormalisedComponent=component=>component&&"moodle"!==component&&"core"!==component?component:"core"})); //# sourceMappingURL=utils.min.js.map \ No newline at end of file diff --git a/lib/amd/build/utils.min.js.map b/lib/amd/build/utils.min.js.map index 397b96e19b4..64e77a81139 100644 --- a/lib/amd/build/utils.min.js.map +++ b/lib/amd/build/utils.min.js.map @@ -1 +1 @@ -{"version":3,"file":"utils.min.js","sources":["../src/utils.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module core/utils\n * @copyright 2019 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n /**\n * Create a wrapper function to throttle the execution of the given\n *\n * function to at most once every specified period.\n *\n * If the function is attempted to be executed while it's in cooldown\n * (during the wait period) then it'll immediately execute again as\n * soon as the cooldown is over.\n *\n * @method\n * @param {Function} func The function to throttle\n * @param {Number} wait The number of milliseconds to wait between executions\n * @return {Function}\n */\nexport const throttle = (func, wait) => {\n let onCooldown = false;\n let runAgain = null;\n const run = function(...args) {\n if (runAgain === null) {\n // This is the first time the function has been called.\n runAgain = false;\n } else {\n // This function has been called a second time during the wait period\n // so re-run it once the wait period is over.\n runAgain = true;\n }\n\n if (onCooldown) {\n // Function has already run for this wait period.\n return;\n }\n\n func.apply(this, args);\n onCooldown = true;\n\n setTimeout(() => {\n const recurse = runAgain;\n onCooldown = false;\n runAgain = null;\n\n if (recurse) {\n run(args);\n }\n }, wait);\n };\n\n return run;\n};\n\n/**\n * Create a wrapper function to debounce the execution of the given\n * function. Each attempt to execute the function will reset the cooldown\n * period.\n *\n * @method\n * @param {Function} func The function to debounce\n * @param {Number} wait The number of milliseconds to wait after the final attempt to execute\n * @return {Function}\n */\nexport const debounce = (func, wait) => {\n let timeout = null;\n return function(...args) {\n clearTimeout(timeout);\n timeout = setTimeout(() => {\n func.apply(this, args);\n }, wait);\n };\n};\n\n/**\n * Normalise the provided component such that '', 'moodle', and 'core' are treated consistently.\n *\n * @param {String} component\n * @returns {String}\n */\nexport const getNormalisedComponent = (component) => {\n if (component) {\n if (component !== 'moodle' && component !== 'core') {\n return component;\n }\n }\n\n return 'core';\n};\n"],"names":["func","wait","onCooldown","runAgain","run","args","apply","this","setTimeout","recurse","timeout","clearTimeout","component"],"mappings":"yMAqCwB,CAACA,KAAMC,YACvBC,YAAa,EACbC,SAAW,WACTC,IAAM,yCAAYC,6CAAAA,2BAGhBF,SAFa,OAAbA,SASAD,aAKJF,KAAKM,MAAMC,KAAMF,MACjBH,YAAa,EAEbM,YAAW,WACDC,QAAUN,SAChBD,YAAa,EACbC,SAAW,KAEPM,SACAL,IAAIC,QAETJ,eAGAG,uBAaa,CAACJ,KAAMC,YACvBS,QAAU,YACP,0CAAYL,kDAAAA,6BACfM,aAAaD,SACbA,QAAUF,YAAW,KACjBR,KAAKM,MAAMC,KAAMF,QAClBJ,wCAU4BW,WAC/BA,WACkB,WAAdA,WAAwC,SAAdA,UACnBA,UAIR"} \ No newline at end of file +{"version":3,"file":"utils.min.js","sources":["../src/utils.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Utility functions.\n *\n * @module core/utils\n * @copyright 2019 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Pending from 'core/pending';\n\n /**\n * Create a wrapper function to throttle the execution of the given\n *\n * function to at most once every specified period.\n *\n * If the function is attempted to be executed while it's in cooldown\n * (during the wait period) then it'll immediately execute again as\n * soon as the cooldown is over.\n *\n * @method\n * @param {Function} func The function to throttle\n * @param {Number} wait The number of milliseconds to wait between executions\n * @return {Function}\n */\nexport const throttle = (func, wait) => {\n let onCooldown = false;\n let runAgain = null;\n const run = function(...args) {\n if (runAgain === null) {\n // This is the first time the function has been called.\n runAgain = false;\n } else {\n // This function has been called a second time during the wait period\n // so re-run it once the wait period is over.\n runAgain = true;\n }\n\n if (onCooldown) {\n // Function has already run for this wait period.\n return;\n }\n\n func.apply(this, args);\n onCooldown = true;\n\n setTimeout(() => {\n const recurse = runAgain;\n onCooldown = false;\n runAgain = null;\n\n if (recurse) {\n run(args);\n }\n }, wait);\n };\n\n return run;\n};\n\n/**\n * @property {Map} debounceMap A map of functions to their debounced pending promises.\n */\nconst debounceMap = new Map();\n\n/**\n * Create a wrapper function to debounce the execution of the given\n * function. Each attempt to execute the function will reset the cooldown\n * period.\n *\n * @method\n * @param {Function} func The function to debounce\n * @param {Number} wait The number of milliseconds to wait after the final attempt to execute\n * @param {Object} [options]\n * @param {boolean} [options.pending=false] Whether to wrap the debounced method in a pending promise\n * @return {Function}\n */\nexport const debounce = (\n func,\n wait,\n {\n pending = false,\n } = {},\n) => {\n let timeout = null;\n\n const returnedFunction = (...args) => {\n if (pending && !debounceMap.has(returnedFunction)) {\n debounceMap.set(returnedFunction, new Pending('core/utils:debounce'));\n }\n clearTimeout(timeout);\n timeout = setTimeout(async () => {\n // Get the current pending promise and immediately empty it.\n // This is important to allow the function to be debounced again as soon as possible.\n // We do not resolve it until later - but that's fine because the promise is appropriately scoped.\n const pendingPromise = debounceMap.get(returnedFunction);\n debounceMap.delete(returnedFunction);\n\n // Allow the debounced function to return a Promise.\n // This ensures that Behat will not continue until the function has finished executing.\n await func.apply(this, args);\n\n // Resolve the pending promise if it exists.\n pendingPromise?.resolve();\n }, wait);\n };\n\n return returnedFunction;\n};\n\n/**\n * Normalise the provided component such that '', 'moodle', and 'core' are treated consistently.\n *\n * @param {String} component\n * @returns {String}\n */\nexport const getNormalisedComponent = (component) => {\n if (component) {\n if (component !== 'moodle' && component !== 'core') {\n return component;\n }\n }\n\n return 'core';\n};\n"],"names":["func","wait","onCooldown","runAgain","run","args","apply","this","setTimeout","recurse","debounceMap","Map","pending","timeout","returnedFunction","has","set","Pending","clearTimeout","async","pendingPromise","get","delete","resolve","component"],"mappings":"mSAuCwB,CAACA,KAAMC,YACvBC,YAAa,EACbC,SAAW,WACTC,IAAM,yCAAYC,6CAAAA,2BAGhBF,SAFa,OAAbA,SASAD,aAKJF,KAAKM,MAAMC,KAAMF,MACjBH,YAAa,EAEbM,YAAW,WACDC,QAAUN,SAChBD,YAAa,EACbC,SAAW,KAEPM,SACAL,IAAIC,QAETJ,eAGAG,WAMLM,YAAc,IAAIC,sBAcA,SACpBX,KACAC,UACAW,QACIA,SAAU,0DACV,GAEAC,QAAU,WAERC,iBAAmB,0CAAIT,kDAAAA,6BACrBO,UAAYF,YAAYK,IAAID,mBAC5BJ,YAAYM,IAAIF,iBAAkB,IAAIG,iBAAQ,wBAElDC,aAAaL,SACbA,QAAUL,YAAWW,gBAIXC,eAAiBV,YAAYW,IAAIP,kBACvCJ,YAAYY,OAAOR,wBAIbd,KAAKM,gBAAYD,MAGvBe,MAAAA,gBAAAA,eAAgBG,YACjBtB,cAGAa,kDAS4BU,WAC/BA,WACkB,WAAdA,WAAwC,SAAdA,UACnBA,UAIR"} \ No newline at end of file diff --git a/lib/amd/src/comboboxsearch/search_combobox.js b/lib/amd/src/comboboxsearch/search_combobox.js index d408c27b6a8..0c8f51599c7 100644 --- a/lib/amd/src/comboboxsearch/search_combobox.js +++ b/lib/amd/src/comboboxsearch/search_combobox.js @@ -302,6 +302,11 @@ export default class { 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() === '') { From 1629a219e919abad8ed76b395c20324185cdcfce Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 6 Sep 2023 12:40:45 +0800 Subject: [PATCH 3/3] MDL-78779 gradereport_user: Replace wait steps with expectations --- .../user/tests/behat/usersearch.feature | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/grade/report/user/tests/behat/usersearch.feature b/grade/report/user/tests/behat/usersearch.feature index 0f4782bdbad..9dc3e78de74 100644 --- a/grade/report/user/tests/behat/usersearch.feature +++ b/grade/report/user/tests/behat/usersearch.feature @@ -46,7 +46,7 @@ Feature: Within the User report, a teacher can search for users. | -1- | | Teacher 1 | When I set the field "Search users" to "Turtle" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist And I confirm "Turtle Manatee" in "user" search within the gradebook widget exists And I confirm "User Example" in "user" search within the gradebook widget does not exist And I click on "Turtle Manatee" "list_item" @@ -63,7 +63,7 @@ Feature: Within the User report, a teacher can search for users. | User Test | | Dummy User | And I set the field "Search users" to "Turt" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist And I click on "Clear search input" "button" in the ".user-search" "css_element" And "View all results (5)" "option_role" should not be visible @@ -98,7 +98,7 @@ Feature: Within the User report, a teacher can search for users. # Case: Multiple users found and select only one result. Then I set the field "Search users" to "User" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist And I confirm "Dummy User" in "user" search within the gradebook widget exists And I confirm "User Example" in "user" search within the gradebook widget exists And I confirm "User Test" in "user" search within the gradebook widget exists @@ -124,7 +124,7 @@ Feature: Within the User report, a teacher can search for users. # Business case cont. When pressing enter with multiple partial matches, behave like when you select the "View all results for (Bob)" # Case: Multiple users found and select all partial matches. And I set the field "Search users" to "User" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist And I click on "View all results (5)" "option_role" And I wait until the page is ready And the following should exist in the "user-grades" table: @@ -164,10 +164,10 @@ Feature: Within the User report, a teacher can search for users. Scenario: A teacher can search for values besides the users' name Given I set the field "Search users" to "student5@example.com" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist And "Turtle Manatee" "list_item" should exist And I set the field "Search users" to "@example.com" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist # Note: All learners match this email & showing emails is current default. And I confirm "Dummy User" in "user" search within the gradebook widget exists And I confirm "User Example" in "user" search within the gradebook widget exists @@ -177,7 +177,7 @@ Feature: Within the User report, a teacher can search for users. # Search on the country field. When I set the field "Search users" to "JP" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist And I wait until "Turtle Manatee" "list_item" does not exist And I confirm "Dummy User" in "user" search within the gradebook widget exists And I confirm "User Test" in "user" search within the gradebook widget exists @@ -189,7 +189,7 @@ Feature: Within the User report, a teacher can search for users. # Search on the institution field. And I set the field "Search users" to "ABCD" - And I wait until "Dummy User" "list_item" exists + And "Dummy User" "list_item" should exist And I confirm "User Example" in "user" search within the gradebook widget exists And I confirm "User Test" in "user" search within the gradebook widget exists And I confirm "Student 1" in "user" search within the gradebook widget exists @@ -213,7 +213,7 @@ Feature: Within the User report, a teacher can search for users. # Search on the institution field then press enter to show the record set. And I set the field "Search users" to "ABC" - And I wait until "Turtle Manatee" "list_item" exists + And "Turtle Manatee" "list_item" should exist And I confirm "Dummy User" in "user" search within the gradebook widget exists And I confirm "User Example" in "user" search within the gradebook widget exists And I confirm "User Test" in "user" search within the gradebook widget exists @@ -239,7 +239,7 @@ Feature: Within the User report, a teacher can search for users. And the page should meet "wcag131, wcag141, wcag412" accessibility standards And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests # Move onto general keyboard navigation testing. - When I wait until "Turtle Manatee" "option_role" exists + When "Turtle Manatee" "option_role" should exist And I press the down key And the focused element is "Student 1" "option_role" And I press the end key @@ -258,13 +258,13 @@ Feature: Within the User report, a teacher can search for users. And I navigate to "View > User report" in the course gradebook And I set the field "Search users" to "ABC" - And I wait until "Turtle Manatee" "option_role" exists + And "Turtle Manatee" "option_role" should exist And I press the down key And the focused element is "Student 1" "option_role" # Lets check the tabbing order. And I set the field "Search users" to "ABC" - And I wait until "View all results (5)" "option_role" exists + And "View all results (5)" "option_role" should exist And I press the tab key And the focused element is "Clear search input" "button" in the ".user-search" "css_element" And I press the tab key @@ -289,7 +289,7 @@ Feature: Within the User report, a teacher can search for users. # Sometimes with behat we get unattached nodes causing spurious failures. And I wait "1" seconds And I set the field "Search users" to "ABC" - And I wait until "Turtle Manatee" "option_role" exists + And "Turtle Manatee" "option_role" should exist And I press the tab key And the focused element is "Clear search input" "button" in the ".user-search" "css_element" And I press the enter key