Skip to content

Commit a8d6ff1

Browse files
committed
feat(EventPopup): enhance popup animations and add unit tests for rendering and close functionality
1 parent 7371964 commit a8d6ff1

3 files changed

Lines changed: 162 additions & 53 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from "react";
2+
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3+
import moment from "jalali-moment";
4+
import EventPopup from "../../components/EventPopup";
5+
6+
describe("EventPopup", () => {
7+
it("renders with content and close button, and calls onClose when clicked", async () => {
8+
const date = moment();
9+
const onClose = jest.fn();
10+
11+
const anchorRect = {
12+
left: 120,
13+
top: 100,
14+
bottom: 160,
15+
width: 120,
16+
height: 40,
17+
};
18+
19+
const event = {
20+
title: "جلسه تست",
21+
fullName: "جلسه هفتگی تست",
22+
color: "#ff5a5f",
23+
link: "https://example.com",
24+
resource: "https://resource.example",
25+
time: "ساعت ۱۰:۰۰ تا ۱۱:۰۰",
26+
};
27+
28+
const { container } = render(
29+
<EventPopup
30+
visible={true}
31+
anchorRect={anchorRect}
32+
date={date}
33+
event={event}
34+
onClose={onClose}
35+
/>
36+
);
37+
38+
const root = container.querySelector(".event-popup");
39+
expect(root).toBeInTheDocument();
40+
41+
// content should exist
42+
const content = container.querySelector(".event-popup__content");
43+
expect(content).toBeInTheDocument();
44+
45+
// wait for animation class to be applied
46+
await waitFor(() => expect(content).toHaveClass("is-open"));
47+
48+
// check text content
49+
expect(screen.getByText("جلسه هفتگی تست")).toBeInTheDocument();
50+
expect(screen.getByText("ساعت ۱۰:۰۰ تا ۱۱:۰۰")).toBeInTheDocument();
51+
52+
// pressing Escape should call handler
53+
fireEvent.keyDown(document, { key: "Escape" });
54+
expect(onClose).toHaveBeenCalledTimes(1);
55+
});
56+
});

src/assets/scss/components/_event-popup.scss

Lines changed: 64 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@
33
position: absolute;
44
box-sizing: border-box;
55

6+
transition:
7+
transform 260ms cubic-bezier(0.16, 1, 0.3, 1),
8+
opacity 200ms cubic-bezier(0.16, 1, 0.3, 1) !important;
9+
transform-origin: top center;
10+
will-change: transform, opacity;
11+
12+
&.is-mounted {
13+
opacity: 1;
14+
transform: translateY(0) scale(1);
15+
}
16+
&.is-closing {
17+
opacity: 0;
18+
transform: translateY(12px) scale(0.98);
19+
transition:
20+
transform 280ms cubic-bezier(0.22, 0.9, 0.28, 1),
21+
opacity 240ms ease !important;
22+
}
23+
624
.event-popup__arrow {
725
position: absolute;
826
width: 0;
@@ -12,7 +30,20 @@
1230
border-bottom: 10px solid var(--bg-color-alt, #fff);
1331
top: -10px;
1432
transform-origin: center;
15-
filter: drop-shadow(0 6px 12px rgba(16, 24, 40, 0.08));
33+
filter: drop-shadow(0 10px 18px rgba(16, 24, 40, 0.08));
34+
transform: translateY(8px);
35+
opacity: 0;
36+
transition:
37+
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
38+
opacity 180ms ease !important;
39+
}
40+
41+
&.is-closing .event-popup__arrow {
42+
transform: translateY(14px);
43+
opacity: 0;
44+
transition:
45+
transform 240ms cubic-bezier(0.22, 0.9, 0.28, 1),
46+
opacity 200ms ease !important;
1647
}
1748

1849
&.event-popup--above {
@@ -21,6 +52,7 @@
2152
bottom: -10px;
2253
border-bottom: 0;
2354
border-top: 10px solid var(--bg-color-alt, #fff);
55+
transform-origin: bottom center;
2456
}
2557
}
2658

@@ -34,14 +66,25 @@
3466
direction: rtl;
3567
overflow: hidden;
3668
border: 1px solid rgba(0, 0, 0, 0.06);
69+
transform-origin: inherit;
70+
transform: translateY(8px) scale(0.994);
71+
opacity: 0;
3772
transition:
38-
transform 160ms cubic-bezier(0.2, 0.9, 0.2, 1),
39-
opacity 140ms ease;
40-
transform-origin: top center;
73+
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
74+
opacity 200ms cubic-bezier(0.16, 1, 0.3, 1),
75+
box-shadow 220ms ease !important;
4176

4277
&.is-open {
43-
transform: translateY(0);
78+
transform: translateY(0) scale(1);
4479
opacity: 1;
80+
box-shadow: 0 18px 48px rgba(16, 24, 40, 0.14);
81+
}
82+
&:not(.is-open) {
83+
transform: translateY(6px) scale(0.985);
84+
opacity: 0;
85+
transition:
86+
transform 280ms cubic-bezier(0.22, 0.9, 0.28, 1),
87+
opacity 240ms ease !important;
4588
}
4689
}
4790

@@ -52,7 +95,6 @@
5295
gap: 12px;
5396
margin-bottom: 10px;
5497
}
55-
5698
.event-popup__subtitle {
5799
font-size: 12px;
58100
font-weight: 500;
@@ -67,20 +109,18 @@
67109
}
68110

69111
.event-popup__header-left {
112+
min-width: 0;
70113
display: flex;
71114
align-items: center;
72115
gap: 12px;
73-
min-width: 0;
74116
}
75-
76117
.event-popup__color-dot {
77118
width: 14px;
78119
height: 14px;
79120
border-radius: 50%;
80121
box-shadow: 0 4px 8px rgba(16, 24, 40, 0.08);
81122
flex: 0 0 14px;
82123
}
83-
84124
.event-popup__header-title {
85125
font-weight: 700;
86126
font-size: 15px;
@@ -108,11 +148,15 @@
108148
justify-content: center;
109149
width: 34px;
110150
height: 34px;
111-
&:hover {
112-
background: rgba(0, 0, 0, 0.04);
113-
color: rgba(0, 0, 0, 0.85);
114-
transform: translateY(-1px);
115-
}
151+
}
152+
.event-popup__close-btn:hover {
153+
background: rgba(0, 0, 0, 0.045);
154+
color: rgba(0, 0, 0, 0.85);
155+
transform: translateY(-2px) scale(1.02);
156+
}
157+
.event-popup__close-btn:focus {
158+
box-shadow: 0 0 0 4px rgba(47, 111, 237, 0.12);
159+
outline: none;
116160
}
117161

118162
.event-popup__row {
@@ -121,7 +165,6 @@
121165
margin-bottom: 10px;
122166
align-items: center;
123167
}
124-
125168
.event-popup__label {
126169
min-width: 84px;
127170
color: rgba(0, 0, 0, 0.58);
@@ -130,7 +173,6 @@
130173
letter-spacing: 0.2px;
131174
flex: 0 0 auto;
132175
}
133-
134176
.event-popup__value {
135177
flex: 1 1 auto;
136178
word-break: break-word;
@@ -148,7 +190,6 @@
148190
background 120ms ease,
149191
transform 80ms ease;
150192
}
151-
152193
.event-popup__link--primary {
153194
background: linear-gradient(
154195
90deg,
@@ -158,7 +199,6 @@
158199
border: 1px solid rgba(47, 111, 237, 0.1);
159200
color: var(--link-color, #2f6fed);
160201
}
161-
162202
.event-popup__link:hover {
163203
background: rgba(47, 111, 237, 0.06);
164204
transform: translateY(-1px);
@@ -172,14 +212,12 @@
172212
align-items: center;
173213
gap: 12px;
174214
}
175-
176215
.event-popup__meta {
177216
display: flex;
178217
gap: 12px;
179218
align-items: center;
180219
color: rgba(0, 0, 0, 0.65);
181220
}
182-
183221
.event-popup__label.small {
184222
min-width: 60px;
185223
font-weight: 600;
@@ -189,13 +227,12 @@
189227
.event-popup__actions {
190228
display: flex;
191229
gap: 8px;
192-
193-
.ant-btn {
194-
padding: 8px 14px;
195-
border-radius: 8px;
196-
font-weight: 600;
197-
box-shadow: none;
198-
}
230+
}
231+
.event-popup__actions .ant-btn {
232+
padding: 8px 14px;
233+
border-radius: 8px;
234+
font-weight: 600;
235+
box-shadow: none;
199236
}
200237

201238
.event-popup__creator {

src/components/EventPopup.jsx

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import React, { useEffect, useRef } from "react";
1+
import React, { useEffect, useRef, useState } from "react";
22
import moment from "jalali-moment";
33
import CalendarEventCreator from "./CalendarEventCreator";
4+
import "../assets/scss/components/_event-popup.scss";
45

56
const EventPopup = ({ visible, anchorRect, date, event, onClose }) => {
67
const popupRef = useRef(null);
78

9+
const [mounted, setMounted] = useState(visible);
10+
const [isOpen, setIsOpen] = useState(false);
11+
812
const justOpenedRef = useRef(false);
913

1014
useEffect(() => {
@@ -37,15 +41,28 @@ const EventPopup = ({ visible, anchorRect, date, event, onClose }) => {
3741
}, [visible, onClose]);
3842

3943
useEffect(() => {
40-
if (!visible) return undefined;
41-
justOpenedRef.current = true;
42-
const t = setTimeout(() => (justOpenedRef.current = false), 120);
44+
if (visible) {
45+
setMounted(true);
46+
47+
const t1 = setTimeout(() => setIsOpen(true), 12);
48+
49+
justOpenedRef.current = true;
50+
const t2 = setTimeout(() => (justOpenedRef.current = false), 140);
51+
return () => {
52+
clearTimeout(t1);
53+
clearTimeout(t2);
54+
};
55+
}
56+
57+
setIsOpen(false);
58+
59+
const t = setTimeout(() => setMounted(false), 300);
4360
return () => clearTimeout(t);
4461
}, [visible]);
4562

46-
if (!visible || !anchorRect) return null;
63+
if (!mounted || !anchorRect) return null;
4764

48-
const defaultWidth = 320;
65+
const defaultWidth = Math.min(380, Math.max(300, anchorRect?.width || 320));
4966
const popupPadding = 12;
5067

5168
let left =
@@ -79,10 +96,14 @@ const EventPopup = ({ visible, anchorRect, date, event, onClose }) => {
7996
? moment(date.toDate()).locale("fa").format("jYYYY/jMM/jDD")
8097
: "-";
8198

99+
const transformOrigin = prefersAbove ? "bottom center" : "top center";
100+
82101
return (
83102
<div
84103
ref={popupRef}
85-
className={`event-popup ${prefersAbove ? "event-popup--above" : ""}`}
104+
className={`event-popup ${prefersAbove ? "event-popup--above" : ""} ${
105+
isOpen ? "is-mounted" : "is-closing"
106+
}`}
86107
style={{
87108
position: "absolute",
88109
top: top,
@@ -93,7 +114,11 @@ const EventPopup = ({ visible, anchorRect, date, event, onClose }) => {
93114
aria-modal="false"
94115
>
95116
<div className="event-popup__arrow" style={{ left: arrowLeft }} />
96-
<div className="event-popup__content">
117+
<div
118+
className={`event-popup__content ${isOpen ? "is-open" : ""}`}
119+
style={{ transformOrigin }}
120+
>
121+
{/* close button removed — using outside click / Escape to close */}
97122
<div className="event-popup__header">
98123
<div className="event-popup__header-left">
99124
{event && (
@@ -162,32 +187,23 @@ const EventPopup = ({ visible, anchorRect, date, event, onClose }) => {
162187
</>
163188
)}
164189

165-
{date && event && (
166-
<div className="event-popup__creator">
167-
<CalendarEventCreator
168-
eventDate={date.format("YYYY-MM-DD")}
169-
eventText={event.fullName || event.title}
170-
/>
171-
</div>
172-
)}
173-
174190
<div className="event-popup__footer">
175191
<div className="event-popup__meta">
176192
<div className="event-popup__label small">زمان</div>
177193
<div className="event-popup__value small">
178-
ساعت ۱۸:۰۰ تا ۱۹:۰۰
194+
{event?.time || "ساعت ۱۸:۰۰ تا ۱۹:۰۰"}
179195
</div>
180196
</div>
197+
</div>
181198

182-
<div className="event-popup__actions">
183-
<button
184-
onClick={onClose}
185-
className="ant-btn ant-btn-primary"
186-
>
187-
بستن
188-
</button>
199+
{date && event && (
200+
<div className="event-popup__creator">
201+
<CalendarEventCreator
202+
eventDate={date.format("YYYY-MM-DD")}
203+
eventText={event.fullName || event.title}
204+
/>
189205
</div>
190-
</div>
206+
)}
191207
</div>
192208
</div>
193209
);

0 commit comments

Comments
 (0)