Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

call define conditionally #1

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
377 changes: 194 additions & 183 deletions src/lib/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { isFocusable, isHidden } from "./focusable";
import { queryShadowRoot } from "./shadow";

export interface IFocusTrap {
inactive: boolean;
readonly focused: boolean;
focusFirstElement: (() => void);
focusLastElement: (() => void);
getFocusableElements: (() => HTMLElement[]);
inactive: boolean;
readonly focused: boolean;
focusFirstElement: () => void;
focusLastElement: () => void;
getFocusableElements: () => HTMLElement[];
}

/**
Expand All @@ -26,183 +26,194 @@ template.innerHTML = `
* @slot - Default content.
*/
export class FocusTrap extends HTMLElement implements IFocusTrap {

// Whenever one of these attributes changes we need to render the template again.
static get observedAttributes () {
return [
"inactive"
];
}

/**
* Determines whether the focus trap is active or not.
* @attr
*/
get inactive () {
return this.hasAttribute("inactive");
}

set inactive (value: boolean) {
value ? this.setAttribute("inactive", "") : this.removeAttribute("inactive");
}

// The backup element is only used if there are no other focusable children
private $backup!: HTMLElement;

// The debounce id is used to distinguish this focus trap from others when debouncing
private debounceId = Math.random().toString();

private $start!: HTMLElement;
private $end!: HTMLElement;

private _focused = false;

/**
* Returns whether the element currently has focus.
*/
get focused (): boolean {
return this._focused;
}

/**
* Attaches the shadow root.
*/
constructor () {
super();

const shadow = this.attachShadow({mode: "open"});
shadow.appendChild(template.content.cloneNode(true));

this.focusLastElement = this.focusLastElement.bind(this);
this.focusFirstElement = this.focusFirstElement.bind(this);
this.onFocusIn = this.onFocusIn.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
}

/**
* Hooks up the element.
*/
connectedCallback () {
this.$backup = this.shadowRoot!.querySelector<HTMLElement>("#backup")!;
this.$start = this.shadowRoot!.querySelector<HTMLElement>("#start")!;
this.$end = this.shadowRoot!.querySelector<HTMLElement>("#end")!;

this.$start.addEventListener("focus", this.focusLastElement);
this.$end.addEventListener("focus", this.focusFirstElement);

// Focus out is called every time the user tabs around inside the element
this.addEventListener("focusin", this.onFocusIn);
this.addEventListener("focusout", this.onFocusOut);

this.render();
}


/**
* Tears down the element.
*/
disconnectedCallback () {
this.$start.removeEventListener("focus", this.focusLastElement);
this.$end.removeEventListener("focus", this.focusFirstElement);
this.removeEventListener("focusin", this.onFocusIn);
this.removeEventListener("focusout", this.onFocusOut);
}

/**
* When the attributes changes we need to re-render the template.
*/
attributeChangedCallback () {
this.render();
}

/**
* Focuses the first focusable element in the focus trap.
*/
focusFirstElement () {
this.trapFocus();
}

/**
* Focuses the last focusable element in the focus trap.
*/
focusLastElement () {
this.trapFocus(true);
}

/**
* Returns a list of the focusable children found within the element.
*/
getFocusableElements (): HTMLElement[] {
return queryShadowRoot(this, isHidden, isFocusable);
}

/**
* Focuses on either the last or first focusable element.
* @param {boolean} trapToEnd
*/
protected trapFocus (trapToEnd?: boolean) {
if (this.inactive) return;

let focusableChildren = this.getFocusableElements();
if (focusableChildren.length > 0) {
if (trapToEnd) {
focusableChildren[focusableChildren.length - 1].focus();
} else {
focusableChildren[0].focus();
}

this.$backup.setAttribute("tabindex", "-1");
} else {
// If there are no focusable children we need to focus on the backup
// to trap the focus. This is a useful behavior if the focus trap is
// for example used in a dialog and we don't want the user to tab
// outside the dialog even though there are no focusable children
// in the dialog.
this.$backup.setAttribute("tabindex", "0");
this.$backup.focus();
}
}


/**
* When the element gains focus this function is called.
*/
private onFocusIn () {
this.updateFocused(true);
}

/**
* When the element looses its focus this function is called.
*/
private onFocusOut () {
this.updateFocused(false);
}

/**
* Updates the focused property and updates the view.
* The update is debounced because the focusin and focusout out
* might fire multiple times in a row. We only want to render
* the element once, therefore waiting until the focus is "stable".
* @param value
*/
private updateFocused (value: boolean) {
debounce(() => {
if (this.focused !== value) {
this._focused = value;
this.render();
}
}, 0, this.debounceId);
}

/**
* Updates the template.
*/
protected render () {
if (!this.isConnected) return;
this.$start.setAttribute("tabindex", !this.focused || this.inactive ? `-1` : `0`);
this.$end.setAttribute("tabindex", !this.focused || this.inactive ? `-1` : `0`);
this.focused ? this.setAttribute("focused", "") : this.removeAttribute("focused");
}
// Whenever one of these attributes changes we need to render the template again.
static get observedAttributes() {
return ["inactive"];
}

/**
* Determines whether the focus trap is active or not.
* @attr
*/
get inactive() {
return this.hasAttribute("inactive");
}

set inactive(value: boolean) {
value
? this.setAttribute("inactive", "")
: this.removeAttribute("inactive");
}

// The backup element is only used if there are no other focusable children
private $backup!: HTMLElement;

// The debounce id is used to distinguish this focus trap from others when debouncing
private debounceId = Math.random().toString();

private $start!: HTMLElement;
private $end!: HTMLElement;

private _focused = false;

/**
* Returns whether the element currently has focus.
*/
get focused(): boolean {
return this._focused;
}

/**
* Attaches the shadow root.
*/
constructor() {
super();

const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(template.content.cloneNode(true));

this.focusLastElement = this.focusLastElement.bind(this);
this.focusFirstElement = this.focusFirstElement.bind(this);
this.onFocusIn = this.onFocusIn.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
}

/**
* Hooks up the element.
*/
connectedCallback() {
this.$backup = this.shadowRoot!.querySelector<HTMLElement>("#backup")!;
this.$start = this.shadowRoot!.querySelector<HTMLElement>("#start")!;
this.$end = this.shadowRoot!.querySelector<HTMLElement>("#end")!;

this.$start.addEventListener("focus", this.focusLastElement);
this.$end.addEventListener("focus", this.focusFirstElement);

// Focus out is called every time the user tabs around inside the element
this.addEventListener("focusin", this.onFocusIn);
this.addEventListener("focusout", this.onFocusOut);

this.render();
}

/**
* Tears down the element.
*/
disconnectedCallback() {
this.$start.removeEventListener("focus", this.focusLastElement);
this.$end.removeEventListener("focus", this.focusFirstElement);
this.removeEventListener("focusin", this.onFocusIn);
this.removeEventListener("focusout", this.onFocusOut);
}

/**
* When the attributes changes we need to re-render the template.
*/
attributeChangedCallback() {
this.render();
}

/**
* Focuses the first focusable element in the focus trap.
*/
focusFirstElement() {
this.trapFocus();
}

/**
* Focuses the last focusable element in the focus trap.
*/
focusLastElement() {
this.trapFocus(true);
}

/**
* Returns a list of the focusable children found within the element.
*/
getFocusableElements(): HTMLElement[] {
return queryShadowRoot(this, isHidden, isFocusable);
}

/**
* Focuses on either the last or first focusable element.
* @param {boolean} trapToEnd
*/
protected trapFocus(trapToEnd?: boolean) {
if (this.inactive) return;

let focusableChildren = this.getFocusableElements();
if (focusableChildren.length > 0) {
if (trapToEnd) {
focusableChildren[focusableChildren.length - 1].focus();
} else {
focusableChildren[0].focus();
}

this.$backup.setAttribute("tabindex", "-1");
} else {
// If there are no focusable children we need to focus on the backup
// to trap the focus. This is a useful behavior if the focus trap is
// for example used in a dialog and we don't want the user to tab
// outside the dialog even though there are no focusable children
// in the dialog.
this.$backup.setAttribute("tabindex", "0");
this.$backup.focus();
}
}

/**
* When the element gains focus this function is called.
*/
private onFocusIn() {
this.updateFocused(true);
}

/**
* When the element looses its focus this function is called.
*/
private onFocusOut() {
this.updateFocused(false);
}

/**
* Updates the focused property and updates the view.
* The update is debounced because the focusin and focusout out
* might fire multiple times in a row. We only want to render
* the element once, therefore waiting until the focus is "stable".
* @param value
*/
private updateFocused(value: boolean) {
debounce(
() => {
if (this.focused !== value) {
this._focused = value;
this.render();
}
},
0,
this.debounceId
);
}

/**
* Updates the template.
*/
protected render() {
if (!this.isConnected) return;
this.$start.setAttribute(
"tabindex",
!this.focused || this.inactive ? `-1` : `0`
);
this.$end.setAttribute(
"tabindex",
!this.focused || this.inactive ? `-1` : `0`
);
this.focused
? this.setAttribute("focused", "")
: this.removeAttribute("focused");
}
}

window.customElements.define("focus-trap", FocusTrap);
if (window && window.customElements) {
window.customElements.define("focus-trap", FocusTrap);
}