kamrify commited on
Commit
0f198fd
·
1 Parent(s): 82a7eec

Implement focus trapping

Browse files
Files changed (8) hide show
  1. index.html +5 -0
  2. src/driver.css +2 -2
  3. src/driver.ts +7 -0
  4. src/events.ts +37 -0
  5. src/highlight.ts +1 -1
  6. src/popover.ts +20 -10
  7. src/state.ts +1 -0
  8. src/utils.ts +18 -0
index.html CHANGED
@@ -13,6 +13,11 @@
13
  padding: 0;
14
  }
15
 
 
 
 
 
 
16
  body {
17
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
18
  "Helvetica Neue", sans-serif;
 
13
  padding: 0;
14
  }
15
 
16
+ *:focus {
17
+ outline: 2px solid #1a73e8;
18
+ outline-offset: 2px;
19
+ }
20
+
21
  body {
22
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
23
  "Helvetica Neue", sans-serif;
src/driver.css CHANGED
@@ -80,7 +80,7 @@
80
  transition-duration: 200ms;
81
  }
82
 
83
- .driver-popover-close-btn:hover {
84
  color: #2d2d2d;
85
  }
86
 
@@ -140,7 +140,7 @@
140
  overflow: hidden !important;
141
  }
142
 
143
- .driver-popover-footer button:hover {
144
  background-color: #f7f7f7;
145
  }
146
 
 
80
  transition-duration: 200ms;
81
  }
82
 
83
+ .driver-popover-close-btn:hover, .driver-popover-close-btn:focus {
84
  color: #2d2d2d;
85
  }
86
 
 
140
  overflow: hidden !important;
141
  }
142
 
143
+ .driver-popover-footer button:hover, .driver-popover-footer button:focus {
144
  background-color: #f7f7f7;
145
  }
146
 
src/driver.ts CHANGED
@@ -149,6 +149,7 @@ export function driver(options: Config = {}) {
149
  return;
150
  }
151
 
 
152
  setState("activeIndex", stepIndex);
153
 
154
  const currentStep = steps[stepIndex];
@@ -215,6 +216,8 @@ export function driver(options: Config = {}) {
215
  const activeElement = getState("activeElement");
216
  const activeStep = getState("activeStep");
217
 
 
 
218
  const onDestroyStarted = getConfig("onDestroyStarted");
219
  // `onDestroyStarted` is used to confirm the exit of tour. If we trigger
220
  // the hook for when user calls `destroy`, driver will get into infinite loop
@@ -257,6 +260,10 @@ export function driver(options: Config = {}) {
257
  });
258
  }
259
  }
 
 
 
 
260
  }
261
 
262
  return {
 
149
  return;
150
  }
151
 
152
+ setState("__activeOnDestroyed", document.activeElement as HTMLElement);
153
  setState("activeIndex", stepIndex);
154
 
155
  const currentStep = steps[stepIndex];
 
216
  const activeElement = getState("activeElement");
217
  const activeStep = getState("activeStep");
218
 
219
+ const activeOnDestroyed = getState("__activeOnDestroyed");
220
+
221
  const onDestroyStarted = getConfig("onDestroyStarted");
222
  // `onDestroyStarted` is used to confirm the exit of tour. If we trigger
223
  // the hook for when user calls `destroy`, driver will get into infinite loop
 
260
  });
261
  }
262
  }
263
+
264
+ if (activeOnDestroyed) {
265
+ (activeOnDestroyed as HTMLElement).focus();
266
+ }
267
  }
268
 
269
  return {
src/events.ts CHANGED
@@ -2,6 +2,7 @@ import { refreshActiveHighlight } from "./highlight";
2
  import { emit } from "./emitter";
3
  import { getState, setState } from "./state";
4
  import { getConfig } from "./config";
 
5
 
6
  export function requireRefresh() {
7
  const resizeTimeout = getState("__resizeTimeout");
@@ -12,6 +13,41 @@ export function requireRefresh() {
12
  setState("__resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight));
13
  }
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  function onKeyup(e: KeyboardEvent) {
16
  const allowKeyboardControl = getConfig("allowKeyboardControl") || true;
17
  if (!allowKeyboardControl) {
@@ -78,6 +114,7 @@ export function onDriverClick(
78
 
79
  export function initEvents() {
80
  window.addEventListener("keyup", onKeyup, false);
 
81
  window.addEventListener("resize", requireRefresh);
82
  window.addEventListener("scroll", requireRefresh);
83
  }
 
2
  import { emit } from "./emitter";
3
  import { getState, setState } from "./state";
4
  import { getConfig } from "./config";
5
+ import { getFocusableElements, isElementVisible } from "./utils";
6
 
7
  export function requireRefresh() {
8
  const resizeTimeout = getState("__resizeTimeout");
 
13
  setState("__resizeTimeout", window.requestAnimationFrame(refreshActiveHighlight));
14
  }
15
 
16
+ function trapFocus(e: KeyboardEvent) {
17
+ const isActivated = getState("isInitialized");
18
+ if (!isActivated) {
19
+ return;
20
+ }
21
+
22
+ const isTabKey = e.key === "Tab" || e.keyCode === 9;
23
+ if (!isTabKey) {
24
+ return;
25
+ }
26
+
27
+ const activeElement = getState("activeElement");
28
+ const popoverEl = getState("popover")?.wrapper;
29
+
30
+ const focusableEls = getFocusableElements([
31
+ ...(activeElement ? [activeElement] : []),
32
+ ...(popoverEl ? [popoverEl] : []),
33
+ ]);
34
+
35
+ const firstFocusableEl = focusableEls[0];
36
+ const lastFocusableEl = focusableEls[focusableEls.length - 1];
37
+
38
+ e.preventDefault();
39
+
40
+ if (e.shiftKey) {
41
+ const previousFocusableEl =
42
+ focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) - 1] || lastFocusableEl;
43
+ previousFocusableEl?.focus();
44
+ } else {
45
+ const nextFocusableEl =
46
+ focusableEls[focusableEls.indexOf(document.activeElement as HTMLElement) + 1] || firstFocusableEl;
47
+ nextFocusableEl?.focus();
48
+ }
49
+ }
50
+
51
  function onKeyup(e: KeyboardEvent) {
52
  const allowKeyboardControl = getConfig("allowKeyboardControl") || true;
53
  if (!allowKeyboardControl) {
 
114
 
115
  export function initEvents() {
116
  window.addEventListener("keyup", onKeyup, false);
117
+ window.addEventListener("keydown", trapFocus, false);
118
  window.addEventListener("resize", requireRefresh);
119
  window.addEventListener("scroll", requireRefresh);
120
  }
src/highlight.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { DriveStep } from "./driver";
2
  import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
3
  import { getConfig } from "./config";
4
- import { repositionPopover, renderPopover, hidePopover } from "./popover";
5
  import { bringInView } from "./utils";
6
  import { getState, setState } from "./state";
7
 
 
1
  import { DriveStep } from "./driver";
2
  import { refreshOverlay, trackActiveElement, transitionStage } from "./overlay";
3
  import { getConfig } from "./config";
4
+ import { hidePopover, renderPopover, repositionPopover } from "./popover";
5
  import { bringInView } from "./utils";
6
  import { getState, setState } from "./state";
7
 
src/popover.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { bringInView } from "./utils";
2
  import { Config, DriverHook, getConfig } from "./config";
3
  import { getState, setState, State } from "./state";
4
  import { DriveStep } from "./driver";
@@ -43,9 +43,9 @@ export type PopoverDOM = {
43
  description: HTMLElement;
44
  footer: HTMLElement;
45
  progress: HTMLElement;
46
- previousButton: HTMLElement;
47
- nextButton: HTMLElement;
48
- closeButton: HTMLElement;
49
  footerButtons: HTMLElement;
50
  };
51
 
@@ -100,9 +100,7 @@ export function renderPopover(element: Element, step: DriveStep) {
100
  const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!;
101
  const showProgressConfig = showProgress || getConfig("showProgress") || false;
102
  const showFooter =
103
- showButtonsConfig?.includes("next") ||
104
- showButtonsConfig?.includes("previous") ||
105
- showProgressConfig;
106
 
107
  popover.closeButton.style.display = showButtonsConfig.includes("close") ? "block" : "none";
108
 
@@ -118,14 +116,17 @@ export function renderPopover(element: Element, step: DriveStep) {
118
 
119
  const disabledButtonsConfig: AllowedButtons[] = disableButtons || getConfig("disableButtons")! || [];
120
  if (disabledButtonsConfig?.includes("next")) {
 
121
  popover.nextButton.classList.add("driver-popover-btn-disabled");
122
  }
123
 
124
  if (disabledButtonsConfig?.includes("previous")) {
 
125
  popover.previousButton.classList.add("driver-popover-btn-disabled");
126
  }
127
 
128
  if (disabledButtonsConfig?.includes("close")) {
 
129
  popover.closeButton.classList.add("driver-popover-btn-disabled");
130
  }
131
 
@@ -195,9 +196,11 @@ export function renderPopover(element: Element, step: DriveStep) {
195
  target => {
196
  // Only prevent the default action if we're clicking on a driver button
197
  // This allows us to have links inside the popover title and description
198
- return !popover?.description.contains(target)
199
- && !popover?.title.contains(target)
200
- && target.className.includes("driver-popover");
 
 
201
  }
202
  );
203
 
@@ -213,6 +216,13 @@ export function renderPopover(element: Element, step: DriveStep) {
213
 
214
  repositionPopover(element, step);
215
  bringInView(popoverWrapper);
 
 
 
 
 
 
 
216
  }
217
 
218
  type PopoverDimensions = {
 
1
+ import { bringInView, getFocusableElements } from "./utils";
2
  import { Config, DriverHook, getConfig } from "./config";
3
  import { getState, setState, State } from "./state";
4
  import { DriveStep } from "./driver";
 
43
  description: HTMLElement;
44
  footer: HTMLElement;
45
  progress: HTMLElement;
46
+ previousButton: HTMLButtonElement;
47
+ nextButton: HTMLButtonElement;
48
+ closeButton: HTMLButtonElement;
49
  footerButtons: HTMLElement;
50
  };
51
 
 
100
  const showButtonsConfig: AllowedButtons[] = showButtons || getConfig("showButtons")!;
101
  const showProgressConfig = showProgress || getConfig("showProgress") || false;
102
  const showFooter =
103
+ showButtonsConfig?.includes("next") || showButtonsConfig?.includes("previous") || showProgressConfig;
 
 
104
 
105
  popover.closeButton.style.display = showButtonsConfig.includes("close") ? "block" : "none";
106
 
 
116
 
117
  const disabledButtonsConfig: AllowedButtons[] = disableButtons || getConfig("disableButtons")! || [];
118
  if (disabledButtonsConfig?.includes("next")) {
119
+ popover.nextButton.disabled = true;
120
  popover.nextButton.classList.add("driver-popover-btn-disabled");
121
  }
122
 
123
  if (disabledButtonsConfig?.includes("previous")) {
124
+ popover.previousButton.disabled = true;
125
  popover.previousButton.classList.add("driver-popover-btn-disabled");
126
  }
127
 
128
  if (disabledButtonsConfig?.includes("close")) {
129
+ popover.closeButton.disabled = true;
130
  popover.closeButton.classList.add("driver-popover-btn-disabled");
131
  }
132
 
 
196
  target => {
197
  // Only prevent the default action if we're clicking on a driver button
198
  // This allows us to have links inside the popover title and description
199
+ return (
200
+ !popover?.description.contains(target) &&
201
+ !popover?.title.contains(target) &&
202
+ target.className.includes("driver-popover")
203
+ );
204
  }
205
  );
206
 
 
216
 
217
  repositionPopover(element, step);
218
  bringInView(popoverWrapper);
219
+
220
+ // Focus on the first focusable element in active element or popover
221
+ const isToDummyElement = element.classList.contains("driver-dummy-element");
222
+ const focusableElement = getFocusableElements([...(isToDummyElement ? [] : [element]), popoverWrapper]);
223
+ if (focusableElement.length > 0) {
224
+ focusableElement[0].focus();
225
+ }
226
  }
227
 
228
  type PopoverDimensions = {
src/state.ts CHANGED
@@ -14,6 +14,7 @@ export type State = {
14
 
15
  popover?: PopoverDOM;
16
 
 
17
  __resizeTimeout?: number;
18
  __transitionCallback?: () => void;
19
  __activeStagePosition?: StageDefinition;
 
14
 
15
  popover?: PopoverDOM;
16
 
17
+ __activeOnDestroyed?: Element;
18
  __resizeTimeout?: number;
19
  __transitionCallback?: () => void;
20
  __activeStagePosition?: StageDefinition;
src/utils.ts CHANGED
@@ -7,6 +7,20 @@ export function easeInOutQuad(elapsed: number, initialValue: number, amountOfCha
7
  return (-amountOfChange / 2) * (--elapsed * (elapsed - 2) - 1) + initialValue;
8
  }
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  export function bringInView(element: Element) {
11
  if (!element || isElementInView(element)) {
12
  return;
@@ -43,3 +57,7 @@ function isElementInView(element: Element) {
43
  rect.right <= (window.innerWidth || document.documentElement.clientWidth)
44
  );
45
  }
 
 
 
 
 
7
  return (-amountOfChange / 2) * (--elapsed * (elapsed - 2) - 1) + initialValue;
8
  }
9
 
10
+ export function getFocusableElements(parentEls: Element[] | HTMLElement[]) {
11
+ const focusableQuery =
12
+ 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';
13
+
14
+ return parentEls
15
+ .flatMap(parentEl => {
16
+ const isParentFocusable = parentEl.matches(focusableQuery);
17
+ const focusableEls: HTMLElement[] = Array.from(parentEl.querySelectorAll(focusableQuery));
18
+
19
+ return [...(isParentFocusable ? [parentEl as HTMLElement] : []), ...focusableEls];
20
+ })
21
+ .filter(el => isElementVisible(el));
22
+ }
23
+
24
  export function bringInView(element: Element) {
25
  if (!element || isElementInView(element)) {
26
  return;
 
57
  rect.right <= (window.innerWidth || document.documentElement.clientWidth)
58
  );
59
  }
60
+
61
+ export function isElementVisible(el: HTMLElement) {
62
+ return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
63
+ }