Merge branch 'MDL-78885-main' of https://github.com/rezaies/moodle

This commit is contained in:
Jun Pataleta 2024-03-25 23:37:57 +08:00
commit 281fecbd54
No known key found for this signature in database
GPG key ID: F83510526D99E2C7
72 changed files with 723 additions and 817 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -24,7 +24,6 @@ import search_combobox from 'core/comboboxsearch/search_combobox';
import {getStrings} from 'core/str';
import {renderForPromise, replaceNodeContents} from 'core/templates';
import $ from 'jquery';
import Notification from 'core/notification';
export default class UserSearch extends search_combobox {
@ -36,14 +35,19 @@ export default class UserSearch extends search_combobox {
constructor() {
super();
// Register a small click event onto the document since we need to check if they are clicking off the component.
document.addEventListener('click', (e) => {
// Since we are handling dropdowns manually, ensure we can close it when clicking off.
if (!e.target.closest(this.selectors.component) && this.searchDropdown.classList.contains('show')) {
this.toggleDropdown();
}
// Register a couple of events onto the document since we need to check if they are moving off the component.
['click', 'focus'].forEach(eventType => {
// Since we are handling dropdowns manually, ensure we can close it when moving off.
document.addEventListener(eventType, e => {
if (this.searchDropdown.classList.contains('show') && !this.combobox.contains(e.target)) {
this.toggleDropdown();
}
}, true);
});
// Register keyboard events.
this.component.addEventListener('keydown', this.keyHandler.bind(this));
// Define our standard lookups.
this.selectors = {...this.selectors,
courseid: '[data-region="courseid"]',
@ -51,9 +55,12 @@ export default class UserSearch extends search_combobox {
resetPageButton: '[data-action="resetpage"]',
};
const component = document.querySelector(this.componentSelector());
this.courseID = component.querySelector(this.selectors.courseid).dataset.courseid;
this.courseID = this.component.querySelector(this.selectors.courseid).dataset.courseid;
this.groupID = document.querySelector(this.selectors.groupid)?.dataset?.groupid;
this.instance = this.component.querySelector(this.selectors.instance).dataset.instance;
// We need to render some content by default for ARIA purposes.
this.renderDefault();
}
static init() {
@ -78,15 +85,6 @@ export default class UserSearch extends search_combobox {
return '.usersearchdropdown';
}
/**
* The triggering div that contains the searching widget.
*
* @returns {string}
*/
triggerSelector() {
return '.usersearchwidget';
}
/**
* Build the content then replace the node.
*/
@ -94,11 +92,24 @@ export default class UserSearch extends search_combobox {
const {html, js} = await renderForPromise('core_user/comboboxsearch/resultset', {
users: this.getMatchedResults().slice(0, 5),
hasresults: this.getMatchedResults().length > 0,
instance: this.instance,
matches: this.getMatchedResults().length,
searchterm: this.getSearchTerm(),
selectall: this.selectAllResultsLink(),
});
replaceNodeContents(this.getHTMLElements().searchDropdown, html, js);
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
/**
* Build the content then replace the node by default we want our form to exist.
*/
async renderDefault() {
this.setMatchedResults(await this.filterDataset(await this.getDataset()));
this.filterMatchDataset();
await this.renderDropdown();
}
/**
@ -117,13 +128,17 @@ export default class UserSearch extends search_combobox {
* @returns {Array} The users that match the given criteria.
*/
async filterDataset(filterableData) {
const stringMap = await this.getStringMap();
return filterableData.filter((user) => Object.keys(user).some((key) => {
if (user[key] === "" || user[key] === null || !stringMap.get(key)) {
return false;
}
return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());
}));
if (this.getPreppedSearchTerm()) {
const stringMap = await this.getStringMap();
return filterableData.filter((user) => Object.keys(user).some((key) => {
if (user[key] === "" || user[key] === null || !stringMap.get(key)) {
return false;
}
return user[key].toString().toLowerCase().includes(this.getPreppedSearchTerm());
}));
} else {
return [];
}
}
/**
@ -158,7 +173,6 @@ export default class UserSearch extends search_combobox {
);
user.matchingField = `${escapedMatchingField} (${user.email})`;
user.link = this.selectOneLink(user.id);
break;
}
}
@ -168,22 +182,17 @@ export default class UserSearch extends search_combobox {
}
/**
* The handler for when a user interacts with the component.
* The handler for when a user changes the value of the component (selects an option from the dropdown).
*
* @param {MouseEvent} e The triggering event that we are working with.
* @param {Event} e The change event.
*/
clickHandler(e) {
super.clickHandler(e).catch(Notification.exception);
if (e.target.closest(this.selectors.component)) {
// Forcibly prevent BS events so that we can control the open and close.
// Really needed because by default input elements cant trigger a dropdown.
e.stopImmediatePropagation();
}
if (e.target === this.getHTMLElements().currentViewAll && e.button === 0) {
changeHandler(e) {
this.toggleDropdown(); // Otherwise the dropdown stays open when user choose an option using keyboard.
if (e.target.value === '0') {
window.location = this.selectAllResultsLink();
}
if (e.target.closest(this.selectors.resetPageButton)) {
window.location = e.target.closest(this.selectors.resetPageButton).href;
} else {
window.location = this.selectOneLink(e.target.value);
}
}
@ -193,50 +202,30 @@ export default class UserSearch extends search_combobox {
* @param {KeyboardEvent} e The triggering event that we are working with.
*/
keyHandler(e) {
super.keyHandler(e);
if (e.target === this.getHTMLElements().currentViewAll && (e.key === 'Enter' || e.key === 'Space')) {
window.location = this.selectAllResultsLink();
}
// Switch the key presses to handle keyboard nav.
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
if (
this.getSearchTerm() !== ''
&& !this.searchDropdown.classList.contains('show')
&& e.target.contains(this.combobox)
) {
this.renderAndShow();
}
break;
case 'Enter':
case ' ':
if (document.activeElement === this.getHTMLElements().searchInput) {
if (e.key === 'Enter' && this.selectAllResultsLink() !== null) {
window.location = this.selectAllResultsLink();
}
}
if (document.activeElement === this.getHTMLElements().clearSearchButton) {
this.closeSearch(true);
break;
}
if (e.target.closest(this.selectors.resetPageButton)) {
e.stopPropagation();
window.location = e.target.closest(this.selectors.resetPageButton).href;
break;
}
if (e.target.closest('.dropdown-item')) {
e.preventDefault();
window.location = e.target.closest('.dropdown-item').href;
break;
}
break;
case 'Escape':
this.toggleDropdown();
this.searchInput.focus({preventScroll: true});
break;
case 'Tab':
// If the current focus is on clear search, then check if viewall exists then around tab to it.
if (e.target.closest(this.selectors.clearSearch)) {
if (this.currentViewAll && !e.shiftKey) {
e.preventDefault();
this.currentViewAll.focus({preventScroll: true});
} else {
this.closeSearch();
}
}
break;
}
}
@ -249,11 +238,18 @@ export default class UserSearch extends search_combobox {
if (on) {
this.searchDropdown.classList.add('show');
$(this.searchDropdown).show();
this.component.setAttribute('aria-expanded', 'true');
this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'true');
this.searchInput.focus({preventScroll: true});
} else {
this.searchDropdown.classList.remove('show');
$(this.searchDropdown).hide();
this.component.setAttribute('aria-expanded', 'false');
// As we are manually handling the dropdown, we need to do some housekeeping manually.
this.getHTMLElements().searchInput.setAttribute('aria-expanded', 'false');
this.searchInput.removeAttribute('aria-activedescendant');
this.searchDropdown.querySelectorAll('.active[role="option"]').forEach(option => {
option.classList.remove('active');
});
}
}
@ -266,6 +262,7 @@ export default class UserSearch extends search_combobox {
/**
* Build up the view all link that is dedicated to a particular result.
* We will call this function when a user interacts with the combobox to redirect them to show their results in the page.
*
* @param {Number} userID The ID of the user selected.
*/

View file

@ -22,7 +22,6 @@
* profileimageurl - Link for the users' large profile image.
* matchingField - The field in the user object that matched the search criteria.
* matchingFieldName - The name of the field that was matched upon for A11y purposes.
* link - The link used to redirect upon self to show only this specific user.
Example context (json):
{
@ -30,12 +29,12 @@
"fullname": "Foo bar",
"profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630",
"matchingField": "<span class=\"font-weight-bold\">Foo</span> bar",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
"matchingFieldName": "Fullname"
}
}}
{{<core/local/comboboxsearch/resultitem }}
{{$arialabel}}{{#str}}viewresults, core, {{fullname}}{{/str}}{{/arialabel}}
{{$arialabel}}{{fullname}}{{/arialabel}}
{{$shorttext}}{{fullname}}{{/shorttext}}
{{$content}}
<span class="d-block px-2 w-25">
{{#profileimageurl}}

View file

@ -17,35 +17,35 @@
Wrapping template for returned result items.
Context variables required for this template:
* instance - The instance ID of the combo box.
* users - Our returned users to render.
* found - Count of the found users.
* total - Total count of users within this report.
* selectall - The created link that allows users to select all of the results.
* selectall - Whether to show the select all option.
* searchterm - The entered text to find these results.
* hasusers - Allow the handling where no users exist for the returned search term.
Example context (json):
{
"instance": 25,
"users": [
{
"id": 2,
"fullname": "Foo bar",
"profileimageurl": "http://foo.bar/pluginfile.php/79/user/icon/boost/f1?rev=7630",
"matchingField": "<span class=\"font-weight-bold\">Foo</span> bar",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=2"
"matchingFieldName": "Fullname"
},
{
"id": 3,
"fullname": "Bar Foo",
"profileimageurl": "http://foo.bar/pluginfile.php/80/user/icon/boost/f1?rev=7631",
"matchingField": "Bar <span class=\"font-weight-bold\">Foo</span>",
"matchingFieldName": "Fullname",
"link": "http://foo.bar/grade/report/grader/index.php?id=42&userid=3"
"matchingFieldName": "Fullname"
}
],
"matches": 20,
"selectall": "https://foo.bar/grade/report/grader/index.php?id=2&searchvalue=abe",
"selectall": true,
"searchterm": "Foo",
"hasresults": true
}
@ -59,10 +59,15 @@
{{/results}}
{{$selectall}}
{{#selectall}}
<li class="w-100 result-row p-1 border-top bottom-0 position-sticky" role="none" id="result-row-{{id}}">
<a role="option" class="dropdown-item d-flex small p-3" id="select-all" href="{{{selectall}}}" tabindex="-1">
{{#str}}viewallresults, core, {{matches}}{{/str}}
</a>
<li
id="result-row-{{instance}}-0"
class="w-100 p-1 border-top bottom-0 position-sticky dropdown-item d-flex small p-3"
role="option"
{{! The data-short-text attribute is provided so that aria.js would use it rather than the whole content. }}
data-short-text="{{searchterm}}"
data-value="0"
>
{{#str}}viewallresults, core, {{matches}}{{/str}}
</li>
{{/selectall}}
{{/selectall}}

View file

@ -20,6 +20,8 @@
The user selector trigger element.
Context variables required for this template:
* name - The name of the input element representing the user search combobox.
* value - The value of the input element representing the user search combobox.
* currentvalue - If the user has already searched, set the value to that.
* courseid - The course ID.
* group - The group ID.
@ -27,41 +29,33 @@
Example context (json):
{
"name": "input-1",
"value": "0",
"currentvalue": "bar",
"courseid": 2,
"group": 25,
"resetlink": "grade/report/grader/index.php?id=2"
}
}}
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}" aria-hidden="true"></span>
<span class="d-none" data-region="groupid" data-groupid="{{group}}" aria-hidden="true"></span>
<span class="d-none" data-region="courseid" data-courseid="{{courseid}}"></span>
<span class="d-none" data-region="groupid" data-groupid="{{group}}"></span>
<span class="d-none" data-region="instance" data-instance="{{instance}}"></span>
{{< core/search_input_auto }}
{{$label}}{{#str}}searchusers, core{{/str}}{{/label}}
{{$placeholder}}{{#str}}searchusers, core{{/str}}{{/placeholder}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
role="combobox"
aria-expanded="false"
aria-controls="user-{{instance}}-result-listbox"
aria-autocomplete="list"
aria-haspopup="listbox"
data-input-element="user-input-{{uniqid}}-{{instance}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{#currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchusers, core
{{/str}}{{/label}}
{{$value}}{{currentvalue}}{{/value}}
{{$additionalattributes}}
aria-autocomplete="list"
data-input-element="user-input-{{uniqid}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
<a class="ml-2 btn btn-link border-0 align-self-center" href="{{resetlink}}" data-action="resetpage" role="link" aria-label="{{#str}}clearsearch, core{{/str}}">
{{#str}}clear{{/str}}
</a>
{{/currentvalue}}
{{^currentvalue}}
{{< core/search_input_auto }}
{{$label}}{{#str}}
searchusers, core
{{/str}}{{/label}}
{{$placeholder}}{{#str}}
searchusers, core
{{/str}}{{/placeholder}}
{{$additionalattributes}}
aria-autocomplete="list"
data-input-element="user-input-{{uniqid}}"
{{/additionalattributes}}
{{/ core/search_input_auto }}
{{/currentvalue}}
<input type="hidden" name="search" id="user-input-{{uniqid}}"/>
<input type="hidden" name="{{name}}" value="{{value}}" id="user-input-{{uniqid}}-{{instance}}"/>