952 lines
42 KiB
JavaScript
Executable File
952 lines
42 KiB
JavaScript
Executable File
import * as i0 from '@angular/core';
|
|
import { inject, Injectable, afterNextRender, NgZone, DOCUMENT, Injector, ElementRef, booleanAttribute, Directive, Input, InjectionToken, NgModule } from '@angular/core';
|
|
import { CdkMonitorFocus } from './focus-monitor.mjs';
|
|
import { Platform } from './platform2.mjs';
|
|
import { _getFocusedElementPierceShadowDom } from './shadow-dom.mjs';
|
|
import { _CdkPrivateStyleLoader } from './style-loader.mjs';
|
|
import { _VisuallyHiddenLoader } from './private.mjs';
|
|
import { BreakpointObserver } from './breakpoints-observer.mjs';
|
|
import { ContentObserver, ObserversModule } from './observers.mjs';
|
|
|
|
/**
|
|
* Configuration for the isFocusable method.
|
|
*/
|
|
class IsFocusableConfig {
|
|
/**
|
|
* Whether to count an element as focusable even if it is not currently visible.
|
|
*/
|
|
ignoreVisibility = false;
|
|
}
|
|
// The InteractivityChecker leans heavily on the ally.js accessibility utilities.
|
|
// Methods like `isTabbable` are only covering specific edge-cases for the browsers which are
|
|
// supported.
|
|
/**
|
|
* Utility for checking the interactivity of an element, such as whether it is focusable or
|
|
* tabbable.
|
|
*/
|
|
class InteractivityChecker {
|
|
_platform = inject(Platform);
|
|
constructor() { }
|
|
/**
|
|
* Gets whether an element is disabled.
|
|
*
|
|
* @param element Element to be checked.
|
|
* @returns Whether the element is disabled.
|
|
*/
|
|
isDisabled(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.
|
|
*
|
|
* @returns Whether the element is visible.
|
|
*/
|
|
isVisible(element) {
|
|
return 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 element Element to be checked.
|
|
* @returns Whether the element is tabbable.
|
|
*/
|
|
isTabbable(element) {
|
|
// Nothing is tabbable on the server 😎
|
|
if (!this._platform.isBrowser) {
|
|
return false;
|
|
}
|
|
const frameElement = getFrameElement(getWindow(element));
|
|
if (frameElement) {
|
|
// Frame elements inherit their tabindex onto all child elements.
|
|
if (getTabIndexValue(frameElement) === -1) {
|
|
return false;
|
|
}
|
|
// Browsers disable tabbing to an element inside of an invisible frame.
|
|
if (!this.isVisible(frameElement)) {
|
|
return false;
|
|
}
|
|
}
|
|
let nodeName = element.nodeName.toLowerCase();
|
|
let tabIndexValue = 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 (this._platform.WEBKIT && this._platform.IOS && !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 this._platform.FIREFOX || element.hasAttribute('controls');
|
|
}
|
|
return element.tabIndex >= 0;
|
|
}
|
|
/**
|
|
* Gets whether an element can be focused by the user.
|
|
*
|
|
* @param element Element to be checked.
|
|
* @param config The config object with options to customize this method's behavior
|
|
* @returns Whether the element is focusable.
|
|
*/
|
|
isFocusable(element, config) {
|
|
// Perform checks in order of left to most expensive.
|
|
// Again, naive approach that does not capture many edge cases and browser quirks.
|
|
return (isPotentiallyFocusable(element) &&
|
|
!this.isDisabled(element) &&
|
|
(config?.ignoreVisibility || this.isVisible(element)));
|
|
}
|
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: InteractivityChecker, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: InteractivityChecker, providedIn: 'root' });
|
|
}
|
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: InteractivityChecker, decorators: [{
|
|
type: Injectable,
|
|
args: [{ providedIn: 'root' }]
|
|
}], ctorParameters: () => [] });
|
|
/**
|
|
* 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.
|
|
*/
|
|
function getFrameElement(window) {
|
|
try {
|
|
return window.frameElement;
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
/** Checks whether the specified element has any geometry / rectangles. */
|
|
function hasGeometry(element) {
|
|
// Use logic from jQuery to check for an invisible element.
|
|
// See https://github.com/jquery/jquery/blob/master/src/css/hiddenVisibleSelectors.js#L12
|
|
return !!(element.offsetWidth ||
|
|
element.offsetHeight ||
|
|
(typeof element.getClientRects === 'function' && element.getClientRects().length));
|
|
}
|
|
/** Gets whether an element's */
|
|
function isNativeFormElement(element) {
|
|
let nodeName = element.nodeName.toLowerCase();
|
|
return (nodeName === 'input' ||
|
|
nodeName === 'select' ||
|
|
nodeName === 'button' ||
|
|
nodeName === 'textarea');
|
|
}
|
|
/** Gets whether an element is an `<input type="hidden">`. */
|
|
function isHiddenInput(element) {
|
|
return isInputElement(element) && element.type == 'hidden';
|
|
}
|
|
/** Gets whether an element is an anchor that has an href attribute. */
|
|
function isAnchorWithHref(element) {
|
|
return isAnchorElement(element) && element.hasAttribute('href');
|
|
}
|
|
/** Gets whether an element is an input element. */
|
|
function isInputElement(element) {
|
|
return element.nodeName.toLowerCase() == 'input';
|
|
}
|
|
/** Gets whether an element is an anchor element. */
|
|
function isAnchorElement(element) {
|
|
return element.nodeName.toLowerCase() == 'a';
|
|
}
|
|
/** Gets whether an element has a valid tabindex. */
|
|
function hasValidTabIndex(element) {
|
|
if (!element.hasAttribute('tabindex') || element.tabIndex === undefined) {
|
|
return false;
|
|
}
|
|
let tabIndex = element.getAttribute('tabindex');
|
|
return !!(tabIndex && !isNaN(parseInt(tabIndex, 10)));
|
|
}
|
|
/**
|
|
* Returns the parsed tabindex from the element attributes instead of returning the
|
|
* evaluated tabindex from the browsers defaults.
|
|
*/
|
|
function getTabIndexValue(element) {
|
|
if (!hasValidTabIndex(element)) {
|
|
return null;
|
|
}
|
|
// See browser issue in Gecko https://bugzilla.mozilla.org/show_bug.cgi?id=1128054
|
|
const tabIndex = parseInt(element.getAttribute('tabindex') || '', 10);
|
|
return isNaN(tabIndex) ? -1 : tabIndex;
|
|
}
|
|
/** Checks whether the specified element is potentially tabbable on iOS */
|
|
function isPotentiallyTabbableIOS(element) {
|
|
let nodeName = element.nodeName.toLowerCase();
|
|
let inputType = nodeName === 'input' && element.type;
|
|
return (inputType === 'text' ||
|
|
inputType === 'password' ||
|
|
nodeName === 'select' ||
|
|
nodeName === 'textarea');
|
|
}
|
|
/**
|
|
* Gets whether an element is potentially focusable without taking current visible/disabled state
|
|
* into account.
|
|
*/
|
|
function isPotentiallyFocusable(element) {
|
|
// Inputs are potentially focusable *unless* they're type="hidden".
|
|
if (isHiddenInput(element)) {
|
|
return false;
|
|
}
|
|
return (isNativeFormElement(element) ||
|
|
isAnchorWithHref(element) ||
|
|
element.hasAttribute('contenteditable') ||
|
|
hasValidTabIndex(element));
|
|
}
|
|
/** Gets the parent window of a DOM node with regards of being inside of an iframe. */
|
|
function getWindow(node) {
|
|
// ownerDocument is null if `node` itself *is* a document.
|
|
return (node.ownerDocument && node.ownerDocument.defaultView) || window;
|
|
}
|
|
|
|
/**
|
|
* Class that allows for trapping focus within a DOM element.
|
|
*
|
|
* This class currently uses a relatively simple approach to focus trapping.
|
|
* It assumes that the tab order is the same as DOM order, which is not necessarily true.
|
|
* Things like `tabIndex > 0`, flex `order`, and shadow roots can cause the two to be misaligned.
|
|
*/
|
|
class FocusTrap {
|
|
_element;
|
|
_checker;
|
|
_ngZone;
|
|
_document;
|
|
_injector;
|
|
_startAnchor;
|
|
_endAnchor;
|
|
_hasAttached = false;
|
|
// Event listeners for the anchors. Need to be regular functions so that we can unbind them later.
|
|
startAnchorListener = () => this.focusLastTabbableElement();
|
|
endAnchorListener = () => this.focusFirstTabbableElement();
|
|
/** Whether the focus trap is active. */
|
|
get enabled() {
|
|
return this._enabled;
|
|
}
|
|
set enabled(value) {
|
|
this._enabled = value;
|
|
if (this._startAnchor && this._endAnchor) {
|
|
this._toggleAnchorTabIndex(value, this._startAnchor);
|
|
this._toggleAnchorTabIndex(value, this._endAnchor);
|
|
}
|
|
}
|
|
_enabled = true;
|
|
constructor(_element, _checker, _ngZone, _document, deferAnchors = false,
|
|
/** @breaking-change 20.0.0 param to become required */
|
|
_injector) {
|
|
this._element = _element;
|
|
this._checker = _checker;
|
|
this._ngZone = _ngZone;
|
|
this._document = _document;
|
|
this._injector = _injector;
|
|
if (!deferAnchors) {
|
|
this.attachAnchors();
|
|
}
|
|
}
|
|
/** Destroys the focus trap by cleaning up the anchors. */
|
|
destroy() {
|
|
const startAnchor = this._startAnchor;
|
|
const endAnchor = this._endAnchor;
|
|
if (startAnchor) {
|
|
startAnchor.removeEventListener('focus', this.startAnchorListener);
|
|
startAnchor.remove();
|
|
}
|
|
if (endAnchor) {
|
|
endAnchor.removeEventListener('focus', this.endAnchorListener);
|
|
endAnchor.remove();
|
|
}
|
|
this._startAnchor = this._endAnchor = null;
|
|
this._hasAttached = false;
|
|
}
|
|
/**
|
|
* Inserts the anchors into the DOM. This is usually done automatically
|
|
* in the constructor, but can be deferred for cases like directives with `*ngIf`.
|
|
* @returns Whether the focus trap managed to attach successfully. This may not be the case
|
|
* if the target element isn't currently in the DOM.
|
|
*/
|
|
attachAnchors() {
|
|
// If we're not on the browser, there can be no focus to trap.
|
|
if (this._hasAttached) {
|
|
return true;
|
|
}
|
|
this._ngZone.runOutsideAngular(() => {
|
|
if (!this._startAnchor) {
|
|
this._startAnchor = this._createAnchor();
|
|
this._startAnchor.addEventListener('focus', this.startAnchorListener);
|
|
}
|
|
if (!this._endAnchor) {
|
|
this._endAnchor = this._createAnchor();
|
|
this._endAnchor.addEventListener('focus', this.endAnchorListener);
|
|
}
|
|
});
|
|
if (this._element.parentNode) {
|
|
this._element.parentNode.insertBefore(this._startAnchor, this._element);
|
|
this._element.parentNode.insertBefore(this._endAnchor, this._element.nextSibling);
|
|
this._hasAttached = true;
|
|
}
|
|
return this._hasAttached;
|
|
}
|
|
/**
|
|
* Waits for the zone to stabilize, then focuses the first tabbable element.
|
|
* @returns Returns a promise that resolves with a boolean, depending
|
|
* on whether focus was moved successfully.
|
|
*/
|
|
focusInitialElementWhenReady(options) {
|
|
return new Promise(resolve => {
|
|
this._executeOnStable(() => resolve(this.focusInitialElement(options)));
|
|
});
|
|
}
|
|
/**
|
|
* Waits for the zone to stabilize, then focuses
|
|
* the first tabbable element within the focus trap region.
|
|
* @returns Returns a promise that resolves with a boolean, depending
|
|
* on whether focus was moved successfully.
|
|
*/
|
|
focusFirstTabbableElementWhenReady(options) {
|
|
return new Promise(resolve => {
|
|
this._executeOnStable(() => resolve(this.focusFirstTabbableElement(options)));
|
|
});
|
|
}
|
|
/**
|
|
* Waits for the zone to stabilize, then focuses
|
|
* the last tabbable element within the focus trap region.
|
|
* @returns Returns a promise that resolves with a boolean, depending
|
|
* on whether focus was moved successfully.
|
|
*/
|
|
focusLastTabbableElementWhenReady(options) {
|
|
return new Promise(resolve => {
|
|
this._executeOnStable(() => resolve(this.focusLastTabbableElement(options)));
|
|
});
|
|
}
|
|
/**
|
|
* Get the specified boundary element of the trapped region.
|
|
* @param bound The boundary to get (start or end of trapped region).
|
|
* @returns The boundary element.
|
|
*/
|
|
_getRegionBoundary(bound) {
|
|
// Contains the deprecated version of selector, for temporary backwards comparability.
|
|
const markers = this._element.querySelectorAll(`[cdk-focus-region-${bound}], ` + `[cdkFocusRegion${bound}], ` + `[cdk-focus-${bound}]`);
|
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
for (let i = 0; i < markers.length; i++) {
|
|
// @breaking-change 8.0.0
|
|
if (markers[i].hasAttribute(`cdk-focus-${bound}`)) {
|
|
console.warn(`Found use of deprecated attribute 'cdk-focus-${bound}', ` +
|
|
`use 'cdkFocusRegion${bound}' instead. The deprecated ` +
|
|
`attribute will be removed in 8.0.0.`, markers[i]);
|
|
}
|
|
else if (markers[i].hasAttribute(`cdk-focus-region-${bound}`)) {
|
|
console.warn(`Found use of deprecated attribute 'cdk-focus-region-${bound}', ` +
|
|
`use 'cdkFocusRegion${bound}' instead. The deprecated attribute ` +
|
|
`will be removed in 8.0.0.`, markers[i]);
|
|
}
|
|
}
|
|
}
|
|
if (bound == 'start') {
|
|
return markers.length ? markers[0] : this._getFirstTabbableElement(this._element);
|
|
}
|
|
return markers.length
|
|
? markers[markers.length - 1]
|
|
: this._getLastTabbableElement(this._element);
|
|
}
|
|
/**
|
|
* Focuses the element that should be focused when the focus trap is initialized.
|
|
* @returns Whether focus was moved successfully.
|
|
*/
|
|
focusInitialElement(options) {
|
|
// Contains the deprecated version of selector, for temporary backwards comparability.
|
|
const redirectToElement = this._element.querySelector(`[cdk-focus-initial], ` + `[cdkFocusInitial]`);
|
|
if (redirectToElement) {
|
|
// @breaking-change 8.0.0
|
|
if ((typeof ngDevMode === 'undefined' || ngDevMode) &&
|
|
redirectToElement.hasAttribute(`cdk-focus-initial`)) {
|
|
console.warn(`Found use of deprecated attribute 'cdk-focus-initial', ` +
|
|
`use 'cdkFocusInitial' instead. The deprecated attribute ` +
|
|
`will be removed in 8.0.0`, redirectToElement);
|
|
}
|
|
// Warn the consumer if the element they've pointed to
|
|
// isn't focusable, when not in production mode.
|
|
if ((typeof ngDevMode === 'undefined' || ngDevMode) &&
|
|
!this._checker.isFocusable(redirectToElement)) {
|
|
console.warn(`Element matching '[cdkFocusInitial]' is not focusable.`, redirectToElement);
|
|
}
|
|
if (!this._checker.isFocusable(redirectToElement)) {
|
|
const focusableChild = this._getFirstTabbableElement(redirectToElement);
|
|
focusableChild?.focus(options);
|
|
return !!focusableChild;
|
|
}
|
|
redirectToElement.focus(options);
|
|
return true;
|
|
}
|
|
return this.focusFirstTabbableElement(options);
|
|
}
|
|
/**
|
|
* Focuses the first tabbable element within the focus trap region.
|
|
* @returns Whether focus was moved successfully.
|
|
*/
|
|
focusFirstTabbableElement(options) {
|
|
const redirectToElement = this._getRegionBoundary('start');
|
|
if (redirectToElement) {
|
|
redirectToElement.focus(options);
|
|
}
|
|
return !!redirectToElement;
|
|
}
|
|
/**
|
|
* Focuses the last tabbable element within the focus trap region.
|
|
* @returns Whether focus was moved successfully.
|
|
*/
|
|
focusLastTabbableElement(options) {
|
|
const redirectToElement = this._getRegionBoundary('end');
|
|
if (redirectToElement) {
|
|
redirectToElement.focus(options);
|
|
}
|
|
return !!redirectToElement;
|
|
}
|
|
/**
|
|
* Checks whether the focus trap has successfully been attached.
|
|
*/
|
|
hasAttached() {
|
|
return this._hasAttached;
|
|
}
|
|
/** Get the first tabbable element from a DOM subtree (inclusive). */
|
|
_getFirstTabbableElement(root) {
|
|
if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) {
|
|
return root;
|
|
}
|
|
const children = root.children;
|
|
for (let i = 0; i < children.length; i++) {
|
|
const tabbableChild = children[i].nodeType === this._document.ELEMENT_NODE
|
|
? this._getFirstTabbableElement(children[i])
|
|
: null;
|
|
if (tabbableChild) {
|
|
return tabbableChild;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
/** Get the last tabbable element from a DOM subtree (inclusive). */
|
|
_getLastTabbableElement(root) {
|
|
if (this._checker.isFocusable(root) && this._checker.isTabbable(root)) {
|
|
return root;
|
|
}
|
|
// Iterate in reverse DOM order.
|
|
const children = root.children;
|
|
for (let i = children.length - 1; i >= 0; i--) {
|
|
const tabbableChild = children[i].nodeType === this._document.ELEMENT_NODE
|
|
? this._getLastTabbableElement(children[i])
|
|
: null;
|
|
if (tabbableChild) {
|
|
return tabbableChild;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
/** Creates an anchor element. */
|
|
_createAnchor() {
|
|
const anchor = this._document.createElement('div');
|
|
this._toggleAnchorTabIndex(this._enabled, anchor);
|
|
anchor.classList.add('cdk-visually-hidden');
|
|
anchor.classList.add('cdk-focus-trap-anchor');
|
|
anchor.setAttribute('aria-hidden', 'true');
|
|
return anchor;
|
|
}
|
|
/**
|
|
* Toggles the `tabindex` of an anchor, based on the enabled state of the focus trap.
|
|
* @param isEnabled Whether the focus trap is enabled.
|
|
* @param anchor Anchor on which to toggle the tabindex.
|
|
*/
|
|
_toggleAnchorTabIndex(isEnabled, anchor) {
|
|
// Remove the tabindex completely, rather than setting it to -1, because if the
|
|
// element has a tabindex, the user might still hit it when navigating with the arrow keys.
|
|
isEnabled ? anchor.setAttribute('tabindex', '0') : anchor.removeAttribute('tabindex');
|
|
}
|
|
/**
|
|
* Toggles the`tabindex` of both anchors to either trap Tab focus or allow it to escape.
|
|
* @param enabled: Whether the anchors should trap Tab.
|
|
*/
|
|
toggleAnchors(enabled) {
|
|
if (this._startAnchor && this._endAnchor) {
|
|
this._toggleAnchorTabIndex(enabled, this._startAnchor);
|
|
this._toggleAnchorTabIndex(enabled, this._endAnchor);
|
|
}
|
|
}
|
|
/** Executes a function when the zone is stable. */
|
|
_executeOnStable(fn) {
|
|
// TODO: remove this conditional when injector is required in the constructor.
|
|
if (this._injector) {
|
|
afterNextRender(fn, { injector: this._injector });
|
|
}
|
|
else {
|
|
setTimeout(fn);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Factory that allows easy instantiation of focus traps.
|
|
*/
|
|
class FocusTrapFactory {
|
|
_checker = inject(InteractivityChecker);
|
|
_ngZone = inject(NgZone);
|
|
_document = inject(DOCUMENT);
|
|
_injector = inject(Injector);
|
|
constructor() {
|
|
inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader);
|
|
}
|
|
/**
|
|
* Creates a focus-trapped region around the given element.
|
|
* @param element The element around which focus will be trapped.
|
|
* @param deferCaptureElements Defers the creation of focus-capturing elements to be done
|
|
* manually by the user.
|
|
* @returns The created focus trap instance.
|
|
*/
|
|
create(element, deferCaptureElements = false) {
|
|
return new FocusTrap(element, this._checker, this._ngZone, this._document, deferCaptureElements, this._injector);
|
|
}
|
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: FocusTrapFactory, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: FocusTrapFactory, providedIn: 'root' });
|
|
}
|
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: FocusTrapFactory, decorators: [{
|
|
type: Injectable,
|
|
args: [{ providedIn: 'root' }]
|
|
}], ctorParameters: () => [] });
|
|
/** Directive for trapping focus within a region. */
|
|
class CdkTrapFocus {
|
|
_elementRef = inject(ElementRef);
|
|
_focusTrapFactory = inject(FocusTrapFactory);
|
|
/** Underlying FocusTrap instance. */
|
|
focusTrap;
|
|
/** Previously focused element to restore focus to upon destroy when using autoCapture. */
|
|
_previouslyFocusedElement = null;
|
|
/** Whether the focus trap is active. */
|
|
get enabled() {
|
|
return this.focusTrap?.enabled || false;
|
|
}
|
|
set enabled(value) {
|
|
if (this.focusTrap) {
|
|
this.focusTrap.enabled = value;
|
|
}
|
|
}
|
|
/**
|
|
* Whether the directive should automatically move focus into the trapped region upon
|
|
* initialization and return focus to the previous activeElement upon destruction.
|
|
*/
|
|
autoCapture;
|
|
constructor() {
|
|
const platform = inject(Platform);
|
|
if (platform.isBrowser) {
|
|
this.focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement, true);
|
|
}
|
|
}
|
|
ngOnDestroy() {
|
|
this.focusTrap?.destroy();
|
|
// If we stored a previously focused element when using autoCapture, return focus to that
|
|
// element now that the trapped region is being destroyed.
|
|
if (this._previouslyFocusedElement) {
|
|
this._previouslyFocusedElement.focus();
|
|
this._previouslyFocusedElement = null;
|
|
}
|
|
}
|
|
ngAfterContentInit() {
|
|
this.focusTrap?.attachAnchors();
|
|
if (this.autoCapture) {
|
|
this._captureFocus();
|
|
}
|
|
}
|
|
ngDoCheck() {
|
|
if (this.focusTrap && !this.focusTrap.hasAttached()) {
|
|
this.focusTrap.attachAnchors();
|
|
}
|
|
}
|
|
ngOnChanges(changes) {
|
|
const autoCaptureChange = changes['autoCapture'];
|
|
if (autoCaptureChange &&
|
|
!autoCaptureChange.firstChange &&
|
|
this.autoCapture &&
|
|
this.focusTrap?.hasAttached()) {
|
|
this._captureFocus();
|
|
}
|
|
}
|
|
_captureFocus() {
|
|
this._previouslyFocusedElement = _getFocusedElementPierceShadowDom();
|
|
this.focusTrap?.focusInitialElementWhenReady();
|
|
}
|
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: CdkTrapFocus, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "16.1.0", version: "20.2.0-next.2", type: CdkTrapFocus, isStandalone: true, selector: "[cdkTrapFocus]", inputs: { enabled: ["cdkTrapFocus", "enabled", booleanAttribute], autoCapture: ["cdkTrapFocusAutoCapture", "autoCapture", booleanAttribute] }, exportAs: ["cdkTrapFocus"], usesOnChanges: true, ngImport: i0 });
|
|
}
|
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: CdkTrapFocus, decorators: [{
|
|
type: Directive,
|
|
args: [{
|
|
selector: '[cdkTrapFocus]',
|
|
exportAs: 'cdkTrapFocus',
|
|
}]
|
|
}], ctorParameters: () => [], propDecorators: { enabled: [{
|
|
type: Input,
|
|
args: [{ alias: 'cdkTrapFocus', transform: booleanAttribute }]
|
|
}], autoCapture: [{
|
|
type: Input,
|
|
args: [{ alias: 'cdkTrapFocusAutoCapture', transform: booleanAttribute }]
|
|
}] } });
|
|
|
|
const LIVE_ANNOUNCER_ELEMENT_TOKEN = new InjectionToken('liveAnnouncerElement', {
|
|
providedIn: 'root',
|
|
factory: LIVE_ANNOUNCER_ELEMENT_TOKEN_FACTORY,
|
|
});
|
|
/**
|
|
* @docs-private
|
|
* @deprecated No longer used, will be removed.
|
|
* @breaking-change 21.0.0
|
|
*/
|
|
function LIVE_ANNOUNCER_ELEMENT_TOKEN_FACTORY() {
|
|
return null;
|
|
}
|
|
/** Injection token that can be used to configure the default options for the LiveAnnouncer. */
|
|
const LIVE_ANNOUNCER_DEFAULT_OPTIONS = new InjectionToken('LIVE_ANNOUNCER_DEFAULT_OPTIONS');
|
|
|
|
let uniqueIds = 0;
|
|
class LiveAnnouncer {
|
|
_ngZone = inject(NgZone);
|
|
_defaultOptions = inject(LIVE_ANNOUNCER_DEFAULT_OPTIONS, {
|
|
optional: true,
|
|
});
|
|
_liveElement;
|
|
_document = inject(DOCUMENT);
|
|
_previousTimeout;
|
|
_currentPromise;
|
|
_currentResolve;
|
|
constructor() {
|
|
const elementToken = inject(LIVE_ANNOUNCER_ELEMENT_TOKEN, { optional: true });
|
|
this._liveElement = elementToken || this._createLiveElement();
|
|
}
|
|
announce(message, ...args) {
|
|
const defaultOptions = this._defaultOptions;
|
|
let politeness;
|
|
let duration;
|
|
if (args.length === 1 && typeof args[0] === 'number') {
|
|
duration = args[0];
|
|
}
|
|
else {
|
|
[politeness, duration] = args;
|
|
}
|
|
this.clear();
|
|
clearTimeout(this._previousTimeout);
|
|
if (!politeness) {
|
|
politeness =
|
|
defaultOptions && defaultOptions.politeness ? defaultOptions.politeness : 'polite';
|
|
}
|
|
if (duration == null && defaultOptions) {
|
|
duration = defaultOptions.duration;
|
|
}
|
|
// TODO: ensure changing the politeness works on all environments we support.
|
|
this._liveElement.setAttribute('aria-live', politeness);
|
|
if (this._liveElement.id) {
|
|
this._exposeAnnouncerToModals(this._liveElement.id);
|
|
}
|
|
// This 100ms timeout is necessary for some browser + screen-reader combinations:
|
|
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
|
|
// - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a
|
|
// second time without clearing and then using a non-zero delay.
|
|
// (using JAWS 17 at time of this writing).
|
|
return this._ngZone.runOutsideAngular(() => {
|
|
if (!this._currentPromise) {
|
|
this._currentPromise = new Promise(resolve => (this._currentResolve = resolve));
|
|
}
|
|
clearTimeout(this._previousTimeout);
|
|
this._previousTimeout = setTimeout(() => {
|
|
this._liveElement.textContent = message;
|
|
if (typeof duration === 'number') {
|
|
this._previousTimeout = setTimeout(() => this.clear(), duration);
|
|
}
|
|
// For some reason in tests this can be undefined
|
|
// Probably related to ZoneJS and every other thing that patches browser APIs in tests
|
|
this._currentResolve?.();
|
|
this._currentPromise = this._currentResolve = undefined;
|
|
}, 100);
|
|
return this._currentPromise;
|
|
});
|
|
}
|
|
/**
|
|
* Clears the current text from the announcer element. Can be used to prevent
|
|
* screen readers from reading the text out again while the user is going
|
|
* through the page landmarks.
|
|
*/
|
|
clear() {
|
|
if (this._liveElement) {
|
|
this._liveElement.textContent = '';
|
|
}
|
|
}
|
|
ngOnDestroy() {
|
|
clearTimeout(this._previousTimeout);
|
|
this._liveElement?.remove();
|
|
this._liveElement = null;
|
|
this._currentResolve?.();
|
|
this._currentPromise = this._currentResolve = undefined;
|
|
}
|
|
_createLiveElement() {
|
|
const elementClass = 'cdk-live-announcer-element';
|
|
const previousElements = this._document.getElementsByClassName(elementClass);
|
|
const liveEl = this._document.createElement('div');
|
|
// Remove any old containers. This can happen when coming in from a server-side-rendered page.
|
|
for (let i = 0; i < previousElements.length; i++) {
|
|
previousElements[i].remove();
|
|
}
|
|
liveEl.classList.add(elementClass);
|
|
liveEl.classList.add('cdk-visually-hidden');
|
|
liveEl.setAttribute('aria-atomic', 'true');
|
|
liveEl.setAttribute('aria-live', 'polite');
|
|
liveEl.id = `cdk-live-announcer-${uniqueIds++}`;
|
|
this._document.body.appendChild(liveEl);
|
|
return liveEl;
|
|
}
|
|
/**
|
|
* Some browsers won't expose the accessibility node of the live announcer element if there is an
|
|
* `aria-modal` and the live announcer is outside of it. This method works around the issue by
|
|
* pointing the `aria-owns` of all modals to the live announcer element.
|
|
*/
|
|
_exposeAnnouncerToModals(id) {
|
|
// TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with
|
|
// the `SnakBarContainer` and other usages.
|
|
//
|
|
// Note that the selector here is limited to CDK overlays at the moment in order to reduce the
|
|
// section of the DOM we need to look through. This should cover all the cases we support, but
|
|
// the selector can be expanded if it turns out to be too narrow.
|
|
const modals = this._document.querySelectorAll('body > .cdk-overlay-container [aria-modal="true"]');
|
|
for (let i = 0; i < modals.length; i++) {
|
|
const modal = modals[i];
|
|
const ariaOwns = modal.getAttribute('aria-owns');
|
|
if (!ariaOwns) {
|
|
modal.setAttribute('aria-owns', id);
|
|
}
|
|
else if (ariaOwns.indexOf(id) === -1) {
|
|
modal.setAttribute('aria-owns', ariaOwns + ' ' + id);
|
|
}
|
|
}
|
|
}
|
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: LiveAnnouncer, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: LiveAnnouncer, providedIn: 'root' });
|
|
}
|
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: LiveAnnouncer, decorators: [{
|
|
type: Injectable,
|
|
args: [{ providedIn: 'root' }]
|
|
}], ctorParameters: () => [] });
|
|
/**
|
|
* A directive that works similarly to aria-live, but uses the LiveAnnouncer to ensure compatibility
|
|
* with a wider range of browsers and screen readers.
|
|
*/
|
|
class CdkAriaLive {
|
|
_elementRef = inject(ElementRef);
|
|
_liveAnnouncer = inject(LiveAnnouncer);
|
|
_contentObserver = inject(ContentObserver);
|
|
_ngZone = inject(NgZone);
|
|
/** The aria-live politeness level to use when announcing messages. */
|
|
get politeness() {
|
|
return this._politeness;
|
|
}
|
|
set politeness(value) {
|
|
this._politeness = value === 'off' || value === 'assertive' ? value : 'polite';
|
|
if (this._politeness === 'off') {
|
|
if (this._subscription) {
|
|
this._subscription.unsubscribe();
|
|
this._subscription = null;
|
|
}
|
|
}
|
|
else if (!this._subscription) {
|
|
this._subscription = this._ngZone.runOutsideAngular(() => {
|
|
return this._contentObserver.observe(this._elementRef).subscribe(() => {
|
|
// Note that we use textContent here, rather than innerText, in order to avoid a reflow.
|
|
const elementText = this._elementRef.nativeElement.textContent;
|
|
// The `MutationObserver` fires also for attribute
|
|
// changes which we don't want to announce.
|
|
if (elementText !== this._previousAnnouncedText) {
|
|
this._liveAnnouncer.announce(elementText, this._politeness, this.duration);
|
|
this._previousAnnouncedText = elementText;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
_politeness = 'polite';
|
|
/** Time in milliseconds after which to clear out the announcer element. */
|
|
duration;
|
|
_previousAnnouncedText;
|
|
_subscription;
|
|
constructor() {
|
|
inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader);
|
|
}
|
|
ngOnDestroy() {
|
|
if (this._subscription) {
|
|
this._subscription.unsubscribe();
|
|
}
|
|
}
|
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: CdkAriaLive, deps: [], target: i0.ɵɵFactoryTarget.Directive });
|
|
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.2.0-next.2", type: CdkAriaLive, isStandalone: true, selector: "[cdkAriaLive]", inputs: { politeness: ["cdkAriaLive", "politeness"], duration: ["cdkAriaLiveDuration", "duration"] }, exportAs: ["cdkAriaLive"], ngImport: i0 });
|
|
}
|
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: CdkAriaLive, decorators: [{
|
|
type: Directive,
|
|
args: [{
|
|
selector: '[cdkAriaLive]',
|
|
exportAs: 'cdkAriaLive',
|
|
}]
|
|
}], ctorParameters: () => [], propDecorators: { politeness: [{
|
|
type: Input,
|
|
args: ['cdkAriaLive']
|
|
}], duration: [{
|
|
type: Input,
|
|
args: ['cdkAriaLiveDuration']
|
|
}] } });
|
|
|
|
/** Set of possible high-contrast mode backgrounds. */
|
|
var HighContrastMode;
|
|
(function (HighContrastMode) {
|
|
HighContrastMode[HighContrastMode["NONE"] = 0] = "NONE";
|
|
HighContrastMode[HighContrastMode["BLACK_ON_WHITE"] = 1] = "BLACK_ON_WHITE";
|
|
HighContrastMode[HighContrastMode["WHITE_ON_BLACK"] = 2] = "WHITE_ON_BLACK";
|
|
})(HighContrastMode || (HighContrastMode = {}));
|
|
/** CSS class applied to the document body when in black-on-white high-contrast mode. */
|
|
const BLACK_ON_WHITE_CSS_CLASS = 'cdk-high-contrast-black-on-white';
|
|
/** CSS class applied to the document body when in white-on-black high-contrast mode. */
|
|
const WHITE_ON_BLACK_CSS_CLASS = 'cdk-high-contrast-white-on-black';
|
|
/** CSS class applied to the document body when in high-contrast mode. */
|
|
const HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS = 'cdk-high-contrast-active';
|
|
/**
|
|
* Service to determine whether the browser is currently in a high-contrast-mode environment.
|
|
*
|
|
* Microsoft Windows supports an accessibility feature called "High Contrast Mode". This mode
|
|
* changes the appearance of all applications, including web applications, to dramatically increase
|
|
* contrast.
|
|
*
|
|
* IE, Edge, and Firefox currently support this mode. Chrome does not support Windows High Contrast
|
|
* Mode. This service does not detect high-contrast mode as added by the Chrome "High Contrast"
|
|
* browser extension.
|
|
*/
|
|
class HighContrastModeDetector {
|
|
_platform = inject(Platform);
|
|
/**
|
|
* Figuring out the high contrast mode and adding the body classes can cause
|
|
* some expensive layouts. This flag is used to ensure that we only do it once.
|
|
*/
|
|
_hasCheckedHighContrastMode;
|
|
_document = inject(DOCUMENT);
|
|
_breakpointSubscription;
|
|
constructor() {
|
|
this._breakpointSubscription = inject(BreakpointObserver)
|
|
.observe('(forced-colors: active)')
|
|
.subscribe(() => {
|
|
if (this._hasCheckedHighContrastMode) {
|
|
this._hasCheckedHighContrastMode = false;
|
|
this._applyBodyHighContrastModeCssClasses();
|
|
}
|
|
});
|
|
}
|
|
/** Gets the current high-contrast-mode for the page. */
|
|
getHighContrastMode() {
|
|
if (!this._platform.isBrowser) {
|
|
return HighContrastMode.NONE;
|
|
}
|
|
// Create a test element with an arbitrary background-color that is neither black nor
|
|
// white; high-contrast mode will coerce the color to either black or white. Also ensure that
|
|
// appending the test element to the DOM does not affect layout by absolutely positioning it
|
|
const testElement = this._document.createElement('div');
|
|
testElement.style.backgroundColor = 'rgb(1,2,3)';
|
|
testElement.style.position = 'absolute';
|
|
this._document.body.appendChild(testElement);
|
|
// Get the computed style for the background color, collapsing spaces to normalize between
|
|
// browsers. Once we get this color, we no longer need the test element. Access the `window`
|
|
// via the document so we can fake it in tests. Note that we have extra null checks, because
|
|
// this logic will likely run during app bootstrap and throwing can break the entire app.
|
|
const documentWindow = this._document.defaultView || window;
|
|
const computedStyle = documentWindow && documentWindow.getComputedStyle
|
|
? documentWindow.getComputedStyle(testElement)
|
|
: null;
|
|
const computedColor = ((computedStyle && computedStyle.backgroundColor) || '').replace(/ /g, '');
|
|
testElement.remove();
|
|
switch (computedColor) {
|
|
// Pre Windows 11 dark theme.
|
|
case 'rgb(0,0,0)':
|
|
// Windows 11 dark themes.
|
|
case 'rgb(45,50,54)':
|
|
case 'rgb(32,32,32)':
|
|
return HighContrastMode.WHITE_ON_BLACK;
|
|
// Pre Windows 11 light theme.
|
|
case 'rgb(255,255,255)':
|
|
// Windows 11 light theme.
|
|
case 'rgb(255,250,239)':
|
|
return HighContrastMode.BLACK_ON_WHITE;
|
|
}
|
|
return HighContrastMode.NONE;
|
|
}
|
|
ngOnDestroy() {
|
|
this._breakpointSubscription.unsubscribe();
|
|
}
|
|
/** Applies CSS classes indicating high-contrast mode to document body (browser-only). */
|
|
_applyBodyHighContrastModeCssClasses() {
|
|
if (!this._hasCheckedHighContrastMode && this._platform.isBrowser && this._document.body) {
|
|
const bodyClasses = this._document.body.classList;
|
|
bodyClasses.remove(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, BLACK_ON_WHITE_CSS_CLASS, WHITE_ON_BLACK_CSS_CLASS);
|
|
this._hasCheckedHighContrastMode = true;
|
|
const mode = this.getHighContrastMode();
|
|
if (mode === HighContrastMode.BLACK_ON_WHITE) {
|
|
bodyClasses.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, BLACK_ON_WHITE_CSS_CLASS);
|
|
}
|
|
else if (mode === HighContrastMode.WHITE_ON_BLACK) {
|
|
bodyClasses.add(HIGH_CONTRAST_MODE_ACTIVE_CSS_CLASS, WHITE_ON_BLACK_CSS_CLASS);
|
|
}
|
|
}
|
|
}
|
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: HighContrastModeDetector, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: HighContrastModeDetector, providedIn: 'root' });
|
|
}
|
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: HighContrastModeDetector, decorators: [{
|
|
type: Injectable,
|
|
args: [{ providedIn: 'root' }]
|
|
}], ctorParameters: () => [] });
|
|
|
|
class A11yModule {
|
|
constructor() {
|
|
inject(HighContrastModeDetector)._applyBodyHighContrastModeCssClasses();
|
|
}
|
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: A11yModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
|
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.2.0-next.2", ngImport: i0, type: A11yModule, imports: [ObserversModule, CdkAriaLive, CdkTrapFocus, CdkMonitorFocus], exports: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus] });
|
|
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: A11yModule, imports: [ObserversModule] });
|
|
}
|
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.0-next.2", ngImport: i0, type: A11yModule, decorators: [{
|
|
type: NgModule,
|
|
args: [{
|
|
imports: [ObserversModule, CdkAriaLive, CdkTrapFocus, CdkMonitorFocus],
|
|
exports: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus],
|
|
}]
|
|
}], ctorParameters: () => [] });
|
|
|
|
export { A11yModule, CdkAriaLive, CdkTrapFocus, FocusTrap, FocusTrapFactory, HighContrastMode, HighContrastModeDetector, InteractivityChecker, IsFocusableConfig, LIVE_ANNOUNCER_DEFAULT_OPTIONS, LIVE_ANNOUNCER_ELEMENT_TOKEN, LIVE_ANNOUNCER_ELEMENT_TOKEN_FACTORY, LiveAnnouncer };
|
|
//# sourceMappingURL=a11y-module.mjs.map
|