diff --git a/.changelog/14138.txt b/.changelog/14138.txt new file mode 100644 index 000000000000..0978a97ce52d --- /dev/null +++ b/.changelog/14138.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: add general keyboard navigation to the Nomad UI +``` \ No newline at end of file diff --git a/ui/.template-lintrc.js b/ui/.template-lintrc.js index 28057918d84a..d6284a06200b 100644 --- a/ui/.template-lintrc.js +++ b/ui/.template-lintrc.js @@ -7,6 +7,12 @@ module.exports = { 'no-action': 'off', 'no-invalid-interactive': 'off', 'no-inline-styles': 'off', - 'no-curly-component-invocation': { allow: ['format-volume-name'] }, + 'no-curly-component-invocation': { + allow: ['format-volume-name', 'keyboard-commands'], + }, + 'no-implicit-this': { allow: ['keyboard-commands'] }, }, + ignore: [ + 'app/components/breadcrumbs/*', // using {{(modifier)}} syntax + ], }; diff --git a/ui/app/components/allocation-subnav.js b/ui/app/components/allocation-subnav.js index 594440223adc..09e91502a151 100644 --- a/ui/app/components/allocation-subnav.js +++ b/ui/app/components/allocation-subnav.js @@ -8,6 +8,7 @@ import classic from 'ember-classic-decorator'; @tagName('') export default class AllocationSubnav extends Component { @service router; + @service keyboard; @equal('router.currentRouteName', 'allocations.allocation.fs') fsIsActive; diff --git a/ui/app/components/app-breadcrumbs.js b/ui/app/components/app-breadcrumbs.js new file mode 100644 index 000000000000..868c81c2438c --- /dev/null +++ b/ui/app/components/app-breadcrumbs.js @@ -0,0 +1,7 @@ +import Component from '@glimmer/component'; + +export default class AppBreadcrumbsComponent extends Component { + isOneCrumbUp(iter = 0, totalNum = 0) { + return iter === totalNum - 2; + } +} diff --git a/ui/app/components/breadcrumbs/default.hbs b/ui/app/components/breadcrumbs/default.hbs index 3117a3b54e2d..73e86b6f6e53 100644 --- a/ui/app/components/breadcrumbs/default.hbs +++ b/ui/app/components/breadcrumbs/default.hbs @@ -1,9 +1,17 @@ {{! template-lint-disable no-unknown-arguments-for-builtin-components }} -
  • +
  • + data-test-breadcrumb={{@crumb.args.firstObject}}> {{#if @crumb.title}}
    diff --git a/ui/app/components/breadcrumbs/default.js b/ui/app/components/breadcrumbs/default.js new file mode 100644 index 000000000000..c8c7464bf5c4 --- /dev/null +++ b/ui/app/components/breadcrumbs/default.js @@ -0,0 +1,18 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import KeyboardShortcutModifier from 'nomad-ui/modifiers/keyboard-shortcut'; +import { inject as service } from '@ember/service'; + +export default class BreadcrumbsTemplate extends Component { + @service router; + + @action + traverseUpALevel(args) { + const [path, ...rest] = args; + this.router.transitionTo(path, ...rest); + } + + get maybeKeyboardShortcut() { + return this.args.isOneCrumbUp() ? KeyboardShortcutModifier : null; + } +} diff --git a/ui/app/components/breadcrumbs/job.hbs b/ui/app/components/breadcrumbs/job.hbs index d3956485f063..dfd8302cd836 100644 --- a/ui/app/components/breadcrumbs/job.hbs +++ b/ui/app/components/breadcrumbs/job.hbs @@ -26,7 +26,16 @@
  • {{/if}} -
  • +
  • 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)}} +
    +
    + +

    Keyboard Shortcuts

    +

    Click a key pattern to re-bind it to a shortcut of your choosing.

    +
    +
      + {{#each this.commands as |command|}} +
    • + {{command.label}} + + {{#if command.recording}} + Recording; ESC to cancel. + {{else}} + {{#if command.custom}} + + {{/if}} + {{/if}} + + + +
    • + {{/each}} +
    +
    + Keyboard shortcuts {{#if this.keyboard.enabled}}enabled{{else}}disabled{{/if}} + +
    +
    +{{/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 @@
      @@ -33,7 +34,7 @@
      {{/if}}