From 187226ebf20856e2205e8204724389ce6952bc86 Mon Sep 17 00:00:00 2001 From: bjarkler Date: Thu, 28 Oct 2021 19:06:08 +0000 Subject: [PATCH 1/2] fix(all): fix Trusted Types violations during initialization (#12128) Currently, when Angular Material is loaded in an application that has Trusted Types enforced, two violations occur. Both violations are caused when a plain string is passed to angular.element since there is no guarantee that the string was not derived from user input, which could cause XSS. It should be noted that, in this case, neither call to angular.element represents a security vulnerability, but this blocks Trusted Types adoption in applications that load Angular Material. To fix the violations, refactor the calls to angular.element to use safe DOM operations instead. This change does not alter any functionality and is fully backwards compatible. (cherry picked from commit 4e354a6ef15362920f23167194e52fb40cb70ea9) --- src/components/panel/panel.js | 9 +++++++-- src/components/select/select.js | 10 ++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/panel/panel.js b/src/components/panel/panel.js index 10bd6291b7..7ffce6a7dd 100644 --- a/src/components/panel/panel.js +++ b/src/components/panel/panel.js @@ -933,8 +933,7 @@ angular var MD_PANEL_Z_INDEX = 80; var MD_PANEL_HIDDEN = '_md-panel-hidden'; -var FOCUS_TRAP_TEMPLATE = angular.element( - '
'); +var FOCUS_TRAP_TEMPLATE; var _presets = {}; @@ -2232,6 +2231,12 @@ MdPanelRef.prototype._configureTrapFocus = function() { var element = this.panelEl; // Set up elements before and after the panel to capture focus and // redirect back into the panel. + if (!FOCUS_TRAP_TEMPLATE) { + var template = document.createElement('div'); + template.className = '_md-panel-focus-trap'; + template.tabIndex = 0; + FOCUS_TRAP_TEMPLATE = angular.element(template); + } this._topFocusTrap = FOCUS_TRAP_TEMPLATE.clone()[0]; this._bottomFocusTrap = FOCUS_TRAP_TEMPLATE.clone()[0]; diff --git a/src/components/select/select.js b/src/components/select/select.js index 1675cd6c9b..7536c99f49 100755 --- a/src/components/select/select.js +++ b/src/components/select/select.js @@ -12,8 +12,7 @@ var SELECT_EDGE_MARGIN = 8; var selectNextId = 0; -var CHECKBOX_SELECTION_INDICATOR = - angular.element('
'); +var CHECKBOX_SELECTION_INDICATOR; angular.module('material.components.select', [ 'material.core', @@ -1017,6 +1016,13 @@ function OptionDirective($mdButtonInkRipple, $mdUtil, $mdTheming) { if (selectCtrl.isMultiple) { element.addClass('md-checkbox-enabled'); + if (!CHECKBOX_SELECTION_INDICATOR) { + var indicator = document.createElement('div'); + indicator.className = 'md-container'; + indicator.appendChild(document.createElement('div')); + indicator.firstChild.className = 'md-icon'; + CHECKBOX_SELECTION_INDICATOR = angular.element(indicator); + } element.prepend(CHECKBOX_SELECTION_INDICATOR.clone()); } From 1bb6fbb3ce675c5cce01c8e3495d0bbb8df54d8c Mon Sep 17 00:00:00 2001 From: Michael Prentice Date: Fri, 16 Aug 2019 19:17:48 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(autocomplete):=20improve=20implementati?= =?UTF-8?q?on=20of=20aria-activedescen=E2=80=A6=20(#11743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allow screen readers to do more and us to do less - remove extra calls to announce the item that is visually focused - remove tests for these extra live announcements - give every option an id for use with `aria-activedescendant` - use the `selected` class for styling and finding the active option - implement recommendations from a11y guides - add the clear button to the tab order - change input type to `text` - always define a `name` attribute - when the popup isn't expanded - `aria-owns` and `aria-activedescendant` shouldn't be defined - when the autocomplete is disabled - `aria-autocomplete` and `aria-role` shouldn't be defined - `aria-haspopup` should be false - add md-autocomplete-suggestion class for styling instead of using `li` - add `md-autoselect` to the dialog demo for help w/ manual testing - remove overly verbose `aria-describedby` from basic demo - mark `md-icons` in `md-item-templates` of autocomplete demos as hidden - update demos to use `md-escape-options="clear"` for better a11y Fixes #11742 (cherry picked from commit 8c159aa43344cf464e544d0c23a03d3d9e001896) --- .../autocomplete/autocomplete-theme.scss | 4 +- src/components/autocomplete/autocomplete.scss | 4 +- .../autocomplete/autocomplete.spec.js | 426 ++++++++++++++++-- .../autocomplete/demoBasicUsage/index.html | 4 +- .../demoCustomTemplate/index.html | 3 +- .../demoCustomTemplate/style.global.css | 4 +- .../autocomplete/demoFloatingLabel/index.html | 23 +- .../demoInsideDialog/dialog.tmpl.html | 3 +- .../autocomplete/demoRepeatMode/index.html | 6 +- .../demoRepeatMode/style.global.css | 8 +- .../autocomplete/js/autocompleteController.js | 75 +-- .../autocomplete/js/autocompleteDirective.js | 38 +- src/core/util/util.js | 4 +- test/angular-material-mocks.js | 2 +- 14 files changed, 494 insertions(+), 110 deletions(-) diff --git a/src/components/autocomplete/autocomplete-theme.scss b/src/components/autocomplete/autocomplete-theme.scss index 03585333e0..aece493afd 100644 --- a/src/components/autocomplete/autocomplete-theme.scss +++ b/src/components/autocomplete/autocomplete-theme.scss @@ -49,10 +49,10 @@ md-autocomplete.md-THEME_NAME-theme { .md-autocomplete-suggestions-container.md-THEME_NAME-theme, .md-autocomplete-standard-list-container.md-THEME_NAME-theme { background: '{{background-hue-1}}'; - li { + .md-autocomplete-suggestion { color: '{{foreground-1}}'; &:hover, - &#selected_option { + &.selected { background: '{{background-500-0.18}}'; } } diff --git a/src/components/autocomplete/autocomplete.scss b/src/components/autocomplete/autocomplete.scss index b9302f4d3c..ddc9127449 100644 --- a/src/components/autocomplete/autocomplete.scss +++ b/src/components/autocomplete/autocomplete.scss @@ -174,7 +174,7 @@ md-autocomplete { input { border: 1px solid $border-color; } - li:focus { + .md-autocomplete-suggestion:focus { color: #fff; } } @@ -214,7 +214,7 @@ md-autocomplete { list-style: none; padding: 0; - li { + .md-autocomplete-suggestion { font-size: 14px; overflow: hidden; padding: 0 15px; diff --git a/src/components/autocomplete/autocomplete.spec.js b/src/components/autocomplete/autocomplete.spec.js index 6ef3052c5a..c97a873b32 100644 --- a/src/components/autocomplete/autocomplete.spec.js +++ b/src/components/autocomplete/autocomplete.spec.js @@ -1769,10 +1769,12 @@ describe('', function() { }); describe('Accessibility', function() { - var $timeout = null; + var $timeout = null, $mdConstant = null, $material = null; beforeEach(inject(function ($injector) { $timeout = $injector.get('$timeout'); + $material = $injector.get('$material'); + $mdConstant = $injector.get('$mdConstant'); })); it('should add the placeholder as the input\'s aria-label', function() { @@ -1795,6 +1797,393 @@ describe('', function() { expect(input.attr('aria-label')).toBe('placeholder'); }); + it('should set activeOption when autoselect is off', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).not.toContain('selected'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[0].classList).toContain('selected'); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(false); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + }); + + it('should start from the end when up arrow is pressed', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).not.toContain('selected'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.UP_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[1].classList).toContain('selected'); + + expect(ctrl.index).toBe(1); + expect(ctrl.hidden).toBe(false); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-1'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-1'); + }); + + it('should set activeOption when autoselect is on', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + // Wait for the next tick when the values will be updated + $timeout.flush(); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).toContain('selected'); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[1].classList).toContain('selected'); + + expect(ctrl.index).toBe(1); + expect(ctrl.hidden).toBe(false); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-1'); + }); + + it('should update activeOption when selection is cleared and autoselect is off', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + // Wait for the next tick when the values will be updated + $timeout.flush(); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).not.toContain('selected'); + expect(ctrl.activeOption).toBe(null); + expect(ctrl.hidden).toBe(false); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); + $material.flushInterimElement(); + + expect(suggestions[0].classList).toContain('selected'); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER)); + $material.flushInterimElement(); + expect(ctrl.hidden).toBe(true); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE)); + $timeout.flush(); + + expect(ctrl.index).toBe(-1); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + }); + + it('should update activeOption when selection is cleared and autoselect is on', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'ba'; + waitForVirtualRepeat(element); + + // Wait for the next tick when the values will be updated + $timeout.flush(); + + var suggestions = ul.find('li'); + expect(suggestions[0].classList).toContain('selected'); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe('ul-' + ctrl.id); + expect(input[0].getAttribute('aria-activedescendant')).toBe('md-option-' + ctrl.id + '-0'); + + expect(ctrl.hidden).toBe(false); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER)); + $material.flushInterimElement(); + expect(ctrl.hidden).toBe(true); + + ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ESCAPE)); + $timeout.flush(); + + expect(ctrl.index).toBe(0); + expect(ctrl.hidden).toBe(true); + expect(ctrl.activeOption).toBe('md-option-' + ctrl.id + '-0'); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-activedescendant')).toBe(null); + }); + + it('should always define the name attribute on the input', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('name')).toBe('input-' + ctrl.id); + }); + + it('should always define the name attribute on the input when floating label is enabled', + function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('name')).toBe('fl-input-' + ctrl.id); + }); + + it('should set proper aria values and remove attributes on input when ng-disabled', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('role')).toBe(null); + expect(input[0].getAttribute('aria-autocomplete')).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-haspopup')).toBe('false'); + }); + + it('should set proper aria values and remove attributes on input when disabled', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var input = element.find('input'); + // Run our initial flush + $timeout.flush(); + + expect(input[0].getAttribute('role')).toBe(null); + expect(input[0].getAttribute('aria-autocomplete')).toBe(null); + expect(input[0].getAttribute('aria-owns')).toBe(null); + expect(input[0].getAttribute('aria-haspopup')).toBe('false'); + }); + + it('should add IDs to each option', function() { + var template = + '' + + ' {{item.display}}' + + ''; + var scope = createScope(); + var element = compile(template, scope); + var ctrl = element.controller('mdAutocomplete'); + var ul = element.find('ul'); + + // Focus the input + ctrl.focus(); + + // Update the scope + element.scope().searchText = 'fo'; + waitForVirtualRepeat(element); + + var suggestions = ul.find('li'); + + expect(suggestions[0].getAttribute('id')).toContain('md-option-'); + }); + it('should add the input-aria-label as the input\'s aria-label', function() { var template = '', function() { expect(liveEl.textContent).toBe(scope.items[0].display + ' There are 3 matches available.'); }); - it('should announce the selection when using the arrow keys', function() { + it('should announce when an option is picked', function() { ctrl.focus(); waitForVirtualRepeat(); expect(ctrl.hidden).toBe(false); ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); - - // Flush twice, because the display value will be resolved asynchronously and then the live-announcer will - // be triggered. - $timeout.flush(); $timeout.flush(); expect(ctrl.index).toBe(0); - expect(liveEl.textContent).toBe(scope.items[0].display); - - ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); - - // Flush twice, because the display value will be resolved asynchronously and then the - // live-announcer will be triggered. - $timeout.flush(); - $timeout.flush(); - - expect(ctrl.index).toBe(1); - expect(liveEl.textContent).toBe(scope.items[1].display); - }); - - it('should announce when an option is selected', function() { - ctrl.focus(); - waitForVirtualRepeat(); - expect(ctrl.hidden).toBe(false); - ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.DOWN_ARROW)); - - // Flush twice, because the display value will be resolved asynchronously and then the - // live-announcer will be triggered. - $timeout.flush(); - $timeout.flush(); - - expect(ctrl.index).toBe(0); - expect(liveEl.textContent).toBe(scope.items[0].display); - ctrl.keydown(keydownEvent($mdConstant.KEY_CODE.ENTER)); // Flush twice, because the display value will be resolved asynchronously and then the @@ -2033,6 +2391,7 @@ describe('', function() { $timeout.flush(); expect(liveEl.textContent).toBe(scope.items[0].display + ' ' + ctrl.selectedMessage); + expect(ctrl.hidden).toBe(true); }); it('should announce the count when matches change', function() { @@ -2133,6 +2492,7 @@ describe('', function() { element.remove(); })); + it('passes the value to the item watcher', inject(function($timeout) { var scope = createScope(); var itemValue = null; diff --git a/src/components/autocomplete/demoBasicUsage/index.html b/src/components/autocomplete/demoBasicUsage/index.html index e5ac536ef2..8bb8cd24d2 100644 --- a/src/components/autocomplete/demoBasicUsage/index.html +++ b/src/components/autocomplete/demoBasicUsage/index.html @@ -15,9 +15,9 @@ md-items="item in ctrl.querySearch(ctrl.searchText)" md-item-text="item.display" md-min-length="0" + md-escape-options="clear" placeholder="Ex. Alaska" - input-aria-labelledby="favoriteStateLabel" - input-aria-describedby="autocompleteDetailedDescription"> + input-aria-labelledby="favoriteStateLabel"> {{item.display}} diff --git a/src/components/autocomplete/demoCustomTemplate/index.html b/src/components/autocomplete/demoCustomTemplate/index.html index 0074a57a81..1540796902 100644 --- a/src/components/autocomplete/demoCustomTemplate/index.html +++ b/src/components/autocomplete/demoCustomTemplate/index.html @@ -13,13 +13,14 @@ md-items="item in ctrl.querySearch(ctrl.searchText)" md-item-text="item.name" md-min-length="0" + md-escape-options="clear" input-aria-label="Current Repository" placeholder="Pick an Angular repository" md-menu-class="autocomplete-custom-template" md-menu-container-class="custom-container"> - + {{item.name}}