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

Commit 7d5e262

Browse files
committed
fix(dialog): shift+tab does not cycle through tabbable elements
- don't let shift+tab get stuck on the `.md-dialog-container` element - the new behavior of shift+tab matches Angular Material and the CDK - the old behavior, of tab stopping on the `.md-dialog-container` element as part of the tabindex cycle of a dialog, remains - this differs from Angular Material and the CDK which redirects the focus after the end focus trap to the first tabbable element, rather than the container - this behavior is left in place to minimize any possible disruption caused by this fix - fix out of date/inaccurate comments Fixes #10963
1 parent 3ea5630 commit 7d5e262

File tree

2 files changed

+349
-8
lines changed

2 files changed

+349
-8
lines changed

src/components/dialog/dialog.js

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -654,8 +654,8 @@ function MdDialogProvider($$interimElementProvider) {
654654
autoWrap: true,
655655
fullscreen: false,
656656
transformTemplate: function(template, options) {
657-
// Make the dialog container focusable, because otherwise the focus will be always redirected to
658-
// an element outside of the container, and the focus trap won't work probably..
657+
// Make the dialog container focusable, because otherwise the focus will be always
658+
// redirected to an element outside of the container, and the focus trap won't work.
659659
// Also the tabindex is needed for the `escapeToClose` functionality, because
660660
// the keyDown event can't be triggered when the focus is outside of the container.
661661
var startSymbol = $interpolate.startSymbol();
@@ -1070,11 +1070,25 @@ function MdDialogProvider($$interimElementProvider) {
10701070

10711071
bottomFocusTrap = topFocusTrap.cloneNode(false);
10721072

1073-
// When focus is about to move out of the dialog, we want to intercept it and redirect it
1074-
// back to the dialog element.
1075-
var focusHandler = function() {
1076-
element.focus();
1073+
/**
1074+
* When focus is about to move out of the end of the dialog, we intercept it and redirect it
1075+
* back to the md-dialog element.
1076+
* When focus is about to move out of the start of the dialog, we intercept it and redirect it
1077+
* back to the last focusable element in the md-dialog.
1078+
* @param {FocusEvent} event
1079+
*/
1080+
var focusHandler = function(event) {
1081+
if (event.target && event.target.nextSibling &&
1082+
event.target.nextSibling.nodeName === 'MD-DIALOG') {
1083+
var lastFocusableElement = $mdUtil.getLastTabbableElement(element[0]);
1084+
if (angular.isElement(lastFocusableElement)) {
1085+
lastFocusableElement.focus();
1086+
}
1087+
} else {
1088+
element.focus();
1089+
}
10771090
};
1091+
10781092
topFocusTrap.addEventListener('focus', focusHandler);
10791093
bottomFocusTrap.addEventListener('focus', focusHandler);
10801094

@@ -1092,7 +1106,7 @@ function MdDialogProvider($$interimElementProvider) {
10921106
};
10931107

10941108
// The top focus trap inserted immediately before the md-dialog element (as a sibling).
1095-
// The bottom focus trap is inserted at the very end of the md-dialog element (as a child).
1109+
// The bottom focus trap is inserted immediately after the md-dialog element (as a sibling).
10961110
element[0].parentNode.insertBefore(topFocusTrap, element[0]);
10971111
element.after(bottomFocusTrap);
10981112
}

src/core/util/util.js

Lines changed: 328 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
* will create its own instance of this array and the app's IDs
55
* will not be unique.
66
*/
7-
var nextUniqueId = 0, isIos, isAndroid;
7+
var nextUniqueId = 0, isIos, isAndroid, isFirefox;
88

99
// Support material-tools builds.
1010
if (window.navigator) {
1111
var userAgent = window.navigator.userAgent || window.navigator.vendor || window.opera;
1212
isIos = userAgent.match(/ipad|iphone|ipod/i);
1313
isAndroid = userAgent.match(/android/i);
14+
isFirefox = userAgent.match(/(firefox|minefield)/i);
1415
}
1516

1617
/**
@@ -1028,6 +1029,332 @@ function UtilFactory($document, $timeout, $compile, $rootScope, $$mdAnimate, $in
10281029
sanitize: function(term) {
10291030
if (!term) return term;
10301031
return term.replace(/[\\^$*+?.()|{}[]/g, '\\$&');
1032+
},
1033+
1034+
/**********************************************************************************************
1035+
* The following functions were sourced from
1036+
* https://github.com/angular/components/blob/3c37e4b1c1cb74a3d0a90d173240fc730d21d9d4/src/cdk/a11y/interactivity-checker/interactivity-checker.ts
1037+
**********************************************************************************************/
1038+
1039+
/**
1040+
* Gets whether an element is disabled.
1041+
* @param {HTMLElement} element Element to be checked.
1042+
* @returns {boolean} Whether the element is disabled.
1043+
*/
1044+
isDisabled: function(element) {
1045+
// This does not capture some cases, such as a non-form control with a disabled attribute or
1046+
// a form control inside of a disabled form, but should capture the most common cases.
1047+
return element.hasAttribute('disabled');
1048+
},
1049+
1050+
/**
1051+
* Gets whether an element is visible for the purposes of interactivity.
1052+
*
1053+
* This will capture states like `display: none` and `visibility: hidden`, but not things like
1054+
* being clipped by an `overflow: hidden` parent or being outside the viewport.
1055+
*
1056+
* @param {HTMLElement} element
1057+
* @returns {boolean} Whether the element is visible.
1058+
*/
1059+
isVisible: function(element) {
1060+
return $mdUtil.hasGeometry(element) && getComputedStyle(element).visibility === 'visible';
1061+
},
1062+
1063+
/**
1064+
* Gets whether an element can be reached via Tab key.
1065+
* Assumes that the element has already been checked with isFocusable.
1066+
* @param {HTMLElement} element Element to be checked.
1067+
* @returns {boolean} Whether the element is tabbable.
1068+
*/
1069+
isTabbable: function(element) {
1070+
var frameElement = $mdUtil.getFrameElement($mdUtil.getWindow(element));
1071+
1072+
if (frameElement) {
1073+
// Frame elements inherit their tabindex onto all child elements.
1074+
if ($mdUtil.getTabIndexValue(frameElement) === -1) {
1075+
return false;
1076+
}
1077+
1078+
// Browsers disable tabbing to an element inside of an invisible frame.
1079+
if (!$mdUtil.isVisible(frameElement)) {
1080+
return false;
1081+
}
1082+
}
1083+
1084+
var nodeName = element.nodeName.toLowerCase();
1085+
var tabIndexValue = $mdUtil.getTabIndexValue(element);
1086+
1087+
if (element.hasAttribute('contenteditable')) {
1088+
return tabIndexValue !== -1;
1089+
}
1090+
1091+
if (nodeName === 'iframe' || nodeName === 'object') {
1092+
// The frame or object's content may be tabbable depending on the content, but it's
1093+
// not possibly to reliably detect the content of the frames. We always consider such
1094+
// elements as non-tabbable.
1095+
return false;
1096+
}
1097+
1098+
// In iOS, the browser only considers some specific elements as tabbable.
1099+
if (isIos && !$mdUtil.isPotentiallyTabbableIOS(element)) {
1100+
return false;
1101+
}
1102+
1103+
if (nodeName === 'audio') {
1104+
// Audio elements without controls enabled are never tabbable, regardless
1105+
// of the tabindex attribute explicitly being set.
1106+
if (!element.hasAttribute('controls')) {
1107+
return false;
1108+
}
1109+
// Audio elements with controls are by default tabbable unless the
1110+
// tabindex attribute is set to `-1` explicitly.
1111+
return tabIndexValue !== -1;
1112+
}
1113+
1114+
if (nodeName === 'video') {
1115+
// For all video elements, if the tabindex attribute is set to `-1`, the video
1116+
// is not tabbable. Note: We cannot rely on the default `HTMLElement.tabIndex`
1117+
// property as that one is set to `-1` in Chrome, Edge and Safari v13.1. The
1118+
// tabindex attribute is the source of truth here.
1119+
if (tabIndexValue === -1) {
1120+
return false;
1121+
}
1122+
// If the tabindex is explicitly set, and not `-1` (as per check before), the
1123+
// video element is always tabbable (regardless of whether it has controls or not).
1124+
if (tabIndexValue !== null) {
1125+
return true;
1126+
}
1127+
// Otherwise (when no explicit tabindex is set), a video is only tabbable if it
1128+
// has controls enabled. Firefox is special as videos are always tabbable regardless
1129+
// of whether there are controls or not.
1130+
return isFirefox || element.hasAttribute('controls');
1131+
}
1132+
1133+
return element.tabIndex >= 0;
1134+
},
1135+
1136+
/**
1137+
* Gets whether an element can be focused by the user.
1138+
* @param {HTMLElement} element Element to be checked.
1139+
* @returns {boolean} Whether the element is focusable.
1140+
*/
1141+
isFocusable: function(element) {
1142+
// Perform checks in order of left to most expensive.
1143+
// Again, naive approach that does not capture many edge cases and browser quirks.
1144+
return $mdUtil.isPotentiallyFocusable(element) && !$mdUtil.isDisabled(element) &&
1145+
$mdUtil.isVisible(element);
1146+
},
1147+
1148+
/**
1149+
* Gets whether an element is potentially focusable without taking current visible/disabled
1150+
* state into account.
1151+
* @param {HTMLElement} element
1152+
* @returns {boolean}
1153+
*/
1154+
isPotentiallyFocusable: function(element) {
1155+
// Inputs are potentially focusable *unless* they're type="hidden".
1156+
if ($mdUtil.isHiddenInput(element)) {
1157+
return false;
1158+
}
1159+
1160+
return $mdUtil.isNativeFormElement(element) ||
1161+
$mdUtil.isAnchorWithHref(element) ||
1162+
element.hasAttribute('contenteditable') ||
1163+
$mdUtil.hasValidTabIndex(element);
1164+
},
1165+
1166+
/**
1167+
* Checks whether the specified element is potentially tabbable on iOS.
1168+
* @param {HTMLElement} element
1169+
* @returns {boolean}
1170+
*/
1171+
isPotentiallyTabbableIOS: function(element) {
1172+
var nodeName = element.nodeName.toLowerCase();
1173+
var inputType = nodeName === 'input' && element.type;
1174+
1175+
return inputType === 'text'
1176+
|| inputType === 'password'
1177+
|| nodeName === 'select'
1178+
|| nodeName === 'textarea';
1179+
},
1180+
1181+
/**
1182+
* Returns the parsed tabindex from the element attributes instead of returning the
1183+
* evaluated tabindex from the browsers defaults.
1184+
* @param {HTMLElement} element
1185+
* @returns {null|number}
1186+
*/
1187+
getTabIndexValue: function(element) {
1188+
if (!$mdUtil.hasValidTabIndex(element)) {
1189+
return null;
1190+
}
1191+
1192+
// See browser issue in Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=1128054
1193+
var tabIndex = parseInt(element.getAttribute('tabindex') || '', 10);
1194+
1195+
return isNaN(tabIndex) ? -1 : tabIndex;
1196+
},
1197+
1198+
/**
1199+
* Gets whether an element has a valid tabindex.
1200+
* @param {HTMLElement} element
1201+
* @returns {boolean}
1202+
*/
1203+
hasValidTabIndex: function(element) {
1204+
if (!element.hasAttribute('tabindex') || element.tabIndex === undefined) {
1205+
return false;
1206+
}
1207+
1208+
var tabIndex = element.getAttribute('tabindex');
1209+
1210+
// IE11 parses tabindex="" as the value "-32768"
1211+
if (tabIndex == '-32768') {
1212+
return false;
1213+
}
1214+
1215+
return !!(tabIndex && !isNaN(parseInt(tabIndex, 10)));
1216+
},
1217+
1218+
/**
1219+
* Checks whether the specified element has any geometry / rectangles.
1220+
* @param {HTMLElement} element
1221+
* @returns {boolean}
1222+
*/
1223+
hasGeometry: function(element) {
1224+
// Use logic from jQuery to check for an invisible element.
1225+
// See https://github.com/jquery/jquery/blob/8969732518470a7f8e654d5bc5be0b0076cb0b87/src/css/hiddenVisibleSelectors.js#L9
1226+
return !!(element.offsetWidth || element.offsetHeight ||
1227+
(typeof element.getClientRects === 'function' && element.getClientRects().length));
1228+
},
1229+
1230+
/**
1231+
* Returns the frame element from a window object. Since browsers like MS Edge throw errors if
1232+
* the frameElement property is being accessed from a different host address, this property
1233+
* should be accessed carefully.
1234+
* @param {Window} window
1235+
* @returns {null|HTMLElement}
1236+
*/
1237+
getFrameElement: function(window) {
1238+
try {
1239+
return window.frameElement;
1240+
} catch (error) {
1241+
return null;
1242+
}
1243+
},
1244+
1245+
/**
1246+
* Gets the parent window of a DOM node with regards of being inside of an iframe.
1247+
* @param {HTMLElement} node
1248+
* @returns {Window}
1249+
*/
1250+
getWindow: function(node) {
1251+
// ownerDocument is null if `node` itself *is* a document.
1252+
return node.ownerDocument && node.ownerDocument.defaultView || window;
1253+
},
1254+
1255+
/**
1256+
* Gets whether an element's
1257+
* @param {Node} element
1258+
* @returns {boolean}
1259+
*/
1260+
isNativeFormElement: function(element) {
1261+
var nodeName = element.nodeName.toLowerCase();
1262+
return nodeName === 'input' ||
1263+
nodeName === 'select' ||
1264+
nodeName === 'button' ||
1265+
nodeName === 'textarea';
1266+
},
1267+
1268+
/**
1269+
* Gets whether an element is an `<input type="hidden">`.
1270+
* @param {HTMLElement} element
1271+
* @returns {boolean}
1272+
*/
1273+
isHiddenInput: function(element) {
1274+
return $mdUtil.isInputElement(element) && element.type == 'hidden';
1275+
},
1276+
1277+
/**
1278+
* Gets whether an element is an anchor that has an href attribute.
1279+
* @param {HTMLElement} element
1280+
* @returns {boolean}
1281+
*/
1282+
isAnchorWithHref: function(element) {
1283+
return $mdUtil.isAnchorElement(element) && element.hasAttribute('href');
1284+
},
1285+
1286+
/**
1287+
* Gets whether an element is an input element.
1288+
* @param {HTMLElement} element
1289+
* @returns {boolean}
1290+
*/
1291+
isInputElement: function(element) {
1292+
return element.nodeName.toLowerCase() == 'input';
1293+
},
1294+
1295+
/**
1296+
* Gets whether an element is an anchor element.
1297+
* @param {HTMLElement} element
1298+
* @returns {boolean}
1299+
*/
1300+
isAnchorElement: function(element) {
1301+
return element.nodeName.toLowerCase() == 'a';
1302+
},
1303+
1304+
/**********************************************************************************************
1305+
* The following two functions were sourced from
1306+
* https://github.com/angular/components/blob/3c37e4b1c1cb74a3d0a90d173240fc730d21d9d4/src/cdk/a11y/focus-trap/focus-trap.ts#L268-L311
1307+
**********************************************************************************************/
1308+
1309+
/**
1310+
* Get the first tabbable element from a DOM subtree (inclusive).
1311+
* @param {HTMLElement} root
1312+
* @returns {HTMLElement|null}
1313+
*/
1314+
getFirstTabbableElement: function(root) {
1315+
if ($mdUtil.isFocusable(root) && $mdUtil.isTabbable(root)) {
1316+
return root;
1317+
}
1318+
1319+
// Iterate in DOM order. Note that IE doesn't have `children` for SVG so we fall
1320+
// back to `childNodes` which includes text nodes, comments etc.
1321+
var children = root.children || root.childNodes;
1322+
1323+
for (var i = 0; i < children.length; i++) {
1324+
var tabbableChild = children[i].nodeType === $document[0].ELEMENT_NODE ?
1325+
$mdUtil.getFirstTabbableElement(children[i]) : null;
1326+
1327+
if (tabbableChild) {
1328+
return tabbableChild;
1329+
}
1330+
}
1331+
1332+
return null;
1333+
},
1334+
1335+
/**
1336+
* Get the last tabbable element from a DOM subtree (inclusive).
1337+
* @param {HTMLElement} root
1338+
* @returns {HTMLElement|null}
1339+
*/
1340+
getLastTabbableElement: function(root) {
1341+
if ($mdUtil.isFocusable(root) && $mdUtil.isTabbable(root)) {
1342+
return root;
1343+
}
1344+
1345+
// Iterate in reverse DOM order.
1346+
var children = root.children || root.childNodes;
1347+
1348+
for (var i = children.length - 1; i >= 0; i--) {
1349+
var tabbableChild = children[i].nodeType === $document[0].ELEMENT_NODE ?
1350+
$mdUtil.getLastTabbableElement(children[i]) : null;
1351+
1352+
if (tabbableChild) {
1353+
return tabbableChild;
1354+
}
1355+
}
1356+
1357+
return null;
10311358
}
10321359
};
10331360

0 commit comments

Comments
 (0)