@prairielearn/browser-utils 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +25 -0
- package/dist/focus.d.ts +5 -0
- package/dist/focus.js +106 -0
- package/dist/focus.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/focus.ts +127 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @prairielearn/browser-utils
|
|
2
2
|
|
|
3
|
+
## 2.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d7f1045: Add `trapFocus` and `focusFirstFocusbaleChild` functions
|
|
8
|
+
|
|
9
|
+
## 2.1.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [852c2e2]
|
|
14
|
+
- @prairielearn/html@4.0.5
|
|
15
|
+
|
|
3
16
|
## 2.1.2
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -90,3 +90,28 @@ document.querySelectorAll('.js-delete-course').forEach((el) => {
|
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
92
|
```
|
|
93
|
+
|
|
94
|
+
### `trapFocus`
|
|
95
|
+
|
|
96
|
+
This function can be used to trap focus within an element, such as a popover or modal. It will ensure that the user cannot tab out of the element.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { trapFocus } from '@prairielearn/browser-utils';
|
|
100
|
+
|
|
101
|
+
const popover = document.querySelector('.popover');
|
|
102
|
+
const trap = trapFocus(popover);
|
|
103
|
+
|
|
104
|
+
// When the container is being closed or removed, deactivate the trap.
|
|
105
|
+
trap.deactivate();
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `focusFirstFocusableChild`
|
|
109
|
+
|
|
110
|
+
This function will focus the first focusable child of an element. This is useful when opening a modal or popover.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { focusFirstFocusableChild } from '@prairielearn/browser-utils';
|
|
114
|
+
|
|
115
|
+
const modal = document.querySelector('.modal');
|
|
116
|
+
focusFirstFocusableChild(modal);
|
|
117
|
+
```
|
package/dist/focus.d.ts
ADDED
package/dist/focus.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Borrowed from Bootstrap:
|
|
2
|
+
// https://github.com/twbs/bootstrap/blob/5f75413735d8779aeefe0097af9dc5a416208ae5/js/src/dom/selector-engine.js#L67
|
|
3
|
+
const FOCUSABLE_SELECTOR = [
|
|
4
|
+
'a',
|
|
5
|
+
'button',
|
|
6
|
+
'input',
|
|
7
|
+
'textarea',
|
|
8
|
+
'select',
|
|
9
|
+
'details',
|
|
10
|
+
'[tabindex]',
|
|
11
|
+
'[contenteditable="true"]',
|
|
12
|
+
]
|
|
13
|
+
.map((selector) => `${selector}:not([tabindex^="-"]):not(.btn-close)`)
|
|
14
|
+
.join(',');
|
|
15
|
+
function isElement(object) {
|
|
16
|
+
return typeof object?.nodeType !== 'undefined';
|
|
17
|
+
}
|
|
18
|
+
function isVisible(element) {
|
|
19
|
+
if (!isElement(element) || element.getClientRects().length === 0) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible';
|
|
23
|
+
// Handle `details` element as its content may falsly appear visible when it is closed
|
|
24
|
+
const closedDetails = element.closest('details:not([open])');
|
|
25
|
+
if (!closedDetails) {
|
|
26
|
+
return elementIsVisible;
|
|
27
|
+
}
|
|
28
|
+
if (closedDetails !== element) {
|
|
29
|
+
const summary = element.closest('summary');
|
|
30
|
+
if (summary && summary.parentNode !== closedDetails) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (summary === null) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return elementIsVisible;
|
|
38
|
+
}
|
|
39
|
+
function isDisabled(element) {
|
|
40
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (typeof element.disabled !== 'undefined') {
|
|
44
|
+
return element.disabled;
|
|
45
|
+
}
|
|
46
|
+
return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false';
|
|
47
|
+
}
|
|
48
|
+
function focusableChildren(element) {
|
|
49
|
+
const focusableChildren = element.querySelectorAll(FOCUSABLE_SELECTOR);
|
|
50
|
+
return Array.from(focusableChildren).filter((child) => !isDisabled(child) && isVisible(child));
|
|
51
|
+
}
|
|
52
|
+
export function trapFocus(element) {
|
|
53
|
+
// Store the previous active element so we can restore it later.
|
|
54
|
+
const previousActiveElement = document.activeElement ?? document.body;
|
|
55
|
+
function keyDown(e) {
|
|
56
|
+
console.log('handling keydown', e.key);
|
|
57
|
+
if (e.key !== 'Tab')
|
|
58
|
+
return;
|
|
59
|
+
const focusable = focusableChildren(element);
|
|
60
|
+
const firstFocusable = focusable[0];
|
|
61
|
+
const lastFocusable = focusable[focusable.length - 1];
|
|
62
|
+
if (e.shiftKey) {
|
|
63
|
+
// Tabbing backwards
|
|
64
|
+
if (document.activeElement === firstFocusable) {
|
|
65
|
+
focusable[focusable.length - 1].focus();
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Tabbing forwards
|
|
71
|
+
if (document.activeElement === lastFocusable) {
|
|
72
|
+
focusable[0].focus();
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
document.addEventListener('keydown', keyDown);
|
|
78
|
+
return {
|
|
79
|
+
deactivate() {
|
|
80
|
+
document.removeEventListener('keydown', keyDown);
|
|
81
|
+
previousActiveElement?.focus();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function focusFirstFocusableChild(el) {
|
|
86
|
+
// In case the user (or more frequently, Cypress) is too fast and focuses a
|
|
87
|
+
// specific element inside the container before this script runs, don't transfer
|
|
88
|
+
// focus to a different element.
|
|
89
|
+
if (el.contains(document.activeElement))
|
|
90
|
+
return;
|
|
91
|
+
// Escape hatch: if the first element isn't the one that should be focused,
|
|
92
|
+
// add the `autofocus` attribute to the element that should be.
|
|
93
|
+
const autofocusElement = el.querySelector('[autofocus]');
|
|
94
|
+
if (autofocusElement) {
|
|
95
|
+
autofocusElement.focus();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const focusablePopoverChildren = focusableChildren(el);
|
|
99
|
+
if (focusablePopoverChildren.length > 0) {
|
|
100
|
+
focusablePopoverChildren[0].focus();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// If we still couldn't find a child element, focus the container itself.
|
|
104
|
+
el.focus();
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=focus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus.js","sourceRoot":"","sources":["../src/focus.ts"],"names":[],"mappings":"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","sourcesContent":["// Borrowed from Bootstrap:\n// https://github.com/twbs/bootstrap/blob/5f75413735d8779aeefe0097af9dc5a416208ae5/js/src/dom/selector-engine.js#L67\nconst FOCUSABLE_SELECTOR = [\n 'a',\n 'button',\n 'input',\n 'textarea',\n 'select',\n 'details',\n '[tabindex]',\n '[contenteditable=\"true\"]',\n]\n .map((selector) => `${selector}:not([tabindex^=\"-\"]):not(.btn-close)`)\n .join(',');\n\nfunction isElement(object: Element) {\n return typeof object?.nodeType !== 'undefined';\n}\n\nfunction isVisible(element: Element) {\n if (!isElement(element) || element.getClientRects().length === 0) {\n return false;\n }\n\n const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible';\n // Handle `details` element as its content may falsly appear visible when it is closed\n const closedDetails = element.closest('details:not([open])');\n\n if (!closedDetails) {\n return elementIsVisible;\n }\n\n if (closedDetails !== element) {\n const summary = element.closest('summary');\n if (summary && summary.parentNode !== closedDetails) {\n return false;\n }\n\n if (summary === null) {\n return false;\n }\n }\n\n return elementIsVisible;\n}\n\nfunction isDisabled(element: Element) {\n if (!element || element.nodeType !== Node.ELEMENT_NODE) {\n return true;\n }\n\n if (typeof (element as any).disabled !== 'undefined') {\n return (element as any).disabled;\n }\n\n return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false';\n}\n\nfunction focusableChildren(element: Element): HTMLElement[] {\n const focusableChildren = element.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);\n return Array.from(focusableChildren).filter((child) => !isDisabled(child) && isVisible(child));\n}\n\nexport interface FocusTrap {\n deactivate(): void;\n}\n\nexport function trapFocus(element: Element): FocusTrap {\n // Store the previous active element so we can restore it later.\n const previousActiveElement = document.activeElement ?? document.body;\n\n function keyDown(e: KeyboardEvent) {\n console.log('handling keydown', e.key);\n if (e.key !== 'Tab') return;\n\n const focusable = focusableChildren(element);\n const firstFocusable = focusable[0];\n const lastFocusable = focusable[focusable.length - 1];\n\n if (e.shiftKey) {\n // Tabbing backwards\n if (document.activeElement === firstFocusable) {\n (focusable[focusable.length - 1] as HTMLElement).focus();\n e.preventDefault();\n }\n } else {\n // Tabbing forwards\n if (document.activeElement === lastFocusable) {\n (focusable[0] as HTMLElement).focus();\n e.preventDefault();\n }\n }\n }\n\n document.addEventListener('keydown', keyDown);\n\n return {\n deactivate() {\n document.removeEventListener('keydown', keyDown);\n (previousActiveElement as HTMLElement)?.focus();\n },\n };\n}\n\nexport function focusFirstFocusableChild(el: HTMLElement) {\n // In case the user (or more frequently, Cypress) is too fast and focuses a\n // specific element inside the container before this script runs, don't transfer\n // focus to a different element.\n if (el.contains(document.activeElement)) return;\n\n // Escape hatch: if the first element isn't the one that should be focused,\n // add the `autofocus` attribute to the element that should be.\n const autofocusElement = el.querySelector<HTMLElement>('[autofocus]');\n if (autofocusElement) {\n autofocusElement.focus();\n return;\n }\n\n const focusablePopoverChildren = focusableChildren(el);\n if (focusablePopoverChildren.length > 0) {\n focusablePopoverChildren[0].focus();\n return;\n }\n\n // If we still couldn't find a child element, focus the container itself.\n el.focus();\n}\n"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { onDocumentReady } from './on-document-ready.js';
|
|
|
2
2
|
export { parseHTML, parseHTMLElement } from './parse-html.js';
|
|
3
3
|
export { EncodedData, decodeData } from './encode-data.js';
|
|
4
4
|
export { templateFromAttributes } from './template-from-attributes.js';
|
|
5
|
+
export { trapFocus, focusFirstFocusableChild } from './focus.js';
|
package/dist/index.js
CHANGED
|
@@ -2,4 +2,5 @@ export { onDocumentReady } from './on-document-ready.js';
|
|
|
2
2
|
export { parseHTML, parseHTMLElement } from './parse-html.js';
|
|
3
3
|
export { EncodedData, decodeData } from './encode-data.js';
|
|
4
4
|
export { templateFromAttributes } from './template-from-attributes.js';
|
|
5
|
+
export { trapFocus, focusFirstFocusableChild } from './focus.js';
|
|
5
6
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC","sourcesContent":["export { onDocumentReady } from './on-document-ready.js';\nexport { parseHTML, parseHTMLElement } from './parse-html.js';\nexport { EncodedData, decodeData } from './encode-data.js';\nexport { templateFromAttributes } from './template-from-attributes.js';\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AACvE,OAAO,EAAE,SAAS,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC","sourcesContent":["export { onDocumentReady } from './on-document-ready.js';\nexport { parseHTML, parseHTMLElement } from './parse-html.js';\nexport { EncodedData, decodeData } from './encode-data.js';\nexport { templateFromAttributes } from './template-from-attributes.js';\nexport { trapFocus, focusFirstFocusableChild } from './focus.js';\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/browser-utils",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"dev": "tsc --watch --preserveWatchOutput"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@prairielearn/html": "^4.0.
|
|
17
|
+
"@prairielearn/html": "^4.0.5",
|
|
18
18
|
"js-base64": "^3.7.7"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
package/src/focus.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Borrowed from Bootstrap:
|
|
2
|
+
// https://github.com/twbs/bootstrap/blob/5f75413735d8779aeefe0097af9dc5a416208ae5/js/src/dom/selector-engine.js#L67
|
|
3
|
+
const FOCUSABLE_SELECTOR = [
|
|
4
|
+
'a',
|
|
5
|
+
'button',
|
|
6
|
+
'input',
|
|
7
|
+
'textarea',
|
|
8
|
+
'select',
|
|
9
|
+
'details',
|
|
10
|
+
'[tabindex]',
|
|
11
|
+
'[contenteditable="true"]',
|
|
12
|
+
]
|
|
13
|
+
.map((selector) => `${selector}:not([tabindex^="-"]):not(.btn-close)`)
|
|
14
|
+
.join(',');
|
|
15
|
+
|
|
16
|
+
function isElement(object: Element) {
|
|
17
|
+
return typeof object?.nodeType !== 'undefined';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isVisible(element: Element) {
|
|
21
|
+
if (!isElement(element) || element.getClientRects().length === 0) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible';
|
|
26
|
+
// Handle `details` element as its content may falsly appear visible when it is closed
|
|
27
|
+
const closedDetails = element.closest('details:not([open])');
|
|
28
|
+
|
|
29
|
+
if (!closedDetails) {
|
|
30
|
+
return elementIsVisible;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (closedDetails !== element) {
|
|
34
|
+
const summary = element.closest('summary');
|
|
35
|
+
if (summary && summary.parentNode !== closedDetails) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (summary === null) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return elementIsVisible;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isDisabled(element: Element) {
|
|
48
|
+
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof (element as any).disabled !== 'undefined') {
|
|
53
|
+
return (element as any).disabled;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function focusableChildren(element: Element): HTMLElement[] {
|
|
60
|
+
const focusableChildren = element.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
61
|
+
return Array.from(focusableChildren).filter((child) => !isDisabled(child) && isVisible(child));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface FocusTrap {
|
|
65
|
+
deactivate(): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function trapFocus(element: Element): FocusTrap {
|
|
69
|
+
// Store the previous active element so we can restore it later.
|
|
70
|
+
const previousActiveElement = document.activeElement ?? document.body;
|
|
71
|
+
|
|
72
|
+
function keyDown(e: KeyboardEvent) {
|
|
73
|
+
console.log('handling keydown', e.key);
|
|
74
|
+
if (e.key !== 'Tab') return;
|
|
75
|
+
|
|
76
|
+
const focusable = focusableChildren(element);
|
|
77
|
+
const firstFocusable = focusable[0];
|
|
78
|
+
const lastFocusable = focusable[focusable.length - 1];
|
|
79
|
+
|
|
80
|
+
if (e.shiftKey) {
|
|
81
|
+
// Tabbing backwards
|
|
82
|
+
if (document.activeElement === firstFocusable) {
|
|
83
|
+
(focusable[focusable.length - 1] as HTMLElement).focus();
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
// Tabbing forwards
|
|
88
|
+
if (document.activeElement === lastFocusable) {
|
|
89
|
+
(focusable[0] as HTMLElement).focus();
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
document.addEventListener('keydown', keyDown);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
deactivate() {
|
|
99
|
+
document.removeEventListener('keydown', keyDown);
|
|
100
|
+
(previousActiveElement as HTMLElement)?.focus();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function focusFirstFocusableChild(el: HTMLElement) {
|
|
106
|
+
// In case the user (or more frequently, Cypress) is too fast and focuses a
|
|
107
|
+
// specific element inside the container before this script runs, don't transfer
|
|
108
|
+
// focus to a different element.
|
|
109
|
+
if (el.contains(document.activeElement)) return;
|
|
110
|
+
|
|
111
|
+
// Escape hatch: if the first element isn't the one that should be focused,
|
|
112
|
+
// add the `autofocus` attribute to the element that should be.
|
|
113
|
+
const autofocusElement = el.querySelector<HTMLElement>('[autofocus]');
|
|
114
|
+
if (autofocusElement) {
|
|
115
|
+
autofocusElement.focus();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const focusablePopoverChildren = focusableChildren(el);
|
|
120
|
+
if (focusablePopoverChildren.length > 0) {
|
|
121
|
+
focusablePopoverChildren[0].focus();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// If we still couldn't find a child element, focus the container itself.
|
|
126
|
+
el.focus();
|
|
127
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { onDocumentReady } from './on-document-ready.js';
|
|
|
2
2
|
export { parseHTML, parseHTMLElement } from './parse-html.js';
|
|
3
3
|
export { EncodedData, decodeData } from './encode-data.js';
|
|
4
4
|
export { templateFromAttributes } from './template-from-attributes.js';
|
|
5
|
+
export { trapFocus, focusFirstFocusableChild } from './focus.js';
|