Skip to content
This repository was archived by the owner on Sep 5, 2024. It is now read-only.

fix(dialog): shift+tab does not cycle through tabbable elements #12061

Merged
merged 1 commit into from
Dec 16, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions src/components/dialog/dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand All @@ -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);
}
Expand Down
329 changes: 328 additions & 1 deletion src/core/util/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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 `<input type="hidden">`.
* @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;
}
};

Expand Down