bits-ui 2.14.3 → 2.15.0

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.
@@ -128,8 +128,14 @@ export class CheckboxRootState {
128
128
  onkeydown(e) {
129
129
  if (this.trueDisabled || this.trueReadonly)
130
130
  return;
131
- if (e.key === kbd.ENTER)
131
+ if (e.key === kbd.ENTER) {
132
132
  e.preventDefault();
133
+ if (this.opts.type.current === "submit") {
134
+ const form = e.currentTarget.closest("form");
135
+ form?.requestSubmit();
136
+ }
137
+ return;
138
+ }
133
139
  if (e.key === kbd.SPACE) {
134
140
  e.preventDefault();
135
141
  this.#toggle();
@@ -134,6 +134,10 @@ export class CommandRootState {
134
134
  if (!this._commandState.value || !this.#isInitialMount) {
135
135
  this.#selectFirstItem();
136
136
  }
137
+ else if (this.#isInitialMount && this._commandState.value) {
138
+ // scroll the initial value into view if it exists
139
+ this.#scrollInitialValue();
140
+ }
137
141
  return;
138
142
  }
139
143
  const scores = this._commandState.filtered.items;
@@ -216,6 +220,19 @@ export class CommandRootState {
216
220
  this.#isInitialMount = false;
217
221
  });
218
222
  }
223
+ /**
224
+ * Scrolls the initial value into view if it exists and is not the first item.
225
+ * Called during initial mount when a value is provided.
226
+ */
227
+ #scrollInitialValue() {
228
+ afterTick(() => {
229
+ const shouldPreventScroll = this.opts.disableInitialScroll.current;
230
+ if (!shouldPreventScroll) {
231
+ this.#scrollSelectedIntoView();
232
+ }
233
+ this.#isInitialMount = false;
234
+ });
235
+ }
219
236
  /**
220
237
  * Updates filtered items/groups based on search.
221
238
  * Recalculates scores and filtered count.
@@ -18,6 +18,9 @@
18
18
  onCloseAutoFocus = noop,
19
19
  onOpenAutoFocus = noop,
20
20
  preventScroll = true,
21
+ side = "right",
22
+ sideOffset = 2,
23
+ align = "start",
21
24
  // we need to explicitly pass this prop to the PopperLayer to override
22
25
  // the default menu behavior of handling outside interactions on the trigger
23
26
  onEscapeKeydown = noop,
@@ -74,9 +77,9 @@
74
77
  {...mergedProps}
75
78
  {...contentState.popperProps}
76
79
  ref={contentState.opts.ref}
77
- side="right"
78
- sideOffset={2}
79
- align="start"
80
+ {side}
81
+ {sideOffset}
82
+ {align}
80
83
  enabled={contentState.parentMenu.opts.open.current}
81
84
  {preventScroll}
82
85
  onInteractOutside={handleInteractOutside}
@@ -1,4 +1,3 @@
1
- import type { ContextMenuContentProps } from "../types.js";
2
- declare const ContextMenuContent: import("svelte").Component<ContextMenuContentProps, {}, "ref">;
1
+ declare const ContextMenuContent: import("svelte").Component<import("../../menu/types.js").MenuContentProps, {}, "ref">;
3
2
  type ContextMenuContent = ReturnType<typeof ContextMenuContent>;
4
3
  export default ContextMenuContent;
@@ -1,8 +1,8 @@
1
1
  import type { MenuContentProps, MenuContentPropsWithoutHTML } from "../menu/types.js";
2
2
  import type { WithChild, Without } from "../../internal/types.js";
3
3
  import type { BitsPrimitiveDivAttributes } from "../../shared/attributes.js";
4
- export type ContextMenuContentPropsWithoutHTML = Omit<MenuContentPropsWithoutHTML, "align" | "side" | "sideOffset">;
5
- export type ContextMenuContentProps = Omit<MenuContentProps, "side" | "sideOffset" | "align">;
4
+ export type ContextMenuContentPropsWithoutHTML = MenuContentPropsWithoutHTML;
5
+ export type ContextMenuContentProps = MenuContentProps;
6
6
  export type ContextMenuTriggerPropsWithoutHTML = WithChild<{
7
7
  /**
8
8
  * Whether the context menu trigger is disabled. If disabled, the trigger will not
@@ -48,7 +48,7 @@ export type LinkPreviewRootPropsWithoutHTML = WithChildren<{
48
48
  ignoreNonKeyboardFocus?: boolean;
49
49
  }>;
50
50
  export type LinkPreviewRootProps = LinkPreviewRootPropsWithoutHTML;
51
- export type LinkPreviewContentPropsWithoutHTML = WithChildNoChildrenSnippetProps<Pick<FloatingLayerContentProps, "side" | "sideOffset" | "align" | "alignOffset" | "avoidCollisions" | "collisionBoundary" | "collisionPadding" | "arrowPadding" | "sticky" | "hideWhenDetached" | "dir"> & Omit<DismissibleLayerProps, "onInteractOutsideStart"> & EscapeLayerProps & {
51
+ export type LinkPreviewContentPropsWithoutHTML = WithChildNoChildrenSnippetProps<Pick<FloatingLayerContentProps, "side" | "sideOffset" | "align" | "alignOffset" | "avoidCollisions" | "collisionBoundary" | "collisionPadding" | "arrowPadding" | "sticky" | "hideWhenDetached" | "dir" | "customAnchor"> & Omit<DismissibleLayerProps, "onInteractOutsideStart"> & EscapeLayerProps & {
52
52
  /**
53
53
  * When `true`, the link preview content will be forced to mount in the DOM.
54
54
  *
@@ -3,7 +3,7 @@ import { Context } from "runed";
3
3
  import { CustomEventDispatcher } from "../../internal/events.js";
4
4
  import type { AnyFn, BitsFocusEvent, BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, OnChangeFn, RefAttachment, WithRefOpts } from "../../internal/types.js";
5
5
  import type { Direction } from "../../shared/index.js";
6
- import { IsUsingKeyboard } from "../../index.js";
6
+ import { IsUsingKeyboard } from "../utilities/is-using-keyboard/is-using-keyboard.svelte.js";
7
7
  import type { KeyboardEventHandler, PointerEventHandler, MouseEventHandler } from "svelte/elements";
8
8
  import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
9
9
  import { PresenceManager } from "../../internal/presence-manager.svelte.js";
@@ -6,13 +6,14 @@ import { CustomEventDispatcher } from "../../internal/events.js";
6
6
  import { isElement, isElementOrSVGElement, isHTMLElement } from "../../internal/is.js";
7
7
  import { kbd } from "../../internal/kbd.js";
8
8
  import { createBitsAttrs, getAriaChecked, boolToStr, getDataOpenClosed, boolToEmptyStrOrUndef, } from "../../internal/attrs.js";
9
- import { IsUsingKeyboard } from "../../index.js";
9
+ import { IsUsingKeyboard } from "../utilities/is-using-keyboard/is-using-keyboard.svelte.js";
10
10
  import { getTabbableFrom } from "../../internal/tabbable.js";
11
11
  import { isTabbable } from "tabbable";
12
12
  import { DOMTypeahead } from "../../internal/dom-typeahead.svelte.js";
13
13
  import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
14
14
  import { GraceArea } from "../../internal/grace-area.svelte.js";
15
15
  import { PresenceManager } from "../../internal/presence-manager.svelte.js";
16
+ import { arraysAreEqual } from "../../internal/arrays.js";
16
17
  export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger";
17
18
  export const CONTEXT_MENU_CONTENT_ATTR = "data-context-menu-content";
18
19
  const MenuRootContext = new Context("Menu.Root");
@@ -942,6 +943,8 @@ export class MenuCheckboxGroupState {
942
943
  if (!this.opts.value.current.includes(checkboxValue)) {
943
944
  const newValue = [...$state.snapshot(this.opts.value.current), checkboxValue];
944
945
  this.opts.value.current = newValue;
946
+ if (arraysAreEqual(this.opts.value.current, newValue))
947
+ return;
945
948
  this.opts.onValueChange.current(newValue);
946
949
  }
947
950
  }
@@ -953,6 +956,8 @@ export class MenuCheckboxGroupState {
953
956
  return;
954
957
  const newValue = this.opts.value.current.filter((v) => v !== checkboxValue);
955
958
  this.opts.value.current = newValue;
959
+ if (arraysAreEqual(this.opts.value.current, newValue))
960
+ return;
956
961
  this.opts.onValueChange.current(newValue);
957
962
  }
958
963
  props = $derived.by(() => ({
@@ -16,6 +16,7 @@
16
16
  ref = $bindable(null),
17
17
  id = createId(uid),
18
18
  forceMount = false,
19
+ onOpenAutoFocus = noop,
19
20
  onCloseAutoFocus = noop,
20
21
  onEscapeKeydown = noop,
21
22
  onInteractOutside = noop,
@@ -37,6 +38,17 @@
37
38
  });
38
39
 
39
40
  const mergedProps = $derived(mergeProps(restProps, contentState.props));
41
+
42
+ // respect user's trapFocus setting, but disable when hover-opened without interaction
43
+ const effectiveTrapFocus = $derived(trapFocus && contentState.shouldTrapFocus);
44
+
45
+ // prevent auto-focus when opened via hover until user interacts
46
+ function handleOpenAutoFocus(e: Event) {
47
+ if (!contentState.shouldTrapFocus) {
48
+ e.preventDefault();
49
+ }
50
+ onOpenAutoFocus(e);
51
+ }
40
52
  </script>
41
53
 
42
54
  {#if forceMount}
@@ -46,11 +58,12 @@
46
58
  ref={contentState.opts.ref}
47
59
  enabled={contentState.root.opts.open.current}
48
60
  {id}
49
- {trapFocus}
61
+ trapFocus={effectiveTrapFocus}
50
62
  {preventScroll}
51
63
  loop
52
64
  forceMount={true}
53
65
  {customAnchor}
66
+ onOpenAutoFocus={handleOpenAutoFocus}
54
67
  {onCloseAutoFocus}
55
68
  shouldRender={contentState.shouldRender}
56
69
  >
@@ -76,11 +89,12 @@
76
89
  ref={contentState.opts.ref}
77
90
  open={contentState.root.opts.open.current}
78
91
  {id}
79
- {trapFocus}
92
+ trapFocus={effectiveTrapFocus}
80
93
  {preventScroll}
81
94
  loop
82
95
  forceMount={false}
83
96
  {customAnchor}
97
+ onOpenAutoFocus={handleOpenAutoFocus}
84
98
  {onCloseAutoFocus}
85
99
  shouldRender={contentState.shouldRender}
86
100
  >
@@ -14,6 +14,9 @@
14
14
  ref = $bindable(null),
15
15
  type = "button",
16
16
  disabled = false,
17
+ openOnHover = false,
18
+ openDelay = 700,
19
+ closeDelay = 300,
17
20
  ...restProps
18
21
  }: PopoverTriggerProps = $props();
19
22
 
@@ -24,6 +27,9 @@
24
27
  (v) => (ref = v)
25
28
  ),
26
29
  disabled: boxWith(() => Boolean(disabled)),
30
+ openOnHover: boxWith(() => openOnHover),
31
+ openDelay: boxWith(() => openDelay),
32
+ closeDelay: boxWith(() => closeDelay),
27
33
  });
28
34
 
29
35
  const mergedProps = $derived(mergeProps(restProps, triggerState.props, { type }));
@@ -1,5 +1,5 @@
1
- import { type ReadableBoxedValues, type WritableBoxedValues } from "svelte-toolbelt";
2
- import type { BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, OnChangeFn, RefAttachment, WithRefOpts } from "../../internal/types.js";
1
+ import { type ReadableBoxedValues, type WritableBoxedValues, DOMContext } from "svelte-toolbelt";
2
+ import type { BitsFocusEvent, BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, OnChangeFn, RefAttachment, WithRefOpts } from "../../internal/types.js";
3
3
  import type { Measurable } from "../../internal/floating-svelte/types.js";
4
4
  import { PresenceManager } from "../../internal/presence-manager.svelte.js";
5
5
  interface PopoverRootStateOpts extends WritableBoxedValues<{
@@ -9,6 +9,7 @@ interface PopoverRootStateOpts extends WritableBoxedValues<{
9
9
  }> {
10
10
  }
11
11
  export declare class PopoverRootState {
12
+ #private;
12
13
  static create(opts: PopoverRootStateOpts): PopoverRootState;
13
14
  readonly opts: PopoverRootStateOpts;
14
15
  contentNode: HTMLElement | null;
@@ -16,12 +17,24 @@ export declare class PopoverRootState {
16
17
  triggerNode: HTMLElement | null;
17
18
  overlayNode: HTMLElement | null;
18
19
  overlayPresence: PresenceManager;
20
+ openedViaHover: boolean;
21
+ hasInteractedWithContent: boolean;
22
+ closeDelay: number;
19
23
  constructor(opts: PopoverRootStateOpts);
24
+ setDomContext(ctx: DOMContext): void;
20
25
  toggleOpen(): void;
21
26
  handleClose(): void;
27
+ handleHoverOpen(): void;
28
+ handleHoverClose(): void;
29
+ handleDelayedHoverClose(): void;
30
+ cancelDelayedClose(): void;
31
+ markInteraction(): void;
22
32
  }
23
33
  interface PopoverTriggerStateOpts extends WithRefOpts, ReadableBoxedValues<{
24
34
  disabled: boolean;
35
+ openOnHover: boolean;
36
+ openDelay: number;
37
+ closeDelay: number;
25
38
  }> {
26
39
  }
27
40
  export declare class PopoverTriggerState {
@@ -30,7 +43,10 @@ export declare class PopoverTriggerState {
30
43
  readonly opts: PopoverTriggerStateOpts;
31
44
  readonly root: PopoverRootState;
32
45
  readonly attachment: RefAttachment;
46
+ readonly domContext: DOMContext;
33
47
  constructor(opts: PopoverTriggerStateOpts, root: PopoverRootState);
48
+ onpointerenter(e: BitsPointerEvent): void;
49
+ onpointerleave(e: BitsPointerEvent): void;
34
50
  onclick(e: BitsMouseEvent): void;
35
51
  onkeydown(e: BitsKeyboardEvent): void;
36
52
  readonly props: {
@@ -42,6 +58,8 @@ export declare class PopoverTriggerState {
42
58
  readonly disabled: boolean;
43
59
  readonly onkeydown: (e: BitsKeyboardEvent) => void;
44
60
  readonly onclick: (e: BitsMouseEvent) => void;
61
+ readonly onpointerenter: (e: BitsPointerEvent) => void;
62
+ readonly onpointerleave: (e: BitsPointerEvent) => void;
45
63
  };
46
64
  }
47
65
  interface PopoverContentStateOpts extends WithRefOpts, ReadableBoxedValues<{
@@ -56,9 +74,14 @@ export declare class PopoverContentState {
56
74
  readonly root: PopoverRootState;
57
75
  readonly attachment: RefAttachment;
58
76
  constructor(opts: PopoverContentStateOpts, root: PopoverRootState);
77
+ onpointerdown(_: BitsPointerEvent): void;
78
+ onfocusin(e: BitsFocusEvent): void;
79
+ onpointerenter(e: BitsPointerEvent): void;
80
+ onpointerleave(e: BitsPointerEvent): void;
59
81
  onInteractOutside: (e: PointerEvent) => void;
60
82
  onEscapeKeydown: (e: KeyboardEvent) => void;
61
83
  get shouldRender(): boolean;
84
+ get shouldTrapFocus(): boolean;
62
85
  readonly snippetProps: {
63
86
  open: boolean;
64
87
  };
@@ -69,6 +92,10 @@ export declare class PopoverContentState {
69
92
  readonly style: {
70
93
  readonly pointerEvents: "auto";
71
94
  };
95
+ readonly onpointerdown: (_: BitsPointerEvent) => void;
96
+ readonly onfocusin: (e: BitsFocusEvent) => void;
97
+ readonly onpointerenter: (e: BitsPointerEvent) => void;
98
+ readonly onpointerleave: (e: BitsPointerEvent) => void;
72
99
  };
73
100
  readonly popperProps: {
74
101
  onInteractOutside: (e: PointerEvent) => void;
@@ -1,9 +1,11 @@
1
- import { attachRef, boxWith, } from "svelte-toolbelt";
2
- import { Context } from "runed";
1
+ import { attachRef, boxWith, DOMContext, } from "svelte-toolbelt";
2
+ import { Context, watch } from "runed";
3
3
  import { kbd } from "../../internal/kbd.js";
4
4
  import { createBitsAttrs, boolToStr, getDataOpenClosed } from "../../internal/attrs.js";
5
- import { isElement } from "../../internal/is.js";
5
+ import { isElement, isTouch } from "../../internal/is.js";
6
6
  import { PresenceManager } from "../../internal/presence-manager.svelte.js";
7
+ import { SafePolygon } from "../../internal/safe-polygon.svelte.js";
8
+ import { isTabbable } from "tabbable";
7
9
  const popoverAttrs = createBitsAttrs({
8
10
  component: "popover",
9
11
  parts: ["root", "trigger", "content", "close", "overlay"],
@@ -19,6 +21,12 @@ export class PopoverRootState {
19
21
  triggerNode = $state(null);
20
22
  overlayNode = $state(null);
21
23
  overlayPresence;
24
+ // hover tracking state
25
+ openedViaHover = $state(false);
26
+ hasInteractedWithContent = $state(false);
27
+ closeDelay = $state(0);
28
+ #closeTimeout = null;
29
+ #domContext = null;
22
30
  constructor(opts) {
23
31
  this.opts = opts;
24
32
  this.contentPresence = new PresenceManager({
@@ -32,15 +40,73 @@ export class PopoverRootState {
32
40
  ref: boxWith(() => this.overlayNode),
33
41
  open: this.opts.open,
34
42
  });
43
+ watch(() => this.opts.open.current, (isOpen) => {
44
+ if (!isOpen) {
45
+ this.openedViaHover = false;
46
+ this.hasInteractedWithContent = false;
47
+ this.#clearCloseTimeout();
48
+ }
49
+ });
50
+ }
51
+ setDomContext(ctx) {
52
+ this.#domContext = ctx;
53
+ }
54
+ #clearCloseTimeout() {
55
+ if (this.#closeTimeout !== null && this.#domContext) {
56
+ this.#domContext.clearTimeout(this.#closeTimeout);
57
+ this.#closeTimeout = null;
58
+ }
35
59
  }
36
60
  toggleOpen() {
61
+ this.#clearCloseTimeout();
37
62
  this.opts.open.current = !this.opts.open.current;
38
63
  }
39
64
  handleClose() {
65
+ this.#clearCloseTimeout();
40
66
  if (!this.opts.open.current)
41
67
  return;
42
68
  this.opts.open.current = false;
43
69
  }
70
+ handleHoverOpen() {
71
+ this.#clearCloseTimeout();
72
+ if (this.opts.open.current)
73
+ return;
74
+ this.openedViaHover = true;
75
+ this.opts.open.current = true;
76
+ }
77
+ handleHoverClose() {
78
+ if (!this.opts.open.current)
79
+ return;
80
+ // only close if opened via hover and user hasn't interacted with content
81
+ if (this.openedViaHover && !this.hasInteractedWithContent) {
82
+ this.opts.open.current = false;
83
+ }
84
+ }
85
+ handleDelayedHoverClose() {
86
+ if (!this.opts.open.current)
87
+ return;
88
+ if (!this.openedViaHover || this.hasInteractedWithContent)
89
+ return;
90
+ this.#clearCloseTimeout();
91
+ if (this.closeDelay <= 0) {
92
+ this.opts.open.current = false;
93
+ }
94
+ else if (this.#domContext) {
95
+ this.#closeTimeout = this.#domContext.setTimeout(() => {
96
+ if (this.openedViaHover && !this.hasInteractedWithContent) {
97
+ this.opts.open.current = false;
98
+ }
99
+ this.#closeTimeout = null;
100
+ }, this.closeDelay);
101
+ }
102
+ }
103
+ cancelDelayedClose() {
104
+ this.#clearCloseTimeout();
105
+ }
106
+ markInteraction() {
107
+ this.hasInteractedWithContent = true;
108
+ this.#clearCloseTimeout();
109
+ }
44
110
  }
45
111
  export class PopoverTriggerState {
46
112
  static create(opts) {
@@ -49,18 +115,87 @@ export class PopoverTriggerState {
49
115
  opts;
50
116
  root;
51
117
  attachment;
118
+ domContext;
119
+ #openTimeout = null;
120
+ #closeTimeout = null;
121
+ #isHovering = $state(false);
52
122
  constructor(opts, root) {
53
123
  this.opts = opts;
54
124
  this.root = root;
55
125
  this.attachment = attachRef(this.opts.ref, (v) => (this.root.triggerNode = v));
126
+ this.domContext = new DOMContext(opts.ref);
127
+ this.root.setDomContext(this.domContext);
56
128
  this.onclick = this.onclick.bind(this);
57
129
  this.onkeydown = this.onkeydown.bind(this);
130
+ this.onpointerenter = this.onpointerenter.bind(this);
131
+ this.onpointerleave = this.onpointerleave.bind(this);
132
+ watch(() => this.opts.closeDelay.current, (delay) => {
133
+ this.root.closeDelay = delay;
134
+ });
135
+ }
136
+ #clearOpenTimeout() {
137
+ if (this.#openTimeout !== null) {
138
+ this.domContext.clearTimeout(this.#openTimeout);
139
+ this.#openTimeout = null;
140
+ }
141
+ }
142
+ #clearCloseTimeout() {
143
+ if (this.#closeTimeout !== null) {
144
+ this.domContext.clearTimeout(this.#closeTimeout);
145
+ this.#closeTimeout = null;
146
+ }
147
+ }
148
+ #clearAllTimeouts() {
149
+ this.#clearOpenTimeout();
150
+ this.#clearCloseTimeout();
151
+ }
152
+ onpointerenter(e) {
153
+ if (this.opts.disabled.current)
154
+ return;
155
+ if (!this.opts.openOnHover.current)
156
+ return;
157
+ if (isTouch(e))
158
+ return;
159
+ this.#isHovering = true;
160
+ this.#clearCloseTimeout();
161
+ this.root.cancelDelayedClose();
162
+ if (this.root.opts.open.current)
163
+ return;
164
+ const delay = this.opts.openDelay.current;
165
+ if (delay <= 0) {
166
+ this.root.handleHoverOpen();
167
+ }
168
+ else {
169
+ this.#openTimeout = this.domContext.setTimeout(() => {
170
+ this.root.handleHoverOpen();
171
+ this.#openTimeout = null;
172
+ }, delay);
173
+ }
174
+ }
175
+ onpointerleave(e) {
176
+ if (this.opts.disabled.current)
177
+ return;
178
+ if (!this.opts.openOnHover.current)
179
+ return;
180
+ if (isTouch(e))
181
+ return;
182
+ this.#isHovering = false;
183
+ this.#clearOpenTimeout();
184
+ // let GraceArea handle the close - it will call handleHoverClose via onPointerExit
185
+ // we just need to stop any pending open timer
58
186
  }
59
187
  onclick(e) {
60
188
  if (this.opts.disabled.current)
61
189
  return;
62
190
  if (e.button !== 0)
63
191
  return;
192
+ this.#clearAllTimeouts();
193
+ // if clicked while hovering and popover is open, convert to click-based open
194
+ if (this.#isHovering && this.root.opts.open.current && this.root.openedViaHover) {
195
+ this.root.openedViaHover = false;
196
+ this.root.hasInteractedWithContent = true;
197
+ return;
198
+ }
64
199
  this.root.toggleOpen();
65
200
  }
66
201
  onkeydown(e) {
@@ -69,13 +204,13 @@ export class PopoverTriggerState {
69
204
  if (!(e.key === kbd.ENTER || e.key === kbd.SPACE))
70
205
  return;
71
206
  e.preventDefault();
207
+ this.#clearAllTimeouts();
72
208
  this.root.toggleOpen();
73
209
  }
74
210
  #getAriaControls() {
75
211
  if (this.root.opts.open.current && this.root.contentNode?.id) {
76
212
  return this.root.contentNode?.id;
77
213
  }
78
- return undefined;
79
214
  }
80
215
  props = $derived.by(() => ({
81
216
  id: this.opts.id.current,
@@ -88,6 +223,8 @@ export class PopoverTriggerState {
88
223
  //
89
224
  onkeydown: this.onkeydown,
90
225
  onclick: this.onclick,
226
+ onpointerenter: this.onpointerenter,
227
+ onpointerleave: this.onpointerleave,
91
228
  ...this.attachment,
92
229
  }));
93
230
  }
@@ -102,6 +239,39 @@ export class PopoverContentState {
102
239
  this.opts = opts;
103
240
  this.root = root;
104
241
  this.attachment = attachRef(this.opts.ref, (v) => (this.root.contentNode = v));
242
+ this.onpointerdown = this.onpointerdown.bind(this);
243
+ this.onfocusin = this.onfocusin.bind(this);
244
+ this.onpointerenter = this.onpointerenter.bind(this);
245
+ this.onpointerleave = this.onpointerleave.bind(this);
246
+ new SafePolygon({
247
+ triggerNode: () => this.root.triggerNode,
248
+ contentNode: () => this.root.contentNode,
249
+ enabled: () => this.root.opts.open.current &&
250
+ this.root.openedViaHover &&
251
+ !this.root.hasInteractedWithContent,
252
+ onPointerExit: () => {
253
+ this.root.handleDelayedHoverClose();
254
+ },
255
+ });
256
+ }
257
+ onpointerdown(_) {
258
+ this.root.markInteraction();
259
+ }
260
+ onfocusin(e) {
261
+ const target = e.target;
262
+ if (isElement(target) && isTabbable(target)) {
263
+ this.root.markInteraction();
264
+ }
265
+ }
266
+ onpointerenter(e) {
267
+ if (isTouch(e))
268
+ return;
269
+ this.root.cancelDelayedClose();
270
+ }
271
+ onpointerleave(e) {
272
+ if (isTouch(e))
273
+ return;
274
+ // handled by grace area
105
275
  }
106
276
  onInteractOutside = (e) => {
107
277
  this.opts.onInteractOutside.current(e);
@@ -134,6 +304,11 @@ export class PopoverContentState {
134
304
  get shouldRender() {
135
305
  return this.root.contentPresence.shouldRender;
136
306
  }
307
+ get shouldTrapFocus() {
308
+ if (this.root.openedViaHover && !this.root.hasInteractedWithContent)
309
+ return false;
310
+ return true;
311
+ }
137
312
  snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
138
313
  props = $derived.by(() => ({
139
314
  id: this.opts.id.current,
@@ -143,6 +318,10 @@ export class PopoverContentState {
143
318
  style: {
144
319
  pointerEvents: "auto",
145
320
  },
321
+ onpointerdown: this.onpointerdown,
322
+ onfocusin: this.onfocusin,
323
+ onpointerenter: this.onpointerenter,
324
+ onpointerleave: this.onpointerleave,
146
325
  ...this.attachment,
147
326
  }));
148
327
  popperProps = {
@@ -24,7 +24,25 @@ export type PopoverContentPropsWithoutHTML = WithChildNoChildrenSnippetProps<Omi
24
24
  export type PopoverContentProps = PopoverContentPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, PopoverContentPropsWithoutHTML>;
25
25
  export type PopoverContentStaticPropsWithoutHTML = WithChildNoChildrenSnippetProps<Omit<PopperLayerStaticProps, "content" | "loop">, StaticContentSnippetProps>;
26
26
  export type PopoverContentStaticProps = PopoverContentStaticPropsWithoutHTML & Without<BitsPrimitiveDivAttributes, PopoverContentStaticPropsWithoutHTML>;
27
- export type PopoverTriggerPropsWithoutHTML = WithChild;
27
+ export type PopoverTriggerPropsWithoutHTML = WithChild<{
28
+ /**
29
+ * Whether the popover should open when the trigger is hovered.
30
+ * @default false
31
+ */
32
+ openOnHover?: boolean;
33
+ /**
34
+ * How long to wait before opening the popover on hover (ms).
35
+ * Only applies when `openOnHover` is `true`.
36
+ * @default 700
37
+ */
38
+ openDelay?: number;
39
+ /**
40
+ * How long to wait before closing the popover after hover ends (ms).
41
+ * Only applies when `openOnHover` is `true`.
42
+ * @default 300
43
+ */
44
+ closeDelay?: number;
45
+ }>;
28
46
  export type PopoverTriggerProps = PopoverTriggerPropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, PopoverTriggerPropsWithoutHTML>;
29
47
  export type PopoverClosePropsWithoutHTML = WithChild;
30
48
  export type PopoverCloseProps = PopoverClosePropsWithoutHTML & Without<BitsPrimitiveButtonAttributes, PopoverClosePropsWithoutHTML>;
@@ -348,7 +348,7 @@ export declare class FloatingContentState {
348
348
  readonly perspective?: import("csstype").Property.Perspective<0 | (string & {})> | undefined;
349
349
  readonly perspectiveOrigin?: import("csstype").Property.PerspectiveOrigin<0 | (string & {})> | undefined;
350
350
  readonly pointerEvents?: import("csstype").Property.PointerEvents | undefined;
351
- position: "relative" | "absolute" | "inherit" | "fixed" | "sticky" | "-moz-initial" | "initial" | "revert" | "revert-layer" | "unset" | "-webkit-sticky" | "static";
351
+ position: "relative" | "absolute" | "fixed" | "sticky" | "-moz-initial" | "inherit" | "initial" | "revert" | "revert-layer" | "unset" | "-webkit-sticky" | "static";
352
352
  readonly printColorAdjust?: import("csstype").Property.PrintColorAdjust | undefined;
353
353
  readonly quotes?: import("csstype").Property.Quotes | undefined;
354
354
  readonly resize?: import("csstype").Property.Resize | undefined;
@@ -901,10 +901,10 @@ export declare class FloatingContentState {
901
901
  readonly vectorEffect?: import("csstype").Property.VectorEffect | undefined;
902
902
  readonly "pointer-events"?: string | undefined;
903
903
  readonly "--bits-floating-transform-origin": `${any} ${any}`;
904
- readonly "--bits-floating-available-width": `${number}px` | "undefinedpx";
905
- readonly "--bits-floating-available-height": `${number}px` | "undefinedpx";
906
- readonly "--bits-floating-anchor-width": `${number}px` | "undefinedpx";
907
- readonly "--bits-floating-anchor-height": `${number}px` | "undefinedpx";
904
+ readonly "--bits-floating-available-width": "undefinedpx" | `${number}px`;
905
+ readonly "--bits-floating-available-height": "undefinedpx" | `${number}px`;
906
+ readonly "--bits-floating-anchor-width": "undefinedpx" | `${number}px`;
907
+ readonly "--bits-floating-anchor-height": "undefinedpx" | `${number}px`;
908
908
  };
909
909
  readonly dir: Direction;
910
910
  };
@@ -88,7 +88,11 @@ const bodyLockStackCount = new SharedState(() => {
88
88
  ensureInitialStyleCaptured();
89
89
  // if we're applying lock styles, we're no longer in a cleanup transition
90
90
  isInCleanupTransition = false;
91
+ const htmlStyle = getComputedStyle(document.documentElement);
91
92
  const bodyStyle = getComputedStyle(document.body);
93
+ // check if scrollbar-gutter: stable is already handling scrollbar space
94
+ const hasStableGutter = htmlStyle.scrollbarGutter?.includes("stable") ||
95
+ bodyStyle.scrollbarGutter?.includes("stable");
92
96
  // TODO: account for RTL direction, etc.
93
97
  const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
94
98
  const paddingRight = Number.parseInt(bodyStyle.paddingRight ?? "0", 10);
@@ -96,12 +100,13 @@ const bodyLockStackCount = new SharedState(() => {
96
100
  padding: paddingRight + verticalScrollbarWidth,
97
101
  margin: Number.parseInt(bodyStyle.marginRight ?? "0", 10),
98
102
  };
99
- if (verticalScrollbarWidth > 0) {
103
+ // only add padding compensation if stable gutter isn't handling it
104
+ if (verticalScrollbarWidth > 0 && !hasStableGutter) {
100
105
  document.body.style.paddingRight = `${config.padding}px`;
101
106
  document.body.style.marginRight = `${config.margin}px`;
102
107
  document.body.style.setProperty("--scrollbar-width", `${verticalScrollbarWidth}px`);
103
- document.body.style.overflow = "hidden";
104
108
  }
109
+ document.body.style.overflow = "hidden";
105
110
  if (isIOS) {
106
111
  // IOS devices are special and require a touchmove listener to prevent scrolling
107
112
  stopTouchMoveListener = on(document, "touchmove", (e) => {
@@ -0,0 +1,16 @@
1
+ import { type Getter } from "svelte-toolbelt";
2
+ export interface SafePolygonOptions {
3
+ enabled: Getter<boolean>;
4
+ triggerNode: Getter<HTMLElement | null>;
5
+ contentNode: Getter<HTMLElement | null>;
6
+ onPointerExit: () => void;
7
+ buffer?: number;
8
+ }
9
+ /**
10
+ * Creates a safe polygon area that allows users to move their cursor between
11
+ * the trigger and floating content without closing it.
12
+ */
13
+ export declare class SafePolygon {
14
+ #private;
15
+ constructor(opts: SafePolygonOptions);
16
+ }
@@ -0,0 +1,237 @@
1
+ import { getDocument } from "svelte-toolbelt";
2
+ import { on } from "svelte/events";
3
+ import { watch } from "runed";
4
+ import { isElement } from "./is.js";
5
+ function isPointInPolygon(point, polygon) {
6
+ const [x, y] = point;
7
+ let isInside = false;
8
+ const length = polygon.length;
9
+ for (let i = 0, j = length - 1; i < length; j = i++) {
10
+ const [xi, yi] = polygon[i] ?? [0, 0];
11
+ const [xj, yj] = polygon[j] ?? [0, 0];
12
+ const intersect = yi >= y !== yj >= y && x <= ((xj - xi) * (y - yi)) / (yj - yi) + xi;
13
+ if (intersect) {
14
+ isInside = !isInside;
15
+ }
16
+ }
17
+ return isInside;
18
+ }
19
+ function isInsideRect(point, rect) {
20
+ return (point[0] >= rect.left &&
21
+ point[0] <= rect.right &&
22
+ point[1] >= rect.top &&
23
+ point[1] <= rect.bottom);
24
+ }
25
+ function getSide(triggerRect, contentRect) {
26
+ // determine which side the content is on relative to trigger
27
+ const triggerCenterX = triggerRect.left + triggerRect.width / 2;
28
+ const triggerCenterY = triggerRect.top + triggerRect.height / 2;
29
+ const contentCenterX = contentRect.left + contentRect.width / 2;
30
+ const contentCenterY = contentRect.top + contentRect.height / 2;
31
+ const deltaX = contentCenterX - triggerCenterX;
32
+ const deltaY = contentCenterY - triggerCenterY;
33
+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
34
+ return deltaX > 0 ? "right" : "left";
35
+ }
36
+ return deltaY > 0 ? "bottom" : "top";
37
+ }
38
+ /**
39
+ * Creates a safe polygon area that allows users to move their cursor between
40
+ * the trigger and floating content without closing it.
41
+ */
42
+ export class SafePolygon {
43
+ #opts;
44
+ #buffer;
45
+ // tracks the cursor position when leaving trigger or content
46
+ #exitPoint = null;
47
+ // tracks what we're moving toward: "content" when leaving trigger, "trigger" when leaving content
48
+ #exitTarget = null;
49
+ constructor(opts) {
50
+ this.#opts = opts;
51
+ this.#buffer = opts.buffer ?? 1;
52
+ watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
53
+ if (!triggerNode || !contentNode || !enabled) {
54
+ this.#exitPoint = null;
55
+ this.#exitTarget = null;
56
+ return;
57
+ }
58
+ const doc = getDocument(triggerNode);
59
+ const handlePointerMove = (e) => {
60
+ this.#onPointerMove(e, triggerNode, contentNode);
61
+ };
62
+ const handleTriggerLeave = (e) => {
63
+ // when leaving trigger toward content, record exit point
64
+ const target = e.relatedTarget;
65
+ // if going directly to content, no need for polygon tracking
66
+ if (isElement(target) && contentNode.contains(target)) {
67
+ return;
68
+ }
69
+ this.#exitPoint = [e.clientX, e.clientY];
70
+ this.#exitTarget = "content";
71
+ };
72
+ const handleTriggerEnter = () => {
73
+ // reached trigger, clear tracking
74
+ this.#exitPoint = null;
75
+ this.#exitTarget = null;
76
+ };
77
+ const handleContentEnter = () => {
78
+ // reached content, clear tracking
79
+ this.#exitPoint = null;
80
+ this.#exitTarget = null;
81
+ };
82
+ const handleContentLeave = (e) => {
83
+ // when leaving content, check if going directly back to trigger
84
+ const target = e.relatedTarget;
85
+ if (isElement(target) && triggerNode.contains(target)) {
86
+ // going directly to trigger, no polygon tracking needed
87
+ return;
88
+ }
89
+ // might be traversing gap back to trigger, set up polygon tracking
90
+ this.#exitPoint = [e.clientX, e.clientY];
91
+ this.#exitTarget = "trigger";
92
+ };
93
+ return [
94
+ on(doc, "pointermove", handlePointerMove),
95
+ on(triggerNode, "pointerleave", handleTriggerLeave),
96
+ on(triggerNode, "pointerenter", handleTriggerEnter),
97
+ on(contentNode, "pointerenter", handleContentEnter),
98
+ on(contentNode, "pointerleave", handleContentLeave),
99
+ ].reduce((acc, cleanup) => () => {
100
+ acc();
101
+ cleanup();
102
+ }, () => { });
103
+ });
104
+ }
105
+ #onPointerMove(e, triggerNode, contentNode) {
106
+ // if no exit point recorded, nothing to check
107
+ if (!this.#exitPoint || !this.#exitTarget)
108
+ return;
109
+ const clientPoint = [e.clientX, e.clientY];
110
+ const triggerRect = triggerNode.getBoundingClientRect();
111
+ const contentRect = contentNode.getBoundingClientRect();
112
+ // check if pointer reached the target
113
+ if (this.#exitTarget === "content" && isInsideRect(clientPoint, contentRect)) {
114
+ this.#exitPoint = null;
115
+ this.#exitTarget = null;
116
+ return;
117
+ }
118
+ if (this.#exitTarget === "trigger" && isInsideRect(clientPoint, triggerRect)) {
119
+ this.#exitPoint = null;
120
+ this.#exitTarget = null;
121
+ return;
122
+ }
123
+ // check if pointer is in the rectangular corridor between trigger and content
124
+ const side = getSide(triggerRect, contentRect);
125
+ const corridorPoly = this.#getCorridorPolygon(triggerRect, contentRect, side);
126
+ if (corridorPoly && isPointInPolygon(clientPoint, corridorPoly)) {
127
+ return;
128
+ }
129
+ // check if pointer is within the safe polygon from exit point to target
130
+ const targetRect = this.#exitTarget === "content" ? contentRect : triggerRect;
131
+ const safePoly = this.#getSafePolygon(this.#exitPoint, targetRect, side, this.#exitTarget);
132
+ if (isPointInPolygon(clientPoint, safePoly)) {
133
+ return;
134
+ }
135
+ // pointer is outside all safe zones - close
136
+ this.#exitPoint = null;
137
+ this.#exitTarget = null;
138
+ this.#opts.onPointerExit();
139
+ }
140
+ /**
141
+ * Creates a rectangular corridor between trigger and content
142
+ * This prevents closing when cursor is in the gap between them
143
+ */
144
+ #getCorridorPolygon(triggerRect, contentRect, side) {
145
+ const buffer = this.#buffer;
146
+ switch (side) {
147
+ case "top":
148
+ return [
149
+ [Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.top],
150
+ [Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.bottom],
151
+ [Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.bottom],
152
+ [Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.top],
153
+ ];
154
+ case "bottom":
155
+ return [
156
+ [Math.min(triggerRect.left, contentRect.left) - buffer, triggerRect.bottom],
157
+ [Math.min(triggerRect.left, contentRect.left) - buffer, contentRect.top],
158
+ [Math.max(triggerRect.right, contentRect.right) + buffer, contentRect.top],
159
+ [Math.max(triggerRect.right, contentRect.right) + buffer, triggerRect.bottom],
160
+ ];
161
+ case "left":
162
+ return [
163
+ [triggerRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
164
+ [contentRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
165
+ [contentRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
166
+ [triggerRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
167
+ ];
168
+ case "right":
169
+ return [
170
+ [triggerRect.right, Math.min(triggerRect.top, contentRect.top) - buffer],
171
+ [contentRect.left, Math.min(triggerRect.top, contentRect.top) - buffer],
172
+ [contentRect.left, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
173
+ [triggerRect.right, Math.max(triggerRect.bottom, contentRect.bottom) + buffer],
174
+ ];
175
+ }
176
+ }
177
+ /**
178
+ * Creates a triangular/trapezoidal safe zone from the exit point to the target
179
+ */
180
+ #getSafePolygon(exitPoint, targetRect, side, exitTarget) {
181
+ const buffer = this.#buffer * 4;
182
+ const [x, y] = exitPoint;
183
+ // when going back to trigger, we need to flip the side
184
+ const effectiveSide = exitTarget === "trigger" ? this.#flipSide(side) : side;
185
+ // create polygon points from cursor to target edges
186
+ switch (effectiveSide) {
187
+ case "top":
188
+ return [
189
+ [x - buffer, y + buffer],
190
+ [x + buffer, y + buffer],
191
+ [targetRect.right + buffer, targetRect.bottom],
192
+ [targetRect.right + buffer, targetRect.top],
193
+ [targetRect.left - buffer, targetRect.top],
194
+ [targetRect.left - buffer, targetRect.bottom],
195
+ ];
196
+ case "bottom":
197
+ return [
198
+ [x - buffer, y - buffer],
199
+ [x + buffer, y - buffer],
200
+ [targetRect.right + buffer, targetRect.top],
201
+ [targetRect.right + buffer, targetRect.bottom],
202
+ [targetRect.left - buffer, targetRect.bottom],
203
+ [targetRect.left - buffer, targetRect.top],
204
+ ];
205
+ case "left":
206
+ return [
207
+ [x + buffer, y - buffer],
208
+ [x + buffer, y + buffer],
209
+ [targetRect.right, targetRect.bottom + buffer],
210
+ [targetRect.left, targetRect.bottom + buffer],
211
+ [targetRect.left, targetRect.top - buffer],
212
+ [targetRect.right, targetRect.top - buffer],
213
+ ];
214
+ case "right":
215
+ return [
216
+ [x - buffer, y - buffer],
217
+ [x - buffer, y + buffer],
218
+ [targetRect.left, targetRect.bottom + buffer],
219
+ [targetRect.right, targetRect.bottom + buffer],
220
+ [targetRect.right, targetRect.top - buffer],
221
+ [targetRect.left, targetRect.top - buffer],
222
+ ];
223
+ }
224
+ }
225
+ #flipSide(side) {
226
+ switch (side) {
227
+ case "top":
228
+ return "bottom";
229
+ case "bottom":
230
+ return "top";
231
+ case "left":
232
+ return "right";
233
+ case "right":
234
+ return "left";
235
+ }
236
+ }
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "2.14.3",
3
+ "version": "2.15.0",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",