@fluixi-ui/context-menu 0.2.0-alpha.17 → 0.2.0-alpha.19
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/dist/context-menu.d.ts.map +1 -1
- package/dist/context-menu.js +557 -413
- package/dist/index.js +2 -13
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/package.json +5 -5
package/dist/context-menu.js
CHANGED
|
@@ -1,435 +1,579 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createComponent, createNativeElement, delegateEvents, handleRef, insert, mergeProps, splitProps, spread } from "@fluixi/dom";
|
|
2
|
+
import { createEffect, createMemo, createRenderEffect, createSignal, onCleanup } from "@fluixi/reactive/signal";
|
|
3
3
|
import { fxProps } from "@fluixi-ui/fx";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { Show, onMount, useRef } from "@fluixi/core";
|
|
5
|
+
import { composeEventHandlers, composeId, createControllableBooleanSignal, createId, createSafeContext, dataAttr, focusWithoutScrolling } from "@fluixi-ui/kit";
|
|
6
|
+
import { Portal, createCollection, createDismissableLayer, createFloatingPosition, createFocusScope, createTypeahead, getNextEnabledIndex } from "@fluixi-ui/primitives";
|
|
7
|
+
//#region src/context-menu.tsx
|
|
7
8
|
delegateEvents(["keydown", "click"]);
|
|
8
|
-
|
|
9
|
+
var [ContextMenuProvider, useContextMenuContext] = createSafeContext("ContextMenu");
|
|
10
|
+
/**
|
|
11
|
+
* Root of a context menu opened by right-click (or long-press) on its trigger,
|
|
12
|
+
* positioned at the pointer. Same keyboard-navigable menu as
|
|
13
|
+
* {@link DropdownMenu}, but anchored to the cursor rather than a button.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* import {
|
|
18
|
+
* ContextMenu, ContextMenuTrigger, ContextMenuPortal,
|
|
19
|
+
* ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
|
|
20
|
+
* } from '@fluixi-ui/context-menu';
|
|
21
|
+
*
|
|
22
|
+
* <ContextMenu>
|
|
23
|
+
* <ContextMenuTrigger>Right-click this area</ContextMenuTrigger>
|
|
24
|
+
* <ContextMenuPortal>
|
|
25
|
+
* <ContextMenuContent>
|
|
26
|
+
* <ContextMenuItem onSelect={rename}>Rename</ContextMenuItem>
|
|
27
|
+
* <ContextMenuSeparator />
|
|
28
|
+
* <ContextMenuItem onSelect={remove}>Delete</ContextMenuItem>
|
|
29
|
+
* </ContextMenuContent>
|
|
30
|
+
* </ContextMenuPortal>
|
|
31
|
+
* </ContextMenu>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
9
34
|
function ContextMenu(props) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
35
|
+
const [isOpen, setOpen] = createControllableBooleanSignal({
|
|
36
|
+
value: () => props.open,
|
|
37
|
+
defaultValue: () => props.defaultOpen ?? false,
|
|
38
|
+
onChange: (open) => props.onOpenChange?.(open)
|
|
39
|
+
});
|
|
40
|
+
const contentId = createId("context-menu");
|
|
41
|
+
const [anchor, setAnchor] = createSignal({
|
|
42
|
+
x: 0,
|
|
43
|
+
y: 0
|
|
44
|
+
});
|
|
45
|
+
const collection = createCollection();
|
|
46
|
+
const [focusedIndex, setFocusedIndex] = createSignal(-1);
|
|
47
|
+
let activeSubClose;
|
|
48
|
+
const focusIndex = (index) => {
|
|
49
|
+
const item = collection.items()[index];
|
|
50
|
+
if (!item) return;
|
|
51
|
+
setFocusedIndex(index);
|
|
52
|
+
focusWithoutScrolling(item.ref);
|
|
53
|
+
};
|
|
54
|
+
const move = (action) => {
|
|
55
|
+
const next = getNextEnabledIndex(collection.items(), focusedIndex(), action, true);
|
|
56
|
+
if (next !== -1) focusIndex(next);
|
|
57
|
+
};
|
|
58
|
+
const focusFirst = () => {
|
|
59
|
+
const i = collection.items().findIndex((item) => !item.disabled);
|
|
60
|
+
if (i !== -1) focusIndex(i);
|
|
61
|
+
};
|
|
62
|
+
const focusLast = () => {
|
|
63
|
+
const items = collection.items();
|
|
64
|
+
for (let i = items.length - 1; i >= 0; i--) if (!items[i].disabled) return focusIndex(i);
|
|
65
|
+
};
|
|
66
|
+
const typeahead = createTypeahead({
|
|
67
|
+
getItems: () => collection.items().map((item) => ({
|
|
68
|
+
textValue: item.ref.textContent ?? "",
|
|
69
|
+
disabled: item.disabled
|
|
70
|
+
})),
|
|
71
|
+
getActiveIndex: () => focusedIndex(),
|
|
72
|
+
onMatch: (index) => focusIndex(index)
|
|
73
|
+
});
|
|
74
|
+
const onContentKeyDown = (event) => {
|
|
75
|
+
switch (event.key) {
|
|
76
|
+
case "ArrowDown":
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
move("next");
|
|
79
|
+
break;
|
|
80
|
+
case "ArrowUp":
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
move("previous");
|
|
83
|
+
break;
|
|
84
|
+
case "Home":
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
focusFirst();
|
|
87
|
+
break;
|
|
88
|
+
case "End":
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
focusLast();
|
|
91
|
+
break;
|
|
92
|
+
case "Tab":
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
setOpen(false);
|
|
95
|
+
break;
|
|
96
|
+
default: typeahead.onKeyDown(event);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const context = {
|
|
100
|
+
isOpen,
|
|
101
|
+
setOpen: (open) => setOpen(open),
|
|
102
|
+
contentId,
|
|
103
|
+
anchor,
|
|
104
|
+
openAt: (x, y) => {
|
|
105
|
+
setAnchor({
|
|
106
|
+
x,
|
|
107
|
+
y
|
|
108
|
+
});
|
|
109
|
+
setOpen(true);
|
|
110
|
+
},
|
|
111
|
+
collection,
|
|
112
|
+
onContentKeyDown,
|
|
113
|
+
focusFirst,
|
|
114
|
+
select: () => {
|
|
115
|
+
activeSubClose?.();
|
|
116
|
+
activeSubClose = void 0;
|
|
117
|
+
setOpen(false);
|
|
118
|
+
},
|
|
119
|
+
openSub: (closeFn) => {
|
|
120
|
+
activeSubClose?.();
|
|
121
|
+
activeSubClose = closeFn;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return createMemo(() => createComponent(ContextMenuProvider, {
|
|
125
|
+
get value() {
|
|
126
|
+
return context;
|
|
127
|
+
},
|
|
128
|
+
get children() {
|
|
129
|
+
return props.children;
|
|
130
|
+
}
|
|
131
|
+
}));
|
|
96
132
|
}
|
|
133
|
+
/** The region that opens the menu at the cursor on right-click / long-press. */
|
|
97
134
|
function ContextMenuTrigger(props) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
135
|
+
const ctx = useContextMenuContext();
|
|
136
|
+
const [local, others] = splitProps(props, [
|
|
137
|
+
"ref",
|
|
138
|
+
"children",
|
|
139
|
+
"fx",
|
|
140
|
+
"style"
|
|
141
|
+
]);
|
|
142
|
+
const setRef = (node) => {
|
|
143
|
+
handleRef(local.ref, node);
|
|
144
|
+
const onContextMenu = (event) => {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
ctx.openAt(event.clientX, event.clientY);
|
|
147
|
+
};
|
|
148
|
+
node.addEventListener("contextmenu", onContextMenu);
|
|
149
|
+
onCleanup(() => node.removeEventListener("contextmenu", onContextMenu));
|
|
150
|
+
};
|
|
151
|
+
return (() => {
|
|
152
|
+
const _el$ = createNativeElement("div");
|
|
153
|
+
spread({
|
|
154
|
+
element: _el$,
|
|
155
|
+
props: mergeProps(fxProps(() => local.fx, () => local.style), others, {
|
|
156
|
+
ref: setRef,
|
|
157
|
+
"data-context-menu-trigger": "",
|
|
158
|
+
"data-state": () => ctx.isOpen() ? "open" : "closed"
|
|
159
|
+
})
|
|
160
|
+
});
|
|
161
|
+
insert(_el$, local.children, null);
|
|
162
|
+
return _el$;
|
|
163
|
+
})();
|
|
118
164
|
}
|
|
165
|
+
/** Portals the menu content to the document body while open. */
|
|
119
166
|
function ContextMenuPortal(props) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
167
|
+
const ctx = useContextMenuContext();
|
|
168
|
+
const ref = props.ref || useRef(null);
|
|
169
|
+
createEffect(() => {
|
|
170
|
+
if (!ctx.isOpen() && ref.current) ref.current.remove();
|
|
171
|
+
});
|
|
172
|
+
return createMemo(() => createComponent(Show, {
|
|
173
|
+
get when() {
|
|
174
|
+
return ctx.isOpen();
|
|
175
|
+
},
|
|
176
|
+
get children() {
|
|
177
|
+
return createMemo(() => createComponent(Portal, {
|
|
178
|
+
get ref() {
|
|
179
|
+
return ref;
|
|
180
|
+
},
|
|
181
|
+
get children() {
|
|
182
|
+
return props.children;
|
|
183
|
+
}
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
}));
|
|
136
187
|
}
|
|
188
|
+
/** The floating `role="menu"` surface positioned at the pointer. */
|
|
137
189
|
function ContextMenuContent(props) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
190
|
+
const ctx = useContextMenuContext();
|
|
191
|
+
const [local, others] = splitProps(props, [
|
|
192
|
+
"ref",
|
|
193
|
+
"children",
|
|
194
|
+
"style",
|
|
195
|
+
"fx",
|
|
196
|
+
"style"
|
|
197
|
+
]);
|
|
198
|
+
const [element, setElement] = createSignal(void 0);
|
|
199
|
+
createDismissableLayer(element, {
|
|
200
|
+
shouldExcludeTarget: (target) => {
|
|
201
|
+
return !!(target instanceof Element ? target : target.parentElement)?.closest(`[data-menu-owner="${ctx.contentId}"]`);
|
|
202
|
+
},
|
|
203
|
+
onDismiss: () => ctx.setOpen(false)
|
|
204
|
+
});
|
|
205
|
+
createFocusScope(element, {
|
|
206
|
+
trap: false,
|
|
207
|
+
autoFocus: false,
|
|
208
|
+
restoreFocus: true
|
|
209
|
+
});
|
|
210
|
+
createRenderEffect(() => {
|
|
211
|
+
const el = element();
|
|
212
|
+
if (!el) return;
|
|
213
|
+
const { x, y } = ctx.anchor();
|
|
214
|
+
el.style.position = "fixed";
|
|
215
|
+
const rect = el.getBoundingClientRect();
|
|
216
|
+
const margin = 8;
|
|
217
|
+
const maxLeft = window.innerWidth - rect.width - margin;
|
|
218
|
+
const maxTop = window.innerHeight - rect.height - margin;
|
|
219
|
+
const left = x > maxLeft ? Math.max(margin, x - rect.width) : x;
|
|
220
|
+
const top = y > maxTop ? Math.max(margin, y - rect.height) : y;
|
|
221
|
+
el.style.left = `${Math.min(Math.max(margin, left), Math.max(margin, maxLeft))}px`;
|
|
222
|
+
el.style.top = `${Math.min(Math.max(margin, top), Math.max(margin, maxTop))}px`;
|
|
223
|
+
});
|
|
224
|
+
onMount(() => queueMicrotask(() => ctx.focusFirst()));
|
|
225
|
+
const setRef = (node) => {
|
|
226
|
+
setElement(node);
|
|
227
|
+
handleRef(local.ref, node);
|
|
228
|
+
};
|
|
229
|
+
return (() => {
|
|
230
|
+
const _el$ = createNativeElement("div");
|
|
231
|
+
spread({
|
|
232
|
+
element: _el$,
|
|
233
|
+
props: mergeProps(fxProps(() => local.fx, () => local.style), others, {
|
|
234
|
+
ref: setRef,
|
|
235
|
+
role: "menu",
|
|
236
|
+
id: () => ctx.contentId,
|
|
237
|
+
"data-state": "open",
|
|
238
|
+
tabindex: -1,
|
|
239
|
+
onKeyDown: ctx.onContentKeyDown
|
|
240
|
+
})
|
|
241
|
+
});
|
|
242
|
+
insert(_el$, local.children, null);
|
|
243
|
+
return _el$;
|
|
244
|
+
})();
|
|
171
245
|
}
|
|
246
|
+
/** A menu action; runs `onSelect` and closes the menu when chosen. */
|
|
172
247
|
function ContextMenuItem(props) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
248
|
+
const ctx = useContextMenuContext();
|
|
249
|
+
const [local, others] = splitProps(props, [
|
|
250
|
+
"disabled",
|
|
251
|
+
"onSelect",
|
|
252
|
+
"onClick",
|
|
253
|
+
"onPointerEnter",
|
|
254
|
+
"ref",
|
|
255
|
+
"children",
|
|
256
|
+
"fx",
|
|
257
|
+
"style"
|
|
258
|
+
]);
|
|
259
|
+
const setRef = (node) => {
|
|
260
|
+
const remove = ctx.collection.register({
|
|
261
|
+
ref: node,
|
|
262
|
+
disabled: local.disabled
|
|
263
|
+
});
|
|
264
|
+
onCleanup(remove);
|
|
265
|
+
handleRef(local.ref, node);
|
|
266
|
+
};
|
|
267
|
+
const select = () => {
|
|
268
|
+
if (local.disabled) return;
|
|
269
|
+
local.onSelect?.();
|
|
270
|
+
ctx.select();
|
|
271
|
+
};
|
|
272
|
+
return (() => {
|
|
273
|
+
const _el$ = createNativeElement("div");
|
|
274
|
+
spread({
|
|
275
|
+
element: _el$,
|
|
276
|
+
props: mergeProps(fxProps(() => local.fx, () => local.style), others, {
|
|
277
|
+
ref: setRef,
|
|
278
|
+
role: "menuitem",
|
|
279
|
+
tabindex: -1,
|
|
280
|
+
"aria-disabled": () => local.disabled ? "true" : void 0,
|
|
281
|
+
"data-disabled": () => dataAttr(Boolean(local.disabled)),
|
|
282
|
+
onClick: composeEventHandlers([local.onClick, () => select()]),
|
|
283
|
+
onKeyDown: (event) => {
|
|
284
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
select();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
onPointerEnter: composeEventHandlers([local.onPointerEnter, (e) => {
|
|
290
|
+
if (local.disabled) return;
|
|
291
|
+
e.currentTarget.focus();
|
|
292
|
+
}])
|
|
293
|
+
})
|
|
294
|
+
});
|
|
295
|
+
insert(_el$, local.children, null);
|
|
296
|
+
return _el$;
|
|
297
|
+
})();
|
|
208
298
|
}
|
|
299
|
+
/** A visual divider between groups of menu items. */
|
|
209
300
|
function ContextMenuSeparator(props) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
301
|
+
return (() => {
|
|
302
|
+
const _el$ = createNativeElement("div");
|
|
303
|
+
spread({
|
|
304
|
+
element: _el$,
|
|
305
|
+
props: mergeProps(props, {
|
|
306
|
+
role: "separator",
|
|
307
|
+
"aria-orientation": "horizontal"
|
|
308
|
+
})
|
|
309
|
+
});
|
|
310
|
+
return _el$;
|
|
311
|
+
})();
|
|
215
312
|
}
|
|
313
|
+
/** A non-interactive heading labelling a group of menu items. */
|
|
216
314
|
function ContextMenuLabel(props) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
315
|
+
const [local, others] = splitProps(props, [
|
|
316
|
+
"children",
|
|
317
|
+
"fx",
|
|
318
|
+
"style"
|
|
319
|
+
]);
|
|
320
|
+
return (() => {
|
|
321
|
+
const _el$ = createNativeElement("div");
|
|
322
|
+
spread({
|
|
323
|
+
element: _el$,
|
|
324
|
+
props: mergeProps(fxProps(() => local.fx, () => local.style), others, { "data-menu-label": "" })
|
|
325
|
+
});
|
|
326
|
+
insert(_el$, local.children, null);
|
|
327
|
+
return _el$;
|
|
328
|
+
})();
|
|
227
329
|
}
|
|
228
|
-
|
|
330
|
+
var [SubMenuProvider, useSubMenuContext] = createSafeContext("ContextMenuSub");
|
|
331
|
+
/** Wraps a sub-trigger + sub-content pair to create a nested flyout menu. */
|
|
229
332
|
function ContextMenuSub(props) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
333
|
+
const rootCtx = useContextMenuContext();
|
|
334
|
+
const baseId = createId("context-menu-sub");
|
|
335
|
+
const [isOpen, setIsOpen] = createSignal(false);
|
|
336
|
+
const [triggerRef, setTriggerRef] = createSignal(void 0);
|
|
337
|
+
const subCollection = createCollection();
|
|
338
|
+
const [focusedIndex, setFocusedIndex] = createSignal(-1);
|
|
339
|
+
let openTimer;
|
|
340
|
+
let closeTimer;
|
|
341
|
+
onCleanup(() => {
|
|
342
|
+
clearTimeout(openTimer);
|
|
343
|
+
clearTimeout(closeTimer);
|
|
344
|
+
});
|
|
345
|
+
const focusIndex = (index) => {
|
|
346
|
+
const item = subCollection.items()[index];
|
|
347
|
+
if (!item) return;
|
|
348
|
+
setFocusedIndex(index);
|
|
349
|
+
focusWithoutScrolling(item.ref);
|
|
350
|
+
};
|
|
351
|
+
const focusFirst = () => {
|
|
352
|
+
const i = subCollection.items().findIndex((item) => !item.disabled);
|
|
353
|
+
if (i !== -1) focusIndex(i);
|
|
354
|
+
};
|
|
355
|
+
const openSub = () => {
|
|
356
|
+
rootCtx.openSub(() => setIsOpen(false));
|
|
357
|
+
setIsOpen(true);
|
|
358
|
+
};
|
|
359
|
+
createEffect(() => {
|
|
360
|
+
if (!rootCtx.isOpen()) setIsOpen(false);
|
|
361
|
+
});
|
|
362
|
+
const context = {
|
|
363
|
+
isOpen,
|
|
364
|
+
setOpen: (open) => {
|
|
365
|
+
if (open) openSub();
|
|
366
|
+
else setIsOpen(false);
|
|
367
|
+
},
|
|
368
|
+
ids: {
|
|
369
|
+
trigger: composeId(baseId, "trigger"),
|
|
370
|
+
content: composeId(baseId, "content")
|
|
371
|
+
},
|
|
372
|
+
triggerRef,
|
|
373
|
+
setTriggerRef,
|
|
374
|
+
collection: subCollection,
|
|
375
|
+
focusedIndex,
|
|
376
|
+
focusIndex,
|
|
377
|
+
focusFirst,
|
|
378
|
+
scheduleOpen: () => {
|
|
379
|
+
clearTimeout(closeTimer);
|
|
380
|
+
openTimer = setTimeout(openSub, 200);
|
|
381
|
+
},
|
|
382
|
+
scheduleClose: (movingTowardSub = false) => {
|
|
383
|
+
clearTimeout(openTimer);
|
|
384
|
+
closeTimer = setTimeout(() => setIsOpen(false), movingTowardSub ? 500 : 200);
|
|
385
|
+
},
|
|
386
|
+
cancelClose: () => clearTimeout(closeTimer),
|
|
387
|
+
closeRoot: () => rootCtx.select()
|
|
388
|
+
};
|
|
389
|
+
return createMemo(() => createComponent(SubMenuProvider, {
|
|
390
|
+
get value() {
|
|
391
|
+
return context;
|
|
392
|
+
},
|
|
393
|
+
get children() {
|
|
394
|
+
return props.children;
|
|
395
|
+
}
|
|
396
|
+
}));
|
|
288
397
|
}
|
|
398
|
+
/** A `menuitem` inside a context menu that opens a nested flyout on hover or `ArrowRight`. */
|
|
289
399
|
function ContextMenuSubTrigger(props) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
400
|
+
const ctx = useContextMenuContext();
|
|
401
|
+
const sub = useSubMenuContext();
|
|
402
|
+
const [local, others] = splitProps(props, [
|
|
403
|
+
"disabled",
|
|
404
|
+
"onKeyDown",
|
|
405
|
+
"onPointerEnter",
|
|
406
|
+
"onPointerLeave",
|
|
407
|
+
"ref",
|
|
408
|
+
"children",
|
|
409
|
+
"fx",
|
|
410
|
+
"style"
|
|
411
|
+
]);
|
|
412
|
+
const setRef = (node) => {
|
|
413
|
+
sub.setTriggerRef(node);
|
|
414
|
+
const remove = ctx.collection.register({
|
|
415
|
+
ref: node,
|
|
416
|
+
disabled: local.disabled
|
|
417
|
+
});
|
|
418
|
+
onCleanup(remove);
|
|
419
|
+
handleRef(local.ref, node);
|
|
420
|
+
};
|
|
421
|
+
const open = () => {
|
|
422
|
+
sub.setOpen(true);
|
|
423
|
+
queueMicrotask(() => sub.focusFirst());
|
|
424
|
+
};
|
|
425
|
+
return (() => {
|
|
426
|
+
const _el$ = createNativeElement("div");
|
|
427
|
+
spread({
|
|
428
|
+
element: _el$,
|
|
429
|
+
props: mergeProps(fxProps(() => local.fx, () => local.style), others, {
|
|
430
|
+
ref: setRef,
|
|
431
|
+
role: "menuitem",
|
|
432
|
+
tabindex: -1,
|
|
433
|
+
"aria-haspopup": "menu",
|
|
434
|
+
"aria-expanded": () => sub.isOpen() ? "true" : "false",
|
|
435
|
+
"aria-controls": () => sub.ids.content,
|
|
436
|
+
"data-state": () => sub.isOpen() ? "open" : "closed",
|
|
437
|
+
"data-disabled": () => dataAttr(Boolean(local.disabled)),
|
|
438
|
+
"aria-disabled": () => local.disabled ? "true" : void 0,
|
|
439
|
+
onPointerEnter: composeEventHandlers([local.onPointerEnter, (e) => {
|
|
440
|
+
if (local.disabled) return;
|
|
441
|
+
e.currentTarget.focus();
|
|
442
|
+
sub.scheduleOpen();
|
|
443
|
+
}]),
|
|
444
|
+
onPointerLeave: composeEventHandlers([local.onPointerLeave, (e) => sub.scheduleClose(e.movementX > 0)]),
|
|
445
|
+
onKeyDown: composeEventHandlers([local.onKeyDown, (e) => {
|
|
446
|
+
if (local.disabled) return;
|
|
447
|
+
if (e.key === "ArrowRight" || e.key === "Enter" || e.key === " ") {
|
|
448
|
+
e.preventDefault();
|
|
449
|
+
e.stopPropagation();
|
|
450
|
+
open();
|
|
451
|
+
}
|
|
452
|
+
}])
|
|
453
|
+
})
|
|
454
|
+
});
|
|
455
|
+
insert(_el$, local.children, null);
|
|
456
|
+
return _el$;
|
|
457
|
+
})();
|
|
332
458
|
}
|
|
459
|
+
/** The floating `role="menu"` surface for a nested context-menu submenu. Self-portals while open. */
|
|
333
460
|
function ContextMenuSubContent(props) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
461
|
+
const sub = useSubMenuContext();
|
|
462
|
+
const root = useContextMenuContext();
|
|
463
|
+
const [local, others] = splitProps(props, [
|
|
464
|
+
"ref",
|
|
465
|
+
"children",
|
|
466
|
+
"style",
|
|
467
|
+
"fx",
|
|
468
|
+
"style"
|
|
469
|
+
]);
|
|
470
|
+
const [element, setElement] = createSignal(void 0);
|
|
471
|
+
const portalRef = useRef(null);
|
|
472
|
+
createEffect(() => {
|
|
473
|
+
if (!sub.isOpen() && portalRef.current) portalRef.current.remove();
|
|
474
|
+
});
|
|
475
|
+
const position = createFloatingPosition(sub.triggerRef, element, {
|
|
476
|
+
placement: "right-start",
|
|
477
|
+
offset: -2
|
|
478
|
+
});
|
|
479
|
+
createRenderEffect(() => {
|
|
480
|
+
const el = element();
|
|
481
|
+
if (!el) return;
|
|
482
|
+
el.style.position = "absolute";
|
|
483
|
+
el.style.left = `${Math.round(position.x())}px`;
|
|
484
|
+
el.style.top = `${Math.round(position.y())}px`;
|
|
485
|
+
});
|
|
486
|
+
onMount(() => queueMicrotask(() => sub.focusFirst()));
|
|
487
|
+
const move = (action) => {
|
|
488
|
+
const next = getNextEnabledIndex(sub.collection.items(), sub.focusedIndex(), action, true);
|
|
489
|
+
if (next !== -1) sub.focusIndex(next);
|
|
490
|
+
};
|
|
491
|
+
const focusLast = () => {
|
|
492
|
+
const items = sub.collection.items();
|
|
493
|
+
for (let i = items.length - 1; i >= 0; i--) if (!items[i].disabled) {
|
|
494
|
+
sub.focusIndex(i);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
const typeahead = createTypeahead({
|
|
499
|
+
getItems: () => sub.collection.items().map((item) => ({
|
|
500
|
+
textValue: item.ref.textContent ?? "",
|
|
501
|
+
disabled: item.disabled
|
|
502
|
+
})),
|
|
503
|
+
getActiveIndex: () => sub.focusedIndex(),
|
|
504
|
+
onMatch: (index) => sub.focusIndex(index)
|
|
505
|
+
});
|
|
506
|
+
const onContentKeyDown = (event) => {
|
|
507
|
+
switch (event.key) {
|
|
508
|
+
case "ArrowDown":
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
move("next");
|
|
511
|
+
break;
|
|
512
|
+
case "ArrowUp":
|
|
513
|
+
event.preventDefault();
|
|
514
|
+
move("previous");
|
|
515
|
+
break;
|
|
516
|
+
case "Home":
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
sub.focusFirst();
|
|
519
|
+
break;
|
|
520
|
+
case "End":
|
|
521
|
+
event.preventDefault();
|
|
522
|
+
focusLast();
|
|
523
|
+
break;
|
|
524
|
+
case "ArrowLeft":
|
|
525
|
+
case "Escape":
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
event.stopPropagation();
|
|
528
|
+
sub.setOpen(false);
|
|
529
|
+
focusWithoutScrolling(sub.triggerRef());
|
|
530
|
+
break;
|
|
531
|
+
case "Tab":
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
sub.closeRoot();
|
|
534
|
+
break;
|
|
535
|
+
default: typeahead.onKeyDown(event);
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const setRef = (node) => {
|
|
539
|
+
setElement(node);
|
|
540
|
+
handleRef(local.ref, node);
|
|
541
|
+
};
|
|
542
|
+
return createMemo(() => createComponent(Show, {
|
|
543
|
+
get when() {
|
|
544
|
+
return sub.isOpen();
|
|
545
|
+
},
|
|
546
|
+
get children() {
|
|
547
|
+
return createMemo(() => createComponent(Portal, {
|
|
548
|
+
get ref() {
|
|
549
|
+
return portalRef;
|
|
550
|
+
},
|
|
551
|
+
get children() {
|
|
552
|
+
return (() => {
|
|
553
|
+
const _el$ = createNativeElement("div");
|
|
554
|
+
spread({
|
|
555
|
+
element: _el$,
|
|
556
|
+
props: mergeProps(fxProps(() => local.fx, () => local.style), others, {
|
|
557
|
+
ref: setRef,
|
|
558
|
+
role: "menu",
|
|
559
|
+
id: () => sub.ids.content,
|
|
560
|
+
"data-menu-owner": () => root.contentId,
|
|
561
|
+
"aria-labelledby": () => sub.ids.trigger,
|
|
562
|
+
"data-state": "open",
|
|
563
|
+
"data-placement": () => position.placement(),
|
|
564
|
+
tabindex: -1,
|
|
565
|
+
onKeyDown: onContentKeyDown,
|
|
566
|
+
onPointerEnter: () => sub.cancelClose(),
|
|
567
|
+
onPointerLeave: () => sub.scheduleClose()
|
|
568
|
+
})
|
|
569
|
+
});
|
|
570
|
+
insert(_el$, local.children, null);
|
|
571
|
+
return _el$;
|
|
572
|
+
})();
|
|
573
|
+
}
|
|
574
|
+
}));
|
|
575
|
+
}
|
|
576
|
+
}));
|
|
423
577
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
ContextMenuContent,
|
|
427
|
-
ContextMenuItem,
|
|
428
|
-
ContextMenuLabel,
|
|
429
|
-
ContextMenuPortal,
|
|
430
|
-
ContextMenuSeparator,
|
|
431
|
-
ContextMenuSub,
|
|
432
|
-
ContextMenuSubContent,
|
|
433
|
-
ContextMenuSubTrigger,
|
|
434
|
-
ContextMenuTrigger
|
|
435
|
-
};
|
|
578
|
+
//#endregion
|
|
579
|
+
export { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger };
|