@fluixi-ui/context-menu 0.2.0-alpha.18 → 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.
@@ -1,443 +1,579 @@
1
- import { delegateEvents, createComponent, splitProps, createNativeElement, spread, mergeProps, insert, handleRef } from "@fluixi/dom";
2
- import { createSignal, createMemo, createEffect, createRenderEffect, onCleanup } from "@fluixi/reactive/signal";
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 { useRef, Show, onMount } from "@fluixi/core";
5
- import { createSafeContext, createControllableBooleanSignal, createId, composeEventHandlers, dataAttr, composeId, focusWithoutScrolling } from "@fluixi-ui/kit";
6
- import { createCollection, createTypeahead, Portal, createDismissableLayer, createFocusScope, createFloatingPosition, getNextEnabledIndex } from "@fluixi-ui/primitives";
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
- const [ContextMenuProvider, useContextMenuContext] = createSafeContext("ContextMenu");
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
- const [isOpen, setOpen] = createControllableBooleanSignal({
11
- value: () => props.open,
12
- defaultValue: () => props.defaultOpen ?? false,
13
- onChange: (open) => props.onOpenChange?.(open)
14
- });
15
- const contentId = createId("context-menu");
16
- const [anchor, setAnchor] = createSignal({ x: 0, y: 0 });
17
- const collection = createCollection();
18
- const [focusedIndex, setFocusedIndex] = createSignal(-1);
19
- let activeSubClose;
20
- const focusIndex = (index) => {
21
- const item = collection.items()[index];
22
- if (!item) return;
23
- setFocusedIndex(index);
24
- focusWithoutScrolling(item.ref);
25
- };
26
- const move = (action) => {
27
- const next = getNextEnabledIndex(collection.items(), focusedIndex(), action, true);
28
- if (next !== -1) focusIndex(next);
29
- };
30
- const focusFirst = () => {
31
- const i = collection.items().findIndex((item) => !item.disabled);
32
- if (i !== -1) focusIndex(i);
33
- };
34
- const focusLast = () => {
35
- const items = collection.items();
36
- for (let i = items.length - 1; i >= 0; i--) if (!items[i].disabled) return focusIndex(i);
37
- };
38
- const typeahead = createTypeahead({
39
- getItems: () => collection.items().map((item) => ({ textValue: item.ref.textContent ?? "", disabled: item.disabled })),
40
- getActiveIndex: () => focusedIndex(),
41
- onMatch: (index) => focusIndex(index)
42
- });
43
- const onContentKeyDown = (event) => {
44
- switch (event.key) {
45
- case "ArrowDown":
46
- event.preventDefault();
47
- move("next");
48
- break;
49
- case "ArrowUp":
50
- event.preventDefault();
51
- move("previous");
52
- break;
53
- case "Home":
54
- event.preventDefault();
55
- focusFirst();
56
- break;
57
- case "End":
58
- event.preventDefault();
59
- focusLast();
60
- break;
61
- case "Tab":
62
- event.preventDefault();
63
- setOpen(false);
64
- break;
65
- default:
66
- typeahead.onKeyDown(event);
67
- }
68
- };
69
- const context = {
70
- isOpen,
71
- setOpen: (open) => setOpen(open),
72
- contentId,
73
- anchor,
74
- openAt: (x, y) => {
75
- setAnchor({ x, y });
76
- setOpen(true);
77
- },
78
- collection,
79
- onContentKeyDown,
80
- focusFirst,
81
- select: () => {
82
- activeSubClose?.();
83
- activeSubClose = void 0;
84
- setOpen(false);
85
- },
86
- openSub: (closeFn) => {
87
- activeSubClose?.();
88
- activeSubClose = closeFn;
89
- }
90
- };
91
- return createMemo(() => createComponent(ContextMenuProvider, { get value() {
92
- return context;
93
- }, get children() {
94
- return props.children;
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
- const ctx = useContextMenuContext();
99
- const [local, others] = splitProps(props, ["ref", "children", "fx", "style"]);
100
- const setRef = (node) => {
101
- handleRef(local.ref, node);
102
- const onContextMenu = (event) => {
103
- event.preventDefault();
104
- ctx.openAt(event.clientX, event.clientY);
105
- };
106
- node.addEventListener("contextmenu", onContextMenu);
107
- onCleanup(() => node.removeEventListener("contextmenu", onContextMenu));
108
- };
109
- return (() => {
110
- const _el$ = createNativeElement("div");
111
- spread({ element: _el$, props: mergeProps(fxProps(
112
- () => local.fx,
113
- () => local.style
114
- ), others, { ref: setRef, "data-context-menu-trigger": "", "data-state": () => ctx.isOpen() ? "open" : "closed" }) });
115
- insert(_el$, local.children);
116
- return _el$;
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
- const ctx = useContextMenuContext();
121
- const ref = props.ref || useRef(null);
122
- createEffect(() => {
123
- if (!ctx.isOpen() && ref.current) {
124
- ref.current.remove();
125
- }
126
- });
127
- return createMemo(() => createComponent(Show, { get when() {
128
- return ctx.isOpen();
129
- }, get children() {
130
- return createMemo(() => createComponent(Portal, { get ref() {
131
- return ref;
132
- }, get children() {
133
- return props.children;
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
- const ctx = useContextMenuContext();
139
- const [local, others] = splitProps(props, ["ref", "children", "style", "fx", "style"]);
140
- const [element, setElement] = createSignal(void 0);
141
- createDismissableLayer(element, {
142
- // Submenus portal themselves outside this content; a press in one of ours is inside.
143
- shouldExcludeTarget: (target) => {
144
- const el = target instanceof Element ? target : target.parentElement;
145
- return !!el?.closest(`[data-menu-owner="${ctx.contentId}"]`);
146
- },
147
- onDismiss: () => ctx.setOpen(false)
148
- });
149
- createFocusScope(element, { trap: false, autoFocus: false, restoreFocus: true });
150
- createRenderEffect(() => {
151
- const el = element();
152
- if (!el) return;
153
- const { x, y } = ctx.anchor();
154
- el.style.position = "fixed";
155
- const rect = el.getBoundingClientRect();
156
- const margin = 8;
157
- const maxLeft = window.innerWidth - rect.width - margin;
158
- const maxTop = window.innerHeight - rect.height - margin;
159
- const left = x > maxLeft ? Math.max(margin, x - rect.width) : x;
160
- const top = y > maxTop ? Math.max(margin, y - rect.height) : y;
161
- el.style.left = `${Math.min(Math.max(margin, left), Math.max(margin, maxLeft))}px`;
162
- el.style.top = `${Math.min(Math.max(margin, top), Math.max(margin, maxTop))}px`;
163
- });
164
- onMount(() => queueMicrotask(() => ctx.focusFirst()));
165
- const setRef = (node) => {
166
- setElement(node);
167
- handleRef(local.ref, node);
168
- };
169
- return (() => {
170
- const _el$ = createNativeElement("div");
171
- spread({ element: _el$, props: mergeProps(fxProps(
172
- () => local.fx,
173
- () => local.style
174
- ), others, { ref: setRef, role: "menu", id: () => ctx.contentId, "data-state": "open", tabindex: -1, onKeyDown: ctx.onContentKeyDown }) });
175
- insert(_el$, local.children);
176
- return _el$;
177
- })();
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
+ })();
178
245
  }
246
+ /** A menu action; runs `onSelect` and closes the menu when chosen. */
179
247
  function ContextMenuItem(props) {
180
- const ctx = useContextMenuContext();
181
- const [local, others] = splitProps(props, ["disabled", "onSelect", "onClick", "onPointerEnter", "ref", "children", "fx", "style"]);
182
- const setRef = (node) => {
183
- const remove = ctx.collection.register({ ref: node, disabled: local.disabled });
184
- onCleanup(remove);
185
- handleRef(local.ref, node);
186
- };
187
- const select = () => {
188
- if (local.disabled) return;
189
- local.onSelect?.();
190
- ctx.select();
191
- };
192
- return (() => {
193
- const _el$ = createNativeElement("div");
194
- spread({ element: _el$, props: mergeProps(fxProps(
195
- () => local.fx,
196
- () => local.style
197
- ), others, { ref: setRef, role: "menuitem", tabindex: -1, "aria-disabled": () => local.disabled ? "true" : void 0, "data-disabled": () => dataAttr(Boolean(local.disabled)), onClick: composeEventHandlers([
198
- local.onClick,
199
- () => select()
200
- ]), onKeyDown: (event) => {
201
- if (event.key === "Enter" || event.key === " ") {
202
- event.preventDefault();
203
- select();
204
- }
205
- }, onPointerEnter: composeEventHandlers([
206
- local.onPointerEnter,
207
- (e) => {
208
- if (local.disabled) return;
209
- e.currentTarget.focus();
210
- }
211
- ]) }) });
212
- insert(_el$, local.children);
213
- return _el$;
214
- })();
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
+ })();
215
298
  }
299
+ /** A visual divider between groups of menu items. */
216
300
  function ContextMenuSeparator(props) {
217
- return (() => {
218
- const _el$ = createNativeElement("div");
219
- spread({ element: _el$, props: mergeProps(props, { role: "separator", "aria-orientation": "horizontal" }) });
220
- return _el$;
221
- })();
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
+ })();
222
312
  }
313
+ /** A non-interactive heading labelling a group of menu items. */
223
314
  function ContextMenuLabel(props) {
224
- const [local, others] = splitProps(props, ["children", "fx", "style"]);
225
- return (() => {
226
- const _el$ = createNativeElement("div");
227
- spread({ element: _el$, props: mergeProps(fxProps(
228
- () => local.fx,
229
- () => local.style
230
- ), others, { "data-menu-label": "" }) });
231
- insert(_el$, local.children);
232
- return _el$;
233
- })();
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
+ })();
234
329
  }
235
- const [SubMenuProvider, useSubMenuContext] = createSafeContext("ContextMenuSub");
330
+ var [SubMenuProvider, useSubMenuContext] = createSafeContext("ContextMenuSub");
331
+ /** Wraps a sub-trigger + sub-content pair to create a nested flyout menu. */
236
332
  function ContextMenuSub(props) {
237
- const rootCtx = useContextMenuContext();
238
- const baseId = createId("context-menu-sub");
239
- const [isOpen, setIsOpen] = createSignal(false);
240
- const [triggerRef, setTriggerRef] = createSignal(void 0);
241
- const subCollection = createCollection();
242
- const [focusedIndex, setFocusedIndex] = createSignal(-1);
243
- let openTimer;
244
- let closeTimer;
245
- onCleanup(() => {
246
- clearTimeout(openTimer);
247
- clearTimeout(closeTimer);
248
- });
249
- const focusIndex = (index) => {
250
- const item = subCollection.items()[index];
251
- if (!item) return;
252
- setFocusedIndex(index);
253
- focusWithoutScrolling(item.ref);
254
- };
255
- const focusFirst = () => {
256
- const i = subCollection.items().findIndex((item) => !item.disabled);
257
- if (i !== -1) focusIndex(i);
258
- };
259
- const openSub = () => {
260
- rootCtx.openSub(() => setIsOpen(false));
261
- setIsOpen(true);
262
- };
263
- createEffect(() => {
264
- if (!rootCtx.isOpen()) setIsOpen(false);
265
- });
266
- const context = {
267
- isOpen,
268
- setOpen: (open) => {
269
- if (open) openSub();
270
- else setIsOpen(false);
271
- },
272
- ids: { trigger: composeId(baseId, "trigger"), content: composeId(baseId, "content") },
273
- triggerRef,
274
- setTriggerRef,
275
- collection: subCollection,
276
- focusedIndex,
277
- focusIndex,
278
- focusFirst,
279
- scheduleOpen: () => {
280
- clearTimeout(closeTimer);
281
- openTimer = setTimeout(openSub, 200);
282
- },
283
- scheduleClose: (movingTowardSub = false) => {
284
- clearTimeout(openTimer);
285
- closeTimer = setTimeout(() => setIsOpen(false), movingTowardSub ? 500 : 200);
286
- },
287
- cancelClose: () => clearTimeout(closeTimer),
288
- closeRoot: () => rootCtx.select()
289
- };
290
- return createMemo(() => createComponent(SubMenuProvider, { get value() {
291
- return context;
292
- }, get children() {
293
- return props.children;
294
- } }));
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
+ }));
295
397
  }
398
+ /** A `menuitem` inside a context menu that opens a nested flyout on hover or `ArrowRight`. */
296
399
  function ContextMenuSubTrigger(props) {
297
- const ctx = useContextMenuContext();
298
- const sub = useSubMenuContext();
299
- const [local, others] = splitProps(props, ["disabled", "onKeyDown", "onPointerEnter", "onPointerLeave", "ref", "children", "fx", "style"]);
300
- const setRef = (node) => {
301
- sub.setTriggerRef(node);
302
- const remove = ctx.collection.register({ ref: node, disabled: local.disabled });
303
- onCleanup(remove);
304
- handleRef(local.ref, node);
305
- };
306
- const open = () => {
307
- sub.setOpen(true);
308
- queueMicrotask(() => sub.focusFirst());
309
- };
310
- return (() => {
311
- const _el$ = createNativeElement("div");
312
- spread({ element: _el$, props: mergeProps(fxProps(
313
- () => local.fx,
314
- () => local.style
315
- ), others, { ref: setRef, role: "menuitem", tabindex: -1, "aria-haspopup": "menu", "aria-expanded": () => sub.isOpen() ? "true" : "false", "aria-controls": () => sub.ids.content, "data-state": () => sub.isOpen() ? "open" : "closed", "data-disabled": () => dataAttr(Boolean(local.disabled)), "aria-disabled": () => local.disabled ? "true" : void 0, onPointerEnter: composeEventHandlers([
316
- local.onPointerEnter,
317
- (e) => {
318
- if (local.disabled) return;
319
- e.currentTarget.focus();
320
- sub.scheduleOpen();
321
- }
322
- ]), onPointerLeave: composeEventHandlers([
323
- local.onPointerLeave,
324
- (e) => sub.scheduleClose(e.movementX > 0)
325
- ]), onKeyDown: composeEventHandlers([
326
- local.onKeyDown,
327
- (e) => {
328
- if (local.disabled) return;
329
- if (e.key === "ArrowRight" || e.key === "Enter" || e.key === " ") {
330
- e.preventDefault();
331
- e.stopPropagation();
332
- open();
333
- }
334
- }
335
- ]) }) });
336
- insert(_el$, local.children);
337
- return _el$;
338
- })();
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
+ })();
339
458
  }
459
+ /** The floating `role="menu"` surface for a nested context-menu submenu. Self-portals while open. */
340
460
  function ContextMenuSubContent(props) {
341
- const sub = useSubMenuContext();
342
- const root = useContextMenuContext();
343
- const [local, others] = splitProps(props, ["ref", "children", "style", "fx", "style"]);
344
- const [element, setElement] = createSignal(void 0);
345
- const portalRef = useRef(null);
346
- createEffect(() => {
347
- if (!sub.isOpen() && portalRef.current) portalRef.current.remove();
348
- });
349
- const position = createFloatingPosition(sub.triggerRef, element, {
350
- placement: "right-start",
351
- offset: -2
352
- });
353
- createRenderEffect(() => {
354
- const el = element();
355
- if (!el) return;
356
- el.style.position = "absolute";
357
- el.style.left = `${Math.round(position.x())}px`;
358
- el.style.top = `${Math.round(position.y())}px`;
359
- });
360
- onMount(() => queueMicrotask(() => sub.focusFirst()));
361
- const move = (action) => {
362
- const next = getNextEnabledIndex(sub.collection.items(), sub.focusedIndex(), action, true);
363
- if (next !== -1) sub.focusIndex(next);
364
- };
365
- const focusLast = () => {
366
- const items = sub.collection.items();
367
- for (let i = items.length - 1; i >= 0; i--) if (!items[i].disabled) {
368
- sub.focusIndex(i);
369
- return;
370
- }
371
- };
372
- const typeahead = createTypeahead({
373
- getItems: () => sub.collection.items().map((item) => ({ textValue: item.ref.textContent ?? "", disabled: item.disabled })),
374
- getActiveIndex: () => sub.focusedIndex(),
375
- onMatch: (index) => sub.focusIndex(index)
376
- });
377
- const onContentKeyDown = (event) => {
378
- switch (event.key) {
379
- case "ArrowDown":
380
- event.preventDefault();
381
- move("next");
382
- break;
383
- case "ArrowUp":
384
- event.preventDefault();
385
- move("previous");
386
- break;
387
- case "Home":
388
- event.preventDefault();
389
- sub.focusFirst();
390
- break;
391
- case "End":
392
- event.preventDefault();
393
- focusLast();
394
- break;
395
- case "ArrowLeft":
396
- case "Escape":
397
- event.preventDefault();
398
- event.stopPropagation();
399
- sub.setOpen(false);
400
- focusWithoutScrolling(sub.triggerRef());
401
- break;
402
- case "Tab":
403
- event.preventDefault();
404
- sub.closeRoot();
405
- break;
406
- default:
407
- typeahead.onKeyDown(event);
408
- }
409
- };
410
- const setRef = (node) => {
411
- setElement(node);
412
- handleRef(local.ref, node);
413
- };
414
- return createMemo(() => createComponent(Show, { get when() {
415
- return sub.isOpen();
416
- }, get children() {
417
- return createMemo(() => createComponent(Portal, { get ref() {
418
- return portalRef;
419
- }, get children() {
420
- return (() => {
421
- const _el$ = createNativeElement("div");
422
- spread({ element: _el$, props: mergeProps(fxProps(
423
- () => local.fx,
424
- () => local.style
425
- ), others, { ref: setRef, role: "menu", id: () => sub.ids.content, "data-menu-owner": () => root.contentId, "aria-labelledby": () => sub.ids.trigger, "data-state": "open", "data-placement": () => position.placement(), tabindex: -1, onKeyDown: onContentKeyDown, onPointerEnter: () => sub.cancelClose(), onPointerLeave: () => sub.scheduleClose() }) });
426
- insert(_el$, local.children);
427
- return _el$;
428
- })();
429
- } }));
430
- } }));
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
+ }));
431
577
  }
432
- export {
433
- ContextMenu,
434
- ContextMenuContent,
435
- ContextMenuItem,
436
- ContextMenuLabel,
437
- ContextMenuPortal,
438
- ContextMenuSeparator,
439
- ContextMenuSub,
440
- ContextMenuSubContent,
441
- ContextMenuSubTrigger,
442
- ContextMenuTrigger
443
- };
578
+ //#endregion
579
+ export { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuPortal, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger };