This commit is contained in:
Huong Nguyen
2026-03-03 08:35:55 +07:00
11 changed files with 35 additions and 28 deletions
@@ -0,0 +1,8 @@
issueNumber: MDL-87993
notes:
core:
- message: >-
The `core/toast` JS module now accepts a `visuallyHidden` configuration
parameter to render visually hidden toast messages for screen reader
users.
type: improved
+1 -1
View File
@@ -1,3 +1,3 @@
define("core/local/reactive/srlogger",["exports","core/local/reactive/logger"],(function(_exports,_logger){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_logger=(obj=_logger)&&obj.__esModule?obj:{default:obj};class SRLogger extends _logger.default{add(entry){if(entry.feedbackMessage){let loggerFeedback=document.getElementById(SRLogger.liveRegionId);loggerFeedback||(loggerFeedback=document.createElement("div"),loggerFeedback.id=SRLogger.liveRegionId,loggerFeedback.classList.add("visually-hidden"),loggerFeedback.setAttribute("aria-live","polite"),document.body.append(loggerFeedback)),loggerFeedback.innerHTML=entry.feedbackMessage,setTimeout((()=>{loggerFeedback.innerHTML=""}),4e3)}}}return _exports.default=SRLogger,function(obj,key,value){key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}(SRLogger,"liveRegionId","sr-logger-feedback-container"),_exports.default}));
define("core/local/reactive/srlogger",["exports","core/toast","core/local/reactive/logger"],(function(_exports,_toast,_logger){var obj;Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_logger=(obj=_logger)&&obj.__esModule?obj:{default:obj};class SRLogger extends _logger.default{add(entry){entry.feedbackMessage&&(0,_toast.add)(entry.feedbackMessage,{visuallyHidden:!0})}}return _exports.default=SRLogger,function(obj,key,value){key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}(SRLogger,"liveRegionId","sr-logger-feedback-container"),_exports.default}));
//# sourceMappingURL=srlogger.min.js.map
@@ -1 +1 @@
{"version":3,"file":"srlogger.min.js","sources":["../../../src/local/reactive/srlogger.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Screen reader-only (visually-hidden) reactive mutations logger class.\n *\n * This logger can be used by the StateManager to log mutation feedbacks and actions.\n * The feedback messages logged by this logger will be rendered in a visually-hidden, ARIA live region.\n *\n * @module core/local/reactive/srlogger\n * @class SRLogger\n * @copyright 2023 Jun Pataleta <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Logger from 'core/local/reactive/logger';\n\n/**\n * Logger entry structure.\n *\n * @typedef {object} LoggerEntry\n * @property {string} feedbackMessage Feedback message.\n */\n\n/**\n * Screen reader-only (visually-hidden) reactive mutations logger class.\n *\n * @class SRLogger\n */\nexport default class SRLogger extends Logger {\n /**\n * The element ID of the ARIA live region where the logger feedback will be rendered.\n *\n * @type {string}\n */\n static liveRegionId = 'sr-logger-feedback-container';\n\n /**\n * Add a log entry.\n * @param {LoggerEntry} entry Log entry.\n */\n add(entry) {\n if (entry.feedbackMessage) {\n // Fetch or create an ARIA live region that will serve as the container for the logger feedback.\n let loggerFeedback = document.getElementById(SRLogger.liveRegionId);\n if (!loggerFeedback) {\n loggerFeedback = document.createElement('div');\n loggerFeedback.id = SRLogger.liveRegionId;\n loggerFeedback.classList.add('visually-hidden');\n loggerFeedback.setAttribute('aria-live', 'polite');\n document.body.append(loggerFeedback);\n }\n // Set the ARIA live region's contents with the feedback.\n loggerFeedback.innerHTML = entry.feedbackMessage;\n\n // Clear the feedback message after 4 seconds to avoid the contents from being read out in case the user navigates\n // to this region. This is similar to the default timeout of toast messages before disappearing from view.\n setTimeout(() => {\n loggerFeedback.innerHTML = '';\n }, 4000);\n }\n }\n}\n"],"names":["SRLogger","Logger","add","entry","feedbackMessage","loggerFeedback","document","getElementById","liveRegionId","createElement","id","classList","setAttribute","body","append","innerHTML","setTimeout"],"mappings":"iQAyCqBA,iBAAiBC,gBAYlCC,IAAIC,UACIA,MAAMC,gBAAiB,KAEnBC,eAAiBC,SAASC,eAAeP,SAASQ,cACjDH,iBACDA,eAAiBC,SAASG,cAAc,OACxCJ,eAAeK,GAAKV,SAASQ,aAC7BH,eAAeM,UAAUT,IAAI,mBAC7BG,eAAeO,aAAa,YAAa,UACzCN,SAASO,KAAKC,OAAOT,iBAGzBA,eAAeU,UAAYZ,MAAMC,gBAIjCY,YAAW,KACPX,eAAeU,UAAY,KAC5B,kLA9BMf,wBAMK"}
{"version":3,"file":"srlogger.min.js","sources":["../../../src/local/reactive/srlogger.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Screen reader-only (visually-hidden) reactive mutations logger class.\n *\n * This logger can be used by the StateManager to log mutation feedbacks and actions.\n * The feedback messages logged by this logger will be rendered in a visually-hidden, ARIA live region.\n *\n * @module core/local/reactive/srlogger\n * @class SRLogger\n * @copyright 2023 Jun Pataleta <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {add as addToast} from 'core/toast';\nimport Logger from 'core/local/reactive/logger';\n\n/**\n * Logger entry structure.\n *\n * @typedef {object} LoggerEntry\n * @property {string} feedbackMessage Feedback message.\n */\n\n/**\n * Screen reader-only (visually-hidden) reactive mutations logger class.\n *\n * @class SRLogger\n */\nexport default class SRLogger extends Logger {\n /**\n * The element ID of the ARIA live region where the logger feedback will be rendered.\n *\n * @type {string}\n */\n static liveRegionId = 'sr-logger-feedback-container';\n\n /**\n * Add a log entry.\n * @param {LoggerEntry} entry Log entry.\n */\n add(entry) {\n if (entry.feedbackMessage) {\n addToast(entry.feedbackMessage, {visuallyHidden: true});\n }\n }\n}\n"],"names":["SRLogger","Logger","add","entry","feedbackMessage","visuallyHidden"],"mappings":"qRA0CqBA,iBAAiBC,gBAYlCC,IAAIC,OACIA,MAAMC,gCACGD,MAAMC,gBAAiB,CAACC,gBAAgB,gLAdxCL,wBAMK"}
+1 -1
View File
@@ -5,6 +5,6 @@ define("core/toast",["exports","core/templates","core/notification","core/pendin
* @module core/toast
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.removeToastRegion=_exports.addToastRegion=_exports.add=void 0,_templates=_interopRequireDefault(_templates),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);const addToastRegion=async parent=>{const pendingPromise=new _pending.default("addToastRegion");try{const{html:html,js:js}=await _templates.default.renderForPromise("core/local/toast/wrapper",{});_templates.default.prependNodeContents(parent,html,js)}catch(e){_notification.default.exception(e)}pendingPromise.resolve()};_exports.addToastRegion=addToastRegion;_exports.add=async(message,configuration)=>{const pendingPromise=new _pending.default("addToastRegion");configuration={type:"info",closeButton:!1,autohide:!0,delay:4e3,...configuration};try{const{html:html,js:js}=await _templates.default.renderForPromise("core/local/toast/message",{message:await message,...configuration}),targetNode=await getTargetNode();_templates.default.prependNodeContents(targetNode,html,js)}catch(e){_notification.default.exception(e)}pendingPromise.resolve()};const getTargetNode=async()=>{const regions=document.querySelectorAll(".toast-wrapper");return regions.length?regions[regions.length-1]:(await addToastRegion(document.body),getTargetNode())};_exports.removeToastRegion=async function(parent){let newParent=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document;const pendingPromise=new _pending.default("core/toast:removeToastRegion"),getRegionFromParent=thisParent=>thisParent.querySelector(".toast-wrapper"),regionToRemove=getRegionFromParent(parent);if(regionToRemove){const targetRegion=getRegionFromParent(newParent);regionToRemove.children.forEach((node=>{targetRegion.insertBefore(node,targetRegion.firstChild)})),regionToRemove.remove()}pendingPromise.resolve()}}));
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.removeToastRegion=_exports.addToastRegion=_exports.add=void 0,_templates=_interopRequireDefault(_templates),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);const addToastRegion=async parent=>{const pendingPromise=new _pending.default("addToastRegion");try{const{html:html,js:js}=await _templates.default.renderForPromise("core/local/toast/wrapper",{});_templates.default.prependNodeContents(parent,html,js)}catch(e){_notification.default.exception(e)}pendingPromise.resolve()};_exports.addToastRegion=addToastRegion;_exports.add=async(message,configuration)=>{const pendingPromise=new _pending.default("addToastRegion");(configuration={type:"info",closeButton:!1,autohide:!0,delay:4e3,visuallyHidden:!1,...configuration}).visuallyHidden&&(configuration.autohide=!0,configuration.closeButton=!1);try{const{html:html,js:js}=await _templates.default.renderForPromise("core/local/toast/message",{message:await message,...configuration}),targetNode=await getTargetNode();_templates.default.prependNodeContents(targetNode,html,js)}catch(e){_notification.default.exception(e)}pendingPromise.resolve()};const getTargetNode=async()=>{const regions=document.querySelectorAll(".toast-wrapper");return regions.length?regions[regions.length-1]:(await addToastRegion(document.body),getTargetNode())};_exports.removeToastRegion=async function(parent){let newParent=arguments.length>1&&void 0!==arguments[1]?arguments[1]:document;const pendingPromise=new _pending.default("core/toast:removeToastRegion"),getRegionFromParent=thisParent=>thisParent.querySelector(".toast-wrapper"),regionToRemove=getRegionFromParent(parent);if(regionToRemove){const targetRegion=getRegionFromParent(newParent);regionToRemove.children.forEach((node=>{targetRegion.insertBefore(node,targetRegion.firstChild)})),regionToRemove.remove()}pendingPromise.resolve()}}));
//# sourceMappingURL=toast.min.js.map
File diff suppressed because one or more lines are too long
+2 -17
View File
@@ -25,6 +25,7 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {add as addToast} from 'core/toast';
import Logger from 'core/local/reactive/logger';
/**
@@ -53,23 +54,7 @@ export default class SRLogger extends Logger {
*/
add(entry) {
if (entry.feedbackMessage) {
// Fetch or create an ARIA live region that will serve as the container for the logger feedback.
let loggerFeedback = document.getElementById(SRLogger.liveRegionId);
if (!loggerFeedback) {
loggerFeedback = document.createElement('div');
loggerFeedback.id = SRLogger.liveRegionId;
loggerFeedback.classList.add('visually-hidden');
loggerFeedback.setAttribute('aria-live', 'polite');
document.body.append(loggerFeedback);
}
// Set the ARIA live region's contents with the feedback.
loggerFeedback.innerHTML = entry.feedbackMessage;
// Clear the feedback message after 4 seconds to avoid the contents from being read out in case the user navigates
// to this region. This is similar to the default timeout of toast messages before disappearing from view.
setTimeout(() => {
loggerFeedback.innerHTML = '';
}, 4000);
addToast(entry.feedbackMessage, {visuallyHidden: true});
}
}
}
+14
View File
@@ -57,6 +57,8 @@ export const addToastRegion = async(parent) => {
* @param {Boolean} [configuration.autohide=true]
* @param {Boolean} [configuration.closeButton=false]
* @param {Number} [configuration.delay=4000]
* @param {Boolean} [configuration.visuallyHidden=false] Whether the toast should be visually hidden. This is intended for cases
* where we want to announce ARIA live updates for screen reader users only.
*
* @example
* import {add as addToast} from 'core/toast';
@@ -73,6 +75,10 @@ export const addToastRegion = async(parent) => {
* autohide: false,
* closeButton: true,
* });
*
* addToast('A string to be rendered visually hidden', {
* visuallyHidden: true,
* });
*/
export const add = async(message, configuration) => {
const pendingPromise = new Pending('addToastRegion');
@@ -81,15 +87,23 @@ export const add = async(message, configuration) => {
closeButton: false,
autohide: true,
delay: 4000,
visuallyHidden: false,
...configuration,
};
// Ensure that visually hidden toasts are always autohidden and do not have a close button that can receive keyboard focus.
if (configuration.visuallyHidden) {
configuration.autohide = true;
configuration.closeButton = false;
}
const templateName = `core/local/toast/message`;
try {
const {html, js} = await Templates.renderForPromise(templateName, {
message: await message,
...configuration
});
const targetNode = await getTargetNode();
Templates.prependNodeContents(targetNode, html, js);
} catch (e) {
@@ -36,7 +36,7 @@
"closebutton": true
}
}}
<div id="toast-{{uniqid}}" class="toast mx-auto {{#type}}toast-{{type}}{{/type}}" data-bs-delay="{{delay}}" {{^autohide}}data-bs-autohide="false"{{/autohide}}>
<div id="toast-{{uniqid}}" class="toast mx-auto {{#type}}toast-{{type}}{{/type}} {{#visuallyHidden}}visually-hidden{{/visuallyHidden}}" data-bs-delay="{{delay}}" {{^autohide}}data-bs-autohide="false"{{/autohide}}>
{{#title}}
<div class="toast-header">
<span class="toast-title">{{{title}}}</span>
+2 -2
View File
@@ -1,10 +1,10 @@
define("mod_forum/forum_overview_toggle",["exports","core/notification","core/str","core/local/reactive/srlogger","mod_forum/repository"],(function(_exports,_notification,_str,_srlogger,_repository){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
define("mod_forum/forum_overview_toggle",["exports","core/notification","core/str","core/toast","mod_forum/repository"],(function(_exports,_notification,_str,_toast,_repository){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}
/**
* Handle forum subscription/tracking toggling.
*
* @module mod_forum/forum_overview_toggle
* @copyright 2025 Sara Arjona <sara@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/function registerEventListeners(toggleElement){toggleElement.addEventListener("change",(()=>{"forum-subscription-toggle"===toggleElement.dataset.type&&async function(toggleElement){const forumId=toggleElement.dataset.forumid,forumName=toggleElement.dataset.forumname,newState=toggleElement.dataset.targetstate;if(!forumId||!newState)return;try{const newTargetState=!!(await _repository.default.setForumSubscriptionState(forumId,newState)).userstate.subscribed;await updateSwitchState(toggleElement,newTargetState,newTargetState?"subscribe":"unsubscribe");const feedbackMessage=await(0,_str.getString)(newTargetState?"subscribedtoforum":"unsubscribedfromforum","mod_forum",forumName);(new _srlogger.default).add({feedbackMessage:feedbackMessage})}catch(error){_notification.default.exception(error)}}(toggleElement),"forum-track-toggle"===toggleElement.dataset.type&&async function(toggleElement){const forumId=toggleElement.dataset.forumid,forumName=toggleElement.dataset.forumname,newState=toggleElement.dataset.targetstate;if(!forumId||!newState)return;try{const newTargetState=!!(await _repository.default.setForumTrackingState(forumId,newState)).userstate.tracked;await updateSwitchState(toggleElement,newTargetState,newTargetState?"trackingon":"trackingoff");const feedbackMessage=await(0,_str.getString)(newTargetState?"trackedforforum":"untrackedforforum","mod_forum",forumName);(new _srlogger.default).add({feedbackMessage:feedbackMessage})}catch(error){_notification.default.exception(error)}}(toggleElement)}))}async function updateSwitchState(toggleElement,newTargetState,stringKey){toggleElement.dataset.targetstate=newTargetState?0:1;const string=await(0,_str.getString)(stringKey,"mod_forum");toggleElement.closest("td").querySelector('label[for="'.concat(toggleElement.id,'"] span')).textContent=string}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_notification=_interopRequireDefault(_notification),_srlogger=_interopRequireDefault(_srlogger),_repository=_interopRequireDefault(_repository);_exports.init=toggleSelector=>{const toggleElement=document.querySelector(toggleSelector);toggleElement&&registerEventListeners(toggleElement)}}));
*/function registerEventListeners(toggleElement){toggleElement.addEventListener("change",(()=>{"forum-subscription-toggle"===toggleElement.dataset.type&&async function(toggleElement){const forumId=toggleElement.dataset.forumid,forumName=toggleElement.dataset.forumname,newState=toggleElement.dataset.targetstate;if(!forumId||!newState)return;try{const newTargetState=!!(await _repository.default.setForumSubscriptionState(forumId,newState)).userstate.subscribed;await updateSwitchState(toggleElement,newTargetState,newTargetState?"subscribe":"unsubscribe");const feedbackMessage=await(0,_str.getString)(newTargetState?"subscribedtoforum":"unsubscribedfromforum","mod_forum",forumName);(0,_toast.add)(feedbackMessage,{visuallyHidden:!0})}catch(error){_notification.default.exception(error)}}(toggleElement),"forum-track-toggle"===toggleElement.dataset.type&&async function(toggleElement){const forumId=toggleElement.dataset.forumid,forumName=toggleElement.dataset.forumname,newState=toggleElement.dataset.targetstate;if(!forumId||!newState)return;try{const newTargetState=!!(await _repository.default.setForumTrackingState(forumId,newState)).userstate.tracked;await updateSwitchState(toggleElement,newTargetState,newTargetState?"trackingon":"trackingoff");const feedbackMessage=await(0,_str.getString)(newTargetState?"trackedforforum":"untrackedforforum","mod_forum",forumName);(0,_toast.add)(feedbackMessage,{visuallyHidden:!0})}catch(error){_notification.default.exception(error)}}(toggleElement)}))}async function updateSwitchState(toggleElement,newTargetState,stringKey){toggleElement.dataset.targetstate=newTargetState?0:1;const string=await(0,_str.getString)(stringKey,"mod_forum");toggleElement.closest("td").querySelector('label[for="'.concat(toggleElement.id,'"] span')).textContent=string}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_notification=_interopRequireDefault(_notification),_repository=_interopRequireDefault(_repository);_exports.init=toggleSelector=>{const toggleElement=document.querySelector(toggleSelector);toggleElement&&registerEventListeners(toggleElement)}}));
//# sourceMappingURL=forum_overview_toggle.min.js.map
File diff suppressed because one or more lines are too long
@@ -23,7 +23,7 @@
import Notification from 'core/notification';
import {getString} from 'core/str';
import SRLogger from 'core/local/reactive/srlogger';
import {add as addToast} from 'core/toast';
import Repository from 'mod_forum/repository';
/**
@@ -69,7 +69,7 @@ async function subscriptionToggleClickHandler(toggleElement) {
'mod_forum',
forumName,
);
new SRLogger().add({feedbackMessage});
addToast(feedbackMessage, {visuallyHidden: true});
} catch (error) {
Notification.exception(error);
}
@@ -103,7 +103,7 @@ async function trackToggleClickHanldler(toggleElement) {
'mod_forum',
forumName,
);
new SRLogger().add({feedbackMessage});
addToast(feedbackMessage, {visuallyHidden: true});
} catch (error) {
Notification.exception(error);
}