colibri-ui 0.8.94 → 0.8.96
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/Modal/Modal.module.css +11 -0
- package/Modal/index.js +9 -9
- package/Modal/index.props.d.ts +6 -0
- package/esm/Modal/Modal.module.css +11 -0
- package/esm/Modal/index.js +10 -10
- package/esm/Modal/index.props.d.ts +6 -0
- package/esm/hooks/useTrapFocus/index.d.ts +7 -2
- package/esm/hooks/useTrapFocus/index.js +62 -7
- package/hooks/useTrapFocus/index.d.ts +7 -2
- package/hooks/useTrapFocus/index.js +61 -6
- package/package.json +1 -1
package/Modal/Modal.module.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
.root {
|
|
2
2
|
bottom: 0;
|
|
3
3
|
height: 100%;
|
|
4
|
+
outline: none;
|
|
4
5
|
position: fixed;
|
|
5
6
|
top: 0;
|
|
6
7
|
width: 100%;
|
|
@@ -60,4 +61,14 @@
|
|
|
60
61
|
max-height: calc(100% - var(--space-8));
|
|
61
62
|
max-width: calc(100% - var(--space-8));
|
|
62
63
|
min-width: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.modal-wrapper_contained.modal-wrapper_fill {
|
|
67
|
+
border: 0;
|
|
68
|
+
border-radius: 0;
|
|
69
|
+
height: 100%;
|
|
70
|
+
max-height: none;
|
|
71
|
+
max-width: none;
|
|
72
|
+
min-width: 0;
|
|
73
|
+
width: 100%;
|
|
63
74
|
}
|
package/Modal/index.js
CHANGED
|
@@ -30,9 +30,10 @@ const ModalRoot = ({
|
|
|
30
30
|
className,
|
|
31
31
|
onClose,
|
|
32
32
|
title,
|
|
33
|
-
withinParent = false
|
|
33
|
+
withinParent = false,
|
|
34
|
+
fillParent = false
|
|
34
35
|
}) => {
|
|
35
|
-
const
|
|
36
|
+
const [dialog, setDialog] = React.useState(null);
|
|
36
37
|
const [container, setContainer] = React.useState(null);
|
|
37
38
|
const parentModal = React.useContext(context.ModalContext);
|
|
38
39
|
const parentContainer = withinParent ? parentModal === null || parentModal === void 0 ? void 0 : parentModal.container : undefined;
|
|
@@ -42,7 +43,7 @@ const ModalRoot = ({
|
|
|
42
43
|
const [titleCount, setTitleCount] = React.useState(0);
|
|
43
44
|
const isTopmost = index$5.useModalStack(titleId);
|
|
44
45
|
index$6.useScrollLock();
|
|
45
|
-
index$7.useTrapFocus(
|
|
46
|
+
index$7.useTrapFocus(dialog, isTopmost);
|
|
46
47
|
const handleClose = React.useCallback(() => {
|
|
47
48
|
onClose === null || onClose === void 0 ? void 0 : onClose();
|
|
48
49
|
}, [onClose]);
|
|
@@ -50,10 +51,6 @@ const ModalRoot = ({
|
|
|
50
51
|
setTitleCount(count => count + 1);
|
|
51
52
|
return () => setTitleCount(count => count - 1);
|
|
52
53
|
}, []);
|
|
53
|
-
const setWrapperRef = React.useCallback(node => {
|
|
54
|
-
modalRef.current = node;
|
|
55
|
-
setContainer(node);
|
|
56
|
-
}, []);
|
|
57
54
|
const contextValue = React.useMemo(() => ({
|
|
58
55
|
onClose: handleClose,
|
|
59
56
|
titleId,
|
|
@@ -99,8 +96,10 @@ const ModalRoot = ({
|
|
|
99
96
|
className: clsx__default.default(styles__default.default.root, {
|
|
100
97
|
[styles__default.default.root_contained]: isContained
|
|
101
98
|
}),
|
|
99
|
+
ref: setDialog,
|
|
102
100
|
role: "dialog",
|
|
103
101
|
"aria-modal": "true",
|
|
102
|
+
tabIndex: -1,
|
|
104
103
|
"aria-labelledby": titleCount > 0 ? titleId : undefined
|
|
105
104
|
}, /*#__PURE__*/React__default.default.createElement("div", {
|
|
106
105
|
className: styles__default.default["modal-overlay"],
|
|
@@ -110,9 +109,10 @@ const ModalRoot = ({
|
|
|
110
109
|
className: clsx__default.default(styles__default.default["modal-wrapper"], {
|
|
111
110
|
[styles__default.default["modal-wrapper_desktop"]]: isDesktop,
|
|
112
111
|
[styles__default.default["modal-wrapper_mobile"]]: !isDesktop,
|
|
113
|
-
[styles__default.default["modal-wrapper_contained"]]: isContained
|
|
112
|
+
[styles__default.default["modal-wrapper_contained"]]: isContained,
|
|
113
|
+
[styles__default.default["modal-wrapper_fill"]]: isContained && fillParent
|
|
114
114
|
}, className),
|
|
115
|
-
ref:
|
|
115
|
+
ref: setContainer
|
|
116
116
|
}, content))));
|
|
117
117
|
};
|
|
118
118
|
const Modal = Object.assign(ModalRoot, {
|
package/Modal/index.props.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export type ModalProps = {
|
|
|
13
13
|
* rendered inside another `Modal`.
|
|
14
14
|
*/
|
|
15
15
|
withinParent?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Makes a `withinParent` modal take the whole parent window, with no
|
|
18
|
+
* offset. The parent border and radius form the edges. Has no effect
|
|
19
|
+
* without `withinParent` or a parent modal.
|
|
20
|
+
*/
|
|
21
|
+
fillParent?: boolean;
|
|
16
22
|
};
|
|
17
23
|
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
18
24
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
.root {
|
|
2
2
|
bottom: 0;
|
|
3
3
|
height: 100%;
|
|
4
|
+
outline: none;
|
|
4
5
|
position: fixed;
|
|
5
6
|
top: 0;
|
|
6
7
|
width: 100%;
|
|
@@ -60,4 +61,14 @@
|
|
|
60
61
|
max-height: calc(100% - var(--space-8));
|
|
61
62
|
max-width: calc(100% - var(--space-8));
|
|
62
63
|
min-width: 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.modal-wrapper_contained.modal-wrapper_fill {
|
|
67
|
+
border: 0;
|
|
68
|
+
border-radius: 0;
|
|
69
|
+
height: 100%;
|
|
70
|
+
max-height: none;
|
|
71
|
+
max-width: none;
|
|
72
|
+
min-width: 0;
|
|
73
|
+
width: 100%;
|
|
63
74
|
}
|
package/esm/Modal/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useState, useContext, useId, useCallback, useMemo, useEffect, Children, isValidElement } from 'react';
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useScrollLock } from '../hooks/useScrollLock/index.js';
|
|
4
4
|
import { useTrapFocus } from '../hooks/useTrapFocus/index.js';
|
|
@@ -22,9 +22,10 @@ const ModalRoot = ({
|
|
|
22
22
|
className,
|
|
23
23
|
onClose,
|
|
24
24
|
title,
|
|
25
|
-
withinParent = false
|
|
25
|
+
withinParent = false,
|
|
26
|
+
fillParent = false
|
|
26
27
|
}) => {
|
|
27
|
-
const
|
|
28
|
+
const [dialog, setDialog] = useState(null);
|
|
28
29
|
const [container, setContainer] = useState(null);
|
|
29
30
|
const parentModal = useContext(ModalContext);
|
|
30
31
|
const parentContainer = withinParent ? parentModal === null || parentModal === void 0 ? void 0 : parentModal.container : undefined;
|
|
@@ -34,7 +35,7 @@ const ModalRoot = ({
|
|
|
34
35
|
const [titleCount, setTitleCount] = useState(0);
|
|
35
36
|
const isTopmost = useModalStack(titleId);
|
|
36
37
|
useScrollLock();
|
|
37
|
-
useTrapFocus(
|
|
38
|
+
useTrapFocus(dialog, isTopmost);
|
|
38
39
|
const handleClose = useCallback(() => {
|
|
39
40
|
onClose === null || onClose === void 0 ? void 0 : onClose();
|
|
40
41
|
}, [onClose]);
|
|
@@ -42,10 +43,6 @@ const ModalRoot = ({
|
|
|
42
43
|
setTitleCount(count => count + 1);
|
|
43
44
|
return () => setTitleCount(count => count - 1);
|
|
44
45
|
}, []);
|
|
45
|
-
const setWrapperRef = useCallback(node => {
|
|
46
|
-
modalRef.current = node;
|
|
47
|
-
setContainer(node);
|
|
48
|
-
}, []);
|
|
49
46
|
const contextValue = useMemo(() => ({
|
|
50
47
|
onClose: handleClose,
|
|
51
48
|
titleId,
|
|
@@ -91,8 +88,10 @@ const ModalRoot = ({
|
|
|
91
88
|
className: clsx(styles.root, {
|
|
92
89
|
[styles.root_contained]: isContained
|
|
93
90
|
}),
|
|
91
|
+
ref: setDialog,
|
|
94
92
|
role: "dialog",
|
|
95
93
|
"aria-modal": "true",
|
|
94
|
+
tabIndex: -1,
|
|
96
95
|
"aria-labelledby": titleCount > 0 ? titleId : undefined
|
|
97
96
|
}, /*#__PURE__*/React.createElement("div", {
|
|
98
97
|
className: styles["modal-overlay"],
|
|
@@ -102,9 +101,10 @@ const ModalRoot = ({
|
|
|
102
101
|
className: clsx(styles["modal-wrapper"], {
|
|
103
102
|
[styles["modal-wrapper_desktop"]]: isDesktop,
|
|
104
103
|
[styles["modal-wrapper_mobile"]]: !isDesktop,
|
|
105
|
-
[styles["modal-wrapper_contained"]]: isContained
|
|
104
|
+
[styles["modal-wrapper_contained"]]: isContained,
|
|
105
|
+
[styles["modal-wrapper_fill"]]: isContained && fillParent
|
|
106
106
|
}, className),
|
|
107
|
-
ref:
|
|
107
|
+
ref: setContainer
|
|
108
108
|
}, content))));
|
|
109
109
|
};
|
|
110
110
|
const Modal = Object.assign(ModalRoot, {
|
|
@@ -13,6 +13,12 @@ export type ModalProps = {
|
|
|
13
13
|
* rendered inside another `Modal`.
|
|
14
14
|
*/
|
|
15
15
|
withinParent?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Makes a `withinParent` modal take the whole parent window, with no
|
|
18
|
+
* offset. The parent border and radius form the edges. Has no effect
|
|
19
|
+
* without `withinParent` or a parent modal.
|
|
20
|
+
*/
|
|
21
|
+
fillParent?: boolean;
|
|
16
22
|
};
|
|
17
23
|
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
18
24
|
/**
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Moves focus into `element` when it mounts (first focusable element, or the
|
|
3
|
+
* element itself, which needs tabIndex={-1}), keeps Tab inside it while
|
|
4
|
+
* `isActive` returns true, and returns focus to the previously focused
|
|
5
|
+
* element when it unmounts.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useTrapFocus(element: HTMLElement | null, isActive?: () => boolean): void;
|
|
@@ -1,15 +1,70 @@
|
|
|
1
|
-
import { useEffect } from 'react';
|
|
1
|
+
import { useRef, useEffect } from 'react';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
const candidateSelector = ["a[href]", "button", "textarea", 'input:not([type="hidden"])', "select", "[tabindex]", '[role="button"]'].join(",");
|
|
4
|
+
// The selector only finds candidates: `[tabindex]` also matches disabled
|
|
5
|
+
// controls and `[role="button"]` alone does not make an element focusable.
|
|
6
|
+
const isTabbable = node => node.tabIndex >= 0 && !node.matches(":disabled");
|
|
7
|
+
const getFocusableElements = element => Array.from(element.querySelectorAll(candidateSelector)).filter(isTabbable);
|
|
8
|
+
// Open traps in mount order, so a closing trap can hand its saved focus to
|
|
9
|
+
// traps opened on top of it.
|
|
10
|
+
const focusStack = [];
|
|
11
|
+
/**
|
|
12
|
+
* Moves focus into `element` when it mounts (first focusable element, or the
|
|
13
|
+
* element itself, which needs tabIndex={-1}), keeps Tab inside it while
|
|
14
|
+
* `isActive` returns true, and returns focus to the previously focused
|
|
15
|
+
* element when it unmounts.
|
|
16
|
+
*/
|
|
17
|
+
function useTrapFocus(element, isActive) {
|
|
18
|
+
// Read during render, before children with autoFocus take focus.
|
|
19
|
+
const previousFocusRef = useRef(null);
|
|
20
|
+
if (!element && typeof document !== "undefined") {
|
|
21
|
+
previousFocusRef.current = document.activeElement;
|
|
22
|
+
}
|
|
5
23
|
useEffect(() => {
|
|
24
|
+
var _a;
|
|
25
|
+
if (!element) return;
|
|
26
|
+
const entry = {
|
|
27
|
+
element,
|
|
28
|
+
previousFocus: previousFocusRef.current
|
|
29
|
+
};
|
|
30
|
+
focusStack.push(entry);
|
|
31
|
+
if (!element.contains(document.activeElement)) {
|
|
32
|
+
((_a = getFocusableElements(element)[0]) !== null && _a !== void 0 ? _a : element).focus();
|
|
33
|
+
}
|
|
34
|
+
return () => {
|
|
35
|
+
focusStack.splice(focusStack.indexOf(entry), 1);
|
|
36
|
+
// Traps opened from inside this one would restore focus to a removed
|
|
37
|
+
// element; give them the focus this trap saved instead.
|
|
38
|
+
focusStack.forEach(other => {
|
|
39
|
+
if (element.contains(other.previousFocus)) {
|
|
40
|
+
other.previousFocus = entry.previousFocus;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// Focus inside another trap stays there. Focus removed with this
|
|
44
|
+
// element falls back to body.
|
|
45
|
+
const active = document.activeElement;
|
|
46
|
+
const ownsFocus = !active || active === document.body || element.contains(active);
|
|
47
|
+
const {
|
|
48
|
+
previousFocus
|
|
49
|
+
} = entry;
|
|
50
|
+
if (ownsFocus && previousFocus instanceof HTMLElement && previousFocus.isConnected) {
|
|
51
|
+
previousFocus.focus();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}, [element]);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!element) return;
|
|
6
57
|
const handler = event => {
|
|
7
|
-
if (
|
|
58
|
+
if (event.key !== "Tab") return;
|
|
8
59
|
if (isActive && !isActive()) return;
|
|
9
|
-
const focusable =
|
|
60
|
+
const focusable = getFocusableElements(element);
|
|
10
61
|
const firstElement = focusable[0];
|
|
11
62
|
const lastElement = focusable[focusable.length - 1];
|
|
12
|
-
if (!lastElement || !firstElement)
|
|
63
|
+
if (!lastElement || !firstElement) {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
element.focus();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
13
68
|
const focusOutside = document.activeElement ? !focusable.includes(document.activeElement) : true;
|
|
14
69
|
if (event.shiftKey && (document.activeElement === firstElement || focusOutside)) {
|
|
15
70
|
lastElement.focus();
|
|
@@ -23,7 +78,7 @@ function useTrapFocus(refObject, isActive) {
|
|
|
23
78
|
return () => {
|
|
24
79
|
document.removeEventListener("keydown", handler);
|
|
25
80
|
};
|
|
26
|
-
}, [
|
|
81
|
+
}, [element, isActive]);
|
|
27
82
|
}
|
|
28
83
|
|
|
29
84
|
export { useTrapFocus };
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Moves focus into `element` when it mounts (first focusable element, or the
|
|
3
|
+
* element itself, which needs tabIndex={-1}), keeps Tab inside it while
|
|
4
|
+
* `isActive` returns true, and returns focus to the previously focused
|
|
5
|
+
* element when it unmounts.
|
|
6
|
+
*/
|
|
7
|
+
export declare function useTrapFocus(element: HTMLElement | null, isActive?: () => boolean): void;
|
|
@@ -2,16 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
var React = require('react');
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
5
|
+
const candidateSelector = ["a[href]", "button", "textarea", 'input:not([type="hidden"])', "select", "[tabindex]", '[role="button"]'].join(",");
|
|
6
|
+
// The selector only finds candidates: `[tabindex]` also matches disabled
|
|
7
|
+
// controls and `[role="button"]` alone does not make an element focusable.
|
|
8
|
+
const isTabbable = node => node.tabIndex >= 0 && !node.matches(":disabled");
|
|
9
|
+
const getFocusableElements = element => Array.from(element.querySelectorAll(candidateSelector)).filter(isTabbable);
|
|
10
|
+
// Open traps in mount order, so a closing trap can hand its saved focus to
|
|
11
|
+
// traps opened on top of it.
|
|
12
|
+
const focusStack = [];
|
|
13
|
+
/**
|
|
14
|
+
* Moves focus into `element` when it mounts (first focusable element, or the
|
|
15
|
+
* element itself, which needs tabIndex={-1}), keeps Tab inside it while
|
|
16
|
+
* `isActive` returns true, and returns focus to the previously focused
|
|
17
|
+
* element when it unmounts.
|
|
18
|
+
*/
|
|
19
|
+
function useTrapFocus(element, isActive) {
|
|
20
|
+
// Read during render, before children with autoFocus take focus.
|
|
21
|
+
const previousFocusRef = React.useRef(null);
|
|
22
|
+
if (!element && typeof document !== "undefined") {
|
|
23
|
+
previousFocusRef.current = document.activeElement;
|
|
24
|
+
}
|
|
7
25
|
React.useEffect(() => {
|
|
26
|
+
var _a;
|
|
27
|
+
if (!element) return;
|
|
28
|
+
const entry = {
|
|
29
|
+
element,
|
|
30
|
+
previousFocus: previousFocusRef.current
|
|
31
|
+
};
|
|
32
|
+
focusStack.push(entry);
|
|
33
|
+
if (!element.contains(document.activeElement)) {
|
|
34
|
+
((_a = getFocusableElements(element)[0]) !== null && _a !== void 0 ? _a : element).focus();
|
|
35
|
+
}
|
|
36
|
+
return () => {
|
|
37
|
+
focusStack.splice(focusStack.indexOf(entry), 1);
|
|
38
|
+
// Traps opened from inside this one would restore focus to a removed
|
|
39
|
+
// element; give them the focus this trap saved instead.
|
|
40
|
+
focusStack.forEach(other => {
|
|
41
|
+
if (element.contains(other.previousFocus)) {
|
|
42
|
+
other.previousFocus = entry.previousFocus;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// Focus inside another trap stays there. Focus removed with this
|
|
46
|
+
// element falls back to body.
|
|
47
|
+
const active = document.activeElement;
|
|
48
|
+
const ownsFocus = !active || active === document.body || element.contains(active);
|
|
49
|
+
const {
|
|
50
|
+
previousFocus
|
|
51
|
+
} = entry;
|
|
52
|
+
if (ownsFocus && previousFocus instanceof HTMLElement && previousFocus.isConnected) {
|
|
53
|
+
previousFocus.focus();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}, [element]);
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
if (!element) return;
|
|
8
59
|
const handler = event => {
|
|
9
|
-
if (
|
|
60
|
+
if (event.key !== "Tab") return;
|
|
10
61
|
if (isActive && !isActive()) return;
|
|
11
|
-
const focusable =
|
|
62
|
+
const focusable = getFocusableElements(element);
|
|
12
63
|
const firstElement = focusable[0];
|
|
13
64
|
const lastElement = focusable[focusable.length - 1];
|
|
14
|
-
if (!lastElement || !firstElement)
|
|
65
|
+
if (!lastElement || !firstElement) {
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
element.focus();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
15
70
|
const focusOutside = document.activeElement ? !focusable.includes(document.activeElement) : true;
|
|
16
71
|
if (event.shiftKey && (document.activeElement === firstElement || focusOutside)) {
|
|
17
72
|
lastElement.focus();
|
|
@@ -25,7 +80,7 @@ function useTrapFocus(refObject, isActive) {
|
|
|
25
80
|
return () => {
|
|
26
81
|
document.removeEventListener("keydown", handler);
|
|
27
82
|
};
|
|
28
|
-
}, [
|
|
83
|
+
}, [element, isActive]);
|
|
29
84
|
}
|
|
30
85
|
|
|
31
86
|
exports.useTrapFocus = useTrapFocus;
|