Skip to content

Commit ce4f44e

Browse files
authored
Merge branch 'huntabyte:main' into select-align-item
2 parents 9bd2cc2 + 25f8137 commit ce4f44e

7 files changed

Lines changed: 131 additions & 14 deletions

File tree

packages/bits-ui/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# bits-ui
22

3+
## 2.18.1
4+
5+
### Patch Changes
6+
7+
- fix(text-selection-layer): snapshot enabled and pointer handlers for listeners ([#2041](https://github.com/huntabyte/bits-ui/pull/2041))
8+
9+
- fix(Tooltip): set wrapper pointer-events when hoverable content is disabled ([#2041](https://github.com/huntabyte/bits-ui/pull/2041))
10+
11+
- fix(Menu): prevent page scroll-jump on item hover when `scroll-padding` is set ([#2035](https://github.com/huntabyte/bits-ui/pull/2035))
12+
313
## 2.18.0
414

515
### Minor Changes

packages/bits-ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bits-ui",
3-
"version": "2.18.0",
3+
"version": "2.18.1",
44
"license": "MIT",
55
"repository": "github:huntabyte/bits-ui",
66
"funding": "https://github.com/sponsors/huntabyte",

packages/bits-ui/src/lib/bits/menu/menu.svelte.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,7 +1117,7 @@ export class MenuContentState {
11171117
if (this.#isPointerMovingToSubmenu() || this.parentMenu.root.isUsingKeyboard.current)
11181118
return;
11191119
const contentNode = this.parentMenu.contentNode;
1120-
contentNode?.focus();
1120+
contentNode?.focus({ preventScroll: true });
11211121
this.rovingFocusGroup.setCurrentTabStopId("");
11221122
}
11231123

@@ -1213,7 +1213,7 @@ class MenuItemSharedState {
12131213
if (defaultPrevented) return;
12141214
const item = e.currentTarget;
12151215
if (!isHTMLElement(item)) return;
1216-
item.focus();
1216+
item.focus({ preventScroll: true });
12171217
}
12181218
}
12191219

packages/bits-ui/src/lib/bits/tooltip/components/tooltip-content.svelte

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,20 @@
7474
contentPointerEvents={contentState.root.disableHoverableContent ? "none" : "auto"}
7575
>
7676
{#snippet popper({ props, wrapperProps })}
77+
{@const finalWrapperProps = mergeProps(wrapperProps, {
78+
style: {
79+
pointerEvents: contentState.root.disableHoverableContent ? "none" : undefined,
80+
},
81+
})}
7782
{@const finalProps = mergeProps(
7883
props,
7984
{ style: getFloatingContentCSSVars("tooltip") },
8085
{ style }
8186
)}
8287
{#if child}
83-
{@render child({ props: finalProps, wrapperProps, ...contentState.snippetProps })}
88+
{@render child({ props: finalProps, wrapperProps: finalWrapperProps, ...contentState.snippetProps })}
8489
{:else}
85-
<div {...wrapperProps}>
90+
<div {...finalWrapperProps}>
8691
<div {...finalProps}>
8792
{@render children?.()}
8893
</div>
@@ -106,15 +111,20 @@
106111
contentPointerEvents={contentState.root.disableHoverableContent ? "none" : "auto"}
107112
>
108113
{#snippet popper({ props, wrapperProps })}
114+
{@const finalWrapperProps = mergeProps(wrapperProps, {
115+
style: {
116+
pointerEvents: contentState.root.disableHoverableContent ? "none" : undefined,
117+
},
118+
})}
109119
{@const finalProps = mergeProps(
110120
props,
111121
{ style: getFloatingContentCSSVars("tooltip") },
112122
{ style }
113123
)}
114124
{#if child}
115-
{@render child({ props: finalProps, wrapperProps, ...contentState.snippetProps })}
125+
{@render child({ props: finalProps, wrapperProps: finalWrapperProps, ...contentState.snippetProps })}
116126
{:else}
117-
<div {...wrapperProps}>
127+
<div {...finalWrapperProps}>
118128
<div {...finalProps}>
119129
{@render children?.()}
120130
</div>

packages/bits-ui/src/lib/bits/utilities/text-selection-layer/use-text-selection-layer.svelte.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import {
88
} from "svelte-toolbelt";
99
import { watch } from "runed";
1010
import { on } from "svelte/events";
11-
import type { TextSelectionLayerImplProps } from "./types.js";
11+
import type { PointerHandler, TextSelectionLayerImplProps } from "./types.js";
1212
import { noop } from "$lib/internal/noop.js";
1313
import { isHTMLElement } from "$lib/internal/is.js";
1414

15+
const noopPointer: PointerHandler = () => {};
16+
1517
interface TextSelectionLayerStateOpts
1618
extends ReadableBoxedValues<
1719
Required<
@@ -30,6 +32,9 @@ export class TextSelectionLayerState {
3032
readonly opts: TextSelectionLayerStateOpts;
3133
readonly domContext: DOMContext;
3234
#unsubSelectionLock = noop;
35+
#enabledSnapshot = false;
36+
#onPointerDownSnapshot: PointerHandler = noopPointer;
37+
#onPointerUpSnapshot: PointerHandler = noopPointer;
3338

3439
constructor(opts: TextSelectionLayerStateOpts) {
3540
this.opts = opts;
@@ -38,14 +43,24 @@ export class TextSelectionLayerState {
3843
let unsubEvents = noop;
3944

4045
watch(
41-
() => this.opts.enabled.current,
42-
(isEnabled) => {
43-
if (isEnabled) {
46+
() =>
47+
[
48+
this.opts.enabled.current,
49+
this.opts.onPointerDown.current,
50+
this.opts.onPointerUp.current,
51+
] as const,
52+
([enabled, onPointerDown, onPointerUp]) => {
53+
this.#enabledSnapshot = enabled;
54+
this.#onPointerDownSnapshot = onPointerDown;
55+
this.#onPointerUpSnapshot = onPointerUp;
56+
57+
if (enabled) {
4458
globalThis.bitsTextSelectionLayers.set(this, this.opts.enabled);
4559
unsubEvents();
4660
unsubEvents = this.#addEventListeners();
4761
}
4862
return () => {
63+
this.#enabledSnapshot = false;
4964
unsubEvents();
5065
this.#resetSelectionLock();
5166
globalThis.bitsTextSelectionLayers.delete(this);
@@ -60,22 +75,26 @@ export class TextSelectionLayerState {
6075
on(
6176
this.domContext.getDocument(),
6277
"pointerup",
63-
composeHandlers(this.#resetSelectionLock, this.opts.onPointerUp.current)
78+
composeHandlers(this.#resetSelectionLock, this.#pointerupUserHandler)
6479
)
6580
);
6681
}
6782

83+
#pointerupUserHandler = (e: PointerEvent) => {
84+
this.#onPointerUpSnapshot(e);
85+
};
86+
6887
#pointerdown = (e: PointerEvent) => {
6988
const node = this.opts.ref.current;
7089
const target = e.target;
71-
if (!isHTMLElement(node) || !isHTMLElement(target) || !this.opts.enabled.current) return;
90+
if (!isHTMLElement(node) || !isHTMLElement(target) || !this.#enabledSnapshot) return;
7291
/**
7392
* We only lock user-selection overflow if layer is the top most layer and
7493
* pointerdown occurred inside the node. You are still allowed to select text
7594
* outside the node provided pointerdown occurs outside the node.
7695
*/
7796
if (!isHighestLayer(this) || !contains(node, target)) return;
78-
this.opts.onPointerDown.current(e);
97+
this.#onPointerDownSnapshot(e);
7998
if (e.defaultPrevented) return;
8099
this.#unsubSelectionLock = preventTextSelectionOverflow(
81100
node,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script lang="ts" module>
2+
import { DropdownMenu } from "bits-ui";
3+
export type DropdownMenuScrollPaddingTestProps = {
4+
contentProps?: Omit<DropdownMenu.ContentProps, "children" | "child">;
5+
};
6+
</script>
7+
8+
<script lang="ts">
9+
let { contentProps = {} }: DropdownMenuScrollPaddingTestProps = $props();
10+
const rows = Array.from({ length: 80 }, (_, i) => i + 1);
11+
</script>
12+
13+
<svelte:head>
14+
<style>
15+
:root {
16+
scroll-padding-top: 200px;
17+
}
18+
</style>
19+
</svelte:head>
20+
21+
<div data-testid="page">
22+
{#each rows as row, i (i)}
23+
<div style="height: 24px;">Row {row}</div>
24+
{/each}
25+
26+
<DropdownMenu.Root>
27+
<DropdownMenu.Trigger data-testid="trigger">Open</DropdownMenu.Trigger>
28+
<DropdownMenu.Portal>
29+
<DropdownMenu.Content {...contentProps} data-testid="content" preventScroll={false}>
30+
<DropdownMenu.Item data-testid="item-1">Item 1</DropdownMenu.Item>
31+
<DropdownMenu.Item data-testid="item-2">Item 2</DropdownMenu.Item>
32+
<DropdownMenu.Item data-testid="item-3">Item 3</DropdownMenu.Item>
33+
</DropdownMenu.Content>
34+
</DropdownMenu.Portal>
35+
</DropdownMenu.Root>
36+
37+
{#each rows as row, i (i)}
38+
<div style="height: 24px;">After row {row}</div>
39+
{/each}
40+
</div>

tests/src/tests/dropdown-menu/dropdown-menu.browser.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../browser-utils";
1515
import DropdownMenuTest from "./dropdown-menu-test.svelte";
1616
import DropdownMenuMultipleTest from "./dropdown-menu-multiple-test.svelte";
17+
import DropdownMenuScrollPaddingTest from "./dropdown-menu-scroll-padding-test.svelte";
1718

1819
const kbd = getTestKbd();
1920
const OPEN_KEYS = [kbd.ENTER, kbd.ARROW_DOWN, kbd.SPACE];
@@ -762,3 +763,40 @@ it("keyboard navigation should not cause unwanted jumps between menus", async ()
762763
await userEvent.keyboard(kbd.ARROW_DOWN);
763764
await expectNotExists(content1);
764765
});
766+
767+
it("should call `focus` with `preventScroll: true` on hover and item-leave so `scroll-padding` does not scroll the page", async () => {
768+
const t = render(DropdownMenuScrollPaddingTest);
769+
onTestFinished(() => t.unmount());
770+
771+
await page.getByTestId("trigger").click();
772+
await expectExists(page.getByTestId("content"));
773+
774+
const focusSpy = vi.spyOn(HTMLElement.prototype, "focus");
775+
try {
776+
const scrollYBeforeHover = window.scrollY;
777+
// hover one item then another to trigger both onpointermove (item focus)
778+
// and onItemLeave (content focus)
779+
await page.getByTestId("item-1").hover();
780+
await page.getByTestId("item-2").hover();
781+
782+
const relevantCalls = focusSpy.mock.calls
783+
.map(([options], i) => ({
784+
options,
785+
testid: (focusSpy.mock.contexts[i] as HTMLElement).getAttribute("data-testid"),
786+
}))
787+
.filter(
788+
({ testid }) => testid === "item-1" || testid === "item-2" || testid === "content"
789+
);
790+
791+
expect(window.scrollY).toBe(scrollYBeforeHover);
792+
expect(relevantCalls).toEqual(
793+
expect.arrayContaining([
794+
{ testid: "item-1", options: { preventScroll: true } },
795+
{ testid: "item-2", options: { preventScroll: true } },
796+
{ testid: "content", options: { preventScroll: true } },
797+
])
798+
);
799+
} finally {
800+
focusSpy.mockRestore();
801+
}
802+
});

0 commit comments

Comments
 (0)