bits-ui 1.0.0-next.88 → 1.0.0-next.89

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.
@@ -16,6 +16,7 @@
16
16
  ref = $bindable(null),
17
17
  loop = true,
18
18
  onInteractOutside = noop,
19
+ onCloseAutoFocus = noop,
19
20
  preventScroll = true,
20
21
  // we need to explicitly pass this prop to the PopperLayer to override
21
22
  // the default menu behavior of handling outside interactions on the trigger
@@ -31,6 +32,7 @@
31
32
  () => ref,
32
33
  (v) => (ref = v)
33
34
  ),
35
+ onCloseAutoFocus: box.with(() => onCloseAutoFocus),
34
36
  });
35
37
 
36
38
  const mergedProps = $derived(mergeProps(restProps, contentState.props));
@@ -16,6 +16,7 @@
16
16
  ref = $bindable(null),
17
17
  loop = true,
18
18
  onInteractOutside = noop,
19
+ onCloseAutoFocus = noop,
19
20
  preventScroll = true,
20
21
  // we need to explicitly pass this prop to the PopperLayer to override
21
22
  // the default menu behavior of handling outside interactions on the trigger
@@ -31,6 +32,7 @@
31
32
  () => ref,
32
33
  (v) => (ref = v)
33
34
  ),
35
+ onCloseAutoFocus: box.with(() => onCloseAutoFocus),
34
36
  });
35
37
 
36
38
  const mergedProps = $derived(mergeProps(restProps, contentState.props));
@@ -17,6 +17,7 @@
17
17
  loop = true,
18
18
  onInteractOutside = noop,
19
19
  onEscapeKeydown = noop,
20
+ onCloseAutoFocus = noop,
20
21
  forceMount = false,
21
22
  ...restProps
22
23
  }: DropdownMenuContentStaticProps = $props();
@@ -28,6 +29,7 @@
28
29
  () => ref,
29
30
  (v) => (ref = v)
30
31
  ),
32
+ onCloseAutoFocus: box.with(() => onCloseAutoFocus),
31
33
  });
32
34
 
33
35
  const mergedProps = $derived(mergeProps(restProps, contentState.props));
@@ -17,6 +17,7 @@
17
17
  loop = true,
18
18
  onInteractOutside = noop,
19
19
  onEscapeKeydown = noop,
20
+ onCloseAutoFocus = noop,
20
21
  forceMount = false,
21
22
  ...restProps
22
23
  }: DropdownMenuContentProps = $props();
@@ -28,6 +29,7 @@
28
29
  () => ref,
29
30
  (v) => (ref = v)
30
31
  ),
32
+ onCloseAutoFocus: box.with(() => onCloseAutoFocus),
31
33
  });
32
34
 
33
35
  const mergedProps = $derived(mergeProps(restProps, contentState.props));
@@ -15,7 +15,6 @@ declare class LinkPreviewRootState {
15
15
  contentNode: HTMLElement | null;
16
16
  contentMounted: boolean;
17
17
  triggerNode: HTMLElement | null;
18
- isPointerInTransit: import("svelte-toolbelt").WritableBox<boolean>;
19
18
  isOpening: boolean;
20
19
  constructor(opts: LinkPreviewRootStateProps);
21
20
  clearTimeout(): void;
@@ -1,4 +1,4 @@
1
- import { afterSleep, box, onDestroyEffect, useRefById } from "svelte-toolbelt";
1
+ import { afterSleep, onDestroyEffect, useRefById } from "svelte-toolbelt";
2
2
  import { Context, watch } from "runed";
3
3
  import { on } from "svelte/events";
4
4
  import { getAriaExpanded, getDataOpenClosed } from "../../internal/attrs.js";
@@ -16,7 +16,6 @@ class LinkPreviewRootState {
16
16
  contentNode = $state(null);
17
17
  contentMounted = $state(false);
18
18
  triggerNode = $state(null);
19
- isPointerInTransit = box(false);
20
19
  isOpening = false;
21
20
  constructor(opts) {
22
21
  this.opts = opts;
@@ -152,14 +151,13 @@ class LinkPreviewContentState {
152
151
  },
153
152
  deps: () => this.root.opts.open.current,
154
153
  });
155
- watch(() => this.root.opts.open.current, (isOpen) => {
156
- if (!isOpen)
157
- return;
158
- const { isPointerInTransit, onPointerExit } = useGraceArea(() => this.root.triggerNode, () => this.opts.ref.current);
159
- this.root.isPointerInTransit = isPointerInTransit;
160
- onPointerExit(() => {
154
+ useGraceArea({
155
+ triggerNode: () => this.root.triggerNode,
156
+ contentNode: () => this.opts.ref.current,
157
+ enabled: () => this.root.opts.open.current,
158
+ onPointerExit: () => {
161
159
  this.root.handleClose();
162
- });
160
+ },
163
161
  });
164
162
  onDestroyEffect(() => {
165
163
  this.root.clearTimeout();
@@ -17,6 +17,7 @@
17
17
  loop = true,
18
18
  onInteractOutside = noop,
19
19
  onEscapeKeydown = noop,
20
+ onCloseAutoFocus: onCloseAutoFocusProp = noop,
20
21
  forceMount = false,
21
22
  ...restProps
22
23
  }: MenuContentStaticProps = $props();
@@ -28,6 +29,7 @@
28
29
  () => ref,
29
30
  (v) => (ref = v)
30
31
  ),
32
+ onCloseAutoFocus: box.with(() => onCloseAutoFocusProp),
31
33
  });
32
34
 
33
35
  const mergedProps = $derived(
@@ -17,6 +17,7 @@
17
17
  loop = true,
18
18
  onInteractOutside = noop,
19
19
  onEscapeKeydown = noop,
20
+ onCloseAutoFocus: onCloseAutoFocusProp = noop,
20
21
  forceMount = false,
21
22
  ...restProps
22
23
  }: MenuContentProps = $props();
@@ -28,6 +29,7 @@
28
29
  () => ref,
29
30
  (v) => (ref = v)
30
31
  ),
32
+ onCloseAutoFocus: box.with(() => onCloseAutoFocusProp),
31
33
  });
32
34
 
33
35
  const mergedProps = $derived(
@@ -35,6 +35,8 @@
35
35
  () => ref,
36
36
  (v) => (ref = v)
37
37
  ),
38
+ onCloseAutoFocus: box.with(() => handleCloseAutoFocus),
39
+ isSub: true,
38
40
  });
39
41
 
40
42
  function onkeydown(e: KeyboardEvent) {
@@ -106,7 +108,6 @@
106
108
  {...mergedProps}
107
109
  {interactOutsideBehavior}
108
110
  {escapeKeydownBehavior}
109
- onCloseAutoFocus={handleCloseAutoFocus}
110
111
  onOpenAutoFocus={handleOpenAutoFocus}
111
112
  enabled={subContentState.parentMenu.opts.open.current}
112
113
  onInteractOutside={handleInteractOutside}
@@ -36,6 +36,8 @@
36
36
  () => ref,
37
37
  (v) => (ref = v)
38
38
  ),
39
+ isSub: true,
40
+ onCloseAutoFocus: box.with(() => handleCloseAutoFocus),
39
41
  });
40
42
 
41
43
  function onkeydown(e: KeyboardEvent) {
@@ -108,7 +110,6 @@
108
110
  {...mergedProps}
109
111
  {interactOutsideBehavior}
110
112
  {escapeKeydownBehavior}
111
- onCloseAutoFocus={handleCloseAutoFocus}
112
113
  onOpenAutoFocus={handleOpenAutoFocus}
113
114
  enabled={subContentState.parentMenu.opts.open.current}
114
115
  onInteractOutside={handleInteractOutside}
@@ -1,5 +1,3 @@
1
- import { IsFocusWithin } from "runed";
2
- import { type GraceIntent } from "./utils.js";
3
1
  import type { ReadableBoxedValues, WritableBoxedValues } from "../../internal/box.svelte.js";
4
2
  import { CustomEventDispatcher } from "../../internal/events.js";
5
3
  import type { AnyFn, BitsFocusEvent, BitsKeyboardEvent, BitsMouseEvent, BitsPointerEvent, WithRefProps } from "../../internal/types.js";
@@ -19,6 +17,7 @@ declare class MenuRootState {
19
17
  readonly opts: MenuRootStateProps;
20
18
  isUsingKeyboard: IsUsingKeyboard;
21
19
  ignoreCloseAutoFocus: boolean;
20
+ isPointerInTransit: boolean;
22
21
  constructor(opts: MenuRootStateProps);
23
22
  getAttr(name: string): string;
24
23
  }
@@ -39,26 +38,26 @@ declare class MenuMenuState {
39
38
  }
40
39
  type MenuContentStateProps = WithRefProps & ReadableBoxedValues<{
41
40
  loop: boolean;
42
- }>;
41
+ onCloseAutoFocus: (event: Event) => void;
42
+ }> & {
43
+ isSub?: boolean;
44
+ };
43
45
  declare class MenuContentState {
44
46
  #private;
45
47
  readonly opts: MenuContentStateProps;
46
48
  readonly parentMenu: MenuMenuState;
47
49
  search: string;
48
- pointerGraceTimer: number;
49
50
  rovingFocusGroup: ReturnType<typeof useRovingFocus>;
50
51
  mounted: boolean;
51
- isFocusWithin: IsFocusWithin;
52
52
  constructor(opts: MenuContentStateProps, parentMenu: MenuMenuState);
53
- onPointerGraceIntentChange(intent: GraceIntent | null): void;
53
+ onCloseAutoFocus: (e: Event) => void;
54
54
  handleTabKeyDown(e: BitsKeyboardEvent): void;
55
55
  onkeydown(e: BitsKeyboardEvent): void;
56
56
  onblur(e: BitsFocusEvent): void;
57
57
  onfocus(_: BitsFocusEvent): void;
58
- onpointermove(e: BitsPointerEvent): void;
59
- onItemEnter(e: BitsPointerEvent): boolean;
58
+ onItemEnter(): boolean;
60
59
  onItemLeave(e: BitsPointerEvent): void;
61
- onTriggerLeave(e: BitsPointerEvent): boolean;
60
+ onTriggerLeave(): boolean;
62
61
  onOpenAutoFocus: (e: Event) => void;
63
62
  handleInteractOutside(e: PointerEvent): void;
64
63
  snippetProps: {
@@ -74,8 +73,8 @@ declare class MenuContentState {
74
73
  readonly "data-state": "open" | "closed";
75
74
  readonly onkeydown: (e: BitsKeyboardEvent) => void;
76
75
  readonly onblur: (e: BitsFocusEvent) => void;
77
- readonly onpointermove: (e: BitsPointerEvent) => void;
78
76
  readonly onfocus: (_: BitsFocusEvent) => void;
77
+ readonly onCloseAutoFocus: (e: Event) => void;
79
78
  readonly dir: Direction;
80
79
  readonly style: {
81
80
  readonly pointerEvents: "auto";
@@ -1,5 +1,5 @@
1
1
  import { afterTick, box, mergeProps, onDestroyEffect, useRefById } from "svelte-toolbelt";
2
- import { Context, IsFocusWithin, watch } from "runed";
2
+ import { Context, watch } from "runed";
3
3
  import { FIRST_LAST_KEYS, LAST_KEYS, SELECTION_KEYS, SUB_OPEN_KEYS, getCheckedState, isMouseEvent, } from "./utils.js";
4
4
  import { focusFirst } from "../../internal/focus.js";
5
5
  import { CustomEventDispatcher } from "../../internal/events.js";
@@ -8,10 +8,11 @@ import { isElement, isElementOrSVGElement, isHTMLElement } from "../../internal/
8
8
  import { useRovingFocus } from "../../internal/use-roving-focus.svelte.js";
9
9
  import { kbd } from "../../internal/kbd.js";
10
10
  import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaOrientation, getDataDisabled, getDataOpenClosed, } from "../../internal/attrs.js";
11
- import { isPointerInGraceArea, makeHullFromElements } from "../../internal/polygon.js";
12
11
  import { IsUsingKeyboard } from "../../index.js";
12
+ import { useGraceArea } from "../../internal/use-grace-area.svelte.js";
13
13
  import { getTabbableFrom } from "../../internal/tabbable.js";
14
14
  import { FocusScopeContext } from "../utilities/focus-scope/use-focus-scope.svelte.js";
15
+ import { isTabbable } from "tabbable";
15
16
  export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger";
16
17
  const MenuRootContext = new Context("Menu.Root");
17
18
  const MenuMenuContext = new Context("Menu.Root | Menu.Sub");
@@ -26,6 +27,7 @@ class MenuRootState {
26
27
  opts;
27
28
  isUsingKeyboard = new IsUsingKeyboard();
28
29
  ignoreCloseAutoFocus = $state(false);
30
+ isPointerInTransit = $state(false);
29
31
  constructor(opts) {
30
32
  this.opts = opts;
31
33
  }
@@ -45,8 +47,8 @@ class MenuMenuState {
45
47
  this.root = root;
46
48
  this.parentMenu = parentMenu;
47
49
  if (parentMenu) {
48
- watch(() => parentMenu.opts.open, (isOpen) => {
49
- if (isOpen)
50
+ watch(() => parentMenu.opts.open.current, () => {
51
+ if (parentMenu.opts.open.current)
50
52
  return;
51
53
  this.opts.open.current = false;
52
54
  });
@@ -66,23 +68,18 @@ class MenuContentState {
66
68
  opts;
67
69
  parentMenu;
68
70
  search = $state("");
69
- #timer = $state(0);
70
- pointerGraceTimer = $state(0);
71
- #pointerGraceIntent = $state(null);
72
- #pointerDir = $state("right");
73
- #lastPointerX = $state(0);
71
+ #timer = 0;
74
72
  #handleTypeaheadSearch;
75
73
  rovingFocusGroup;
76
74
  mounted = $state(false);
77
- isFocusWithin = new IsFocusWithin(() => this.parentMenu.contentNode ?? undefined);
75
+ #isSub;
78
76
  constructor(opts, parentMenu) {
79
77
  this.opts = opts;
80
78
  this.parentMenu = parentMenu;
81
- this.parentMenu = parentMenu;
82
- this.parentMenu.contentId = opts.id;
79
+ parentMenu.contentId = opts.id;
80
+ this.#isSub = opts.isSub ?? false;
83
81
  this.onkeydown = this.onkeydown.bind(this);
84
82
  this.onblur = this.onblur.bind(this);
85
- this.onpointermove = this.onpointermove.bind(this);
86
83
  this.onfocus = this.onfocus.bind(this);
87
84
  this.handleInteractOutside = this.handleInteractOutside.bind(this);
88
85
  useRefById({
@@ -94,8 +91,17 @@ class MenuContentState {
94
91
  }
95
92
  },
96
93
  });
97
- onDestroyEffect(() => {
98
- window.clearTimeout(this.#timer);
94
+ useGraceArea({
95
+ contentNode: () => this.parentMenu.contentNode,
96
+ triggerNode: () => this.parentMenu.triggerNode,
97
+ enabled: () => this.parentMenu.opts.open.current &&
98
+ Boolean(this.parentMenu.triggerNode?.hasAttribute(this.parentMenu.root.getAttr("sub-trigger"))),
99
+ onPointerExit: () => {
100
+ this.parentMenu.opts.open.current = false;
101
+ },
102
+ setIsPointerInTransit: (value) => {
103
+ this.parentMenu.root.isPointerInTransit = value;
104
+ },
99
105
  });
100
106
  this.#handleTypeaheadSearch = useDOMTypeahead().handleTypeaheadSearch;
101
107
  this.rovingFocusGroup = useRovingFocus({
@@ -116,6 +122,11 @@ class MenuContentState {
116
122
  };
117
123
  return MenuOpenEvent.listen(contentNode, handler);
118
124
  });
125
+ $effect(() => {
126
+ if (!this.parentMenu.opts.open.current) {
127
+ window.clearTimeout(this.#timer);
128
+ }
129
+ });
119
130
  }
120
131
  #getCandidateNodes() {
121
132
  const node = this.parentMenu.contentNode;
@@ -124,13 +135,17 @@ class MenuContentState {
124
135
  const candidates = Array.from(node.querySelectorAll(`[${this.parentMenu.root.getAttr("item")}]:not([data-disabled])`));
125
136
  return candidates;
126
137
  }
127
- #isPointerMovingToSubmenu(e) {
128
- const isMovingTowards = this.#pointerDir === this.#pointerGraceIntent?.side;
129
- return isMovingTowards && isPointerInGraceArea(e, this.#pointerGraceIntent?.area);
130
- }
131
- onPointerGraceIntentChange(intent) {
132
- this.#pointerGraceIntent = intent;
138
+ #isPointerMovingToSubmenu() {
139
+ return this.parentMenu.root.isPointerInTransit;
133
140
  }
141
+ onCloseAutoFocus = (e) => {
142
+ this.opts.onCloseAutoFocus.current(e);
143
+ if (e.defaultPrevented || this.#isSub)
144
+ return;
145
+ if (this.parentMenu.triggerNode && isTabbable(this.parentMenu.triggerNode)) {
146
+ this.parentMenu.triggerNode.focus();
147
+ }
148
+ };
134
149
  handleTabKeyDown(e) {
135
150
  /**
136
151
  * We locate the root `menu`'s trigger by going up the tree until
@@ -220,38 +235,20 @@ class MenuContentState {
220
235
  return;
221
236
  afterTick(() => this.rovingFocusGroup.focusFirstCandidate());
222
237
  }
223
- onpointermove(e) {
224
- if (!isMouseEvent(e))
225
- return;
226
- const target = e.target;
227
- if (!isElement(target))
228
- return;
229
- const pointerXHasChanged = this.#lastPointerX !== e.clientX;
230
- const currentTarget = e.currentTarget;
231
- if (!isElement(currentTarget))
232
- return;
233
- // We don't use `event.movementX` for this check because Safari will
234
- // always return `0` on a pointer event.
235
- if (currentTarget.contains(target) && pointerXHasChanged) {
236
- const newDir = e.clientX > this.#lastPointerX ? "right" : "left";
237
- this.#pointerDir = newDir;
238
- this.#lastPointerX = e.clientX;
239
- }
240
- }
241
- onItemEnter(e) {
242
- if (this.#isPointerMovingToSubmenu(e))
243
- return true;
244
- return false;
238
+ onItemEnter() {
239
+ return this.#isPointerMovingToSubmenu();
245
240
  }
246
241
  onItemLeave(e) {
247
- if (this.#isPointerMovingToSubmenu(e))
242
+ if (e.currentTarget.hasAttribute(this.parentMenu.root.getAttr("sub-trigger")))
243
+ return;
244
+ if (this.#isPointerMovingToSubmenu() || this.parentMenu.root.isUsingKeyboard.current)
248
245
  return;
249
246
  const contentNode = this.parentMenu.contentNode;
250
247
  contentNode?.focus();
251
248
  this.rovingFocusGroup.setCurrentTabStopId("");
252
249
  }
253
- onTriggerLeave(e) {
254
- if (this.#isPointerMovingToSubmenu(e))
250
+ onTriggerLeave() {
251
+ if (this.#isPointerMovingToSubmenu())
255
252
  return true;
256
253
  return false;
257
254
  }
@@ -283,8 +280,8 @@ class MenuContentState {
283
280
  "data-state": getDataOpenClosed(this.parentMenu.opts.open.current),
284
281
  onkeydown: this.onkeydown,
285
282
  onblur: this.onblur,
286
- onpointermove: this.onpointermove,
287
283
  onfocus: this.onfocus,
284
+ onCloseAutoFocus: (e) => this.onCloseAutoFocus(e),
288
285
  dir: this.parentMenu.root.opts.dir.current,
289
286
  style: {
290
287
  pointerEvents: "auto",
@@ -316,7 +313,7 @@ class MenuItemSharedState {
316
313
  this.content.onItemLeave(e);
317
314
  }
318
315
  else {
319
- const defaultPrevented = this.content.onItemEnter(e);
316
+ const defaultPrevented = this.content.onItemEnter();
320
317
  if (defaultPrevented)
321
318
  return;
322
319
  const item = e.currentTarget;
@@ -326,13 +323,11 @@ class MenuItemSharedState {
326
323
  }
327
324
  }
328
325
  onpointerleave(e) {
329
- afterTick(() => {
330
- if (e.defaultPrevented)
331
- return;
332
- if (!isMouseEvent(e))
333
- return;
334
- this.content.onItemLeave(e);
335
- });
326
+ if (e.defaultPrevented)
327
+ return;
328
+ if (!isMouseEvent(e))
329
+ return;
330
+ this.content.onItemLeave(e);
336
331
  }
337
332
  onfocus(e) {
338
333
  afterTick(() => {
@@ -366,7 +361,7 @@ class MenuItemSharedState {
366
361
  class MenuItemState {
367
362
  opts;
368
363
  item;
369
- #isPointerDown = $state(false);
364
+ #isPointerDown = false;
370
365
  root;
371
366
  constructor(opts, item) {
372
367
  this.opts = opts;
@@ -450,7 +445,7 @@ class MenuSubTriggerState {
450
445
  this.#clearOpenTimer();
451
446
  });
452
447
  useRefById({
453
- ...this.item.opts,
448
+ ...item.opts,
454
449
  onRefChange: (node) => {
455
450
  this.submenu.triggerNode = node;
456
451
  },
@@ -465,13 +460,9 @@ class MenuSubTriggerState {
465
460
  onpointermove(e) {
466
461
  if (!isMouseEvent(e))
467
462
  return;
468
- const defaultPrevented = this.content.onItemEnter(e);
469
- if (defaultPrevented)
470
- return;
471
463
  if (!this.item.opts.disabled.current &&
472
464
  !this.submenu.opts.open.current &&
473
465
  !this.#openTimer) {
474
- this.content.onPointerGraceIntentChange(null);
475
466
  this.#openTimer = window.setTimeout(() => {
476
467
  this.submenu.onOpen();
477
468
  this.#clearOpenTimer();
@@ -482,25 +473,6 @@ class MenuSubTriggerState {
482
473
  if (!isMouseEvent(e))
483
474
  return;
484
475
  this.#clearOpenTimer();
485
- const contentNode = this.submenu.contentNode;
486
- const subTriggerNode = this.item.opts.ref.current;
487
- if (contentNode && subTriggerNode) {
488
- const polygon = makeHullFromElements([subTriggerNode, contentNode]);
489
- const side = contentNode?.dataset.side;
490
- this.content.onPointerGraceIntentChange({
491
- area: polygon,
492
- side,
493
- });
494
- window.clearTimeout(this.content.pointerGraceTimer);
495
- this.content.pointerGraceTimer = window.setTimeout(() => this.content.onPointerGraceIntentChange(null), 300);
496
- }
497
- else {
498
- const defaultPrevented = this.content.onTriggerLeave(e);
499
- if (defaultPrevented)
500
- return;
501
- // There's 100ms where the user may leave an item before the submenu was opened.
502
- this.content.onPointerGraceIntentChange(null);
503
- }
504
476
  }
505
477
  onkeydown(e) {
506
478
  const isTypingAhead = this.content.search !== "";
@@ -753,7 +725,7 @@ class ContextMenuTriggerState {
753
725
  virtualElement = box({
754
726
  getBoundingClientRect: () => DOMRect.fromRect({ width: 0, height: 0, ...this.#point }),
755
727
  });
756
- #longPressTimer = $state(null);
728
+ #longPressTimer = null;
757
729
  constructor(opts, parentMenu) {
758
730
  this.opts = opts;
759
731
  this.parentMenu = parentMenu;
@@ -1,6 +1,5 @@
1
1
  import type { Direction } from "../../shared/index.js";
2
2
  export type CheckedState = boolean | "indeterminate";
3
- export declare const ITEM_NAME = "MenuItem";
4
3
  export declare const SELECTION_KEYS: string[];
5
4
  export declare const FIRST_KEYS: string[];
6
5
  export declare const LAST_KEYS: string[];
@@ -9,14 +8,4 @@ export declare const SUB_OPEN_KEYS: Record<Direction, string[]>;
9
8
  export declare const SUB_CLOSE_KEYS: Record<Direction, string[]>;
10
9
  export declare function isIndeterminate(checked?: CheckedState): checked is "indeterminate";
11
10
  export declare function getCheckedState(checked: CheckedState): "checked" | "unchecked" | "indeterminate";
12
- export interface Point {
13
- x: number;
14
- y: number;
15
- }
16
- export type Polygon = Point[];
17
- export type Side = "left" | "right";
18
- export interface GraceIntent {
19
- area: Polygon;
20
- side: Side;
21
- }
22
11
  export declare function isMouseEvent(event: PointerEvent): boolean;
@@ -1,5 +1,4 @@
1
1
  import { kbd } from "../../internal/kbd.js";
2
- export const ITEM_NAME = "MenuItem";
3
2
  export const SELECTION_KEYS = [kbd.ENTER, kbd.SPACE];
4
3
  export const FIRST_KEYS = [kbd.ARROW_DOWN, kbd.PAGE_UP, kbd.HOME];
5
4
  export const LAST_KEYS = [kbd.ARROW_UP, kbd.PAGE_DOWN, kbd.END];
@@ -204,14 +204,16 @@ class TooltipContentState {
204
204
  },
205
205
  deps: () => this.root.opts.open.current,
206
206
  });
207
- $effect(() => {
208
- if (!this.root.opts.open.current || this.root.disableHoverableContent)
209
- return;
210
- const { isPointerInTransit, onPointerExit } = useGraceArea(() => this.root.triggerNode, () => this.root.contentNode);
211
- this.root.provider.isPointerInTransit = isPointerInTransit;
212
- onPointerExit(() => {
207
+ useGraceArea({
208
+ triggerNode: () => this.root.triggerNode,
209
+ contentNode: () => this.root.contentNode,
210
+ enabled: () => this.root.opts.open.current && this.root.disableHoverableContent,
211
+ onPointerExit: () => {
213
212
  this.root.handleClose();
214
- });
213
+ },
214
+ setIsPointerInTransit: (value) => {
215
+ this.root.provider.isPointerInTransit.current = value;
216
+ },
215
217
  });
216
218
  onMountEffect(() => executeCallbacks(on(window, "scroll", (e) => {
217
219
  const target = e.target;
@@ -6,6 +6,7 @@ import { focus, focusFirst, getTabbableCandidates, getTabbableEdges } from "../.
6
6
  import { CustomEventDispatcher } from "../../../internal/events.js";
7
7
  import { isHTMLElement } from "../../../internal/is.js";
8
8
  import { kbd } from "../../../internal/kbd.js";
9
+ import { isTabbable } from "tabbable";
9
10
  const AutoFocusOnMountEvent = new CustomEventDispatcher("focusScope.autoFocusOnMount", {
10
11
  bubbles: false,
11
12
  cancelable: true,
@@ -140,7 +141,9 @@ export function useFocusScope({ id, loop, enabled, onOpenAutoFocus, onCloseAutoF
140
141
  const shouldIgnore = ctx.ignoreCloseAutoFocus;
141
142
  afterSleep(0, () => {
142
143
  if (!destroyEvent.defaultPrevented && prevFocusedElement && !shouldIgnore) {
143
- focus(prevFocusedElement ?? document.body, { select: true });
144
+ focus(isTabbable(prevFocusedElement) ? prevFocusedElement : document.body, {
145
+ select: true,
146
+ });
144
147
  }
145
148
  focusScopeStack.remove(focusScope);
146
149
  });
@@ -5,4 +5,4 @@ import { type WritableBox } from "svelte-toolbelt";
5
5
  * @param defaultValue The value which will be set.
6
6
  * @param afterMs A zero-or-greater delay in milliseconds.
7
7
  */
8
- export declare function boxAutoReset<T>(defaultValue: T, afterMs?: number): WritableBox<T>;
8
+ export declare function boxAutoReset<T>(defaultValue: T, afterMs?: number, onChange?: (value: T) => void): WritableBox<T>;
@@ -1,16 +1,18 @@
1
1
  import { box } from "svelte-toolbelt";
2
+ import { noop } from "./noop.js";
2
3
  /**
3
4
  * Creates a box which will be reset to the default value after some time.
4
5
  *
5
6
  * @param defaultValue The value which will be set.
6
7
  * @param afterMs A zero-or-greater delay in milliseconds.
7
8
  */
8
- export function boxAutoReset(defaultValue, afterMs = 10000) {
9
+ export function boxAutoReset(defaultValue, afterMs = 10000, onChange = noop) {
9
10
  let timeout = null;
10
11
  let value = $state(defaultValue);
11
12
  function resetAfter() {
12
- return setTimeout(() => {
13
+ return window.setTimeout(() => {
13
14
  value = defaultValue;
15
+ onChange(defaultValue);
14
16
  }, afterMs);
15
17
  }
16
18
  $effect(() => {
@@ -21,6 +23,7 @@ export function boxAutoReset(defaultValue, afterMs = 10000) {
21
23
  });
22
24
  return box.with(() => value, (v) => {
23
25
  value = v;
26
+ onChange(v);
24
27
  if (timeout)
25
28
  clearTimeout(timeout);
26
29
  timeout = resetAfter();
@@ -0,0 +1,5 @@
1
+ import type { Polygon } from "../polygon.js";
2
+ /**
3
+ * Debugging utility to visualize the grace area of a floating layer component.
4
+ */
5
+ export declare function visualizeGraceArea(graceArea: Polygon): void;
@@ -0,0 +1,28 @@
1
+ let graceAreaElement = null;
2
+ let svgContainer = null;
3
+ /**
4
+ * Debugging utility to visualize the grace area of a floating layer component.
5
+ */
6
+ export function visualizeGraceArea(graceArea) {
7
+ if (graceAreaElement) {
8
+ graceAreaElement.remove();
9
+ }
10
+ if (!svgContainer) {
11
+ svgContainer = document.createElementNS("http://www.w3.org/2000/svg", "svg");
12
+ svgContainer.style.position = "absolute";
13
+ svgContainer.style.top = "0";
14
+ svgContainer.style.left = "0";
15
+ svgContainer.style.width = "100%";
16
+ svgContainer.style.height = "100%";
17
+ svgContainer.style.pointerEvents = "none";
18
+ document.body.appendChild(svgContainer);
19
+ }
20
+ graceAreaElement = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
21
+ const pointsString = graceArea.map((p) => `${p.x},${p.y}`).join(" ");
22
+ graceAreaElement.setAttribute("points", pointsString);
23
+ graceAreaElement.setAttribute("fill", "rgba(255, 0, 0, 0.3)");
24
+ graceAreaElement.setAttribute("stroke", "red");
25
+ graceAreaElement.setAttribute("stroke-width", "1");
26
+ graceAreaElement.style.pointerEvents = "none";
27
+ svgContainer.appendChild(graceAreaElement);
28
+ }
@@ -7,3 +7,4 @@ export declare function getFirstNonCommentChild(element: HTMLElement | null): Ch
7
7
  * into the DOM but visually appear inside the content.
8
8
  */
9
9
  export declare function isClickTrulyOutside(event: PointerEvent, contentNode: HTMLElement): boolean;
10
+ export declare function getTarget(event: Event): EventTarget | null | undefined;
@@ -28,3 +28,11 @@ export function isClickTrulyOutside(event, contentNode) {
28
28
  const rect = contentNode.getBoundingClientRect();
29
29
  return (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom);
30
30
  }
31
+ export function getTarget(event) {
32
+ if ("composedPath" in event) {
33
+ return event.composedPath()[0];
34
+ }
35
+ // TS thinks `event` is of type never as it assumes all browsers support
36
+ // `composedPath()`, but browsers without shadow DOM don't.
37
+ return event.target;
38
+ }
@@ -1,5 +1,12 @@
1
1
  import { type Getter } from "svelte-toolbelt";
2
- export declare function useGraceArea(getTriggerNode: Getter<HTMLElement | null>, getContentNode: Getter<HTMLElement | null>): {
2
+ interface UseGraceAreaOpts {
3
+ enabled: Getter<boolean>;
4
+ triggerNode: Getter<HTMLElement | null>;
5
+ contentNode: Getter<HTMLElement | null>;
6
+ onPointerExit: () => void;
7
+ setIsPointerInTransit?: (value: boolean) => void;
8
+ }
9
+ export declare function useGraceArea(opts: UseGraceAreaOpts): {
3
10
  isPointerInTransit: import("svelte-toolbelt").WritableBox<boolean>;
4
- onPointerExit: import("./create-event-hook.svelte.js").EventHookOn<void>;
5
11
  };
12
+ export {};
@@ -2,14 +2,15 @@ import { executeCallbacks } from "svelte-toolbelt";
2
2
  import { on } from "svelte/events";
3
3
  import { watch } from "runed";
4
4
  import { boxAutoReset } from "./box-auto-reset.svelte.js";
5
- import { createEventHook } from "./create-event-hook.svelte.js";
6
5
  import { isElement, isHTMLElement } from "./is.js";
7
- export function useGraceArea(getTriggerNode, getContentNode) {
8
- const isPointerInTransit = boxAutoReset(false, 300);
9
- const triggerNode = $derived(getTriggerNode());
10
- const contentNode = $derived(getContentNode());
6
+ export function useGraceArea(opts) {
7
+ const enabled = $derived(opts.enabled());
8
+ const isPointerInTransit = boxAutoReset(false, 300, (value) => {
9
+ if (enabled) {
10
+ opts.setIsPointerInTransit?.(value);
11
+ }
12
+ });
11
13
  let pointerGraceArea = $state(null);
12
- const pointerExit = createEventHook();
13
14
  function handleRemoveGraceArea() {
14
15
  pointerGraceArea = null;
15
16
  isPointerInTransit.current = false;
@@ -26,8 +27,8 @@ export function useGraceArea(getTriggerNode, getContentNode) {
26
27
  pointerGraceArea = graceArea;
27
28
  isPointerInTransit.current = true;
28
29
  }
29
- watch([() => triggerNode, () => contentNode], ([triggerNode, contentNode]) => {
30
- if (!triggerNode || !contentNode)
30
+ watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
31
+ if (!triggerNode || !contentNode || !enabled)
31
32
  return;
32
33
  const handleTriggerLeave = (e) => {
33
34
  handleCreateGraceArea(e, contentNode);
@@ -37,7 +38,7 @@ export function useGraceArea(getTriggerNode, getContentNode) {
37
38
  };
38
39
  return executeCallbacks(on(triggerNode, "pointerleave", handleTriggerLeave), on(contentNode, "pointerleave", handleContentLeave));
39
40
  });
40
- watch(() => pointerGraceArea, (pointerGraceArea) => {
41
+ watch(() => pointerGraceArea, () => {
41
42
  const handleTrackPointerGrace = (e) => {
42
43
  if (!pointerGraceArea)
43
44
  return;
@@ -45,21 +46,20 @@ export function useGraceArea(getTriggerNode, getContentNode) {
45
46
  if (!isElement(target))
46
47
  return;
47
48
  const pointerPosition = { x: e.clientX, y: e.clientY };
48
- const hasEnteredTarget = triggerNode?.contains(target) || contentNode?.contains(target);
49
+ const hasEnteredTarget = opts.triggerNode()?.contains(target) || opts.contentNode()?.contains(target);
49
50
  const isPointerOutsideGraceArea = !isPointInPolygon(pointerPosition, pointerGraceArea);
50
51
  if (hasEnteredTarget) {
51
52
  handleRemoveGraceArea();
52
53
  }
53
54
  else if (isPointerOutsideGraceArea) {
54
55
  handleRemoveGraceArea();
55
- pointerExit.trigger();
56
+ opts.onPointerExit();
56
57
  }
57
58
  };
58
59
  return on(document, "pointermove", handleTrackPointerGrace);
59
60
  });
60
61
  return {
61
62
  isPointerInTransit,
62
- onPointerExit: pointerExit.on,
63
63
  };
64
64
  }
65
65
  function getExitSideFromRect(point, rect) {
@@ -81,22 +81,35 @@ function getExitSideFromRect(point, rect) {
81
81
  }
82
82
  }
83
83
  function getPaddedExitPoints(exitPoint, exitSide, padding = 5) {
84
- const paddedExitPoints = [];
84
+ // we extend the tip of the exit point to make it easier to navigate without
85
+ // a minor jitter triggering a pointer exit
86
+ const tipPadding = padding * 1.5;
85
87
  switch (exitSide) {
86
88
  case "top":
87
- paddedExitPoints.push({ x: exitPoint.x - padding, y: exitPoint.y + padding }, { x: exitPoint.x + padding, y: exitPoint.y + padding });
88
- break;
89
+ return [
90
+ { x: exitPoint.x - padding, y: exitPoint.y + padding },
91
+ { x: exitPoint.x, y: exitPoint.y - tipPadding },
92
+ { x: exitPoint.x + padding, y: exitPoint.y + padding },
93
+ ];
89
94
  case "bottom":
90
- paddedExitPoints.push({ x: exitPoint.x - padding, y: exitPoint.y - padding }, { x: exitPoint.x + padding, y: exitPoint.y - padding });
91
- break;
95
+ return [
96
+ { x: exitPoint.x - padding, y: exitPoint.y - padding },
97
+ { x: exitPoint.x, y: exitPoint.y + tipPadding },
98
+ { x: exitPoint.x + padding, y: exitPoint.y - padding },
99
+ ];
92
100
  case "left":
93
- paddedExitPoints.push({ x: exitPoint.x + padding, y: exitPoint.y - padding }, { x: exitPoint.x + padding, y: exitPoint.y + padding });
94
- break;
101
+ return [
102
+ { x: exitPoint.x + padding, y: exitPoint.y - padding },
103
+ { x: exitPoint.x - tipPadding, y: exitPoint.y },
104
+ { x: exitPoint.x + padding, y: exitPoint.y + padding },
105
+ ];
95
106
  case "right":
96
- paddedExitPoints.push({ x: exitPoint.x - padding, y: exitPoint.y - padding }, { x: exitPoint.x - padding, y: exitPoint.y + padding });
97
- break;
107
+ return [
108
+ { x: exitPoint.x - padding, y: exitPoint.y - padding },
109
+ { x: exitPoint.x + tipPadding, y: exitPoint.y },
110
+ { x: exitPoint.x - padding, y: exitPoint.y + padding },
111
+ ];
98
112
  }
99
- return paddedExitPoints;
100
113
  }
101
114
  function getPointsFromRect(rect) {
102
115
  const { top, right, bottom, left } = rect;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "1.0.0-next.88",
3
+ "version": "1.0.0-next.89",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",