Skip to content

Commit

Permalink
Added screen reader alerts for various actions throughout the app.
Browse files Browse the repository at this point in the history
  • Loading branch information
tonyanziano committed Sep 19, 2019
1 parent e8b9ede commit b95ebc5
Show file tree
Hide file tree
Showing 19 changed files with 273 additions and 16 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [1867](https://github.com/microsoft/BotFramework-Emulator/pull/1867)
- [1871](https://github.com/microsoft/BotFramework-Emulator/pull/1871)
- [1872](https://github.com/microsoft/BotFramework-Emulator/pull/1872)
- [1873](https://github.com/microsoft/BotFramework-Emulator/pull/1873)

- [client] Fixed an issue with the transcripts path input inside of the resource settings dialog in PR [1836](https://github.com/microsoft/BotFramework-Emulator/pull/1836)

Expand Down
60 changes: 60 additions & 0 deletions packages/app/client/src/ui/a11y/ariaAlertService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license.
//
// Microsoft Bot Framework: http://botframework.com
//
// Bot Framework Emulator Github:
// https://github.com/Microsoft/BotFramwork-Emulator
//
// Copyright (c) Microsoft Corporation
// All rights reserved.
//
// MIT License:
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

import { ariaAlertService } from './ariaAlertService';

describe('AriaAlertService', () => {
it('should create an aria alert and only one at a time', () => {
ariaAlertService.alert('I am an alert!');
const alertElement = document.querySelector('span#alert-from-service') as HTMLSpanElement;

expect(alertElement).toBeTruthy();
expect(alertElement.innerText).toBe('I am an alert!');

ariaAlertService.alert('I am another alert!');

const alertElements = document.querySelectorAll('span#alert-from-service');

expect(alertElements.length).toBe(1);
});

it('should not create an aria alert if there is no message', () => {
// make sure there are no leftover alerts from previous test(s)
const preExistingAlerts = document.querySelectorAll('span#alert-from-service');
preExistingAlerts.forEach(alert => alert.remove());
ariaAlertService.alert(undefined);
const alertElement = document.querySelector('span#alert-from-service') as HTMLSpanElement;

expect(alertElement).toBeFalsy();
});
});
57 changes: 57 additions & 0 deletions packages/app/client/src/ui/a11y/ariaAlertService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license.
//
// Microsoft Bot Framework: http://botframework.com
//
// Bot Framework Emulator Github:
// https://github.com/Microsoft/BotFramwork-Emulator
//
// Copyright (c) Microsoft Corporation
// All rights reserved.
//
// MIT License:
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

let singleton: AriaAlertService;
class AriaAlertService {
constructor() {
singleton = this;
}

/** Creates an alert and inserts it into the DOM */
public alert(msg: string): void {
if (!msg) {
return;
}
const prevAlert = document.querySelector('span#alert-from-service');
prevAlert && prevAlert.remove();
const alert = document.createElement('span');
alert.innerText = msg;
alert.setAttribute('id', 'alert-from-service');
alert.setAttribute('role', 'alert');
alert.setAttribute('style', 'position: absolute; top: -9999px;');
document.body.appendChild(alert);
}
}

/** Creates invisible alerts to be read by screen reader technologies */
export const ariaAlertService = singleton || new AriaAlertService();
34 changes: 34 additions & 0 deletions packages/app/client/src/ui/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license.
//
// Microsoft Bot Framework: http://botframework.com
//
// Bot Framework Emulator Github:
// https://github.com/Microsoft/BotFramwork-Emulator
//
// Copyright (c) Microsoft Corporation
// All rights reserved.
//
// MIT License:
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

export * from './ariaAlertService';
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

import { mount } from 'enzyme';
import { mount, ReactWrapper } from 'enzyme';
import * as React from 'react';
import { CommandServiceImpl, CommandServiceInstance } from '@bfemulator/sdk-shared';

import { ActiveBotHelper } from '../../helpers/activeBotHelper';
import { ariaAlertService } from '../../a11y';

import { BotCreationDialog, BotCreationDialogState } from './botCreationDialog';

Expand Down Expand Up @@ -85,7 +86,7 @@ describe('BotCreationDialog tests', () => {
commandService = descriptor.descriptor.get();
});

let testWrapper;
let testWrapper: ReactWrapper<any, any, any>;
beforeEach(() => {
testWrapper = mount(<BotCreationDialog />);
});
Expand Down Expand Up @@ -141,9 +142,11 @@ describe('BotCreationDialog tests', () => {
});
(window.document.getElementById as any) = mockGetElementById;
window.document.execCommand = mockExec;
const alertServiceSpy = jest.spyOn(ariaAlertService, 'alert').mockReturnValueOnce(undefined);

(testWrapper.instance() as any).onCopyClick();
expect(mockExec).toHaveBeenCalledWith('copy');
expect(alertServiceSpy).toHaveBeenCalledWith('Secret copied to clipboard.');

// restore window functions
window.document.execCommand = backupExec;
Expand Down Expand Up @@ -238,4 +241,24 @@ describe('BotCreationDialog tests', () => {
null
);
});

it('should toggle the visibility of the secret', () => {
const spy = jest.spyOn(ariaAlertService, 'alert');
testWrapper.setState({ encryptKey: true, revealSecret: false });
testWrapper.instance().onRevealSecretClick();

expect(spy).toHaveBeenCalledWith('Secret showing.');
expect(testWrapper.instance().state.revealSecret).toBe(true);

testWrapper.instance().onRevealSecretClick();

expect(spy).toHaveBeenCalledWith('Secret hidden.');
});

it('should not toggle the visibility of the secret if the encryption is disabled', () => {
testWrapper.setState({ encryptKey: false, revealSecret: false });
testWrapper.instance().onRevealSecretClick();

expect(testWrapper.instance().state.revealSecret).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { store } from '../../../state/store';
import { generateBotSecret } from '../../../utils';
import { ActiveBotHelper } from '../../helpers/activeBotHelper';
import { DialogService } from '../service';
import { ariaAlertService } from '../../a11y';

import * as styles from './botCreationDialog.scss';

Expand All @@ -70,6 +71,8 @@ export class BotCreationDialog extends React.Component<{}, BotCreationDialogStat
@CommandServiceInstance()
public commandService: CommandServiceImpl;

private secretInputRef: HTMLInputElement;

public constructor(props: {}, context: BotCreationDialogState) {
super(props, context);

Expand Down Expand Up @@ -171,12 +174,13 @@ export class BotCreationDialog extends React.Component<{}, BotCreationDialogStat
</Row>

<TextField
disabled={!encryptKey}
inputContainerClassName={styles.key}
label="Secret "
inputRef={this.setSecretInputRef}
label="Secret"
value={secret}
placeholder="Your keys are not encrypted"
disabled={true}
id="key-input"
readOnly={true}
type={revealSecret ? 'text' : 'password'}
/>
<ul className={styles.actionsList}>
Expand Down Expand Up @@ -261,21 +265,22 @@ export class BotCreationDialog extends React.Component<{}, BotCreationDialogStat
if (!this.state.encryptKey) {
return null;
}
this.setState({ revealSecret: !this.state.revealSecret });
const revealSecret = !this.state.revealSecret;
ariaAlertService.alert(`Secret ${revealSecret ? 'showing' : 'hidden'}.`);
this.setState({ revealSecret });
};

private onCopyClick = (): void => {
if (!this.state.encryptKey) {
return null;
}
const input: HTMLInputElement = window.document.getElementById('key-input') as HTMLInputElement;
input.removeAttribute('disabled');
const { type } = input;
input.type = 'text';
input.select();
const { secretInputRef } = this;
const { type } = secretInputRef;
secretInputRef.type = 'text';
secretInputRef.select();
window.document.execCommand('copy');
input.type = type;
input.setAttribute('disabled', '');
secretInputRef.type = type;
ariaAlertService.alert('Secret copied to clipboard.');
};

// TODO: Re-enable ability to re-generate secret after 4.1
Expand Down Expand Up @@ -364,4 +369,8 @@ export class BotCreationDialog extends React.Component<{}, BotCreationDialogStat
const controllerRegEx = /api\/messages\/?$/;
return controllerRegEx.test(endpoint) ? '' : `Please include route if necessary: "/api/messages"`;
}

private setSecretInputRef = (ref: HTMLInputElement): void => {
this.secretInputRef = ref;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { clientAwareSettingsChanged } from '../../../state/actions/clientAwareSe
import { bot } from '../../../state/reducers/bot';
import { clientAwareSettings } from '../../../state/reducers/clientAwareSettings';
import { DialogService } from '../service';
import { ariaAlertService } from '../../a11y';

import { OpenBotDialog } from './openBotDialog';
import { OpenBotDialogContainer } from './openBotDialogContainer';
Expand Down Expand Up @@ -243,4 +244,14 @@ describe('The OpenBotDialog', () => {

expect(instance.state.botUrl).toBe('http://localhost:3978');
});

it('should announce any validation error messages', () => {
// make sure there are no leftover alerts from previous test(s)
const preExistingAlerts = document.querySelectorAll('body > span');
preExistingAlerts.forEach(alert => alert.remove());
const spy = jest.spyOn(ariaAlertService, 'alert').mockReturnValueOnce(undefined);
instance.announceErrorMessage('Invalid bot url.');

expect(spy).toHaveBeenCalledWith('Invalid bot url.');
});
});
11 changes: 11 additions & 0 deletions packages/app/client/src/ui/dialogs/openBotDialog/openBotDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { EmulatorMode } from '@bfemulator/sdk-shared';
import * as openBotStyles from './openBotDialog.scss';

export interface OpenBotDialogProps {
createAriaAlert?: (msg: string) => void;
mode?: EmulatorMode;
isDebug?: boolean;
onDialogCancel?: () => void;
Expand Down Expand Up @@ -127,6 +128,7 @@ export class OpenBotDialog extends Component<OpenBotDialogProps, OpenBotDialogSt
const { botUrl, appId, appPassword, mode, isDebug, isAzureGov } = this.state;
const validationResult = OpenBotDialog.validateEndpoint(botUrl);
const errorMessage = OpenBotDialog.getErrorMessage(validationResult);
errorMessage && this.announceErrorMessage(errorMessage);
const shouldBeDisabled =
validationResult === ValidationResult.Invalid || validationResult === ValidationResult.Empty;
const botUrlLabel = 'Bot URL';
Expand Down Expand Up @@ -254,4 +256,13 @@ export class OpenBotDialog extends Component<OpenBotDialogProps, OpenBotDialogSt
}
return null;
}

/** Announces the error message to screen reader technologies */
private announceErrorMessage(msg: string): void {
// ensure that we aren't spamming aria alerts each time the input is validated
const existingAlerts = document.querySelectorAll('span#alert-from-service');
if (!existingAlerts.length) {
this.props.createAriaAlert(msg);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ import { Action } from 'redux';
import { openBotViaFilePathAction, openBotViaUrlAction } from '../../../state/actions/botActions';
import { DialogService } from '../service';
import { RootState } from '../../../state/store';
import { ariaAlertService } from '../../a11y';

import { OpenBotDialog, OpenBotDialogProps, OpenBotDialogState } from './openBotDialog';

const mapDispatchToProps = (dispatch: (action: Action) => void): OpenBotDialogProps => {
return {
createAriaAlert: (msg: string) => {
ariaAlertService.alert(msg);
},
openBot: (componentState: OpenBotDialogState) => {
DialogService.hideDialog();
const { appId = '', appPassword = '', botUrl = '', mode = 'livechat-url', isAzureGov } = componentState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import * as EditorActions from '../../../state/actions/editorActions';
import { setFrameworkSettings, saveFrameworkSettings } from '../../../state/actions/frameworkSettingsActions';
import { getTabGroupForDocument } from '../../../state/helpers/editorHelpers';
import { framework } from '../../../state/reducers/framework';
import { ariaAlertService } from '../../a11y';

import { AppSettingsEditor } from './appSettingsEditor';
import { AppSettingsEditorContainer } from './appSettingsEditorContainer';
Expand Down Expand Up @@ -163,6 +164,8 @@ describe('The AppSettingsEditorContainer', () => {
});

it('should save the framework settings then get them again from main when the "onSaveClick" handler is called', async () => {
const alertServiceSpy = jest.spyOn(ariaAlertService, 'alert').mockReturnValueOnce(undefined);

await (instance as any).onSaveClick();

const keys = Object.keys(frameworkDefault).sort();
Expand All @@ -172,6 +175,8 @@ describe('The AppSettingsEditorContainer', () => {
...saveSettingsAction.payload,
hash: jasmine.any(String),
};

expect(mockDispatch).toHaveBeenLastCalledWith(saveFrameworkSettings(savedSettings));
expect(alertServiceSpy).toHaveBeenCalledWith('App settings saved.');
});
});
Loading

0 comments on commit b95ebc5

Please sign in to comment.