diff --git a/client/css/style.css b/client/css/style.css index 72961c8c..7feb14a0 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1981,12 +1981,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ transition: background-color 0.2s; } +.context-menu-item:focus, +.textcomplete-item:focus, .context-menu-item:hover, .textcomplete-item:hover, .textcomplete-menu .active, #chat .userlist .user.active { background-color: #f6f6f6; transition: none; + outline: 0; /* TODO: Handle focus outlines in PR #1873 */ } .context-menu-item::before, diff --git a/client/index.html.tpl b/client/index.html.tpl index 7ecf4569..9035dc87 100644 --- a/client/index.html.tpl +++ b/client/index.html.tpl @@ -96,10 +96,7 @@ -
- -
- +
diff --git a/client/js/contextMenu.js b/client/js/contextMenu.js index b5008f75..b41dbbdd 100644 --- a/client/js/contextMenu.js +++ b/client/js/contextMenu.js @@ -1,65 +1,134 @@ "use strict"; + const $ = require("jquery"); +const Mousetrap = require("mousetrap"); const templates = require("../views"); -let contextMenu, contextMenuContainer; + +const contextMenuContainer = $("#context-menu-container"); module.exports = class ContextMenu { constructor(contextMenuItems, contextMenuActions, selectedElement, event) { + this.previousActiveElement = document.activeElement; this.contextMenuItems = contextMenuItems; this.contextMenuActions = contextMenuActions; this.selectedElement = selectedElement; this.event = event; - - contextMenuContainer = $("#context-menu-container"); - contextMenu = $("#context-menu"); } show() { - showContextMenu(this.contextMenuItems, this.selectedElement, this.event); - this.bindEvents(); + const contextMenu = showContextMenu(this.contextMenuItems, this.selectedElement, this.event); + this.bindEvents(contextMenu); return false; } - bindEvents() { + hide() { + contextMenuContainer + .hide() + .empty() + .off(".contextMenu"); + + Mousetrap.unbind("escape"); + } + + bindEvents(contextMenu) { const contextMenuActions = this.contextMenuActions; contextMenuActions.execute = (id, ...args) => contextMenuActions[id] && contextMenuActions[id](...args); - contextMenu.find(".context-menu-item").on("click", function() { - const $this = $(this); - const itemData = $this.attr("data-data"); - const contextAction = $this.attr("data-action"); + const clickItem = (item) => { + const itemData = item.attr("data-data"); + const contextAction = item.attr("data-action"); + + this.hide(); + 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) { const target = $(event.currentTarget); - let output = ""; + const contextMenu = $("