diff --git a/src/components/dialog/dialog.js b/src/components/dialog/dialog.js index 0e2edc46d26..a349245a89f 100644 --- a/src/components/dialog/dialog.js +++ b/src/components/dialog/dialog.js @@ -654,8 +654,8 @@ function MdDialogProvider($$interimElementProvider) { autoWrap: true, fullscreen: false, transformTemplate: function(template, options) { - // Make the dialog container focusable, because otherwise the focus will be always redirected to - // an element outside of the container, and the focus trap won't work probably.. + // Make the dialog container focusable, because otherwise the focus will be always + // redirected to an element outside of the container, and the focus trap won't work. // Also the tabindex is needed for the `escapeToClose` functionality, because // the keyDown event can't be triggered when the focus is outside of the container. var startSymbol = $interpolate.startSymbol(); @@ -1070,11 +1070,25 @@ function MdDialogProvider($$interimElementProvider) { bottomFocusTrap = topFocusTrap.cloneNode(false); - // When focus is about to move out of the dialog, we want to intercept it and redirect it - // back to the dialog element. - var focusHandler = function() { - element.focus(); + /** + * When focus is about to move out of the end of the dialog, we intercept it and redirect it + * back to the md-dialog element. + * When focus is about to move out of the start of the dialog, we intercept it and redirect it + * back to the last focusable element in the md-dialog. + * @param {FocusEvent} event + */ + var focusHandler = function(event) { + if (event.target && event.target.nextSibling && + event.target.nextSibling.nodeName === 'MD-DIALOG') { + var lastFocusableElement = $mdUtil.getLastTabbableElement(element[0]); + if (angular.isElement(lastFocusableElement)) { + lastFocusableElement.focus(); + } + } else { + element.focus(); + } }; + topFocusTrap.addEventListener('focus', focusHandler); bottomFocusTrap.addEventListener('focus', focusHandler); @@ -1092,7 +1106,7 @@ function MdDialogProvider($$interimElementProvider) { }; // The top focus trap inserted immediately before the md-dialog element (as a sibling). - // The bottom focus trap is inserted at the very end of the md-dialog element (as a child). + // The bottom focus trap is inserted immediately after the md-dialog element (as a sibling). element[0].parentNode.insertBefore(topFocusTrap, element[0]); element.after(bottomFocusTrap); } diff --git a/src/core/util/util.js b/src/core/util/util.js index bfe11644442..b3b24f8d088 100644 --- a/src/core/util/util.js +++ b/src/core/util/util.js @@ -4,13 +4,14 @@ * will create its own instance of this array and the app's IDs * will not be unique. */ -var nextUniqueId = 0, isIos, isAndroid; +var nextUniqueId = 0, isIos, isAndroid, isFirefox; // Support material-tools builds. if (window.navigator) { var userAgent = window.navigator.userAgent || window.navigator.vendor || window.opera; isIos = userAgent.match(/ipad|iphone|ipod/i); isAndroid = userAgent.match(/android/i); + isFirefox = userAgent.match(/(firefox|minefield)/i); } /** @@ -1028,6 +1029,332 @@ function UtilFactory($document, $timeout, $compile, $rootScope, $$mdAnimate, $in sanitize: function(term) { if (!term) return term; return term.replace(/[\\^$*+?.()|{}[]/g, '\\$&'); + }, + + /********************************************************************************************** + * The following functions were sourced from + * https://github.com/angular/components/blob/3c37e4b1c1cb74a3d0a90d173240fc730d21d9d4/src/cdk/a11y/interactivity-checker/interactivity-checker.ts + **********************************************************************************************/ + + /** + * Gets whether an element is disabled. + * @param {HTMLElement} element Element to be checked. + * @returns {boolean} Whether the element is disabled. + */ + isDisabled: function(element) { + // This does not capture some cases, such as a non-form control with a disabled attribute or + // a form control inside of a disabled form, but should capture the most common cases. + return element.hasAttribute('disabled'); + }, + + /** + * Gets whether an element is visible for the purposes of interactivity. + * + * This will capture states like `display: none` and `visibility: hidden`, but not things like + * being clipped by an `overflow: hidden` parent or being outside the viewport. + * + * @param {HTMLElement} element + * @returns {boolean} Whether the element is visible. + */ + isVisible: function(element) { + return $mdUtil.hasGeometry(element) && getComputedStyle(element).visibility === 'visible'; + }, + + /** + * Gets whether an element can be reached via Tab key. + * Assumes that the element has already been checked with isFocusable. + * @param {HTMLElement} element Element to be checked. + * @returns {boolean} Whether the element is tabbable. + */ + isTabbable: function(element) { + var frameElement = $mdUtil.getFrameElement($mdUtil.getWindow(element)); + + if (frameElement) { + // Frame elements inherit their tabindex onto all child elements. + if ($mdUtil.getTabIndexValue(frameElement) === -1) { + return false; + } + + // Browsers disable tabbing to an element inside of an invisible frame. + if (!$mdUtil.isVisible(frameElement)) { + return false; + } + } + + var nodeName = element.nodeName.toLowerCase(); + var tabIndexValue = $mdUtil.getTabIndexValue(element); + + if (element.hasAttribute('contenteditable')) { + return tabIndexValue !== -1; + } + + if (nodeName === 'iframe' || nodeName === 'object') { + // The frame or object's content may be tabbable depending on the content, but it's + // not possibly to reliably detect the content of the frames. We always consider such + // elements as non-tabbable. + return false; + } + + // In iOS, the browser only considers some specific elements as tabbable. + if (isIos && !$mdUtil.isPotentiallyTabbableIOS(element)) { + return false; + } + + if (nodeName === 'audio') { + // Audio elements without controls enabled are never tabbable, regardless + // of the tabindex attribute explicitly being set. + if (!element.hasAttribute('controls')) { + return false; + } + // Audio elements with controls are by default tabbable unless the + // tabindex attribute is set to `-1` explicitly. + return tabIndexValue !== -1; + } + + if (nodeName === 'video') { + // For all video elements, if the tabindex attribute is set to `-1`, the video + // is not tabbable. Note: We cannot rely on the default `HTMLElement.tabIndex` + // property as that one is set to `-1` in Chrome, Edge and Safari v13.1. The + // tabindex attribute is the source of truth here. + if (tabIndexValue === -1) { + return false; + } + // If the tabindex is explicitly set, and not `-1` (as per check before), the + // video element is always tabbable (regardless of whether it has controls or not). + if (tabIndexValue !== null) { + return true; + } + // Otherwise (when no explicit tabindex is set), a video is only tabbable if it + // has controls enabled. Firefox is special as videos are always tabbable regardless + // of whether there are controls or not. + return isFirefox || element.hasAttribute('controls'); + } + + return element.tabIndex >= 0; + }, + + /** + * Gets whether an element can be focused by the user. + * @param {HTMLElement} element Element to be checked. + * @returns {boolean} Whether the element is focusable. + */ + isFocusable: function(element) { + // Perform checks in order of left to most expensive. + // Again, naive approach that does not capture many edge cases and browser quirks. + return $mdUtil.isPotentiallyFocusable(element) && !$mdUtil.isDisabled(element) && + $mdUtil.isVisible(element); + }, + + /** + * Gets whether an element is potentially focusable without taking current visible/disabled + * state into account. + * @param {HTMLElement} element + * @returns {boolean} + */ + isPotentiallyFocusable: function(element) { + // Inputs are potentially focusable *unless* they're type="hidden". + if ($mdUtil.isHiddenInput(element)) { + return false; + } + + return $mdUtil.isNativeFormElement(element) || + $mdUtil.isAnchorWithHref(element) || + element.hasAttribute('contenteditable') || + $mdUtil.hasValidTabIndex(element); + }, + + /** + * Checks whether the specified element is potentially tabbable on iOS. + * @param {HTMLElement} element + * @returns {boolean} + */ + isPotentiallyTabbableIOS: function(element) { + var nodeName = element.nodeName.toLowerCase(); + var inputType = nodeName === 'input' && element.type; + + return inputType === 'text' + || inputType === 'password' + || nodeName === 'select' + || nodeName === 'textarea'; + }, + + /** + * Returns the parsed tabindex from the element attributes instead of returning the + * evaluated tabindex from the browsers defaults. + * @param {HTMLElement} element + * @returns {null|number} + */ + getTabIndexValue: function(element) { + if (!$mdUtil.hasValidTabIndex(element)) { + return null; + } + + // See browser issue in Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=1128054 + var tabIndex = parseInt(element.getAttribute('tabindex') || '', 10); + + return isNaN(tabIndex) ? -1 : tabIndex; + }, + + /** + * Gets whether an element has a valid tabindex. + * @param {HTMLElement} element + * @returns {boolean} + */ + hasValidTabIndex: function(element) { + if (!element.hasAttribute('tabindex') || element.tabIndex === undefined) { + return false; + } + + var tabIndex = element.getAttribute('tabindex'); + + // IE11 parses tabindex="" as the value "-32768" + if (tabIndex == '-32768') { + return false; + } + + return !!(tabIndex && !isNaN(parseInt(tabIndex, 10))); + }, + + /** + * Checks whether the specified element has any geometry / rectangles. + * @param {HTMLElement} element + * @returns {boolean} + */ + hasGeometry: function(element) { + // Use logic from jQuery to check for an invisible element. + // See https://github.com/jquery/jquery/blob/8969732518470a7f8e654d5bc5be0b0076cb0b87/src/css/hiddenVisibleSelectors.js#L9 + return !!(element.offsetWidth || element.offsetHeight || + (typeof element.getClientRects === 'function' && element.getClientRects().length)); + }, + + /** + * Returns the frame element from a window object. Since browsers like MS Edge throw errors if + * the frameElement property is being accessed from a different host address, this property + * should be accessed carefully. + * @param {Window} window + * @returns {null|HTMLElement} + */ + getFrameElement: function(window) { + try { + return window.frameElement; + } catch (error) { + return null; + } + }, + + /** + * Gets the parent window of a DOM node with regards of being inside of an iframe. + * @param {HTMLElement} node + * @returns {Window} + */ + getWindow: function(node) { + // ownerDocument is null if `node` itself *is* a document. + return node.ownerDocument && node.ownerDocument.defaultView || window; + }, + + /** + * Gets whether an element's + * @param {Node} element + * @returns {boolean} + */ + isNativeFormElement: function(element) { + var nodeName = element.nodeName.toLowerCase(); + return nodeName === 'input' || + nodeName === 'select' || + nodeName === 'button' || + nodeName === 'textarea'; + }, + + /** + * Gets whether an element is an ``. + * @param {HTMLElement} element + * @returns {boolean} + */ + isHiddenInput: function(element) { + return $mdUtil.isInputElement(element) && element.type == 'hidden'; + }, + + /** + * Gets whether an element is an anchor that has an href attribute. + * @param {HTMLElement} element + * @returns {boolean} + */ + isAnchorWithHref: function(element) { + return $mdUtil.isAnchorElement(element) && element.hasAttribute('href'); + }, + + /** + * Gets whether an element is an input element. + * @param {HTMLElement} element + * @returns {boolean} + */ + isInputElement: function(element) { + return element.nodeName.toLowerCase() == 'input'; + }, + + /** + * Gets whether an element is an anchor element. + * @param {HTMLElement} element + * @returns {boolean} + */ + isAnchorElement: function(element) { + return element.nodeName.toLowerCase() == 'a'; + }, + + /********************************************************************************************** + * The following two functions were sourced from + * https://github.com/angular/components/blob/3c37e4b1c1cb74a3d0a90d173240fc730d21d9d4/src/cdk/a11y/focus-trap/focus-trap.ts#L268-L311 + **********************************************************************************************/ + + /** + * Get the first tabbable element from a DOM subtree (inclusive). + * @param {HTMLElement} root + * @returns {HTMLElement|null} + */ + getFirstTabbableElement: function(root) { + if ($mdUtil.isFocusable(root) && $mdUtil.isTabbable(root)) { + return root; + } + + // Iterate in DOM order. Note that IE doesn't have `children` for SVG so we fall + // back to `childNodes` which includes text nodes, comments etc. + var children = root.children || root.childNodes; + + for (var i = 0; i < children.length; i++) { + var tabbableChild = children[i].nodeType === $document[0].ELEMENT_NODE ? + $mdUtil.getFirstTabbableElement(children[i]) : null; + + if (tabbableChild) { + return tabbableChild; + } + } + + return null; + }, + + /** + * Get the last tabbable element from a DOM subtree (inclusive). + * @param {HTMLElement} root + * @returns {HTMLElement|null} + */ + getLastTabbableElement: function(root) { + if ($mdUtil.isFocusable(root) && $mdUtil.isTabbable(root)) { + return root; + } + + // Iterate in reverse DOM order. + var children = root.children || root.childNodes; + + for (var i = children.length - 1; i >= 0; i--) { + var tabbableChild = children[i].nodeType === $document[0].ELEMENT_NODE ? + $mdUtil.getLastTabbableElement(children[i]) : null; + + if (tabbableChild) { + return tabbableChild; + } + } + + return null; } };