MDL-78826 javascript: add dropdown js controls

This commit is contained in:
Ferran Recio
2023-09-08 10:23:13 +02:00
committed by Mikel Martín
parent e715b7a791
commit c87fe6036d
18 changed files with 880 additions and 23 deletions
+10
View File
@@ -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 <ferran@moodle.com>
* @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
File diff suppressed because one or more lines are too long
+16
View File
@@ -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 <ferran@moodle.com>
* @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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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 <ferran@moodle.com>
* @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()<Sizes_small;_exports.isSmall=()=>getCurrentWidth()<Sizes_medium;_exports.isLarge=()=>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()<Sizes_small;_exports.isSmall=()=>getCurrentWidth()<Sizes_medium;_exports.isLarge=()=>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
File diff suppressed because one or more lines are too long
+202
View File
@@ -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 <http://www.gnu.org/licenses/>.
/**
* Dropdown status JS controls.
*
* @module core/local/dropdown/dialog
* @copyright 2023 Ferran Recio <[email protected]>
* @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();
};
+286
View File
@@ -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 <http://www.gnu.org/licenses/>.
/**
* 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 <[email protected]>
* @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();
};
+11
View File
@@ -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.
+8 -2
View File
@@ -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,
@@ -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';
}
}
/**
+25 -9
View File
@@ -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" : "<a href=\"#\">Moodle</a>",
"extras" : [
"dropdownid": "internaldropdownid",
"buttonid": "internalbuttonid",
"buttoncontent": "Trigger button",
"dialogcontent": "<a href=\"#\">Moodle</a>",
"extras": [
{
"attribute" : "data-example",
"value" : "stickyfooter"
"attribute": "data-example",
"value": "stickyfooter"
}
],
"buttonclasses" : "extraclasses",
"dialogclasses" : "extraclasses",
"classes" : "extraclasses"
"buttonclasses": "extraclasses",
"dialogclasses": "extraclasses",
"classes": "extraclasses"
}
}}
<div
@@ -55,6 +57,10 @@
}} {{$ dropdownclasses }} {{!
}} {{#classes}} {{classes}} {{/classes}} {{!
}} {{/ dropdownclasses }}"
id="{{$ dropdownid }}{{!
}}{{#dropdownid}}{{dropdownid}}{{/dropdownid}}{{!
}}{{^dropdownid}}dropdownDialog_{{uniqid}}{{/dropdownid}}{{!
}}{{/ dropdownid }}"
{{$ extras }}
{{#extras}}
{{attribute}}="{{value}}"
@@ -71,6 +77,7 @@
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
data-for="dropdowndialog_button"
>
{{$ buttoncontent }}
{{{ buttoncontent }}}
@@ -83,6 +90,7 @@
}} {{#dialogclasses}} {{dialogclasses}} {{/dialogclasses}} {{!
}} {{/ dialogclasses }}"
aria-labelledby="{{#buttonid}}{{buttonid}}{{/buttonid}}{{^buttonid}}dropdownDialog{{uniqid}}{{/buttonid}}"
data-for="dropdowndialog_dialog"
>
<div class="p-2" data-for="dropdowndialog_content">
{{$ dialogcontent }}
@@ -91,3 +99,11 @@
</div>
</div>
</div>
{{#js}}
require(['core/local/dropdown/dialog'], function(Module) {
Module.init('#' + '{{$ dropdownid }}{{!
}}{{#dropdownid}}{{dropdownid}}{{/dropdownid}}{{!
}}{{^dropdownid}}dropdownDialog_{{uniqid}}{{/dropdownid}}{{!
}}{{/ dropdownid }}');
});
{{/js}}
+15 -6
View File
@@ -98,7 +98,8 @@
<div class="d-flex flex-column" role="listbox">
{{#options}}
<div
class="d-flex flex-row align-items-start p-2 position-relative rounded {{!
class="d-flex flex-row align-items-start p-2 mb-1 {{!
}} position-relative rounded dropdown-item-outline {{!
}} {{#disabled}} dimmed_text {{/disabled}} {{!
}} {{#selected}} bg-light selected {{/selected}}"
data-optionnumber="{{optionnumber}}"
@@ -109,13 +110,13 @@
{{>core/pix_icon}}
</div>
{{/icon}}
<div class="otion-select-indicator">
{{#selected}}
<div class="option-select-indicator">
<span class="{{^selected}} d-none {{/selected}}" data-for="checkedIcon">
{{#pix}} i/checkedcircle, core, {{#str}} selected, form {{/str}} {{/pix}}
{{/selected}}
{{^selected}}
</span>
<span class="{{#selected}} d-none {{/selected}}" data-for="uncheckedIcon">
{{#pix}} i/uncheckedcircle{{/pix}}
{{/selected}}
</span>
</div>
<div class="option-name">
<a
@@ -151,3 +152,11 @@
{{/choices}}
{{/ dialogcontent }}
{{/ core/local/dropdown/dialog }}
{{#js}}
require(['core/local/dropdown/status'], function(Module) {
Module.init('#' + '{{$ dropdownid }}{{!
}}{{#dropdownid}}{{dropdownid}}{{/dropdownid}}{{!
}}{{^dropdownid}}dropdownDialog_{{uniqid}}{{/dropdownid}}{{!
}}{{/ dropdownid }}');
});
{{/js}}
+107 -3
View File
@@ -41,9 +41,9 @@ Feature: Test dropdown output module
Scenario: Dropdown status can have as selected option
When I click on "Open dialog" "button" in the "statusselectedscenario" "region"
Then "Selected" "icon" should exist in the "#statusselectedscenario [data-optionnumber='2']" "css_element"
And "Selected" "icon" should not exist in the "#statusselectedscenario [data-optionnumber='1']" "css_element"
And "Selected" "icon" should not exist in the "#statusselectedscenario [data-optionnumber='3']" "css_element"
Then "Selected" "icon" in the "#statusselectedscenario [data-optionnumber='2']" "css_element" should be visible
And "Selected" "icon" in the "#statusselectedscenario [data-optionnumber='1']" "css_element" should not be visible
And "Selected" "icon" in the "#statusselectedscenario [data-optionnumber='3']" "css_element" should not be visible
Scenario: Dropdown status can have a disabled option
When I click on "Open dialog" "button" in the "statusdisablescenario" "region"
@@ -62,3 +62,107 @@ Feature: Test dropdown output module
When I click on "Open dialog" "button" in the "statusoptionurl" "region"
And I click on "Option 2" "link" in the "statusoptionurl" "region"
Then I should see "Foo param value: bar"
Scenario: Dropdowns dialogs can be controlled via javascript
Given "Open dialog" "button" should exist in the "dialogjscontrolssection" "region"
And I should see "The dropdown is hidden" in the "dialogjscontrolssection" "region"
# Change button text.
When I click on "Change button text" "button" in the "dialogjscontrolssection" "region"
Then "New button text" "button" should exist in the "dialogjscontrolssection" "region"
# Open dropdown.
And I click on "Open" "button" in the "dialogjscontrolssection" "region"
And I should see "Dialog content" in the "dialogjscontrolssection" "region"
And I should see "The dropdown is visible" in the "dialogjscontrolssection" "region"
# Close dropdown.
And I click on "Close" "button" in the "dialogjscontrolssection" "region"
And I should not see "Dialog content" in the "dialogjscontrolssection" "region"
And I should see "The dropdown is hidden" in the "dialogjscontrolssection" "region"
Scenario: Dropdown status can sync the clicked option with the button text
Given I should see "Option 2" in the "statussyncbutton" "region"
When I click on "Option 2" "button" in the "statussyncbutton" "region"
And "Selected" "icon" in the "#statussyncbutton [data-optionnumber='2']" "css_element" should be visible
And "Selected" "icon" in the "#statussyncbutton [data-optionnumber='3']" "css_element" should not be visible
And I click on "Option 3" "link" in the "statussyncbutton" "region"
Then I should see "Option 3" in the "statussyncbutton" "region"
And I should not see "Option 2" in the "statussyncbutton" "region"
And I click on "Option 3" "button" in the "statussyncbutton" "region"
And "Selected" "icon" in the "#statussyncbutton [data-optionnumber='2']" "css_element" should not be visible
And "Selected" "icon" in the "#statussyncbutton [data-optionnumber='3']" "css_element" should be visible
Scenario: Dropdowns status can be controlled via javascript
Given "Open dialog" "button" should exist in the "statusjscontrolsection" "region"
And I should see "The status value is option2" in the "statusjscontrolsection" "region"
# Change value.
When I click on "Change selected value" "button" in the "statusjscontrolsection" "region"
Then I should see "The status value is option3" in the "statusjscontrolsection" "region"
And I click on "Open dialog" "button" in the "statusjscontrolsection" "region"
And "Selected" "icon" in the "#statusjscontrolsection [data-optionnumber='2']" "css_element" should not be visible
And "Selected" "icon" in the "#statusjscontrolsection [data-optionnumber='3']" "css_element" should be visible
# Enable button sync.
And I click on "Enable sync" "button" in the "statusjscontrolsection" "region"
And I should see "Option 3" in the "statusjscontrolsection" "region"
And I click on "Option 3" "button" in the "statusjscontrolsection" "region"
And I click on "Option 2" "link" in the "statusjscontrolsection" "region"
And I should see "The status value is option2" in the "statusjscontrolsection" "region"
And I should see "Option 2" in the "statusjscontrolsection" "region"
# Trigger change event with button text sync.
And I click on "Change selected value" "button" in the "statusjscontrolsection" "region"
And I should see "Option 3" in the "statusjscontrolsection" "region"
And I should see "The status value is option3" in the "statusjscontrolsection" "region"
# Disable button text sync.
And I click on "Disable sync" "button" in the "statusjscontrolsection" "region"
And I click on "Option 3" "button" in the "statusjscontrolsection" "region"
And I click on "Option 1" "link" in the "statusjscontrolsection" "region"
And I should see "Option 3" in the "statusjscontrolsection" "region"
And I should see "The status value is option1" in the "statusjscontrolsection" "region"
And I click on "Change selected value" "button" in the "statusjscontrolsection" "region"
And I should see "Option 3" in the "statusjscontrolsection" "region"
And I should see "The status value is option2" in the "statusjscontrolsection" "region"
# Disable update.
And I click on "Disable update" "button" in the "statusjscontrolsection" "region"
And I click on "Option 3" "button" in the "statusjscontrolsection" "region"
And I click on "Option 1" "link" in the "statusjscontrolsection" "region"
And I should see "The status value is option2" in the "statusjscontrolsection" "region"
And I click on "Option 3" "button" in the "statusjscontrolsection" "region"
And "Selected" "icon" in the "#statusjscontrolsection [data-optionnumber='1']" "css_element" should not be visible
And "Selected" "icon" in the "#statusjscontrolsection [data-optionnumber='2']" "css_element" should be visible
Scenario: Dropdown status content is accessible with keyboard
Given I click on "Focus helper" "button" in the "statussyncbutton" "region"
When I press the tab key
# Open and close dropdown with enter key.
Then I press the enter key
And the focused element is "[data-for='dropdowndialog_button']" "css_element" in the "statussyncbutton" "region"
And I should see "Option 1" in the "statussyncbutton" "region"
And I press the enter key
And the focused element is "[data-for='dropdowndialog_button']" "css_element" in the "statussyncbutton" "region"
And I should not see "Option 1" in the "statussyncbutton" "region"
# Open and close with down and up keys.
And I press the down key
And the focused element is "[data-optionnumber='1'] a" "css_element" in the "statussyncbutton" "region"
And I should see "Option 1" in the "statussyncbutton" "region"
And I press the up key
And the focused element is "[data-for='dropdowndialog_button']" "css_element" in the "statussyncbutton" "region"
And I should see "Option 1" in the "statussyncbutton" "region"
And I press the up key
And the focused element is "[data-for='dropdowndialog_button']" "css_element" in the "statussyncbutton" "region"
And I should not see "Option 1" in the "statussyncbutton" "region"
# Select to option 3 and check user cannot go beyond that.
And I press the down key
And the focused element is "[data-optionnumber='1'] a" "css_element" in the "statussyncbutton" "region"
And I press the down key
And the focused element is "[data-optionnumber='2'] a" "css_element" in the "statussyncbutton" "region"
And I press the down key
And the focused element is "[data-optionnumber='3'] a" "css_element" in the "statussyncbutton" "region"
And I press the down key
And the focused element is "[data-optionnumber='3'] a" "css_element" in the "statussyncbutton" "region"
And I press the enter key
And I should see "Option 3" in the "statussyncbutton" "region"
# Close dropdown with escape key.
And I press the down key
And the focused element is "[data-optionnumber='1'] a" "css_element" in the "statussyncbutton" "region"
And I should see "Option 1" in the "statussyncbutton" "region"
And I press the escape key
And the focused element is "[data-for='dropdowndialog_button']" "css_element" in the "statussyncbutton" "region"
And I should not see "Option 1" in the "statussyncbutton" "region"
@@ -123,6 +123,59 @@ $dialog->set_classes('mb-3');
echo $OUTPUT->render($dialog);
echo '</div>';
echo '<div id="dialogjscontrolssection" class="mb-4">';
echo "<h3>Dropdown JS module controls</h3>";
echo '<div class="mb-2">
<button class="btn btn-secondary" id="buttontext">Change button text</button>
<button class="btn btn-secondary" id="opendropdown">Open</button>
<button class="btn btn-secondary" id="closedropdown">Close</button>
<span id="dialogvisibility"></span>
</div>';
$dialog = new core\output\local\dropdown\dialog('Open dialog', 'Dialog content', [
'extras' => ['id' => 'dialogjscontrols'],
]);
echo $OUTPUT->render($dialog);
echo '</div>';
$inlinejs = <<<EOF
require(
['core/local/dropdown/dialog', 'jquery'],
(Module, jQuery) => {
const dialog = Module.getDropdownDialog('#dialogjscontrols');
document.querySelector('#buttontext').addEventListener('click', () => {
dialog.setButtonContent('New button text');
});
document.querySelector('#opendropdown').addEventListener('click', (e) => {
e.stopPropagation();
dialog.setVisible(true);
});
document.querySelector('#closedropdown').addEventListener('click', (e) => {
e.stopPropagation();
dialog.setVisible(false);
});
const visibility = () => {
const text = 'The dropdown is ' + (dialog.isVisible() ? 'visible' : 'hidden') + '.';
document.querySelector('#dialogvisibility').innerHTML = text;
}
visibility();
// Bootstrap 4 events are still jQuery.
jQuery(dialog.getElement()).on('shown.bs.dropdown', (e) => {
visibility();
});
jQuery(dialog.getElement()).on('hidden.bs.dropdown', (e) => {
visibility();
});
}
);
EOF;
$PAGE->requires->js_amd_inline($inlinejs);
echo "<h2>Dropdown status test page</h2>";
echo '<div id="statusregularscenario" class="mb-4">';
@@ -205,4 +258,109 @@ $foo = optional_param('foo', 'none', PARAM_TEXT);
echo "<p>Foo param value: $foo</p>";
echo '</div>';
echo '<div id="statussyncbutton" class="mb-4">';
echo "<h3>Sync button text</h3>";
$choice = new core\output\choicelist('Dialog content');
$choice->add_option('option1', 'Option 1', [
'description' => 'Option 1 description',
'icon' => new pix_icon('t/show', 'Eye icon 1')
]);
$choice->add_option('option2', 'Option 2', [
'description' => 'Option 2 description',
'icon' => new pix_icon('t/hide', 'Eye icon 2')
]);
$choice->add_option('option3', 'Option 3', [
'description' => 'Option 3 description',
'icon' => new pix_icon('t/stealth', 'Eye icon 3')
]);
$choice->set_selected_value('option2');
$dialog = new core\output\local\dropdown\status(
'Open dialog',
$choice,
['buttonsync' => true, 'updatestatus' => true]
);
echo '<button class="btn">Focus helper</button>';
echo $OUTPUT->render($dialog);
echo '</div>';
echo '<div id="statusjscontrolsection" class="mb-4">';
echo "<h3>Status JS controls</h3>";
echo '<div class="mb-2">
<button class="btn btn-secondary" id="setselected">Change selected value</button>
<button class="btn btn-secondary" id="syncbutton">Enable sync</button>
<button class="btn btn-secondary" id="updatestatus">Disable update</button>
<span id="statusvalue"></span>
</div>';
$choice = new core\output\choicelist('Dialog content');
$choice->add_option('option1', 'Option 1', [
'description' => 'Option 1 description',
'icon' => new pix_icon('t/show', 'Eye icon 1')
]);
$choice->add_option('option2', 'Option 2', [
'description' => 'Option 2 description',
'icon' => new pix_icon('t/hide', 'Eye icon 2')
]);
$choice->add_option('option3', 'Option 3', [
'description' => 'Option 3 description',
'icon' => new pix_icon('t/stealth', 'Eye icon 3')
]);
$choice->set_selected_value('option2');
$dialog = new core\output\local\dropdown\status(
'Open dialog',
$choice,
[
'extras' => ['id' => 'statusjscontrols'],
'updatestatus' => true
],
);
echo $OUTPUT->render($dialog);
echo '</div>';
$inlinejs = <<<EOF
require(
['core/local/dropdown/status', 'jquery'],
(Module, jQuery) => {
const status = Module.getDropdownStatus('#statusjscontrols');
const printValue = () => {
const text = 'The status value is ' + status.getSelectedValue() + '.';
document.querySelector('#statusvalue').innerHTML = text;
}
printValue();
document.querySelector('#setselected').addEventListener('click', () => {
if (status.getSelectedValue() == 'option2') {
status.setSelectedValue('option3');
} else {
status.setSelectedValue('option2');
}
});
document.querySelector('#syncbutton').addEventListener('click', (e) => {
if (status.isButtonSyncEnabled()) {
status.setButtonSyncEnabled(false);
} else {
status.setButtonSyncEnabled(true);
}
e.target.innerHTML = (status.isButtonSyncEnabled()) ? 'Disable sync': 'Enable sync';
});
document.querySelector('#updatestatus').addEventListener('click', (e) => {
if (status.isUpdateStatusEnabled()) {
status.setUpdateStatusEnabled(false);
} else {
status.setUpdateStatusEnabled(true);
}
e.target.innerHTML = (status.isUpdateStatusEnabled()) ? 'Disable update': 'Enable update';
});
status.getElement().addEventListener('change', () => {
printValue();
});
}
);
EOF;
$PAGE->requires->js_amd_inline($inlinejs);
echo $OUTPUT->footer();
+13
View File
@@ -117,3 +117,16 @@
}
}
}
.dropdown-item-outline {
&:focus,
&:focus-within {
outline: solid $dropdown-link-active-bg;
}
a:focus,
a:focus-visible {
outline: 0;
}
}
+8
View File
@@ -26083,6 +26083,14 @@ blockquote {
font-size: 0.7rem;
}
.dropdown-item-outline:focus, .dropdown-item-outline:focus-within {
outline: solid #0f6cbf;
}
.dropdown-item-outline a:focus,
.dropdown-item-outline a:focus-visible {
outline: 0;
}
.icon {
font-size: 16px;
width: 16px;
+8
View File
@@ -26083,6 +26083,14 @@ blockquote {
font-size: 0.7rem;
}
.dropdown-item-outline:focus, .dropdown-item-outline:focus-within {
outline: solid #0f6cbf;
}
.dropdown-item-outline a:focus,
.dropdown-item-outline a:focus-visible {
outline: 0;
}
.icon {
font-size: 16px;
width: 16px;