Skip to content

Commit 093b866

Browse files
committed
tooltip visual testing
1 parent 1832e4b commit 093b866

File tree

6 files changed

+371
-6
lines changed

6 files changed

+371
-6
lines changed

packages/floating-ui-svelte/src/hooks/use-hover.svelte.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,11 @@ function getDelay(
112112
}
113113

114114
class HoverInteraction implements ElementProps {
115-
#enabled = $derived.by(() => extract(this.options.enabled ?? true));
116-
#mouseOnly = $derived.by(() => extract(this.options.mouseOnly ?? false));
117-
#delay = $derived.by(() => extract(this.options.delay ?? 0));
118-
#restMs = $derived.by(() => extract(this.options.restMs ?? 0));
119-
#move = $derived.by(() => extract(this.options.move ?? true));
115+
#enabled = $derived.by(() => extract(this.options.enabled, true));
116+
#mouseOnly = $derived.by(() => extract(this.options.mouseOnly, false));
117+
#delay = $derived.by(() => extract(this.options.delay, 0));
118+
#restMs = $derived.by(() => extract(this.options.restMs, 0));
119+
#move = $derived.by(() => extract(this.options.move, true));
120120
#handleClose = $state<HandleCloseFn | undefined | null>(null);
121121
#tree: FloatingTreeType | null = null;
122122
#parentId: string | null = null;
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import type { PropertiesHyphen } from "csstype";
2+
import { extract } from "../internal/extract.js";
3+
import { watch } from "../internal/watch.svelte.js";
4+
import type { Boxed, Getter, MaybeGetter, ReferenceType } from "../types.js";
5+
import type { FloatingContext } from "./use-floating.svelte.js";
6+
import type { Placement, Side } from "@floating-ui/utils";
7+
import { styleObjectToString } from "../internal/style-object-to-string.js";
8+
9+
function execWithArgsOrReturn<Value extends object | undefined, SidePlacement>(
10+
valueOrFn: Value | ((args: SidePlacement) => Value),
11+
args: SidePlacement,
12+
): Value {
13+
return typeof valueOrFn === "function" ? valueOrFn(args) : valueOrFn;
14+
}
15+
16+
type UseDelayUnmountOptions = {
17+
open: Getter<boolean>;
18+
durationMs: Getter<number>;
19+
};
20+
21+
function useDelayUnmount(options: UseDelayUnmountOptions): Boxed<boolean> {
22+
const open = $derived(extract(options.open));
23+
const durationMs = $derived(extract(options.durationMs));
24+
25+
let isMounted = $state(open);
26+
27+
$effect(() => {
28+
if (open && !isMounted) {
29+
isMounted = true;
30+
}
31+
});
32+
33+
$effect(() => {
34+
if (!open && isMounted) {
35+
const timeout = setTimeout(() => {
36+
isMounted = false;
37+
}, durationMs);
38+
return () => clearTimeout(timeout);
39+
}
40+
});
41+
42+
return {
43+
get current() {
44+
return isMounted;
45+
},
46+
};
47+
}
48+
49+
interface UseTransitionStatusOptions {
50+
/**
51+
* The duration of the transition in milliseconds, or an object containing
52+
* `open` and `close` keys for different durations.
53+
*/
54+
duration?: MaybeGetter<number | { open?: number; close?: number }>;
55+
}
56+
type TransitionStatus = "unmounted" | "initial" | "open" | "close";
57+
58+
class TransitionStatusState {
59+
#duration = $derived.by(() => extract(this.options.duration, 250));
60+
#closeDuration = $derived.by(() => {
61+
if (typeof this.#duration === "number") {
62+
return this.#duration;
63+
}
64+
return this.#duration.close || 0;
65+
});
66+
#status: TransitionStatus = $state("unmounted");
67+
#isMounted: ReturnType<typeof useDelayUnmount>;
68+
69+
constructor(
70+
private readonly context: FloatingContext,
71+
private readonly options: UseTransitionStatusOptions,
72+
) {
73+
this.#isMounted = useDelayUnmount({
74+
open: () => this.context.open,
75+
durationMs: () => this.#closeDuration,
76+
});
77+
78+
$effect.pre(() => {
79+
if (!this.#isMounted.current && this.#status === "close") {
80+
this.#status = "unmounted";
81+
}
82+
});
83+
84+
watch.pre([() => this.context.open, () => this.context.floating], () => {
85+
if (!this.context.floating) return;
86+
87+
if (this.context.open) {
88+
this.#status = "initial";
89+
90+
const frame = requestAnimationFrame(() => {
91+
this.#status = "open";
92+
});
93+
94+
return () => {
95+
cancelAnimationFrame(frame);
96+
};
97+
}
98+
99+
this.#status = "close";
100+
});
101+
}
102+
103+
get isMounted() {
104+
return this.#isMounted.current;
105+
}
106+
107+
get status() {
108+
return this.#status;
109+
}
110+
}
111+
112+
/**
113+
* Provides a status string to apply CSS transitions to a floating element,
114+
* correctly handling placement-aware transitions.
115+
*/
116+
function useTransitionStatus(
117+
context: FloatingContext,
118+
options: UseTransitionStatusOptions = {},
119+
): TransitionStatusState {
120+
return new TransitionStatusState(context, options);
121+
}
122+
123+
type CSSStylesProperty =
124+
| PropertiesHyphen
125+
| ((params: { side: Side; placement: Placement }) => PropertiesHyphen);
126+
127+
interface UseTransitionStylesOptions extends UseTransitionStatusOptions {
128+
/**
129+
* The styles to apply when the floating element is initially mounted.
130+
*/
131+
initial?: CSSStylesProperty;
132+
/**
133+
* The styles to apply when the floating element is transitioning to the
134+
* `open` state.
135+
*/
136+
open?: CSSStylesProperty;
137+
/**
138+
* The styles to apply when the floating element is transitioning to the
139+
* `close` state.
140+
*/
141+
close?: CSSStylesProperty;
142+
/**
143+
* The styles to apply to all states.
144+
*/
145+
common?: CSSStylesProperty;
146+
}
147+
148+
class TransitionStylesState<RT extends ReferenceType = ReferenceType> {
149+
#initial = $derived.by(() => this.options.initial ?? { opacity: 0 });
150+
#open = $derived.by(() => this.options.open);
151+
#close = $derived.by(() => this.options.close);
152+
#common = $derived.by(() => this.options.common);
153+
#duration = $derived.by(() => extract(this.options.duration, 250));
154+
#placement = $derived.by(() => this.context.placement);
155+
#side = $derived.by(() => this.#placement.split("-")[0] as Side);
156+
#fnArgs = $derived.by(() => ({
157+
side: this.#side,
158+
placement: this.#placement,
159+
}));
160+
#openDuration = $derived.by(() => {
161+
if (typeof this.#duration === "number") {
162+
return this.#duration;
163+
}
164+
return this.#duration.open || 0;
165+
});
166+
#closeDuration = $derived.by(() => {
167+
if (typeof this.#duration === "number") {
168+
return this.#duration;
169+
}
170+
return this.#duration.close || 0;
171+
});
172+
#styles = $state.raw<PropertiesHyphen>({});
173+
#transitionStatus: TransitionStatusState;
174+
#status = $derived.by(() => this.#transitionStatus.status);
175+
176+
constructor(
177+
private readonly context: FloatingContext<RT>,
178+
private readonly options: UseTransitionStylesOptions = {},
179+
) {
180+
this.#styles = {
181+
...execWithArgsOrReturn(this.#common, this.#fnArgs),
182+
...execWithArgsOrReturn(this.#initial, this.#fnArgs),
183+
};
184+
this.#transitionStatus = useTransitionStatus(context, {
185+
duration: this.options.duration,
186+
});
187+
188+
watch.pre(
189+
[
190+
() => this.#closeDuration,
191+
() => this.#close,
192+
() => this.#initial,
193+
() => this.#open,
194+
() => this.#common,
195+
() => this.#openDuration,
196+
() => this.#status,
197+
() => this.#fnArgs,
198+
],
199+
() => {
200+
const initialStyles = execWithArgsOrReturn(this.#initial, this.#fnArgs);
201+
const closeStyles = execWithArgsOrReturn(this.#close, this.#fnArgs);
202+
const commonStyles = execWithArgsOrReturn(this.#common, this.#fnArgs);
203+
const openStyles =
204+
execWithArgsOrReturn(this.#open, this.#fnArgs) ||
205+
Object.keys(initialStyles).reduce((acc: Record<string, "">, key) => {
206+
acc[key] = "";
207+
return acc;
208+
}, {});
209+
210+
if (this.#status === "initial") {
211+
this.#styles = {
212+
"transition-property": this.#styles["transition-property"],
213+
...commonStyles,
214+
...initialStyles,
215+
};
216+
}
217+
218+
if (this.#status === "open") {
219+
this.#styles = {
220+
"transition-property": Object.keys(openStyles).join(", "),
221+
"transition-duration": `${this.#openDuration}ms`,
222+
...commonStyles,
223+
...openStyles,
224+
};
225+
}
226+
227+
if (this.#status === "close") {
228+
const localStyles = closeStyles || initialStyles;
229+
this.#styles = {
230+
"transition-property": Object.keys(localStyles).join(", "),
231+
"transition-duration": `${this.#closeDuration}ms`,
232+
...commonStyles,
233+
...localStyles,
234+
};
235+
}
236+
},
237+
);
238+
}
239+
240+
get styles() {
241+
return styleObjectToString(this.#styles);
242+
}
243+
244+
get isMounted() {
245+
return this.#transitionStatus.isMounted;
246+
}
247+
}
248+
249+
function useTransitionStyles(
250+
context: FloatingContext,
251+
options: UseTransitionStylesOptions = {},
252+
): TransitionStylesState {
253+
return new TransitionStylesState(context, options);
254+
}
255+
256+
export { useTransitionStyles, useTransitionStatus };
257+
export type { UseTransitionStatusOptions, UseTransitionStylesOptions };

packages/floating-ui-svelte/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export * from "./hooks/use-hover.svelte.js";
3535
export * from "./hooks/use-id.js";
3636
export * from "./hooks/use-interactions.svelte.js";
3737
export * from "./hooks/use-role.svelte.js";
38+
export * from "./hooks/use-transition.svelte.js";
3839
export * from "./safe-polygon.js";
3940

4041
/**
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script lang="ts">
2+
import FloatingDelayGroup from "../../../../src/components/floating-delay-group.svelte";
3+
import Button from "../button.svelte";
4+
import Tooltip from "./tooltip.svelte";
5+
</script>
6+
7+
<h1 class="text-5xl font-bold mb-8">Tooltip</h1>
8+
<div
9+
class="grid place-items-center border border-slate-400 rounded lg:w-[40rem] h-[20rem] mb-4">
10+
<Tooltip label="My tooltip">
11+
{#snippet children(ref, props)}
12+
<Button bind:ref={ref.current} {...props}>My button</Button>
13+
{/snippet}
14+
</Tooltip>
15+
</div>
16+
<div
17+
class="grid place-items-center border border-slate-400 rounded lg:w-[40rem] h-[20rem] mb-4">
18+
<div class="flex gap-1">
19+
<FloatingDelayGroup delay={{ open: 500, close: 200 }} timeoutMs={200}>
20+
<Tooltip label="My tooltip">
21+
{#snippet children(ref, props)}
22+
<Button bind:ref={ref.current} {...props}>My button</Button>
23+
{/snippet}
24+
</Tooltip>
25+
<Tooltip label="My tooltip 2">
26+
{#snippet children(ref, props)}
27+
<Button bind:ref={ref.current} {...props}>My button</Button>
28+
{/snippet}
29+
</Tooltip>
30+
<Tooltip label="My tooltip 3">
31+
{#snippet children(ref, props)}
32+
<Button bind:ref={ref.current} {...props}>My button</Button>
33+
{/snippet}
34+
</Tooltip>
35+
</FloatingDelayGroup>
36+
</div>
37+
</div>

0 commit comments

Comments
 (0)