this.closeSidebar(),
+ },
+ ];
}
diff --git a/ui/app/components/gutter-menu.js b/ui/app/components/gutter-menu.js
index e73f58da351d..c84a4d0e4667 100644
--- a/ui/app/components/gutter-menu.js
+++ b/ui/app/components/gutter-menu.js
@@ -7,6 +7,7 @@ import classic from 'ember-classic-decorator';
export default class GutterMenu extends Component {
@service system;
@service router;
+ @service keyboard;
@computed('system.namespaces.@each.name')
get sortedNamespaces() {
@@ -37,6 +38,11 @@ export default class GutterMenu extends Component {
onHamburgerClick() {}
+ // Seemingly redundant, but serves to ensure the action is passed to the keyboard service correctly
+ transitionTo(destination) {
+ return this.router.transitionTo(destination);
+ }
+
gotoJobsForNamespace(namespace) {
if (!namespace || !namespace.get('id')) return;
diff --git a/ui/app/components/job-subnav.js b/ui/app/components/job-subnav.js
index 4d80322d86fb..3560dab8395c 100644
--- a/ui/app/components/job-subnav.js
+++ b/ui/app/components/job-subnav.js
@@ -3,6 +3,7 @@ import Component from '@glimmer/component';
export default class JobSubnav extends Component {
@service can;
+ @service keyboard;
get shouldRenderClientsTab() {
const { job } = this.args;
diff --git a/ui/app/components/keyboard-shortcuts-modal.hbs b/ui/app/components/keyboard-shortcuts-modal.hbs
new file mode 100644
index 000000000000..47c233d04ba3
--- /dev/null
+++ b/ui/app/components/keyboard-shortcuts-modal.hbs
@@ -0,0 +1,70 @@
+{{#if this.keyboard.shortcutsVisible}}
+ {{keyboard-commands (array this.escapeCommand)}}
+
+
+
+ {{#each this.commands as |command|}}
+ -
+ {{command.label}}
+
+ {{#if command.recording}}
+ Recording; ESC to cancel.
+ {{else}}
+ {{#if command.custom}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+
+ {{/each}}
+
+
+
+{{/if}}
+
+{{#if (and this.keyboard.enabled this.keyboard.displayHints)}}
+ {{#each this.hints as |hint|}}
+
+ {{/each}}
+{{/if}}
diff --git a/ui/app/components/keyboard-shortcuts-modal.js b/ui/app/components/keyboard-shortcuts-modal.js
new file mode 100644
index 000000000000..117a8a66872f
--- /dev/null
+++ b/ui/app/components/keyboard-shortcuts-modal.js
@@ -0,0 +1,70 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { computed } from '@ember/object';
+import { action } from '@ember/object';
+import Tether from 'tether';
+
+export default class KeyboardShortcutsModalComponent extends Component {
+ @service keyboard;
+ @service config;
+
+ escapeCommand = {
+ label: 'Hide Keyboard Shortcuts',
+ pattern: ['Escape'],
+ action: () => {
+ this.keyboard.shortcutsVisible = false;
+ },
+ };
+
+ /**
+ * commands: filter keyCommands to those that have an action and a label,
+ * to distinguish between those that are just visual hints of existing commands
+ */
+ @computed('keyboard.keyCommands.[]')
+ get commands() {
+ return this.keyboard.keyCommands.reduce((memo, c) => {
+ if (c.label && c.action && !memo.find((m) => m.label === c.label)) {
+ memo.push(c);
+ }
+ return memo;
+ }, []);
+ }
+
+ /**
+ * hints: filter keyCommands to those that have an element property,
+ * and then compute a position on screen to place the hint.
+ */
+ @computed('keyboard.{keyCommands.length,displayHints}')
+ get hints() {
+ if (this.keyboard.displayHints) {
+ return this.keyboard.keyCommands.filter((c) => c.element);
+ } else {
+ return [];
+ }
+ }
+
+ @action
+ tetherToElement(element, hint, self) {
+ if (!this.config.isTest) {
+ let binder = new Tether({
+ element: self,
+ target: element,
+ attachment: 'top left',
+ targetAttachment: 'top left',
+ targetModifier: 'visible',
+ });
+ hint.binder = binder;
+ }
+ }
+
+ @action
+ untetherFromElement(hint) {
+ if (!this.config.isTest) {
+ hint.binder.destroy();
+ }
+ }
+
+ @action toggleListener() {
+ this.keyboard.enabled = !this.keyboard.enabled;
+ }
+}
diff --git a/ui/app/components/plugin-subnav.js b/ui/app/components/plugin-subnav.js
new file mode 100644
index 000000000000..1333547a77e8
--- /dev/null
+++ b/ui/app/components/plugin-subnav.js
@@ -0,0 +1,6 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+
+export default class PluginSubnavComponent extends Component {
+ @service keyboard;
+}
diff --git a/ui/app/components/safe-link-to.js b/ui/app/components/safe-link-to.js
new file mode 100644
index 000000000000..d4dfc4a74517
--- /dev/null
+++ b/ui/app/components/safe-link-to.js
@@ -0,0 +1,9 @@
+import { LinkComponent } from '@ember/legacy-built-in-components';
+import classic from 'ember-classic-decorator';
+
+// Necessary for programmatic routing away pages with
s that contain @query properties.
+// (There's an issue with query param calculations in the new component that uses the router service)
+// https://github.com/emberjs/ember.js/issues/20051
+
+@classic
+export default class SafeLinkToComponent extends LinkComponent {}
diff --git a/ui/app/components/server-agent-row.js b/ui/app/components/server-agent-row.js
index e0cc0ede2a7a..8c2b2c2825e9 100644
--- a/ui/app/components/server-agent-row.js
+++ b/ui/app/components/server-agent-row.js
@@ -41,9 +41,13 @@ export default class ServerAgentRow extends Component {
return currentURL.replace(/%40/g, '@') === targetURL.replace(/%40/g, '@');
}
- click() {
+ goToAgent() {
const transition = () =>
this.router.transitionTo('servers.server', this.agent);
lazyClick([transition, event]);
}
+
+ click() {
+ this.goToAgent();
+ }
}
diff --git a/ui/app/components/server-subnav.js b/ui/app/components/server-subnav.js
index 3a4002f173f3..88e893a9292c 100644
--- a/ui/app/components/server-subnav.js
+++ b/ui/app/components/server-subnav.js
@@ -1,5 +1,8 @@
import Component from '@ember/component';
import { tagName } from '@ember-decorators/component';
+import { inject as service } from '@ember/service';
@tagName('')
-export default class ServerSubnav extends Component {}
+export default class ServerSubnav extends Component {
+ @service keyboard;
+}
diff --git a/ui/app/components/storage-subnav.js b/ui/app/components/storage-subnav.js
new file mode 100644
index 000000000000..a43db30e2a3a
--- /dev/null
+++ b/ui/app/components/storage-subnav.js
@@ -0,0 +1,6 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+
+export default class StorageSubnavComponent extends Component {
+ @service keyboard;
+}
diff --git a/ui/app/components/task-subnav.js b/ui/app/components/task-subnav.js
index 702b19caa4ff..b77ad6b2ddc3 100644
--- a/ui/app/components/task-subnav.js
+++ b/ui/app/components/task-subnav.js
@@ -8,6 +8,7 @@ import classic from 'ember-classic-decorator';
@tagName('')
export default class TaskSubnav extends Component {
@service router;
+ @service keyboard;
@equal('router.currentRouteName', 'allocations.allocation.task.fs')
fsIsActive;
diff --git a/ui/app/controllers/application.js b/ui/app/controllers/application.js
index e4ac82c7ce79..4781831fad61 100644
--- a/ui/app/controllers/application.js
+++ b/ui/app/controllers/application.js
@@ -9,7 +9,8 @@ import codesForError from '../utils/codes-for-error';
import NoLeaderError from '../utils/no-leader-error';
import OTTExchangeError from '../utils/ott-exchange-error';
import classic from 'ember-classic-decorator';
-
+// eslint-disable-next-line no-unused-vars
+import KeyboardService from '../services/keyboard';
@classic
export default class ApplicationController extends Controller {
@service config;
@@ -17,6 +18,17 @@ export default class ApplicationController extends Controller {
@service token;
@service flashMessages;
+ /**
+ * @type {KeyboardService}
+ */
+ @service keyboard;
+
+ // eslint-disable-next-line ember/classic-decorator-hooks
+ constructor() {
+ super(...arguments);
+ this.keyboard.listenForKeypress();
+ }
+
queryParams = [
{
region: 'region',
diff --git a/ui/app/controllers/csi/volumes/index.js b/ui/app/controllers/csi/volumes/index.js
index 7adee636fd85..74ad3d6cc2a8 100644
--- a/ui/app/controllers/csi/volumes/index.js
+++ b/ui/app/controllers/csi/volumes/index.js
@@ -22,6 +22,7 @@ export default class IndexController extends Controller.extend(
) {
@service system;
@service userSettings;
+ @service keyboard;
@controller('csi/volumes') volumesController;
@alias('volumesController.isForbidden')
diff --git a/ui/app/controllers/evaluations/index.js b/ui/app/controllers/evaluations/index.js
index cb13f3a4945a..9438394dad9a 100644
--- a/ui/app/controllers/evaluations/index.js
+++ b/ui/app/controllers/evaluations/index.js
@@ -65,7 +65,9 @@ export default class EvaluationsController extends Controller {
async handleEvaluationClick(evaluation, e) {
if (
e instanceof MouseEvent ||
- (e instanceof KeyboardEvent && (e.code === 'Enter' || e.code === 'Space'))
+ (e instanceof KeyboardEvent &&
+ (e.code === 'Enter' || e.code === 'Space')) ||
+ !e
) {
this.statechart.send('LOAD_EVALUATION', { evaluation });
}
diff --git a/ui/app/helpers/clean-keycommand.js b/ui/app/helpers/clean-keycommand.js
new file mode 100644
index 000000000000..1aeead05c9bf
--- /dev/null
+++ b/ui/app/helpers/clean-keycommand.js
@@ -0,0 +1,18 @@
+// @ts-check
+import { helper } from '@ember/component/helper';
+
+const KEY_ALIAS_MAP = {
+ ArrowRight: '→',
+ ArrowLeft: '←',
+ ArrowUp: '↑',
+ ArrowDown: '↓',
+ '+': ' + ',
+};
+
+export default helper(function cleanKeycommand([key] /*, named*/) {
+ let cleaned = key;
+ Object.keys(KEY_ALIAS_MAP).forEach((k) => {
+ cleaned = cleaned.replace(k, KEY_ALIAS_MAP[k]);
+ });
+ return cleaned;
+});
diff --git a/ui/app/helpers/keyboard-commands.js b/ui/app/helpers/keyboard-commands.js
new file mode 100644
index 000000000000..f03fd4b92e75
--- /dev/null
+++ b/ui/app/helpers/keyboard-commands.js
@@ -0,0 +1,26 @@
+import Helper from '@ember/component/helper';
+import { inject as service } from '@ember/service';
+
+/**
+ `{{keyboard-commands}}` helper used to initialize and tear down contextual keynav commands
+ @public
+ @method keyboard-commands
+ */
+export default class keyboardCommands extends Helper {
+ @service keyboard;
+
+ constructor() {
+ super(...arguments);
+ }
+
+ compute([commands]) {
+ if (commands) {
+ this.commands = commands;
+ this.keyboard.addCommands(commands);
+ }
+ }
+ willDestroy() {
+ super.willDestroy();
+ this.keyboard.removeCommands(this.commands);
+ }
+}
diff --git a/ui/app/helpers/lazy-click.js b/ui/app/helpers/lazy-click.js
index 4260cca805d6..b1905022fbac 100644
--- a/ui/app/helpers/lazy-click.js
+++ b/ui/app/helpers/lazy-click.js
@@ -9,7 +9,7 @@ import Helper from '@ember/component/helper';
* that should be handled instead.
*/
export function lazyClick([onClick, event]) {
- if (!['a', 'button'].includes(event.target.tagName.toLowerCase())) {
+ if (!['a', 'button'].includes(event?.target.tagName.toLowerCase())) {
onClick(event);
}
}
diff --git a/ui/app/modifiers/keyboard-shortcut.js b/ui/app/modifiers/keyboard-shortcut.js
new file mode 100644
index 000000000000..0a75255d8e8a
--- /dev/null
+++ b/ui/app/modifiers/keyboard-shortcut.js
@@ -0,0 +1,38 @@
+import { inject as service } from '@ember/service';
+import Modifier from 'ember-modifier';
+import { registerDestructor } from '@ember/destroyable';
+
+export default class KeyboardShortcutModifier extends Modifier {
+ @service keyboard;
+ @service router;
+
+ modify(
+ element,
+ _positional,
+ {
+ label,
+ pattern = '',
+ action = () => {},
+ menuLevel = false,
+ enumerated = false,
+ exclusive = false,
+ }
+ ) {
+ let commands = [
+ {
+ label,
+ action,
+ pattern,
+ element,
+ menuLevel,
+ enumerated,
+ exclusive,
+ },
+ ];
+
+ this.keyboard.addCommands(commands);
+ registerDestructor(this, () => {
+ this.keyboard.removeCommands(commands);
+ });
+ }
+}
diff --git a/ui/app/services/keyboard.js b/ui/app/services/keyboard.js
new file mode 100644
index 000000000000..1a9e077ec479
--- /dev/null
+++ b/ui/app/services/keyboard.js
@@ -0,0 +1,465 @@
+// @ts-check
+import Service from '@ember/service';
+import { inject as service } from '@ember/service';
+import { timeout, restartableTask } from 'ember-concurrency';
+import { tracked } from '@glimmer/tracking';
+import { compare } from '@ember/utils';
+import { A } from '@ember/array';
+// eslint-disable-next-line no-unused-vars
+import EmberRouter from '@ember/routing/router';
+import { schedule } from '@ember/runloop';
+import { action, set } from '@ember/object';
+import { guidFor } from '@ember/object/internals';
+import { assert } from '@ember/debug';
+// eslint-disable-next-line no-unused-vars
+import MutableArray from '@ember/array/mutable';
+import localStorageProperty from 'nomad-ui/utils/properties/local-storage';
+
+/**
+ * @typedef {Object} KeyCommand
+ * @property {string} label
+ * @property {string[]} pattern
+ * @property {any} action
+ * @property {boolean} [requireModifier]
+ * @property {boolean} [enumerated]
+ * @property {boolean} [recording]
+ * @property {boolean} [custom]
+ * @property {boolean} [exclusive]
+ */
+
+const DEBOUNCE_MS = 750;
+// This modifies event.key to a symbol; get the digit equivalent to perform commands
+const DIGIT_MAP = {
+ '!': 1,
+ '@': 2,
+ '#': 3,
+ $: 4,
+ '%': 5,
+ '^': 6,
+ '&': 7,
+ '*': 8,
+ '(': 9,
+ ')': 0,
+};
+
+const DISALLOWED_KEYS = [
+ 'Shift',
+ 'Backspace',
+ 'Delete',
+ 'Meta',
+ 'Alt',
+ 'Control',
+ 'Tab',
+ 'CapsLock',
+ 'Clear',
+ 'ScrollLock',
+];
+
+export default class KeyboardService extends Service {
+ /**
+ * @type {EmberRouter}
+ */
+ @service router;
+
+ @service config;
+
+ @tracked shortcutsVisible = false;
+ @tracked buffer = A([]);
+ @tracked displayHints = false;
+
+ @localStorageProperty('keyboardNavEnabled', true) enabled;
+
+ defaultPatterns = {
+ 'Go to Jobs': ['g', 'j'],
+ 'Go to Storage': ['g', 'r'],
+ 'Go to Servers': ['g', 's'],
+ 'Go to Clients': ['g', 'c'],
+ 'Go to Topology': ['g', 't'],
+ 'Go to Evaluations': ['g', 'e'],
+ 'Go to ACL Tokens': ['g', 'a'],
+ 'Next Subnav': ['Shift+ArrowRight'],
+ 'Previous Subnav': ['Shift+ArrowLeft'],
+ 'Previous Main Section': ['Shift+ArrowUp'],
+ 'Next Main Section': ['Shift+ArrowDown'],
+ 'Show Keyboard Shortcuts': ['Shift+?'],
+ };
+
+ /**
+ * @type {MutableArray}
+ */
+ @tracked
+ keyCommands = A(
+ [
+ {
+ label: 'Go to Jobs',
+ action: () => this.router.transitionTo('jobs'),
+ rebindable: true,
+ },
+ {
+ label: 'Go to Storage',
+ action: () => this.router.transitionTo('csi.volumes'),
+ rebindable: true,
+ },
+ {
+ label: 'Go to Servers',
+ action: () => this.router.transitionTo('servers'),
+ rebindable: true,
+ },
+ {
+ label: 'Go to Clients',
+ action: () => this.router.transitionTo('clients'),
+ rebindable: true,
+ },
+ {
+ label: 'Go to Topology',
+ action: () => this.router.transitionTo('topology'),
+ rebindable: true,
+ },
+ {
+ label: 'Go to Evaluations',
+ action: () => this.router.transitionTo('evaluations'),
+ rebindable: true,
+ },
+ {
+ label: 'Go to ACL Tokens',
+ action: () => this.router.transitionTo('settings.tokens'),
+ rebindable: true,
+ },
+ {
+ label: 'Next Subnav',
+ action: () => {
+ this.traverseLinkList(this.subnavLinks, 1);
+ },
+ requireModifier: true,
+ rebindable: true,
+ },
+ {
+ label: 'Previous Subnav',
+ action: () => {
+ this.traverseLinkList(this.subnavLinks, -1);
+ },
+ requireModifier: true,
+ rebindable: true,
+ },
+ {
+ label: 'Previous Main Section',
+ action: () => {
+ this.traverseLinkList(this.navLinks, -1);
+ },
+ requireModifier: true,
+ rebindable: true,
+ },
+ {
+ label: 'Next Main Section',
+ action: () => {
+ this.traverseLinkList(this.navLinks, 1);
+ },
+ requireModifier: true,
+ rebindable: true,
+ },
+ {
+ label: 'Show Keyboard Shortcuts',
+ action: () => {
+ this.shortcutsVisible = true;
+ },
+ },
+ ].map((command) => {
+ const persistedValue = window.localStorage.getItem(
+ `keyboard.command.${command.label}`
+ );
+ if (persistedValue) {
+ set(command, 'pattern', JSON.parse(persistedValue));
+ set(command, 'custom', true);
+ } else {
+ set(command, 'pattern', this.defaultPatterns[command.label]);
+ }
+ return command;
+ })
+ );
+
+ /**
+ * For Dynamic/iterative keyboard shortcuts, we want to do a couple things to make them more human-friendly:
+ * 1. Make them 1-based, instead of 0-based
+ * 2. Prefix numbers 1-9 with "0" to make it so "Shift+10" doesn't trigger "Shift+1" then "0", etc.
+ * ^--- stops being a good solution with 100+ row lists/tables, but a better UX than waiting for shift key-up otherwise
+ *
+ * @param {number} iter
+ * @returns {string[]}
+ */
+ cleanPattern(iter) {
+ iter = iter + 1; // first item should be Shift+1, not Shift+0
+ assert('Dynamic keyboard shortcuts only work up to 99 digits', iter < 100);
+ return [`Shift+${('0' + iter).slice(-2)}`]; // Shift+01, not Shift+1
+ }
+
+ recomputeEnumeratedCommands() {
+ this.keyCommands.filterBy('enumerated').forEach((command, iter) => {
+ command.pattern = this.cleanPattern(iter);
+ });
+ }
+
+ addCommands(commands) {
+ schedule('afterRender', () => {
+ commands.forEach((command) => {
+ if (command.exclusive) {
+ this.removeCommands(
+ this.keyCommands.filterBy('label', command.label)
+ );
+ }
+ this.keyCommands.pushObject(command);
+ if (command.enumerated) {
+ // Recompute enumerated numbers to handle things like sort
+ this.recomputeEnumeratedCommands();
+ }
+ });
+ });
+ }
+
+ removeCommands(commands = A([])) {
+ this.keyCommands.removeObjects(commands);
+ }
+
+ //#region Nav Traversal
+
+ subnavLinks = [];
+ navLinks = [];
+
+ /**
+ * Map over a passed element's links and determine if they're routable
+ * If so, return them in a transitionTo-able format
+ *
+ * @param {HTMLElement} element did-insertable menu container element
+ * @param {Object} args
+ * @param {('main' | 'subnav')} args.type determine which traversable list the routes belong to
+ */
+ @action
+ registerNav(element, _, args) {
+ const { type } = args;
+ const links = Array.from(element.querySelectorAll('a:not(.loading)'))
+ .map((link) => {
+ if (link.getAttribute('href')) {
+ return {
+ route: this.router.recognize(link.getAttribute('href'))?.name,
+ parent: guidFor(element),
+ };
+ }
+ })
+ .compact();
+
+ if (type === 'main') {
+ this.navLinks = links;
+ } else if (type === 'subnav') {
+ this.subnavLinks = links;
+ }
+ }
+
+ /**
+ * Removes links associated with a specific nav.
+ * guidFor is necessary because willDestroy runs async;
+ * it can happen after the next page's did-insert, so we .reject() instead of resetting to [].
+ *
+ * @param {HTMLElement} element
+ */
+ @action
+ unregisterSubnav(element) {
+ this.subnavLinks = this.subnavLinks.reject(
+ (link) => link.parent === guidFor(element)
+ );
+ }
+
+ /**
+ *
+ * @param {Array} links - array of root.branch.twig strings
+ * @param {number} traverseBy - positive or negative number to move along links
+ */
+ traverseLinkList(links, traverseBy) {
+ // afterRender because LinkTos evaluate their href value at render time
+ schedule('afterRender', () => {
+ if (links.length) {
+ let activeLink = links.find((link) => this.router.isActive(link.route));
+
+ // If no activeLink, means we're nested within a primary section.
+ // Luckily, Ember's RouteInfo.find() gives us access to parents and connected leaves of a route.
+ // So, if we're on /csi/volumes but the nav link is to /csi, we'll .find() it.
+ // Similarly, /job/:job/taskgroupid/index will find /job.
+ if (!activeLink) {
+ activeLink = links.find((link) => {
+ return this.router.currentRoute.find((r) => {
+ return r.name === link.route || `${r.name}.index` === link.route;
+ });
+ });
+ }
+
+ if (activeLink) {
+ const activeLinkPosition = links.indexOf(activeLink);
+ const nextPosition = activeLinkPosition + traverseBy;
+
+ // Modulo (%) logic: if the next position is longer than the array, wrap to 0.
+ // If it's before the beginning, wrap to the end.
+ const nextLink =
+ links[((nextPosition % links.length) + links.length) % links.length]
+ .route;
+
+ this.router.transitionTo(nextLink);
+ }
+ }
+ });
+ }
+
+ //#endregion Nav Traversal
+
+ /**
+ *
+ * @param {("press" | "release")} type
+ * @param {KeyboardEvent} event
+ */
+ recordKeypress(type, event) {
+ const inputElements = ['input', 'textarea', 'code'];
+ const disallowedClassNames = [
+ 'ember-basic-dropdown-trigger',
+ 'dropdown-option',
+ ];
+ const targetElementName = event.target.nodeName.toLowerCase();
+ const inputDisallowed =
+ inputElements.includes(targetElementName) ||
+ disallowedClassNames.any((className) =>
+ event.target.classList.contains(className)
+ );
+
+ // Don't fire keypress events from within an input field
+ if (!inputDisallowed) {
+ // Treat Shift like a special modifier key.
+ // If it's depressed, display shortcuts
+ const { key } = event;
+ const shifted = event.getModifierState('Shift');
+ if (type === 'press') {
+ if (key === 'Shift') {
+ this.displayHints = true;
+ } else {
+ if (!DISALLOWED_KEYS.includes(key)) {
+ this.addKeyToBuffer.perform(key, shifted);
+ }
+ }
+ } else if (type === 'release') {
+ if (key === 'Shift') {
+ this.displayHints = false;
+ }
+ }
+ }
+ }
+
+ rebindCommand = (cmd, ele) => {
+ ele.target.blur(); // keynav ignores on inputs
+ this.clearBuffer();
+ set(cmd, 'recording', true);
+ set(cmd, 'previousPattern', cmd.pattern);
+ set(cmd, 'pattern', null);
+ };
+
+ endRebind = (cmd) => {
+ set(cmd, 'custom', true);
+ set(cmd, 'recording', false);
+ set(cmd, 'previousPattern', null);
+ window.localStorage.setItem(
+ `keyboard.command.${cmd.label}`,
+ JSON.stringify([...this.buffer])
+ );
+ };
+
+ resetCommandToDefault = (cmd) => {
+ window.localStorage.removeItem(`keyboard.command.${cmd.label}`);
+ set(cmd, 'pattern', this.defaultPatterns[cmd.label]);
+ set(cmd, 'custom', false);
+ };
+
+ /**
+ *
+ * @param {string} key
+ * @param {boolean} shifted
+ */
+ @restartableTask *addKeyToBuffer(key, shifted) {
+ // Replace key with its unshifted equivalent if it's a number key
+ if (shifted && key in DIGIT_MAP) {
+ key = DIGIT_MAP[key];
+ }
+ this.buffer.pushObject(shifted ? `Shift+${key}` : key);
+ let recorder = this.keyCommands.find((c) => c.recording);
+ if (recorder) {
+ if (key === 'Escape' || key === '/') {
+ // Escape cancels recording; slash is reserved for global search
+ set(recorder, 'recording', false);
+ set(recorder, 'pattern', recorder.previousPattern);
+ recorder = null;
+ } else if (key === 'Enter') {
+ // Enter finishes recording and removes itself from the buffer
+ this.buffer = this.buffer.slice(0, -1);
+ this.endRebind(recorder);
+ recorder = null;
+ } else {
+ set(recorder, 'pattern', [...this.buffer]);
+ }
+ } else {
+ if (this.matchedCommands.length) {
+ this.matchedCommands.forEach((command) => {
+ if (
+ this.enabled ||
+ command.label === 'Show Keyboard Shortcuts' ||
+ command.label === 'Hide Keyboard Shortcuts'
+ ) {
+ command.action();
+ }
+ });
+ this.clearBuffer();
+ }
+ }
+ yield timeout(DEBOUNCE_MS);
+ if (recorder) {
+ this.endRebind(recorder);
+ }
+ this.clearBuffer();
+ }
+
+ get matchedCommands() {
+ // Shiftless Buffer: handle the case where use is holding shift (to see shortcut hints) and typing a key command
+ const shiftlessBuffer = this.buffer.map((key) =>
+ key.replace('Shift+', '').toLowerCase()
+ );
+
+ // Shift Friendly Buffer: If you hold Shift and type 0 and 1, it'll output as ['Shift+0', 'Shift+1'].
+ // Instead, translate that to ['Shift+01'] for clearer UX
+ const shiftFriendlyBuffer = [
+ `Shift+${this.buffer.map((key) => key.replace('Shift+', '')).join('')}`,
+ ];
+
+ // Ember Compare: returns 0 if there's no diff between arrays.
+ const matches = this.keyCommands.filter((command) => {
+ return (
+ command.action &&
+ (!compare(command.pattern, this.buffer) ||
+ (command.requireModifier
+ ? false
+ : !compare(command.pattern, shiftlessBuffer)) ||
+ (command.requireModifier
+ ? false
+ : !compare(command.pattern, shiftFriendlyBuffer)))
+ );
+ });
+ return matches;
+ }
+
+ clearBuffer() {
+ this.buffer.clear();
+ }
+
+ listenForKeypress() {
+ set(this, '_keyDownHandler', this.recordKeypress.bind(this, 'press'));
+ document.addEventListener('keydown', this._keyDownHandler);
+ set(this, '_keyUpHandler', this.recordKeypress.bind(this, 'release'));
+ document.addEventListener('keyup', this._keyUpHandler);
+ }
+
+ willDestroy() {
+ document.removeEventListener('keydown', this._keyDownHandler);
+ document.removeEventListener('keyup', this._keyUpHandler);
+ }
+}
diff --git a/ui/app/styles/components.scss b/ui/app/styles/components.scss
index 8eb2f85e91ef..f08a19286778 100644
--- a/ui/app/styles/components.scss
+++ b/ui/app/styles/components.scss
@@ -47,3 +47,4 @@
@import './components/two-step-button';
@import './components/evaluations';
@import './components/secure-variables';
+@import './components/keyboard-shortcuts-modal';
diff --git a/ui/app/styles/components/keyboard-shortcuts-modal.scss b/ui/app/styles/components/keyboard-shortcuts-modal.scss
new file mode 100644
index 000000000000..ca71f768009a
--- /dev/null
+++ b/ui/app/styles/components/keyboard-shortcuts-modal.scss
@@ -0,0 +1,127 @@
+.keyboard-shortcuts {
+ position: fixed;
+ background-color: white;
+ padding: 2rem;
+ margin-top: 20vh;
+ width: 40vw;
+ left: 30vw;
+ z-index: 499;
+ box-shadow: 2px 2px 12px 3000px rgb(0, 0, 0, 0.8);
+ animation-name: slideIn;
+ animation-duration: 0.2s;
+ animation-fill-mode: both;
+ max-height: 60vh;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+
+ header {
+ margin-bottom: 2rem;
+ h2 {
+ font-size: $size-3;
+ font-weight: $weight-semibold;
+ }
+
+ button.dismiss {
+ float: right;
+ font-size: 0.7rem;
+ margin-bottom: 1rem;
+ }
+ }
+
+ ul.commands-list {
+ overflow: auto;
+ margin: 0 -2rem;
+ padding: 0 2rem;
+ li {
+ list-style-type: none;
+ padding: 0.5rem 0;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ &:not(:last-of-type) {
+ border-bottom: 1px solid #ccc;
+ }
+ strong {
+ padding: 0.25rem 0;
+ }
+ .keys {
+ text-align: right;
+ & > span.recording {
+ color: $red;
+ font-size: 0.75rem;
+ }
+ button {
+ border: none;
+ background: #eee;
+ cursor: pointer;
+
+ &:hover {
+ background: #ddd;
+ }
+
+ &[disabled],
+ &[disabled]:hover {
+ background: #eee;
+ color: black;
+ cursor: not-allowed;
+ }
+ span {
+ margin: 0.25rem;
+ display: inline-block;
+ }
+
+ &.reset-to-default {
+ background: white;
+ color: $red;
+ font-size: 0.75rem;
+ }
+ }
+ }
+ }
+ }
+
+ footer {
+ background: #eee;
+ padding: 1rem 2rem;
+ margin: 1rem -2rem -2rem;
+ display: grid;
+ grid-template-columns: auto 1fr;
+
+ .toggle {
+ text-align: right;
+ }
+ }
+}
+
+// Global keyboard hint style
+
+// .display-hints {
+[data-shortcut] {
+ background: lighten($nomad-green, 25%);
+ border: 1px solid $nomad-green-dark;
+ content: attr(data-shortcut);
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ font-size: 0.75rem;
+ padding: 0 0.5rem;
+ text-transform: lowercase;
+ color: black;
+ font-weight: 300;
+ z-index: $z-popover;
+ &.menu-level {
+ z-index: $z-tooltip;
+ }
+}
+// }
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ top: 40px;
+ }
+ to {
+ opacity: 1;
+ top: 0px;
+ }
+}
diff --git a/ui/app/templates/allocations/allocation/index.hbs b/ui/app/templates/allocations/allocation/index.hbs
index 6519c3629aa4..7ebec44e7c54 100644
--- a/ui/app/templates/allocations/allocation/index.hbs
+++ b/ui/app/templates/allocations/allocation/index.hbs
@@ -184,6 +184,10 @@
+
+
{{#if this.error}}
diff --git a/ui/app/templates/clients/client/index.hbs b/ui/app/templates/clients/client/index.hbs
index c347b6d2e2d6..20a8f3506b53 100644
--- a/ui/app/templates/clients/client/index.hbs
+++ b/ui/app/templates/clients/client/index.hbs
@@ -546,6 +546,10 @@
diff --git a/ui/app/templates/components/allocation-subnav.hbs b/ui/app/templates/components/allocation-subnav.hbs
index 335d19641745..374e4f5c9b1e 100644
--- a/ui/app/templates/components/allocation-subnav.hbs
+++ b/ui/app/templates/components/allocation-subnav.hbs
@@ -1,4 +1,4 @@
-
+
- Overview
- Files
diff --git a/ui/app/templates/components/app-breadcrumbs.hbs b/ui/app/templates/components/app-breadcrumbs.hbs
index 7c1e105a92c0..a56bf2316ee7 100644
--- a/ui/app/templates/components/app-breadcrumbs.hbs
+++ b/ui/app/templates/components/app-breadcrumbs.hbs
@@ -1,7 +1,7 @@
- {{#each breadcrumbs as |crumb|}}
+ {{#each breadcrumbs as |crumb iter|}}
{{#let crumb.args.crumb as |c|}}
- {{component (concat "breadcrumbs/" (or c.type "default")) crumb=c}}
+ {{component (concat "breadcrumbs/" (or c.type "default")) crumb=c isOneCrumbUp=(action this.isOneCrumbUp iter breadcrumbs.length)}}
{{/let}}
{{/each}}
\ No newline at end of file
diff --git a/ui/app/templates/components/client-subnav.hbs b/ui/app/templates/components/client-subnav.hbs
index 98978a90e59b..c8a769eafb14 100644
--- a/ui/app/templates/components/client-subnav.hbs
+++ b/ui/app/templates/components/client-subnav.hbs
@@ -1,4 +1,4 @@
-
+
- Overview
- Monitor
diff --git a/ui/app/templates/components/global-header.hbs b/ui/app/templates/components/global-header.hbs
index eb23131f3bf9..384d2c67e715 100644
--- a/ui/app/templates/components/global-header.hbs
+++ b/ui/app/templates/components/global-header.hbs
@@ -60,7 +60,7 @@
>
Documentation
-
+
ACL Tokens
diff --git a/ui/app/templates/components/gutter-menu.hbs b/ui/app/templates/components/gutter-menu.hbs
index 69fc6f2a8e26..215680212840 100644
--- a/ui/app/templates/components/gutter-menu.hbs
+++ b/ui/app/templates/components/gutter-menu.hbs
@@ -1,6 +1,7 @@