Merge branch 'MDL-78665-master-v02' of https://github.com/ferranrecio/moodle

This commit is contained in:
Ilya Tregubov
2023-08-07 12:19:30 +08:00
22 changed files with 1846 additions and 72 deletions
@@ -0,0 +1,212 @@
---
layout: docs
title: "Action menus"
description: "A reusable action menu component"
date: 2023-07-27T10:10:00+08:00
draft: false
tags:
- MDL-78665
- "4.3"
---
## How it works
Moodle action menus are a reusable component that can display a list of actions in a dropdown menu. They are used in many places in Moodle, including the user menu, the course administration menu, and the activity administration menu.
## Source files
- `lib/outputcomponents.php`: contains the main `action_menu`, `action_menu_link` and `pix_icon`.
- `lib/classes/output/local/action_menu/subpanel.php`: contains the `subpanel` menu item class.
- `lib/templates/action_menu.mustache`: contains the main template for the action menu.
- `lib/templates/action_menu_*`: location for the legacy auxliar mustache files.
- `lib/templates/local/action_menu/*`: location for any new auxiliar mustache files.
## Examples
<!-- markdownlint-disable-next-line MD033 -->
<iframe src="../../../../examples/actionmenu.php" style="overflow:hidden;height:400px;width:100%;border:0" title="Moodle action menus"></iframe>
## Usage
### Rendering an action menu
The component output classes can render an action menu entirely in PHP. The steps to do it are:
1. Create an action menu instance (with or without items)
2. (optional) Setup the action menu trigger
3. (optional) Add items to the menu (if they are not added on creation)
4. Render the menu.
The following code is a basic example of an action menu:
{{< php >}}
/** @var core_renderer $output*/
$output = $PAGE->get_renderer('core');
$menu = new action_menu();
// Add items.
$menu->add(new action_menu_link(
new moodle_url($PAGE->url, ['foo' => 'bar']),
new pix_icon('t/emptystar', ''),
'Action link example',
false
));
echo $output->render($menu);
{{< / php >}}
And this is the same example but passing the items in the creation:
{{< php >}}
/** @var core_renderer $output*/
$output = $PAGE->get_renderer('core');
$menu = new action_menu([
new action_menu_link(
new moodle_url($PAGE->url, ['foo' => 'bar']),
new pix_icon('t/emptystar', ''),
'Action link example',
false
),
]);
echo $output->render($menu);
{{< / php >}}
### Setup the menu trigger
By default, the action menu trigger is a cog icon. However, the class has methods to convert it to a kebab menu or even display any arbitrary content.
Example of a kebab menu:
{{< php >}}
/** @var core_renderer $output*/
$output = $PAGE->get_renderer('core');
$menu = new action_menu();
$menu->set_kebab_trigger(get_string('edit'), $output);
$menu->set_additional_classes('fields-actions');
{{< / php >}}
Example of a custom trigger:
{{< php >}}
/** @var core_renderer $output*/
$output = $PAGE->get_renderer('core');
$menu = new action_menu();
$menu->set_menu_trigger(get_string('edit'));
{{< / php >}}
### Add items
Items can be added as an array on creation or using the `add` method. Depending on the param passed to `add` the item can be displayed in two different locations:
Primary items: are displayed next to the trigger button as direct actions.
Secondary items: are displayed inside the action menu dropdown.
The item location must be configured before adding the element. The following example shows different ways to add primary and secondary menu items.
{{< php >}}
// Primary items examples.
$menu->add(new action_menu_link(
new moodle_url($PAGE->url),
new pix_icon('t/emptystar', ''),
'Action link example',
true
));
$menu->add(new action_menu_link_primary(
$PAGE->url,
new pix_icon('t/emptystar', ''),
'Action link example',
));
// Secondary items examples.
$menu->add(new action_menu_link(
new moodle_url($PAGE->url),
new pix_icon('t/emptystar', ''),
'Action link example',
false
));
$menu->add(new action_menu_link_secondary(
$PAGE->url,
new pix_icon('t/user', ''),
'Action link example',
));
{{< / php >}}
## Types of items
The `add` method accepts several item types.
### `action_menu_link`
The `action_menu_link` class is the generic class for link items. It has several construct params:
- `moodle_url $url`: the link URL.
- `pix_icon $icon`: an optional pix_icon. If none passed, the item will show the trigger icon or none if it is a kebab menu.
- `string $text`: the text to display.
- `bool $primary`: if the item is primary or secondary. By default, all items are primary.
- `array $attributes`: an optional array of HTML attributes.
Two convenience classes extend `action_menu_link`:
- `action_menu_link_primary`: will be added as a primary item.
- `action_menu_link_secondary`: will be added as a secondary item.
### `pix_icon`
The action menu can render `pix_icon` as primary actions. The `pix_icon` is a standard output class for generating icons in Moodle.
Construct params:
- `String $pix`: the internal icon location. For example, "t/user".
- `String $alt`: an optional alternative text
- `String $component`: the pix icon component. By default, only core icons from the `pix` folder will be used
- `Array $attributes`: optional HTML attributes.
### `core\output\local\action_menu\subpanel`
The `core\output\local\action_menu` allow the `action_menu` to add items that display subpanels when hovered or clicked.
Construct params:
- `string $text`: text to display in the menu item
- `renderable $subpanel`: the output to render inside the subpanel. This param should be renderable using the standard `output::render` method.
- `array $attributes` optional HTML attributes
The following example creates a subpanel using a renderable choicelist instance:
{{< php >}}
/** @var core_renderer $output*/
$output = $PAGE->get_renderer('core');
// A choice list is a renderable class to outpout a user choice.
$choice = new core\output\choicelist('Choice example');
$choice->add_option("statusa", "Status A", [
'url' => $PAGE->url,
'description' => 'Status A description',
'icon' => new pix_icon('t/user', '', ''),
]);
$choice->add_option("statusb", "Status B", [
'url' => $PAGE->url,
'description' => 'Status B description',
'icon' => new pix_icon('t/groupv', '', ''),
]);
$choice->set_selected_value('statusb');
$menu = new action_menu();
// Add subpanel item.
$menu->add(new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice
));
echo $output->render($menu);
{{< / php >}}
### HTML string
If a plain string is added to an action_menu, the action_menu::add method will be printed as it is inside the dropdown menu (as a secondary item).
@@ -0,0 +1,159 @@
<?php
// 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/>.
/**
* Moodle Component Library
*
* A sample of a default action menu.
*
* @package tool_componentlibrary
* @copyright 2023 Ferran Recio <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
declare(strict_types=1);
require_once(__DIR__ . '/../../../../config.php');
global $PAGE;
$PAGE->set_url(new moodle_url('/admin/tool/componentlibrary/examples/actionmenu.php'));
$PAGE->set_context(context_system::instance());
$PAGE->set_pagelayout('embedded');
$PAGE->set_heading('Moodle action menus');
$PAGE->set_title('Moodle action menus');
/** @var core_renderer $output*/
$output = $PAGE->get_renderer('core');
// Some menu items require as renderable element. This is just an
// example of a choice list but it can be any other renderable.
$choice = new core\output\choicelist('Choice example');
$choice->add_option("statusa", "Status A", [
'url' => $PAGE->url,
'description' => 'Status A description',
'icon' => new pix_icon('t/user', '', ''),
]);
$choice->add_option("statusb", "Status B", [
'url' => $PAGE->url,
'description' => 'Status B description',
'icon' => new pix_icon('t/groupv', '', ''),
]);
$choice->set_selected_value('statusb');
// Those are some examples of action items.
// Action menu links is the most used action item.
$basicactionlink = new action_menu_link(
new moodle_url($PAGE->url),
new pix_icon('t/emptystar', ''),
'Action link example',
false
);
// Subpanels display lateral panels on hovered or clicked.
$subpanel = new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice
);
echo $output->header();
echo '<p><strong>Important note:</strong> actions menus are not prepared
to be displayed inside iframes. You may need to scroll to see the
action menu options.</p>';
echo $output->heading("Action menu default example", 4);
$menu = new action_menu();
$menu->add($basicactionlink);
$menu->add($basicactionlink);
$menu->add($subpanel);
$menu->add($basicactionlink);
echo '<div class="border m-3 p-3 d-flex flex-row">';
echo '<div class="flex-fill">Example of default an action menu</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo $output->heading("Kebab menu example", 4);
$menu = new action_menu();
$menu->set_kebab_trigger(get_string('edit'), $output);
$menu->set_additional_classes('fields-actions');
$menu->add($basicactionlink);
$menu->add($basicactionlink);
$menu->add(new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice
));
$menu->add($basicactionlink);
echo '<div class="border m-3 p-3 d-flex flex-row">';
echo '<div class="flex-fill">Example of kebab menu</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo $output->heading("Custom trigger menu example", 4);
$menu = new action_menu();
$menu->set_menu_trigger(get_string('edit'));
$menu->add($basicactionlink);
$menu->add($basicactionlink);
$menu->add(new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice
));
$menu->add($basicactionlink);
echo '<div class="border m-3 p-3 d-flex flex-row">';
echo '<div class="flex-fill">Example of kebab menu</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo $output->heading("Primary actions menu example", 4);
$menu = new action_menu();
$menu->set_menu_trigger(get_string('edit'));
$menu->add($basicactionlink);
$menu->add($basicactionlink);
$menu->add(new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice
));
$menu->add($basicactionlink);
$menu->add(new action_menu_link_primary(
$PAGE->url,
new pix_icon('t/emptystar', ''),
'Action link example',
));
$menu->add(new action_menu_link_primary(
$PAGE->url,
new pix_icon('t/user', ''),
'Action link example',
));
echo '<div class="border m-3 p-3 d-flex flex-row">';
echo '<div class="flex-fill">Example of a menu with primary actions</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo $output->footer();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
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;
/**
* Page utility helpers.
*
* @module core/pagehelpers
* @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$}}));
//# sourceMappingURL=pagehelpers.min.js.map
File diff suppressed because one or more lines are too long
+343
View File
@@ -0,0 +1,343 @@
// 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/>.
/**
* Action menu subpanel JS controls.
*
* @module core/local/action_menu/subpanel
* @copyright 2023 Mikel Martín <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import jQuery from 'jquery';
import {debounce} from 'core/utils';
import {
isExtraSmall,
firstFocusableElement,
previousFocusableElement,
nextFocusableElement,
} from 'core/pagehelpers';
import Pending from 'core/pending';
const Selectors = {
mainMenu: '[role="menu"]',
dropdownRight: '.dropdown-menu-right',
subPanel: '.dropdown-subpanel',
subPanelMenuItem: '.dropdown-subpanel > .dropdown-item',
subPanelContent: '.dropdown-subpanel > .dropdown-menu',
drawer: '[data-region="fixed-drawer"]',
};
const Classes = {
dropRight: 'dropright',
dropLeft: 'dropleft',
dropDown: 'dropdown',
forceLeft: 'downleft',
contentDisplayed: 'content-displayed',
};
const BootstrapEvents = {
hideDropdown: 'hidden.bs.dropdown',
};
let initialized = false;
/**
* Initialize all delegated events into the page.
*/
const initPageEvents = () => {
if (initialized) {
return;
}
// Hide all subpanels when hidind a dropdown.
// This is using JQuery because of BS4 events. JQuery won't be needed with BS5.
jQuery(document).on(BootstrapEvents.hideDropdown, () => {
document.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
const subPanel = new SubPanel(dropdownSubPanel);
subPanel.setVisibility(false);
});
});
window.addEventListener('resize', debounce(updateAllPanelsPosition, 400));
initialized = true;
};
/**
* Update all the panels position.
*/
const updateAllPanelsPosition = () => {
document.querySelectorAll(Selectors.subPanel).forEach(dropdown => {
const subpanel = new SubPanel(dropdown);
subpanel.updatePosition();
});
};
/**
* Subpanel class.
* @private
*/
class SubPanel {
/**
* Constructor.
* @param {HTMLElement} element The element to initialize.
*/
constructor(element) {
this.element = element;
this.menuItem = element.querySelector(Selectors.subPanelMenuItem);
this.panelContent = element.querySelector(Selectors.subPanelContent);
}
/**
* Initialize the subpanel element.
*
* This method adds the event listeners to the subpanel and the position classes.
*/
init() {
if (this.element.dataset.subPanelInitialized) {
return;
}
this.updatePosition();
// Full element events.
this.element.addEventListener('focusin', this._mainElementFocusInHandler.bind(this));
// Menu Item events.
this.menuItem.addEventListener('click', this._menuItemClickHandler.bind(this));
this.menuItem.addEventListener('keydown', this._menuItemKeyHandler.bind(this));
this.menuItem.addEventListener('mouseover', this._menuItemHoverHandler.bind(this));
this.menuItem.addEventListener('mouseout', this._menuItemHoverOutHandler.bind(this));
// Subpanel content events.
this.panelContent.addEventListener('keydown', this._panelContentKeyHandler.bind(this));
this.element.dataset.subPanelInitialized = true;
}
/**
* Checks if the subpanel has enough space.
*
* In general there are two scenarios were the subpanel must be interacted differently:
* - Extra small screens: The subpanel is displayed below the menu item.
* - Drawer: The subpanel is displayed one of the drawers.
*
* @returns {Boolean} true if the subpanel should be displayed in small screens.
*/
_needSmallSpaceBehaviour() {
return isExtraSmall() || this.element.closest(Selectors.drawer) !== null;
}
/**
* Main element focus in handler.
*/
_mainElementFocusInHandler() {
if (this._needSmallSpaceBehaviour()) {
return;
}
this.setVisibility(true);
}
/**
* Menu item click handler.
* @param {Event} event
*/
_menuItemClickHandler(event) {
// Avoid dropdowns being closed after clicking a subemnu.
// This won't be needed with BS5 (data-bs-auto-close handles it).
event.stopPropagation();
event.preventDefault();
if (this._needSmallSpaceBehaviour()) {
this.setVisibility(!this.getVisibility());
}
}
/**
* Menu item hover handler.
* @private
*/
_menuItemHoverHandler() {
if (this._needSmallSpaceBehaviour()) {
return;
}
this.setVisibility(true);
}
/**
* Menu item hover out handler.
* @private
*/
_menuItemHoverOutHandler() {
if (this._needSmallSpaceBehaviour()) {
return;
}
this._hideOtherSubPanels();
}
/**
* Menu item key handler.
* @param {Event} event
* @private
*/
_menuItemKeyHandler(event) {
// In small sizes te down key will focus on the panel.
if (event.key === 'ArrowUp' || (event.key === 'ArrowDown' && !this._needSmallSpaceBehaviour())) {
this.setVisibility(false);
return;
}
// Keys to move focus to the panel.
let focusPanel = false;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
focusPanel = true;
}
if ((event.key === 'Enter' || event.key === ' ') && !this._needSmallSpaceBehaviour()) {
focusPanel = true;
}
// In extra small screen the panel is shown below the item.
if (event.key === 'ArrowDown' && this._needSmallSpaceBehaviour() && this.getVisibility()) {
focusPanel = true;
}
if (focusPanel) {
event.stopPropagation();
event.preventDefault();
this.setVisibility(true);
this._focusPanelContent();
}
}
/**
* Sub panel content key handler.
* @param {Event} event
* @private
*/
_panelContentKeyHandler(event) {
// In extra small devices the panel is displayed under the menu item
// so the arrow up/down switch between subpanel and the menu item.
const canLoop = !this._needSmallSpaceBehaviour();
let isBrowsingSubPanel = false;
let newFocus = null;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
newFocus = this.menuItem;
}
if (event.key === 'ArrowUp') {
newFocus = previousFocusableElement(this.panelContent, canLoop);
isBrowsingSubPanel = true;
}
if (event.key === 'ArrowDown') {
newFocus = nextFocusableElement(this.panelContent, canLoop);
isBrowsingSubPanel = true;
}
// If the user cannot loop and arrive to the start/end of the subpanel
// we focus on the menu item.
if (newFocus === null && isBrowsingSubPanel && !canLoop) {
newFocus = this.menuItem;
}
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/action_menu/subpanel: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.panelContent);
if (firstFocusable) {
firstFocusable.focus();
}
pendingPromise.resolve();
}, 100);
}
/**
* Set the visibility of a subpanel.
* @param {Boolean} visible true if the subpanel should be visible.
*/
setVisibility(visible) {
if (visible) {
this._hideOtherSubPanels();
}
this.menuItem.setAttribute('aria-expanded', visible ? 'true' : 'false');
this.panelContent.classList.toggle('show', visible);
this.element.classList.toggle(Classes.contentDisplayed, visible);
}
/**
* Hide all other subpanels in the parent menu.
* @private
*/
_hideOtherSubPanels() {
const dropdown = this.element.closest(Selectors.mainMenu);
dropdown.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
if (dropdownSubPanel === this.element) {
return;
}
const subPanel = new SubPanel(dropdownSubPanel);
subPanel.setVisibility(false);
});
}
/**
* Get the visibility of a subpanel.
* @returns {Boolean} true if the subpanel is visible.
*/
getVisibility() {
return this.menuItem.getAttribute('aria-expanded') === 'true';
}
/**
* Update the panels position depending on the screen size and panel position.
*/
updatePosition() {
const dropdownRight = this.element.closest(Selectors.dropdownRight);
if (this._needSmallSpaceBehaviour()) {
this.element.classList.remove(Classes.dropRight);
this.element.classList.remove(Classes.dropLeft);
this.element.classList.add(Classes.dropDown);
this.element.classList.toggle(Classes.forceLeft, dropdownRight !== null);
return;
}
this.element.classList.remove(Classes.dropDown);
this.element.classList.remove(Classes.forceLeft);
this.element.classList.toggle(Classes.dropRight, dropdownRight === null);
this.element.classList.toggle(Classes.dropLeft, dropdownRight !== null);
}
}
/**
* Initialise module for given report
*
* @method
* @param {string} selector The query selector to init.
*/
export const init = (selector) => {
initPageEvents();
const subMenu = document.querySelector(selector);
if (!subMenu) {
throw new Error(`Sub panel element not found: ${selector}`);
}
const subPanel = new SubPanel(subMenu);
subPanel.init();
};
+150
View File
@@ -0,0 +1,150 @@
// 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/>.
/**
* Page utility helpers.
*
* @module core/pagehelpers
* @copyright 2023 Ferran Recio <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Maximum sizes for breakpoints. This needs to correspond with Bootstrap
* Breakpoints
*
* @private
*/
const Sizes = {
small: 576,
medium: 991,
large: 1400
};
const Selectors = {
focusable: 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
};
/**
* Get the current body width.
* @returns {number} the current body width.
*/
export const getCurrentWidth = () => {
const DomRect = document.body.getBoundingClientRect();
return DomRect.x + DomRect.width;
};
/**
* Check if the user uses an extra small size browser.
*
* @returns {boolean} true if the body is smaller than sizes.small max size.
*/
export const isExtraSmall = () => {
const browserWidth = getCurrentWidth();
return browserWidth < Sizes.small;
};
/**
* Check if the user uses a small size browser.
*
* @returns {boolean} true if the body is smaller than sizes.medium max size.
*/
export const isSmall = () => {
const browserWidth = getCurrentWidth();
return browserWidth < Sizes.medium;
};
/**
* Check if the user uses a large size browser.
*
* @returns {boolean} true if the body is smaller than sizes.large max size.
*/
export const isLarge = () => {
const browserWidth = getCurrentWidth();
return browserWidth >= Sizes.large;
};
/**
* Get the first focusable element inside a container.
* @param {HTMLElement} [container] Container to search in. Defaults to document.
* @returns {HTMLElement|null}
*/
export const firstFocusableElement = (container) => {
const containerElement = container || document;
return containerElement.querySelector(Selectors.focusable);
};
/**
* Get all focusable elements inside a container.
* @param {HTMLElement} [container] Container to search in. Defaults to document.
* @returns {HTMLElement[]}
*/
export const focusableElements = (container) => {
const containerElement = container || document;
return containerElement.querySelectorAll(Selectors.focusable);
};
/**
* Get the previous focusable element in a container.
* It uses the current focused element to know where to start the search.
* @param {HTMLElement} [container] Container to search in. Defaults to document.
* @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.
* @returns {HTMLElement|null}
*/
export const previousFocusableElement = (container, loopSelection) => {
return getRelativeFocusableElement(container, loopSelection, -1);
};
/**
* Get the next focusable element in a container.
* It uses the current focused element to know where to start the search.
* @param {HTMLElement} [container] Container to search in. Defaults to document.
* @param {Boolean} [loopSelection] Whether to loop selection or not. Default to false.
* @returns {HTMLElement|null}
*/
export const nextFocusableElement = (container, loopSelection) => {
return getRelativeFocusableElement(container, loopSelection, 1);
};
/**
* Internal function to get the next or previous focusable element.
* @param {HTMLElement} [container] Container to search in. Defaults to document.
* @param {Boolean} [loopSelection] Whether to loop selection or not.
* @param {Number} [direction] Direction to search in. 1 for next, -1 for previous.
* @returns {HTMLElement|null}
* @private
*/
const getRelativeFocusableElement = (container, loopSelection, direction) => {
const focusedElement = document.activeElement;
const focusables = [...focusableElements(container)];
const focusedIndex = focusables.indexOf(focusedElement);
if (focusedIndex === -1) {
return null;
}
const newIndex = focusedIndex + direction;
if (focusables[newIndex] !== undefined) {
return focusables[newIndex];
}
if (loopSelection != true) {
return null;
}
if (direction > 0) {
return focusables[0] ?? null;
}
return focusables[focusables.length - 1] ?? null;
};
+12 -1
View File
@@ -18,6 +18,7 @@ namespace core\output;
use renderable;
use renderer_base;
use core\output\named_templatable;
/**
* A generic user choice output class.
@@ -29,7 +30,7 @@ use renderer_base;
* @copyright 2023 Ferran Recio <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class choicelist implements renderable {
class choicelist implements renderable, named_templatable {
/** @var object[] The user choices. */
protected $options = [];
@@ -185,4 +186,14 @@ class choicelist implements renderable {
'hasoptions' => !empty($options),
];
}
/**
* Get the name of the template to use for this templatable.
*
* @param renderer_base $renderer The renderer requesting the template name
* @return string
*/
public function get_template_name(renderer_base $renderer): string {
return 'core/local/choicelist';
}
}
@@ -0,0 +1,82 @@
<?php
// 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/>.
namespace core\output\local\action_menu;
use action_link;
use pix_icon;
use renderable;
use stdClass;
/**
* Interface to a subpanel implementation.
*
* @package core_admin
* @copyright 2023 Ferran Recio <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class subpanel extends action_link implements renderable {
/**
* The subpanel content.
* @var renderable
*/
protected $subpanel;
/**
* The number of instances of this action menu link (and its subclasses).
* @var int
*/
protected static $instance = 1;
/**
* Constructor.
* @param string $text the text to display
* @param renderable $subpanel the subpanel content
* @param array|null $attributes an optional array of attributes
* @param pix_icon|null $icon an optional icon
*/
public function __construct(
$text,
renderable $subpanel,
array $attributes = null,
pix_icon $icon = null
) {
$this->text = $text;
$this->subpanel = $subpanel;
if (empty($attributes['id'])) {
$attributes['id'] = \html_writer::random_id('action_menu_submenu');
}
$this->attributes = (array) $attributes;
$this->icon = $icon;
}
/**
* Export this object for template rendering.
* @param \renderer_base $output the output renderer
* @return stdClass
*/
public function export_for_template(\renderer_base $output): stdClass {
$data = parent::export_for_template($output);
$data->instance = self::$instance++;
$data->subpanelcontent = $output->render($this->subpanel);
// The menu trigger icon collides with the subpanel item icon. Unlike regular menu items,
// subpanel items usually does not use icons. To prevent the collision, subpanels use a diferent
// context variable for item icon.
$data->itemicon = $data->icon;
unset($data->icon);
return $data;
}
}
+59 -20
View File
@@ -26,6 +26,8 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core\output\local\action_menu\subpanel;
defined('MOODLE_INTERNAL') || die();
/**
@@ -4469,10 +4471,13 @@ class action_menu implements renderable, templatable {
/**
* Adds an action to this action menu.
*
* @param action_menu_link|pix_icon|string $action
* @param action_menu_link|pix_icon|subpanel|string $action
*/
public function add($action) {
if ($action instanceof action_link) {
if ($action instanceof subpanel) {
$this->add_secondary_subpanel($action);
} else if ($action instanceof action_link) {
if ($action->primary) {
$this->add_primary_action($action);
} else {
@@ -4485,6 +4490,14 @@ class action_menu implements renderable, templatable {
}
}
/**
* Adds a secondary subpanel.
* @param subpanel $subpanel
*/
public function add_secondary_subpanel(subpanel $subpanel) {
$this->secondaryactions[] = $subpanel;
}
/**
* Adds a primary action to the action menu.
*
@@ -4754,8 +4767,6 @@ class action_menu implements renderable, templatable {
$this->attributes['role'] = 'menubar';
}
$attributes = $this->attributes;
$attributesprimary = $this->attributesprimary;
$attributessecondary = $this->attributessecondary;
$data->instance = $this->instance;
@@ -4766,17 +4777,34 @@ class action_menu implements renderable, templatable {
return [ 'name' => $key, 'value' => $value ];
}, array_keys($attributes), $attributes);
$data->primary = $this->export_primary_actions_for_template($output);
$data->secondary = $this->export_secondary_actions_for_template($output);
$data->dropdownalignment = $this->dropdownalignment;
return $data;
}
/**
* Export the primary actions for the template.
* @param renderer_base $output
* @return stdClass
*/
protected function export_primary_actions_for_template(renderer_base $output): stdClass {
$attributes = $this->attributes;
$attributesprimary = $this->attributesprimary;
$primary = new stdClass();
$primary->title = '';
$primary->prioritise = $this->prioritise;
$primary->classes = isset($attributesprimary['class']) ? $attributesprimary['class'] : '';
unset($attributesprimary['class']);
$primary->attributes = array_map(function($key, $value) {
return [ 'name' => $key, 'value' => $value ];
$primary->attributes = array_map(function ($key, $value) {
return ['name' => $key, 'value' => $value];
}, array_keys($attributesprimary), $attributesprimary);
$primary->triggerattributes = array_map(function($key, $value) {
return [ 'name' => $key, 'value' => $value ];
$primary->triggerattributes = array_map(function ($key, $value) {
return ['name' => $key, 'value' => $value];
}, array_keys($this->triggerattributes), $this->triggerattributes);
$actionicon = $this->actionicon;
@@ -4812,7 +4840,7 @@ class action_menu implements renderable, templatable {
}
$primary->actiontext = $this->actiontext ? (string) $this->actiontext : '';
$primary->items = array_map(function($item) use ($output) {
$primary->items = array_map(function ($item) use ($output) {
$data = (object) [];
if ($item instanceof action_menu_link) {
$data->actionmenulink = $item->export_for_template($output);
@@ -4827,19 +4855,36 @@ class action_menu implements renderable, templatable {
}
return $data;
}, $this->primaryactions);
return $primary;
}
/**
* Export the secondary actions for the template.
* @param renderer_base $output
* @return stdClass
*/
protected function export_secondary_actions_for_template(renderer_base $output): stdClass {
$attributessecondary = $this->attributessecondary;
$secondary = new stdClass();
$secondary->classes = isset($attributessecondary['class']) ? $attributessecondary['class'] : '';
unset($attributessecondary['class']);
$secondary->attributes = array_map(function($key, $value) {
return [ 'name' => $key, 'value' => $value ];
$secondary->attributes = array_map(function ($key, $value) {
return ['name' => $key, 'value' => $value];
}, array_keys($attributessecondary), $attributessecondary);
$secondary->items = array_map(function($item) use ($output) {
$data = (object) [];
$secondary->items = array_map(function ($item) use ($output) {
$data = (object) [
'simpleitem' => true,
];
if ($item instanceof action_menu_link) {
$data->actionmenulink = $item->export_for_template($output);
$data->simpleitem = false;
} else if ($item instanceof action_menu_filler) {
$data->actionmenufiller = $item->export_for_template($output);
$data->simpleitem = false;
} else if ($item instanceof subpanel) {
$data->subpanel = $item->export_for_template($output);
$data->simpleitem = false;
} else if ($item instanceof action_link) {
$data->actionlink = $item->export_for_template($output);
} else if ($item instanceof pix_icon) {
@@ -4849,14 +4894,8 @@ class action_menu implements renderable, templatable {
}
return $data;
}, $this->secondaryactions);
$data->primary = $primary;
$data->secondary = $secondary;
$data->dropdownalignment = $this->dropdownalignment;
return $data;
return $secondary;
}
}
/**
+6 -5
View File
@@ -121,11 +121,12 @@
{{#actionmenufiller}}
<div class="dropdown-divider" role="presentation"><span class="filler">&nbsp;</span></div>
{{/actionmenufiller}}
{{^actionmenulink}}
{{^actionmenufiller}}
<div class="dropdown-item">{{> core/action_menu_item }}</div>
{{/actionmenufiller}}
{{/actionmenulink}}
{{#subpanel}}
{{> core/local/action_menu/subpanel}}
{{/subpanel}}
{{#simpleitem}}
<div class="dropdown-item">{{> core/action_menu_item }}</div>
{{/simpleitem}}
{{/items}}
</div>
{{/secondary}}
@@ -0,0 +1,68 @@
{{!
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/>.
}}
{{!
@template core/local/action_menu/subpanel
Action menu link.
Example context (json):
{
"id": "exampleId12345678",
"text": "Example link text",
"subpanelcontent": "Example subpanel content",
"url": "http://example.com",
"attributes": [
{
"name": "data-attribute",
"value": "example"
}
],
"itemicon": {
"key": "t/groups",
"component": "core",
"title": "Example icon title"
},
"instance": 1,
"disabled": false
}
}}
<div
class="dropdown-subpanel position-relative dropright"
id="{{id}}"
>
<a
class="dropdown-item dropdown-toggle {{#disabled}} disabled {{/disabled}}"
href="{{url}}"
data-toggle="dropdown-subpanel"
role="menuitem"
aria-haspopup="true"
tabindex="-1"
aria-expanded="false"
aria-label="{{text}}"
{{#attributes}}
{{name}}="{{value}}"
{{/attributes}}
>
{{#itemicon}}
{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}
{{/itemicon}}
<span class="menu-action-text" id="actionmenuactionsubpanel-{{instance}}">{{{text}}}</span>
</a>
<div class="dropdown-menu dropdown-subpanel-content" role="menu">
{{{subpanelcontent}}}
</div>
</div>
{{#js}}
require(['core/local/action_menu/subpanel'], function(Module) {
Module.init('#' + '{{id}}');
});
{{/js}}
+130
View File
@@ -0,0 +1,130 @@
{{!
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/>.
}}
{{!
@template core/local/choicelist
Default template for a choicelist object.
Classes required for JS:
* none
Context variables required for this template:
* buttoncontent String - the dropdown trigger button content.
* choices Array - the status options.
Example context (json):
{
"hasoptions": true,
"dialogcontent": "Dialog content",
"options": [
{
"optionid": "option1",
"value": "value1",
"name": "First option",
"description": "First option description",
"hasicon": false,
"first": true,
"optionnumber": 1,
"optionuniqid": "option1uniqid",
"selected": true
},
{
"optionid": "option2",
"value": "value2",
"name": "Second option",
"description": "Second option description",
"icon": {
"extraclasses": "iconhelp",
"attributes": [
{"name": "src", "value": "../../../pix/help.svg"},
{"name": "alt", "value": "Help icon"}
]
},
"hasicon": true,
"optionnumber": 2,
"optionuniqid": "option2uniqid"
},
{
"optionid": "option3",
"value": "value3",
"name": "Third option",
"description": "Third option description",
"icon": {
"extraclasses": "iconhelp",
"attributes": [
{"name": "src", "value": "../../../pix/help.svg"},
{"name": "alt", "value": "Help icon"}
]
},
"hasicon": true,
"disabled": true,
"optionnumber": 3,
"optionuniqid": "option3uniqid"
}
]
}
}}
<div class="d-flex flex-column choicelist" role="listbox">
{{#options}}
<div
class="d-flex flex-row align-items-start p-2 position-relative rounded {{!
}} {{#disabled}} dimmed_text {{/disabled}} {{!
}} {{#selected}} bg-light selected {{/selected}}"
data-optionnumber="{{optionnumber}}"
data-selected="{{selected}}"
>
{{#icon}}
<div class="option-icon">
{{>core/pix_icon}}
</div>
{{/icon}}
<div class="option-select-indicator">
{{#selected}}
{{#pix}} i/checkedcircle, core, {{#str}} selected, form {{/str}} {{/pix}}
{{/selected}}
{{^selected}}
{{#pix}} i/uncheckedcircle{{/pix}}
{{/selected}}
</div>
<div class="option-name">
<a
class="stretched-link text-wrap {{!
}} {{#disabled}} disabled {{/disabled}} {{!
}} {{#selected}} selected disabled {{/selected}}"
role="option"
{{#selected}} aria-selected="true" {{/selected}}
{{#description}} aria-describedby="{{optionuniqid}}" {{/description}}
data-value="{{value}}"
{{#hasurl}} href="{{{url}}}" {{/hasurl}}
{{! If there is no url, supose JS will handle it somehow. }}
{{^hasurl}} href="#" {{/hasurl}}
{{#disabled}} tabindex="-1" {{/disabled}}
{{#extras}}
{{attribute}}="{{value}}"
{{/extras}}
>
{{name}}
</a>
{{#description}}
<div id="{{optionuniqid}}" class="option-description small text-muted text-wrap">
{{{description}}}
</div>
{{/description}}
</div>
</div>
{{/options}}
</div>
@@ -0,0 +1,227 @@
@core @javascript
Feature: Navigate action menu subpanels
In order to navigate an action menu subpanel
As a user
I need to be able to use both keyboard and mouse to open the subpanel
Background:
Given I log in as "admin"
And I am on fixture page "/lib/tests/behat/fixtures/action_menu_subpanel_output_testpage.php"
Scenario: Navigate several action menus subpanels with mouse
Given I click on "Actions menu" "button" in the "regularscenario" "region"
And I click on "Subpanel example" "menuitem" in the "regularscenario" "region"
And I should see "Status A" in the "regularscenario" "region"
And I should see "Status B" in the "regularscenario" "region"
And I should not see "Status C" in the "regularscenario" "region"
And I should not see "Status D" in the "regularscenario" "region"
When I click on "Another subpanel" "menuitem" in the "regularscenario" "region"
Then I should not see "Status A" in the "regularscenario" "region"
And I should not see "Status B" in the "regularscenario" "region"
And I should see "Status C" in the "regularscenario" "region"
And I should see "Status D" in the "regularscenario" "region"
And I click on "Status D" "link" in the "regularscenario" "region"
And I should see "Foo param value: Donkey" in the "paramcheck" "region"
Scenario: Check extra data in subpanel action menu items
When I should see "Adding data attributes to menu item" in the "dataattributes" "region"
# the page have a javascript script to check that for us.
Then "[data-extra='some other value']" "css_element" should exist in the "dataattributes" "region"
And "[data-extra='some other value']" "css_element" should exist in the "dataattributes" "region"
And I should see "Extra data attribute detected: some extra value" in the "datachecks" "region"
And I should see "Extra data attribute detected: some other value" in the "datachecks" "region"
Scenario: User can navigate left menus subpanels
Given I click on "Actions menu" "button" in the "menuleft" "region"
And I click on "Subpanel example" "menuitem" in the "menuleft" "region"
And I should see "Status A" in the "menuleft" "region"
And I should see "Status B" in the "menuleft" "region"
And I should not see "Status C" in the "menuleft" "region"
And I should not see "Status D" in the "menuleft" "region"
When I click on "Another subpanel" "menuitem" in the "menuleft" "region"
Then I should not see "Status A" in the "menuleft" "region"
And I should not see "Status B" in the "menuleft" "region"
And I should see "Status C" in the "menuleft" "region"
And I should see "Status D" in the "menuleft" "region"
And I click on "Status D" "link" in the "menuleft" "region"
And I should see "Foo param value: Donkey" in the "paramcheck" "region"
Scenario: User can show the subpanels content using keyboard
Given I click on "Actions menu" "button" in the "regularscenario" "region"
# Move to the first subpanel element.
And I press the down key
And I press the down key
And I press the down key
And I should see "Status A" in the "regularscenario" "region"
And I should see "Status B" in the "regularscenario" "region"
And I should not see "Status C" in the "regularscenario" "region"
And I should not see "Status D" in the "regularscenario" "region"
# Move to the next subpanel.
When I press the down key
Then I should not see "Status A" in the "regularscenario" "region"
And I should not see "Status B" in the "regularscenario" "region"
And I should see "Status C" in the "regularscenario" "region"
And I should see "Status D" in the "regularscenario" "region"
Scenario: User can browse the subpanel content using the arrow keys
Given I click on "Actions menu" "button" in the "regularscenario" "region"
# Move to the first subpanel element.
And I press the down key
And I press the down key
And I press the down key
# Move in the subpanel with arrow keys and loop the links with up and down.
When I press the right key
And the focused element is "Status A" "link" in the "regularscenario" "region"
And I press the down key
And the focused element is "Status B" "link" in the "regularscenario" "region"
And I press the down key
And the focused element is "Status A" "link" in the "regularscenario" "region"
And I press the up key
And the focused element is "Status B" "link" in the "regularscenario" "region"
# Leave the subpanel with right and left key.
Then I press the right key
And the focused element is "Subpanel example" "menuitem" in the "regularscenario" "region"
And I press the right key
And the focused element is "Status A" "link" in the "regularscenario" "region"
And I press the left key
And the focused element is "Subpanel example" "menuitem" in the "regularscenario" "region"
And I press the left key
And the focused element is "Status A" "link" in the "regularscenario" "region"
And I press the left key
And the focused element is "Subpanel example" "menuitem" in the "regularscenario" "region"
# Move to the next subpanel with enter.
And I press the down key
And I press the right key
And the focused element is "Status C" "link" in the "regularscenario" "region"
And I press the down key
And the focused element is "Status D" "link" in the "regularscenario" "region"
# Select the current link of the panel with enter.
And I press the enter key
And I should see "Foo param value: Donkey" in the "paramcheck" "region"
Scenario: User can open and close subpanels in mobile
Given I change the viewport size to "mobile"
And I click on "Actions menu" "button" in the "regularscenario" "region"
And I should not see "Status A" in the "regularscenario" "region"
And I should not see "Status B" in the "regularscenario" "region"
And I should not see "Status C" in the "regularscenario" "region"
And I should not see "Status D" in the "regularscenario" "region"
When I click on "Subpanel example" "menuitem" in the "regularscenario" "region"
And I should see "Status A" in the "regularscenario" "region"
And I should see "Status B" in the "regularscenario" "region"
And I should not see "Status C" in the "regularscenario" "region"
And I should not see "Status D" in the "regularscenario" "region"
# In mobile click the menu item toggles the subpanel.
Then I click on "Subpanel example" "menuitem" in the "regularscenario" "region"
And I should not see "Status A" in the "regularscenario" "region"
And I should not see "Status B" in the "regularscenario" "region"
And I should not see "Status C" in the "regularscenario" "region"
And I should not see "Status D" in the "regularscenario" "region"
And I click on "Another subpanel" "menuitem" in the "regularscenario" "region"
And I should not see "Status A" in the "regularscenario" "region"
And I should not see "Status B" in the "regularscenario" "region"
And I should see "Status C" in the "regularscenario" "region"
And I should see "Status D" in the "regularscenario" "region"
And I click on "Status D" "link" in the "regularscenario" "region"
And I should see "Foo param value: Donkey" in the "paramcheck" "region"
Scenario: User can browse the subpanels using keys in extra small windows
Given I change the viewport size to "mobile"
And I click on "Actions menu" "button" in the "regularscenario" "region"
# Go to the seconds subpanel and open it with enter.
And I press the down key
And I press the down key
And I press the down key
And I press the down key
And the focused element is "Another subpanel" "menuitem" in the "regularscenario" "region"
And I press the enter key
When I should not see "Status A" in the "regularscenario" "region"
And I should not see "Status B" in the "regularscenario" "region"
And I should see "Status C" in the "regularscenario" "region"
And I should see "Status D" in the "regularscenario" "region"
# Loop the subpanel links wand the menu item with up and down.
Then I press the down key
And the focused element is "Status C" "link" in the "regularscenario" "region"
And I press the down key
And the focused element is "Status D" "link" in the "regularscenario" "region"
And I press the down key
And the focused element is "Another subpanel" "menuitem" in the "regularscenario" "region"
And I press the down key
And the focused element is "Status C" "link" in the "regularscenario" "region"
And I press the down key
And the focused element is "Status D" "link" in the "regularscenario" "region"
And I press the up key
And the focused element is "Status C" "link" in the "regularscenario" "region"
And I press the up key
And the focused element is "Another subpanel" "menuitem" in the "regularscenario" "region"
# Use up in the item to close the panel.
And I press the up key
And I should not see "Status A" in the "regularscenario" "region"
And I should not see "Status B" in the "regularscenario" "region"
And I should not see "Status C" in the "regularscenario" "region"
And I should not see "Status D" in the "regularscenario" "region"
And the focused element is "Subpanel example" "menuitem" in the "regularscenario" "region"
# Enter the panel and select the second link.
And I press the enter key
And I press the down key
And the focused element is "Status A" "link" in the "regularscenario" "region"
And I press the enter key
And I should see "Foo param value: Aardvark" in the "paramcheck" "region"
Scenario: action menu subpanels can display optional icons in the menu item
Given I click on "Actions menu" "button" in the "regularscenario" "region"
And "Locked icon" "icon" should not exist in the "regularscenario" "region"
And "Message icon" "icon" should not exist in the "regularscenario" "region"
And I click on "Actions menu" "button" in the "menuleft" "region"
And "Locked icon" "icon" should not exist in the "menuleft" "region"
And "Message icon" "icon" should not exist in the "menuleft" "region"
When I click on "Actions menu" "button" in the "itemicon" "region"
Then "Locked icon" "icon" should exist in the "itemicon" "region"
And "Message icon" "icon" should exist in the "itemicon" "region"
And I click on "Actions menu" "button" in the "itemiconleft" "region"
And "Locked icon" "icon" should exist in the "itemiconleft" "region"
And "Message icon" "icon" should exist in the "itemiconleft" "region"
@accessibility
Scenario: User can browse the subpanels using keys in a drawer action menu
Given I click on "Actions menu" "button" in the "drawersimulation" "region"
# Go to the seconds subpanel and open it with enter.
And I press the down key
And I press the down key
And I press the down key
And I press the down key
And the focused element is "Another subpanel" "menuitem" in the "drawersimulation" "region"
And I press the enter key
When I should not see "Status A" in the "drawersimulation" "region"
And I should not see "Status B" in the "drawersimulation" "region"
And I should see "Status C" in the "drawersimulation" "region"
And I should see "Status D" in the "drawersimulation" "region"
# Loop the subpanel links wand the menu item with up and down.
Then I press the down key
And the focused element is "Status C" "link" in the "drawersimulation" "region"
And I press the down key
And the focused element is "Status D" "link" in the "drawersimulation" "region"
And I press the down key
And the focused element is "Another subpanel" "menuitem" in the "drawersimulation" "region"
And I press the down key
And the focused element is "Status C" "link" in the "drawersimulation" "region"
And I press the down key
And the focused element is "Status D" "link" in the "drawersimulation" "region"
And I press the up key
And the focused element is "Status C" "link" in the "drawersimulation" "region"
And I press the up key
And the focused element is "Another subpanel" "menuitem" in the "drawersimulation" "region"
# Use up in the item to close the panel.
And I press the up key
And I should not see "Status A" in the "drawersimulation" "region"
And I should not see "Status B" in the "drawersimulation" "region"
And I should not see "Status C" in the "drawersimulation" "region"
And I should not see "Status D" in the "drawersimulation" "region"
And the focused element is "Subpanel example" "menuitem" in the "drawersimulation" "region"
And the page should meet accessibility standards with "wcag143" extra tests
# Enter the panel and select the second link.
And I press the enter key
And I press the down key
And the focused element is "Status A" "link" in the "drawersimulation" "region"
And I press the enter key
And I should see "Foo param value: Aardvark" in the "paramcheck" "region"
@@ -0,0 +1,253 @@
<?php
// 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/>.
/**
* Test page for action menu subpanel output component.
*
* @copyright 2023 Ferran Recio <[email protected]>
* @package core
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
require_once(__DIR__ . '/../../../../config.php');
defined('BEHAT_SITE_RUNNING') || die();
$foo = optional_param('foo', 'none', PARAM_TEXT);
global $CFG, $PAGE, $OUTPUT;
$PAGE->set_url('/lib/tests/behat/fixtures/action_menu_subpanel_output_testpage.php');
$PAGE->add_body_class('limitedwidth');
require_login();
$PAGE->set_context(core\context\system::instance());
$PAGE->set_title('Action menu subpanel test page');
echo $OUTPUT->header();
$choice1 = new core\output\choicelist('Choice example');
$choice1->add_option("statusa", "Status A", [
'url' => new moodle_url($PAGE->url, ['foo' => 'Aardvark']),
'description' => 'Status A description',
'icon' => new pix_icon('t/user', '', ''),
]);
$choice1->add_option("statusb", "Status B", [
'url' => new moodle_url($PAGE->url, ['foo' => 'Beetle']),
'description' => 'Status B description',
'icon' => new pix_icon('t/groupv', '', ''),
]);
$choice1->set_selected_value('statusb');
$choice2 = new core\output\choicelist('Choice example');
$choice2->add_option("statusc", "Status C", [
'url' => new moodle_url($PAGE->url, ['foo' => 'Caterpillar']),
'description' => 'Status C description',
'icon' => new pix_icon('t/groups', '', ''),
]);
$choice2->add_option("statusd", "Status D", [
'url' => new moodle_url($PAGE->url, ['foo' => 'Donkey']),
'description' => 'Status D description',
'icon' => new pix_icon('t/hide', '', ''),
]);
$choice2->set_selected_value('statusc');
$normalactionlink = new action_menu_link(
new moodle_url($PAGE->url, ['foo' => 'bar']),
new pix_icon('t/emptystar', ''),
'Action link example',
false
);
echo "<h2>Action menu subpanel test page</h2>";
echo '<div id="paramcheck" class="mb-4">';
echo "<p>Foo param value: $foo</p>";
echo '</div>';
echo '<div id="regularscenario" class="mb-4">';
echo "<h3>Basic example</h3>";
$menu = new action_menu();
$menu->add($normalactionlink);
$menu->add($normalactionlink);
$menu->add(
new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice1
)
);
$menu->add(
new core\output\local\action_menu\subpanel(
'Another subpanel',
$choice2
)
);
echo '<div class="border p-2 d-flex flex-row">';
echo '<div class="flex-fill">Menu right example</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo '</div>';
echo '<div id="menuleft" class="mb-4">';
echo "<h3>Menu left</h3>";
$menu = new action_menu();
$menu->set_menu_left();
$menu->add($normalactionlink);
$menu->add($normalactionlink);
$menu->add(
new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice1,
null,
null
)
);
$menu->add(
new core\output\local\action_menu\subpanel(
'Another subpanel',
$choice2,
null,
null
)
);
echo '<div class="border p-2 d-flex flex-row"><div>';
echo $OUTPUT->render($menu);
echo '</div><div class="flex-fill ml-2">Menu left example</div></div>';
echo '</div>';
echo '<div id="itemicon" class="mb-4">';
echo "<h3>Menu item with icon</h3>";
$menu = new action_menu();
$menu->add($normalactionlink);
$menu->add($normalactionlink);
$menu->add(
new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice1,
null,
new pix_icon('t/locked', 'Locked icon')
)
);
$menu->add(
new core\output\local\action_menu\subpanel(
'Another subpanel',
$choice2,
null,
new pix_icon('t/message', 'Message icon')
)
);
echo '<div class="border p-2 d-flex flex-row">';
echo '<div class="flex-fill">Menu right example</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo '</div>';
echo '<div id="itemiconleft" class="mb-4">';
echo "<h3>Left menu with item icon</h3>";
$menu = new action_menu();
$menu->set_menu_left();
$menu->add($normalactionlink);
$menu->add($normalactionlink);
$menu->add(
new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice1,
null,
new pix_icon('t/locked', 'Locked icon')
)
);
$menu->add(
new core\output\local\action_menu\subpanel(
'Another subpanel',
$choice2,
null,
new pix_icon('t/message', 'Message icon')
)
);
echo '<div class="border p-2 d-flex flex-row"><div>';
echo $OUTPUT->render($menu);
echo '</div><div class="flex-fill ml-2">Menu left example</div></div>';
echo '</div>';
echo '<div id="dataattributes" class="mb-4">';
echo "<h3>Adding data attributes to menu item</h3>";
$menu = new action_menu();
$menu->add($normalactionlink);
$menu->add($normalactionlink);
$menu->add(
new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice1,
['data-extra' => 'some extra value']
)
);
$menu->add(
new core\output\local\action_menu\subpanel(
'Another subpanel',
$choice2,
['data-extra' => 'some other value']
)
);
echo '<div class="border p-2 d-flex flex-row">';
echo '<div class="flex-fill">Menu right example</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo '<div class="mt-1 p-2 border" id="datachecks">Nothing here.</div>';
echo '</div>';
$inlinejs = <<<EOF
const datachecks = document.getElementById('datachecks');
const dataitems = document.querySelectorAll('[data-extra]');
let dataitemshtml = '';
for (let i = 0; i < dataitems.length; i++) {
dataitemshtml += '<p>Extra data attribute detected: ' + dataitems[i].getAttribute('data-extra') + '</p>';
}
datachecks.innerHTML = dataitemshtml;
EOF;
$PAGE->requires->js_amd_inline($inlinejs);
echo '<div id="drawersimulation" class="mb-4">';
echo "<h3>Drawer like example</h3>";
$menu = new action_menu();
$menu->add($normalactionlink);
$menu->add($normalactionlink);
$menu->add(
new core\output\local\action_menu\subpanel(
'Subpanel example',
$choice1
)
);
$menu->add(
new core\output\local\action_menu\subpanel(
'Another subpanel',
$choice2
)
);
echo '<div class="border p-2 d-flex flex-row" data-region="fixed-drawer" data-behat-fake-drawer="true" style="width: 350px;">';
echo '<div class="flex-fill">Drawer example</div><div>';
echo $OUTPUT->render($menu);
echo '</div></div>';
echo '</div>';
echo $OUTPUT->footer();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -44
View File
@@ -25,6 +25,7 @@ import Templates from 'core/templates';
import * as Aria from 'core/aria';
import {dispatchEvent} from 'core/event_dispatcher';
import {debounce} from 'core/utils';
import {isSmall, isLarge} from 'core/pagehelpers';
import Pending from 'core/pending';
// The jQuery module is only used for interacting with Boostrap 4. It can we removed when MDL-71979 is integrated.
import jQuery from 'jquery';
@@ -50,50 +51,6 @@ const CLASSES = {
TOGGLERIGHT: '.drawer-right-toggle',
};
/**
* Maximum sizes for breakpoints. This needs to correspond with Bootstrap
* Breakpoints
*
* @private
*/
const sizes = {
medium: 991,
large: 1400
};
/**
* Get the current body width.
*
* @returns {number} the current body width.
* @private
*/
const getCurrentWidth = () => {
const DomRect = document.body.getBoundingClientRect();
return DomRect.x + DomRect.width;
};
/**
* Check if the user uses a small size browser.
*
* @returns {boolean} true if the body is smaller than sizes.medium max size.
* @private
*/
const isSmall = () => {
const browserWidth = getCurrentWidth();
return browserWidth < sizes.medium;
};
/**
* Check if the user uses a large size browser.
*
* @returns {boolean} true if the body is smaller than sizes.large max size.
* @private
*/
const isLarge = () => {
const browserWidth = getCurrentWidth();
return browserWidth >= sizes.large;
};
/**
* Add a backdrop to the page.
*
@@ -283,6 +240,11 @@ export default class Drawers {
drawerNode = null;
constructor(drawerNode) {
// Some behat tests may use fake drawer divs to test components in drawers.
if (drawerNode.dataset.behatFakeDrawer !== undefined) {
return;
}
this.drawerNode = drawerNode;
if (isSmall()) {
+44
View File
@@ -370,6 +370,23 @@ img.resize {
.action-menu {
white-space: nowrap;
display: inline;
.dropdown.downleft .dropdown-subpanel-content {
right: 0;
left: auto;
}
.dropdown-subpanel.content-displayed {
background-color: $gray-200;
}
.dropdown-subpanel-content {
max-width: $modal-sm;
a:focus {
outline: solid $primary;
}
}
}
.block img.resize {
@@ -3059,12 +3076,21 @@ body.dragging {
width: 9px;
}
.dir-rtl .dropleft .dropdown-toggle::before {
content: fa-content($fa-var-chevron-right);
}
.dropright .dropdown-toggle::after {
border: 0;
@extend .fa-solid;
content: fa-content($fa-var-chevron-right);
}
.dir-rtl .dropright .dropdown-toggle::after {
content: fa-content($fa-var-chevron-left);
}
.dropup .dropdown-toggle::after {
border: 0;
@extend .fa-solid;
@@ -3205,3 +3231,21 @@ blockquote {
}
}
}
/* Choice list component. */
.choicelist {
min-width: $modal-sm;
}
[data-region="fixed-drawer"] {
.choicelist {
min-width: calc(#{$modal-sm} - 25px);
}
}
@include media-breakpoint-down(xs) {
// Modal small are larger than xs breakpoint.
.choicelist {
min-width: calc(#{$modal-sm} - 25px);
}
}
+35
View File
@@ -23246,6 +23246,19 @@ img.resize {
white-space: nowrap;
display: inline;
}
.action-menu .dropdown.downleft .dropdown-subpanel-content {
right: 0;
left: auto;
}
.action-menu .dropdown-subpanel.content-displayed {
background-color: #e9ecef;
}
.action-menu .dropdown-subpanel-content {
max-width: 300px;
}
.action-menu .dropdown-subpanel-content a:focus {
outline: solid #0f6cbf;
}
.block img.resize {
height: 0.9em;
@@ -25872,11 +25885,19 @@ body.dragging .dragging {
width: 9px;
}
.dir-rtl .dropleft .dropdown-toggle::before {
content: "\f054";
}
.dropright .dropdown-toggle::after {
border: 0;
content: "\f054";
}
.dir-rtl .dropright .dropdown-toggle::after {
content: "\f053";
}
.dropup .dropdown-toggle::after {
border: 0;
content: "\f077";
@@ -26004,6 +26025,20 @@ blockquote {
width: 48px !important;
}
/* Choice list component. */
.choicelist {
min-width: 300px;
}
[data-region=fixed-drawer] .choicelist {
min-width: calc(300px - 25px);
}
@media (max-width: 575.98px) {
.choicelist {
min-width: calc(300px - 25px);
}
}
.icon {
font-size: 16px;
width: 16px;
+35
View File
@@ -23246,6 +23246,19 @@ img.resize {
white-space: nowrap;
display: inline;
}
.action-menu .dropdown.downleft .dropdown-subpanel-content {
right: 0;
left: auto;
}
.action-menu .dropdown-subpanel.content-displayed {
background-color: #e9ecef;
}
.action-menu .dropdown-subpanel-content {
max-width: 300px;
}
.action-menu .dropdown-subpanel-content a:focus {
outline: solid #0f6cbf;
}
.block img.resize {
height: 0.9em;
@@ -25872,11 +25885,19 @@ body.dragging .dragging {
width: 9px;
}
.dir-rtl .dropleft .dropdown-toggle::before {
content: "\f054";
}
.dropright .dropdown-toggle::after {
border: 0;
content: "\f054";
}
.dir-rtl .dropright .dropdown-toggle::after {
content: "\f053";
}
.dropup .dropdown-toggle::after {
border: 0;
content: "\f077";
@@ -26004,6 +26025,20 @@ blockquote {
width: 48px !important;
}
/* Choice list component. */
.choicelist {
min-width: 300px;
}
[data-region=fixed-drawer] .choicelist {
min-width: calc(300px - 25px);
}
@media (max-width: 575.98px) {
.choicelist {
min-width: calc(300px - 25px);
}
}
.icon {
font-size: 16px;
width: 16px;