bits-ui 2.8.0 → 2.8.1

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.
Files changed (28) hide show
  1. package/dist/bits/alert-dialog/components/alert-dialog-content.svelte +6 -9
  2. package/dist/bits/calendar/components/calendar-next-button.svelte +3 -1
  3. package/dist/bits/calendar/components/calendar-prev-button.svelte +3 -1
  4. package/dist/bits/context-menu/components/context-menu-content.svelte +1 -1
  5. package/dist/bits/dialog/components/dialog-content.svelte +5 -7
  6. package/dist/bits/dropdown-menu/components/dropdown-menu-content.svelte +3 -2
  7. package/dist/bits/menu/menu.svelte.js +4 -14
  8. package/dist/bits/menubar/menubar.svelte.d.ts +0 -2
  9. package/dist/bits/menubar/menubar.svelte.js +0 -3
  10. package/dist/bits/navigation-menu/components/navigation-menu-link.svelte +2 -1
  11. package/dist/bits/navigation-menu/components/navigation-menu-trigger.svelte +2 -1
  12. package/dist/bits/utilities/focus-scope/focus-scope-manager.d.ts +12 -0
  13. package/dist/bits/utilities/focus-scope/focus-scope-manager.js +40 -0
  14. package/dist/bits/utilities/focus-scope/focus-scope.svelte +6 -8
  15. package/dist/bits/utilities/focus-scope/focus-scope.svelte.d.ts +1 -0
  16. package/dist/bits/utilities/focus-scope/focus-scope.svelte.js +204 -0
  17. package/dist/bits/utilities/focus-scope/types.d.ts +2 -6
  18. package/dist/bits/utilities/popper-layer/popper-layer-inner.svelte +2 -2
  19. package/dist/internal/focus.js +1 -1
  20. package/dist/internal/should-enable-focus-trap.d.ts +5 -0
  21. package/dist/internal/should-enable-focus-trap.js +5 -0
  22. package/package.json +2 -2
  23. package/dist/bits/utilities/focus-scope/focus-scope-stack.svelte.d.ts +0 -14
  24. package/dist/bits/utilities/focus-scope/focus-scope-stack.svelte.js +0 -50
  25. package/dist/bits/utilities/focus-scope/use-focus-scope.svelte.d.ts +0 -49
  26. package/dist/bits/utilities/focus-scope/use-focus-scope.svelte.js +0 -218
  27. package/dist/internal/should-trap-focus.d.ts +0 -6
  28. package/dist/internal/should-trap-focus.js +0 -5
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { afterTick, box, mergeProps } from "svelte-toolbelt";
2
+ import { afterSleep, box, mergeProps } from "svelte-toolbelt";
3
3
  import type { AlertDialogContentProps } from "../types.js";
4
4
  import DismissibleLayer from "../../utilities/dismissible-layer/dismissible-layer.svelte";
5
5
  import EscapeLayer from "../../utilities/escape-layer/escape-layer.svelte";
@@ -10,7 +10,7 @@
10
10
  import { noop } from "../../../internal/noop.js";
11
11
  import ScrollLock from "../../utilities/scroll-lock/scroll-lock.svelte";
12
12
  import { DialogContentState } from "../../dialog/dialog.svelte.js";
13
- import { shouldTrapFocus } from "../../../internal/should-trap-focus.js";
13
+ import { shouldEnableFocusTrap } from "../../../internal/should-enable-focus-trap.js";
14
14
 
15
15
  const uid = $props.id();
16
16
 
@@ -51,25 +51,22 @@
51
51
  <FocusScope
52
52
  ref={contentState.opts.ref}
53
53
  loop
54
- trapFocus={shouldTrapFocus({
54
+ {trapFocus}
55
+ enabled={shouldEnableFocusTrap({
55
56
  forceMount,
56
57
  present: contentState.root.opts.open.current,
57
- trapFocus,
58
58
  open: contentState.root.opts.open.current,
59
59
  })}
60
- {id}
61
60
  onCloseAutoFocus={(e) => {
62
61
  onCloseAutoFocus(e);
63
62
  if (e.defaultPrevented) return;
64
- contentState.root.triggerNode?.focus();
63
+ afterSleep(0, () => contentState.root.triggerNode?.focus());
65
64
  }}
66
65
  onOpenAutoFocus={(e) => {
67
66
  onOpenAutoFocus(e);
68
67
  if (e.defaultPrevented) return;
69
68
  e.preventDefault();
70
- afterTick(() => {
71
- contentState.opts.ref.current?.focus();
72
- });
69
+ afterSleep(0, () => contentState.opts.ref.current?.focus());
73
70
  }}
74
71
  >
75
72
  {#snippet focusScope({ props: focusScopeProps })}
@@ -11,6 +11,8 @@
11
11
  child,
12
12
  id = createId(uid),
13
13
  ref = $bindable(null),
14
+ // for safari
15
+ tabindex = 0,
14
16
  ...restProps
15
17
  }: CalendarNextButtonProps = $props();
16
18
 
@@ -22,7 +24,7 @@
22
24
  ),
23
25
  });
24
26
 
25
- const mergedProps = $derived(mergeProps(restProps, nextButtonState.props));
27
+ const mergedProps = $derived(mergeProps(restProps, nextButtonState.props, { tabindex }));
26
28
  </script>
27
29
 
28
30
  {#if child}
@@ -11,6 +11,8 @@
11
11
  child,
12
12
  id = createId(uid),
13
13
  ref = $bindable(null),
14
+ // for safari
15
+ tabindex = 0,
14
16
  ...restProps
15
17
  }: CalendarPrevButtonProps = $props();
16
18
 
@@ -22,7 +24,7 @@
22
24
  ),
23
25
  });
24
26
 
25
- const mergedProps = $derived(mergeProps(restProps, prevButtonState.props));
27
+ const mergedProps = $derived(mergeProps(restProps, prevButtonState.props, { tabindex }));
26
28
  </script>
27
29
 
28
30
  {#if child}
@@ -22,7 +22,7 @@
22
22
  // the default menu behavior of handling outside interactions on the trigger
23
23
  onEscapeKeydown = noop,
24
24
  forceMount = false,
25
- trapFocus = true,
25
+ trapFocus = false,
26
26
  ...restProps
27
27
  }: ContextMenuContentProps = $props();
28
28
 
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { box, mergeProps } from "svelte-toolbelt";
2
+ import { afterSleep, box, mergeProps } from "svelte-toolbelt";
3
3
  import { DialogContentState } from "../dialog.svelte.js";
4
4
  import type { DialogContentProps } from "../types.js";
5
5
  import DismissibleLayer from "../../utilities/dismissible-layer/dismissible-layer.svelte";
@@ -10,8 +10,7 @@
10
10
  import { createId } from "../../../internal/create-id.js";
11
11
  import { noop } from "../../../internal/noop.js";
12
12
  import ScrollLock from "../../utilities/scroll-lock/scroll-lock.svelte";
13
- import { shouldTrapFocus } from "../../../internal/should-trap-focus.js";
14
-
13
+ import { shouldEnableFocusTrap } from "../../../internal/should-enable-focus-trap.js";
15
14
  const uid = $props.id();
16
15
 
17
16
  let {
@@ -51,18 +50,17 @@
51
50
  <FocusScope
52
51
  ref={contentState.opts.ref}
53
52
  loop
54
- trapFocus={shouldTrapFocus({
53
+ {trapFocus}
54
+ enabled={shouldEnableFocusTrap({
55
55
  forceMount,
56
56
  present: contentState.root.opts.open.current,
57
- trapFocus,
58
57
  open: contentState.root.opts.open.current,
59
58
  })}
60
59
  {onOpenAutoFocus}
61
- {id}
62
60
  onCloseAutoFocus={(e) => {
63
61
  onCloseAutoFocus(e);
64
62
  if (e.defaultPrevented) return;
65
- contentState.root.triggerNode?.focus();
63
+ afterSleep(1, () => contentState.root.triggerNode?.focus());
66
64
  }}
67
65
  >
68
66
  {#snippet focusScope({ props: focusScopeProps })}
@@ -20,6 +20,7 @@
20
20
  onEscapeKeydown = noop,
21
21
  onCloseAutoFocus = noop,
22
22
  forceMount = false,
23
+ trapFocus = false,
23
24
  ...restProps
24
25
  }: DropdownMenuContentProps = $props();
25
26
 
@@ -57,7 +58,7 @@
57
58
  enabled={contentState.parentMenu.opts.open.current}
58
59
  onInteractOutside={handleInteractOutside}
59
60
  onEscapeKeydown={handleEscapeKeydown}
60
- trapFocus
61
+ {trapFocus}
61
62
  {loop}
62
63
  forceMount={true}
63
64
  {id}
@@ -85,7 +86,7 @@
85
86
  open={contentState.parentMenu.opts.open.current}
86
87
  onInteractOutside={handleInteractOutside}
87
88
  onEscapeKeydown={handleEscapeKeydown}
88
- trapFocus
89
+ {trapFocus}
89
90
  {loop}
90
91
  forceMount={false}
91
92
  {id}
@@ -8,9 +8,7 @@ import { kbd } from "../../internal/kbd.js";
8
8
  import { createBitsAttrs, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaOrientation, getDataDisabled, getDataOpenClosed, } from "../../internal/attrs.js";
9
9
  import { IsUsingKeyboard } from "../../index.js";
10
10
  import { getTabbableFrom } from "../../internal/tabbable.js";
11
- import { FocusScopeContext } from "../utilities/focus-scope/use-focus-scope.svelte.js";
12
11
  import { isTabbable } from "tabbable";
13
- import { untrack } from "svelte";
14
12
  import { DOMTypeahead } from "../../internal/dom-typeahead.svelte.js";
15
13
  import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
16
14
  import { GraceArea } from "../../internal/grace-area.svelte.js";
@@ -47,11 +45,6 @@ export const menuAttrs = createBitsAttrs({
47
45
  export class MenuRootState {
48
46
  static create(opts) {
49
47
  const root = new MenuRootState(opts);
50
- FocusScopeContext.set({
51
- get ignoreCloseAutoFocus() {
52
- return root.ignoreCloseAutoFocus;
53
- },
54
- });
55
48
  return MenuRootContext.set(root);
56
49
  }
57
50
  opts;
@@ -196,9 +189,8 @@ export class MenuContentState {
196
189
  rootMenu = rootMenu.parentMenu;
197
190
  }
198
191
  // if for some unforeseen reason the root menu has no trigger, we bail
199
- if (!rootMenu.triggerNode) {
192
+ if (!rootMenu.triggerNode)
200
193
  return;
201
- }
202
194
  // cancel default tab behavior
203
195
  e.preventDefault();
204
196
  // find the next/previous tabbable
@@ -328,11 +320,9 @@ export class MenuContentState {
328
320
  pointerEvents: "auto",
329
321
  },
330
322
  ...attachRef(this.opts.ref, (v) => {
331
- untrack(() => {
332
- if (this.parentMenu.contentNode !== v) {
333
- this.parentMenu.contentNode = v;
334
- }
335
- });
323
+ if (this.parentMenu.contentNode !== v) {
324
+ this.parentMenu.contentNode = v;
325
+ }
336
326
  }),
337
327
  }));
338
328
  popperProps = {
@@ -2,7 +2,6 @@ import { type ReadableBox, type ReadableBoxedValues, type WritableBoxedValues }
2
2
  import type { InteractOutsideBehaviorType } from "../utilities/dismissible-layer/types.js";
3
3
  import type { Direction } from "../../shared/index.js";
4
4
  import type { OnChangeFn, WithRefOpts } from "../../internal/types.js";
5
- import { type FocusScopeContextValue } from "../utilities/focus-scope/use-focus-scope.svelte.js";
6
5
  import type { FocusEventHandler, KeyboardEventHandler, PointerEventHandler } from "svelte/elements";
7
6
  import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
8
7
  interface MenubarRootStateOpts extends WithRefOpts, ReadableBoxedValues<{
@@ -111,7 +110,6 @@ export declare class MenubarContentState {
111
110
  readonly opts: MenubarContentStateOpts;
112
111
  readonly menu: MenubarMenuState;
113
112
  readonly root: MenubarRootState;
114
- focusScopeContext: FocusScopeContextValue;
115
113
  constructor(opts: MenubarContentStateOpts, menu: MenubarMenuState);
116
114
  onCloseAutoFocus: (e: Event) => void;
117
115
  onFocusOutside: (e: FocusEvent) => void;
@@ -3,7 +3,6 @@ import { Context, watch } from "runed";
3
3
  import { createBitsAttrs, getAriaExpanded, getDataDisabled, getDataOpenClosed, } from "../../internal/attrs.js";
4
4
  import { kbd } from "../../internal/kbd.js";
5
5
  import { wrapArray } from "../../internal/arrays.js";
6
- import { FocusScopeContext, } from "../utilities/focus-scope/use-focus-scope.svelte.js";
7
6
  import { onMount } from "svelte";
8
7
  import { getFloatingContentCSSVars } from "../../internal/floating-svelte/floating-utils.svelte";
9
8
  import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
@@ -215,12 +214,10 @@ export class MenubarContentState {
215
214
  opts;
216
215
  menu;
217
216
  root;
218
- focusScopeContext;
219
217
  constructor(opts, menu) {
220
218
  this.opts = opts;
221
219
  this.menu = menu;
222
220
  this.root = menu.root;
223
- this.focusScopeContext = FocusScopeContext.get();
224
221
  }
225
222
  onCloseAutoFocus = (e) => {
226
223
  this.opts.onCloseAutoFocus.current(e);
@@ -14,6 +14,7 @@
14
14
  children,
15
15
  active = false,
16
16
  onSelect = noop,
17
+ tabindex = 0,
17
18
  ...restProps
18
19
  }: NavigationMenuLinkProps = $props();
19
20
 
@@ -27,7 +28,7 @@
27
28
  onSelect: box.with(() => onSelect),
28
29
  });
29
30
 
30
- const mergedProps = $derived(mergeProps(restProps, linkState.props));
31
+ const mergedProps = $derived(mergeProps(restProps, linkState.props, { tabindex }));
31
32
  </script>
32
33
 
33
34
  {#if child}
@@ -14,6 +14,7 @@
14
14
  children,
15
15
  child,
16
16
  ref = $bindable(null),
17
+ tabindex = 0,
17
18
  ...restProps
18
19
  }: NavigationMenuTriggerProps = $props();
19
20
 
@@ -26,7 +27,7 @@
26
27
  ),
27
28
  });
28
29
 
29
- const mergedProps = $derived(mergeProps(restProps, triggerState.props));
30
+ const mergedProps = $derived(mergeProps(restProps, triggerState.props, { tabindex }));
30
31
  </script>
31
32
 
32
33
  {#if child}
@@ -0,0 +1,12 @@
1
+ import { FocusScope } from "./focus-scope.svelte.js";
2
+ export declare class FocusScopeManager {
3
+ #private;
4
+ static instance: FocusScopeManager;
5
+ static getInstance(): FocusScopeManager;
6
+ register(scope: FocusScope): void;
7
+ unregister(scope: FocusScope): void;
8
+ getActive(): FocusScope | undefined;
9
+ setFocusMemory(scope: FocusScope, element: HTMLElement): void;
10
+ getFocusMemory(scope: FocusScope): HTMLElement | undefined;
11
+ isActiveScope(scope: FocusScope): boolean;
12
+ }
@@ -0,0 +1,40 @@
1
+ import { box } from "svelte-toolbelt";
2
+ import { FocusScope } from "./focus-scope.svelte.js";
3
+ export class FocusScopeManager {
4
+ static instance;
5
+ #scopeStack = box([]);
6
+ #focusHistory = new WeakMap();
7
+ static getInstance() {
8
+ if (!this.instance) {
9
+ this.instance = new FocusScopeManager();
10
+ }
11
+ return this.instance;
12
+ }
13
+ register(scope) {
14
+ const current = this.getActive();
15
+ if (current && current !== scope) {
16
+ current.pause();
17
+ }
18
+ this.#scopeStack.current = this.#scopeStack.current.filter((s) => s !== scope);
19
+ this.#scopeStack.current.unshift(scope);
20
+ }
21
+ unregister(scope) {
22
+ this.#scopeStack.current = this.#scopeStack.current.filter((s) => s !== scope);
23
+ const next = this.getActive();
24
+ if (next) {
25
+ next.resume();
26
+ }
27
+ }
28
+ getActive() {
29
+ return this.#scopeStack.current[0];
30
+ }
31
+ setFocusMemory(scope, element) {
32
+ this.#focusHistory.set(scope, element);
33
+ }
34
+ getFocusMemory(scope) {
35
+ return this.#focusHistory.get(scope);
36
+ }
37
+ isActiveScope(scope) {
38
+ return this.getActive() === scope;
39
+ }
40
+ }
@@ -1,27 +1,25 @@
1
1
  <script lang="ts">
2
2
  import { box } from "svelte-toolbelt";
3
3
  import type { FocusScopeImplProps } from "./types.js";
4
- import { useFocusScope } from "./use-focus-scope.svelte.js";
5
4
  import { noop } from "../../../internal/noop.js";
5
+ import { FocusScope } from "./focus-scope.svelte.js";
6
6
 
7
7
  let {
8
- id,
8
+ enabled = false,
9
9
  trapFocus = false,
10
10
  loop = false,
11
11
  onCloseAutoFocus = noop,
12
12
  onOpenAutoFocus = noop,
13
13
  focusScope,
14
- forceMount = false,
15
14
  ref,
16
15
  }: FocusScopeImplProps = $props();
17
16
 
18
- const focusScopeState = useFocusScope({
19
- enabled: box.with(() => trapFocus),
20
- loop: box.with(() => loop),
17
+ const focusScopeState = FocusScope.use({
18
+ enabled: box.with(() => enabled),
19
+ trap: box.with(() => trapFocus),
20
+ loop: loop,
21
21
  onCloseAutoFocus: box.with(() => onCloseAutoFocus),
22
22
  onOpenAutoFocus: box.with(() => onOpenAutoFocus),
23
- id: box.with(() => id),
24
- forceMount: box.with(() => forceMount),
25
23
  ref,
26
24
  });
27
25
  </script>
@@ -1,4 +1,5 @@
1
1
  import type { FocusScopeImplProps } from "./types.js";
2
+ import { FocusScope } from "./focus-scope.svelte.js";
2
3
  declare const FocusScope: import("svelte").Component<FocusScopeImplProps, {}, "">;
3
4
  type FocusScope = ReturnType<typeof FocusScope>;
4
5
  export default FocusScope;
@@ -0,0 +1,204 @@
1
+ import { onDestroyEffect } from "svelte-toolbelt";
2
+ import { FocusScopeManager } from "./focus-scope-manager.js";
3
+ import { focusable, isFocusable, tabbable } from "tabbable";
4
+ import { on } from "svelte/events";
5
+ import { watch } from "runed";
6
+ export class FocusScope {
7
+ #paused = false;
8
+ #container = null;
9
+ #manager = FocusScopeManager.getInstance();
10
+ #cleanupFns = [];
11
+ #opts;
12
+ constructor(opts) {
13
+ this.#opts = opts;
14
+ }
15
+ get paused() {
16
+ return this.#paused;
17
+ }
18
+ pause() {
19
+ this.#paused = true;
20
+ }
21
+ resume() {
22
+ this.#paused = false;
23
+ }
24
+ #cleanup() {
25
+ for (const fn of this.#cleanupFns) {
26
+ fn();
27
+ }
28
+ this.#cleanupFns = [];
29
+ }
30
+ mount(container) {
31
+ if (this.#container) {
32
+ this.unmount();
33
+ }
34
+ this.#container = container;
35
+ this.#manager.register(this);
36
+ this.#setupEventListeners();
37
+ this.#handleOpenAutoFocus();
38
+ }
39
+ unmount() {
40
+ if (!this.#container)
41
+ return;
42
+ this.#cleanup();
43
+ // handle close auto-focus
44
+ this.#handleCloseAutoFocus();
45
+ this.#manager.unregister(this);
46
+ this.#container = null;
47
+ }
48
+ #handleOpenAutoFocus() {
49
+ if (!this.#container)
50
+ return;
51
+ const event = new CustomEvent("focusScope.onOpenAutoFocus", {
52
+ bubbles: false,
53
+ cancelable: true,
54
+ });
55
+ this.#opts.onOpenAutoFocus.current(event);
56
+ if (!event.defaultPrevented) {
57
+ requestAnimationFrame(() => {
58
+ if (!this.#container)
59
+ return;
60
+ const firstTabbable = this.#getFirstTabbable();
61
+ if (firstTabbable) {
62
+ firstTabbable.focus();
63
+ this.#manager.setFocusMemory(this, firstTabbable);
64
+ }
65
+ else {
66
+ this.#container.focus();
67
+ }
68
+ });
69
+ }
70
+ }
71
+ #handleCloseAutoFocus() {
72
+ const event = new CustomEvent("focusScope.onCloseAutoFocus", {
73
+ bubbles: false,
74
+ cancelable: true,
75
+ });
76
+ this.#opts.onCloseAutoFocus.current(event);
77
+ if (!event.defaultPrevented) {
78
+ // return focus to previously focused element
79
+ const prevFocused = document.activeElement;
80
+ if (prevFocused && prevFocused !== document.body) {
81
+ prevFocused.focus();
82
+ }
83
+ }
84
+ }
85
+ #setupEventListeners() {
86
+ if (!this.#container || !this.#opts.trap.current)
87
+ return;
88
+ const container = this.#container;
89
+ const doc = container.ownerDocument;
90
+ const handleFocus = (e) => {
91
+ if (this.#paused || !this.#manager.isActiveScope(this))
92
+ return;
93
+ const target = e.target;
94
+ if (!target)
95
+ return;
96
+ const isInside = container.contains(target);
97
+ if (isInside) {
98
+ // store last focused element
99
+ this.#manager.setFocusMemory(this, target);
100
+ }
101
+ else {
102
+ // focus escaped - bring it back
103
+ const lastFocused = this.#manager.getFocusMemory(this);
104
+ if (lastFocused && container.contains(lastFocused) && isFocusable(lastFocused)) {
105
+ e.preventDefault();
106
+ lastFocused.focus();
107
+ }
108
+ else {
109
+ // fallback to first tabbable or first focusable or container
110
+ const firstTabbable = this.#getFirstTabbable();
111
+ const firstFocusable = this.#getAllFocusables()[0];
112
+ (firstTabbable || firstFocusable || container).focus();
113
+ }
114
+ }
115
+ };
116
+ const handleKeydown = (e) => {
117
+ if (!this.#opts.loop || this.#paused || e.key !== "Tab")
118
+ return;
119
+ if (!this.#manager.isActiveScope(this))
120
+ return;
121
+ const tabbables = this.#getTabbables();
122
+ if (tabbables.length < 2)
123
+ return;
124
+ const first = tabbables[0];
125
+ const last = tabbables[tabbables.length - 1];
126
+ if (!e.shiftKey && doc.activeElement === last) {
127
+ e.preventDefault();
128
+ first.focus();
129
+ }
130
+ else if (e.shiftKey && doc.activeElement === first) {
131
+ e.preventDefault();
132
+ last.focus();
133
+ }
134
+ };
135
+ this.#cleanupFns.push(on(doc, "focusin", handleFocus, { capture: true }), on(container, "keydown", handleKeydown));
136
+ const observer = new MutationObserver(() => {
137
+ const lastFocused = this.#manager.getFocusMemory(this);
138
+ if (lastFocused && !container.contains(lastFocused)) {
139
+ // last focused element was removed
140
+ const firstTabbable = this.#getFirstTabbable();
141
+ const firstFocusable = this.#getAllFocusables()[0];
142
+ const elementToFocus = firstTabbable || firstFocusable;
143
+ if (elementToFocus) {
144
+ elementToFocus.focus();
145
+ this.#manager.setFocusMemory(this, elementToFocus);
146
+ }
147
+ else {
148
+ // no focusable elements left, focus container
149
+ container.focus();
150
+ }
151
+ }
152
+ });
153
+ observer.observe(container, {
154
+ childList: true,
155
+ subtree: true,
156
+ });
157
+ this.#cleanupFns.push(() => observer.disconnect());
158
+ }
159
+ #getTabbables() {
160
+ if (!this.#container)
161
+ return [];
162
+ return tabbable(this.#container, {
163
+ includeContainer: false,
164
+ getShadowRoot: true,
165
+ });
166
+ }
167
+ #getFirstTabbable() {
168
+ const tabbables = this.#getTabbables();
169
+ return tabbables[0] || null;
170
+ }
171
+ #getAllFocusables() {
172
+ if (!this.#container)
173
+ return [];
174
+ return focusable(this.#container, {
175
+ includeContainer: false,
176
+ getShadowRoot: true,
177
+ });
178
+ }
179
+ static use(opts) {
180
+ let scope = null;
181
+ watch([() => opts.ref.current, () => opts.enabled.current], ([ref, enabled]) => {
182
+ if (ref && enabled) {
183
+ if (!scope) {
184
+ scope = new FocusScope(opts);
185
+ }
186
+ scope.mount(ref);
187
+ }
188
+ else if (scope) {
189
+ scope.unmount();
190
+ scope = null;
191
+ }
192
+ });
193
+ onDestroyEffect(() => {
194
+ scope?.unmount();
195
+ });
196
+ return {
197
+ get props() {
198
+ return {
199
+ tabindex: -1,
200
+ };
201
+ },
202
+ };
203
+ }
204
+ }
@@ -1,5 +1,4 @@
1
1
  import type { Snippet } from "svelte";
2
- import type { FocusScopeContainerProps } from "./use-focus-scope.svelte.js";
3
2
  import type { EventCallback } from "../../../internal/events.js";
4
3
  import type { ReadableBox } from "svelte-toolbelt";
5
4
  export type FocusScopeProps = {
@@ -21,15 +20,11 @@ export type FocusScopeProps = {
21
20
  trapFocus?: boolean;
22
21
  };
23
22
  export type FocusScopeImplProps = {
24
- /**
25
- * The ID of the focus scope container node.
26
- */
27
- id: string;
28
23
  /**
29
24
  * The snippet to render the focus scope container with its props.
30
25
  */
31
26
  focusScope?: Snippet<[{
32
- props: FocusScopeContainerProps;
27
+ props: Record<string, unknown>;
33
28
  }]>;
34
29
  /**
35
30
  * When `true` will loop through the tabbable elements in the focus scope.
@@ -39,5 +34,6 @@ export type FocusScopeImplProps = {
39
34
  * Whether the content within the focus trap is being force mounted or not.
40
35
  */
41
36
  forceMount?: boolean;
37
+ enabled: boolean;
42
38
  ref: ReadableBox<HTMLElement | null>;
43
39
  } & FocusScopeProps;
@@ -82,11 +82,11 @@
82
82
  <ScrollLock {preventScroll} />
83
83
  {/if}
84
84
  <FocusScope
85
- {id}
86
85
  {onOpenAutoFocus}
87
86
  {onCloseAutoFocus}
88
87
  {loop}
89
- trapFocus={enabled && trapFocus}
88
+ {enabled}
89
+ {trapFocus}
90
90
  forceMount={restProps.forceMount}
91
91
  {ref}
92
92
  >
@@ -34,7 +34,7 @@ export function focusWithoutScroll(element) {
34
34
  * A utility function that focuses an element.
35
35
  */
36
36
  export function focus(element, { select = false } = {}) {
37
- if (!(element && element.focus))
37
+ if (!element || !element.focus)
38
38
  return;
39
39
  const doc = getDocument(element);
40
40
  if (doc.activeElement === element)
@@ -0,0 +1,5 @@
1
+ export declare function shouldEnableFocusTrap({ forceMount, present, open, }: {
2
+ forceMount: boolean;
3
+ present: boolean;
4
+ open: boolean;
5
+ }): boolean;
@@ -0,0 +1,5 @@
1
+ export function shouldEnableFocusTrap({ forceMount, present, open, }) {
2
+ if (forceMount)
3
+ return open;
4
+ return present && open;
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "2.8.0",
3
+ "version": "2.8.1",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",
@@ -46,7 +46,7 @@
46
46
  "css.escape": "^1.5.1",
47
47
  "esm-env": "^1.1.2",
48
48
  "runed": "^0.28.0",
49
- "svelte-toolbelt": "^0.9.1",
49
+ "svelte-toolbelt": "^0.9.2",
50
50
  "tabbable": "^6.2.0"
51
51
  },
52
52
  "peerDependencies": {
@@ -1,14 +0,0 @@
1
- export interface FocusScopeAPI {
2
- id: string;
3
- paused: boolean;
4
- pause: () => void;
5
- resume: () => void;
6
- isHandlingFocus: boolean;
7
- }
8
- export declare function createFocusScopeStack(): {
9
- add(focusScope: FocusScopeAPI): void;
10
- remove(focusScope: FocusScopeAPI): void;
11
- readonly current: FocusScopeAPI[];
12
- };
13
- export declare function createFocusScopeAPI(): FocusScopeAPI;
14
- export declare function removeLinks(items: HTMLElement[]): HTMLElement[];
@@ -1,50 +0,0 @@
1
- import { box } from "svelte-toolbelt";
2
- import { useId } from "../../../internal/use-id.js";
3
- const focusStack = box([]);
4
- export function createFocusScopeStack() {
5
- return {
6
- add(focusScope) {
7
- const activeFocusScope = focusStack.current[0];
8
- if (activeFocusScope && focusScope.id !== activeFocusScope.id) {
9
- activeFocusScope.pause();
10
- }
11
- focusStack.current = removeFromFocusScopeArray(focusStack.current, focusScope);
12
- focusStack.current.unshift(focusScope);
13
- },
14
- remove(focusScope) {
15
- focusStack.current = removeFromFocusScopeArray(focusStack.current, focusScope);
16
- focusStack.current[0]?.resume();
17
- },
18
- get current() {
19
- return focusStack.current;
20
- },
21
- };
22
- }
23
- export function createFocusScopeAPI() {
24
- let paused = $state(false);
25
- let isHandlingFocus = $state(false);
26
- return {
27
- id: useId(),
28
- get paused() {
29
- return paused;
30
- },
31
- get isHandlingFocus() {
32
- return isHandlingFocus;
33
- },
34
- set isHandlingFocus(value) {
35
- isHandlingFocus = value;
36
- },
37
- pause() {
38
- paused = true;
39
- },
40
- resume() {
41
- paused = false;
42
- },
43
- };
44
- }
45
- function removeFromFocusScopeArray(arr, item) {
46
- return [...arr].filter((i) => i.id !== item.id);
47
- }
48
- export function removeLinks(items) {
49
- return items.filter((item) => item.tagName !== "A");
50
- }
@@ -1,49 +0,0 @@
1
- import { type ReadableBoxedValues } from "svelte-toolbelt";
2
- import { Context } from "runed";
3
- import { type EventCallback } from "../../../internal/events.js";
4
- export type FocusScopeContextValue = {
5
- ignoreCloseAutoFocus: boolean;
6
- };
7
- export declare const FocusScopeContext: Context<FocusScopeContextValue>;
8
- type UseFocusScopeProps = ReadableBoxedValues<{
9
- /**
10
- * ID of the focus scope container node.
11
- */
12
- id: string;
13
- /**
14
- * When `true` will loop through the tabbable elements in the focus scope.
15
- *
16
- * @defaultValue false
17
- */
18
- loop: boolean;
19
- /**
20
- * Whether focus is trapped within the focus scope.
21
- *
22
- * @defaultValue false
23
- */
24
- enabled: boolean;
25
- /**
26
- * Event handler called when auto-focusing onMount.
27
- * Can be prevented.
28
- */
29
- onOpenAutoFocus: EventCallback;
30
- /**
31
- * Event handler called when auto-focusing onDestroy.
32
- * Can be prevented.
33
- */
34
- onCloseAutoFocus: EventCallback;
35
- /**
36
- * Whether force mount is enabled or not
37
- */
38
- forceMount: boolean;
39
- ref: HTMLElement | null;
40
- }>;
41
- export type FocusScopeContainerProps = {
42
- id: string;
43
- tabindex: number;
44
- onkeydown: EventCallback<KeyboardEvent>;
45
- };
46
- export declare function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoFocus, forceMount, ref, }: UseFocusScopeProps): {
47
- readonly props: FocusScopeContainerProps;
48
- };
49
- export {};
@@ -1,218 +0,0 @@
1
- import { afterSleep, afterTick, DOMContext, executeCallbacks, } from "svelte-toolbelt";
2
- import { Context, watch } from "runed";
3
- import { on } from "svelte/events";
4
- import { createFocusScopeAPI, createFocusScopeStack, removeLinks, } from "./focus-scope-stack.svelte.js";
5
- import { focus, focusFirst, getTabbableCandidates, getTabbableEdges } from "../../../internal/focus.js";
6
- import { CustomEventDispatcher } from "../../../internal/events.js";
7
- import { isHTMLElement } from "../../../internal/is.js";
8
- import { kbd } from "../../../internal/kbd.js";
9
- import { isTabbable } from "tabbable";
10
- const AutoFocusOnMountEvent = new CustomEventDispatcher("focusScope.autoFocusOnMount", {
11
- bubbles: false,
12
- cancelable: true,
13
- });
14
- const AutoFocusOnDestroyEvent = new CustomEventDispatcher("focusScope.autoFocusOnDestroy", {
15
- bubbles: false,
16
- cancelable: true,
17
- });
18
- export const FocusScopeContext = new Context("FocusScope");
19
- export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoFocus, forceMount, ref, }) {
20
- const focusScopeStack = createFocusScopeStack();
21
- const focusScope = createFocusScopeAPI();
22
- const ctx = FocusScopeContext.getOr({ ignoreCloseAutoFocus: false });
23
- let lastFocusedElement = null;
24
- const domContext = new DOMContext(ref);
25
- function manageFocus(event) {
26
- if (focusScope.paused || !ref.current || focusScope.isHandlingFocus)
27
- return;
28
- focusScope.isHandlingFocus = true;
29
- try {
30
- const target = event.target;
31
- if (!isHTMLElement(target))
32
- return;
33
- const isWithinActiveScope = ref.current.contains(target);
34
- if (event.type === "focusin") {
35
- if (isWithinActiveScope) {
36
- lastFocusedElement = target;
37
- }
38
- else {
39
- if (ctx.ignoreCloseAutoFocus)
40
- return;
41
- focus(lastFocusedElement, { select: true });
42
- }
43
- }
44
- else if (event.type === "focusout") {
45
- if (!isWithinActiveScope && !ctx.ignoreCloseAutoFocus) {
46
- focus(lastFocusedElement, { select: true });
47
- }
48
- }
49
- }
50
- finally {
51
- focusScope.isHandlingFocus = false;
52
- }
53
- }
54
- /**
55
- * Handles DOM mutations within the container. Specifically checks if the
56
- * last known focused element inside the container has been removed. If so,
57
- * and focus has escaped the container (likely moved to document.body),
58
- * it refocuses the container itself to maintain the trap.
59
- */
60
- function handleMutations(mutations) {
61
- // if there's no record of a last focused el, or container isn't mounted, bail
62
- if (!lastFocusedElement || !ref.current)
63
- return;
64
- // track if the last focused element was removed
65
- let elementWasRemoved = false;
66
- for (const mutation of mutations) {
67
- // we only care about mutations where nodes were removed
68
- if (mutation.type === "childList" && mutation.removedNodes.length > 0) {
69
- // check if any removed nodes are the last focused element or contain it
70
- for (const removedNode of mutation.removedNodes) {
71
- if (removedNode === lastFocusedElement) {
72
- elementWasRemoved = true;
73
- // found it directly
74
- break;
75
- }
76
- // contains() only works on elements, so we need to check nodeType
77
- if (removedNode.nodeType === Node.ELEMENT_NODE &&
78
- removedNode.contains(lastFocusedElement)) {
79
- elementWasRemoved = true;
80
- // descendant found,
81
- break;
82
- }
83
- }
84
- }
85
- // if we've confirmed removal in any mutation, bail
86
- if (elementWasRemoved)
87
- break;
88
- }
89
- /**
90
- * If the element was removed and focus is now outside the container,
91
- * (e.g., browser moved it to body), refocus the container.
92
- */
93
- if (elementWasRemoved &&
94
- ref.current &&
95
- !ref.current.contains(domContext.getActiveElement())) {
96
- focus(ref.current);
97
- }
98
- }
99
- watch([() => ref.current, () => enabled.current], ([container, enabled]) => {
100
- if (!container || !enabled)
101
- return;
102
- const removeEvents = executeCallbacks(on(domContext.getDocument(), "focusin", manageFocus), on(domContext.getDocument(), "focusout", manageFocus));
103
- const mutationObserver = new MutationObserver(handleMutations);
104
- mutationObserver.observe(container, {
105
- childList: true,
106
- subtree: true,
107
- attributes: false,
108
- });
109
- return () => {
110
- removeEvents();
111
- mutationObserver.disconnect();
112
- };
113
- });
114
- watch([() => forceMount.current, () => ref.current], ([forceMount, container]) => {
115
- if (forceMount)
116
- return;
117
- const prevFocusedElement = domContext.getActiveElement();
118
- handleOpen(container, prevFocusedElement);
119
- return () => {
120
- if (!container)
121
- return;
122
- handleClose(prevFocusedElement);
123
- };
124
- });
125
- watch([() => forceMount.current, () => ref.current, () => enabled.current], ([forceMount, container]) => {
126
- if (!forceMount)
127
- return;
128
- const prevFocusedElement = domContext.getActiveElement();
129
- handleOpen(container, prevFocusedElement);
130
- return () => {
131
- if (!container)
132
- return;
133
- handleClose(prevFocusedElement);
134
- };
135
- });
136
- function handleOpen(container, prevFocusedElement) {
137
- if (!container)
138
- container = domContext.getElementById(id.current);
139
- if (!container || !enabled.current)
140
- return;
141
- focusScopeStack.add(focusScope);
142
- const hasFocusedCandidate = container.contains(prevFocusedElement);
143
- if (!hasFocusedCandidate) {
144
- const mountEvent = AutoFocusOnMountEvent.createEvent();
145
- onOpenAutoFocus.current(mountEvent);
146
- if (!mountEvent.defaultPrevented) {
147
- afterTick(() => {
148
- if (!container)
149
- return;
150
- const result = focusFirst(removeLinks(getTabbableCandidates(container)), {
151
- select: true,
152
- }, () => domContext.getActiveElement());
153
- if (!result)
154
- focus(container);
155
- });
156
- }
157
- }
158
- }
159
- function handleClose(prevFocusedElement) {
160
- const destroyEvent = AutoFocusOnDestroyEvent.createEvent();
161
- onCloseAutoFocus.current?.(destroyEvent);
162
- const shouldIgnore = ctx.ignoreCloseAutoFocus;
163
- afterSleep(0, () => {
164
- if (!destroyEvent.defaultPrevented && prevFocusedElement && !shouldIgnore) {
165
- focus(isTabbable(prevFocusedElement)
166
- ? prevFocusedElement
167
- : domContext.getDocument().body, {
168
- select: true,
169
- });
170
- }
171
- focusScopeStack.remove(focusScope);
172
- });
173
- }
174
- function handleKeydown(e) {
175
- if (!enabled.current)
176
- return;
177
- if (!loop.current && !enabled.current)
178
- return;
179
- if (focusScope.paused)
180
- return;
181
- const isTabKey = e.key === kbd.TAB && !e.ctrlKey && !e.altKey && !e.metaKey;
182
- const focusedElement = domContext.getActiveElement();
183
- if (!(isTabKey && focusedElement))
184
- return;
185
- const container = ref.current;
186
- if (!container)
187
- return;
188
- const [first, last] = getTabbableEdges(container);
189
- const hasTabbableElementsInside = first && last;
190
- if (!hasTabbableElementsInside) {
191
- if (focusedElement === container) {
192
- e.preventDefault();
193
- }
194
- }
195
- else {
196
- if (!e.shiftKey && focusedElement === last) {
197
- e.preventDefault();
198
- if (loop.current)
199
- focus(first, { select: true });
200
- }
201
- else if (e.shiftKey && focusedElement === first) {
202
- e.preventDefault();
203
- if (loop.current)
204
- focus(last, { select: true });
205
- }
206
- }
207
- }
208
- const props = $derived.by(() => ({
209
- id: id.current,
210
- tabindex: -1,
211
- onkeydown: handleKeydown,
212
- }));
213
- return {
214
- get props() {
215
- return props;
216
- },
217
- };
218
- }
@@ -1,6 +0,0 @@
1
- export declare function shouldTrapFocus({ forceMount, present, trapFocus, open, }: {
2
- forceMount: boolean;
3
- present: boolean;
4
- trapFocus: boolean;
5
- open: boolean;
6
- }): boolean;
@@ -1,5 +0,0 @@
1
- export function shouldTrapFocus({ forceMount, present, trapFocus, open, }) {
2
- if (forceMount)
3
- return open && trapFocus;
4
- return present && trapFocus && open;
5
- }