Address a11y issues in browser-based console UI (#26872)

This commit is contained in:
Melanie Sumner
2024-05-16 11:39:13 -05:00
committed by GitHub
parent 3e0d934f47
commit 530b26608e
17 changed files with 104 additions and 59 deletions

3
changelog/26872.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:bug
ui: Resolved accessibility issues with Web REPL. Associated label and help text with input, added a conditional to show the console/ui-panel only when toggled open, added keyboard focus trap.
```

View File

@@ -17,16 +17,17 @@ export default Component.extend({
actions: { actions: {
handleKeyUp(event) { handleKeyUp(event) {
const keyCode = event.keyCode; const keyCode = event.keyCode;
const val = event.target.value;
switch (keyCode) { switch (keyCode) {
case keys.ENTER: case keys.ENTER:
this.onExecuteCommand(event.target.value); this.onExecuteCommand(val);
break; break;
case keys.UP: case keys.UP:
case keys.DOWN: case keys.DOWN:
this.onShiftCommand(keyCode); this.onShiftCommand(keyCode);
break; break;
default: default:
this.onValueUpdate(event.target.value); this.onValueUpdate(val);
} }
}, },
fullscreen() { fullscreen() {

View File

@@ -49,7 +49,14 @@
<LinkStatus @status={{this.currentCluster.cluster.hcpLinkStatus}} /> <LinkStatus @status={{this.currentCluster.cluster.hcpLinkStatus}} />
{{yield}} {{yield}}
<div data-test-console-panel class={{if this.console.isOpen "panel-open"}}> <div data-test-console-panel class={{if this.console.isOpen "panel-open"}}>
<Console::UiPanel @isFullscreen={{this.consoleFullscreen}} /> {{#if this.console.isOpen}}
<Console::UiPanel
@isFullscreen={{this.consoleFullscreen}}
{{focus-trap
focusTrapOptions=(hash initialFocus="#console-input" clickOutsideDeactivates=true onDeactivate=this.closeConsole)
}}
/>
{{/if}}
</div> </div>
</Frame.Main> </Frame.Main>
</Hds::AppFrame> </Hds::AppFrame>

View File

@@ -14,7 +14,9 @@ $console-close-height: 35px;
overflow: auto; overflow: auto;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
transition: min-height $speed $easing, transform $speed ease-in; transition:
min-height $speed $easing,
transform $speed ease-in;
will-change: transform, min-height; will-change: transform, min-height;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
z-index: 199; z-index: 199;
@@ -118,11 +120,11 @@ $console-close-height: 35px;
.panel-open .console-ui-panel { .panel-open .console-ui-panel {
box-shadow: $box-shadow-highest; box-shadow: $box-shadow-highest;
min-height: 400px; min-height: 425px;
} }
.main--console-open { .main--console-open {
padding-bottom: 400px; padding-bottom: 425px;
} }
.panel-open .console-ui-panel.fullscreen { .panel-open .console-ui-panel.fullscreen {

View File

@@ -10,19 +10,20 @@
<Chevron /> <Chevron />
{{/if}} {{/if}}
<input <input
aria-label="command input" aria-label="web R.E.P.L."
aria-describedby="namespace-reminder"
onkeyup={{action "handleKeyUp"}} onkeyup={{action "handleKeyUp"}}
value={{this.value}} value={{this.value}}
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
id="console-input"
/> />
<Hds::Button <Hds::Button
class="hds-side-nav__icon-button" class="hds-side-nav__icon-button"
{{on "click" (action "fullscreen")}} {{on "click" (action "fullscreen")}}
{{hds-tooltip (if this.isFullscreen "minimize" "maximize")}} data-test-dismiss-console-button
data-test-tool-tip-trigger
@icon={{if this.isFullscreen "minimize" "maximize"}} @icon={{if this.isFullscreen "minimize" "maximize"}}
@text={{if this.isFullscreen "Minimize" "Maximize"}} @text={{if this.isFullscreen "Minimize window" "Maximize window"}}
@isIconOnly={{true}} @isIconOnly={{true}}
/> />
</div> </div>

View File

@@ -3,17 +3,19 @@
SPDX-License-Identifier: BUSL-1.1 SPDX-License-Identifier: BUSL-1.1
~}} ~}}
<div class="console-close-button">
<Hds::Button
class="hds-side-nav__icon-button"
{{on "click" (action "closeConsole")}}
data-test-console-panel-close
@text="Close console"
@icon="x"
@isIconOnly={{true}}
/>
</div>
<div class="console-ui-panel-content"> <div class="console-ui-panel-content">
<div class="content has-bottom-margin-s">
<div class="console-close-button">
<Hds::Button
class="hds-side-nav__icon-button"
{{on "click" (action "closeConsole")}}
data-test-console-panel-close
@text="Close console"
@icon="x"
@isIconOnly={{true}}
/>
</div>
</div>
<div class="content has-bottom-margin-l"> <div class="content has-bottom-margin-l">
<p class="console-ui-panel-intro is-font-mono has-bottom-margin-s"> <p class="console-ui-panel-intro is-font-mono has-bottom-margin-s">
The Vault Web REPL provides an easy way to execute common Vault CLI commands, such as write, read, delete, and list. It The Vault Web REPL provides an easy way to execute common Vault CLI commands, such as write, read, delete, and list. It
@@ -22,14 +24,18 @@
<Hds::Link::Inline @href={{doc-link "/vault/docs/command/web"}}>HashiCorp Developer site</Hds::Link::Inline>. <Hds::Link::Inline @href={{doc-link "/vault/docs/command/web"}}>HashiCorp Developer site</Hds::Link::Inline>.
</p> </p>
<p class="console-ui-panel-intro is-font-mono has-bottom-margin-s">Examples:</p> <p class="console-ui-panel-intro is-font-mono has-bottom-margin-s">Examples:</p>
<p class="console-ui-panel-intro is-font-mono">→ Write secrets to kv v1: write &lt;mount&gt;/my-secret foo=bar</p> <p class="console-ui-panel-intro is-font-mono">
<p class="console-ui-panel-intro is-font-mono">→ List kv v1 secret keys: list &lt;mount&gt;/</p> <span aria-hidden="true">→ </span>Write secrets to kv v1: write &lt;mount&gt;/my-secret foo=bar</p>
<p class="console-ui-panel-intro is-font-mono">→ Read a kv v1 secret: read &lt;mount&gt;/my-secret</p> <p class="console-ui-panel-intro is-font-mono">
<p class="console-ui-panel-intro is-font-mono">→ Mount a kv v2 secret engine: write sys/mounts/&lt;mount&gt; type=kv <span aria-hidden="true">→ </span>List kv v1 secret keys: list &lt;mount&gt;/</p>
options=version=2</p> <p class="console-ui-panel-intro is-font-mono">
<p class="console-ui-panel-intro is-font-mono">→ Read a kv v2 secret: kv-get &lt;mount&gt;/secret-path</p> <span aria-hidden="true">→ </span>Read a kv v1 secret: read &lt;mount&gt;/my-secret</p>
<p class="console-ui-panel-intro is-font-mono">→ Read a kv v2 secret's metadata: kv-get &lt;mount&gt;/secret-path <p class="console-ui-panel-intro is-font-mono">
-metadata</p> <span aria-hidden="true">→ </span>Mount a kv v2 secret engine: write sys/mounts/&lt;mount&gt; type=kv options=version=2</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>Read a kv v2 secret: kv-get &lt;mount&gt;/secret-path</p>
<p class="console-ui-panel-intro is-font-mono">
<span aria-hidden="true">→ </span>Read a kv v2 secret's metadata: kv-get &lt;mount&gt;/secret-path-metadata</p>
</div> </div>
<Console::OutputLog @outputLog={{this.cliLog}} /> <Console::OutputLog @outputLog={{this.cliLog}} />
<Console::CommandInput <Console::CommandInput

View File

@@ -5,11 +5,11 @@
{{#if this.showMessage}} {{#if this.showMessage}}
{{#if (has-block)}} {{#if (has-block)}}
<p class="namespace-reminder"> <p class="namespace-reminder" id="namespace-reminder">
{{yield (hash namespace=this.namespace)}} {{yield (hash namespace=this.namespace)}}
</p> </p>
{{else}} {{else}}
<p class="namespace-reminder"> <p class="namespace-reminder" id="namespace-reminder">
This This
{{@noun}} {{@noun}}
will be will be

View File

@@ -25,8 +25,6 @@ module('Acceptance | console', function (hooks) {
assert.expect(6); assert.expect(6);
await enginesPage.visit(); await enginesPage.visit();
await settled(); await settled();
await consoleComponent.toggle();
await settled();
const ids = [uuidv4(), uuidv4(), uuidv4()]; const ids = [uuidv4(), uuidv4(), uuidv4()];
for (const id of ids) { for (const id of ids) {
const inputString = `write sys/mounts/console-route-${id} type=kv`; const inputString = `write sys/mounts/console-route-${id} type=kv`;
@@ -59,7 +57,7 @@ module('Acceptance | console', function (hooks) {
test('fullscreen command expands the cli panel', async function (assert) { test('fullscreen command expands the cli panel', async function (assert) {
await consoleComponent.toggle(); await consoleComponent.toggle();
await settled(); await settled();
await consoleComponent.runCommands('fullscreen'); await consoleComponent.runCommands('fullscreen', false);
await settled(); await settled();
const consoleEle = document.querySelector('[data-test-component="console/ui-panel"]'); const consoleEle = document.querySelector('[data-test-component="console/ui-panel"]');
// wait for the CSS transition to finish // wait for the CSS transition to finish
@@ -74,7 +72,7 @@ module('Acceptance | console', function (hooks) {
test('array output is correctly formatted', async function (assert) { test('array output is correctly formatted', async function (assert) {
await consoleComponent.toggle(); await consoleComponent.toggle();
await settled(); await settled();
await consoleComponent.runCommands('read -field=policies /auth/token/lookup-self'); await consoleComponent.runCommands('read -field=policies /auth/token/lookup-self', false);
await settled(); await settled();
const consoleOut = document.querySelector('.console-ui-output>pre'); const consoleOut = document.querySelector('.console-ui-output>pre');
// wait for the CSS transition to finish // wait for the CSS transition to finish
@@ -86,7 +84,7 @@ module('Acceptance | console', function (hooks) {
test('number output is correctly formatted', async function (assert) { test('number output is correctly formatted', async function (assert) {
await consoleComponent.toggle(); await consoleComponent.toggle();
await settled(); await settled();
await consoleComponent.runCommands('read -field=creation_time /auth/token/lookup-self'); await consoleComponent.runCommands('read -field=creation_time /auth/token/lookup-self', false);
await settled(); await settled();
const consoleOut = document.querySelector('.console-ui-output>pre'); const consoleOut = document.querySelector('.console-ui-output>pre');
// wait for the CSS transition to finish // wait for the CSS transition to finish
@@ -97,7 +95,7 @@ module('Acceptance | console', function (hooks) {
test('boolean output is correctly formatted', async function (assert) { test('boolean output is correctly formatted', async function (assert) {
await consoleComponent.toggle(); await consoleComponent.toggle();
await settled(); await settled();
await consoleComponent.runCommands('read -field=orphan /auth/token/lookup-self'); await consoleComponent.runCommands('read -field=orphan /auth/token/lookup-self', false);
await settled(); await settled();
const consoleOut = document.querySelector('.console-ui-output>pre'); const consoleOut = document.querySelector('.console-ui-output>pre');
// have to wrap in a later so that we can wait for the CSS transition to finish // have to wrap in a later so that we can wait for the CSS transition to finish

View File

@@ -111,6 +111,7 @@ module('Acceptance | pki workflow', function (hooks) {
allow_subdomains=true \ allow_subdomains=true \
max_ttl="720h"`, max_ttl="720h"`,
]); ]);
await runCmd([`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test"`]); await runCmd([`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test"`]);
const pki_admin_policy = adminPolicy(this.mountPath, 'roles'); const pki_admin_policy = adminPolicy(this.mountPath, 'roles');
const pki_reader_policy = readerPolicy(this.mountPath, 'roles'); const pki_reader_policy = readerPolicy(this.mountPath, 'roles');

View File

@@ -25,7 +25,12 @@ const visit = async (url) => {
const consoleComponent = create(consoleClass); const consoleComponent = create(consoleClass);
const wrappedAuth = async () => { const wrappedAuth = async () => {
await consoleComponent.runCommands(`write -field=token auth/token/create policies=default -wrap-ttl=5m`); await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands(
`write -field=token auth/token/create policies=default -wrap-ttl=5m`,
false
);
await settled(); await settled();
// because of flaky test, trying to capture the token using a dom selector instead of the page object // because of flaky test, trying to capture the token using a dom selector instead of the page object
const token = document.querySelector('[data-test-component="console/log-text"] pre').textContent; const token = document.querySelector('[data-test-component="console/log-text"] pre').textContent;

View File

@@ -7,7 +7,7 @@ import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit'; import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support';
import { create } from 'ember-cli-page-object'; import { create } from 'ember-cli-page-object';
import { fillIn } from '@ember/test-helpers'; import { fillIn, settled } from '@ember/test-helpers';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import enablePage from 'vault/tests/pages/settings/auth/enable'; import enablePage from 'vault/tests/pages/settings/auth/enable';
@@ -59,6 +59,8 @@ module('Acceptance | settings/auth/configure/section', function (hooks) {
for (const type of ['aws', 'azure', 'gcp', 'github', 'kubernetes']) { for (const type of ['aws', 'azure', 'gcp', 'github', 'kubernetes']) {
test(`it shows tabs for auth method: ${type}`, async function (assert) { test(`it shows tabs for auth method: ${type}`, async function (assert) {
const path = `${type}-showtab-${this.uid}`; const path = `${type}-showtab-${this.uid}`;
await cli.toggle();
await settled();
await cli.consoleInput(`write sys/auth/${path} type=${type}`); await cli.consoleInput(`write sys/auth/${path} type=${type}`);
await cli.enter(); await cli.enter();
await indexPage.visit({ path }); await indexPage.visit({ path });

View File

@@ -153,12 +153,16 @@ module('Acceptance | settings/mount-secret-backend', function (hooks) {
capabilities = ["read"] capabilities = ["read"]
} }
`; `;
await consoleComponent.runCommands([ await consoleComponent.toggle();
// delete any previous mount with same name await consoleComponent.runCommands(
`delete sys/mounts/${enginePath}`, [
`write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`, // delete any previous mount with same name
'write -field=client_token auth/token/create policies=kv-v2-degrade', `delete sys/mounts/${enginePath}`,
]); `write sys/policies/acl/kv-v2-degrade policy=${btoa(V2_POLICY)}`,
'write -field=client_token auth/token/create policies=kv-v2-degrade',
],
false
);
await settled(); await settled();
const userToken = consoleComponent.lastLogOutput; const userToken = consoleComponent.lastLogOutput;
await logout.visit(); await logout.visit();

View File

@@ -14,7 +14,12 @@ import consoleClass from 'vault/tests/pages/components/console/ui-panel';
const consoleComponent = create(consoleClass); const consoleComponent = create(consoleClass);
const wrappedAuth = async () => { const wrappedAuth = async () => {
await consoleComponent.runCommands(`write -field=token auth/token/create policies=default -wrap-ttl=3m`); await consoleComponent.toggle();
await settled();
await consoleComponent.runCommands(
`write -field=token auth/token/create policies=default -wrap-ttl=3m`,
false
);
await settled(); await settled();
return consoleComponent.lastLogOutput; return consoleComponent.lastLogOutput;
}; };

View File

@@ -1,10 +1,11 @@
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { create } from 'ember-cli-page-object';
/** /**
* Copyright (c) HashiCorp, Inc. * Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1 * SPDX-License-Identifier: BUSL-1.1
*/ */
import consoleClass from 'vault/tests/pages/components/console/ui-panel';
import { create } from 'ember-cli-page-object';
/** /**
* Helper functions to run common commands in the consoleComponent during tests. * Helper functions to run common commands in the consoleComponent during tests.
* Please note that a user must be logged in during the test context for the commands to run. * Please note that a user must be logged in during the test context for the commands to run.
@@ -45,8 +46,10 @@ export async function runCmd(commands, throwErrors = true) {
if (!Array.isArray(commands)) { if (!Array.isArray(commands)) {
commands = [commands]; commands = [commands];
} }
await cc.runCommands(commands); await cc.toggle();
await cc.runCommands(commands, false);
const lastOutput = cc.lastLogOutput; const lastOutput = cc.lastLogOutput;
await cc.toggle();
if (throwErrors && lastOutput.includes('Error')) { if (throwErrors && lastOutput.includes('Error')) {
throw new Error(`Error occurred while running commands: "${commands.join('; ')}" - ${lastOutput}`); throw new Error(`Error occurred while running commands: "${commands.join('; ')}" - ${lastOutput}`);
} }

View File

@@ -23,20 +23,21 @@ module('Integration | Component | console/ui panel', function (hooks) {
test('it clears console input on enter', async function (assert) { test('it clears console input on enter', async function (assert) {
await render(hbs`{{console/ui-panel}}`); await render(hbs`{{console/ui-panel}}`);
await component.runCommands('list this/thing/here', false);
await component.runCommands('list this/thing/here');
await settled(); await settled();
assert.strictEqual(component.consoleInputValue, '', 'empties input field on enter'); assert.strictEqual(component.consoleInputValue, '', 'empties input field on enter');
}); });
test('it clears the log when using clear command', async function (assert) { test('it clears the log when using clear command', async function (assert) {
await render(hbs`{{console/ui-panel}}`); await render(hbs`{{console/ui-panel}}`);
await component.runCommands(
await component.runCommands(['list this/thing/here', 'list this/other/thing', 'read another/thing']); ['list this/thing/here', 'list this/other/thing', 'read another/thing'],
false
);
await settled(); await settled();
assert.notEqual(component.logOutput, '', 'there is output in the log'); assert.notEqual(component.logOutput, '', 'there is output in the log');
await component.runCommands('clear'); await component.runCommands('clear', false);
await settled(); await settled();
await component.up(); await component.up();
await settled(); await settled();
@@ -51,7 +52,7 @@ module('Integration | Component | console/ui panel', function (hooks) {
test('it adds command to history on enter', async function (assert) { test('it adds command to history on enter', async function (assert) {
await render(hbs`{{console/ui-panel}}`); await render(hbs`{{console/ui-panel}}`);
await component.runCommands('list this/thing/here'); await component.runCommands('list this/thing/here', false);
await settled(); await settled();
await component.up(); await component.up();
await settled(); await settled();
@@ -67,8 +68,7 @@ module('Integration | Component | console/ui panel', function (hooks) {
test('it cycles through history with more than one command', async function (assert) { test('it cycles through history with more than one command', async function (assert) {
await render(hbs`{{console/ui-panel}}`); await render(hbs`{{console/ui-panel}}`);
await component.runCommands(['list this/thing/here', 'read that/thing/there', 'qwerty'], false);
await component.runCommands(['list this/thing/here', 'read that/thing/there', 'qwerty']);
await settled(); await settled();
await component.up(); await component.up();
await settled(); await settled();

View File

@@ -35,7 +35,7 @@ module('Integration | Component | sidebar-frame', function (hooks) {
assert.dom('[data-test-sidebar-nav]').doesNotExist('Sidebar is hidden'); assert.dom('[data-test-sidebar-nav]').doesNotExist('Sidebar is hidden');
}); });
test('it should render link status, console ui panel and yield block for app content', async function (assert) { test('it should render link status, console ui panel container and yield block for app content', async function (assert) {
const currentCluster = this.owner.lookup('service:currentCluster'); const currentCluster = this.owner.lookup('service:currentCluster');
currentCluster.setCluster({ hcpLinkStatus: 'connected' }); currentCluster.setCluster({ hcpLinkStatus: 'connected' });
const version = this.owner.lookup('service:version'); const version = this.owner.lookup('service:version');
@@ -50,7 +50,7 @@ module('Integration | Component | sidebar-frame', function (hooks) {
`); `);
assert.dom('[data-test-link-status]').exists('Link status component renders'); assert.dom('[data-test-link-status]').exists('Link status component renders');
assert.dom('[data-test-component="console/ui-panel"]').exists('Console UI panel renders'); assert.dom('[data-test-console-panel]').exists('Console UI panel container renders');
assert.dom('.page-container').exists('Block yields for app content'); assert.dom('.page-container').exists('Block yields for app content');
}); });

View File

@@ -11,6 +11,7 @@ import keys from 'core/utils/key-codes';
export default { export default {
toggle: clickable('[data-test-console-toggle]'), toggle: clickable('[data-test-console-toggle]'),
dismissConsole: clickable(['data-test-dismiss-console-button']),
consoleInput: fillable('[data-test-component="console/command-input"] input'), consoleInput: fillable('[data-test-component="console/command-input"] input'),
consoleInputValue: value('[data-test-component="console/command-input"] input'), consoleInputValue: value('[data-test-component="console/command-input"] input'),
logOutput: text('[data-test-component="console/output-log"]'), logOutput: text('[data-test-component="console/output-log"]'),
@@ -54,12 +55,18 @@ export default {
eventProperties: { keyCode: keys.ENTER }, eventProperties: { keyCode: keys.ENTER },
}), }),
hasInput: isPresent('[data-test-component="console/command-input"] input'), hasInput: isPresent('[data-test-component="console/command-input"] input'),
runCommands: async function (commands) { runCommands: async function (commands, shouldToggle = true) {
const toExecute = Array.isArray(commands) ? commands : [commands]; const toExecute = Array.isArray(commands) ? commands : [commands];
if (shouldToggle) {
await this.toggle(); // toggle the console open
}
for (const command of toExecute) { for (const command of toExecute) {
await this.consoleInput(command); await this.consoleInput(command);
await this.enter(); await this.enter();
await settled(); await settled();
} }
if (shouldToggle) {
await this.toggle(); // toggle it closed
}
}, },
}; };