From fa51cec078ae0ddbd79448bc0eee7a3e58ecbfae Mon Sep 17 00:00:00 2001 From: Michael Prentice Date: Thu, 23 Aug 2018 22:02:16 -0400 Subject: [PATCH] fix(toast): improve a11y support for $mdToast.simple(). improve docs move the role="alert" up a level - makes action button visible to screen readers add support for defining an actionKey to assign a hot key to an action - this enables Control-actionKey to activate the action add support for defining a dismissHint for screen readers add support for defining an actionHint for screen readers align custom toast demo with Material Design guidelines enhance custom toast demo to demonstrate an accessible custom toast Fixes #349 --- src/components/toast/demoBasicUsage/script.js | 17 ++- .../toast/demoCustomUsage/index.html | 17 +-- .../toast/demoCustomUsage/script.js | 138 ++++++++++++------ .../toast/demoCustomUsage/style.scss | 9 ++ .../toast/demoCustomUsage/toast-template.html | 16 +- src/components/toast/toast.js | 109 +++++++++++--- src/components/toast/toast.spec.js | 8 +- 7 files changed, 223 insertions(+), 91 deletions(-) create mode 100644 src/components/toast/demoCustomUsage/style.scss diff --git a/src/components/toast/demoBasicUsage/script.js b/src/components/toast/demoBasicUsage/script.js index 0f39479932c..004b139982d 100644 --- a/src/components/toast/demoBasicUsage/script.js +++ b/src/components/toast/demoBasicUsage/script.js @@ -1,5 +1,4 @@ - -angular.module('toastDemo1', ['ngMaterial']) +angular.module('toastBasicDemo', ['ngMaterial']) .controller('AppCtrl', function($scope, $mdToast) { var last = { @@ -36,7 +35,7 @@ angular.module('toastDemo1', ['ngMaterial']) $mdToast.show( $mdToast.simple() .textContent('Simple Toast!') - .position(pinTo ) + .position(pinTo) .hideDelay(3000) ); }; @@ -45,14 +44,18 @@ angular.module('toastDemo1', ['ngMaterial']) var pinTo = $scope.getToastPosition(); var toast = $mdToast.simple() .textContent('Marked as read') + .actionKey('z') + .actionHint('Press the Control-"z" key combination to ') .action('UNDO') + .dismissHint('Activate the Escape key to dismiss this toast.') .highlightAction(true) - .highlightClass('md-accent')// Accent is used by default, this just demonstrates the usage. - .position(pinTo); + .highlightClass('md-accent') // Accent is used by default, this just demonstrates the usage. + .position(pinTo) + .hideDelay(6000); $mdToast.show(toast).then(function(response) { - if ( response == 'ok' ) { - alert('You clicked the \'UNDO\' action.'); + if (response === 'ok') { + alert('You selected the \'UNDO\' action.'); } }); }; diff --git a/src/components/toast/demoCustomUsage/index.html b/src/components/toast/demoCustomUsage/index.html index f0f76b19c78..0990c8eb9ec 100644 --- a/src/components/toast/demoCustomUsage/index.html +++ b/src/components/toast/demoCustomUsage/index.html @@ -1,13 +1,6 @@ -
-
- -

- Toast can have multiple actions: -

- - - Show Custom Toast - - -
+
+ Toast can have multiple actions: + + Show Custom Toast +
diff --git a/src/components/toast/demoCustomUsage/script.js b/src/components/toast/demoCustomUsage/script.js index 4ac7cd5a192..e2f58b9fad2 100644 --- a/src/components/toast/demoCustomUsage/script.js +++ b/src/components/toast/demoCustomUsage/script.js @@ -1,48 +1,100 @@ (function() { - var isDlgOpen; + var ACTION_RESOLVE = 'undo'; + var UNDO_KEY = 'z'; + var DIALOG_KEY = 'd'; + + angular.module('toastCustomDemo', ['ngMaterial']) + .controller('AppCtrl', AppCtrl) + .controller('ToastCtrl', ToastCtrl); + + function AppCtrl($mdToast, $log) { + var ctrl = this; + + ctrl.showCustomToast = function() { + $mdToast.show({ + hideDelay: 6000, + position: 'top right', + controller: 'ToastCtrl', + controllerAs: 'ctrl', + templateUrl: 'toast-template.html' + }).then(function(result) { + if (result === ACTION_RESOLVE) { + $log.log('Undo action triggered by button.'); + } else if (result === 'key') { + $log.log('Undo action triggered by hot key: Control-' + UNDO_KEY + '.'); + } else if (result === false) { + $log.log('Custom toast dismissed by Escape key.'); + } else { + $log.log('Custom toast hidden automatically.'); + } + }).catch(function(error) { + $log.error('Custom toast failure:', error); + }); + }; + } + + function ToastCtrl($mdToast, $mdDialog, $document) { + var ctrl = this; + ctrl.keyListenerConfigured = false; + ctrl.undoKey = UNDO_KEY; + ctrl.dialogKey = DIALOG_KEY; + setupActionKeyListener(); + + ctrl.closeToast = function() { + if (isDlgOpen) { + return; + } + + $mdToast.hide(ACTION_RESOLVE).then(function() { + isDlgOpen = false; + }); + }; + + ctrl.openMoreInfo = function(e) { + if (isDlgOpen) { + return; + } + isDlgOpen = true; + + $mdDialog.show( + $mdDialog.alert() + .title('More info goes here.') + .textContent('Something witty.') + .ariaLabel('More info') + .ok('Got it') + .targetEvent(e) + ).then(function() { + isDlgOpen = false; + }); + }; + + /** + * @param {KeyboardEvent} event + */ + function handleKeyDown(event) { + if (event.key === 'Escape') { + $mdToast.hide(false); + } + if (event.key === UNDO_KEY && event.ctrlKey) { + $mdToast.hide('key'); + } + if (event.key === DIALOG_KEY && event.ctrlKey) { + ctrl.openMoreInfo(event); + } + } + + function setupActionKeyListener() { + if (!ctrl.keyListenerConfigured) { + $document.on('keydown', handleKeyDown); + ctrl.keyListenerConfigured = true; + } + } - angular - .module('toastDemo2', ['ngMaterial']) - .controller('AppCtrl', function($scope, $mdToast) { - $scope.showCustomToast = function() { - $mdToast.show({ - hideDelay : 3000, - position : 'top right', - controller : 'ToastCtrl', - templateUrl : 'toast-template.html' - }); - }; - }) - .controller('ToastCtrl', function($scope, $mdToast, $mdDialog) { - - $scope.closeToast = function() { - if (isDlgOpen) return; - - $mdToast - .hide() - .then(function() { - isDlgOpen = false; - }); - }; - - $scope.openMoreInfo = function(e) { - if ( isDlgOpen ) return; - isDlgOpen = true; - - $mdDialog - .show($mdDialog - .alert() - .title('More info goes here.') - .textContent('Something witty.') - .ariaLabel('More info') - .ok('Got it') - .targetEvent(e) - ) - .then(function() { - isDlgOpen = false; - }); - }; - }); + function removeActionKeyListener() { + $document.off('keydown'); + ctrl.keyListenerConfigured = false; + } + } })(); diff --git a/src/components/toast/demoCustomUsage/style.scss b/src/components/toast/demoCustomUsage/style.scss new file mode 100644 index 00000000000..75131efa74d --- /dev/null +++ b/src/components/toast/demoCustomUsage/style.scss @@ -0,0 +1,9 @@ +#custom-toast-container { + height: 300px; + padding: 25px; + + .md-button.md-raised { + padding-left: 10px; + padding-right: 10px; + } +} diff --git a/src/components/toast/demoCustomUsage/toast-template.html b/src/components/toast/demoCustomUsage/toast-template.html index a729aee3074..7959e1aa748 100644 --- a/src/components/toast/demoCustomUsage/toast-template.html +++ b/src/components/toast/demoCustomUsage/toast-template.html @@ -1,9 +1,15 @@ - - Custom toast! - + + Custom toast + + Press Escape to dismiss. Press Control-"{{ctrl.dialogKey}}" for + + More info - - Close + + Press Control-"{{ctrl.undoKey}}" to + + + Undo diff --git a/src/components/toast/toast.js b/src/components/toast/toast.js index ff8006d2550..91b0e913c6a 100644 --- a/src/components/toast/toast.js +++ b/src/components/toast/toast.js @@ -156,9 +156,30 @@ function MdToastDirective($mdToast) { * `.action(string)` * * Adds an action button.
- * If clicked, the promise (returned from `show()`) - * will resolve with the value `'ok'`; otherwise, it is resolved with `true` after a `hideDelay` - * timeout + * If clicked, the promise (returned from `show()`) will resolve with the value `'ok'`; + * otherwise, it is resolved with `true` after a `hideDelay` timeout. + * + * + * + * `.actionKey(string)` + * + * Adds a hot key for the action button.
+ * If the `actionKey` and Control are pressed, the toast's action will be triggered.
+ * Defaults to the first character of the action if not defined. + * + * + * + * `.actionHint(string)` + * + * Text that a screen reader will announce to let the user know how to activate the + * action.
Defaults to: "Press Control-"`actionKey`" to " followed by the action. + * + * + * + * `.dismissHint(string)` + * + * Text that a screen reader will announce to let the user know how to dismiss the toast. + *
Defaults to: "Press Escape to dismiss." * * * @@ -247,7 +268,7 @@ function MdToastDirective($mdToast) { * be used as names of values to inject into the controller. For example, * `locals: {three: 3}` would inject `three` into the controller with the value * of 3. - * - `bindToController` - `bool`: bind the locals to the controller, instead of passing them in. + * - `bindToController` - `{boolean=}`: bind the locals to the controller, instead of passing them in. * - `resolve` - `{object=}`: Similar to locals, except it takes promises as values * and the toast will not open until the promises resolve. * - `controllerAs` - `{string=}`: An alias to assign the controller to on the scope. @@ -295,7 +316,8 @@ function MdToastDirective($mdToast) { */ function MdToastProvider($$interimElementProvider) { - // Differentiate promise resolves: hide timeout (value == true) and hide action clicks (value == ok). + // Differentiate promise resolves: hide timeout (value == true) and hide action clicks + // (value == ok). var ACTION_RESOLVE = 'ok'; var activeToastContent; @@ -306,17 +328,22 @@ function MdToastProvider($$interimElementProvider) { }) .addPreset('simple', { argOption: 'textContent', - methods: ['textContent', 'content', 'action', 'highlightAction', 'highlightClass', 'theme', 'parent' ], + methods: ['textContent', 'content', 'action', 'actionKey', 'actionHint', 'highlightAction', + 'highlightClass', 'theme', 'parent', 'dismissHint' ], options: /* @ngInject */ function($mdToast, $mdTheming) { return { template: '' + - '
' + - ' ' + + ' ' + @@ -329,6 +356,8 @@ function MdToastProvider($$interimElementProvider) { } }) .addMethod('updateTextContent', updateTextContent) + // updateContent is deprecated. Use updateTextContent instead. + // TODO remove this in 1.2. .addMethod('updateContent', updateTextContent); function updateTextContent(newContent) { @@ -354,18 +383,31 @@ function MdToastProvider($$interimElementProvider) { ]; } + // If no actionKey is defined, use the first char of the action name. + if (self.action && !self.actionKey) { + self.actionKey = self.action.charAt(0).toLocaleLowerCase(); + } + + if (self.actionKey && !self.actionHint) { + self.actionHint = 'Press Control-"' + self.actionKey + '" to '; + } + + if (!self.dismissHint) { + self.dismissHint = 'Press Escape to dismiss.'; + } + $scope.$watch(function() { return activeToastContent; }, function() { self.content = activeToastContent; }); this.resolve = function() { - $mdToast.hide( ACTION_RESOLVE ); + $mdToast.hide(ACTION_RESOLVE); }; }; } /* @ngInject */ - function toastDefaultOptions($animate, $mdToast, $mdUtil, $mdMedia) { + function toastDefaultOptions($animate, $mdToast, $mdUtil, $mdMedia, $document) { var SWIPE_EVENTS = '$md.swipeleft $md.swiperight $md.swipeup $md.swipedown'; return { onShow: onShow, @@ -409,7 +451,9 @@ function MdToastProvider($$interimElementProvider) { }; function onShow(scope, element, options) { - activeToastContent = options.textContent || options.content; // support deprecated #content method + // support deprecated #content method + // TODO remove support for content in 1.2. + activeToastContent = options.textContent || options.content; var isSmScreen = !$mdMedia('gt-sm'); @@ -423,8 +467,8 @@ function MdToastProvider($$interimElementProvider) { // If the swipe direction is down/up but the toast came from top/bottom don't fade away // Unless the screen is small, then the toast always on bottom - if ((direction === 'down' && options.position.indexOf('top') != -1 && !isSmScreen) || - (direction === 'up' && (options.position.indexOf('bottom') != -1 || isSmScreen))) { + if ((direction === 'down' && options.position.indexOf('top') !== -1 && !isSmScreen) || + (direction === 'up' && (options.position.indexOf('bottom') !== -1 || isSmScreen))) { return; } @@ -447,23 +491,31 @@ function MdToastProvider($$interimElementProvider) { options.parent.css('position', 'relative'); } + setupActionKeyListener(scope.toast ? scope.toast.actionKey : undefined); element.on(SWIPE_EVENTS, options.onSwipe); element.addClass(isSmScreen ? 'md-bottom' : options.position.split(' ').map(function(pos) { return 'md-' + pos; }).join(' ')); - if (options.parent) options.parent.addClass('md-toast-animating'); + if (options.parent) { + options.parent.addClass('md-toast-animating'); + } return $animate.enter(element, options.parent).then(function() { - if (options.parent) options.parent.removeClass('md-toast-animating'); + if (options.parent) { + options.parent.removeClass('md-toast-animating'); + } }); } function onRemove(scope, element, options) { + if (scope.toast && scope.toast.actionKey) { + removeActionKeyListener(); + } element.off(SWIPE_EVENTS, options.onSwipe); if (options.parent) options.parent.addClass('md-toast-animating'); if (options.openClass) options.parent.removeClass(options.openClass); - return ((options.$destroy == true) ? element.remove() : $animate.leave(element)) + return ((options.$destroy === true) ? element.remove() : $animate.leave(element)) .then(function () { if (options.parent) options.parent.removeClass('md-toast-animating'); if ($mdUtil.hasComputedStyle(options.parent, 'position', 'static')) { @@ -478,9 +530,26 @@ function MdToastProvider($$interimElementProvider) { return 'md-toast-open-bottom'; } - return 'md-toast-open-' + - (position.indexOf('top') > -1 ? 'top' : 'bottom'); + return 'md-toast-open-' + (position.indexOf('top') > -1 ? 'top' : 'bottom'); } - } + function setupActionKeyListener(actionKey) { + /** + * @param {KeyboardEvent} event + */ + var handleKeyDown = function(event) { + if (event.key === 'Escape') { + $mdToast.hide(false); + } + if (actionKey && event.key === actionKey && event.ctrlKey) { + $mdToast.hide(ACTION_RESOLVE); + } + }; + $document.on('keydown', handleKeyDown); + } + + function removeActionKeyListener() { + $document.off('keydown'); + } + } } diff --git a/src/components/toast/toast.spec.js b/src/components/toast/toast.spec.js index 46d8c5c0e58..bc8284f4b0e 100644 --- a/src/components/toast/toast.spec.js +++ b/src/components/toast/toast.spec.js @@ -59,7 +59,7 @@ describe('$mdToast service', function() { $material.flushOutstandingAnimations(); - expect(parent.find('span').text().trim()).toBe('Do something'); + expect(parent.find('span').text().trim()).toContain('Do something'); expect(parent.find('span')).toHaveClass('md-toast-text'); expect(parent.find('md-toast')).toHaveClass('md-capsule'); expect(parent.find('md-toast').attr('md-theme')).toBe('some-theme'); @@ -69,13 +69,13 @@ describe('$mdToast service', function() { expect(openAndclosed).toBe(true); })); - it('supports dynamicly updating the content', inject(function($mdToast, $rootScope, $rootElement) { + it('supports dynamically updating the content', inject(function($mdToast, $rootScope, $rootElement) { var parent = angular.element('
'); $mdToast.showSimple('Hello world'); $rootScope.$digest(); - $mdToast.updateContent('Goodbye world'); + $mdToast.updateTextContent('Goodbye world'); $rootScope.$digest(); - expect($rootElement.find('span').text().trim()).toBe('Goodbye world'); + expect($rootElement.find('span').text().trim()).toContain('Goodbye world'); })); it('supports an action toast', inject(function($mdToast, $rootScope, $material) {