bits-ui 1.0.0-next.87 → 1.0.0-next.88
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/bits/menu/menu.svelte.d.ts +5 -2
- package/dist/bits/menu/menu.svelte.js +51 -5
- package/dist/bits/menubar/menubar.svelte.d.ts +16 -5
- package/dist/bits/menubar/menubar.svelte.js +58 -41
- package/dist/bits/utilities/focus-scope/use-focus-scope.svelte.d.ts +5 -0
- package/dist/bits/utilities/focus-scope/use-focus-scope.svelte.js +17 -10
- package/dist/internal/dom.d.ts +2 -0
- package/dist/internal/dom.js +10 -0
- package/dist/internal/tabbable.d.ts +9 -0
- package/dist/internal/tabbable.js +66 -0
- package/dist/internal/use-roving-focus.svelte.d.ts +2 -6
- package/dist/internal/use-roving-focus.svelte.js +1 -3
- package/package.json +3 -2
|
@@ -18,6 +18,7 @@ export declare const MenuOpenEvent: CustomEventDispatcher<unknown>;
|
|
|
18
18
|
declare class MenuRootState {
|
|
19
19
|
readonly opts: MenuRootStateProps;
|
|
20
20
|
isUsingKeyboard: IsUsingKeyboard;
|
|
21
|
+
ignoreCloseAutoFocus: boolean;
|
|
21
22
|
constructor(opts: MenuRootStateProps);
|
|
22
23
|
getAttr(name: string): string;
|
|
23
24
|
}
|
|
@@ -27,11 +28,11 @@ type MenuMenuStateProps = WritableBoxedValues<{
|
|
|
27
28
|
declare class MenuMenuState {
|
|
28
29
|
readonly opts: MenuMenuStateProps;
|
|
29
30
|
readonly root: MenuRootState;
|
|
30
|
-
readonly parentMenu
|
|
31
|
+
readonly parentMenu: MenuMenuState | null;
|
|
31
32
|
contentId: import("svelte-toolbelt").ReadableBox<string>;
|
|
32
33
|
contentNode: HTMLElement | null;
|
|
33
34
|
triggerNode: HTMLElement | null;
|
|
34
|
-
constructor(opts: MenuMenuStateProps, root: MenuRootState, parentMenu
|
|
35
|
+
constructor(opts: MenuMenuStateProps, root: MenuRootState, parentMenu: MenuMenuState | null);
|
|
35
36
|
toggleOpen(): void;
|
|
36
37
|
onOpen(): void;
|
|
37
38
|
onClose(): void;
|
|
@@ -50,6 +51,7 @@ declare class MenuContentState {
|
|
|
50
51
|
isFocusWithin: IsFocusWithin;
|
|
51
52
|
constructor(opts: MenuContentStateProps, parentMenu: MenuMenuState);
|
|
52
53
|
onPointerGraceIntentChange(intent: GraceIntent | null): void;
|
|
54
|
+
handleTabKeyDown(e: BitsKeyboardEvent): void;
|
|
53
55
|
onkeydown(e: BitsKeyboardEvent): void;
|
|
54
56
|
onblur(e: BitsFocusEvent): void;
|
|
55
57
|
onfocus(_: BitsFocusEvent): void;
|
|
@@ -348,6 +350,7 @@ declare class ContextMenuTriggerState {
|
|
|
348
350
|
readonly "data-disabled": "" | undefined;
|
|
349
351
|
readonly "data-state": "open" | "closed";
|
|
350
352
|
readonly "data-context-menu-trigger": "";
|
|
353
|
+
readonly tabindex: -1;
|
|
351
354
|
readonly onpointerdown: (e: BitsPointerEvent) => void;
|
|
352
355
|
readonly onpointermove: (e: BitsPointerEvent) => void;
|
|
353
356
|
readonly onpointercancel: (e: BitsPointerEvent) => void;
|
|
@@ -10,6 +10,8 @@ import { kbd } from "../../internal/kbd.js";
|
|
|
10
10
|
import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaOrientation, getDataDisabled, getDataOpenClosed, } from "../../internal/attrs.js";
|
|
11
11
|
import { isPointerInGraceArea, makeHullFromElements } from "../../internal/polygon.js";
|
|
12
12
|
import { IsUsingKeyboard } from "../../index.js";
|
|
13
|
+
import { getTabbableFrom } from "../../internal/tabbable.js";
|
|
14
|
+
import { FocusScopeContext } from "../utilities/focus-scope/use-focus-scope.svelte.js";
|
|
13
15
|
export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger";
|
|
14
16
|
const MenuRootContext = new Context("Menu.Root");
|
|
15
17
|
const MenuMenuContext = new Context("Menu.Root | Menu.Sub");
|
|
@@ -23,6 +25,7 @@ export const MenuOpenEvent = new CustomEventDispatcher("bitsmenuopen", {
|
|
|
23
25
|
class MenuRootState {
|
|
24
26
|
opts;
|
|
25
27
|
isUsingKeyboard = new IsUsingKeyboard();
|
|
28
|
+
ignoreCloseAutoFocus = $state(false);
|
|
26
29
|
constructor(opts) {
|
|
27
30
|
this.opts = opts;
|
|
28
31
|
}
|
|
@@ -128,9 +131,48 @@ class MenuContentState {
|
|
|
128
131
|
onPointerGraceIntentChange(intent) {
|
|
129
132
|
this.#pointerGraceIntent = intent;
|
|
130
133
|
}
|
|
134
|
+
handleTabKeyDown(e) {
|
|
135
|
+
/**
|
|
136
|
+
* We locate the root `menu`'s trigger by going up the tree until
|
|
137
|
+
* we find a menu that has no parent. This will allow us to focus the next
|
|
138
|
+
* tabbable element before/after the root trigger.
|
|
139
|
+
*/
|
|
140
|
+
let rootMenu = this.parentMenu;
|
|
141
|
+
while (rootMenu.parentMenu !== null) {
|
|
142
|
+
rootMenu = rootMenu.parentMenu;
|
|
143
|
+
}
|
|
144
|
+
// if for some unforeseen reason the root menu has no trigger, we bail
|
|
145
|
+
if (!rootMenu.triggerNode)
|
|
146
|
+
return;
|
|
147
|
+
// cancel default tab behavior
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
// find the next/previous tabbable
|
|
150
|
+
const nodeToFocus = getTabbableFrom(rootMenu.triggerNode, e.shiftKey ? "prev" : "next");
|
|
151
|
+
if (nodeToFocus) {
|
|
152
|
+
/**
|
|
153
|
+
* We set a flag to ignore the `onCloseAutoFocus` event handler
|
|
154
|
+
* as well as the fallbacks inside the focus scope to prevent
|
|
155
|
+
* race conditions causing focus to fall back to the body even
|
|
156
|
+
* though we're trying to focus the next tabbable element.
|
|
157
|
+
*/
|
|
158
|
+
this.parentMenu.root.ignoreCloseAutoFocus = true;
|
|
159
|
+
rootMenu.onClose();
|
|
160
|
+
nodeToFocus.focus();
|
|
161
|
+
afterTick(() => {
|
|
162
|
+
this.parentMenu.root.ignoreCloseAutoFocus = false;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
document.body.focus();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
131
169
|
onkeydown(e) {
|
|
132
170
|
if (e.defaultPrevented)
|
|
133
171
|
return;
|
|
172
|
+
if (e.key === kbd.TAB) {
|
|
173
|
+
this.handleTabKeyDown(e);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
134
176
|
const target = e.target;
|
|
135
177
|
const currentTarget = e.currentTarget;
|
|
136
178
|
if (!isHTMLElement(target) || !isHTMLElement(currentTarget))
|
|
@@ -147,9 +189,6 @@ class MenuContentState {
|
|
|
147
189
|
return;
|
|
148
190
|
const candidateNodes = this.#getCandidateNodes();
|
|
149
191
|
if (isKeydownInside) {
|
|
150
|
-
// menus do not respect the tab key
|
|
151
|
-
if (e.key === kbd.TAB)
|
|
152
|
-
e.preventDefault();
|
|
153
192
|
if (!isModifierKey && isCharacterKey) {
|
|
154
193
|
this.#handleTypeaheadSearch(e.key, candidateNodes);
|
|
155
194
|
}
|
|
@@ -786,6 +825,7 @@ class ContextMenuTriggerState {
|
|
|
786
825
|
"data-disabled": getDataDisabled(this.opts.disabled.current),
|
|
787
826
|
"data-state": getDataOpenClosed(this.parentMenu.opts.open.current),
|
|
788
827
|
[CONTEXT_MENU_TRIGGER_ATTR]: "",
|
|
828
|
+
tabindex: -1,
|
|
789
829
|
//
|
|
790
830
|
onpointerdown: this.onpointerdown,
|
|
791
831
|
onpointermove: this.onpointermove,
|
|
@@ -795,10 +835,16 @@ class ContextMenuTriggerState {
|
|
|
795
835
|
}));
|
|
796
836
|
}
|
|
797
837
|
export function useMenuRoot(props) {
|
|
798
|
-
|
|
838
|
+
const root = new MenuRootState(props);
|
|
839
|
+
FocusScopeContext.set({
|
|
840
|
+
get ignoreCloseAutoFocus() {
|
|
841
|
+
return root.ignoreCloseAutoFocus;
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
return MenuRootContext.set(root);
|
|
799
845
|
}
|
|
800
846
|
export function useMenuMenu(root, props) {
|
|
801
|
-
return MenuMenuContext.set(new MenuMenuState(props, root));
|
|
847
|
+
return MenuMenuContext.set(new MenuMenuState(props, root, null));
|
|
802
848
|
}
|
|
803
849
|
export function useMenuSubmenu(props) {
|
|
804
850
|
const menu = MenuMenuContext.get();
|
|
@@ -4,6 +4,7 @@ import type { ReadableBoxedValues, WritableBoxedValues } from "../../internal/bo
|
|
|
4
4
|
import { type UseRovingFocusReturn } from "../../internal/use-roving-focus.svelte.js";
|
|
5
5
|
import type { Direction } from "../../shared/index.js";
|
|
6
6
|
import type { BitsFocusEvent, BitsKeyboardEvent, BitsPointerEvent, WithRefProps } from "../../internal/types.js";
|
|
7
|
+
import { type FocusScopeContextValue } from "../utilities/focus-scope/use-focus-scope.svelte.js";
|
|
7
8
|
type MenubarRootStateProps = WithRefProps<ReadableBoxedValues<{
|
|
8
9
|
dir: Direction;
|
|
9
10
|
loop: boolean;
|
|
@@ -13,15 +14,23 @@ type MenubarRootStateProps = WithRefProps<ReadableBoxedValues<{
|
|
|
13
14
|
declare class MenubarRootState {
|
|
14
15
|
readonly opts: MenubarRootStateProps;
|
|
15
16
|
rovingFocusGroup: UseRovingFocusReturn;
|
|
16
|
-
currentTabStopId: import("svelte-toolbelt").WritableBox<string | null>;
|
|
17
17
|
wasOpenedByKeyboard: boolean;
|
|
18
18
|
triggerIds: string[];
|
|
19
19
|
valueToContentId: Map<string, ReadableBox<string>>;
|
|
20
20
|
constructor(opts: MenubarRootStateProps);
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* @param id - the id of the trigger to register
|
|
23
|
+
* @returns - a function to de-register the trigger
|
|
24
|
+
*/
|
|
25
|
+
registerTrigger(id: string): () => void;
|
|
26
|
+
/**
|
|
27
|
+
* @param value - the value of the menu to register
|
|
28
|
+
* @param contentId - the content id to associate with the value
|
|
29
|
+
* @returns - a function to de-register the menu
|
|
30
|
+
*/
|
|
31
|
+
registerMenu(value: string, contentId: ReadableBox<string>): () => void;
|
|
23
32
|
getTriggers(): HTMLButtonElement[];
|
|
24
|
-
onMenuOpen(id: string): void;
|
|
33
|
+
onMenuOpen(id: string, triggerId: string): void;
|
|
25
34
|
onMenuClose(): void;
|
|
26
35
|
onMenuToggle(id: string): void;
|
|
27
36
|
props: {
|
|
@@ -42,6 +51,7 @@ declare class MenubarMenuState {
|
|
|
42
51
|
contentNode: HTMLElement | null;
|
|
43
52
|
constructor(opts: MenubarMenuStateProps, root: MenubarRootState);
|
|
44
53
|
getTriggerNode(): HTMLElement | null;
|
|
54
|
+
openMenu(): void;
|
|
45
55
|
}
|
|
46
56
|
type MenubarTriggerStateProps = WithRefProps<ReadableBoxedValues<{
|
|
47
57
|
disabled: boolean;
|
|
@@ -70,7 +80,7 @@ declare class MenubarTriggerState {
|
|
|
70
80
|
readonly "data-disabled": "" | undefined;
|
|
71
81
|
readonly "data-menu-value": string;
|
|
72
82
|
readonly disabled: true | undefined;
|
|
73
|
-
readonly
|
|
83
|
+
readonly tabindex: number;
|
|
74
84
|
readonly "data-menubar-trigger": "";
|
|
75
85
|
readonly onpointerdown: (e: BitsPointerEvent) => void;
|
|
76
86
|
readonly onpointerenter: (_: BitsPointerEvent) => void;
|
|
@@ -87,6 +97,7 @@ declare class MenubarContentState {
|
|
|
87
97
|
readonly menu: MenubarMenuState;
|
|
88
98
|
root: MenubarRootState;
|
|
89
99
|
hasInteractedOutside: boolean;
|
|
100
|
+
focusScopeContext: FocusScopeContextValue;
|
|
90
101
|
constructor(opts: MenubarContentStateProps, menu: MenubarMenuState);
|
|
91
102
|
onCloseAutoFocus(e: Event): void;
|
|
92
103
|
onFocusOutside(e: Event): void;
|
|
@@ -1,40 +1,53 @@
|
|
|
1
|
-
import { afterTick, box,
|
|
2
|
-
import {
|
|
3
|
-
import { Context } from "runed";
|
|
1
|
+
import { afterTick, box, useRefById } from "svelte-toolbelt";
|
|
2
|
+
import { Context, watch } from "runed";
|
|
4
3
|
import { useRovingFocus, } from "../../internal/use-roving-focus.svelte.js";
|
|
5
4
|
import { getAriaExpanded, getDataDisabled, getDataOpenClosed } from "../../internal/attrs.js";
|
|
6
5
|
import { kbd } from "../../internal/kbd.js";
|
|
7
6
|
import { wrapArray } from "../../internal/arrays.js";
|
|
7
|
+
import { FocusScopeContext, } from "../utilities/focus-scope/use-focus-scope.svelte.js";
|
|
8
|
+
import { onMount } from "svelte";
|
|
8
9
|
const MENUBAR_ROOT_ATTR = "data-menubar-root";
|
|
9
10
|
const MENUBAR_TRIGGER_ATTR = "data-menubar-trigger";
|
|
10
11
|
class MenubarRootState {
|
|
11
12
|
opts;
|
|
12
13
|
rovingFocusGroup;
|
|
13
|
-
currentTabStopId = box(null);
|
|
14
14
|
wasOpenedByKeyboard = $state(false);
|
|
15
15
|
triggerIds = $state([]);
|
|
16
16
|
valueToContentId = new Map();
|
|
17
17
|
constructor(opts) {
|
|
18
18
|
this.opts = opts;
|
|
19
|
-
this.onMenuClose = this.onMenuClose.bind(this);
|
|
20
|
-
this.onMenuOpen = this.onMenuOpen.bind(this);
|
|
21
|
-
this.onMenuToggle = this.onMenuToggle.bind(this);
|
|
22
|
-
this.registerTrigger = this.registerTrigger.bind(this);
|
|
23
|
-
this.deRegisterTrigger = this.deRegisterTrigger.bind(this);
|
|
24
19
|
useRefById(opts);
|
|
25
20
|
this.rovingFocusGroup = useRovingFocus({
|
|
26
21
|
rootNodeId: this.opts.id,
|
|
27
22
|
candidateAttr: MENUBAR_TRIGGER_ATTR,
|
|
28
23
|
loop: this.opts.loop,
|
|
29
24
|
orientation: box.with(() => "horizontal"),
|
|
30
|
-
currentTabStopId: this.currentTabStopId,
|
|
31
25
|
});
|
|
26
|
+
this.onMenuClose = this.onMenuClose.bind(this);
|
|
27
|
+
this.onMenuOpen = this.onMenuOpen.bind(this);
|
|
28
|
+
this.onMenuToggle = this.onMenuToggle.bind(this);
|
|
29
|
+
this.registerTrigger = this.registerTrigger.bind(this);
|
|
32
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* @param id - the id of the trigger to register
|
|
33
|
+
* @returns - a function to de-register the trigger
|
|
34
|
+
*/
|
|
33
35
|
registerTrigger(id) {
|
|
34
36
|
this.triggerIds.push(id);
|
|
37
|
+
return () => {
|
|
38
|
+
this.triggerIds = this.triggerIds.filter((triggerId) => triggerId !== id);
|
|
39
|
+
};
|
|
35
40
|
}
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
/**
|
|
42
|
+
* @param value - the value of the menu to register
|
|
43
|
+
* @param contentId - the content id to associate with the value
|
|
44
|
+
* @returns - a function to de-register the menu
|
|
45
|
+
*/
|
|
46
|
+
registerMenu(value, contentId) {
|
|
47
|
+
this.valueToContentId.set(value, contentId);
|
|
48
|
+
return () => {
|
|
49
|
+
this.valueToContentId.delete(value);
|
|
50
|
+
};
|
|
38
51
|
}
|
|
39
52
|
getTriggers() {
|
|
40
53
|
const node = this.opts.ref.current;
|
|
@@ -42,9 +55,9 @@ class MenubarRootState {
|
|
|
42
55
|
return [];
|
|
43
56
|
return Array.from(node.querySelectorAll(`[${MENUBAR_TRIGGER_ATTR}]`));
|
|
44
57
|
}
|
|
45
|
-
onMenuOpen(id) {
|
|
58
|
+
onMenuOpen(id, triggerId) {
|
|
46
59
|
this.opts.value.current = id;
|
|
47
|
-
this.
|
|
60
|
+
this.rovingFocusGroup.setCurrentTabStopId(triggerId);
|
|
48
61
|
}
|
|
49
62
|
onMenuClose() {
|
|
50
63
|
this.opts.value.current = "";
|
|
@@ -68,23 +81,21 @@ class MenubarMenuState {
|
|
|
68
81
|
constructor(opts, root) {
|
|
69
82
|
this.opts = opts;
|
|
70
83
|
this.root = root;
|
|
71
|
-
|
|
84
|
+
watch(() => this.open, () => {
|
|
72
85
|
if (!this.open) {
|
|
73
|
-
|
|
74
|
-
this.wasOpenedByKeyboard = false;
|
|
75
|
-
});
|
|
86
|
+
this.wasOpenedByKeyboard = false;
|
|
76
87
|
}
|
|
77
88
|
});
|
|
78
|
-
|
|
79
|
-
this.root.
|
|
80
|
-
});
|
|
81
|
-
onDestroyEffect(() => {
|
|
82
|
-
this.root.valueToContentId.delete(this.opts.value.current);
|
|
89
|
+
onMount(() => {
|
|
90
|
+
return this.root.registerMenu(this.opts.value.current, box.with(() => this.contentNode?.id ?? ""));
|
|
83
91
|
});
|
|
84
92
|
}
|
|
85
93
|
getTriggerNode() {
|
|
86
94
|
return this.triggerNode;
|
|
87
95
|
}
|
|
96
|
+
openMenu() {
|
|
97
|
+
this.root.onMenuOpen(this.opts.value.current, this.triggerNode?.id ?? "");
|
|
98
|
+
}
|
|
88
99
|
}
|
|
89
100
|
class MenubarTriggerState {
|
|
90
101
|
opts;
|
|
@@ -107,11 +118,8 @@ class MenubarTriggerState {
|
|
|
107
118
|
this.menu.triggerNode = node;
|
|
108
119
|
},
|
|
109
120
|
});
|
|
110
|
-
|
|
111
|
-
this.root.registerTrigger(opts.id.current);
|
|
112
|
-
});
|
|
113
|
-
onDestroyEffect(() => {
|
|
114
|
-
this.root.deRegisterTrigger(opts.id.current);
|
|
121
|
+
onMount(() => {
|
|
122
|
+
return this.root.registerTrigger(opts.id.current);
|
|
115
123
|
});
|
|
116
124
|
$effect(() => {
|
|
117
125
|
if (this.root.triggerIds.length) {
|
|
@@ -127,24 +135,26 @@ class MenubarTriggerState {
|
|
|
127
135
|
if (!this.menu.open) {
|
|
128
136
|
e.preventDefault();
|
|
129
137
|
}
|
|
130
|
-
this.
|
|
138
|
+
this.menu.openMenu();
|
|
131
139
|
}
|
|
132
140
|
}
|
|
133
141
|
onpointerenter(_) {
|
|
134
142
|
const isMenubarOpen = Boolean(this.root.opts.value.current);
|
|
135
143
|
if (isMenubarOpen && !this.menu.open) {
|
|
136
|
-
this.
|
|
144
|
+
this.menu.openMenu();
|
|
137
145
|
this.menu.getTriggerNode()?.focus();
|
|
138
146
|
}
|
|
139
147
|
}
|
|
140
148
|
onkeydown(e) {
|
|
141
149
|
if (this.opts.disabled.current)
|
|
142
150
|
return;
|
|
151
|
+
if (e.key === kbd.TAB)
|
|
152
|
+
return;
|
|
143
153
|
if (e.key === kbd.ENTER || e.key === kbd.SPACE) {
|
|
144
154
|
this.root.onMenuToggle(this.menu.opts.value.current);
|
|
145
155
|
}
|
|
146
156
|
if (e.key === kbd.ARROW_DOWN) {
|
|
147
|
-
this.
|
|
157
|
+
this.menu.openMenu();
|
|
148
158
|
}
|
|
149
159
|
// prevent keydown from scrolling window / first focused item
|
|
150
160
|
// from inadvertently closing the menu
|
|
@@ -172,7 +182,7 @@ class MenubarTriggerState {
|
|
|
172
182
|
"data-disabled": getDataDisabled(this.opts.disabled.current),
|
|
173
183
|
"data-menu-value": this.menu.opts.value.current,
|
|
174
184
|
disabled: this.opts.disabled.current ? true : undefined,
|
|
175
|
-
|
|
185
|
+
tabindex: this.#tabIndex,
|
|
176
186
|
[MENUBAR_TRIGGER_ATTR]: "",
|
|
177
187
|
onpointerdown: this.onpointerdown,
|
|
178
188
|
onpointerenter: this.onpointerenter,
|
|
@@ -186,10 +196,12 @@ class MenubarContentState {
|
|
|
186
196
|
menu;
|
|
187
197
|
root;
|
|
188
198
|
hasInteractedOutside = $state(false);
|
|
199
|
+
focusScopeContext;
|
|
189
200
|
constructor(opts, menu) {
|
|
190
201
|
this.opts = opts;
|
|
191
202
|
this.menu = menu;
|
|
192
203
|
this.root = menu.root;
|
|
204
|
+
this.focusScopeContext = FocusScopeContext.get();
|
|
193
205
|
this.onCloseAutoFocus = this.onCloseAutoFocus.bind(this);
|
|
194
206
|
this.onFocusOutside = this.onFocusOutside.bind(this);
|
|
195
207
|
this.onInteractOutside = this.onInteractOutside.bind(this);
|
|
@@ -204,8 +216,9 @@ class MenubarContentState {
|
|
|
204
216
|
});
|
|
205
217
|
}
|
|
206
218
|
onCloseAutoFocus(e) {
|
|
207
|
-
|
|
208
|
-
|
|
219
|
+
if (!this.root.opts.value.current &&
|
|
220
|
+
!this.hasInteractedOutside &&
|
|
221
|
+
!this.focusScopeContext.ignoreCloseAutoFocus) {
|
|
209
222
|
this.menu.getTriggerNode()?.focus();
|
|
210
223
|
}
|
|
211
224
|
this.hasInteractedOutside = false;
|
|
@@ -241,16 +254,20 @@ class MenubarContentState {
|
|
|
241
254
|
if (isKeydownInsideSubMenu && isPrevKey)
|
|
242
255
|
return;
|
|
243
256
|
const items = this.root.getTriggers().filter((trigger) => !trigger.disabled);
|
|
244
|
-
let
|
|
257
|
+
let candidates = items.map((item) => ({
|
|
258
|
+
value: item.getAttribute("data-menu-value"),
|
|
259
|
+
triggerId: item.id ?? "",
|
|
260
|
+
}));
|
|
245
261
|
if (isPrevKey)
|
|
246
|
-
|
|
262
|
+
candidates.reverse();
|
|
263
|
+
const candidateValues = candidates.map(({ value }) => value);
|
|
247
264
|
const currentIndex = candidateValues.indexOf(this.menu.opts.value.current);
|
|
248
|
-
|
|
249
|
-
? wrapArray(
|
|
250
|
-
:
|
|
251
|
-
const [nextValue] =
|
|
265
|
+
candidates = this.root.opts.loop.current
|
|
266
|
+
? wrapArray(candidates, currentIndex + 1)
|
|
267
|
+
: candidates.slice(currentIndex + 1);
|
|
268
|
+
const [nextValue] = candidates;
|
|
252
269
|
if (nextValue)
|
|
253
|
-
this.root.onMenuOpen(nextValue);
|
|
270
|
+
this.menu.root.onMenuOpen(nextValue.value, nextValue.triggerId);
|
|
254
271
|
}
|
|
255
272
|
props = $derived.by(() => ({
|
|
256
273
|
id: this.opts.id.current,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { Context } from "runed";
|
|
1
2
|
import type { ReadableBoxedValues } from "../../../internal/box.svelte.js";
|
|
2
3
|
import { type EventCallback } from "../../../internal/events.js";
|
|
4
|
+
export type FocusScopeContextValue = {
|
|
5
|
+
ignoreCloseAutoFocus: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare const FocusScopeContext: Context<FocusScopeContextValue>;
|
|
3
8
|
type UseFocusScopeProps = ReadableBoxedValues<{
|
|
4
9
|
/**
|
|
5
10
|
* ID of the focus scope container node.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterSleep, afterTick, box, executeCallbacks, useRefById } from "svelte-toolbelt";
|
|
2
|
-
import { watch } from "runed";
|
|
2
|
+
import { Context, watch } from "runed";
|
|
3
3
|
import { on } from "svelte/events";
|
|
4
4
|
import { createFocusScopeAPI, createFocusScopeStack, removeLinks, } from "./focus-scope-stack.svelte.js";
|
|
5
5
|
import { focus, focusFirst, getTabbableCandidates, getTabbableEdges } from "../../../internal/focus.js";
|
|
@@ -14,10 +14,12 @@ const AutoFocusOnDestroyEvent = new CustomEventDispatcher("focusScope.autoFocusO
|
|
|
14
14
|
bubbles: false,
|
|
15
15
|
cancelable: true,
|
|
16
16
|
});
|
|
17
|
+
export const FocusScopeContext = new Context("FocusScope");
|
|
17
18
|
export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoFocus, forceMount, }) {
|
|
18
19
|
const focusScopeStack = createFocusScopeStack();
|
|
19
20
|
const focusScope = createFocusScopeAPI();
|
|
20
21
|
const ref = box(null);
|
|
22
|
+
const ctx = FocusScopeContext.getOr({ ignoreCloseAutoFocus: false });
|
|
21
23
|
useRefById({
|
|
22
24
|
id,
|
|
23
25
|
ref,
|
|
@@ -37,12 +39,15 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
|
|
|
37
39
|
lastFocusedElement = target;
|
|
38
40
|
}
|
|
39
41
|
else {
|
|
42
|
+
if (ctx.ignoreCloseAutoFocus)
|
|
43
|
+
return;
|
|
40
44
|
focus(lastFocusedElement, { select: true });
|
|
41
45
|
}
|
|
42
46
|
};
|
|
43
47
|
const handleFocusOut = (event) => {
|
|
44
|
-
if (focusScope.paused || !container)
|
|
48
|
+
if (focusScope.paused || !container || ctx.ignoreCloseAutoFocus) {
|
|
45
49
|
return;
|
|
50
|
+
}
|
|
46
51
|
const relatedTarget = event.relatedTarget;
|
|
47
52
|
if (!isHTMLElement(relatedTarget))
|
|
48
53
|
return;
|
|
@@ -61,8 +66,9 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
|
|
|
61
66
|
return;
|
|
62
67
|
// If the focus has moved to an actual legitimate element (`relatedTarget !== null`)
|
|
63
68
|
// that is outside the container, we move focus to the last valid focused element inside.
|
|
64
|
-
if (!container.contains(relatedTarget))
|
|
69
|
+
if (!container.contains(relatedTarget)) {
|
|
65
70
|
focus(lastFocusedElement, { select: true });
|
|
71
|
+
}
|
|
66
72
|
};
|
|
67
73
|
// When the focused element gets removed from the DOM, browsers move focus
|
|
68
74
|
// back to the document.body. In this case, we move focus to the container
|
|
@@ -88,25 +94,25 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
|
|
|
88
94
|
if (forceMount)
|
|
89
95
|
return;
|
|
90
96
|
const prevFocusedElement = document.activeElement;
|
|
91
|
-
|
|
97
|
+
handleOpen(container, prevFocusedElement);
|
|
92
98
|
return () => {
|
|
93
99
|
if (!container)
|
|
94
100
|
return;
|
|
95
|
-
|
|
101
|
+
handleClose(prevFocusedElement);
|
|
96
102
|
};
|
|
97
103
|
});
|
|
98
104
|
watch([() => forceMount.current, () => ref.current, () => enabled.current], ([forceMount, container]) => {
|
|
99
105
|
if (!forceMount)
|
|
100
106
|
return;
|
|
101
107
|
const prevFocusedElement = document.activeElement;
|
|
102
|
-
|
|
108
|
+
handleOpen(container, prevFocusedElement);
|
|
103
109
|
return () => {
|
|
104
110
|
if (!container)
|
|
105
111
|
return;
|
|
106
|
-
|
|
112
|
+
handleClose(prevFocusedElement);
|
|
107
113
|
};
|
|
108
114
|
});
|
|
109
|
-
function
|
|
115
|
+
function handleOpen(container, prevFocusedElement) {
|
|
110
116
|
if (!container)
|
|
111
117
|
container = document.getElementById(id.current);
|
|
112
118
|
if (!container)
|
|
@@ -128,11 +134,12 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
|
|
|
128
134
|
}
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
|
-
function
|
|
137
|
+
function handleClose(prevFocusedElement) {
|
|
132
138
|
const destroyEvent = AutoFocusOnDestroyEvent.createEvent();
|
|
133
139
|
onCloseAutoFocus.current(destroyEvent);
|
|
140
|
+
const shouldIgnore = ctx.ignoreCloseAutoFocus;
|
|
134
141
|
afterSleep(0, () => {
|
|
135
|
-
if (!destroyEvent.defaultPrevented && prevFocusedElement) {
|
|
142
|
+
if (!destroyEvent.defaultPrevented && prevFocusedElement && !shouldIgnore) {
|
|
136
143
|
focus(prevFocusedElement ?? document.body, { select: true });
|
|
137
144
|
}
|
|
138
145
|
focusScopeStack.remove(focusScope);
|
package/dist/internal/dom.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare function getDocument(element?: Element | null): Document;
|
|
2
|
+
export declare function activeElement(doc: Document): Element | null;
|
|
1
3
|
export declare function getFirstNonCommentChild(element: HTMLElement | null): ChildNode | null;
|
|
2
4
|
/**
|
|
3
5
|
* Determines if the click event truly occurred outside the content node.
|
package/dist/internal/dom.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
export function getDocument(element) {
|
|
2
|
+
return element?.ownerDocument ?? document;
|
|
3
|
+
}
|
|
4
|
+
export function activeElement(doc) {
|
|
5
|
+
let activeElement = doc.activeElement;
|
|
6
|
+
while (activeElement?.shadowRoot?.activeElement != null) {
|
|
7
|
+
activeElement = activeElement.shadowRoot.activeElement;
|
|
8
|
+
}
|
|
9
|
+
return activeElement;
|
|
10
|
+
}
|
|
1
11
|
export function getFirstNonCommentChild(element) {
|
|
2
12
|
if (!element)
|
|
3
13
|
return null;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets all tabbable elements in the body and finds the next/previous tabbable element
|
|
3
|
+
* from the `currentNode` based on the `direction` provided.
|
|
4
|
+
* @param currentNode - the node we want to get the next/previous tabbable from
|
|
5
|
+
*/
|
|
6
|
+
export declare function getTabbableFrom(currentNode: HTMLElement, direction: "next" | "prev"): import("tabbable").FocusableElement | undefined;
|
|
7
|
+
export declare function getTabbableFromFocusable(currentNode: HTMLElement, direction: "next" | "prev"): import("tabbable").FocusableElement;
|
|
8
|
+
export declare function getNextTabbable(): import("tabbable").FocusableElement | undefined;
|
|
9
|
+
export declare function getPreviousTabbable(): import("tabbable").FocusableElement | undefined;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { focusable, isFocusable, isTabbable, tabbable } from "tabbable";
|
|
2
|
+
import { activeElement, getDocument } from "./dom.js";
|
|
3
|
+
function getTabbableOptions() {
|
|
4
|
+
return {
|
|
5
|
+
getShadowRoot: true,
|
|
6
|
+
displayCheck:
|
|
7
|
+
// JSDOM does not support the `tabbable` library. To solve this we can
|
|
8
|
+
// check if `ResizeObserver` is a real function (not polyfilled), which
|
|
9
|
+
// determines if the current environment is JSDOM-like.
|
|
10
|
+
typeof ResizeObserver === "function" &&
|
|
11
|
+
ResizeObserver.toString().includes("[native code]")
|
|
12
|
+
? "full"
|
|
13
|
+
: "none",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function getTabbableIn(container, direction) {
|
|
17
|
+
const allTabbable = tabbable(container, getTabbableOptions());
|
|
18
|
+
if (direction === "prev") {
|
|
19
|
+
allTabbable.reverse();
|
|
20
|
+
}
|
|
21
|
+
const activeEl = activeElement(getDocument(container));
|
|
22
|
+
const activeIndex = allTabbable.indexOf(activeEl);
|
|
23
|
+
const nextTabbableElements = allTabbable.slice(activeIndex + 1);
|
|
24
|
+
return nextTabbableElements[0];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Gets all tabbable elements in the body and finds the next/previous tabbable element
|
|
28
|
+
* from the `currentNode` based on the `direction` provided.
|
|
29
|
+
* @param currentNode - the node we want to get the next/previous tabbable from
|
|
30
|
+
*/
|
|
31
|
+
export function getTabbableFrom(currentNode, direction) {
|
|
32
|
+
if (!isTabbable(currentNode, getTabbableOptions())) {
|
|
33
|
+
return getTabbableFromFocusable(currentNode, direction);
|
|
34
|
+
}
|
|
35
|
+
const allTabbable = tabbable(getDocument(currentNode).body, getTabbableOptions());
|
|
36
|
+
if (direction === "prev")
|
|
37
|
+
allTabbable.reverse();
|
|
38
|
+
const activeIndex = allTabbable.indexOf(currentNode);
|
|
39
|
+
if (activeIndex === -1)
|
|
40
|
+
return document.body;
|
|
41
|
+
const nextTabbableElements = allTabbable.slice(activeIndex + 1);
|
|
42
|
+
return nextTabbableElements[0];
|
|
43
|
+
}
|
|
44
|
+
export function getTabbableFromFocusable(currentNode, direction) {
|
|
45
|
+
if (!isFocusable(currentNode, getTabbableOptions()))
|
|
46
|
+
return document.body;
|
|
47
|
+
// find all focusable nodes, since some elements may be focusable but not tabbable
|
|
48
|
+
// such as context menu triggers
|
|
49
|
+
const allFocusable = focusable(getDocument(currentNode).body, getTabbableOptions());
|
|
50
|
+
// find index of current node among focusable siblings
|
|
51
|
+
if (direction === "prev")
|
|
52
|
+
allFocusable.reverse();
|
|
53
|
+
const activeIndex = allFocusable.indexOf(currentNode);
|
|
54
|
+
if (activeIndex === -1)
|
|
55
|
+
return document.body;
|
|
56
|
+
const nextFocusableElements = allFocusable.slice(activeIndex + 1);
|
|
57
|
+
// find the next focusable node that is also tabbable
|
|
58
|
+
return (nextFocusableElements.find((node) => isTabbable(node, getTabbableOptions())) ??
|
|
59
|
+
document.body);
|
|
60
|
+
}
|
|
61
|
+
export function getNextTabbable() {
|
|
62
|
+
return getTabbableIn(document.body, "next");
|
|
63
|
+
}
|
|
64
|
+
export function getPreviousTabbable() {
|
|
65
|
+
return getTabbableIn(document.body, "prev");
|
|
66
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReadableBox
|
|
1
|
+
import { type ReadableBox } from "svelte-toolbelt";
|
|
2
2
|
import type { Orientation } from "../shared/index.js";
|
|
3
3
|
type UseRovingFocusProps = {
|
|
4
4
|
/**
|
|
@@ -26,10 +26,6 @@ type UseRovingFocusProps = {
|
|
|
26
26
|
* A callback function called when a candidate is focused.
|
|
27
27
|
*/
|
|
28
28
|
onCandidateFocus?: (node: HTMLElement) => void;
|
|
29
|
-
/**
|
|
30
|
-
* The current tab stop id.
|
|
31
|
-
*/
|
|
32
|
-
currentTabStopId?: WritableBox<string | null>;
|
|
33
29
|
};
|
|
34
30
|
export type UseRovingFocusReturn = ReturnType<typeof useRovingFocus>;
|
|
35
31
|
export declare function useRovingFocus(props: UseRovingFocusProps): {
|
|
@@ -37,6 +33,6 @@ export declare function useRovingFocus(props: UseRovingFocusProps): {
|
|
|
37
33
|
getTabIndex: (node: HTMLElement | null | undefined) => 0 | -1;
|
|
38
34
|
handleKeydown: (node: HTMLElement | null | undefined, e: KeyboardEvent, both?: boolean) => HTMLElement | undefined;
|
|
39
35
|
focusFirstCandidate: () => void;
|
|
40
|
-
currentTabStopId: WritableBox<string | null>;
|
|
36
|
+
currentTabStopId: import("svelte-toolbelt").WritableBox<string | null>;
|
|
41
37
|
};
|
|
42
38
|
export {};
|
|
@@ -4,9 +4,7 @@ import { getDirectionalKeys } from "./get-directional-keys.js";
|
|
|
4
4
|
import { kbd } from "./kbd.js";
|
|
5
5
|
import { isBrowser } from "./is.js";
|
|
6
6
|
export function useRovingFocus(props) {
|
|
7
|
-
const currentTabStopId =
|
|
8
|
-
? props.currentTabStopId
|
|
9
|
-
: box(null);
|
|
7
|
+
const currentTabStopId = box(null);
|
|
10
8
|
function getCandidateNodes() {
|
|
11
9
|
if (!isBrowser)
|
|
12
10
|
return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bits-ui",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.88",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": "github:huntabyte/bits-ui",
|
|
6
6
|
"funding": "https://github.com/sponsors/huntabyte",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"@internationalized/date": "^3.5.6",
|
|
44
44
|
"esm-env": "^1.1.2",
|
|
45
45
|
"runed": "^0.23.2",
|
|
46
|
-
"svelte-toolbelt": "^0.7.1"
|
|
46
|
+
"svelte-toolbelt": "^0.7.1",
|
|
47
|
+
"tabbable": "^6.2.0"
|
|
47
48
|
},
|
|
48
49
|
"peerDependencies": {
|
|
49
50
|
"svelte": "^5.11.0"
|