focus-trap.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import { debounce } from "./debounce";
  2. import { isFocusable, isHidden } from "./focusable";
  3. import { queryShadowRoot } from "./shadow";
  4. /**
  5. * Template for the focus trap.
  6. */
  7. const template = document.createElement("template");
  8. template.innerHTML = `
  9. <div id="start"></div>
  10. <div id="backup"></div>
  11. <slot></slot>
  12. <div id="end"></div>
  13. `;
  14. /**
  15. * Focus trap web component.
  16. * @customElement focus-trap
  17. * @slot - Default content.
  18. */
  19. export class FocusTrap extends HTMLElement {
  20. /**
  21. * Attaches the shadow root.
  22. */
  23. constructor() {
  24. super();
  25. // The debounce id is used to distinguish this focus trap from others when debouncing
  26. this.debounceId = Math.random().toString();
  27. this._focused = false;
  28. const shadow = this.attachShadow({ mode: "open" });
  29. shadow.appendChild(template.content.cloneNode(true));
  30. this.$backup = shadow.querySelector("#backup");
  31. this.$start = shadow.querySelector("#start");
  32. this.$end = shadow.querySelector("#end");
  33. this.focusLastElement = this.focusLastElement.bind(this);
  34. this.focusFirstElement = this.focusFirstElement.bind(this);
  35. this.onFocusIn = this.onFocusIn.bind(this);
  36. this.onFocusOut = this.onFocusOut.bind(this);
  37. }
  38. // Whenever one of these attributes changes we need to render the template again.
  39. static get observedAttributes() {
  40. return [
  41. "inactive"
  42. ];
  43. }
  44. /**
  45. * Determines whether the focus trap is active or not.
  46. * @attr
  47. */
  48. get inactive() {
  49. return this.hasAttribute("inactive");
  50. }
  51. set inactive(value) {
  52. value ? this.setAttribute("inactive", "") : this.removeAttribute("inactive");
  53. }
  54. /**
  55. * Returns whether the element currently has focus.
  56. */
  57. get focused() {
  58. return this._focused;
  59. }
  60. /**
  61. * Hooks up the element.
  62. */
  63. connectedCallback() {
  64. this.$start.addEventListener("focus", this.focusLastElement);
  65. this.$end.addEventListener("focus", this.focusFirstElement);
  66. // Focus out is called every time the user tabs around inside the element
  67. this.addEventListener("focusin", this.onFocusIn);
  68. this.addEventListener("focusout", this.onFocusOut);
  69. this.render();
  70. }
  71. /**
  72. * Tears down the element.
  73. */
  74. disconnectedCallback() {
  75. this.$start.removeEventListener("focus", this.focusLastElement);
  76. this.$end.removeEventListener("focus", this.focusFirstElement);
  77. this.removeEventListener("focusin", this.onFocusIn);
  78. this.removeEventListener("focusout", this.onFocusOut);
  79. }
  80. /**
  81. * When the attributes changes we need to re-render the template.
  82. */
  83. attributeChangedCallback() {
  84. this.render();
  85. }
  86. /**
  87. * Focuses the first focusable element in the focus trap.
  88. */
  89. focusFirstElement() {
  90. this.trapFocus();
  91. }
  92. /**
  93. * Focuses the last focusable element in the focus trap.
  94. */
  95. focusLastElement() {
  96. this.trapFocus(true);
  97. }
  98. /**
  99. * Returns a list of the focusable children found within the element.
  100. */
  101. getFocusableElements() {
  102. return queryShadowRoot(this, isHidden, isFocusable);
  103. }
  104. /**
  105. * Focuses on either the last or first focusable element.
  106. * @param {boolean} trapToEnd
  107. */
  108. trapFocus(trapToEnd) {
  109. if (this.inactive)
  110. return;
  111. let focusableChildren = this.getFocusableElements();
  112. if (focusableChildren.length > 0) {
  113. if (trapToEnd) {
  114. focusableChildren[focusableChildren.length - 1].focus();
  115. }
  116. else {
  117. focusableChildren[0].focus();
  118. }
  119. this.$backup.setAttribute("tabindex", "-1");
  120. }
  121. else {
  122. // If there are no focusable children we need to focus on the backup
  123. // to trap the focus. This is a useful behavior if the focus trap is
  124. // for example used in a dialog and we don't want the user to tab
  125. // outside the dialog even though there are no focusable children
  126. // in the dialog.
  127. this.$backup.setAttribute("tabindex", "0");
  128. this.$backup.focus();
  129. }
  130. }
  131. /**
  132. * When the element gains focus this function is called.
  133. */
  134. onFocusIn() {
  135. this.updateFocused(true);
  136. }
  137. /**
  138. * When the element looses its focus this function is called.
  139. */
  140. onFocusOut() {
  141. this.updateFocused(false);
  142. }
  143. /**
  144. * Updates the focused property and updates the view.
  145. * The update is debounced because the focusin and focusout out
  146. * might fire multiple times in a row. We only want to render
  147. * the element once, therefore waiting until the focus is "stable".
  148. * @param value
  149. */
  150. updateFocused(value) {
  151. debounce(() => {
  152. if (this.focused !== value) {
  153. this._focused = value;
  154. this.render();
  155. }
  156. }, 0, this.debounceId);
  157. }
  158. /**
  159. * Updates the template.
  160. */
  161. render() {
  162. this.$start.setAttribute("tabindex", !this.focused || this.inactive ? `-1` : `0`);
  163. this.$end.setAttribute("tabindex", !this.focused || this.inactive ? `-1` : `0`);
  164. this.focused ? this.setAttribute("focused", "") : this.removeAttribute("focused");
  165. }
  166. }
  167. window.customElements.define("focus-trap", FocusTrap);
  168. //# sourceMappingURL=focus-trap.js.map