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 17, 2019
1 parent dc8cf55 commit 7546241
Show file tree
Hide file tree
Showing 19 changed files with 263 additions and 16 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [1865](https://github.com/microsoft/BotFramework-Emulator/pull/1865)
- [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)

- [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
52 changes: 52 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,52 @@
//
// 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', () => {
ariaAlertService.alert('I am an alert!');
const alertElement = document.querySelector('body > span') as HTMLSpanElement;
expect(alertElement).toBeTruthy();
expect(alertElement.innerText).toBe('I am an alert!');
});

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('body > span');
preExistingAlerts.forEach(alert => alert.remove());
ariaAlertService.alert(undefined);
const alertElement = document.querySelector('body > span') as HTMLSpanElement;
expect(alertElement).toBeFalsy();
});
});
56 changes: 56 additions & 0 deletions packages/app/client/src/ui/a11y/ariaAlertService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// 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 alert = document.createElement('span');
alert.innerText = msg;
alert.setAttribute('role', 'alert');
alert.setAttribute('style', 'position: absolute; top: -9999px;');
document.body.appendChild(alert);
// remove the alert after it has been read
setTimeout(alert.remove.bind(alert), 5000);
}
}

/** 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('body > span');
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 @@ -172,6 +173,9 @@ describe('The AppSettingsEditorContainer', () => {
...saveSettingsAction.payload,
hash: jasmine.any(String),
};
const alertServiceSpy = jest.spyOn(ariaAlertService, 'alert').mockReturnValueOnce(undefined);

expect(mockDispatch).toHaveBeenLastCalledWith(saveFrameworkSettings(savedSettings));
expect(alertServiceSpy).toHaveBeenCalledWith('App settings saved.');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface AppSettingsEditorProps {
documentId?: string;
dirty?: boolean;
framework?: FrameworkSettings;

createAriaAlert?: (msg: string) => void;
discardChanges?: () => void;
openBrowseForNgrok: () => Promise<string>;
saveFrameworkSettings?: (framework: FrameworkSettings) => void;
Expand Down Expand Up @@ -310,6 +310,7 @@ export class AppSettingsEditor extends React.Component<AppSettingsEditorProps, A

this.setState({ dirty: false });
this.props.saveFrameworkSettings(newState);
this.props.createAriaAlert('App settings saved.');
};

private updateDirtyFlag(change: { [prop: string]: any }) {
Expand Down
Loading

0 comments on commit 7546241

Please sign in to comment.