kopia lustrzana https://github.com/shoelace-style/shoelace
152 wiersze
5.0 KiB
TypeScript
152 wiersze
5.0 KiB
TypeScript
import { activeElements, getDeepestActiveElement } from './active-elements.js';
|
|
import { getTabbableElements } from './tabbable.js';
|
|
|
|
let activeModals: HTMLElement[] = [];
|
|
|
|
export default class Modal {
|
|
element: HTMLElement;
|
|
isExternalActivated: boolean;
|
|
tabDirection: 'forward' | 'backward' = 'forward';
|
|
currentFocus: HTMLElement | null;
|
|
previousFocus: HTMLElement | null;
|
|
elementsWithTabbableControls: string[];
|
|
|
|
constructor(element: HTMLElement) {
|
|
this.element = element;
|
|
|
|
this.elementsWithTabbableControls = ['iframe'];
|
|
}
|
|
|
|
/** Activates focus trapping. */
|
|
activate() {
|
|
activeModals.push(this.element);
|
|
document.addEventListener('focusin', this.handleFocusIn);
|
|
document.addEventListener('keydown', this.handleKeyDown);
|
|
document.addEventListener('keyup', this.handleKeyUp);
|
|
}
|
|
|
|
/** Deactivates focus trapping. */
|
|
deactivate() {
|
|
activeModals = activeModals.filter(modal => modal !== this.element);
|
|
this.currentFocus = null;
|
|
document.removeEventListener('focusin', this.handleFocusIn);
|
|
document.removeEventListener('keydown', this.handleKeyDown);
|
|
document.removeEventListener('keyup', this.handleKeyUp);
|
|
}
|
|
|
|
/** Determines if this modal element is currently active or not. */
|
|
isActive() {
|
|
// The "active" modal is always the most recent one shown
|
|
return activeModals[activeModals.length - 1] === this.element;
|
|
}
|
|
|
|
/** Activates external modal behavior and temporarily disables focus trapping. */
|
|
activateExternal() {
|
|
this.isExternalActivated = true;
|
|
}
|
|
|
|
/** Deactivates external modal behavior and re-enables focus trapping. */
|
|
deactivateExternal() {
|
|
this.isExternalActivated = false;
|
|
}
|
|
|
|
private checkFocus() {
|
|
if (this.isActive() && !this.isExternalActivated) {
|
|
const tabbableElements = getTabbableElements(this.element);
|
|
if (!this.element.matches(':focus-within')) {
|
|
const start = tabbableElements[0];
|
|
const end = tabbableElements[tabbableElements.length - 1];
|
|
const target = this.tabDirection === 'forward' ? start : end;
|
|
|
|
if (typeof target?.focus === 'function') {
|
|
this.currentFocus = target;
|
|
target.focus({ preventScroll: false });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleFocusIn = () => {
|
|
if (!this.isActive()) return;
|
|
this.checkFocus();
|
|
};
|
|
|
|
private possiblyHasTabbableChildren(element: HTMLElement) {
|
|
return (
|
|
this.elementsWithTabbableControls.includes(element.tagName.toLowerCase()) || element.hasAttribute('controls')
|
|
// Should we add a data-attribute for people to set just in case they have an element where we don't know if it has possibly tabbable elements?
|
|
);
|
|
}
|
|
|
|
private handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Tab' || this.isExternalActivated) return;
|
|
if (!this.isActive()) return;
|
|
|
|
// Because sometimes focus can actually be taken over from outside sources,
|
|
// we don't want to rely on `this.currentFocus`. Instead we check the actual `activeElement` and
|
|
// recurse through shadowRoots.
|
|
const currentActiveElement = getDeepestActiveElement();
|
|
this.previousFocus = currentActiveElement as HTMLElement | null;
|
|
|
|
if (this.previousFocus && this.possiblyHasTabbableChildren(this.previousFocus)) {
|
|
return;
|
|
}
|
|
|
|
if (event.shiftKey) {
|
|
this.tabDirection = 'backward';
|
|
} else {
|
|
this.tabDirection = 'forward';
|
|
}
|
|
|
|
const tabbableElements = getTabbableElements(this.element);
|
|
|
|
let currentFocusIndex = tabbableElements.findIndex(el => el === currentActiveElement);
|
|
|
|
this.previousFocus = this.currentFocus;
|
|
|
|
const addition = this.tabDirection === 'forward' ? 1 : -1;
|
|
|
|
// eslint-disable-next-line
|
|
while (true) {
|
|
if (currentFocusIndex + addition >= tabbableElements.length) {
|
|
currentFocusIndex = 0;
|
|
} else if (currentFocusIndex + addition < 0) {
|
|
currentFocusIndex = tabbableElements.length - 1;
|
|
} else {
|
|
currentFocusIndex += addition;
|
|
}
|
|
|
|
this.previousFocus = this.currentFocus;
|
|
const nextFocus = /** @type {HTMLElement} */ tabbableElements[currentFocusIndex];
|
|
|
|
// This is a special case. We need to make sure we're not calling .focus() if we're already focused on an element
|
|
// that possibly has "controls"
|
|
if (this.tabDirection === 'backward') {
|
|
if (this.previousFocus && this.possiblyHasTabbableChildren(this.previousFocus)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (nextFocus && this.possiblyHasTabbableChildren(nextFocus)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
this.currentFocus = nextFocus;
|
|
this.currentFocus?.focus({ preventScroll: false });
|
|
|
|
// Check to make sure focus actually changed. It may not always be the next focus, we just don't want it to be the previousFocus.
|
|
const allActiveElements = [...activeElements()];
|
|
if (allActiveElements.includes(this.currentFocus) || !allActiveElements.includes(this.previousFocus!)) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
setTimeout(() => this.checkFocus());
|
|
};
|
|
|
|
private handleKeyUp = () => {
|
|
this.tabDirection = 'forward';
|
|
};
|
|
}
|