Skip to content

Commit 70574d5

Browse files
committed
fix: preserve reactive props through themes and slots
1 parent 31a51d7 commit 70574d5

9 files changed

Lines changed: 594 additions & 25 deletions

File tree

apps/playground/src/sink/hover-card/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ export default function HoverCardPage() {
88
<DocsSectionBody>
99
<HoverCard.Root>
1010
<HoverCard.Trigger>
11-
<Link>A fancy link</Link>
11+
<Link id="hover-card-demo-trigger">A fancy link</Link>
1212
</HoverCard.Trigger>
13-
<HoverCard.Content width="200px">
13+
<HoverCard.Content id="hover-card-demo-content" width="200px">
1414
<Text as="p" size="2">
1515
Jan Tschichold was a German calligrapher, typographer and book designer. He played a
1616
significant role in the development of graphic design in the 20th century.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { render } from 'fict';
4+
5+
import HoverCardPage from '../src/sink/hover-card/page';
6+
7+
const cleanups: Array<() => void> = [];
8+
9+
function flush() {
10+
return new Promise<void>((resolve) => {
11+
if (typeof queueMicrotask === 'function') {
12+
queueMicrotask(resolve);
13+
return;
14+
}
15+
16+
Promise.resolve().then(resolve);
17+
});
18+
}
19+
20+
function pointerEvent(target: Element, type: string, init: PointerEventInit = {}) {
21+
target.dispatchEvent(
22+
new PointerEvent(type, {
23+
bubbles: true,
24+
cancelable: true,
25+
pointerType: 'mouse',
26+
...init,
27+
}),
28+
);
29+
}
30+
31+
async function advance(ms: number) {
32+
await vi.advanceTimersByTimeAsync(ms);
33+
await flush();
34+
}
35+
36+
describe('playground hover card demo', () => {
37+
beforeEach(() => {
38+
vi.useFakeTimers();
39+
});
40+
41+
afterEach(() => {
42+
while (cleanups.length > 0) {
43+
cleanups.pop()?.();
44+
}
45+
vi.useRealTimers();
46+
vi.restoreAllMocks();
47+
document.body.innerHTML = '';
48+
});
49+
50+
it('shows content on hover and hides it on pointer leave', async () => {
51+
const container = document.createElement('div');
52+
document.body.append(container);
53+
54+
cleanups.push(render(() => <HoverCardPage />, container));
55+
56+
await flush();
57+
58+
const trigger = container.querySelector('#hover-card-demo-trigger');
59+
expect(trigger).not.toBeNull();
60+
expect(document.body.querySelector('#hover-card-demo-content')).toBeNull();
61+
62+
pointerEvent(trigger as Element, 'pointerenter');
63+
await advance(200);
64+
65+
expect(document.body.querySelector('#hover-card-demo-content')).not.toBeNull();
66+
67+
const currentTrigger = container.querySelector('#hover-card-demo-trigger');
68+
expect(currentTrigger).not.toBeNull();
69+
70+
pointerEvent(currentTrigger as Element, 'pointerleave');
71+
await advance(170);
72+
73+
expect(document.body.querySelector('#hover-card-demo-content')).toBeNull();
74+
});
75+
});
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { render } from 'fict';
4+
import { HoverCard as ThemedHoverCard, Link, Theme } from '@fictjs/radix-ui-themes';
5+
import { HoverCard } from '@fictjs/radix-ui';
6+
7+
const cleanups: Array<() => void> = [];
8+
9+
function pointerEvent(target: Element, type: string, init: PointerEventInit = {}) {
10+
target.dispatchEvent(
11+
new PointerEvent(type, {
12+
bubbles: true,
13+
cancelable: true,
14+
pointerType: 'mouse',
15+
...init,
16+
}),
17+
);
18+
}
19+
20+
async function flushEffects(cycles = 4) {
21+
for (let index = 0; index < cycles; index += 1) {
22+
await new Promise<void>((resolve) => {
23+
if (typeof queueMicrotask === 'function') {
24+
queueMicrotask(resolve);
25+
return;
26+
}
27+
28+
Promise.resolve().then(resolve);
29+
});
30+
}
31+
}
32+
33+
async function advance(ms: number) {
34+
await vi.advanceTimersByTimeAsync(ms);
35+
await flushEffects();
36+
}
37+
38+
describe('primitive hover card', () => {
39+
beforeEach(() => {
40+
vi.useFakeTimers();
41+
});
42+
43+
afterEach(() => {
44+
while (cleanups.length > 0) {
45+
cleanups.pop()?.();
46+
}
47+
vi.useRealTimers();
48+
vi.restoreAllMocks();
49+
document.body.innerHTML = '';
50+
});
51+
52+
it('closes content after leaving the trigger', async () => {
53+
const container = document.createElement('div');
54+
document.body.append(container);
55+
56+
cleanups.push(
57+
render(
58+
() => (
59+
<HoverCard.Root openDelay={0} closeDelay={20}>
60+
<HoverCard.Trigger>
61+
<a data-testid="trigger">Primitive trigger</a>
62+
</HoverCard.Trigger>
63+
<HoverCard.Portal>
64+
<HoverCard.Content data-testid="content">
65+
<p>Preview</p>
66+
</HoverCard.Content>
67+
</HoverCard.Portal>
68+
</HoverCard.Root>
69+
),
70+
container,
71+
),
72+
);
73+
74+
const trigger = container.querySelector('[data-testid="trigger"]');
75+
expect(trigger).not.toBeNull();
76+
77+
pointerEvent(trigger as Element, 'pointerenter');
78+
await advance(0);
79+
80+
const content = document.body.querySelector('[data-testid="content"]');
81+
expect(content).not.toBeNull();
82+
83+
pointerEvent(trigger as Element, 'pointerleave');
84+
await advance(20);
85+
86+
expect(document.body.querySelector('[data-testid="content"]')).toBeNull();
87+
});
88+
89+
it('closes content after leaving the trigger when wrapped in Theme asChild', async () => {
90+
const container = document.createElement('div');
91+
document.body.append(container);
92+
93+
cleanups.push(
94+
render(
95+
() => (
96+
<HoverCard.Root openDelay={0} closeDelay={20}>
97+
<HoverCard.Trigger>
98+
<a data-testid="theme-trigger">Theme trigger</a>
99+
</HoverCard.Trigger>
100+
<HoverCard.Portal>
101+
<Theme asChild>
102+
<HoverCard.Content
103+
align="start"
104+
collisionPadding={10}
105+
data-testid="theme-content"
106+
sideOffset={8}
107+
class="rt-PopperContent rt-HoverCardContent"
108+
>
109+
<p>Preview</p>
110+
</HoverCard.Content>
111+
</Theme>
112+
</HoverCard.Portal>
113+
</HoverCard.Root>
114+
),
115+
container,
116+
),
117+
);
118+
119+
const trigger = container.querySelector('[data-testid="theme-trigger"]');
120+
expect(trigger).not.toBeNull();
121+
122+
pointerEvent(trigger as Element, 'pointerenter');
123+
await advance(0);
124+
125+
const content = document.body.querySelector('[data-testid="theme-content"]');
126+
expect(content).not.toBeNull();
127+
128+
pointerEvent(trigger as Element, 'pointerleave');
129+
await advance(20);
130+
131+
expect(document.body.querySelector('[data-testid="theme-content"]')).toBeNull();
132+
});
133+
134+
it('closes content after leaving a themed trigger with primitive content', async () => {
135+
const container = document.createElement('div');
136+
document.body.append(container);
137+
138+
cleanups.push(
139+
render(
140+
() => (
141+
<HoverCard.Root openDelay={0} closeDelay={20}>
142+
<ThemedHoverCard.Trigger>
143+
<Link data-testid="mixed-trigger">Mixed trigger</Link>
144+
</ThemedHoverCard.Trigger>
145+
<HoverCard.Portal>
146+
<Theme asChild>
147+
<HoverCard.Content
148+
align="start"
149+
collisionPadding={10}
150+
data-testid="mixed-content"
151+
sideOffset={8}
152+
class="rt-PopperContent rt-HoverCardContent"
153+
>
154+
<p>Preview</p>
155+
</HoverCard.Content>
156+
</Theme>
157+
</HoverCard.Portal>
158+
</HoverCard.Root>
159+
),
160+
container,
161+
),
162+
);
163+
164+
const trigger = container.querySelector('[data-testid="mixed-trigger"]');
165+
expect(trigger).not.toBeNull();
166+
167+
pointerEvent(trigger as Element, 'pointerenter');
168+
await advance(0);
169+
expect(document.body.querySelector('[data-testid="mixed-content"]')).not.toBeNull();
170+
171+
pointerEvent(trigger as Element, 'pointerleave');
172+
await advance(20);
173+
174+
const currentTrigger = container.querySelector('[data-testid="mixed-trigger"]');
175+
expect(currentTrigger?.getAttribute('data-state')).toBe('closed');
176+
expect(document.body.querySelector('[data-testid="mixed-content"]')).toBeNull();
177+
});
178+
179+
it('closes content after leaving a primitive trigger with themed content', async () => {
180+
const container = document.createElement('div');
181+
document.body.append(container);
182+
183+
cleanups.push(
184+
render(
185+
() => (
186+
<HoverCard.Root openDelay={0} closeDelay={20}>
187+
<HoverCard.Trigger>
188+
<a data-testid="mixed-primitive-trigger">Primitive trigger</a>
189+
</HoverCard.Trigger>
190+
<ThemedHoverCard.Content data-testid="mixed-themed-content">
191+
<p>Preview</p>
192+
</ThemedHoverCard.Content>
193+
</HoverCard.Root>
194+
),
195+
container,
196+
),
197+
);
198+
199+
const trigger = container.querySelector('[data-testid="mixed-primitive-trigger"]');
200+
expect(trigger).not.toBeNull();
201+
202+
pointerEvent(trigger as Element, 'pointerenter');
203+
await advance(0);
204+
expect(document.body.querySelector('[data-testid="mixed-themed-content"]')).not.toBeNull();
205+
206+
pointerEvent(trigger as Element, 'pointerleave');
207+
await advance(20);
208+
209+
expect(document.body.querySelector('[data-testid="mixed-themed-content"]')).toBeNull();
210+
});
211+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { render } from 'fict';
4+
import { HoverCard, Link, Text } from '@fictjs/radix-ui-themes';
5+
6+
const cleanups: Array<() => void> = [];
7+
8+
function pointerEvent(target: Element, type: string, init: PointerEventInit = {}) {
9+
target.dispatchEvent(
10+
new PointerEvent(type, {
11+
bubbles: true,
12+
cancelable: true,
13+
pointerType: 'mouse',
14+
...init,
15+
}),
16+
);
17+
}
18+
19+
async function flushEffects(cycles = 4) {
20+
for (let index = 0; index < cycles; index += 1) {
21+
await new Promise<void>((resolve) => {
22+
if (typeof queueMicrotask === 'function') {
23+
queueMicrotask(resolve);
24+
return;
25+
}
26+
27+
Promise.resolve().then(resolve);
28+
});
29+
}
30+
}
31+
32+
async function advance(ms: number) {
33+
await vi.advanceTimersByTimeAsync(ms);
34+
await flushEffects();
35+
}
36+
37+
describe('themes hover card', () => {
38+
beforeEach(() => {
39+
vi.useFakeTimers();
40+
});
41+
42+
afterEach(() => {
43+
while (cleanups.length > 0) {
44+
cleanups.pop()?.();
45+
}
46+
vi.useRealTimers();
47+
vi.restoreAllMocks();
48+
document.body.innerHTML = '';
49+
});
50+
51+
it('closes content after leaving the trigger', async () => {
52+
const container = document.createElement('div');
53+
document.body.append(container);
54+
55+
cleanups.push(
56+
render(
57+
() => (
58+
<HoverCard.Root openDelay={0} closeDelay={20}>
59+
<HoverCard.Trigger>
60+
<Link data-testid="trigger">A fancy link</Link>
61+
</HoverCard.Trigger>
62+
<HoverCard.Content data-testid="content">
63+
<Text as="p" size="2">
64+
Preview
65+
</Text>
66+
</HoverCard.Content>
67+
</HoverCard.Root>
68+
),
69+
container,
70+
),
71+
);
72+
73+
const trigger = container.querySelector('[data-testid="trigger"]');
74+
expect(trigger).not.toBeNull();
75+
expect(document.body.querySelector('[data-testid="content"]')).toBeNull();
76+
77+
pointerEvent(trigger as Element, 'pointerenter');
78+
await advance(0);
79+
80+
const content = document.body.querySelector('[data-testid="content"]');
81+
expect(content).not.toBeNull();
82+
83+
const currentTrigger = container.querySelector('[data-testid="trigger"]');
84+
expect(currentTrigger).not.toBeNull();
85+
86+
pointerEvent(currentTrigger as Element, 'pointerleave');
87+
await advance(20);
88+
89+
expect(document.body.querySelector('[data-testid="content"]')).toBeNull();
90+
});
91+
});

0 commit comments

Comments
 (0)