diff --git a/admin/tool/componentlibrary/content/moodle/components/dropdowns.md b/admin/tool/componentlibrary/content/moodle/components/dropdowns.md
index 31e391ed982..ca2acb9297f 100644
--- a/admin/tool/componentlibrary/content/moodle/components/dropdowns.md
+++ b/admin/tool/componentlibrary/content/moodle/components/dropdowns.md
@@ -184,41 +184,195 @@ $dialog = new core\output\local\dropdown\status('Open dialog button', $choice);
echo $OUTPUT->render($dialog);
{{< / php >}}
-## Capturing events with Javascript
+#### Sync button text with selected status
-Unfortunately, the current implementation does not yet include an ADM module for rendering or controlling dropdowns. However, you can create ad-hoc modules by adding id and data attributes to the relevant elements.
+The status dropdown can be configured to sync the button text with the selected status.
-Here is an example of how to include id and data attributes on the main component:
+To do so, you need to set the `buttonsync` $definition attribute to `true`.
{{< php >}}
-$dialog = new core\output\local\dropdown\dialog(
- 'Open dialog',
- 'Dialog content',
+$choice = new core\output\choicelist();
+$choice->add_option('option1', get_string('option1', YOURPLUGIN));
+$choice->add_option('option2', get_string('option2', YOURPLUGIN));
+$choice->set_selected_value('option2');
+
+// Add some attribute to select through a query selector.
+$dialog = new core\output\local\dropdown\status(
+ get_string('buttontext', YOURPLUGIN),
+ $choice,
[
- extras' => ['id' => 'mydropdown', 'data-foo' => 'bar']
+ 'extras' => ['id' => 'mydropdown'],
+ 'buttonsync' => true,
+ // With 'updatestatus' it will change the status when the user clicks an option
+ // See "Dropdown status in update mode" section for more information.
+ 'updatestatus' => true,
]
);
echo $OUTPUT->render($dialog);
{{< / php >}}
-Below is an example of how to include additional attributes to the options provided to the user:
+## Javascript
+
+### Controlling dropdowns
+
+Both `core/local/dropdown/status` and `core/local/dropdown/status` AMD modules provide functions to:
+
+- Open and close the dropdown.
+- Change the button content.
+- Get the main dropdown HTML element.
+
+Both modules are object-oriented. To get the dropdown instance, the process is as follows:
+
+1. Add id or data attributes to the main component to select it using a query selector.
+2. Import `getDropdownDialog` from `core/local/dropdown/dialog`, or `getDropdownStatus` from `core/local/dropdown/status`, depending on whether you use a dialogue or a status dropdown.
+3. Call `getDropdownDialog` or `getDropdownStatus` with the query selector to get the instance
+
+Both classes provide the following methods:
+
+- `setVisible(Boolean)` to open or close the dropdown.
+- `isVisible()` to know if it is open or closed.
+- `setButtonContent(String)` to replace the button content.
+- `getElement()`to get the main HTMLElement to add eventListeners.
+
+The following example uses the module to open the dropdown when an extra button is preset:
+
+```js
+import {getDropdownDialog} from 'core/local/dropdown/';
+
+const dialog = getDropdownDialog('[MYDROPDOWNSELECTOR]');
+document.querySelector('[data-for="openDropdown"]').addEventListener('click', (event) => {
+ event.stopPropagation();
+ dialog.setVisible(true);
+});
+```
+
+### Specific dropdown status methods
+
+The `core/local/dropdown/status` provides extra controls for the status selector, such as:
+
+- `getSelectedValue()` and `setSelectedValue(String)` to control the currently selected status.
+- `isButtonSyncEnabled()` and `setButtonSyncEnabled(Boolean)` to synchronise the button text with the selected status.
+- `isUpdateStatusEnabled()` and `setUpdateStatusEnabled(Boolean)` to control the auto-update status mode.
+
+## Using dropdown status from the frontend
+
+The dropdown status can operate in two different ways.
+
+### Dropdown status in display only
+
+The display-only is the default behaviour for any dropdown. In display-only mode, the component will show all the status values to the user, but it won't handle and click the event nor change the current status.
+
+If a plugin wants to change the status value when the user clicks, it should code a custom module to:
+
+1. Capture `click` event listeners to the choice items.
+2. Send the new status to the backend (using an ad-hoc webservice).
+3. If the webservice execution is ok, update the component value using the `setSelectedValue` instance method.
+
+The following example shows how to render a display-only dropdown status in the backend:
{{< php >}}
$choice = new core\output\choicelist('Dialog content');
-$choice->add_option('option1', 'Option 1', [
- extras' => ['id' => 'myoption1', 'data-foo' => 'bar1']
+// Add some data attributes to the choices.
+$choice->add_option(
+ 'option1',
+ get_string('option1', YOURPLUGIN), [
+ extras' => ['data-action' => 'updateActionName']
]);
-$choice->add_option('option2', 'Option 2', [
- extras' => ['id' => 'myoption2', 'data-foo' => 'bar2']
+$choice->add_option(
+ 'option2',
+ get_string('option2', YOURPLUGIN), [
+ extras' => ['data-action' => 'updateActionName']
]);
-
$choice->set_selected_value('option2');
-$dialog = new core\output\local\dropdown\status('Open dialog button', $choice);
+// Add some attribute to select through a query selector.
+$dialog = new core\output\local\dropdown\status(
+ get_string('buttontext', YOURPLUGIN),
+ $choice,
+ ['extras' => ['id' => 'mydropdown']]
+);
echo $OUTPUT->render($dialog);
{{< / php >}}
+Having this PHP code, the AMD controller could be something like:
+
+```js
+import {getDropdownStatus} from 'core/local/dropdown/status';
+import {sendValueToTheBackend} from 'YOURPLUGIN/example';
+
+const status = getDropdownStatus('#mydropdown');
+status.getElement().addEventListener('click', (event) => {
+ const option = event.target.closest("[data-action='updateActionName']");
+ if (!option) {
+ return;
+ }
+ try {
+ if(sendValueToTheBackend(option.dataset.value)) {
+ status.setSelectedValue(option.dataset.value);
+ }
+ } catch (error) {
+ // Do some error handling here.
+ }
+});
+```
+
+### Dropdown status in update mode
+
+The component will act more like an HTML radio button in update mode. It will store the current status value and will trigger `change` events when the value changes.
+
+In this case, the plugin controller has to:
+
+1. Capture the component element `change` event. Remember that, as in radio events, the `change` event won't bubble, so it cannot be delegated to a parent element.
+2. Send the new status to the backend (using an ad-hoc webservice).
+3. If the webservice execution fails, do a value rollback using the `setSelectedValue` instance method.
+
+The following example shows how to render an update mode dropdown status in the backend:
+
+{{< php >}}
+$choice = new core\output\choicelist('Dialog content');
+
+$choice->add_option('option1', get_string('option1', YOURPLUGIN));
+$choice->add_option('option2', get_string('option2', YOURPLUGIN));
+$choice->set_selected_value('option2');
+
+// Add some attribute to select through a query selector.
+$dialog = new core\output\local\dropdown\status(
+ get_string('buttontext', YOURPLUGIN),
+ $choice,
+ [
+ 'extras' => ['id' => 'mydropdown'],
+ 'updatestatus' => true,
+ ]
+);
+echo $OUTPUT->render($dialog);
+{{< / php >}}
+
+Having this PHP code, the AMD controller could be something like:
+
+```js
+import {getDropdownStatus} from 'core/local/dropdown/status';
+import {sendValueToTheBackend} from 'YOURPLUGIN/example';
+
+const status = getDropdownStatus('#mydropdown');
+let currentValue = status.getSelectedValue();
+
+status.getElement().addEventListener('change', (event) => {
+ if (currentValue == status.getSelectedValue()) {
+ return;
+ }
+ try {
+ sendValueToTheBackend(status.getSelectedValue());
+ currentValue = status.getSelectedValue();
+ } catch (error) {
+ status.setSelectedValue(currentValue);
+ }
+});
+```
+
+**Note**: the `event.target` is also the main element. You can also get the current value from `event.target.dataset.value` if you prefer.
+
## Examples
+
diff --git a/admin/tool/componentlibrary/examples/dropdowns.php b/admin/tool/componentlibrary/examples/dropdowns.php
index 4e5b7b4ad94..e9546d02724 100644
--- a/admin/tool/componentlibrary/examples/dropdowns.php
+++ b/admin/tool/componentlibrary/examples/dropdowns.php
@@ -44,7 +44,14 @@ $output = $PAGE->get_renderer('core');
echo $output->header();
+echo $output->paragraph(
+ 'Important note: dropdowns are not prepared
+ to be displayed inside iframes. You may need to scroll to see the
+ the dropdown content.'
+);
+
echo $output->heading("Dropdown dialog example", 3);
+echo '
";
echo $output->footer();
diff --git a/lib/amd/build/local/dropdown/dialog.min.js b/lib/amd/build/local/dropdown/dialog.min.js
new file mode 100644
index 00000000000..5d62574de2b
--- /dev/null
+++ b/lib/amd/build/local/dropdown/dialog.min.js
@@ -0,0 +1,10 @@
+define("core/local/dropdown/dialog",["exports","jquery","core/pagehelpers","core/pending"],(function(_exports,_jquery,_pagehelpers,_pending){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
+/**
+ * Dropdown status JS controls.
+ *
+ * @module core/local/dropdown/dialog
+ * @copyright 2023 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=_exports.getDropdownDialog=_exports.DropdownDialog=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const Selectors_dropdownButton='[data-for="dropdowndialog_button"]',Selectors_dropdownDialog='[data-for="dropdowndialog_dialog"]';class DropdownDialog{constructor(element){this.element=element,this.button=element.querySelector(Selectors_dropdownButton),this.panel=element.querySelector(Selectors_dropdownDialog)}init(){this.element.dataset.dropdownDialogInitialized||(this.button.addEventListener("keydown",this._buttonKeyHandler.bind(this)),this.panel.addEventListener("keydown",this._contentKeyHandler.bind(this)),this.element.dataset.dropdownDialogInitialized=!0)}_buttonKeyHandler(event){if("ArrowUp"===event.key||"ArrowLeft"===event.key)return event.stopPropagation(),event.preventDefault(),void this.setVisible(!1);"ArrowDown"!==event.key&&"ArrowRight"!==event.key||(event.stopPropagation(),event.preventDefault(),this.setVisible(!0),this._focusPanelContent())}_contentKeyHandler(event){let newFocus=null;"End"===event.key&&(newFocus=(0,_pagehelpers.lastFocusableElement)(this.panel)),"Home"===event.key&&(newFocus=(0,_pagehelpers.firstFocusableElement)(this.panel)),"ArrowUp"!==event.key&&"ArrowLeft"!==event.key||(newFocus=(0,_pagehelpers.previousFocusableElement)(this.panel,!1),newFocus||(newFocus=this.button)),"ArrowDown"!==event.key&&"ArrowRight"!==event.key||(newFocus=(0,_pagehelpers.nextFocusableElement)(this.panel,!1)),null!==newFocus&&(event.stopPropagation(),event.preventDefault(),newFocus.focus())}_focusPanelContent(){const pendingPromise=new _pending.default("core/dropdown/dialog:focuscontent");setTimeout((()=>{const firstFocusable=(0,_pagehelpers.firstFocusableElement)(this.panel);firstFocusable&&firstFocusable.focus(),pendingPromise.resolve()}),100)}setVisible(visible){visible!==this.isVisible()&&(0,_jquery.default)(this.button).dropdown("toggle")}isVisible(){return"true"===this.button.getAttribute("aria-expanded")}setButtonContent(content){this.button.innerHTML=content}getElement(){return this.element}}_exports.DropdownDialog=DropdownDialog;const getDropdownDialog=selector=>{const dropdownElement=document.querySelector(selector);return dropdownElement?new DropdownDialog(dropdownElement):null};_exports.getDropdownDialog=getDropdownDialog;_exports.init=selector=>{const dropdown=getDropdownDialog(selector);if(!dropdown)throw new Error("Dopdown dialog element not found: ".concat(selector));dropdown.init()}}));
+
+//# sourceMappingURL=dialog.min.js.map
\ No newline at end of file
diff --git a/lib/amd/build/local/dropdown/dialog.min.js.map b/lib/amd/build/local/dropdown/dialog.min.js.map
new file mode 100644
index 00000000000..34ac0e790b8
--- /dev/null
+++ b/lib/amd/build/local/dropdown/dialog.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"dialog.min.js","sources":["../../../src/local/dropdown/dialog.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 * Dropdown status JS controls.\n *\n * @module core/local/dropdown/dialog\n * @copyright 2023 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n// The jQuery module is only used for interacting with Bootstrap 4. It can be removed when MDL-71979 is integrated.\nimport jQuery from 'jquery';\nimport {\n firstFocusableElement,\n lastFocusableElement,\n previousFocusableElement,\n nextFocusableElement,\n} from 'core/pagehelpers';\nimport Pending from 'core/pending';\n\nconst Selectors = {\n dropdownButton: '[data-for=\"dropdowndialog_button\"]',\n dropdownDialog: '[data-for=\"dropdowndialog_dialog\"]',\n};\n\n/**\n * Dropdown dialog class.\n * @private\n */\nexport class DropdownDialog {\n /**\n * Constructor.\n * @param {HTMLElement} element The element to initialize.\n */\n constructor(element) {\n this.element = element;\n this.button = element.querySelector(Selectors.dropdownButton);\n this.panel = element.querySelector(Selectors.dropdownDialog);\n }\n\n /**\n * Initialize the subpanel element.\n *\n * This method adds the event listeners to the subpanel and the position classes.\n */\n init() {\n if (this.element.dataset.dropdownDialogInitialized) {\n return;\n }\n\n // Menu Item events.\n this.button.addEventListener('keydown', this._buttonKeyHandler.bind(this));\n // Subpanel content events.\n this.panel.addEventListener('keydown', this._contentKeyHandler.bind(this));\n\n this.element.dataset.dropdownDialogInitialized = true;\n }\n\n /**\n * Dropdown button key handler.\n * @param {Event} event\n * @private\n */\n _buttonKeyHandler(event) {\n if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {\n event.stopPropagation();\n event.preventDefault();\n this.setVisible(false);\n return;\n }\n\n if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {\n event.stopPropagation();\n event.preventDefault();\n this.setVisible(true);\n this._focusPanelContent();\n }\n }\n\n /**\n * Sub panel content key handler.\n * @param {Event} event\n * @private\n */\n _contentKeyHandler(event) {\n let newFocus = null;\n\n if (event.key === 'End') {\n newFocus = lastFocusableElement(this.panel);\n }\n if (event.key === 'Home') {\n newFocus = firstFocusableElement(this.panel);\n }\n if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {\n newFocus = previousFocusableElement(this.panel, false);\n if (!newFocus) {\n newFocus = this.button;\n }\n }\n if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {\n newFocus = nextFocusableElement(this.panel, false);\n }\n if (newFocus !== null) {\n event.stopPropagation();\n event.preventDefault();\n newFocus.focus();\n }\n }\n\n /**\n * Focus on the first focusable element of the subpanel.\n * @private\n */\n _focusPanelContent() {\n const pendingPromise = new Pending('core/dropdown/dialog:focuscontent');\n // Some Bootstrap events are triggered after the click event.\n // To prevent this from affecting the focus we wait a bit.\n setTimeout(() => {\n const firstFocusable = firstFocusableElement(this.panel);\n if (firstFocusable) {\n firstFocusable.focus();\n }\n pendingPromise.resolve();\n }, 100);\n }\n\n /**\n * Set the visibility of a subpanel.\n * @param {Boolean} visible true if the subpanel should be visible.\n */\n setVisible(visible) {\n if (visible === this.isVisible()) {\n return;\n }\n // All jQuery in this code can be replaced when MDL-71979 is integrated.\n jQuery(this.button).dropdown('toggle');\n }\n\n /**\n * Get the visibility of a subpanel.\n * @returns {Boolean} true if the subpanel is visible.\n */\n isVisible() {\n return this.button.getAttribute('aria-expanded') === 'true';\n }\n\n /**\n * Set the content of the button.\n * @param {String} content\n */\n setButtonContent(content) {\n this.button.innerHTML = content;\n }\n\n /**\n * Return the main dropdown HTML element.\n * @returns {HTMLElement} The element.\n */\n getElement() {\n return this.element;\n }\n}\n\n/**\n * Get the dropdown dialog instance from a selector.\n * @param {string} selector The query selector to init.\n * @returns {DropdownDialog|null} The dropdown dialog instance if any.\n */\nexport const getDropdownDialog = (selector) => {\n const dropdownElement = document.querySelector(selector);\n if (!dropdownElement) {\n return null;\n }\n return new DropdownDialog(dropdownElement);\n};\n\n/**\n * Initialize module.\n *\n * @method\n * @param {string} selector The query selector to init.\n */\nexport const init = (selector) => {\n const dropdown = getDropdownDialog(selector);\n if (!dropdown) {\n throw new Error(`Dopdown dialog element not found: ${selector}`);\n }\n dropdown.init();\n};\n"],"names":["Selectors","DropdownDialog","constructor","element","button","querySelector","panel","init","this","dataset","dropdownDialogInitialized","addEventListener","_buttonKeyHandler","bind","_contentKeyHandler","event","key","stopPropagation","preventDefault","setVisible","_focusPanelContent","newFocus","focus","pendingPromise","Pending","setTimeout","firstFocusable","resolve","visible","isVisible","dropdown","getAttribute","setButtonContent","content","innerHTML","getElement","getDropdownDialog","selector","dropdownElement","document","Error"],"mappings":";;;;;;;6NAiCMA,yBACc,qCADdA,yBAEc,2CAOPC,eAKTC,YAAYC,cACHA,QAAUA,aACVC,OAASD,QAAQE,cAAcL,+BAC/BM,MAAQH,QAAQE,cAAcL,0BAQvCO,OACQC,KAAKL,QAAQM,QAAQC,iCAKpBN,OAAOO,iBAAiB,UAAWH,KAAKI,kBAAkBC,KAAKL,YAE/DF,MAAMK,iBAAiB,UAAWH,KAAKM,mBAAmBD,KAAKL,YAE/DL,QAAQM,QAAQC,2BAA4B,GAQrDE,kBAAkBG,UACI,YAAdA,MAAMC,KAAmC,cAAdD,MAAMC,WACjCD,MAAME,kBACNF,MAAMG,2BACDC,YAAW,GAIF,cAAdJ,MAAMC,KAAqC,eAAdD,MAAMC,MACnCD,MAAME,kBACNF,MAAMG,sBACDC,YAAW,QACXC,sBASbN,mBAAmBC,WACXM,SAAW,KAEG,QAAdN,MAAMC,MACNK,UAAW,qCAAqBb,KAAKF,QAEvB,SAAdS,MAAMC,MACNK,UAAW,sCAAsBb,KAAKF,QAExB,YAAdS,MAAMC,KAAmC,cAAdD,MAAMC,MACjCK,UAAW,yCAAyBb,KAAKF,OAAO,GAC3Ce,WACDA,SAAWb,KAAKJ,SAGN,cAAdW,MAAMC,KAAqC,eAAdD,MAAMC,MACnCK,UAAW,qCAAqBb,KAAKF,OAAO,IAE/B,OAAbe,WACAN,MAAME,kBACNF,MAAMG,iBACNG,SAASC,SAQjBF,2BACUG,eAAiB,IAAIC,iBAAQ,qCAGnCC,YAAW,WACDC,gBAAiB,sCAAsBlB,KAAKF,OAC9CoB,gBACAA,eAAeJ,QAEnBC,eAAeI,YAChB,KAOPR,WAAWS,SACHA,UAAYpB,KAAKqB,iCAIdrB,KAAKJ,QAAQ0B,SAAS,UAOjCD,kBACyD,SAA9CrB,KAAKJ,OAAO2B,aAAa,iBAOpCC,iBAAiBC,cACR7B,OAAO8B,UAAYD,QAO5BE,oBACW3B,KAAKL,sDASPiC,kBAAqBC,iBACxBC,gBAAkBC,SAASlC,cAAcgC,iBAC1CC,gBAGE,IAAIrC,eAAeqC,iBAFf,iEAWMD,iBACXP,SAAWM,kBAAkBC,cAC9BP,eACK,IAAIU,kDAA2CH,WAEzDP,SAASvB"}
\ No newline at end of file
diff --git a/lib/amd/build/local/dropdown/status.min.js b/lib/amd/build/local/dropdown/status.min.js
new file mode 100644
index 00000000000..156ced705c6
--- /dev/null
+++ b/lib/amd/build/local/dropdown/status.min.js
@@ -0,0 +1,16 @@
+define("core/local/dropdown/status",["exports","core/local/dropdown/dialog"],(function(_exports,_dialog){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=_exports.getDropdownStatus=_exports.DropdownStatus=void 0;
+/**
+ * Dropdown status JS controls.
+ *
+ * The status controls enable extra configurarions for the dropdown like:
+ * - Sync the button text with the selected option.
+ * - Update the status of the button when the selected option changes. This will
+ * trigger a "change" event when the status changes.
+ *
+ * @module core/local/dropdown/dialog
+ * @copyright 2023 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+const Selectors_checkedIcon='[data-for="checkedIcon"]',Selectors_option='[role="option"]',Selectors_optionItem="[data-optionnumber]",Selectors_optionIcon=".option-icon",Selectors_selectedOption='[role="option"][aria-selected="true"]',Selectors_uncheckedIcon='[data-for="uncheckedIcon"]',Classes_selectedBg="bg-light",Classes_selected="selected",Classes_disabled="disabled",Classes_hidden="d-none";class DropdownStatus extends _dialog.DropdownDialog{constructor(element){super(element),this.buttonSync="true"==element.dataset.buttonSync,this.updateStatus="true"==element.dataset.updateStatus}init(){super.init(),this.element.dataset.dropdownStatusInitialized||(this.panel.addEventListener("click",this._contentClickHandler.bind(this)),"true"==this.element.dataset.buttonSync&&this.setButtonSyncEnabled(!0),"true"==this.element.dataset.updateStatus&&this.setUpdateStatusEnabled(!0),this.element.dataset.dropdownStatusInitialized=!0)}_contentClickHandler(event){const option=event.target.closest(Selectors_option);option&&"true"!==option.getAttribute("aria-disabled")&&"true"!==option.getAttribute("aria-selected")&&this.isUpdateStatusEnabled()&&this.setSelectedValue(option.dataset.value)}setSelectedValue(value){const selected=this.panel.querySelector(Selectors_selectedOption);if(selected&&selected.dataset.value===value)return;selected&&this._updateOptionChecked(selected,!1);const option=this.panel.querySelector("".concat(Selectors_option,'[data-value="').concat(value,'"]'));option&&this._updateOptionChecked(option,!0),this.isButtonSyncEnabled()&&this.syncButtonText(),this.element.dispatchEvent(new Event("change"))}_updateOptionChecked(option,checked){option.setAttribute("aria-selected",checked.toString()),option.classList.toggle(Classes_selected,checked),option.classList.toggle(Classes_disabled,checked);const optionItem=option.closest(Selectors_optionItem);optionItem&&this._updateOptionItemChecked(optionItem,checked),checked?this.element.dataset.value=option.dataset.value:this.element.dataset.value===option.dataset.value&&delete this.element.dataset.value}_updateOptionItemChecked(optionItem,checked){optionItem.classList.toggle(Classes_selectedBg,checked),optionItem.classList.toggle(Classes_selected,checked),checked?optionItem.dataset.selected=checked:null==optionItem||delete optionItem.dataset.selected;const checkedIcon=optionItem.querySelector(Selectors_checkedIcon);checkedIcon&&checkedIcon.classList.toggle(Classes_hidden,!checked);const uncheckedIcon=optionItem.querySelector(Selectors_uncheckedIcon);uncheckedIcon&&uncheckedIcon.classList.toggle(Classes_hidden,checked)}getSelectedValue(){var _selected$dataset$val;const selected=this.panel.querySelector(Selectors_selectedOption);return null!==(_selected$dataset$val=null==selected?void 0:selected.dataset.value)&&void 0!==_selected$dataset$val?_selected$dataset$val:null}setButtonSyncEnabled(value){value?this.element.dataset.buttonSync="true":delete this.element.dataset.buttonSync,value&&this.syncButtonText()}isButtonSyncEnabled(){return"true"==this.element.dataset.buttonSync}syncButtonText(){const selected=this.panel.querySelector(Selectors_selectedOption);if(!selected)return;let newText=selected.textContent;const optionIcon=this._getOptionIcon(selected);optionIcon&&(newText=optionIcon.innerHTML+newText),this.button.innerHTML=newText}setUpdateStatusEnabled(value){value?this.element.dataset.updateStatus="true":delete this.element.dataset.updateStatus}isUpdateStatusEnabled(){return"true"==this.element.dataset.updateStatus}_getOptionIcon(option){const optionItem=option.closest(Selectors_optionItem);return optionItem?optionItem.querySelector(Selectors_optionIcon):null}}_exports.DropdownStatus=DropdownStatus;const getDropdownStatus=selector=>{const dropdownElement=document.querySelector(selector);return dropdownElement?new DropdownStatus(dropdownElement):null};_exports.getDropdownStatus=getDropdownStatus;_exports.init=selector=>{const dropdown=getDropdownStatus(selector);if(!dropdown)throw new Error("Dopdown status element not found: ".concat(selector));dropdown.init()}}));
+
+//# sourceMappingURL=status.min.js.map
\ No newline at end of file
diff --git a/lib/amd/build/local/dropdown/status.min.js.map b/lib/amd/build/local/dropdown/status.min.js.map
new file mode 100644
index 00000000000..003451b4c23
--- /dev/null
+++ b/lib/amd/build/local/dropdown/status.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"status.min.js","sources":["../../../src/local/dropdown/status.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 * Dropdown status JS controls.\n *\n * The status controls enable extra configurarions for the dropdown like:\n * - Sync the button text with the selected option.\n * - Update the status of the button when the selected option changes. This will\n * trigger a \"change\" event when the status changes.\n *\n * @module core/local/dropdown/dialog\n * @copyright 2023 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {DropdownDialog} from 'core/local/dropdown/dialog';\n\nconst Selectors = {\n checkedIcon: '[data-for=\"checkedIcon\"]',\n option: '[role=\"option\"]',\n optionItem: '[data-optionnumber]',\n optionIcon: '.option-icon',\n selectedOption: '[role=\"option\"][aria-selected=\"true\"]',\n uncheckedIcon: '[data-for=\"uncheckedIcon\"]',\n};\n\nconst Classes = {\n selectedBg: 'bg-light',\n selected: 'selected',\n disabled: 'disabled',\n hidden: 'd-none',\n};\n\n/**\n * Dropdown dialog class.\n * @private\n */\nexport class DropdownStatus extends DropdownDialog {\n /**\n * Constructor.\n * @param {HTMLElement} element The element to initialize.\n */\n constructor(element) {\n super(element);\n this.buttonSync = element.dataset.buttonSync == 'true';\n this.updateStatus = element.dataset.updateStatus == 'true';\n }\n\n /**\n * Initialize the subpanel element.\n *\n * This method adds the event listeners to the subpanel and the position classes.\n * @private\n */\n init() {\n super.init();\n\n if (this.element.dataset.dropdownStatusInitialized) {\n return;\n }\n\n this.panel.addEventListener('click', this._contentClickHandler.bind(this));\n\n if (this.element.dataset.buttonSync == 'true') {\n this.setButtonSyncEnabled(true);\n }\n if (this.element.dataset.updateStatus == 'true') {\n this.setUpdateStatusEnabled(true);\n }\n\n this.element.dataset.dropdownStatusInitialized = true;\n }\n\n /**\n * Handle click events on the status content.\n * @param {Event} event The event.\n * @private\n */\n _contentClickHandler(event) {\n const option = event.target.closest(Selectors.option);\n if (!option) {\n return;\n }\n if (option.getAttribute('aria-disabled') === 'true') {\n return;\n }\n if (option.getAttribute('aria-selected') === 'true') {\n return;\n }\n if (this.isUpdateStatusEnabled()) {\n this.setSelectedValue(option.dataset.value);\n }\n }\n\n /**\n * Sets the selected value.\n * @param {string} value The value to set.\n */\n setSelectedValue(value) {\n const selected = this.panel.querySelector(Selectors.selectedOption);\n if (selected && selected.dataset.value === value) {\n return;\n }\n if (selected) {\n this._updateOptionChecked(selected, false);\n }\n const option = this.panel.querySelector(`${Selectors.option}[data-value=\"${value}\"]`);\n if (option) {\n this._updateOptionChecked(option, true);\n }\n if (this.isButtonSyncEnabled()) {\n this.syncButtonText();\n }\n // Emit standard radio button event with the selected option.\n this.element.dispatchEvent(new Event('change'));\n }\n\n /**\n * Update the option checked content.\n * @private\n * @param {HTMLElement} option the option element to set\n * @param {Boolean} checked the new checked value\n */\n _updateOptionChecked(option, checked) {\n option.setAttribute('aria-selected', checked.toString());\n option.classList.toggle(Classes.selected, checked);\n option.classList.toggle(Classes.disabled, checked);\n\n const optionItem = option.closest(Selectors.optionItem);\n if (optionItem) {\n this._updateOptionItemChecked(optionItem, checked);\n }\n\n if (checked) {\n this.element.dataset.value = option.dataset.value;\n } else if (this.element.dataset.value === option.dataset.value) {\n delete this.element.dataset.value;\n }\n }\n\n /**\n * Update the option item checked content.\n * @private\n * @param {HTMLElement} optionItem\n * @param {Boolean} checked\n */\n _updateOptionItemChecked(optionItem, checked) {\n optionItem.classList.toggle(Classes.selectedBg, checked);\n optionItem.classList.toggle(Classes.selected, checked);\n if (checked) {\n optionItem.dataset.selected = checked;\n } else {\n delete optionItem?.dataset.selected;\n }\n const checkedIcon = optionItem.querySelector(Selectors.checkedIcon);\n if (checkedIcon) {\n checkedIcon.classList.toggle(Classes.hidden, !checked);\n }\n const uncheckedIcon = optionItem.querySelector(Selectors.uncheckedIcon);\n if (uncheckedIcon) {\n uncheckedIcon.classList.toggle(Classes.hidden, checked);\n }\n }\n\n\n /**\n * Return the selected value.\n * @returns {string|null} The selected value.\n */\n getSelectedValue() {\n const selected = this.panel.querySelector(Selectors.selectedOption);\n return selected?.dataset.value ?? null;\n }\n\n /**\n * Set the button sync value.\n *\n * If the sync is enabled, the button text will show the selected option.\n *\n * @param {Boolean} value The value to set.\n */\n setButtonSyncEnabled(value) {\n if (value) {\n this.element.dataset.buttonSync = 'true';\n } else {\n delete this.element.dataset.buttonSync;\n }\n if (value) {\n this.syncButtonText();\n }\n }\n\n /**\n * Return if the button sync is enabled.\n * @returns {Boolean} The button sync value.\n */\n isButtonSyncEnabled() {\n return this.element.dataset.buttonSync == 'true';\n }\n\n /**\n * Sync the button text with the selected option.\n */\n syncButtonText() {\n const selected = this.panel.querySelector(Selectors.selectedOption);\n if (!selected) {\n return;\n }\n let newText = selected.textContent;\n const optionIcon = this._getOptionIcon(selected);\n if (optionIcon) {\n newText = optionIcon.innerHTML + newText;\n }\n this.button.innerHTML = newText;\n }\n\n /**\n * Set the update status value.\n *\n * @param {Boolean} value The value to set.\n */\n setUpdateStatusEnabled(value) {\n if (value) {\n this.element.dataset.updateStatus = 'true';\n } else {\n delete this.element.dataset.updateStatus;\n }\n }\n\n /**\n * Return if the update status is enabled.\n * @returns {Boolean} The update status value.\n */\n isUpdateStatusEnabled() {\n return this.element.dataset.updateStatus == 'true';\n }\n\n _getOptionIcon(option) {\n const optionItem = option.closest(Selectors.optionItem);\n if (!optionItem) {\n return null;\n }\n return optionItem.querySelector(Selectors.optionIcon);\n }\n\n}\n\n/**\n * Get the dropdown dialog instance form a selector.\n * @param {string} selector The query selector to init.\n * @returns {DropdownStatus|null} The dropdown dialog instance if any.\n */\nexport const getDropdownStatus = (selector) => {\n const dropdownElement = document.querySelector(selector);\n if (!dropdownElement) {\n return null;\n }\n return new DropdownStatus(dropdownElement);\n};\n\n/**\n * Initialize module.\n *\n * @method\n * @param {string} selector The query selector to init.\n */\nexport const init = (selector) => {\n const dropdown = getDropdownStatus(selector);\n if (!dropdown) {\n throw new Error(`Dopdown status element not found: ${selector}`);\n }\n dropdown.init();\n};\n"],"names":["Selectors","Classes","DropdownStatus","DropdownDialog","constructor","element","buttonSync","dataset","updateStatus","init","this","dropdownStatusInitialized","panel","addEventListener","_contentClickHandler","bind","setButtonSyncEnabled","setUpdateStatusEnabled","event","option","target","closest","getAttribute","isUpdateStatusEnabled","setSelectedValue","value","selected","querySelector","_updateOptionChecked","isButtonSyncEnabled","syncButtonText","dispatchEvent","Event","checked","setAttribute","toString","classList","toggle","optionItem","_updateOptionItemChecked","checkedIcon","uncheckedIcon","getSelectedValue","newText","textContent","optionIcon","_getOptionIcon","innerHTML","button","getDropdownStatus","selector","dropdownElement","document","dropdown","Error"],"mappings":";;;;;;;;;;;;;MA8BMA,sBACW,2BADXA,iBAEM,kBAFNA,qBAGU,sBAHVA,qBAIU,eAJVA,yBAKc,wCALdA,wBAMa,6BAGbC,mBACU,WADVA,iBAEQ,WAFRA,iBAGQ,WAHRA,eAIM,eAOCC,uBAAuBC,uBAKhCC,YAAYC,eACFA,cACDC,WAA2C,QAA9BD,QAAQE,QAAQD,gBAC7BE,aAA+C,QAAhCH,QAAQE,QAAQC,aASxCC,aACUA,OAEFC,KAAKL,QAAQE,QAAQI,iCAIpBC,MAAMC,iBAAiB,QAASH,KAAKI,qBAAqBC,KAAKL,OAE7B,QAAnCA,KAAKL,QAAQE,QAAQD,iBAChBU,sBAAqB,GAEW,QAArCN,KAAKL,QAAQE,QAAQC,mBAChBS,wBAAuB,QAG3BZ,QAAQE,QAAQI,2BAA4B,GAQrDG,qBAAqBI,aACXC,OAASD,MAAME,OAAOC,QAAQrB,kBAC/BmB,QAGwC,SAAzCA,OAAOG,aAAa,kBAGqB,SAAzCH,OAAOG,aAAa,kBAGpBZ,KAAKa,8BACAC,iBAAiBL,OAAOZ,QAAQkB,OAQ7CD,iBAAiBC,aACPC,SAAWhB,KAAKE,MAAMe,cAAc3B,6BACtC0B,UAAYA,SAASnB,QAAQkB,QAAUA,aAGvCC,eACKE,qBAAqBF,UAAU,SAElCP,OAAST,KAAKE,MAAMe,wBAAiB3B,yCAAgCyB,aACvEN,aACKS,qBAAqBT,QAAQ,GAElCT,KAAKmB,4BACAC,sBAGJzB,QAAQ0B,cAAc,IAAIC,MAAM,WASzCJ,qBAAqBT,OAAQc,SACzBd,OAAOe,aAAa,gBAAiBD,QAAQE,YAC7ChB,OAAOiB,UAAUC,OAAOpC,iBAAkBgC,SAC1Cd,OAAOiB,UAAUC,OAAOpC,iBAAkBgC,eAEpCK,WAAanB,OAAOE,QAAQrB,sBAC9BsC,iBACKC,yBAAyBD,WAAYL,SAG1CA,aACK5B,QAAQE,QAAQkB,MAAQN,OAAOZ,QAAQkB,MACrCf,KAAKL,QAAQE,QAAQkB,QAAUN,OAAOZ,QAAQkB,cAC9Cf,KAAKL,QAAQE,QAAQkB,MAUpCc,yBAAyBD,WAAYL,SACjCK,WAAWF,UAAUC,OAAOpC,mBAAoBgC,SAChDK,WAAWF,UAAUC,OAAOpC,iBAAkBgC,SAC1CA,QACAK,WAAW/B,QAAQmB,SAAWO,QAEvBK,MAAAA,mBAAAA,WAAY/B,QAAQmB,eAEzBc,YAAcF,WAAWX,cAAc3B,uBACzCwC,aACAA,YAAYJ,UAAUC,OAAOpC,gBAAiBgC,eAE5CQ,cAAgBH,WAAWX,cAAc3B,yBAC3CyC,eACAA,cAAcL,UAAUC,OAAOpC,eAAgBgC,SASvDS,mDACUhB,SAAWhB,KAAKE,MAAMe,cAAc3B,+DACnC0B,MAAAA,gBAAAA,SAAUnB,QAAQkB,6DAAS,KAUtCT,qBAAqBS,OACbA,WACKpB,QAAQE,QAAQD,WAAa,cAE3BI,KAAKL,QAAQE,QAAQD,WAE5BmB,YACKK,iBAQbD,4BAC8C,QAAnCnB,KAAKL,QAAQE,QAAQD,WAMhCwB,uBACUJ,SAAWhB,KAAKE,MAAMe,cAAc3B,8BACrC0B,oBAGDiB,QAAUjB,SAASkB,kBACjBC,WAAanC,KAAKoC,eAAepB,UACnCmB,aACAF,QAAUE,WAAWE,UAAYJ,cAEhCK,OAAOD,UAAYJ,QAQ5B1B,uBAAuBQ,OACfA,WACKpB,QAAQE,QAAQC,aAAe,cAE7BE,KAAKL,QAAQE,QAAQC,aAQpCe,8BACgD,QAArCb,KAAKL,QAAQE,QAAQC,aAGhCsC,eAAe3B,cACLmB,WAAanB,OAAOE,QAAQrB,6BAC7BsC,WAGEA,WAAWX,cAAc3B,sBAFrB,mDAYNiD,kBAAqBC,iBACxBC,gBAAkBC,SAASzB,cAAcuB,iBAC1CC,gBAGE,IAAIjD,eAAeiD,iBAFf,iEAWMD,iBACXG,SAAWJ,kBAAkBC,cAC9BG,eACK,IAAIC,kDAA2CJ,WAEzDG,SAAS5C"}
\ No newline at end of file
diff --git a/lib/amd/build/pagehelpers.min.js b/lib/amd/build/pagehelpers.min.js
index 5d7127e508d..49a34967e43 100644
--- a/lib/amd/build/pagehelpers.min.js
+++ b/lib/amd/build/pagehelpers.min.js
@@ -1,4 +1,4 @@
-define("core/pagehelpers",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.previousFocusableElement=_exports.nextFocusableElement=_exports.isSmall=_exports.isLarge=_exports.isExtraSmall=_exports.getCurrentWidth=_exports.focusableElements=_exports.firstFocusableElement=void 0;
+define("core/pagehelpers",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.previousFocusableElement=_exports.nextFocusableElement=_exports.lastFocusableElement=_exports.isSmall=_exports.isLarge=_exports.isExtraSmall=_exports.getCurrentWidth=_exports.focusableElements=_exports.firstFocusableElement=void 0;
/**
* Page utility helpers.
*
@@ -6,6 +6,6 @@ define("core/pagehelpers",["exports"],(function(_exports){Object.defineProperty(
* @copyright 2023 Ferran Recio
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-const Sizes_small=576,Sizes_medium=991,Sizes_large=1400,Selectors_focusable='a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',getCurrentWidth=()=>{const DomRect=document.body.getBoundingClientRect();return DomRect.x+DomRect.width};_exports.getCurrentWidth=getCurrentWidth;_exports.isExtraSmall=()=>getCurrentWidth()getCurrentWidth()getCurrentWidth()>=Sizes_large;_exports.firstFocusableElement=container=>(container||document).querySelector(Selectors_focusable);const focusableElements=container=>(container||document).querySelectorAll(Selectors_focusable);_exports.focusableElements=focusableElements;_exports.previousFocusableElement=(container,loopSelection)=>getRelativeFocusableElement(container,loopSelection,-1);_exports.nextFocusableElement=(container,loopSelection)=>getRelativeFocusableElement(container,loopSelection,1);const getRelativeFocusableElement=(container,loopSelection,direction)=>{var _focusables;const focusedElement=document.activeElement,focusables=[...focusableElements(container)],focusedIndex=focusables.indexOf(focusedElement);if(-1===focusedIndex)return null;const newIndex=focusedIndex+direction;return void 0!==focusables[newIndex]?focusables[newIndex]:1!=loopSelection?null:direction>0?null!==(_focusables$=focusables[0])&&void 0!==_focusables$?_focusables$:null:null!==(_focusables=focusables[focusables.length-1])&&void 0!==_focusables?_focusables:null;var _focusables$}}));
+const Sizes_small=576,Sizes_medium=991,Sizes_large=1400,Selectors_focusable='a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',getCurrentWidth=()=>{const DomRect=document.body.getBoundingClientRect();return DomRect.x+DomRect.width};_exports.getCurrentWidth=getCurrentWidth;_exports.isExtraSmall=()=>getCurrentWidth()getCurrentWidth()getCurrentWidth()>=Sizes_large;_exports.firstFocusableElement=container=>(container||document).querySelector(Selectors_focusable);_exports.lastFocusableElement=container=>{var _focusableElements;const focusableElements=(container||document).querySelectorAll(Selectors_focusable);return null!==(_focusableElements=focusableElements[focusableElements.length-1])&&void 0!==_focusableElements?_focusableElements:null};const focusableElements=container=>(container||document).querySelectorAll(Selectors_focusable);_exports.focusableElements=focusableElements;_exports.previousFocusableElement=(container,loopSelection)=>getRelativeFocusableElement(container,loopSelection,-1);_exports.nextFocusableElement=(container,loopSelection)=>getRelativeFocusableElement(container,loopSelection,1);const getRelativeFocusableElement=(container,loopSelection,direction)=>{var _focusables;const focusedElement=document.activeElement,focusables=[...focusableElements(container)],focusedIndex=focusables.indexOf(focusedElement);if(-1===focusedIndex)return null;const newIndex=focusedIndex+direction;return void 0!==focusables[newIndex]?focusables[newIndex]:1!=loopSelection?null:direction>0?null!==(_focusables$=focusables[0])&&void 0!==_focusables$?_focusables$:null:null!==(_focusables=focusables[focusables.length-1])&&void 0!==_focusables?_focusables:null;var _focusables$}}));
//# sourceMappingURL=pagehelpers.min.js.map
\ No newline at end of file
diff --git a/lib/amd/build/pagehelpers.min.js.map b/lib/amd/build/pagehelpers.min.js.map
index 0792053d5b1..7d183d5e1eb 100644
--- a/lib/amd/build/pagehelpers.min.js.map
+++ b/lib/amd/build/pagehelpers.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"pagehelpers.min.js","sources":["../src/pagehelpers.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 * Page utility helpers.\n *\n * @module core/pagehelpers\n * @copyright 2023 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * Maximum sizes for breakpoints. This needs to correspond with Bootstrap\n * Breakpoints\n *\n * @private\n */\nconst Sizes = {\n small: 576,\n medium: 991,\n large: 1400\n};\n\nconst Selectors = {\n focusable: 'a, button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n};\n\n/**\n * Get the current body width.\n * @returns {number} the current body width.\n */\nexport const getCurrentWidth = () => {\n const DomRect = document.body.getBoundingClientRect();\n return DomRect.x + DomRect.width;\n};\n\n/**\n * Check if the user uses an extra small size browser.\n *\n * @returns {boolean} true if the body is smaller than sizes.small max size.\n */\nexport const isExtraSmall = () => {\n const browserWidth = getCurrentWidth();\n return browserWidth < Sizes.small;\n};\n\n/**\n * Check if the user uses a small size browser.\n *\n * @returns {boolean} true if the body is smaller than sizes.medium max size.\n */\nexport const isSmall = () => {\n const browserWidth = getCurrentWidth();\n return browserWidth < Sizes.medium;\n};\n\n/**\n * Check if the user uses a large size browser.\n *\n * @returns {boolean} true if the body is smaller than sizes.large max size.\n */\nexport const isLarge = () => {\n const browserWidth = getCurrentWidth();\n return browserWidth >= Sizes.large;\n};\n\n/**\n * Get the first focusable element inside a container.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @returns {HTMLElement|null}\n */\nexport const firstFocusableElement = (container) => {\n const containerElement = container || document;\n return containerElement.querySelector(Selectors.focusable);\n};\n\n/**\n * Get all focusable elements inside a container.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @returns {HTMLElement[]}\n */\nexport const focusableElements = (container) => {\n const containerElement = container || document;\n return containerElement.querySelectorAll(Selectors.focusable);\n};\n\n/**\n * Get the previous focusable element in a container.\n * It uses the current focused element to know where to start the search.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.\n * @returns {HTMLElement|null}\n */\nexport const previousFocusableElement = (container, loopSelection) => {\n return getRelativeFocusableElement(container, loopSelection, -1);\n};\n\n/**\n * Get the next focusable element in a container.\n * It uses the current focused element to know where to start the search.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.\n * @returns {HTMLElement|null}\n */\nexport const nextFocusableElement = (container, loopSelection) => {\n return getRelativeFocusableElement(container, loopSelection, 1);\n};\n\n/**\n * Internal function to get the next or previous focusable element.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @param {Boolean} [loopSelection] Whether to loop selection or not.\n * @param {Number} [direction] Direction to search in. 1 for next, -1 for previous.\n * @returns {HTMLElement|null}\n * @private\n */\nconst getRelativeFocusableElement = (container, loopSelection, direction) => {\n const focusedElement = document.activeElement;\n const focusables = [...focusableElements(container)];\n const focusedIndex = focusables.indexOf(focusedElement);\n\n if (focusedIndex === -1) {\n return null;\n }\n\n const newIndex = focusedIndex + direction;\n\n if (focusables[newIndex] !== undefined) {\n return focusables[newIndex];\n }\n if (loopSelection != true) {\n return null;\n }\n if (direction > 0) {\n return focusables[0] ?? null;\n }\n return focusables[focusables.length - 1] ?? null;\n};\n"],"names":["Sizes","Selectors","getCurrentWidth","DomRect","document","body","getBoundingClientRect","x","width","container","querySelector","focusableElements","querySelectorAll","loopSelection","getRelativeFocusableElement","direction","focusedElement","activeElement","focusables","focusedIndex","indexOf","newIndex","undefined","length"],"mappings":";;;;;;;;MA6BMA,YACK,IADLA,aAEM,IAFNA,YAGK,KAGLC,oBACS,sEAOFC,gBAAkB,WACrBC,QAAUC,SAASC,KAAKC,+BACvBH,QAAQI,EAAIJ,QAAQK,sEAQH,IACHN,kBACCF,6BAQH,IACEE,kBACCF,8BAQH,IACEE,mBACEF,2CAQWS,YACTA,WAAaL,UACdM,cAAcT,2BAQ7BU,kBAAqBF,YACLA,WAAaL,UACdQ,iBAAiBX,oGAUL,CAACQ,UAAWI,gBACzCC,4BAA4BL,UAAWI,eAAgB,iCAU9B,CAACJ,UAAWI,gBACrCC,4BAA4BL,UAAWI,cAAe,SAW3DC,4BAA8B,CAACL,UAAWI,cAAeE,mCACrDC,eAAiBZ,SAASa,cAC1BC,WAAa,IAAIP,kBAAkBF,YACnCU,aAAeD,WAAWE,QAAQJ,oBAElB,IAAlBG,oBACO,WAGLE,SAAWF,aAAeJ,sBAEHO,IAAzBJ,WAAWG,UACJH,WAAWG,UAED,GAAjBR,cACO,KAEPE,UAAY,uBACLG,WAAW,wCAAM,yBAErBA,WAAWA,WAAWK,OAAS,sCAAM"}
\ No newline at end of file
+{"version":3,"file":"pagehelpers.min.js","sources":["../src/pagehelpers.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 * Page utility helpers.\n *\n * @module core/pagehelpers\n * @copyright 2023 Ferran Recio \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * Maximum sizes for breakpoints. This needs to correspond with Bootstrap\n * Breakpoints\n *\n * @private\n */\nconst Sizes = {\n small: 576,\n medium: 991,\n large: 1400\n};\n\nconst Selectors = {\n focusable: 'a, button, input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n};\n\n/**\n * Get the current body width.\n * @returns {number} the current body width.\n */\nexport const getCurrentWidth = () => {\n const DomRect = document.body.getBoundingClientRect();\n return DomRect.x + DomRect.width;\n};\n\n/**\n * Check if the user uses an extra small size browser.\n *\n * @returns {boolean} true if the body is smaller than sizes.small max size.\n */\nexport const isExtraSmall = () => {\n const browserWidth = getCurrentWidth();\n return browserWidth < Sizes.small;\n};\n\n/**\n * Check if the user uses a small size browser.\n *\n * @returns {boolean} true if the body is smaller than sizes.medium max size.\n */\nexport const isSmall = () => {\n const browserWidth = getCurrentWidth();\n return browserWidth < Sizes.medium;\n};\n\n/**\n * Check if the user uses a large size browser.\n *\n * @returns {boolean} true if the body is smaller than sizes.large max size.\n */\nexport const isLarge = () => {\n const browserWidth = getCurrentWidth();\n return browserWidth >= Sizes.large;\n};\n\n/**\n * Get the first focusable element inside a container.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @returns {HTMLElement|null}\n */\nexport const firstFocusableElement = (container) => {\n const containerElement = container || document;\n return containerElement.querySelector(Selectors.focusable);\n};\n\n/**\n * Get the last focusable element inside a container.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @returns {HTMLElement|null}\n */\nexport const lastFocusableElement = (container) => {\n const containerElement = container || document;\n const focusableElements = containerElement.querySelectorAll(Selectors.focusable);\n return focusableElements[focusableElements.length - 1] ?? null;\n};\n\n/**\n * Get all focusable elements inside a container.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @returns {HTMLElement[]}\n */\nexport const focusableElements = (container) => {\n const containerElement = container || document;\n return containerElement.querySelectorAll(Selectors.focusable);\n};\n\n/**\n * Get the previous focusable element in a container.\n * It uses the current focused element to know where to start the search.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.\n * @returns {HTMLElement|null}\n */\nexport const previousFocusableElement = (container, loopSelection) => {\n return getRelativeFocusableElement(container, loopSelection, -1);\n};\n\n/**\n * Get the next focusable element in a container.\n * It uses the current focused element to know where to start the search.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.\n * @returns {HTMLElement|null}\n */\nexport const nextFocusableElement = (container, loopSelection) => {\n return getRelativeFocusableElement(container, loopSelection, 1);\n};\n\n/**\n * Internal function to get the next or previous focusable element.\n * @param {HTMLElement} [container] Container to search in. Defaults to document.\n * @param {Boolean} [loopSelection] Whether to loop selection or not.\n * @param {Number} [direction] Direction to search in. 1 for next, -1 for previous.\n * @returns {HTMLElement|null}\n * @private\n */\nconst getRelativeFocusableElement = (container, loopSelection, direction) => {\n const focusedElement = document.activeElement;\n const focusables = [...focusableElements(container)];\n const focusedIndex = focusables.indexOf(focusedElement);\n\n if (focusedIndex === -1) {\n return null;\n }\n\n const newIndex = focusedIndex + direction;\n\n if (focusables[newIndex] !== undefined) {\n return focusables[newIndex];\n }\n if (loopSelection != true) {\n return null;\n }\n if (direction > 0) {\n return focusables[0] ?? null;\n }\n return focusables[focusables.length - 1] ?? null;\n};\n"],"names":["Sizes","Selectors","getCurrentWidth","DomRect","document","body","getBoundingClientRect","x","width","container","querySelector","focusableElements","querySelectorAll","length","loopSelection","getRelativeFocusableElement","direction","focusedElement","activeElement","focusables","focusedIndex","indexOf","newIndex","undefined"],"mappings":";;;;;;;;MA6BMA,YACK,IADLA,aAEM,IAFNA,YAGK,KAGLC,oBACS,sEAOFC,gBAAkB,WACrBC,QAAUC,SAASC,KAAKC,+BACvBH,QAAQI,EAAIJ,QAAQK,sEAQH,IACHN,kBACCF,6BAQH,IACEE,kBACCF,8BAQH,IACEE,mBACEF,2CAQWS,YACTA,WAAaL,UACdM,cAAcT,mDAQLQ,yCAE3BE,mBADmBF,WAAaL,UACKQ,iBAAiBX,uDACrDU,kBAAkBA,kBAAkBE,OAAS,oDAAM,YAQjDF,kBAAqBF,YACLA,WAAaL,UACdQ,iBAAiBX,oGAUL,CAACQ,UAAWK,gBACzCC,4BAA4BN,UAAWK,eAAgB,iCAU9B,CAACL,UAAWK,gBACrCC,4BAA4BN,UAAWK,cAAe,SAW3DC,4BAA8B,CAACN,UAAWK,cAAeE,mCACrDC,eAAiBb,SAASc,cAC1BC,WAAa,IAAIR,kBAAkBF,YACnCW,aAAeD,WAAWE,QAAQJ,oBAElB,IAAlBG,oBACO,WAGLE,SAAWF,aAAeJ,sBAEHO,IAAzBJ,WAAWG,UACJH,WAAWG,UAED,GAAjBR,cACO,KAEPE,UAAY,uBACLG,WAAW,wCAAM,yBAErBA,WAAWA,WAAWN,OAAS,sCAAM"}
\ No newline at end of file
diff --git a/lib/amd/src/local/dropdown/dialog.js b/lib/amd/src/local/dropdown/dialog.js
new file mode 100644
index 00000000000..e4f9efe8bd5
--- /dev/null
+++ b/lib/amd/src/local/dropdown/dialog.js
@@ -0,0 +1,202 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Dropdown status JS controls.
+ *
+ * @module core/local/dropdown/dialog
+ * @copyright 2023 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// The jQuery module is only used for interacting with Bootstrap 4. It can be removed when MDL-71979 is integrated.
+import jQuery from 'jquery';
+import {
+ firstFocusableElement,
+ lastFocusableElement,
+ previousFocusableElement,
+ nextFocusableElement,
+} from 'core/pagehelpers';
+import Pending from 'core/pending';
+
+const Selectors = {
+ dropdownButton: '[data-for="dropdowndialog_button"]',
+ dropdownDialog: '[data-for="dropdowndialog_dialog"]',
+};
+
+/**
+ * Dropdown dialog class.
+ * @private
+ */
+export class DropdownDialog {
+ /**
+ * Constructor.
+ * @param {HTMLElement} element The element to initialize.
+ */
+ constructor(element) {
+ this.element = element;
+ this.button = element.querySelector(Selectors.dropdownButton);
+ this.panel = element.querySelector(Selectors.dropdownDialog);
+ }
+
+ /**
+ * Initialize the subpanel element.
+ *
+ * This method adds the event listeners to the subpanel and the position classes.
+ */
+ init() {
+ if (this.element.dataset.dropdownDialogInitialized) {
+ return;
+ }
+
+ // Menu Item events.
+ this.button.addEventListener('keydown', this._buttonKeyHandler.bind(this));
+ // Subpanel content events.
+ this.panel.addEventListener('keydown', this._contentKeyHandler.bind(this));
+
+ this.element.dataset.dropdownDialogInitialized = true;
+ }
+
+ /**
+ * Dropdown button key handler.
+ * @param {Event} event
+ * @private
+ */
+ _buttonKeyHandler(event) {
+ if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
+ event.stopPropagation();
+ event.preventDefault();
+ this.setVisible(false);
+ return;
+ }
+
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
+ event.stopPropagation();
+ event.preventDefault();
+ this.setVisible(true);
+ this._focusPanelContent();
+ }
+ }
+
+ /**
+ * Sub panel content key handler.
+ * @param {Event} event
+ * @private
+ */
+ _contentKeyHandler(event) {
+ let newFocus = null;
+
+ if (event.key === 'End') {
+ newFocus = lastFocusableElement(this.panel);
+ }
+ if (event.key === 'Home') {
+ newFocus = firstFocusableElement(this.panel);
+ }
+ if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
+ newFocus = previousFocusableElement(this.panel, false);
+ if (!newFocus) {
+ newFocus = this.button;
+ }
+ }
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
+ newFocus = nextFocusableElement(this.panel, false);
+ }
+ if (newFocus !== null) {
+ event.stopPropagation();
+ event.preventDefault();
+ newFocus.focus();
+ }
+ }
+
+ /**
+ * Focus on the first focusable element of the subpanel.
+ * @private
+ */
+ _focusPanelContent() {
+ const pendingPromise = new Pending('core/dropdown/dialog:focuscontent');
+ // Some Bootstrap events are triggered after the click event.
+ // To prevent this from affecting the focus we wait a bit.
+ setTimeout(() => {
+ const firstFocusable = firstFocusableElement(this.panel);
+ if (firstFocusable) {
+ firstFocusable.focus();
+ }
+ pendingPromise.resolve();
+ }, 100);
+ }
+
+ /**
+ * Set the visibility of a subpanel.
+ * @param {Boolean} visible true if the subpanel should be visible.
+ */
+ setVisible(visible) {
+ if (visible === this.isVisible()) {
+ return;
+ }
+ // All jQuery in this code can be replaced when MDL-71979 is integrated.
+ jQuery(this.button).dropdown('toggle');
+ }
+
+ /**
+ * Get the visibility of a subpanel.
+ * @returns {Boolean} true if the subpanel is visible.
+ */
+ isVisible() {
+ return this.button.getAttribute('aria-expanded') === 'true';
+ }
+
+ /**
+ * Set the content of the button.
+ * @param {String} content
+ */
+ setButtonContent(content) {
+ this.button.innerHTML = content;
+ }
+
+ /**
+ * Return the main dropdown HTML element.
+ * @returns {HTMLElement} The element.
+ */
+ getElement() {
+ return this.element;
+ }
+}
+
+/**
+ * Get the dropdown dialog instance from a selector.
+ * @param {string} selector The query selector to init.
+ * @returns {DropdownDialog|null} The dropdown dialog instance if any.
+ */
+export const getDropdownDialog = (selector) => {
+ const dropdownElement = document.querySelector(selector);
+ if (!dropdownElement) {
+ return null;
+ }
+ return new DropdownDialog(dropdownElement);
+};
+
+/**
+ * Initialize module.
+ *
+ * @method
+ * @param {string} selector The query selector to init.
+ */
+export const init = (selector) => {
+ const dropdown = getDropdownDialog(selector);
+ if (!dropdown) {
+ throw new Error(`Dopdown dialog element not found: ${selector}`);
+ }
+ dropdown.init();
+};
diff --git a/lib/amd/src/local/dropdown/status.js b/lib/amd/src/local/dropdown/status.js
new file mode 100644
index 00000000000..98a75d0232b
--- /dev/null
+++ b/lib/amd/src/local/dropdown/status.js
@@ -0,0 +1,286 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Dropdown status JS controls.
+ *
+ * The status controls enable extra configurarions for the dropdown like:
+ * - Sync the button text with the selected option.
+ * - Update the status of the button when the selected option changes. This will
+ * trigger a "change" event when the status changes.
+ *
+ * @module core/local/dropdown/dialog
+ * @copyright 2023 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import {DropdownDialog} from 'core/local/dropdown/dialog';
+
+const Selectors = {
+ checkedIcon: '[data-for="checkedIcon"]',
+ option: '[role="option"]',
+ optionItem: '[data-optionnumber]',
+ optionIcon: '.option-icon',
+ selectedOption: '[role="option"][aria-selected="true"]',
+ uncheckedIcon: '[data-for="uncheckedIcon"]',
+};
+
+const Classes = {
+ selectedBg: 'bg-light',
+ selected: 'selected',
+ disabled: 'disabled',
+ hidden: 'd-none',
+};
+
+/**
+ * Dropdown dialog class.
+ * @private
+ */
+export class DropdownStatus extends DropdownDialog {
+ /**
+ * Constructor.
+ * @param {HTMLElement} element The element to initialize.
+ */
+ constructor(element) {
+ super(element);
+ this.buttonSync = element.dataset.buttonSync == 'true';
+ this.updateStatus = element.dataset.updateStatus == 'true';
+ }
+
+ /**
+ * Initialize the subpanel element.
+ *
+ * This method adds the event listeners to the subpanel and the position classes.
+ * @private
+ */
+ init() {
+ super.init();
+
+ if (this.element.dataset.dropdownStatusInitialized) {
+ return;
+ }
+
+ this.panel.addEventListener('click', this._contentClickHandler.bind(this));
+
+ if (this.element.dataset.buttonSync == 'true') {
+ this.setButtonSyncEnabled(true);
+ }
+ if (this.element.dataset.updateStatus == 'true') {
+ this.setUpdateStatusEnabled(true);
+ }
+
+ this.element.dataset.dropdownStatusInitialized = true;
+ }
+
+ /**
+ * Handle click events on the status content.
+ * @param {Event} event The event.
+ * @private
+ */
+ _contentClickHandler(event) {
+ const option = event.target.closest(Selectors.option);
+ if (!option) {
+ return;
+ }
+ if (option.getAttribute('aria-disabled') === 'true') {
+ return;
+ }
+ if (option.getAttribute('aria-selected') === 'true') {
+ return;
+ }
+ if (this.isUpdateStatusEnabled()) {
+ this.setSelectedValue(option.dataset.value);
+ }
+ }
+
+ /**
+ * Sets the selected value.
+ * @param {string} value The value to set.
+ */
+ setSelectedValue(value) {
+ const selected = this.panel.querySelector(Selectors.selectedOption);
+ if (selected && selected.dataset.value === value) {
+ return;
+ }
+ if (selected) {
+ this._updateOptionChecked(selected, false);
+ }
+ const option = this.panel.querySelector(`${Selectors.option}[data-value="${value}"]`);
+ if (option) {
+ this._updateOptionChecked(option, true);
+ }
+ if (this.isButtonSyncEnabled()) {
+ this.syncButtonText();
+ }
+ // Emit standard radio button event with the selected option.
+ this.element.dispatchEvent(new Event('change'));
+ }
+
+ /**
+ * Update the option checked content.
+ * @private
+ * @param {HTMLElement} option the option element to set
+ * @param {Boolean} checked the new checked value
+ */
+ _updateOptionChecked(option, checked) {
+ option.setAttribute('aria-selected', checked.toString());
+ option.classList.toggle(Classes.selected, checked);
+ option.classList.toggle(Classes.disabled, checked);
+
+ const optionItem = option.closest(Selectors.optionItem);
+ if (optionItem) {
+ this._updateOptionItemChecked(optionItem, checked);
+ }
+
+ if (checked) {
+ this.element.dataset.value = option.dataset.value;
+ } else if (this.element.dataset.value === option.dataset.value) {
+ delete this.element.dataset.value;
+ }
+ }
+
+ /**
+ * Update the option item checked content.
+ * @private
+ * @param {HTMLElement} optionItem
+ * @param {Boolean} checked
+ */
+ _updateOptionItemChecked(optionItem, checked) {
+ optionItem.classList.toggle(Classes.selectedBg, checked);
+ optionItem.classList.toggle(Classes.selected, checked);
+ if (checked) {
+ optionItem.dataset.selected = checked;
+ } else {
+ delete optionItem?.dataset.selected;
+ }
+ const checkedIcon = optionItem.querySelector(Selectors.checkedIcon);
+ if (checkedIcon) {
+ checkedIcon.classList.toggle(Classes.hidden, !checked);
+ }
+ const uncheckedIcon = optionItem.querySelector(Selectors.uncheckedIcon);
+ if (uncheckedIcon) {
+ uncheckedIcon.classList.toggle(Classes.hidden, checked);
+ }
+ }
+
+
+ /**
+ * Return the selected value.
+ * @returns {string|null} The selected value.
+ */
+ getSelectedValue() {
+ const selected = this.panel.querySelector(Selectors.selectedOption);
+ return selected?.dataset.value ?? null;
+ }
+
+ /**
+ * Set the button sync value.
+ *
+ * If the sync is enabled, the button text will show the selected option.
+ *
+ * @param {Boolean} value The value to set.
+ */
+ setButtonSyncEnabled(value) {
+ if (value) {
+ this.element.dataset.buttonSync = 'true';
+ } else {
+ delete this.element.dataset.buttonSync;
+ }
+ if (value) {
+ this.syncButtonText();
+ }
+ }
+
+ /**
+ * Return if the button sync is enabled.
+ * @returns {Boolean} The button sync value.
+ */
+ isButtonSyncEnabled() {
+ return this.element.dataset.buttonSync == 'true';
+ }
+
+ /**
+ * Sync the button text with the selected option.
+ */
+ syncButtonText() {
+ const selected = this.panel.querySelector(Selectors.selectedOption);
+ if (!selected) {
+ return;
+ }
+ let newText = selected.textContent;
+ const optionIcon = this._getOptionIcon(selected);
+ if (optionIcon) {
+ newText = optionIcon.innerHTML + newText;
+ }
+ this.button.innerHTML = newText;
+ }
+
+ /**
+ * Set the update status value.
+ *
+ * @param {Boolean} value The value to set.
+ */
+ setUpdateStatusEnabled(value) {
+ if (value) {
+ this.element.dataset.updateStatus = 'true';
+ } else {
+ delete this.element.dataset.updateStatus;
+ }
+ }
+
+ /**
+ * Return if the update status is enabled.
+ * @returns {Boolean} The update status value.
+ */
+ isUpdateStatusEnabled() {
+ return this.element.dataset.updateStatus == 'true';
+ }
+
+ _getOptionIcon(option) {
+ const optionItem = option.closest(Selectors.optionItem);
+ if (!optionItem) {
+ return null;
+ }
+ return optionItem.querySelector(Selectors.optionIcon);
+ }
+
+}
+
+/**
+ * Get the dropdown dialog instance form a selector.
+ * @param {string} selector The query selector to init.
+ * @returns {DropdownStatus|null} The dropdown dialog instance if any.
+ */
+export const getDropdownStatus = (selector) => {
+ const dropdownElement = document.querySelector(selector);
+ if (!dropdownElement) {
+ return null;
+ }
+ return new DropdownStatus(dropdownElement);
+};
+
+/**
+ * Initialize module.
+ *
+ * @method
+ * @param {string} selector The query selector to init.
+ */
+export const init = (selector) => {
+ const dropdown = getDropdownStatus(selector);
+ if (!dropdown) {
+ throw new Error(`Dopdown status element not found: ${selector}`);
+ }
+ dropdown.init();
+};
diff --git a/lib/amd/src/pagehelpers.js b/lib/amd/src/pagehelpers.js
index 597cd8cdfce..4aef45f0ca8 100644
--- a/lib/amd/src/pagehelpers.js
+++ b/lib/amd/src/pagehelpers.js
@@ -86,6 +86,17 @@ export const firstFocusableElement = (container) => {
return containerElement.querySelector(Selectors.focusable);
};
+/**
+ * Get the last focusable element inside a container.
+ * @param {HTMLElement} [container] Container to search in. Defaults to document.
+ * @returns {HTMLElement|null}
+ */
+export const lastFocusableElement = (container) => {
+ const containerElement = container || document;
+ const focusableElements = containerElement.querySelectorAll(Selectors.focusable);
+ return focusableElements[focusableElements.length - 1] ?? null;
+};
+
/**
* Get all focusable elements inside a container.
* @param {HTMLElement} [container] Container to search in. Defaults to document.
diff --git a/lib/classes/output/local/dropdown/dialog.php b/lib/classes/output/local/dropdown/dialog.php
index 67b0ba80caa..b95b2bb3b61 100644
--- a/lib/classes/output/local/dropdown/dialog.php
+++ b/lib/classes/output/local/dropdown/dialog.php
@@ -179,7 +179,7 @@ class dialog implements named_templatable, renderable {
* @param string $value the value
*/
public function add_button_id(string $value) {
- $this->extras['id'] = $value;
+ $this->extras['buttonid'] = $value;
}
/**
@@ -198,6 +198,11 @@ class dialog implements named_templatable, renderable {
*/
public function export_for_template(\renderer_base $output): array {
$extras = [];
+ // Id is required to add JS controls to the dropdown.
+ $dropdownid = $this->extras['id'] ?? \html_writer::random_id('dropdownDialog_');
+ if (isset($this->extras['id'])) {
+ unset($this->extras['id']);
+ }
foreach ($this->extras as $attribute => $value) {
$extras[] = [
'attribute' => $attribute,
@@ -206,7 +211,8 @@ class dialog implements named_templatable, renderable {
}
$data = [
// Id is required for the correct HTML labelling.
- 'buttonid' => \html_writer::random_id('dropwdownbutton_'),
+ 'dropdownid' => $dropdownid,
+ 'buttonid' => $this->extras['buttonid'] ?? \html_writer::random_id('dropwdownbutton_'),
'buttoncontent' => (string) $this->buttoncontent,
'dialogcontent' => (string) $this->dialogcontent,
'classes' => $this->classes,
diff --git a/lib/classes/output/local/dropdown/status.php b/lib/classes/output/local/dropdown/status.php
index 0d2f5f2702d..98381e1ea96 100644
--- a/lib/classes/output/local/dropdown/status.php
+++ b/lib/classes/output/local/dropdown/status.php
@@ -44,6 +44,8 @@ class status extends dialog {
* - buttonclasses: the button CSS classes.
* - dialogwidth: the dropdown width.
* - extras: extra HTML attributes (attribute => value).
+ * - buttonsync: if the button should be synced with the selected value.
+ * - updatestatus: if component must update the status and trigger a change event when clicked.
*
* @param string $buttoncontent the button content
* @param choicelist $choices the choice object
@@ -52,6 +54,12 @@ class status extends dialog {
public function __construct(string $buttoncontent, choicelist $choices, array $definition = []) {
parent::__construct($buttoncontent, '', $definition);
$this->set_choice($choices);
+ if ($definition['buttonsync'] ?? false) {
+ $this->extras['data-button-sync'] = 'true';
+ }
+ if ($definition['updatestatus'] ?? false) {
+ $this->extras['data-update-status'] = 'true';
+ }
}
/**
diff --git a/lib/templates/local/dropdown/dialog.mustache b/lib/templates/local/dropdown/dialog.mustache
index d9f730886e2..a73c794282c 100644
--- a/lib/templates/local/dropdown/dialog.mustache
+++ b/lib/templates/local/dropdown/dialog.mustache
@@ -33,21 +33,23 @@
* dialogcontent - the dropdown dialog content.
* buttoncontent - the dropdown trigger button content.
* extras - custom HTML attributes for the component.
+ * dropdownid - the dropdown id (will be auto-generate if no id is passed).
Example context (json):
{
- "buttonid" : "someinternalid",
- "buttoncontent" : "Trigger button",
- "dialogcontent" : "Moodle",
- "extras" : [
+ "dropdownid": "internaldropdownid",
+ "buttonid": "internalbuttonid",
+ "buttoncontent": "Trigger button",
+ "dialogcontent": "Moodle",
+ "extras": [
{
- "attribute" : "data-example",
- "value" : "stickyfooter"
+ "attribute": "data-example",
+ "value": "stickyfooter"
}
],
- "buttonclasses" : "extraclasses",
- "dialogclasses" : "extraclasses",
- "classes" : "extraclasses"
+ "buttonclasses": "extraclasses",
+ "dialogclasses": "extraclasses",
+ "classes": "extraclasses"
}
}}