Merge branch 'MDL-78826-master' of https://github.com/roland04/moodle
This commit is contained in:
@@ -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
|
||||
|
||||
<!-- markdownlint-disable-next-line MD033 -->
|
||||
<iframe src="../../../../examples/dropdowns.php" style="overflow:hidden;height:400px;width:100%;border:0" title="Moodle dynamic tabs"></iframe>
|
||||
|
||||
@@ -44,7 +44,14 @@ $output = $PAGE->get_renderer('core');
|
||||
|
||||
echo $output->header();
|
||||
|
||||
echo $output->paragraph(
|
||||
'<strong>Important note:</strong> 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 '<div class="p-3">';
|
||||
|
||||
$dialog = new core\output\local\dropdown\dialog(
|
||||
'Open dialog',
|
||||
@@ -55,15 +62,18 @@ $dialog = new core\output\local\dropdown\dialog(
|
||||
</ul>'
|
||||
);
|
||||
echo $OUTPUT->render($dialog);
|
||||
echo "</div>";
|
||||
|
||||
|
||||
echo $output->heading("Dropdown status example", 3);
|
||||
echo '<div class="p-3">';
|
||||
|
||||
$choice = new core\output\choicelist('Choice description text');
|
||||
|
||||
// Option one is a link.
|
||||
$choice->add_option('option1', 'Option 1', [
|
||||
'description' => 'Option 1 description',
|
||||
'icon' => new pix_icon('t/show', 'Eye icon 1'),
|
||||
'url' => new moodle_url('/admin/tool/componentlibrary/examples/dropdowns.php'),
|
||||
]);
|
||||
// Option two has an icon and description.
|
||||
$choice->add_option('option2', 'Option 2', [
|
||||
@@ -74,11 +84,41 @@ $choice->add_option('option2', 'Option 2', [
|
||||
$choice->add_option('option3', 'Option 3', [
|
||||
'description' => 'Option 3 description',
|
||||
'icon' => new pix_icon('t/stealth', 'Eye icon 3'),
|
||||
'disabled' => true,
|
||||
]);
|
||||
|
||||
$choice->set_selected_value('option2');
|
||||
|
||||
$dialog = new core\output\local\dropdown\status('Open dialog button', $choice);
|
||||
echo $OUTPUT->render($dialog);
|
||||
echo "</div>";
|
||||
|
||||
echo $output->heading("Dropdown status in update mode example", 3);
|
||||
echo '<div class="p-3">';
|
||||
|
||||
$choice = new core\output\choicelist('Choice description text');
|
||||
|
||||
$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->set_selected_value('option2');
|
||||
|
||||
$dialog = new core\output\local\dropdown\status(
|
||||
'Open dialog button',
|
||||
$choice,
|
||||
[
|
||||
'buttonsync' => true,
|
||||
'updatestatus' => true,
|
||||
'dialogwidth' => core\output\local\dropdown\status::WIDTH['big']
|
||||
]
|
||||
);
|
||||
echo $OUTPUT->render($dialog);
|
||||
echo "</div>";
|
||||
|
||||
echo $output->footer();
|
||||
|
||||
+10
@@ -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
@@ -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
Vendored
+2
-2
@@ -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
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -117,3 +117,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item-outline {
|
||||
|
||||
&:focus,
|
||||
&:focus-within {
|
||||
outline: solid $dropdown-link-active-bg;
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user