Make context menus accessible with keyboard

This commit is contained in:
Pavel Djundik 2018-04-22 13:59:01 +03:00
parent a81cef397c
commit d178ac9749
7 changed files with 101 additions and 41 deletions

View file

@ -1981,12 +1981,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.context-menu-item:focus,
.textcomplete-item:focus,
.context-menu-item:hover, .context-menu-item:hover,
.textcomplete-item:hover, .textcomplete-item:hover,
.textcomplete-menu .active, .textcomplete-menu .active,
#chat .userlist .user.active { #chat .userlist .user.active {
background-color: #f6f6f6; background-color: #f6f6f6;
transition: none; transition: none;
outline: 0; /* TODO: Handle focus outlines in PR #1873 */
} }
.context-menu-item::before, .context-menu-item::before,

View file

@ -96,10 +96,7 @@
</article> </article>
</div> </div>
<div id="context-menu-container"> <div id="context-menu-container"></div>
<ul id="context-menu"></ul>
</div>
<div id="image-viewer"></div> <div id="image-viewer"></div>
<script src="js/bundle.vendor.js"></script> <script src="js/bundle.vendor.js"></script>

View file

@ -1,65 +1,134 @@
"use strict"; "use strict";
const $ = require("jquery"); const $ = require("jquery");
const Mousetrap = require("mousetrap");
const templates = require("../views"); const templates = require("../views");
let contextMenu, contextMenuContainer;
const contextMenuContainer = $("#context-menu-container");
module.exports = class ContextMenu { module.exports = class ContextMenu {
constructor(contextMenuItems, contextMenuActions, selectedElement, event) { constructor(contextMenuItems, contextMenuActions, selectedElement, event) {
this.previousActiveElement = document.activeElement;
this.contextMenuItems = contextMenuItems; this.contextMenuItems = contextMenuItems;
this.contextMenuActions = contextMenuActions; this.contextMenuActions = contextMenuActions;
this.selectedElement = selectedElement; this.selectedElement = selectedElement;
this.event = event; this.event = event;
contextMenuContainer = $("#context-menu-container");
contextMenu = $("#context-menu");
} }
show() { show() {
showContextMenu(this.contextMenuItems, this.selectedElement, this.event); const contextMenu = showContextMenu(this.contextMenuItems, this.selectedElement, this.event);
this.bindEvents(); this.bindEvents(contextMenu);
return false; return false;
} }
bindEvents() { hide() {
contextMenuContainer
.hide()
.empty()
.off(".contextMenu");
Mousetrap.unbind("escape");
}
bindEvents(contextMenu) {
const contextMenuActions = this.contextMenuActions; const contextMenuActions = this.contextMenuActions;
contextMenuActions.execute = (id, ...args) => contextMenuActions[id] && contextMenuActions[id](...args); contextMenuActions.execute = (id, ...args) => contextMenuActions[id] && contextMenuActions[id](...args);
contextMenu.find(".context-menu-item").on("click", function() { const clickItem = (item) => {
const $this = $(this); const itemData = item.attr("data-data");
const itemData = $this.attr("data-data"); const contextAction = item.attr("data-action");
const contextAction = $this.attr("data-action");
this.hide();
contextMenuActions.execute(contextAction, itemData); contextMenuActions.execute(contextAction, itemData);
};
contextMenu.on("click", ".context-menu-item", function() {
clickItem($(this));
});
const trap = Mousetrap(contextMenu.get(0));
trap.bind(["up", "down"], (e, key) => {
const items = contextMenu.find(".context-menu-item");
let index = items.toArray().findIndex((item) => $(item).is(":focus"));
if (key === "down") {
index = (index + 1) % items.length;
} else {
index = Math.max(index, 0) - 1;
}
items.eq(index).trigger("focus");
});
trap.bind("enter", () => {
const item = contextMenu.find(".context-menu-item:focus");
if (item.length) {
clickItem(item);
}
return false;
});
// Hide context menu when clicking or right clicking outside of it
contextMenuContainer.on("click.contextMenu contextmenu.contextMenu", (e) => {
// Do not close the menu when clicking inside of the context menu (e.g. on a divider)
if ($(e.target).prop("id") === "context-menu") {
return;
}
this.hide();
return false;
});
// Hide the context menu when pressing escape within the context menu container
Mousetrap.bind("escape", () => {
this.hide();
// Return focus to the previously focused element
$(this.previousActiveElement).trigger("focus");
return false;
}); });
} }
}; };
function showContextMenu(contextMenuItems, selectedElement, event) { function showContextMenu(contextMenuItems, selectedElement, event) {
const target = $(event.currentTarget); const target = $(event.currentTarget);
let output = ""; const contextMenu = $("<ul>", {id: "context-menu"});
for (const item of contextMenuItems) { for (const item of contextMenuItems) {
if (item.check(target)) { if (item.check(target)) {
if (item.divider) { if (item.divider) {
output += templates.contextmenu_divider(); contextMenu.append(templates.contextmenu_divider());
} else { } else {
output += templates.contextmenu_item({ contextMenu.append(templates.contextmenu_item({
class: typeof item.className === "function" ? item.className(target) : item.className, class: typeof item.className === "function" ? item.className(target) : item.className,
action: item.actionId, action: item.actionId,
text: typeof item.displayName === "function" ? item.displayName(target) : item.displayName, text: typeof item.displayName === "function" ? item.displayName(target) : item.displayName,
data: typeof item.data === "function" ? item.data(target) : item.data, data: typeof item.data === "function" ? item.data(target) : item.data,
}); }));
} }
} }
} }
contextMenuContainer.show(); contextMenuContainer
.html(contextMenu)
.show();
contextMenu contextMenu
.html(output) .css(positionContextMenu(contextMenu, selectedElement, event))
.css(positionContextMenu(selectedElement, event)); .find(".context-menu-item:first-child")
.trigger("focus");
return contextMenu;
} }
function positionContextMenu(selectedElement, e) { function positionContextMenu(contextMenu, selectedElement, e) {
let offset; let offset;
const menuWidth = contextMenu.outerWidth(); const menuWidth = contextMenu.outerWidth();
const menuHeight = contextMenu.outerHeight(); const menuHeight = contextMenu.outerHeight();

View file

@ -8,7 +8,6 @@ const form = $("#form");
const input = $("#input"); const input = $("#input");
const sidebar = $("#sidebar"); const sidebar = $("#sidebar");
const windows = $("#windows"); const windows = $("#windows");
const contextMenuContainer = $("#context-menu-container");
Mousetrap.bind([ Mousetrap.bind([
"pageup", "pageup",
@ -97,12 +96,6 @@ Mousetrap.bind([
return false; return false;
}); });
Mousetrap.bind([
"escape",
], function() {
contextMenuContainer.hide();
});
const inputTrap = Mousetrap(input.get(0)); const inputTrap = Mousetrap(input.get(0));
function enableHistory() { function enableHistory() {

View file

@ -18,7 +18,6 @@ require("./webpush");
require("./keybinds"); require("./keybinds");
require("./clipboard"); require("./clipboard");
const contextMenuFactory = require("./contextMenuFactory"); const contextMenuFactory = require("./contextMenuFactory");
const contextMenuContainer = $("#context-menu-container");
$(function() { $(function() {
const sidebar = $("#sidebar, #footer"); const sidebar = $("#sidebar, #footer");
@ -80,11 +79,6 @@ $(function() {
return contextMenuFactory.createContextMenu($(this), e).show(); return contextMenuFactory.createContextMenu($(this), e).show();
}); });
contextMenuContainer.on("click contextmenu", function() {
contextMenuContainer.hide();
return false;
});
function resetInputHeight(input) { function resetInputHeight(input) {
input.style.height = input.style.minHeight; input.style.height = input.style.minHeight;
} }

View file

@ -57,9 +57,9 @@ chat.on("mouseleave", ".userlist .user", function() {
}); });
exports.handleKeybinds = function(input) { exports.handleKeybinds = function(input) {
Mousetrap(input.get(0)).bind(["up", "down"], (e, key) => { const trap = Mousetrap(input.get(0));
e.preventDefault();
trap.bind(["up", "down"], (e, key) => {
const userlists = input.closest(".userlist"); const userlists = input.closest(".userlist");
let userlist; let userlist;
@ -73,7 +73,7 @@ exports.handleKeybinds = function(input) {
const users = userlist.find(".user"); const users = userlist.find(".user");
if (users.length === 0) { if (users.length === 0) {
return; return false;
} }
// Find which item in the array of users is currently selected, if any. // Find which item in the array of users is currently selected, if any.
@ -95,11 +95,13 @@ exports.handleKeybinds = function(input) {
// Adjust scroll when active item is outside of the visible area // Adjust scroll when active item is outside of the visible area
utils.scrollIntoViewNicely(userlist.find(".user.active")[0]); utils.scrollIntoViewNicely(userlist.find(".user.active")[0]);
return false;
}); });
// When pressing Enter, open the context menu (emit a click) on the active // When pressing Enter, open the context menu (emit a click) on the active
// user // user
Mousetrap(input.get(0)).bind("enter", () => { trap.bind("enter", () => {
const user = input.closest(".userlist").find(".user.active"); const user = input.closest(".userlist").find(".user.active");
if (user.length) { if (user.length) {
@ -109,5 +111,7 @@ exports.handleKeybinds = function(input) {
clickEvent.pageY = userOffset.top + user.height(); clickEvent.pageY = userOffset.top + user.height();
user.trigger(clickEvent); user.trigger(clickEvent);
} }
return false;
}); });
}; };

View file

@ -1,3 +1,3 @@
<li class="context-menu-item context-menu-{{class}}" data-action="{{action}}"{{#if data}} data-data="{{data}}"{{/if}}> <li class="context-menu-item context-menu-{{class}}" data-action="{{action}}"{{#if data}} data-data="{{data}}"{{/if}} tabindex="0">
{{text}} {{text}}
</li> </li>