@navikt/ds-react 6.14.0 → 6.16.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/cjs/form/combobox/FilteredOptions/AddNewOption.d.ts +3 -0
- package/cjs/form/combobox/FilteredOptions/AddNewOption.js +41 -0
- package/cjs/form/combobox/FilteredOptions/AddNewOption.js.map +1 -0
- package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +13 -57
- package/cjs/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
- package/cjs/form/combobox/FilteredOptions/FilteredOptionsItem.d.ts +6 -0
- package/cjs/form/combobox/FilteredOptions/FilteredOptionsItem.js +43 -0
- package/cjs/form/combobox/FilteredOptions/FilteredOptionsItem.js.map +1 -0
- package/cjs/form/combobox/FilteredOptions/LoadingMessage.d.ts +3 -0
- package/cjs/form/combobox/FilteredOptions/LoadingMessage.js +16 -0
- package/cjs/form/combobox/FilteredOptions/LoadingMessage.js.map +1 -0
- package/cjs/form/combobox/FilteredOptions/MaxSelectedMessage.d.ts +3 -0
- package/cjs/form/combobox/FilteredOptions/MaxSelectedMessage.js +20 -0
- package/cjs/form/combobox/FilteredOptions/MaxSelectedMessage.js.map +1 -0
- package/cjs/form/combobox/FilteredOptions/NoSearchHitsMessage.d.ts +3 -0
- package/cjs/form/combobox/FilteredOptions/NoSearchHitsMessage.js +14 -0
- package/cjs/form/combobox/FilteredOptions/NoSearchHitsMessage.js.map +1 -0
- package/cjs/form/combobox/Input/Input.d.ts +1 -0
- package/cjs/form/combobox/Input/Input.js +3 -2
- package/cjs/form/combobox/Input/Input.js.map +1 -1
- package/cjs/form/combobox/Input/InputController.js +1 -1
- package/cjs/form/combobox/Input/InputController.js.map +1 -1
- package/cjs/layout/base/BasePrimitive.d.ts +5 -1
- package/cjs/layout/base/BasePrimitive.js +4 -2
- package/cjs/layout/base/BasePrimitive.js.map +1 -1
- package/cjs/overlays/floating-menu/Menu.d.ts +106 -0
- package/cjs/overlays/floating-menu/Menu.js +593 -0
- package/cjs/overlays/floating-menu/Menu.js.map +1 -0
- package/cjs/overlays/floating-menu/parts/FocusScope.d.ts +22 -0
- package/cjs/overlays/floating-menu/parts/FocusScope.js +89 -0
- package/cjs/overlays/floating-menu/parts/FocusScope.js.map +1 -0
- package/cjs/overlays/floating-menu/parts/RovingFocus.d.ts +9 -0
- package/cjs/overlays/floating-menu/parts/RovingFocus.js +112 -0
- package/cjs/overlays/floating-menu/parts/RovingFocus.js.map +1 -0
- package/cjs/overlays/floating-menu/parts/SlottedDivElement.d.ts +7 -0
- package/cjs/overlays/floating-menu/parts/SlottedDivElement.js +46 -0
- package/cjs/overlays/floating-menu/parts/SlottedDivElement.js.map +1 -0
- package/cjs/util/composeEventHandlers.d.ts +1 -1
- package/esm/form/combobox/FilteredOptions/AddNewOption.d.ts +3 -0
- package/esm/form/combobox/FilteredOptions/AddNewOption.js +36 -0
- package/esm/form/combobox/FilteredOptions/AddNewOption.js.map +1 -0
- package/esm/form/combobox/FilteredOptions/FilteredOptions.js +13 -57
- package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
- package/esm/form/combobox/FilteredOptions/FilteredOptionsItem.d.ts +6 -0
- package/esm/form/combobox/FilteredOptions/FilteredOptionsItem.js +38 -0
- package/esm/form/combobox/FilteredOptions/FilteredOptionsItem.js.map +1 -0
- package/esm/form/combobox/FilteredOptions/LoadingMessage.d.ts +3 -0
- package/esm/form/combobox/FilteredOptions/LoadingMessage.js +11 -0
- package/esm/form/combobox/FilteredOptions/LoadingMessage.js.map +1 -0
- package/esm/form/combobox/FilteredOptions/MaxSelectedMessage.d.ts +3 -0
- package/esm/form/combobox/FilteredOptions/MaxSelectedMessage.js +15 -0
- package/esm/form/combobox/FilteredOptions/MaxSelectedMessage.js.map +1 -0
- package/esm/form/combobox/FilteredOptions/NoSearchHitsMessage.d.ts +3 -0
- package/esm/form/combobox/FilteredOptions/NoSearchHitsMessage.js +9 -0
- package/esm/form/combobox/FilteredOptions/NoSearchHitsMessage.js.map +1 -0
- package/esm/form/combobox/Input/Input.d.ts +1 -0
- package/esm/form/combobox/Input/Input.js +3 -2
- package/esm/form/combobox/Input/Input.js.map +1 -1
- package/esm/form/combobox/Input/InputController.js +1 -1
- package/esm/form/combobox/Input/InputController.js.map +1 -1
- package/esm/layout/base/BasePrimitive.d.ts +5 -1
- package/esm/layout/base/BasePrimitive.js +4 -2
- package/esm/layout/base/BasePrimitive.js.map +1 -1
- package/esm/overlays/floating-menu/Menu.d.ts +106 -0
- package/esm/overlays/floating-menu/Menu.js +551 -0
- package/esm/overlays/floating-menu/Menu.js.map +1 -0
- package/esm/overlays/floating-menu/parts/FocusScope.d.ts +22 -0
- package/esm/overlays/floating-menu/parts/FocusScope.js +63 -0
- package/esm/overlays/floating-menu/parts/FocusScope.js.map +1 -0
- package/esm/overlays/floating-menu/parts/RovingFocus.d.ts +9 -0
- package/esm/overlays/floating-menu/parts/RovingFocus.js +86 -0
- package/esm/overlays/floating-menu/parts/RovingFocus.js.map +1 -0
- package/esm/overlays/floating-menu/parts/SlottedDivElement.d.ts +7 -0
- package/esm/overlays/floating-menu/parts/SlottedDivElement.js +20 -0
- package/esm/overlays/floating-menu/parts/SlottedDivElement.js.map +1 -0
- package/esm/util/composeEventHandlers.d.ts +1 -1
- package/package.json +4 -4
- package/src/form/combobox/FilteredOptions/AddNewOption.tsx +63 -0
- package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +11 -121
- package/src/form/combobox/FilteredOptions/FilteredOptionsItem.tsx +73 -0
- package/src/form/combobox/FilteredOptions/LoadingMessage.tsx +20 -0
- package/src/form/combobox/FilteredOptions/MaxSelectedMessage.tsx +27 -0
- package/src/form/combobox/FilteredOptions/NoSearchHitsMessage.tsx +19 -0
- package/src/form/combobox/Input/Input.tsx +4 -2
- package/src/form/combobox/Input/InputController.tsx +1 -0
- package/src/layout/base/BasePrimitive.tsx +9 -0
- package/src/overlays/floating-menu/Menu.tsx +1177 -0
- package/src/overlays/floating-menu/parts/FocusScope.tsx +84 -0
- package/src/overlays/floating-menu/parts/RovingFocus.tsx +121 -0
- package/src/overlays/floating-menu/parts/SlottedDivElement.tsx +17 -0
- package/src/util/composeEventHandlers.ts +1 -1
|
@@ -0,0 +1,1177 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react";
|
|
8
|
+
import ReactDOM from "react-dom";
|
|
9
|
+
import { Portal } from "../../portal";
|
|
10
|
+
import { composeEventHandlers } from "../../util/composeEventHandlers";
|
|
11
|
+
import { createContext } from "../../util/create-context";
|
|
12
|
+
import { useCallbackRef, useId, useMergeRefs } from "../../util/hooks";
|
|
13
|
+
import { createDescendantContext } from "../../util/hooks/descendants/useDescendant";
|
|
14
|
+
import { DismissableLayer } from "../dismissablelayer/DismissableLayer";
|
|
15
|
+
import { Floating } from "../floating/Floating";
|
|
16
|
+
import { FocusScope } from "./parts/FocusScope";
|
|
17
|
+
import { RovingFocus, RovingFocusProps } from "./parts/RovingFocus";
|
|
18
|
+
import {
|
|
19
|
+
SlottedDivElement,
|
|
20
|
+
SlottedDivElementRef,
|
|
21
|
+
SlottedDivProps,
|
|
22
|
+
} from "./parts/SlottedDivElement";
|
|
23
|
+
|
|
24
|
+
/* -------------------------------------------------------------------------- */
|
|
25
|
+
/* Constants */
|
|
26
|
+
/* -------------------------------------------------------------------------- */
|
|
27
|
+
const SELECTION_KEYS = ["Enter", " "];
|
|
28
|
+
const SUB_OPEN_KEYS = [...SELECTION_KEYS, "ArrowRight"];
|
|
29
|
+
const SUB_CLOSE_KEYS = ["ArrowLeft"];
|
|
30
|
+
const FIRST_KEYS = ["ArrowDown", "PageUp", "Home"];
|
|
31
|
+
const LAST_KEYS = ["ArrowUp", "PageDown", "End"];
|
|
32
|
+
const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
|
|
33
|
+
|
|
34
|
+
type Point = { x: number; y: number };
|
|
35
|
+
type Polygon = Point[];
|
|
36
|
+
type SubMenuSide = "left" | "right";
|
|
37
|
+
type GraceIntent = { area: Polygon; side: SubMenuSide };
|
|
38
|
+
type CheckedState = boolean | "indeterminate";
|
|
39
|
+
|
|
40
|
+
/* -------------------------------------------------------------------------- */
|
|
41
|
+
/* Menu */
|
|
42
|
+
/* -------------------------------------------------------------------------- */
|
|
43
|
+
interface MenuProps {
|
|
44
|
+
children?: React.ReactNode;
|
|
45
|
+
open?: boolean;
|
|
46
|
+
onOpenChange?: (open: boolean) => void;
|
|
47
|
+
modal?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface MenuComponent extends React.FC<MenuProps> {
|
|
51
|
+
Anchor: typeof MenuAnchor;
|
|
52
|
+
Portal: typeof MenuPortal;
|
|
53
|
+
Content: typeof MenuContent;
|
|
54
|
+
Group: typeof MenuGroup;
|
|
55
|
+
Item: typeof MenuItem;
|
|
56
|
+
CheckboxItem: typeof MenuCheckboxItem;
|
|
57
|
+
RadioGroup: typeof MenuRadioGroup;
|
|
58
|
+
RadioItem: typeof MenuRadioItem;
|
|
59
|
+
Separator: typeof MenuSeparator;
|
|
60
|
+
Sub: typeof MenuSub;
|
|
61
|
+
SubTrigger: typeof MenuSubTrigger;
|
|
62
|
+
SubContent: typeof MenuSubContent;
|
|
63
|
+
ItemIndicator: typeof MenuItemIndicator;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const [
|
|
67
|
+
MenuDescendantsProvider,
|
|
68
|
+
useMenuDescendantsContext,
|
|
69
|
+
useMenuDescendants,
|
|
70
|
+
useMenuDescendant,
|
|
71
|
+
] = createDescendantContext<SlottedDivElementRef>();
|
|
72
|
+
|
|
73
|
+
type MenuContentElementRef = React.ElementRef<typeof Floating.Content>;
|
|
74
|
+
|
|
75
|
+
type MenuContextValue = {
|
|
76
|
+
open: boolean;
|
|
77
|
+
onOpenChange: (open: boolean) => void;
|
|
78
|
+
content: MenuContentElementRef | null;
|
|
79
|
+
onContentChange: (content: MenuContentElementRef | null) => void;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const [MenuProvider, useMenuContext] = createContext<MenuContextValue>({
|
|
83
|
+
providerName: "MenuProvider",
|
|
84
|
+
hookName: "useMenuContext",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
type MenuRootContextValue = {
|
|
88
|
+
onClose: () => void;
|
|
89
|
+
isUsingKeyboardRef: React.RefObject<boolean>;
|
|
90
|
+
modal: boolean;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const [MenuRootProvider, useMenuRootContext] =
|
|
94
|
+
createContext<MenuRootContextValue>({
|
|
95
|
+
providerName: "MenuRootProvider",
|
|
96
|
+
hookName: "useMenuRootContext",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const MenuRoot = ({
|
|
100
|
+
open = false,
|
|
101
|
+
children,
|
|
102
|
+
onOpenChange,
|
|
103
|
+
modal = true,
|
|
104
|
+
}: MenuProps) => {
|
|
105
|
+
const [content, setContent] = useState<MenuContentElement | null>(null);
|
|
106
|
+
const isUsingKeyboardRef = useRef(false);
|
|
107
|
+
const handleOpenChange = useCallbackRef(onOpenChange);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const globalDocument = globalThis.document;
|
|
111
|
+
// Capturephase ensures we set the boolean before any side effects execute
|
|
112
|
+
// in response to the key or pointer event as they might depend on this value.
|
|
113
|
+
const handlePointer = () => {
|
|
114
|
+
isUsingKeyboardRef.current = false;
|
|
115
|
+
};
|
|
116
|
+
const handleKeyDown = () => {
|
|
117
|
+
isUsingKeyboardRef.current = true;
|
|
118
|
+
globalDocument.addEventListener("pointerdown", handlePointer, {
|
|
119
|
+
capture: true,
|
|
120
|
+
once: true,
|
|
121
|
+
});
|
|
122
|
+
globalDocument.addEventListener("pointermove", handlePointer, {
|
|
123
|
+
capture: true,
|
|
124
|
+
once: true,
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
globalDocument.addEventListener("keydown", handleKeyDown, {
|
|
128
|
+
capture: true,
|
|
129
|
+
});
|
|
130
|
+
return () => {
|
|
131
|
+
globalDocument.removeEventListener("keydown", handleKeyDown, {
|
|
132
|
+
capture: true,
|
|
133
|
+
});
|
|
134
|
+
globalDocument.removeEventListener("pointerdown", handlePointer, {
|
|
135
|
+
capture: true,
|
|
136
|
+
});
|
|
137
|
+
globalDocument.removeEventListener("pointermove", handlePointer, {
|
|
138
|
+
capture: true,
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<Floating>
|
|
145
|
+
<MenuProvider
|
|
146
|
+
open={open}
|
|
147
|
+
onOpenChange={handleOpenChange}
|
|
148
|
+
content={content}
|
|
149
|
+
onContentChange={setContent}
|
|
150
|
+
>
|
|
151
|
+
<MenuRootProvider
|
|
152
|
+
onClose={React.useCallback(
|
|
153
|
+
() => handleOpenChange(false),
|
|
154
|
+
[handleOpenChange],
|
|
155
|
+
)}
|
|
156
|
+
isUsingKeyboardRef={isUsingKeyboardRef}
|
|
157
|
+
modal={modal}
|
|
158
|
+
>
|
|
159
|
+
{children}
|
|
160
|
+
</MenuRootProvider>
|
|
161
|
+
</MenuProvider>
|
|
162
|
+
</Floating>
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const Menu = MenuRoot as MenuComponent;
|
|
167
|
+
|
|
168
|
+
/* -------------------------------------------------------------------------- */
|
|
169
|
+
/* Menu Anchor */
|
|
170
|
+
/* -------------------------------------------------------------------------- */
|
|
171
|
+
type MenuAnchorElement = React.ElementRef<typeof Floating.Anchor>;
|
|
172
|
+
type MenuAnchorProps = React.ComponentPropsWithoutRef<typeof Floating.Anchor>;
|
|
173
|
+
|
|
174
|
+
const MenuAnchor = forwardRef<MenuAnchorElement, MenuAnchorProps>(
|
|
175
|
+
(props: MenuAnchorProps, forwardedRef) => {
|
|
176
|
+
return <Floating.Anchor {...props} ref={forwardedRef} />;
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
/* -------------------------------------------------------------------------- */
|
|
181
|
+
/* Menu Content */
|
|
182
|
+
/* -------------------------------------------------------------------------- */
|
|
183
|
+
type MenuContentContextValue = {
|
|
184
|
+
onItemEnter: (event: React.PointerEvent) => void;
|
|
185
|
+
onItemLeave: (event: React.PointerEvent) => void;
|
|
186
|
+
onPointerLeaveTrigger: (event: React.PointerEvent) => void;
|
|
187
|
+
pointerGraceTimerRef: React.MutableRefObject<number>;
|
|
188
|
+
onPointerGraceIntentChange: (intent: GraceIntent | null) => void;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const [MenuContentProvider, useMenuContentContext] =
|
|
192
|
+
createContext<MenuContentContextValue>({
|
|
193
|
+
providerName: "MenuContentProvider",
|
|
194
|
+
hookName: "useMenuContentContext",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
type MenuContentElement = MenuContentInternalElement;
|
|
198
|
+
interface MenuContentProps extends MenuContentInternalTypeProps {}
|
|
199
|
+
|
|
200
|
+
const MenuContent = React.forwardRef<
|
|
201
|
+
MenuContentInternalElement,
|
|
202
|
+
MenuContentProps
|
|
203
|
+
>((props: MenuContentProps, ref) => {
|
|
204
|
+
const descendants = useMenuDescendants();
|
|
205
|
+
const rootContext = useMenuRootContext();
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<MenuDescendantsProvider value={descendants}>
|
|
209
|
+
{rootContext.modal ? (
|
|
210
|
+
<MenuRootContentModal {...props} ref={ref} />
|
|
211
|
+
) : (
|
|
212
|
+
<MenuRootContentNonModal {...props} ref={ref} />
|
|
213
|
+
)}
|
|
214
|
+
</MenuDescendantsProvider>
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/* ---------------------------- Non-modal content --------------------------- */
|
|
219
|
+
const MenuRootContentNonModal = React.forwardRef<
|
|
220
|
+
MenuContentInternalElement,
|
|
221
|
+
MenuContentInternalTypeProps
|
|
222
|
+
>((props: MenuContentInternalTypeProps, ref) => {
|
|
223
|
+
const context = useMenuContext();
|
|
224
|
+
return (
|
|
225
|
+
<MenuContentInternal
|
|
226
|
+
{...props}
|
|
227
|
+
ref={ref}
|
|
228
|
+
disableOutsidePointerEvents={false}
|
|
229
|
+
onDismiss={() => context.onOpenChange(false)}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
/* ------------------------------ Modal content ----------------------------- */
|
|
235
|
+
const MenuRootContentModal = forwardRef<
|
|
236
|
+
MenuContentInternalElement,
|
|
237
|
+
MenuContentInternalTypeProps
|
|
238
|
+
>((props: MenuContentInternalTypeProps, ref) => {
|
|
239
|
+
const context = useMenuContext();
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<MenuContentInternal
|
|
243
|
+
{...props}
|
|
244
|
+
ref={ref}
|
|
245
|
+
// make sure to only disable pointer events when open
|
|
246
|
+
// this avoids blocking interactions while animating out
|
|
247
|
+
disableOutsidePointerEvents={context.open}
|
|
248
|
+
// When focus is trapped, a `focusout` event may still happen.
|
|
249
|
+
// We make sure we don't trigger our `onDismiss` in such case.
|
|
250
|
+
onFocusOutside={composeEventHandlers(
|
|
251
|
+
props.onFocusOutside,
|
|
252
|
+
(event) => event.preventDefault(),
|
|
253
|
+
{ checkForDefaultPrevented: false },
|
|
254
|
+
)}
|
|
255
|
+
onDismiss={() => context.onOpenChange(false)}
|
|
256
|
+
/>
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
/* -------------------------- Menu content internals ------------------------- */
|
|
261
|
+
type MenuContentInternalElement = React.ElementRef<typeof Floating.Content>;
|
|
262
|
+
type FocusScopeProps = React.ComponentPropsWithoutRef<typeof FocusScope>;
|
|
263
|
+
type DismissableLayerProps = React.ComponentPropsWithoutRef<
|
|
264
|
+
typeof DismissableLayer
|
|
265
|
+
>;
|
|
266
|
+
|
|
267
|
+
type MenuContentInternalPrivateProps = {
|
|
268
|
+
onOpenAutoFocus?: FocusScopeProps["onMountHandler"];
|
|
269
|
+
onDismiss?: DismissableLayerProps["onDismiss"];
|
|
270
|
+
disableOutsidePointerEvents?: DismissableLayerProps["disableOutsidePointerEvents"];
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
interface MenuContentInternalProps
|
|
274
|
+
extends MenuContentInternalPrivateProps,
|
|
275
|
+
Omit<
|
|
276
|
+
React.ComponentPropsWithoutRef<typeof Floating.Content>,
|
|
277
|
+
"dir" | "onPlaced"
|
|
278
|
+
> {
|
|
279
|
+
/**
|
|
280
|
+
* Event handler called when auto-focusing after close.
|
|
281
|
+
* Can be prevented.
|
|
282
|
+
*/
|
|
283
|
+
onCloseAutoFocus?: FocusScopeProps["onUnmountHandler"];
|
|
284
|
+
onEntryFocus?: RovingFocusProps["onEntryFocus"];
|
|
285
|
+
onEscapeKeyDown?: DismissableLayerProps["onEscapeKeyDown"];
|
|
286
|
+
onPointerDownOutside?: DismissableLayerProps["onPointerDownOutside"];
|
|
287
|
+
onFocusOutside?: DismissableLayerProps["onFocusOutside"];
|
|
288
|
+
onInteractOutside?: DismissableLayerProps["onInteractOutside"];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const MenuContentInternal = forwardRef<
|
|
292
|
+
MenuContentInternalElement,
|
|
293
|
+
MenuContentInternalProps
|
|
294
|
+
>(
|
|
295
|
+
(
|
|
296
|
+
{
|
|
297
|
+
onOpenAutoFocus,
|
|
298
|
+
onCloseAutoFocus,
|
|
299
|
+
disableOutsidePointerEvents,
|
|
300
|
+
onEntryFocus,
|
|
301
|
+
onEscapeKeyDown,
|
|
302
|
+
onPointerDownOutside,
|
|
303
|
+
onFocusOutside,
|
|
304
|
+
onInteractOutside,
|
|
305
|
+
onDismiss,
|
|
306
|
+
...rest
|
|
307
|
+
}: MenuContentInternalProps,
|
|
308
|
+
forwardedRef,
|
|
309
|
+
) => {
|
|
310
|
+
const descendants = useMenuDescendantsContext();
|
|
311
|
+
|
|
312
|
+
const context = useMenuContext();
|
|
313
|
+
const rootContext = useMenuRootContext();
|
|
314
|
+
|
|
315
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
316
|
+
const composedRefs = useMergeRefs(
|
|
317
|
+
forwardedRef,
|
|
318
|
+
contentRef,
|
|
319
|
+
context.onContentChange,
|
|
320
|
+
);
|
|
321
|
+
const pointerGraceTimerRef = React.useRef(0);
|
|
322
|
+
const pointerGraceIntentRef = React.useRef<GraceIntent | null>(null);
|
|
323
|
+
const pointerDirRef = React.useRef<SubMenuSide>("right");
|
|
324
|
+
const lastPointerXRef = React.useRef(0);
|
|
325
|
+
|
|
326
|
+
const isPointerMovingToSubmenu = React.useCallback(
|
|
327
|
+
(event: React.PointerEvent) => {
|
|
328
|
+
const isMovingTowards =
|
|
329
|
+
pointerDirRef.current === pointerGraceIntentRef.current?.side;
|
|
330
|
+
return (
|
|
331
|
+
isMovingTowards &&
|
|
332
|
+
isPointerInGraceArea(event, pointerGraceIntentRef.current?.area)
|
|
333
|
+
);
|
|
334
|
+
},
|
|
335
|
+
[],
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<MenuContentProvider
|
|
340
|
+
onItemEnter={React.useCallback(
|
|
341
|
+
(event) => {
|
|
342
|
+
if (isPointerMovingToSubmenu(event)) event.preventDefault();
|
|
343
|
+
},
|
|
344
|
+
[isPointerMovingToSubmenu],
|
|
345
|
+
)}
|
|
346
|
+
onItemLeave={React.useCallback(
|
|
347
|
+
(event) => {
|
|
348
|
+
if (isPointerMovingToSubmenu(event)) return;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Resets focus from current active item to content area
|
|
352
|
+
* This is to prevent focus from being stuck on an item when we move pointer outside the menu or onto a disabled item
|
|
353
|
+
*/
|
|
354
|
+
contentRef.current?.focus();
|
|
355
|
+
},
|
|
356
|
+
[isPointerMovingToSubmenu],
|
|
357
|
+
)}
|
|
358
|
+
onPointerLeaveTrigger={React.useCallback(
|
|
359
|
+
(event) => {
|
|
360
|
+
if (isPointerMovingToSubmenu(event)) event.preventDefault();
|
|
361
|
+
},
|
|
362
|
+
[isPointerMovingToSubmenu],
|
|
363
|
+
)}
|
|
364
|
+
pointerGraceTimerRef={pointerGraceTimerRef}
|
|
365
|
+
onPointerGraceIntentChange={React.useCallback((intent) => {
|
|
366
|
+
pointerGraceIntentRef.current = intent;
|
|
367
|
+
}, [])}
|
|
368
|
+
>
|
|
369
|
+
<FocusScope
|
|
370
|
+
onMountHandler={composeEventHandlers(onOpenAutoFocus, (event) => {
|
|
371
|
+
// when opening, explicitly focus the content area only and leave
|
|
372
|
+
// `onEntryFocus` in control of focusing first item
|
|
373
|
+
event.preventDefault();
|
|
374
|
+
contentRef.current?.focus({ preventScroll: true });
|
|
375
|
+
})}
|
|
376
|
+
onUnmountHandler={onCloseAutoFocus}
|
|
377
|
+
>
|
|
378
|
+
<DismissableLayer
|
|
379
|
+
asChild
|
|
380
|
+
disableOutsidePointerEvents={disableOutsidePointerEvents}
|
|
381
|
+
onEscapeKeyDown={onEscapeKeyDown}
|
|
382
|
+
onPointerDownOutside={onPointerDownOutside}
|
|
383
|
+
onFocusOutside={onFocusOutside}
|
|
384
|
+
onInteractOutside={onInteractOutside}
|
|
385
|
+
onDismiss={onDismiss}
|
|
386
|
+
>
|
|
387
|
+
<RovingFocus
|
|
388
|
+
asChild
|
|
389
|
+
descendants={descendants}
|
|
390
|
+
onEntryFocus={composeEventHandlers(onEntryFocus, (event) => {
|
|
391
|
+
// only focus first item when using keyboard
|
|
392
|
+
if (!rootContext.isUsingKeyboardRef.current)
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
})}
|
|
395
|
+
>
|
|
396
|
+
<Floating.Content
|
|
397
|
+
role="menu"
|
|
398
|
+
aria-orientation="vertical"
|
|
399
|
+
data-state={getOpenState(context.open)}
|
|
400
|
+
data-aksel-menu-content=""
|
|
401
|
+
dir="ltr"
|
|
402
|
+
{...rest}
|
|
403
|
+
ref={composedRefs}
|
|
404
|
+
style={{ outline: "none", ...rest.style }}
|
|
405
|
+
onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
|
|
406
|
+
// submenu key events bubble through portals. We only care about keys in this menu.
|
|
407
|
+
const target = event.target as HTMLElement;
|
|
408
|
+
const isKeyDownInside =
|
|
409
|
+
target.closest("[data-aksel-menu-content]") ===
|
|
410
|
+
event.currentTarget;
|
|
411
|
+
if (isKeyDownInside) {
|
|
412
|
+
// menus should not be navigated using tab key so we prevent it
|
|
413
|
+
if (event.key === "Tab") event.preventDefault();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// focus first/last item based on key pressed
|
|
417
|
+
const content = contentRef.current;
|
|
418
|
+
if (event.target !== content) return;
|
|
419
|
+
if (!FIRST_LAST_KEYS.includes(event.key)) return;
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
|
|
422
|
+
if (LAST_KEYS.includes(event.key)) {
|
|
423
|
+
descendants.lastEnabled()?.node?.focus();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
descendants.firstEnabled()?.node?.focus();
|
|
427
|
+
})}
|
|
428
|
+
onPointerMove={composeEventHandlers(
|
|
429
|
+
rest.onPointerMove,
|
|
430
|
+
whenMouse((event) => {
|
|
431
|
+
const target = event.target as HTMLElement;
|
|
432
|
+
const pointerXHasChanged =
|
|
433
|
+
lastPointerXRef.current !== event.clientX;
|
|
434
|
+
|
|
435
|
+
// We don't use `event.movementX` for this check because Safari will
|
|
436
|
+
// always return `0` on a pointer event.
|
|
437
|
+
if (
|
|
438
|
+
event.currentTarget.contains(target) &&
|
|
439
|
+
pointerXHasChanged
|
|
440
|
+
) {
|
|
441
|
+
const newDir =
|
|
442
|
+
event.clientX > lastPointerXRef.current
|
|
443
|
+
? "right"
|
|
444
|
+
: "left";
|
|
445
|
+
pointerDirRef.current = newDir;
|
|
446
|
+
lastPointerXRef.current = event.clientX;
|
|
447
|
+
}
|
|
448
|
+
}),
|
|
449
|
+
)}
|
|
450
|
+
/>
|
|
451
|
+
</RovingFocus>
|
|
452
|
+
</DismissableLayer>
|
|
453
|
+
</FocusScope>
|
|
454
|
+
</MenuContentProvider>
|
|
455
|
+
);
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
interface MenuContentInternalTypeProps
|
|
460
|
+
extends Omit<
|
|
461
|
+
MenuContentInternalProps,
|
|
462
|
+
keyof MenuContentInternalPrivateProps
|
|
463
|
+
> {}
|
|
464
|
+
|
|
465
|
+
/* -------------------------------------------------------------------------- */
|
|
466
|
+
/* Menu item */
|
|
467
|
+
/* -------------------------------------------------------------------------- */
|
|
468
|
+
const ITEM_SELECT_EVENT = "menu.itemSelect";
|
|
469
|
+
|
|
470
|
+
type MenuItemElement = MenuItemInternalElement;
|
|
471
|
+
|
|
472
|
+
interface MenuItemProps extends Omit<MenuItemInternalProps, "onSelect"> {
|
|
473
|
+
onSelect?: (event: Event) => void;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const MenuItem = forwardRef<MenuItemElement, MenuItemProps>(
|
|
477
|
+
(
|
|
478
|
+
{
|
|
479
|
+
disabled = false,
|
|
480
|
+
onSelect,
|
|
481
|
+
onClick,
|
|
482
|
+
onPointerUp,
|
|
483
|
+
onPointerDown,
|
|
484
|
+
onKeyDown,
|
|
485
|
+
...rest
|
|
486
|
+
}: MenuItemProps,
|
|
487
|
+
forwardedRef,
|
|
488
|
+
) => {
|
|
489
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
490
|
+
const rootContext = useMenuRootContext();
|
|
491
|
+
const composedRefs = useMergeRefs(forwardedRef, ref);
|
|
492
|
+
const isPointerDownRef = useRef(false);
|
|
493
|
+
|
|
494
|
+
const handleSelect = () => {
|
|
495
|
+
const menuItem = ref.current;
|
|
496
|
+
if (!disabled && menuItem && onSelect) {
|
|
497
|
+
const itemSelectEvent = new CustomEvent(ITEM_SELECT_EVENT, {
|
|
498
|
+
bubbles: true,
|
|
499
|
+
cancelable: true,
|
|
500
|
+
});
|
|
501
|
+
menuItem.addEventListener(ITEM_SELECT_EVENT, onSelect, { once: true });
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* We flush the event synchronously to ensure that the event is dispatched before other events react to side-effect from event.
|
|
505
|
+
* This is necessary to prevent the menu from potentially closing before we are able to prevent it.
|
|
506
|
+
*/
|
|
507
|
+
ReactDOM.flushSync(() => menuItem.dispatchEvent(itemSelectEvent));
|
|
508
|
+
if (itemSelectEvent.defaultPrevented) {
|
|
509
|
+
isPointerDownRef.current = false;
|
|
510
|
+
} else {
|
|
511
|
+
rootContext.onClose();
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
<MenuItemInternal
|
|
518
|
+
{...rest}
|
|
519
|
+
tabIndex={disabled ? -1 : 0}
|
|
520
|
+
ref={composedRefs}
|
|
521
|
+
disabled={disabled}
|
|
522
|
+
onClick={composeEventHandlers(onClick, handleSelect)}
|
|
523
|
+
onPointerDown={composeEventHandlers(
|
|
524
|
+
onPointerDown,
|
|
525
|
+
() => {
|
|
526
|
+
isPointerDownRef.current = true;
|
|
527
|
+
},
|
|
528
|
+
{ checkForDefaultPrevented: false },
|
|
529
|
+
)}
|
|
530
|
+
onPointerUp={composeEventHandlers(onPointerUp, (event) => {
|
|
531
|
+
// Pointer down can move to a different menu item which should activate it on pointer up.
|
|
532
|
+
// We dispatch a click for selection to allow composition with click based triggers and to
|
|
533
|
+
// prevent Firefox from getting stuck in text selection mode when the menu closes.
|
|
534
|
+
if (!isPointerDownRef.current) event.currentTarget?.click();
|
|
535
|
+
})}
|
|
536
|
+
onKeyDown={composeEventHandlers(onKeyDown, (event) => {
|
|
537
|
+
if (disabled) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (SELECTION_KEYS.includes(event.key)) {
|
|
541
|
+
event.currentTarget.click();
|
|
542
|
+
/**
|
|
543
|
+
* We prevent default browser behaviour for selection keys as they should only trigger
|
|
544
|
+
* selection.
|
|
545
|
+
* - Prevents space from scrolling the page.
|
|
546
|
+
* - If keydown causes focus to move, prevents keydown from firing on the new target.
|
|
547
|
+
*/
|
|
548
|
+
event.preventDefault();
|
|
549
|
+
}
|
|
550
|
+
})}
|
|
551
|
+
/>
|
|
552
|
+
);
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
/* --------------------------- Menu Item internals --------------------------- */
|
|
557
|
+
type MenuItemInternalElement = SlottedDivElementRef;
|
|
558
|
+
|
|
559
|
+
interface MenuItemInternalProps extends SlottedDivProps {
|
|
560
|
+
disabled?: boolean;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const MenuItemInternal = forwardRef<
|
|
564
|
+
MenuItemInternalElement,
|
|
565
|
+
MenuItemInternalProps
|
|
566
|
+
>(
|
|
567
|
+
(
|
|
568
|
+
{
|
|
569
|
+
disabled = false,
|
|
570
|
+
onPointerMove,
|
|
571
|
+
onPointerLeave,
|
|
572
|
+
...rest
|
|
573
|
+
}: MenuItemInternalProps,
|
|
574
|
+
forwardedRef,
|
|
575
|
+
) => {
|
|
576
|
+
const { register } = useMenuDescendant({ disabled });
|
|
577
|
+
|
|
578
|
+
const contentContext = useMenuContentContext();
|
|
579
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
580
|
+
const composedRefs = useMergeRefs(forwardedRef, ref, register);
|
|
581
|
+
|
|
582
|
+
return (
|
|
583
|
+
<SlottedDivElement
|
|
584
|
+
role="menuitem"
|
|
585
|
+
aria-disabled={disabled || undefined}
|
|
586
|
+
data-disabled={disabled ? "" : undefined}
|
|
587
|
+
tabIndex={-1}
|
|
588
|
+
{...rest}
|
|
589
|
+
style={{ userSelect: "none", ...rest?.style }}
|
|
590
|
+
ref={composedRefs}
|
|
591
|
+
/**
|
|
592
|
+
* We focus items on `pointerMove` make sure that the item is focused or re-focused
|
|
593
|
+
* when the mouse wiggles. If we used `mouseOver`/`mouseEnter` it would not re-focus.
|
|
594
|
+
* This is mostly to handle edgecases where the user uses mouse and keyboard together.
|
|
595
|
+
*/
|
|
596
|
+
onPointerMove={composeEventHandlers(
|
|
597
|
+
onPointerMove,
|
|
598
|
+
whenMouse((event) => {
|
|
599
|
+
if (disabled) {
|
|
600
|
+
/**
|
|
601
|
+
* In the edgecase the focus is still stuck on a previous item, we make sure to reset it
|
|
602
|
+
* even when the disabled item can't be focused itself to reset it.
|
|
603
|
+
*/
|
|
604
|
+
contentContext.onItemLeave(event);
|
|
605
|
+
} else {
|
|
606
|
+
contentContext.onItemEnter(event);
|
|
607
|
+
if (!event.defaultPrevented) {
|
|
608
|
+
event.currentTarget.focus();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}),
|
|
612
|
+
)}
|
|
613
|
+
onPointerLeave={composeEventHandlers(
|
|
614
|
+
onPointerLeave,
|
|
615
|
+
whenMouse(contentContext.onItemLeave),
|
|
616
|
+
)}
|
|
617
|
+
/>
|
|
618
|
+
);
|
|
619
|
+
},
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
/* -------------------------------------------------------------------------- */
|
|
623
|
+
/* Menu Group */
|
|
624
|
+
/* -------------------------------------------------------------------------- */
|
|
625
|
+
interface MenuGroupProps extends SlottedDivProps {}
|
|
626
|
+
|
|
627
|
+
const MenuGroup = forwardRef<SlottedDivElementRef, MenuGroupProps>(
|
|
628
|
+
(props: MenuGroupProps, ref) => {
|
|
629
|
+
return <SlottedDivElement role="group" {...props} ref={ref} />;
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
/* -------------------------------------------------------------------------- */
|
|
634
|
+
/* Menu Portal */
|
|
635
|
+
/* -------------------------------------------------------------------------- */
|
|
636
|
+
type PortalProps = React.ComponentPropsWithoutRef<typeof Portal>;
|
|
637
|
+
type MenuPortalElement = React.ElementRef<typeof Portal>;
|
|
638
|
+
|
|
639
|
+
type MenuPortalProps = PortalProps & {
|
|
640
|
+
children: React.ReactElement;
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const MenuPortal = forwardRef<MenuPortalElement, MenuPortalProps>(
|
|
644
|
+
({ children, rootElement }: MenuPortalProps, ref) => {
|
|
645
|
+
const context = useMenuContext();
|
|
646
|
+
|
|
647
|
+
if (!context.open) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<Portal asChild rootElement={rootElement} ref={ref}>
|
|
653
|
+
{children}
|
|
654
|
+
</Portal>
|
|
655
|
+
);
|
|
656
|
+
},
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
/* -------------------------------------------------------------------------- */
|
|
660
|
+
/* Menu Radio */
|
|
661
|
+
/* -------------------------------------------------------------------------- */
|
|
662
|
+
const [RadioGroupProvider, useMenuRadioGroupContext] =
|
|
663
|
+
createContext<MenuRadioGroupProps>({
|
|
664
|
+
providerName: "MenuRadioGroupProvider",
|
|
665
|
+
hookName: "useMenuRadioGroupContext",
|
|
666
|
+
defaultValue: {
|
|
667
|
+
value: undefined,
|
|
668
|
+
onValueChange: () => {},
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
interface MenuRadioGroupProps extends MenuGroupProps {
|
|
673
|
+
value?: string;
|
|
674
|
+
onValueChange?: (value: string) => void;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const MenuRadioGroup = React.forwardRef<
|
|
678
|
+
React.ElementRef<typeof MenuGroup>,
|
|
679
|
+
MenuRadioGroupProps
|
|
680
|
+
>(({ value, onValueChange, ...rest }: MenuRadioGroupProps, ref) => {
|
|
681
|
+
const handleValueChange = useCallbackRef(onValueChange);
|
|
682
|
+
return (
|
|
683
|
+
<RadioGroupProvider value={value} onValueChange={handleValueChange}>
|
|
684
|
+
<MenuGroup {...rest} ref={ref} />
|
|
685
|
+
</RadioGroupProvider>
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
/* -------------------------------------------------------------------------- */
|
|
690
|
+
/* Menu Item Indicator */
|
|
691
|
+
/* -------------------------------------------------------------------------- */
|
|
692
|
+
const [MenuItemIndicatorProvider, useMenuItemIndicatorContext] = createContext<{
|
|
693
|
+
state: CheckedState;
|
|
694
|
+
}>({
|
|
695
|
+
providerName: "MenuItemIndicatorProvider",
|
|
696
|
+
hookName: "useMenuItemIndicatorContext",
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
interface MenuItemIndicatorProps extends SlottedDivProps {}
|
|
700
|
+
|
|
701
|
+
const MenuItemIndicator = forwardRef<
|
|
702
|
+
SlottedDivElementRef,
|
|
703
|
+
MenuItemIndicatorProps
|
|
704
|
+
>(({ asChild, ...rest }, ref) => {
|
|
705
|
+
const ctx = useMenuItemIndicatorContext();
|
|
706
|
+
|
|
707
|
+
return (
|
|
708
|
+
<SlottedDivElement
|
|
709
|
+
{...rest}
|
|
710
|
+
ref={ref}
|
|
711
|
+
data-state={getCheckedState(ctx.state)}
|
|
712
|
+
aria-hidden
|
|
713
|
+
asChild={asChild}
|
|
714
|
+
/>
|
|
715
|
+
);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
/* -------------------------------------------------------------------------- */
|
|
719
|
+
/* Menu Radio */
|
|
720
|
+
/* -------------------------------------------------------------------------- */
|
|
721
|
+
interface MenuRadioItemProps extends MenuItemProps {
|
|
722
|
+
value: string;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const MenuRadioItem = forwardRef<
|
|
726
|
+
React.ElementRef<typeof MenuItem>,
|
|
727
|
+
MenuRadioItemProps
|
|
728
|
+
>(({ value, onSelect, ...rest }: MenuRadioItemProps, forwardedRef) => {
|
|
729
|
+
const context = useMenuRadioGroupContext();
|
|
730
|
+
const checked = value === context.value;
|
|
731
|
+
|
|
732
|
+
return (
|
|
733
|
+
<MenuItemIndicatorProvider state={checked}>
|
|
734
|
+
<MenuItem
|
|
735
|
+
role="menuitemradio"
|
|
736
|
+
aria-checked={checked}
|
|
737
|
+
{...rest}
|
|
738
|
+
ref={forwardedRef}
|
|
739
|
+
data-state={getCheckedState(checked)}
|
|
740
|
+
onSelect={composeEventHandlers(
|
|
741
|
+
onSelect,
|
|
742
|
+
() => context.onValueChange?.(value),
|
|
743
|
+
{ checkForDefaultPrevented: false },
|
|
744
|
+
)}
|
|
745
|
+
/>
|
|
746
|
+
</MenuItemIndicatorProvider>
|
|
747
|
+
);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
/* -------------------------------------------------------------------------- */
|
|
751
|
+
/* Menu Checkbox */
|
|
752
|
+
/* -------------------------------------------------------------------------- */
|
|
753
|
+
interface MenuCheckboxItemProps extends MenuItemProps {
|
|
754
|
+
checked?: CheckedState;
|
|
755
|
+
// `onCheckedChange` can never be called with `"indeterminate"` from the inside
|
|
756
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const MenuCheckboxItem = forwardRef<MenuItemElement, MenuCheckboxItemProps>(
|
|
760
|
+
(
|
|
761
|
+
{
|
|
762
|
+
checked = false,
|
|
763
|
+
onCheckedChange,
|
|
764
|
+
onSelect,
|
|
765
|
+
...rest
|
|
766
|
+
}: MenuCheckboxItemProps,
|
|
767
|
+
forwardedRef,
|
|
768
|
+
) => {
|
|
769
|
+
return (
|
|
770
|
+
<MenuItemIndicatorProvider state={checked}>
|
|
771
|
+
<MenuItem
|
|
772
|
+
role="menuitemcheckbox"
|
|
773
|
+
aria-checked={isIndeterminate(checked) ? "mixed" : checked}
|
|
774
|
+
{...rest}
|
|
775
|
+
ref={forwardedRef}
|
|
776
|
+
data-state={getCheckedState(checked)}
|
|
777
|
+
onSelect={composeEventHandlers(
|
|
778
|
+
onSelect,
|
|
779
|
+
() => onCheckedChange?.(isIndeterminate(checked) ? true : !checked),
|
|
780
|
+
{ checkForDefaultPrevented: false },
|
|
781
|
+
)}
|
|
782
|
+
/>
|
|
783
|
+
</MenuItemIndicatorProvider>
|
|
784
|
+
);
|
|
785
|
+
},
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
/* -------------------------------------------------------------------------- */
|
|
789
|
+
/* Menu Separator */
|
|
790
|
+
/* -------------------------------------------------------------------------- */
|
|
791
|
+
interface MenuSeparatorProps extends SlottedDivProps {}
|
|
792
|
+
|
|
793
|
+
const MenuSeparator = forwardRef<SlottedDivElementRef, MenuSeparatorProps>(
|
|
794
|
+
(props: MenuSeparatorProps, ref) => {
|
|
795
|
+
return (
|
|
796
|
+
<SlottedDivElement
|
|
797
|
+
role="separator"
|
|
798
|
+
aria-orientation="horizontal"
|
|
799
|
+
{...props}
|
|
800
|
+
ref={ref}
|
|
801
|
+
/>
|
|
802
|
+
);
|
|
803
|
+
},
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
/* -------------------------------------------------------------------------- */
|
|
807
|
+
/* Menu SubMenu */
|
|
808
|
+
/* -------------------------------------------------------------------------- */
|
|
809
|
+
type MenuSubContextValue = {
|
|
810
|
+
contentId: string;
|
|
811
|
+
triggerId: string;
|
|
812
|
+
trigger: MenuItemElement | null;
|
|
813
|
+
onTriggerChange: (trigger: MenuItemElement | null) => void;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const [MenuSubProvider, useMenuSubContext] = createContext<MenuSubContextValue>(
|
|
817
|
+
{
|
|
818
|
+
providerName: "MenuSubProvider",
|
|
819
|
+
hookName: "useMenuSubContext",
|
|
820
|
+
},
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
interface MenuSubProps {
|
|
824
|
+
children?: React.ReactNode;
|
|
825
|
+
open?: boolean;
|
|
826
|
+
onOpenChange?: (open: boolean) => void;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const MenuSub: React.FC<MenuSubProps> = ({
|
|
830
|
+
children,
|
|
831
|
+
onOpenChange,
|
|
832
|
+
open = false,
|
|
833
|
+
}: MenuSubProps) => {
|
|
834
|
+
const parentMenuContext = useMenuContext();
|
|
835
|
+
|
|
836
|
+
const [trigger, setTrigger] = useState<MenuItemElement | null>(null);
|
|
837
|
+
const [content, setContent] = useState<MenuContentInternalElement | null>(
|
|
838
|
+
null,
|
|
839
|
+
);
|
|
840
|
+
const handleOpenChange = useCallbackRef(onOpenChange);
|
|
841
|
+
|
|
842
|
+
// Prevent the parent menu from reopening with open submenus.
|
|
843
|
+
useEffect(() => {
|
|
844
|
+
if (parentMenuContext.open === false) {
|
|
845
|
+
handleOpenChange(false);
|
|
846
|
+
}
|
|
847
|
+
return () => handleOpenChange(false);
|
|
848
|
+
}, [parentMenuContext.open, handleOpenChange]);
|
|
849
|
+
|
|
850
|
+
return (
|
|
851
|
+
<Floating>
|
|
852
|
+
<MenuProvider
|
|
853
|
+
open={open}
|
|
854
|
+
onOpenChange={handleOpenChange}
|
|
855
|
+
content={content}
|
|
856
|
+
onContentChange={setContent}
|
|
857
|
+
>
|
|
858
|
+
<MenuSubProvider
|
|
859
|
+
contentId={useId()}
|
|
860
|
+
triggerId={useId()}
|
|
861
|
+
trigger={trigger}
|
|
862
|
+
onTriggerChange={setTrigger}
|
|
863
|
+
>
|
|
864
|
+
{children}
|
|
865
|
+
</MenuSubProvider>
|
|
866
|
+
</MenuProvider>
|
|
867
|
+
</Floating>
|
|
868
|
+
);
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
/* -------------------------------------------------------------------------- */
|
|
872
|
+
/* Menu SubMenu Trigger */
|
|
873
|
+
/* -------------------------------------------------------------------------- */
|
|
874
|
+
interface MenuSubTriggerProps extends MenuItemInternalProps {}
|
|
875
|
+
|
|
876
|
+
const MenuSubTrigger = forwardRef<MenuItemElement, MenuSubTriggerProps>(
|
|
877
|
+
(props: MenuSubTriggerProps, forwardedRef) => {
|
|
878
|
+
const context = useMenuContext();
|
|
879
|
+
const subContext = useMenuSubContext();
|
|
880
|
+
const contentContext = useMenuContentContext();
|
|
881
|
+
const openTimerRef = useRef<number | null>(null);
|
|
882
|
+
const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext;
|
|
883
|
+
|
|
884
|
+
const composedRefs = useMergeRefs(forwardedRef, subContext.onTriggerChange);
|
|
885
|
+
|
|
886
|
+
const clearOpenTimer = useCallback(() => {
|
|
887
|
+
if (openTimerRef.current) {
|
|
888
|
+
window.clearTimeout(openTimerRef.current);
|
|
889
|
+
}
|
|
890
|
+
openTimerRef.current = null;
|
|
891
|
+
}, []);
|
|
892
|
+
|
|
893
|
+
React.useEffect(() => clearOpenTimer, [clearOpenTimer]);
|
|
894
|
+
|
|
895
|
+
React.useEffect(() => {
|
|
896
|
+
const pointerGraceTimer = pointerGraceTimerRef.current;
|
|
897
|
+
return () => {
|
|
898
|
+
window.clearTimeout(pointerGraceTimer);
|
|
899
|
+
onPointerGraceIntentChange(null);
|
|
900
|
+
};
|
|
901
|
+
}, [pointerGraceTimerRef, onPointerGraceIntentChange]);
|
|
902
|
+
|
|
903
|
+
return (
|
|
904
|
+
<MenuAnchor asChild>
|
|
905
|
+
<MenuItemInternal
|
|
906
|
+
id={subContext.triggerId}
|
|
907
|
+
aria-haspopup="menu"
|
|
908
|
+
aria-expanded={context.open}
|
|
909
|
+
aria-controls={subContext.contentId}
|
|
910
|
+
data-state={getOpenState(context.open)}
|
|
911
|
+
{...props}
|
|
912
|
+
ref={composedRefs}
|
|
913
|
+
/**
|
|
914
|
+
* onClick is added to solve edgecase where the user clicks the trigger,
|
|
915
|
+
* but the focus is outside browser-window or viewport at first.
|
|
916
|
+
*/
|
|
917
|
+
onClick={(event) => {
|
|
918
|
+
props.onClick?.(event);
|
|
919
|
+
if (props.disabled || event.defaultPrevented) return;
|
|
920
|
+
|
|
921
|
+
event.currentTarget.focus();
|
|
922
|
+
if (!context.open) context.onOpenChange(true);
|
|
923
|
+
}}
|
|
924
|
+
onPointerMove={composeEventHandlers(
|
|
925
|
+
props.onPointerMove,
|
|
926
|
+
whenMouse((event) => {
|
|
927
|
+
if (event.defaultPrevented) return;
|
|
928
|
+
if (!props.disabled && !context.open && !openTimerRef.current) {
|
|
929
|
+
contentContext.onPointerGraceIntentChange(null);
|
|
930
|
+
openTimerRef.current = window.setTimeout(() => {
|
|
931
|
+
context.onOpenChange(true);
|
|
932
|
+
clearOpenTimer();
|
|
933
|
+
}, 100);
|
|
934
|
+
}
|
|
935
|
+
}),
|
|
936
|
+
)}
|
|
937
|
+
onPointerLeave={composeEventHandlers(
|
|
938
|
+
props.onPointerLeave,
|
|
939
|
+
whenMouse((event) => {
|
|
940
|
+
clearOpenTimer();
|
|
941
|
+
|
|
942
|
+
const contentRect = context.content?.getBoundingClientRect();
|
|
943
|
+
if (contentRect) {
|
|
944
|
+
const side = context.content?.dataset.side as SubMenuSide;
|
|
945
|
+
const rightSide = side === "right";
|
|
946
|
+
const bleed = rightSide ? -5 : +5;
|
|
947
|
+
const contentNearEdge =
|
|
948
|
+
contentRect[rightSide ? "left" : "right"];
|
|
949
|
+
const contentFarEdge =
|
|
950
|
+
contentRect[rightSide ? "right" : "left"];
|
|
951
|
+
|
|
952
|
+
contentContext.onPointerGraceIntentChange({
|
|
953
|
+
area: [
|
|
954
|
+
// Apply a bleed on clientX to ensure that our exit point is
|
|
955
|
+
// consistently within polygon bounds
|
|
956
|
+
{ x: event.clientX + bleed, y: event.clientY },
|
|
957
|
+
{ x: contentNearEdge, y: contentRect.top },
|
|
958
|
+
{ x: contentFarEdge, y: contentRect.top },
|
|
959
|
+
{ x: contentFarEdge, y: contentRect.bottom },
|
|
960
|
+
{ x: contentNearEdge, y: contentRect.bottom },
|
|
961
|
+
],
|
|
962
|
+
side,
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
window.clearTimeout(pointerGraceTimerRef.current);
|
|
966
|
+
pointerGraceTimerRef.current = window.setTimeout(
|
|
967
|
+
() => contentContext.onPointerGraceIntentChange(null),
|
|
968
|
+
300,
|
|
969
|
+
);
|
|
970
|
+
} else {
|
|
971
|
+
contentContext.onPointerLeaveTrigger(event);
|
|
972
|
+
if (event.defaultPrevented) return;
|
|
973
|
+
|
|
974
|
+
// There's 100ms where the user may leave an item before the submenu was opened.
|
|
975
|
+
contentContext.onPointerGraceIntentChange(null);
|
|
976
|
+
}
|
|
977
|
+
}),
|
|
978
|
+
)}
|
|
979
|
+
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
|
|
980
|
+
if (props.disabled) {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (SUB_OPEN_KEYS.includes(event.key)) {
|
|
984
|
+
context.onOpenChange(true);
|
|
985
|
+
// The trigger may hold focus if opened via pointer interaction
|
|
986
|
+
// so we ensure content is given focus again when switching to keyboard.
|
|
987
|
+
context.content?.focus();
|
|
988
|
+
// prevent window from scrolling
|
|
989
|
+
event.preventDefault();
|
|
990
|
+
}
|
|
991
|
+
})}
|
|
992
|
+
/>
|
|
993
|
+
</MenuAnchor>
|
|
994
|
+
);
|
|
995
|
+
},
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
/* -------------------------------------------------------------------------- */
|
|
999
|
+
/* Menu SubMenu Content */
|
|
1000
|
+
/* -------------------------------------------------------------------------- */
|
|
1001
|
+
interface MenuSubContentProps
|
|
1002
|
+
extends Omit<
|
|
1003
|
+
MenuContentInternalProps,
|
|
1004
|
+
| keyof MenuContentInternalPrivateProps
|
|
1005
|
+
| "onCloseAutoFocus"
|
|
1006
|
+
| "onEntryFocus"
|
|
1007
|
+
| "side"
|
|
1008
|
+
| "align"
|
|
1009
|
+
> {}
|
|
1010
|
+
|
|
1011
|
+
const MenuSubContent = forwardRef<
|
|
1012
|
+
MenuContentInternalElement,
|
|
1013
|
+
MenuSubContentProps
|
|
1014
|
+
>((props: MenuSubContentProps, forwardedRef) => {
|
|
1015
|
+
const descendants = useMenuDescendants();
|
|
1016
|
+
|
|
1017
|
+
const context = useMenuContext();
|
|
1018
|
+
const rootContext = useMenuRootContext();
|
|
1019
|
+
const subContext = useMenuSubContext();
|
|
1020
|
+
const ref = useRef<MenuContentInternalElement>(null);
|
|
1021
|
+
const composedRefs = useMergeRefs(forwardedRef, ref);
|
|
1022
|
+
|
|
1023
|
+
return (
|
|
1024
|
+
<MenuDescendantsProvider value={descendants}>
|
|
1025
|
+
<MenuContentInternal
|
|
1026
|
+
id={subContext.contentId}
|
|
1027
|
+
aria-labelledby={subContext.triggerId}
|
|
1028
|
+
{...props}
|
|
1029
|
+
ref={composedRefs}
|
|
1030
|
+
align="start"
|
|
1031
|
+
side="right"
|
|
1032
|
+
disableOutsidePointerEvents={false}
|
|
1033
|
+
onOpenAutoFocus={(event) => {
|
|
1034
|
+
// when opening a submenu, focus content for keyboard users only
|
|
1035
|
+
if (rootContext.isUsingKeyboardRef.current) {
|
|
1036
|
+
ref.current?.focus();
|
|
1037
|
+
}
|
|
1038
|
+
event.preventDefault();
|
|
1039
|
+
}}
|
|
1040
|
+
// The menu might close because of focusing another menu item in the parent menu. We
|
|
1041
|
+
// don't want it to refocus the trigger in that case so we handle trigger focus ourselves.
|
|
1042
|
+
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
1043
|
+
onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
|
|
1044
|
+
// We prevent closing when the trigger is focused to avoid triggering a re-open animation
|
|
1045
|
+
// on pointer interaction.
|
|
1046
|
+
if (event.target !== subContext.trigger) context.onOpenChange(false);
|
|
1047
|
+
})}
|
|
1048
|
+
onEscapeKeyDown={composeEventHandlers(
|
|
1049
|
+
props.onEscapeKeyDown,
|
|
1050
|
+
(event) => {
|
|
1051
|
+
rootContext.onClose();
|
|
1052
|
+
// Ensure pressing escape in submenu doesn't escape full screen mode
|
|
1053
|
+
event.preventDefault();
|
|
1054
|
+
},
|
|
1055
|
+
)}
|
|
1056
|
+
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
|
|
1057
|
+
// Submenu key events bubble through portals. We only care about keys in this menu.
|
|
1058
|
+
const isKeyDownInside = event.currentTarget.contains(
|
|
1059
|
+
event.target as HTMLElement,
|
|
1060
|
+
);
|
|
1061
|
+
let isCloseKey = SUB_CLOSE_KEYS.includes(event.key);
|
|
1062
|
+
|
|
1063
|
+
/* When submenu opens to the left, we allow closing it with ArrowRight */
|
|
1064
|
+
if (context.content?.dataset.side === "left") {
|
|
1065
|
+
isCloseKey = isCloseKey || event.key === "ArrowRight";
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (isKeyDownInside && isCloseKey) {
|
|
1069
|
+
context.onOpenChange(false);
|
|
1070
|
+
// We focus manually because we prevented it in `onCloseAutoFocus`
|
|
1071
|
+
subContext.trigger?.focus();
|
|
1072
|
+
// Prevent window from scrolling
|
|
1073
|
+
event.preventDefault();
|
|
1074
|
+
}
|
|
1075
|
+
})}
|
|
1076
|
+
/>
|
|
1077
|
+
</MenuDescendantsProvider>
|
|
1078
|
+
);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
/* -------------------------------------------------------------------------- */
|
|
1082
|
+
/* Utilities */
|
|
1083
|
+
/* -------------------------------------------------------------------------- */
|
|
1084
|
+
function getOpenState(open: boolean) {
|
|
1085
|
+
return open ? "open" : "closed";
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function isIndeterminate(checked?: CheckedState): checked is "indeterminate" {
|
|
1089
|
+
return checked === "indeterminate";
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function getCheckedState(checked: CheckedState) {
|
|
1093
|
+
return isIndeterminate(checked)
|
|
1094
|
+
? "indeterminate"
|
|
1095
|
+
: checked
|
|
1096
|
+
? "checked"
|
|
1097
|
+
: "unchecked";
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Determine if a point is inside of a polygon.
|
|
1102
|
+
*/
|
|
1103
|
+
function isPointInPolygon(point: Point, polygon: Polygon) {
|
|
1104
|
+
const { x, y } = point;
|
|
1105
|
+
let inside = false;
|
|
1106
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
1107
|
+
const xi = polygon[i].x;
|
|
1108
|
+
const yi = polygon[i].y;
|
|
1109
|
+
const xj = polygon[j].x;
|
|
1110
|
+
const yj = polygon[j].y;
|
|
1111
|
+
|
|
1112
|
+
// prettier-ignore
|
|
1113
|
+
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
|
|
1114
|
+
if (intersect) inside = !inside;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return inside;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function isPointerInGraceArea(event: React.PointerEvent, area?: Polygon) {
|
|
1121
|
+
if (!area) return false;
|
|
1122
|
+
const cursorPos = { x: event.clientX, y: event.clientY };
|
|
1123
|
+
return isPointInPolygon(cursorPos, area);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function whenMouse<E>(
|
|
1127
|
+
handler: React.PointerEventHandler<E>,
|
|
1128
|
+
): React.PointerEventHandler<E> {
|
|
1129
|
+
return (event) =>
|
|
1130
|
+
event.pointerType === "mouse" ? handler(event) : undefined;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/* -------------------------------------------------------------------------- */
|
|
1134
|
+
Menu.Anchor = MenuAnchor;
|
|
1135
|
+
Menu.Portal = MenuPortal;
|
|
1136
|
+
Menu.Content = MenuContent;
|
|
1137
|
+
Menu.Group = MenuGroup;
|
|
1138
|
+
Menu.Item = MenuItem;
|
|
1139
|
+
Menu.CheckboxItem = MenuCheckboxItem;
|
|
1140
|
+
Menu.RadioGroup = MenuRadioGroup;
|
|
1141
|
+
Menu.RadioItem = MenuRadioItem;
|
|
1142
|
+
Menu.Separator = MenuSeparator;
|
|
1143
|
+
Menu.Sub = MenuSub;
|
|
1144
|
+
Menu.SubTrigger = MenuSubTrigger;
|
|
1145
|
+
Menu.SubContent = MenuSubContent;
|
|
1146
|
+
Menu.ItemIndicator = MenuItemIndicator;
|
|
1147
|
+
|
|
1148
|
+
export {
|
|
1149
|
+
Menu,
|
|
1150
|
+
MenuAnchor,
|
|
1151
|
+
MenuCheckboxItem,
|
|
1152
|
+
MenuContent,
|
|
1153
|
+
MenuGroup,
|
|
1154
|
+
MenuItem,
|
|
1155
|
+
MenuItemIndicator,
|
|
1156
|
+
MenuPortal,
|
|
1157
|
+
MenuRadioGroup,
|
|
1158
|
+
MenuRadioItem,
|
|
1159
|
+
MenuSeparator,
|
|
1160
|
+
MenuSub,
|
|
1161
|
+
MenuSubContent,
|
|
1162
|
+
MenuSubTrigger,
|
|
1163
|
+
type MenuAnchorProps,
|
|
1164
|
+
type MenuCheckboxItemProps,
|
|
1165
|
+
type MenuContentProps,
|
|
1166
|
+
type MenuGroupProps,
|
|
1167
|
+
type MenuItemElement,
|
|
1168
|
+
type MenuItemIndicatorProps,
|
|
1169
|
+
type MenuPortalProps,
|
|
1170
|
+
type MenuProps,
|
|
1171
|
+
type MenuRadioGroupProps,
|
|
1172
|
+
type MenuRadioItemProps,
|
|
1173
|
+
type MenuSeparatorProps,
|
|
1174
|
+
type MenuSubContentProps,
|
|
1175
|
+
type MenuSubProps,
|
|
1176
|
+
type MenuSubTriggerProps,
|
|
1177
|
+
};
|