MDL-65373 forum: accessibility changes for discussion
[moodle.git] / mod / forum / amd / src / discussion.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * Module for viewing a discussion.
18  *
19  * @module     mod_forum/discussion_list
20  * @package    mod_forum
21  * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 define(
25 [
26     'jquery',
27     'core/custom_interaction_events',
28     'mod_forum/selectors'
29 ],
30 function(
31     $,
32     CustomEvents,
33     Selectors
34 ) {
36     /**
37      * Set the focus on the previous post in the list. Previous post is calculated
38      * based on position in list as viewed top to bottom.
39      *
40      * @param {Object} currentPost The post that currently has focus
41      */
42     var focusPreviousPost = function(currentPost) {
43         // See if there is a previous sibling post.
44         var prevPost = currentPost.prev(Selectors.post.post);
46         if (prevPost.length) {
47             // The previous post might have replies that appear visually between
48             // it and the current post (see nested view) so if that's the case
49             // then the last reply will be the previous post in the list.
50             var replyPost = prevPost.find(Selectors.post.post).last();
52             if (replyPost.length) {
53                 // Focus the last reply.
54                 replyPost.focus();
55             } else {
56                 // No replies so we can focus straight on the sibling.
57                 prevPost.focus();
58             }
59         } else {
60             // If there are no siblings then jump up the tree to the parent
61             // post and focus the first parent post we find.
62             currentPost.parents(Selectors.post.post).first().focus();
63         }
64     };
66     /**
67      * Set the focus on the next post in the list. Previous post is calculated
68      * based on position in list as viewed top to bottom.
69      *
70      * @param {Object} currentPost The post that currently has focus
71      */
72     var focusNextPost = function(currentPost) {
73         // The next post in the visual list would be the first reply to this one
74         // so let's see if we have one.
75         var replyPost = currentPost.find(Selectors.post.post).first();
77         if (replyPost.length) {
78             // Got a reply.
79             replyPost.focus();
80         } else {
81             // If we don't have a reply then the next post in the visual list would
82             // be a sibling post (replying to the same parent).
83             var siblingPost = currentPost.next(Selectors.post.post);
85             if (siblingPost.length) {
86                 siblingPost.focus();
87             } else {
88                 // No siblings either. That means we're the lowest level reply in a thread
89                 // so we need to walk back up the tree of posts and find an ancestor post that
90                 // has a sibling post we can focus.
91                 currentPost.parents().toArray().forEach(function(parent) {
92                     var ancestorSiblingPost = $(parent).next(Selectors.post.post);
94                     if (ancestorSiblingPost.length) {
95                         ancestorSiblingPost.focus();
96                         return;
97                     }
98                 });
99             }
100         }
101     };
103     /**
104      * Check if the element is inside the in page reply section.
105      *
106      * @param {Object} element The element to check
107      * @return {Boolean}
108      */
109     var isElementInInPageReplySection = function(element) {
110         var inPageReply = $(element).closest(Selectors.post.inpageReplyContent);
111         return inPageReply.length ? true : false;
112     };
114     /**
115      * Initialise the keyboard accessibility controls for the discussion.
116      *
117      * @param {Object} root The discussion root element
118      */
119     var initAccessibilityKeyboardNav = function(root) {
120         var posts = root.find(Selectors.post.post);
122         // Take each post action out of the tab index.
123         posts.each(function(index, post) {
124             var actions = $(post).find(Selectors.post.action);
125             var firstAction = actions.first();
126             actions.attr('tabindex', '-1');
127             firstAction.attr('tabindex', 0);
128         });
130         CustomEvents.define(root, [
131             CustomEvents.events.up,
132             CustomEvents.events.down,
133             CustomEvents.events.next,
134             CustomEvents.events.previous,
135             CustomEvents.events.home,
136             CustomEvents.events.end,
137         ]);
139         root.on(CustomEvents.events.up, function(e, data) {
140             var activeElement = document.activeElement;
142             if (isElementInInPageReplySection(activeElement)) {
143                 // Focus is currently inside the in page reply section so don't move focus
144                 // to another post.
145                 return;
146             }
148             var focusPost = $(activeElement).closest(Selectors.post.post);
150             if (focusPost.length) {
151                 focusPreviousPost(focusPost);
152             } else {
153                 root.find(Selectors.post.post).first().focus();
154             }
156             data.originalEvent.preventDefault();
157         });
159         root.on(CustomEvents.events.down, function(e, data) {
160             var activeElement = document.activeElement;
162             if (isElementInInPageReplySection(activeElement)) {
163                 // Focus is currently inside the in page reply section so don't move focus
164                 // to another post.
165                 return;
166             }
168             var focusPost = $(activeElement).closest(Selectors.post.post);
170             if (focusPost.length) {
171                 focusNextPost(focusPost);
172             } else {
173                 root.find(Selectors.post.post).first().focus();
174             }
176             data.originalEvent.preventDefault();
177         });
179         root.on(CustomEvents.events.home, function(e, data) {
180             if (isElementInInPageReplySection(document.activeElement)) {
181                 // Focus is currently inside the in page reply section so don't move focus
182                 // to another post.
183                 return;
184             }
185             root.find(Selectors.post.post).first().focus();
186             data.originalEvent.preventDefault();
187         });
189         root.on(CustomEvents.events.end, function(e, data) {
190             if (isElementInInPageReplySection(document.activeElement)) {
191                 // Focus is currently inside the in page reply section so don't move focus
192                 // to another post.
193                 return;
194             }
195             root.find(Selectors.post.post).last().focus();
196             data.originalEvent.preventDefault();
197         });
199         root.on(CustomEvents.events.next, Selectors.post.action, function(e, data) {
200             var currentAction = $(e.target);
201             var container = currentAction.closest(Selectors.post.actionsContainer);
202             var actions = container.find(Selectors.post.action);
203             var nextAction = currentAction.next(Selectors.post.action);
205             actions.attr('tabindex', '-1');
207             if (!nextAction.length) {
208                 nextAction = actions.first();
209             }
211             nextAction.attr('tabindex', 0);
212             nextAction.focus();
214             data.originalEvent.preventDefault();
215         });
217         root.on(CustomEvents.events.previous, Selectors.post.action, function(e, data) {
218             var currentAction = $(e.target);
219             var container = currentAction.closest(Selectors.post.actionsContainer);
220             var actions = container.find(Selectors.post.action);
221             var nextAction = currentAction.prev(Selectors.post.action);
223             actions.attr('tabindex', '-1');
225             if (!nextAction.length) {
226                 nextAction = actions.last();
227             }
229             nextAction.attr('tabindex', 0);
230             nextAction.focus();
232             data.originalEvent.preventDefault();
233         });
235         root.on(CustomEvents.events.home, Selectors.post.action, function(e, data) {
236             var currentAction = $(e.target);
237             var container = currentAction.closest(Selectors.post.actionsContainer);
238             var actions = container.find(Selectors.post.action);
239             var firstAction = actions.first();
241             actions.attr('tabindex', '-1');
242             firstAction.attr('tabindex', 0);
243             firstAction.focus();
245             e.stopPropagation();
246             data.originalEvent.preventDefault();
247         });
249         root.on(CustomEvents.events.end, Selectors.post.action, function(e, data) {
250             var currentAction = $(e.target);
251             var container = currentAction.closest(Selectors.post.actionsContainer);
252             var actions = container.find(Selectors.post.action);
253             var lastAction = actions.last();
255             actions.attr('tabindex', '-1');
256             lastAction.attr('tabindex', 0);
257             lastAction.focus();
259             e.stopPropagation();
260             data.originalEvent.preventDefault();
261         });
262     };
264     return {
265         init: function(root) {
266             initAccessibilityKeyboardNav(root);
267         }
268     };
269 });