Merge branch 'MDL-78665-master-v02' of https://github.com/ferranrecio/moodle
This commit is contained in:
@@ -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();
|
||||
+10
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+11
@@ -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
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,11 +121,12 @@
|
||||
{{#actionmenufiller}}
|
||||
<div class="dropdown-divider" role="presentation"><span class="filler"> </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}}
|
||||
@@ -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();
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user