@crisp-ui-kit/crisp 0.13.1 → 0.14.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.
|
@@ -42,17 +42,37 @@ const contextmenu_recipe_1 = require("./contextmenu.recipe");
|
|
|
42
42
|
* Wrap the target; right-clicking it opens the menu (flipping at the viewport
|
|
43
43
|
* edges), closing on select, outside pointer-down, and Escape.
|
|
44
44
|
*/
|
|
45
|
-
function ContextMenu({ children, items, className }) {
|
|
45
|
+
function ContextMenu({ children, items, menuLabel = "Context menu", className, }) {
|
|
46
46
|
const rootRef = React.useRef(null);
|
|
47
|
+
const menuRef = React.useRef(null);
|
|
48
|
+
const returnFocusTo = React.useRef(null);
|
|
47
49
|
const [pos, setPos] = React.useState(null);
|
|
48
50
|
const open = (e) => {
|
|
49
51
|
e.preventDefault();
|
|
52
|
+
// where focus goes back to on close. The menu takes focus to be operable
|
|
53
|
+
// at all, so something has to remember what had it.
|
|
54
|
+
returnFocusTo.current = document.activeElement;
|
|
50
55
|
// flip so the menu stays on-screen near the right/bottom edges
|
|
51
56
|
const x = Math.min(e.clientX, window.innerWidth - 220);
|
|
52
57
|
const y = Math.min(e.clientY, window.innerHeight - 12 - items.length * 34);
|
|
53
58
|
setPos({ x: Math.max(8, x), y: Math.max(8, y) });
|
|
54
59
|
};
|
|
55
|
-
const close = React.useCallback(() =>
|
|
60
|
+
const close = React.useCallback((restoreFocus = false) => {
|
|
61
|
+
setPos(null);
|
|
62
|
+
if (restoreFocus)
|
|
63
|
+
returnFocusTo.current?.focus?.();
|
|
64
|
+
returnFocusTo.current = null;
|
|
65
|
+
}, []);
|
|
66
|
+
/** The items a roving focus may land on: not separators, not disabled. */
|
|
67
|
+
const enabled = React.useCallback(() => Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? []), []);
|
|
68
|
+
// `role="menu"` puts a screen reader in application mode, where the arrow
|
|
69
|
+
// keys are the only navigation there is — so opening has to put focus on an
|
|
70
|
+
// item, or the role is a promise the component does not keep (#188).
|
|
71
|
+
React.useEffect(() => {
|
|
72
|
+
if (!pos)
|
|
73
|
+
return;
|
|
74
|
+
enabled()[0]?.focus();
|
|
75
|
+
}, [pos, enabled]);
|
|
56
76
|
React.useEffect(() => {
|
|
57
77
|
if (!pos)
|
|
58
78
|
return;
|
|
@@ -60,7 +80,7 @@ function ContextMenu({ children, items, className }) {
|
|
|
60
80
|
if (!rootRef.current?.contains(e.target))
|
|
61
81
|
close();
|
|
62
82
|
};
|
|
63
|
-
const onKey = (e) => e.key === "Escape" && close();
|
|
83
|
+
const onKey = (e) => e.key === "Escape" && close(true);
|
|
64
84
|
document.addEventListener("pointerdown", onDown);
|
|
65
85
|
document.addEventListener("keydown", onKey);
|
|
66
86
|
return () => {
|
|
@@ -68,11 +88,34 @@ function ContextMenu({ children, items, className }) {
|
|
|
68
88
|
document.removeEventListener("keydown", onKey);
|
|
69
89
|
};
|
|
70
90
|
}, [pos, close]);
|
|
71
|
-
|
|
91
|
+
const onMenuKeyDown = (e) => {
|
|
92
|
+
// a menu is not a focus trap: Tab leaves, and leaving means closing
|
|
93
|
+
if (e.key === "Tab") {
|
|
94
|
+
close(true);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
|
|
98
|
+
const items = enabled();
|
|
99
|
+
if (!items.length)
|
|
100
|
+
return;
|
|
101
|
+
const at = items.indexOf(document.activeElement);
|
|
102
|
+
let next = null;
|
|
103
|
+
if (step)
|
|
104
|
+
next = (at + step + items.length) % items.length;
|
|
105
|
+
else if (e.key === "Home")
|
|
106
|
+
next = 0;
|
|
107
|
+
else if (e.key === "End")
|
|
108
|
+
next = items.length - 1;
|
|
109
|
+
if (next === null)
|
|
110
|
+
return;
|
|
111
|
+
e.preventDefault();
|
|
112
|
+
items[next]?.focus();
|
|
113
|
+
};
|
|
114
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, contextmenu_recipe_1.contextmenuRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { onContextMenu: open, children: children }), pos && ((0, jsx_runtime_1.jsx)("div", { ref: menuRef, className: "contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? ((0, jsx_runtime_1.jsx)("hr", { className: "contextmenu-sep" }, i)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
72
115
|
? "contextmenu-item contextmenu-item--danger"
|
|
73
|
-
: "contextmenu-item", onClick: () => {
|
|
116
|
+
: "contextmenu-item", tabIndex: -1, onClick: () => {
|
|
74
117
|
item.onSelect?.();
|
|
75
|
-
close();
|
|
118
|
+
close(true);
|
|
76
119
|
}, children: [item.icon, item.label, item.shortcut && ((0, jsx_runtime_1.jsx)("span", { className: "contextmenu-shortcut", children: item.shortcut }))] }, i))) }))] }));
|
|
77
120
|
}
|
|
78
121
|
ContextMenu.displayName = "ContextMenu";
|
|
@@ -6,17 +6,37 @@ import { contextmenuRecipe } from "./contextmenu.recipe.js";
|
|
|
6
6
|
* Wrap the target; right-clicking it opens the menu (flipping at the viewport
|
|
7
7
|
* edges), closing on select, outside pointer-down, and Escape.
|
|
8
8
|
*/
|
|
9
|
-
export function ContextMenu({ children, items, className }) {
|
|
9
|
+
export function ContextMenu({ children, items, menuLabel = "Context menu", className, }) {
|
|
10
10
|
const rootRef = React.useRef(null);
|
|
11
|
+
const menuRef = React.useRef(null);
|
|
12
|
+
const returnFocusTo = React.useRef(null);
|
|
11
13
|
const [pos, setPos] = React.useState(null);
|
|
12
14
|
const open = (e) => {
|
|
13
15
|
e.preventDefault();
|
|
16
|
+
// where focus goes back to on close. The menu takes focus to be operable
|
|
17
|
+
// at all, so something has to remember what had it.
|
|
18
|
+
returnFocusTo.current = document.activeElement;
|
|
14
19
|
// flip so the menu stays on-screen near the right/bottom edges
|
|
15
20
|
const x = Math.min(e.clientX, window.innerWidth - 220);
|
|
16
21
|
const y = Math.min(e.clientY, window.innerHeight - 12 - items.length * 34);
|
|
17
22
|
setPos({ x: Math.max(8, x), y: Math.max(8, y) });
|
|
18
23
|
};
|
|
19
|
-
const close = React.useCallback(() =>
|
|
24
|
+
const close = React.useCallback((restoreFocus = false) => {
|
|
25
|
+
setPos(null);
|
|
26
|
+
if (restoreFocus)
|
|
27
|
+
returnFocusTo.current?.focus?.();
|
|
28
|
+
returnFocusTo.current = null;
|
|
29
|
+
}, []);
|
|
30
|
+
/** The items a roving focus may land on: not separators, not disabled. */
|
|
31
|
+
const enabled = React.useCallback(() => Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]:not([disabled])') ?? []), []);
|
|
32
|
+
// `role="menu"` puts a screen reader in application mode, where the arrow
|
|
33
|
+
// keys are the only navigation there is — so opening has to put focus on an
|
|
34
|
+
// item, or the role is a promise the component does not keep (#188).
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
if (!pos)
|
|
37
|
+
return;
|
|
38
|
+
enabled()[0]?.focus();
|
|
39
|
+
}, [pos, enabled]);
|
|
20
40
|
React.useEffect(() => {
|
|
21
41
|
if (!pos)
|
|
22
42
|
return;
|
|
@@ -24,7 +44,7 @@ export function ContextMenu({ children, items, className }) {
|
|
|
24
44
|
if (!rootRef.current?.contains(e.target))
|
|
25
45
|
close();
|
|
26
46
|
};
|
|
27
|
-
const onKey = (e) => e.key === "Escape" && close();
|
|
47
|
+
const onKey = (e) => e.key === "Escape" && close(true);
|
|
28
48
|
document.addEventListener("pointerdown", onDown);
|
|
29
49
|
document.addEventListener("keydown", onKey);
|
|
30
50
|
return () => {
|
|
@@ -32,11 +52,34 @@ export function ContextMenu({ children, items, className }) {
|
|
|
32
52
|
document.removeEventListener("keydown", onKey);
|
|
33
53
|
};
|
|
34
54
|
}, [pos, close]);
|
|
35
|
-
|
|
55
|
+
const onMenuKeyDown = (e) => {
|
|
56
|
+
// a menu is not a focus trap: Tab leaves, and leaving means closing
|
|
57
|
+
if (e.key === "Tab") {
|
|
58
|
+
close(true);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
|
|
62
|
+
const items = enabled();
|
|
63
|
+
if (!items.length)
|
|
64
|
+
return;
|
|
65
|
+
const at = items.indexOf(document.activeElement);
|
|
66
|
+
let next = null;
|
|
67
|
+
if (step)
|
|
68
|
+
next = (at + step + items.length) % items.length;
|
|
69
|
+
else if (e.key === "Home")
|
|
70
|
+
next = 0;
|
|
71
|
+
else if (e.key === "End")
|
|
72
|
+
next = items.length - 1;
|
|
73
|
+
if (next === null)
|
|
74
|
+
return;
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
items[next]?.focus();
|
|
77
|
+
};
|
|
78
|
+
return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_jsx("div", { ref: menuRef, className: "contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? (_jsx("hr", { className: "contextmenu-sep" }, i)) : (_jsxs("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
36
79
|
? "contextmenu-item contextmenu-item--danger"
|
|
37
|
-
: "contextmenu-item", onClick: () => {
|
|
80
|
+
: "contextmenu-item", tabIndex: -1, onClick: () => {
|
|
38
81
|
item.onSelect?.();
|
|
39
|
-
close();
|
|
82
|
+
close(true);
|
|
40
83
|
}, children: [item.icon, item.label, item.shortcut && (_jsx("span", { className: "contextmenu-shortcut", children: item.shortcut }))] }, i))) }))] }));
|
|
41
84
|
}
|
|
42
85
|
ContextMenu.displayName = "ContextMenu";
|
|
@@ -14,6 +14,8 @@ export type ContextMenuProps = {
|
|
|
14
14
|
/** The area that opens the menu on right-click. */
|
|
15
15
|
children: React.ReactNode;
|
|
16
16
|
items: ContextMenuOption[];
|
|
17
|
+
/** Accessible name for the menu surface. */
|
|
18
|
+
menuLabel?: string;
|
|
17
19
|
className?: string;
|
|
18
20
|
};
|
|
19
21
|
/**
|
|
@@ -21,7 +23,7 @@ export type ContextMenuProps = {
|
|
|
21
23
|
* Wrap the target; right-clicking it opens the menu (flipping at the viewport
|
|
22
24
|
* edges), closing on select, outside pointer-down, and Escape.
|
|
23
25
|
*/
|
|
24
|
-
export declare function ContextMenu({ children, items, className }: ContextMenuProps): React.JSX.Element;
|
|
26
|
+
export declare function ContextMenu({ children, items, menuLabel, className, }: ContextMenuProps): React.JSX.Element;
|
|
25
27
|
export declare namespace ContextMenu {
|
|
26
28
|
var displayName: string;
|
|
27
29
|
}
|