bits-ui 2.9.4 → 2.9.6

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.
@@ -57,11 +57,7 @@
57
57
  present: contentState.root.opts.open.current,
58
58
  open: contentState.root.opts.open.current,
59
59
  })}
60
- onCloseAutoFocus={(e) => {
61
- onCloseAutoFocus(e);
62
- if (e.defaultPrevented) return;
63
- afterSleep(0, () => contentState.root.triggerNode?.focus());
64
- }}
60
+ {onCloseAutoFocus}
65
61
  onOpenAutoFocus={(e) => {
66
62
  onOpenAutoFocus(e);
67
63
  if (e.defaultPrevented) return;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { afterSleep, box, mergeProps } from "svelte-toolbelt";
2
+ import { 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";
@@ -57,11 +57,7 @@
57
57
  open: contentState.root.opts.open.current,
58
58
  })}
59
59
  {onOpenAutoFocus}
60
- onCloseAutoFocus={(e) => {
61
- onCloseAutoFocus(e);
62
- if (e.defaultPrevented) return;
63
- afterSleep(1, () => contentState.root.triggerNode?.focus());
64
- }}
60
+ {onCloseAutoFocus}
65
61
  >
66
62
  {#snippet focusScope({ props: focusScopeProps })}
67
63
  <EscapeLayer
@@ -6,6 +6,7 @@
6
6
  import { FloatingLayer } from "../../utilities/floating-layer/index.js";
7
7
 
8
8
  let {
9
+ disabled = false,
9
10
  open = $bindable(false),
10
11
  onOpenChange = noop,
11
12
  onOpenChangeComplete = noop,
@@ -15,6 +16,7 @@
15
16
  }: LinkPreviewRootProps = $props();
16
17
 
17
18
  LinkPreviewRootState.create({
19
+ disabled: box.with(() => disabled),
18
20
  open: box.with(
19
21
  () => open,
20
22
  (v) => {
@@ -3,6 +3,7 @@ import type { BitsFocusEvent, BitsPointerEvent, OnChangeFn, RefAttachment, WithR
3
3
  interface LinkPreviewRootStateOpts extends WritableBoxedValues<{
4
4
  open: boolean;
5
5
  }>, ReadableBoxedValues<{
6
+ disabled: boolean;
6
7
  openDelay: number;
7
8
  closeDelay: number;
8
9
  onOpenChangeComplete: OnChangeFn<boolean>;
@@ -76,7 +76,7 @@ export class LinkPreviewRootState {
76
76
  }
77
77
  handleOpen() {
78
78
  this.clearTimeout();
79
- if (this.opts.open.current)
79
+ if (this.opts.open.current || this.opts.disabled.current)
80
80
  return;
81
81
  this.isOpening = true;
82
82
  this.timeout = this.domContext.setTimeout(() => {
@@ -21,7 +21,6 @@
21
21
  onInteractOutside = noop,
22
22
  trapFocus = true,
23
23
  preventScroll = false,
24
-
25
24
  ...restProps
26
25
  }: PopoverContentStaticProps = $props();
27
26
 
@@ -33,7 +32,6 @@
33
32
  ),
34
33
  onInteractOutside: box.with(() => onInteractOutside),
35
34
  onEscapeKeydown: box.with(() => onEscapeKeydown),
36
- onCloseAutoFocus: box.with(() => onCloseAutoFocus),
37
35
  customAnchor: box.with(() => null),
38
36
  });
39
37
 
@@ -52,6 +50,7 @@
52
50
  {preventScroll}
53
51
  loop
54
52
  forceMount={true}
53
+ {onCloseAutoFocus}
55
54
  >
56
55
  {#snippet popper({ props })}
57
56
  {@const finalProps = mergeProps(props, {
@@ -78,6 +77,7 @@
78
77
  {preventScroll}
79
78
  loop
80
79
  forceMount={false}
80
+ {onCloseAutoFocus}
81
81
  >
82
82
  {#snippet popper({ props })}
83
83
  {@const finalProps = mergeProps(props, {
@@ -33,7 +33,6 @@
33
33
  ),
34
34
  onInteractOutside: box.with(() => onInteractOutside),
35
35
  onEscapeKeydown: box.with(() => onEscapeKeydown),
36
- onCloseAutoFocus: box.with(() => onCloseAutoFocus),
37
36
  customAnchor: box.with(() => customAnchor),
38
37
  });
39
38
 
@@ -52,6 +51,7 @@
52
51
  loop
53
52
  forceMount={true}
54
53
  {customAnchor}
54
+ {onCloseAutoFocus}
55
55
  >
56
56
  {#snippet popper({ props, wrapperProps })}
57
57
  {@const finalProps = mergeProps(props, {
@@ -80,6 +80,7 @@
80
80
  loop
81
81
  forceMount={false}
82
82
  {customAnchor}
83
+ {onCloseAutoFocus}
83
84
  >
84
85
  {#snippet popper({ props, wrapperProps })}
85
86
  {@const finalProps = mergeProps(props, {
@@ -43,7 +43,6 @@ export declare class PopoverTriggerState {
43
43
  interface PopoverContentStateOpts extends WithRefOpts, ReadableBoxedValues<{
44
44
  onInteractOutside: (e: PointerEvent) => void;
45
45
  onEscapeKeydown: (e: KeyboardEvent) => void;
46
- onCloseAutoFocus: (e: Event) => void;
47
46
  customAnchor: string | HTMLElement | null | Measurable;
48
47
  }> {
49
48
  }
@@ -55,7 +54,6 @@ export declare class PopoverContentState {
55
54
  constructor(opts: PopoverContentStateOpts, root: PopoverRootState);
56
55
  onInteractOutside: (e: PointerEvent) => void;
57
56
  onEscapeKeydown: (e: KeyboardEvent) => void;
58
- onCloseAutoFocus: (e: Event) => void;
59
57
  readonly snippetProps: {
60
58
  open: boolean;
61
59
  };
@@ -70,7 +68,6 @@ export declare class PopoverContentState {
70
68
  readonly popperProps: {
71
69
  onInteractOutside: (e: PointerEvent) => void;
72
70
  onEscapeKeydown: (e: KeyboardEvent) => void;
73
- onCloseAutoFocus: (e: Event) => void;
74
71
  };
75
72
  }
76
73
  interface PopoverCloseStateOpts extends WithRefOpts {
@@ -124,13 +124,6 @@ export class PopoverContentState {
124
124
  return;
125
125
  this.root.handleClose();
126
126
  };
127
- onCloseAutoFocus = (e) => {
128
- this.opts.onCloseAutoFocus.current?.(e);
129
- if (e.defaultPrevented)
130
- return;
131
- e.preventDefault();
132
- this.root.triggerNode?.focus();
133
- };
134
127
  snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
135
128
  props = $derived.by(() => ({
136
129
  id: this.opts.id.current,
@@ -145,7 +138,6 @@ export class PopoverContentState {
145
138
  popperProps = {
146
139
  onInteractOutside: this.onInteractOutside,
147
140
  onEscapeKeydown: this.onEscapeKeydown,
148
- onCloseAutoFocus: this.onCloseAutoFocus,
149
141
  };
150
142
  }
151
143
  export class PopoverCloseState {
@@ -9,4 +9,7 @@ export declare class FocusScopeManager {
9
9
  setFocusMemory(scope: FocusScope, element: HTMLElement): void;
10
10
  getFocusMemory(scope: FocusScope): HTMLElement | undefined;
11
11
  isActiveScope(scope: FocusScope): boolean;
12
+ setPreFocusMemory(scope: FocusScope, element: HTMLElement): void;
13
+ getPreFocusMemory(scope: FocusScope): HTMLElement | undefined;
14
+ clearPreFocusMemory(scope: FocusScope): void;
12
15
  }
@@ -4,6 +4,7 @@ export class FocusScopeManager {
4
4
  static instance;
5
5
  #scopeStack = box([]);
6
6
  #focusHistory = new WeakMap();
7
+ #preFocusHistory = new WeakMap();
7
8
  static getInstance() {
8
9
  if (!this.instance) {
9
10
  this.instance = new FocusScopeManager();
@@ -15,6 +16,11 @@ export class FocusScopeManager {
15
16
  if (current && current !== scope) {
16
17
  current.pause();
17
18
  }
19
+ // capture the currently focused element before this scope becomes active
20
+ const activeElement = document.activeElement;
21
+ if (activeElement && activeElement !== document.body) {
22
+ this.#preFocusHistory.set(scope, activeElement);
23
+ }
18
24
  this.#scopeStack.current = this.#scopeStack.current.filter((s) => s !== scope);
19
25
  this.#scopeStack.current.unshift(scope);
20
26
  }
@@ -37,4 +43,13 @@ export class FocusScopeManager {
37
43
  isActiveScope(scope) {
38
44
  return this.getActive() === scope;
39
45
  }
46
+ setPreFocusMemory(scope, element) {
47
+ this.#preFocusHistory.set(scope, element);
48
+ }
49
+ getPreFocusMemory(scope) {
50
+ return this.#preFocusHistory.get(scope);
51
+ }
52
+ clearPreFocusMemory(scope) {
53
+ this.#preFocusHistory.delete(scope);
54
+ }
40
55
  }
@@ -43,6 +43,7 @@ export class FocusScope {
43
43
  // handle close auto-focus
44
44
  this.#handleCloseAutoFocus();
45
45
  this.#manager.unregister(this);
46
+ this.#manager.clearPreFocusMemory(this);
46
47
  this.#container = null;
47
48
  }
48
49
  #handleOpenAutoFocus() {
@@ -75,10 +76,17 @@ export class FocusScope {
75
76
  });
76
77
  this.#opts.onCloseAutoFocus.current?.(event);
77
78
  if (!event.defaultPrevented) {
78
- // return focus to previously focused element
79
- const prevFocused = document.activeElement;
80
- if (prevFocused && prevFocused !== document.body) {
81
- prevFocused.focus();
79
+ // return focus to the element that was focused before this scope opened
80
+ const preFocusedElement = this.#manager.getPreFocusMemory(this);
81
+ if (preFocusedElement && document.contains(preFocusedElement)) {
82
+ // ensure the element is still focusable and in the document
83
+ try {
84
+ preFocusedElement.focus();
85
+ }
86
+ catch {
87
+ // fallback if focus fails
88
+ document.body.focus();
89
+ }
82
90
  }
83
91
  }
84
92
  }
@@ -11,6 +11,7 @@ const lockMap = new SvelteMap();
11
11
  let initialBodyStyle = $state(null);
12
12
  let stopTouchMoveListener = null;
13
13
  let cleanupTimeoutId = null;
14
+ let isInCleanupTransition = false;
14
15
  const anyLocked = box.with(() => {
15
16
  for (const value of lockMap.values()) {
16
17
  if (value)
@@ -35,7 +36,6 @@ const bodyLockStackCount = new SharedState(() => {
35
36
  isIOS && stopTouchMoveListener?.();
36
37
  // reset initialBodyStyle so next locker captures the correct styles
37
38
  initialBodyStyle = null;
38
- hasEverBeenLocked = false;
39
39
  }
40
40
  function cancelPendingCleanup() {
41
41
  if (cleanupTimeoutId === null)
@@ -45,6 +45,7 @@ const bodyLockStackCount = new SharedState(() => {
45
45
  }
46
46
  function scheduleCleanupIfNoNewLocks(delay, callback) {
47
47
  cancelPendingCleanup();
48
+ isInCleanupTransition = true;
48
49
  cleanupScheduledAt = Date.now();
49
50
  const currentCleanupId = cleanupScheduledAt;
50
51
  /**
@@ -64,24 +65,20 @@ const bodyLockStackCount = new SharedState(() => {
64
65
  return;
65
66
  // ensure no new locks were added during the delay
66
67
  if (!isAnyLocked(lockMap)) {
68
+ isInCleanupTransition = false;
67
69
  callback();
68
70
  }
71
+ else {
72
+ isInCleanupTransition = false;
73
+ }
69
74
  };
70
- if (delay === null) {
71
- // use a small delay even when no restoreScrollDelay is set
72
- // to handle same-tick destroy/create scenarios (~1 frame)
73
- cleanupTimeoutId = window.setTimeout(cleanupFn, 16);
74
- }
75
- else {
76
- cleanupTimeoutId = window.setTimeout(cleanupFn, delay);
77
- }
75
+ const actualDelay = delay === null ? 24 : delay;
76
+ cleanupTimeoutId = window.setTimeout(cleanupFn, actualDelay);
78
77
  }
79
- // track if we've ever applied lock styles in this session
80
- let hasEverBeenLocked = false;
81
78
  function ensureInitialStyleCaptured() {
82
- if (!hasEverBeenLocked && initialBodyStyle === null) {
79
+ // only capture initial style once, when no locks exist and no cleanup is in progress
80
+ if (initialBodyStyle === null && lockMap.size === 0 && !isInCleanupTransition) {
83
81
  initialBodyStyle = document.body.getAttribute("style");
84
- hasEverBeenLocked = true;
85
82
  }
86
83
  }
87
84
  watch(() => anyLocked.current, () => {
@@ -89,6 +86,8 @@ const bodyLockStackCount = new SharedState(() => {
89
86
  return;
90
87
  // ensure we've captured the initial style before applying any lock styles
91
88
  ensureInitialStyleCaptured();
89
+ // if we're applying lock styles, we're no longer in a cleanup transition
90
+ isInCleanupTransition = false;
92
91
  const bodyStyle = getComputedStyle(document.body);
93
92
  // TODO: account for RTL direction, etc.
94
93
  const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "2.9.4",
3
+ "version": "2.9.6",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",