Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thin-places-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

fix(Tooltip): dont eagerly start timer
37 changes: 35 additions & 2 deletions docs/src/routes/(docs)/sink/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
<script lang="ts">
import DateField from "./date-field.svelte";
import { Tooltip, Dialog } from "bits-ui";

let open = $state(false);
</script>

<DateField />
<Tooltip.Provider>
<Tooltip.Root delayDuration={200} disableCloseOnTriggerClick={true}>
<Tooltip.Trigger
class="inline-flex size-fit items-center justify-center"
onclick={() => (open = true)}
>
Hover Me & Then Click
</Tooltip.Trigger>
<Tooltip.Content sideOffset={8} side="bottom">
<div
class="rounded-input border-dark-10 bg-background shadow-popover outline-hidden z-0 flex items-center justify-center border p-3 text-sm font-medium"
>
Tooltip Content
</div>
</Tooltip.Content>
</Tooltip.Root>

<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Content
class="rounded-input border-dark-10 bg-background shadow-popover outline-hidden z-0 border p-3 text-sm font-medium"
>
<p>Dialog Content</p>
<p>
Click "Close" to close dialog and hover tooltip again. The tooltip will not
appear.
</p>
<Dialog.Close class="block">Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Tooltip.Provider>
37 changes: 13 additions & 24 deletions packages/bits-ui/src/lib/bits/tooltip/tooltip.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,9 @@ export class TooltipProviderState {

constructor(opts: TooltipProviderStateOpts) {
this.opts = opts;
this.#timerFn = new TimeoutFn(
() => {
this.isOpenDelayed = true;
},
this.opts.skipDelayDuration.current,
{ immediate: false }
);
this.#timerFn = new TimeoutFn(() => {
this.isOpenDelayed = true;
}, this.opts.skipDelayDuration.current);
}

#startTimer = () => {
Expand Down Expand Up @@ -143,14 +139,10 @@ export class TooltipRootState {
constructor(opts: TooltipRootStateOpts, provider: TooltipProviderState) {
this.opts = opts;
this.provider = provider;
this.#timerFn = new TimeoutFn(
() => {
this.#wasOpenDelayed = true;
this.opts.open.current = true;
},
this.delayDuration ?? 0,
{ immediate: false }
);
this.#timerFn = new TimeoutFn(() => {
this.#wasOpenDelayed = true;
this.opts.open.current = true;
}, this.delayDuration ?? 0);

new OpenChangeComplete({
open: this.opts.open,
Expand All @@ -164,14 +156,10 @@ export class TooltipRootState {
() => this.delayDuration,
() => {
if (this.delayDuration === undefined) return;
this.#timerFn = new TimeoutFn(
() => {
this.#wasOpenDelayed = true;
this.opts.open.current = true;
},
this.delayDuration,
{ immediate: false }
);
this.#timerFn = new TimeoutFn(() => {
this.#wasOpenDelayed = true;
this.opts.open.current = true;
}, this.delayDuration);
}
);

Expand All @@ -183,7 +171,8 @@ export class TooltipRootState {
} else {
this.provider.onClose(this);
}
}
},
{ lazy: true }
);
}

Expand Down
24 changes: 3 additions & 21 deletions packages/bits-ui/src/lib/internal/timeout-fn.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,18 @@
import { onDestroyEffect } from "svelte-toolbelt";
import type { AnyFn } from "./types.js";
import { BROWSER } from "esm-env";

type TimeoutFnOptions = {
/**
* Start the timer immediate after calling this function
*
* @default true
*/
immediate?: boolean;
};

const defaultOpts: TimeoutFnOptions = {
immediate: true,
};

export class TimeoutFn<T extends AnyFn> {
readonly #opts: TimeoutFnOptions;
readonly #interval: number;
readonly #cb: T;
#timer: number | null = null;

constructor(cb: T, interval: number, opts: TimeoutFnOptions = {}) {
constructor(cb: T, interval: number) {
this.#cb = cb;
this.#interval = interval;
this.#opts = { ...defaultOpts, ...opts };

this.stop = this.stop.bind(this);
this.start = this.start.bind(this);

if (this.#opts.immediate && BROWSER) {
this.start();
}

onDestroyEffect(this.stop);
}

Expand All @@ -44,10 +24,12 @@ export class TimeoutFn<T extends AnyFn> {
}

stop() {
console.log("stopping timeout");
this.#clear();
}

start(...args: Parameters<T> | []) {
console.log("starting timeout");
this.#clear();
this.#timer = window.setTimeout(() => {
this.#timer = null;
Expand Down
30 changes: 30 additions & 0 deletions tests/src/tests/dialog/dialog-integration-test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { DropdownMenu } from "bits-ui";
import { Popover } from "bits-ui";
</script>

<main>
<Dialog.Root>
<Dialog.Trigger data-testid="dialog-trigger">open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content data-testid="dialog-content">
<DropdownMenu.Root>
<DropdownMenu.Trigger data-testid="dropdown-trigger">open</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content data-testid="dropdown-content">
<DropdownMenu.Item>item</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<Popover.Root>
<Popover.Trigger data-testid="popover-trigger">open</Popover.Trigger>
<Popover.Portal>
<Popover.Content data-testid="popover-content">content</Popover.Content>
</Popover.Portal>
</Popover.Root>
content
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</main>
29 changes: 29 additions & 0 deletions tests/src/tests/dialog/dialog-tooltip-test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
import { Tooltip, Dialog } from "bits-ui";

let open = $state(false);
</script>

<Tooltip.Provider>
<Tooltip.Root delayDuration={200} disableCloseOnTriggerClick={true}>
<Tooltip.Trigger data-testid="trigger" onclick={() => (open = true)}>
Hover Me & Then Click
</Tooltip.Trigger>
<Tooltip.Content data-testid="tooltip-content">
<div>Tooltip Content</div>
</Tooltip.Content>
</Tooltip.Root>

<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Content data-testid="dialog-content">
<p>Dialog Content</p>
<p>
Click "Close" to close dialog and hover tooltip again. The tooltip will not
appear.
</p>
<Dialog.Close data-testid="dialog-close">Close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</Tooltip.Provider>
38 changes: 38 additions & 0 deletions tests/src/tests/dialog/dialog.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import DialogTest, { type DialogTestProps } from "./dialog-test.svelte";
import DialogNestedTest from "./dialog-nested-test.svelte";
import { expectExists, expectNotExists, setupBrowserUserEvents } from "../browser-utils";
import DialogForceMountTest from "./dialog-force-mount-test.svelte";
import DialogIntegrationTest from "./dialog-integration-test.svelte";
import DialogTooltipTest from "./dialog-tooltip-test.svelte";

const kbd = getTestKbd();

Expand Down Expand Up @@ -384,3 +386,39 @@ describe("Nested Dialogs", () => {
await expect.element(page.getByTestId("second-open")).toHaveFocus();
});
});

describe("Integration with other components", () => {
it("should allow opening nested floating components within the dialog", async () => {
render(DialogIntegrationTest);
await page.getByTestId("dialog-trigger").click();
await expectExists(page.getByTestId("dialog-content"));
await page.getByTestId("dropdown-trigger").click();
await expectExists(page.getByTestId("dropdown-content"));
await userEvent.keyboard(kbd.ESCAPE);
await expectNotExists(page.getByTestId("dropdown-content"));
await expectExists(page.getByTestId("dialog-content"));
await page.getByTestId("popover-trigger").click();
await expectExists(page.getByTestId("popover-content"));
await userEvent.keyboard(kbd.ESCAPE);
await expectNotExists(page.getByTestId("popover-content"));
await expectExists(page.getByTestId("dialog-content"));
await userEvent.keyboard(kbd.ESCAPE);
await expectNotExists(page.getByTestId("dialog-content"));
});

it("should not break tooltip when opened from tooltip trigger and disableCloseOnTriggerClick is true", async () => {
// https://github.com/huntabyte/bits-ui/issues/1666
render(DialogTooltipTest);
const trigger = page.getByTestId("trigger");
await trigger.hover();
await expectExists(page.getByTestId("tooltip-content"));
await trigger.click();
await expectExists(page.getByTestId("dialog-content"));
await expectNotExists(page.getByTestId("tooltip-content"));
await page.getByTestId("dialog-close").click();

await expectNotExists(page.getByTestId("dialog-content"));
await trigger.hover();
await expectExists(page.getByTestId("tooltip-content"));
});
});
12 changes: 12 additions & 0 deletions tests/src/tests/popover/popover-multiple-triggers-test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<script lang="ts">
import { Popover } from "bits-ui";
</script>

<Popover.Root>
<Popover.Trigger data-testid="trigger-1">trigger-1</Popover.Trigger>
<Popover.Trigger data-testid="trigger-2">trigger-2</Popover.Trigger>
<Popover.Trigger data-testid="trigger-3">trigger-3</Popover.Trigger>
<Popover.Portal>
<Popover.Content data-testid="content">content</Popover.Content>
</Popover.Portal>
</Popover.Root>
19 changes: 19 additions & 0 deletions tests/src/tests/popover/popover.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import PopoverForceMountTest, {
import PopoverSiblingsTest from "./popover-siblings-test.svelte";
import { expectExists, expectNotExists } from "../browser-utils";
import { page, userEvent } from "@vitest/browser/context";
import PopoverMultipleTriggersTest from "./popover-multiple-triggers-test.svelte";

const kbd = getTestKbd();

Expand Down Expand Up @@ -198,3 +199,21 @@ it("should correctly handle focus when closing one popover by clicking another p
await t.getByTestId("close-3").click();
await expectNotExists(t.getByTestId("content-3"));
});

it("should restore focus to the trigger that opened the popover", async () => {
render(PopoverMultipleTriggersTest);
await page.getByTestId("trigger-1").click();
await expectExists(page.getByTestId("content"));
await userEvent.keyboard(kbd.ESCAPE);
await expect.element(page.getByTestId("trigger-1")).toHaveFocus();

await page.getByTestId("trigger-2").click();
await expectExists(page.getByTestId("content"));
await userEvent.keyboard(kbd.ESCAPE);
await expect.element(page.getByTestId("trigger-2")).toHaveFocus();

await page.getByTestId("trigger-3").click();
await expectExists(page.getByTestId("content"));
await userEvent.keyboard(kbd.ESCAPE);
await expect.element(page.getByTestId("trigger-3")).toHaveFocus();
});