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.
@@ -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?: MenuMenuState | undefined;
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?: MenuMenuState | undefined);
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
- return MenuRootContext.set(new MenuRootState(props));
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
- registerTrigger(id: string): void;
22
- deRegisterTrigger(id: string): void;
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 tabIndex: number;
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, onDestroyEffect, onMountEffect, useRefById, } from "svelte-toolbelt";
2
- import { untrack } from "svelte";
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
- deRegisterTrigger(id) {
37
- this.triggerIds = this.triggerIds.filter((triggerId) => triggerId !== id);
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.currentTabStopId.current = id;
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
- $effect(() => {
84
+ watch(() => this.open, () => {
72
85
  if (!this.open) {
73
- untrack(() => {
74
- this.wasOpenedByKeyboard = false;
75
- });
86
+ this.wasOpenedByKeyboard = false;
76
87
  }
77
88
  });
78
- onMountEffect(() => {
79
- this.root.valueToContentId.set(this.opts.value.current, box.with(() => this.contentNode?.id ?? ""));
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
- onMountEffect(() => {
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.root.onMenuOpen(this.menu.opts.value.current);
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.root.onMenuOpen(this.menu.opts.value.current);
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.root.onMenuOpen(this.menu.opts.value.current);
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
- tabIndex: this.#tabIndex,
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
- const menubarOpen = Boolean(this.root.opts.value.current);
208
- if (!menubarOpen && !this.hasInteractedOutside) {
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 candidateValues = items.map((item) => item.getAttribute("data-menu-value"));
257
+ let candidates = items.map((item) => ({
258
+ value: item.getAttribute("data-menu-value"),
259
+ triggerId: item.id ?? "",
260
+ }));
245
261
  if (isPrevKey)
246
- candidateValues.reverse();
262
+ candidates.reverse();
263
+ const candidateValues = candidates.map(({ value }) => value);
247
264
  const currentIndex = candidateValues.indexOf(this.menu.opts.value.current);
248
- candidateValues = this.root.opts.loop.current
249
- ? wrapArray(candidateValues, currentIndex + 1)
250
- : candidateValues.slice(currentIndex + 1);
251
- const [nextValue] = candidateValues;
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
- handleMount(container, prevFocusedElement);
97
+ handleOpen(container, prevFocusedElement);
92
98
  return () => {
93
99
  if (!container)
94
100
  return;
95
- handleDestroy(prevFocusedElement);
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
- handleMount(container, prevFocusedElement);
108
+ handleOpen(container, prevFocusedElement);
103
109
  return () => {
104
110
  if (!container)
105
111
  return;
106
- handleDestroy(prevFocusedElement);
112
+ handleClose(prevFocusedElement);
107
113
  };
108
114
  });
109
- function handleMount(container, prevFocusedElement) {
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 handleDestroy(prevFocusedElement) {
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);
@@ -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.
@@ -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, type WritableBox } from "svelte-toolbelt";
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 = props.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.87",
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"