Skip to content

Commit

Permalink
[ui] general keyboard navigation: 1.3.4 release (#14138)
Browse files Browse the repository at this point in the history
* Initialized keyboard service

Neat but funky: dynamic subnav traversal

👻

generalized traverseSubnav method

Shift as special modifier key

Nice little demo panel

Keyboard shortcuts keycard

Some animation styles on keyboard shortcuts

Handle situations where a link is deeply nested from its parent menu item

Keyboard service cleanup

helper-based initializer and teardown for new contextual commands

Keyboard shortcuts modal component added and demo-ghost removed

Removed j and k from subnav traversal

Register and unregister methods for subnav plus new subnavs for volumes and volume

register main nav method

Generalizing the register nav method

12762 table keynav (#12975)

* Experimental feature: shortcut visual hints

* Long way around to a custom modifier for keyboard shortcuts

* dynamic table and list iterative shortcuts

* Progress with regular old tether

* Delogging

* Table Keynav tether fix, server and client navs, and fix to shiftless on modified arrow keys

Go to Optimize keyboard link and storage key changed to g r

parameterized jobs keyboard nav

Dynamic numeric keynav for multiple tables (#13482)

* Multiple tables init

* URL-bind enumerable keyboard commands and add to more taskRow and allocationRows

* Type safety and lint fixes

* Consolidated push to keyCommands

* Default value when removing keyCommands

* Remove the URL-based removal method and perform a recompute on any add

Get tests passing in Keynav: remove math helpers and a few other defensive moves (#13761)

* Remove ember math helpers

* Test fixes for jobparts/body

* Kill an unneeded integration helper test

* delog

* Trying if disabling percy lets this finish

* Okay so its not percy; try parallelism in circle

* Percyless yet again

* Trying a different angle to not have percy

* Upgrade percy to 1.6.1

[ui] Keyboard nav: "u" key to go up a level (#13754)

* U to go up a level

* Mislabelled my conditional

* Custom lint ignore rule

* Custom lint ignore rule, this time with commas

* Since we're getting rid of ember math helpers elsewhere, do the math ourselves here

Replace ArrowLeft etc. with an ascii arrow (#13776)

* Replace ArrowLeft etc. with an ascii arrow

* non-mutative helper cleanup

Keyboard Nav: let users rebind their shortcuts (#13781)

* click-outside and shortcuts enabled/disabled toggle

* Trap focus when modal open

* Enabled/disabled saved to localStorage

* Autofocus edit button on variable index

* Modal overflow styles

* Functional rebind

* Saving rebinds to localStorage for all majors

* Started on defaultCommandBindings

* Modal header style and cancel rebind on escape

* keyboardable keybindings w buttons instead of spans

* recording and defaultvalues

* Enter short-circuits rebind

* Only some commands are rebindable, and dont show dupes

* No unused get import

* More visually distinct header on modal

* Disallowed keys for rebind, showing buffer as you type, and moving dedupe to modal logic

willDestroy hook to prevent tests from doubling/tripling up addEventListener on kb events

remove unused tests

Keyboard Navigation acceptance tests (#13893)

* Acceptance tests for keyboard modal

* a11y audit fix and localStorage clear

* Bind/rebind/localStorage tests

* Keyboard tests for dynamic nav and tables

* Rebinder and assert expectation

* Second percy snapshot showing hints no longer relevant

Weird issue where linktos with query props specifically from the task-groups page would fail to route / hit undefined.shouldSuperCede errors

Adds the concept of exclusivity to a keycommand, removing peers that also share its label

Lintfix

Changelog and PR feedback

Changelog and PR feedback

Fix to rebinding in firefox by blurring the now-disabled button on rebind (#14053)

* Secure Variables shortcuts removed

* Variable index route autofocus removed

* Updated changelog entry

* Updated changelog entry

* Keynav docs (#14148)

* Section added to the API Docs UI page

* Added a note about disabling

* Prev and Next order

* Remove dev log and unneeded comments
  • Loading branch information
philrenaud committed Aug 19, 2022
1 parent 5244f24 commit 7c2f5fb
Show file tree
Hide file tree
Showing 64 changed files with 1,483 additions and 82 deletions.
3 changes: 3 additions & 0 deletions .changelog/14138.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
ui: add general keyboard navigation to the Nomad UI
```
8 changes: 7 additions & 1 deletion ui/.template-lintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
],
};
1 change: 1 addition & 0 deletions ui/app/components/allocation-subnav.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions ui/app/components/app-breadcrumbs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Component from '@glimmer/component';

export default class AppBreadcrumbsComponent extends Component {
isOneCrumbUp(iter = 0, totalNum = 0) {
return iter === totalNum - 2;
}
}
14 changes: 11 additions & 3 deletions ui/app/components/breadcrumbs/default.hbs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
{{! template-lint-disable no-unknown-arguments-for-builtin-components }}
<li data-test-breadcrumb-default>
<li data-test-breadcrumb-default
{{(modifier
this.maybeKeyboardShortcut
label="Go up a level"
pattern=(array "u")
menuLevel=true
action=(action this.traverseUpALevel @crumb.args)
exclusive=true
)}}
>
<LinkTo
@params={{@crumb.args}}
data-test-breadcrumb={{@crumb.args.firstObject}}
>
data-test-breadcrumb={{@crumb.args.firstObject}}>
{{#if @crumb.title}}
<dl>
<dt>
Expand Down
18 changes: 18 additions & 0 deletions ui/app/components/breadcrumbs/default.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 10 additions & 1 deletion ui/app/components/breadcrumbs/job.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,16 @@
</LinkTo>
</li>
{{/if}}
<li>
<li
{{(modifier
this.maybeKeyboardShortcut
label="Go up a level"
pattern=(array "u")
menuLevel=true
action=(action this.traverseUpALevel (array "jobs.job" this.job.idWithNamespace))
exclusive=true
)}}
>
<LinkTo
@route="jobs.job.index"
@model={{this.job}}
Expand Down
4 changes: 2 additions & 2 deletions ui/app/components/breadcrumbs/job.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { assert } from '@ember/debug';
import { action } from '@ember/object';
import Component from '@glimmer/component';
import BreadcrumbsTemplate from './default';

export default class BreadcrumbsJob extends Component {
export default class BreadcrumbsJob extends BreadcrumbsTemplate {
get job() {
return this.args.crumb.job;
}
Expand Down
5 changes: 4 additions & 1 deletion ui/app/components/client-subnav.js
Original file line number Diff line number Diff line change
@@ -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 ClientSubnav extends Component {}
export default class ClientSubnav extends Component {
@service keyboard;
}
3 changes: 3 additions & 0 deletions ui/app/components/evaluation-sidebar/detail.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{{#let this.currentEvalDetail as |evaluation|}}
{{#if this.isSideBarOpen}}
{{keyboard-commands this.keyCommands}}
{{/if}}
<Portal @target="eval-detail-portal">
<div
data-test-eval-detail
Expand Down
8 changes: 8 additions & 0 deletions ui/app/components/evaluation-sidebar/detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,12 @@ export default class Detail extends Component {
closeSidebar() {
return this.statechart.send('MODAL_CLOSE');
}

keyCommands = [
{
label: 'Close Evaluations Sidebar',
pattern: ['Escape'],
action: () => this.closeSidebar(),
},
];
}
6 changes: 6 additions & 0 deletions ui/app/components/gutter-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import classic from 'ember-classic-decorator';
export default class GutterMenu extends Component {
@service system;
@service router;
@service keyboard;

@computed('[email protected]')
get sortedNamespaces() {
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions ui/app/components/job-subnav.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
70 changes: 70 additions & 0 deletions ui/app/components/keyboard-shortcuts-modal.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{{#if this.keyboard.shortcutsVisible}}
{{keyboard-commands (array this.escapeCommand)}}
<div class="keyboard-shortcuts"
{{on-click-outside
(toggle "keyboard.shortcutsVisible" this)
}}
>
<header>
<button
{{autofocus}}
class="button is-borderless dismiss"
type="button"
{{on "click" (toggle "keyboard.shortcutsVisible" this)}}
aria-label="Dismiss"
>
{{x-icon "cancel"}}
</button>
<h2>Keyboard Shortcuts</h2>
<p>Click a key pattern to re-bind it to a shortcut of your choosing.</p>
</header>
<ul class="commands-list">
{{#each this.commands as |command|}}
<li data-test-command-label={{command.label}}>
<strong>{{command.label}}</strong>
<span class="keys">
{{#if command.recording}}
<span class="recording">Recording; ESC to cancel.</span>
{{else}}
{{#if command.custom}}
<button type="button" class="reset-to-default" {{on "click" (action this.keyboard.resetCommandToDefault command)}}>reset to default</button>
{{/if}}
{{/if}}

<button data-test-rebinder disabled={{or (not command.rebindable) command.recording}} type="button" {{on "click" (action this.keyboard.rebindCommand command)}}>
{{#each command.pattern as |key|}}
<span>{{clean-keycommand key}}</span>
{{/each}}
</button>
</span>
</li>
{{/each}}
</ul>
<footer>
<strong>Keyboard shortcuts {{#if this.keyboard.enabled}}enabled{{else}}disabled{{/if}}</strong>
<Toggle
data-test-enable-shortcuts-toggle
@isActive={{this.keyboard.enabled}}
@onToggle={{this.toggleListener}}
title="{{if this.keyboard.enabled "enable" "disable"}} keyboard shortcuts"
/>
</footer>
</div>
{{/if}}

{{#if (and this.keyboard.enabled this.keyboard.displayHints)}}
{{#each this.hints as |hint|}}
<span
{{did-insert (fn this.tetherToElement hint.element hint)}}
{{will-destroy (fn this.untetherFromElement hint)}}
data-test-keyboard-hint
data-shortcut={{hint.pattern}}
class="{{if hint.menuLevel "menu-level"}}"
aria-hidden="true"
>
{{#each hint.pattern as |key|}}
<span>{{key}}</span>
{{/each}}
</span>
{{/each}}
{{/if}}
70 changes: 70 additions & 0 deletions ui/app/components/keyboard-shortcuts-modal.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 6 additions & 0 deletions ui/app/components/plugin-subnav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class PluginSubnavComponent extends Component {
@service keyboard;
}
9 changes: 9 additions & 0 deletions ui/app/components/safe-link-to.js
Original file line number Diff line number Diff line change
@@ -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 <LinkTo>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 {}
6 changes: 5 additions & 1 deletion ui/app/components/server-agent-row.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
5 changes: 4 additions & 1 deletion ui/app/components/server-subnav.js
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions ui/app/components/storage-subnav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class StorageSubnavComponent extends Component {
@service keyboard;
}
1 change: 1 addition & 0 deletions ui/app/components/task-subnav.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 13 additions & 1 deletion ui/app/controllers/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,25 @@ 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;
@service system;
@service token;

/**
* @type {KeyboardService}
*/
@service keyboard;

// eslint-disable-next-line ember/classic-decorator-hooks
constructor() {
super(...arguments);
this.keyboard.listenForKeypress();
}

queryParams = [
{
region: 'region',
Expand Down
1 change: 1 addition & 0 deletions ui/app/controllers/csi/volumes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default class IndexController extends Controller.extend(
) {
@service system;
@service userSettings;
@service keyboard;
@controller('csi/volumes') volumesController;

@alias('volumesController.isForbidden')
Expand Down
Loading

0 comments on commit 7c2f5fb

Please sign in to comment.