Skip to content

Commit 9d322df

Browse files
committed
feat: enhance Island component to improve toast management and hover behavior
1 parent bff30fb commit 9d322df

1 file changed

Lines changed: 80 additions & 116 deletions

File tree

src/components/island/Island.tsx

Lines changed: 80 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,85 @@
1-
import {Component, mergeComponentProps} from "../../utils";
2-
import React, {useCallback, useEffect, useRef, useState} from "react";
3-
import {create} from 'zustand'
1+
import {Component, mergeComponentProps} from "../../utils"
2+
import React, {useCallback, useEffect, useRef, useState} from "react"
43
import {motion} from "motion/react"
54
import "./Island.style.scss"
6-
import {Flex} from "../flex/Flex";
5+
import {Flex} from "../flex/Flex"
6+
import {useIsland} from "./Island.hook";
77

88
export interface IslandProps extends Component<HTMLDivElement> {
99
children?: React.ReactNode
1010
}
1111

12-
export interface IslandToast {
13-
id?: number
14-
icon: React.ReactNode
15-
message: React.ReactNode
16-
largeContent?: React.ReactNode
17-
}
18-
1912
export const Island: React.FC<IslandProps> = (props) => {
20-
const {children, ...rest} = props
21-
22-
const firstIsland = useIsland(useCallback(state => state.toasts[0], []));
23-
const removeToast = useIsland(useCallback(state => state.removeToast, []));
2413

25-
const [isHovered, setIsHovered] = useState(false);
26-
const hasLargeContent = !!firstIsland?.largeContent;
27-
const isExpanded = isHovered && hasLargeContent;
14+
const {children, ...rest} = props
2815

16+
const toasts = useIsland(state => state.toasts)
17+
const removeToast = useIsland(state => state.removeToast)
2918

30-
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null);
31-
const startTimeRef = useRef<number>(0);
32-
const remainingTimeRef = useRef<number>(4000);
19+
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null)
20+
const startTimeRef = useRef<number>(0)
21+
const remainingTimeRef = useRef<number>(4000)
22+
const [isHovered, setIsHovered] = useState(false)
23+
const containerRef = useRef<HTMLDivElement | null>(null)
3324

34-
const currentIdRef = useRef<number | undefined>(undefined);
35-
currentIdRef.current = firstIsland?.id;
25+
const firstIsland = toasts.length > 0
26+
? toasts.reduce((highest, current) =>
27+
((current.index ?? 0) > (highest.index ?? 0)) ? current : highest
28+
)
29+
: undefined
3630

3731
const stopTimer = useCallback(() => {
3832
if (timeoutIdRef.current) {
39-
clearTimeout(timeoutIdRef.current);
40-
timeoutIdRef.current = null;
33+
clearTimeout(timeoutIdRef.current)
34+
timeoutIdRef.current = null
4135
}
42-
}, []);
36+
}, [])
4337

4438
const startTimer = useCallback(() => {
45-
const toastId = currentIdRef.current;
46-
if (!toastId) return;
39+
const toastId = firstIsland?.id
40+
if (!toastId) return
4741

48-
stopTimer();
49-
startTimeRef.current = Date.now();
42+
stopTimer()
43+
startTimeRef.current = Date.now()
5044

51-
timeoutIdRef.current = setTimeout(() => {
52-
removeToast(toastId);
53-
}, remainingTimeRef.current);
54-
}, [removeToast, stopTimer]);
45+
if (isFinite(remainingTimeRef.current)) {
46+
timeoutIdRef.current = setTimeout(() => {
47+
removeToast(toastId)
48+
}, remainingTimeRef.current)
49+
}
50+
}, [firstIsland?.id, removeToast, stopTimer])
5551

56-
// Triggert den Timer UND die Icon-Zeichenanimation bei neuem Toast
5752
useEffect(() => {
5853
if (firstIsland?.id) {
59-
remainingTimeRef.current = 4000;
60-
startTimer();
54+
remainingTimeRef.current = firstIsland?.duration ?? 4000
55+
startTimer()
6156
} else {
62-
stopTimer();
63-
setIsHovered(false);
57+
stopTimer()
58+
setIsHovered(false)
6459
}
65-
return () => stopTimer();
66-
}, [firstIsland?.id, startTimer, stopTimer]);
60+
return () => stopTimer()
61+
}, [firstIsland?.id, firstIsland?.duration, startTimer, stopTimer])
6762

6863
const handleMouseEnter = () => {
69-
if (!firstIsland?.id) return;
64+
if (!firstIsland?.id) return
7065

71-
stopTimer();
72-
const elapsedTime = Date.now() - startTimeRef.current;
73-
remainingTimeRef.current = Math.max(0, remainingTimeRef.current - elapsedTime);
66+
stopTimer()
67+
const elapsedTime = Date.now() - startTimeRef.current
68+
remainingTimeRef.current = Math.max(0, remainingTimeRef.current - elapsedTime)
7469

75-
if (hasLargeContent) {
76-
setIsHovered(true);
70+
if (!!firstIsland?.content) {
71+
setIsHovered(true)
7772
}
78-
};
73+
}
7974

8075
const handleMouseLeave = () => {
81-
setIsHovered(false);
82-
if (!firstIsland?.id) return;
76+
setIsHovered(false)
77+
if (!firstIsland?.id) return
8378

8479
if (remainingTimeRef.current > 0) {
85-
startTimer();
80+
startTimer()
8681
}
87-
};
82+
}
8883

8984
return (
9085
<motion.div
@@ -94,82 +89,51 @@ export const Island: React.FC<IslandProps> = (props) => {
9489
transition={{
9590
type: "spring",
9691
stiffness: 300,
97-
damping: 17.5,
98-
mass: 0.7
92+
damping: 20,
93+
mass: 0.8
9994
}}
100-
{...mergeComponentProps(`island`, {
101-
...rest,
102-
style: {
103-
overflow: "hidden",
104-
// Hier verankern wir die Mindestmaße der Box, wie gewünscht
105-
minWidth: "max-content",
106-
minHeight: "max-content",
107-
...rest.style
108-
}
109-
})}
95+
{...mergeComponentProps(`island`, rest)}
11096
>
11197

112-
<div>
113-
{/* --- STANDARD CONTENT --- */}
114-
115-
<Flex align={"center"} style={{gap: "0.35rem"}} key={firstIsland?.id}>
116-
{firstIsland && (
117-
<motion.div
118-
key={firstIsland?.id}
119-
layout
120-
initial={{ y: 0, opacity: 1, strokeDashoffset: "300%" }}
121-
animate={{ y: 0, opacity: 1, strokeDashoffset: "0%" }}
122-
transition={{duration: 0.75, ease: "easeInOut"}}
123-
className={"island__icon"}
124-
>
125-
{firstIsland.icon}
126-
</motion.div>
127-
)}
128-
<motion.div layout>
129-
{children}
98+
<Flex align={"center"} style={{gap: "0.35rem"}} ref={containerRef} key={firstIsland?.id}>
99+
{firstIsland && (
100+
<motion.div
101+
key={firstIsland?.id}
102+
layout
103+
initial={{y: 0, opacity: 1, strokeDashoffset: "300%"}}
104+
animate={{y: 0, opacity: 1, strokeDashoffset: "0%"}}
105+
transition={{duration: 0.75, ease: "easeInOut"}}
106+
className={"island__icon"}
107+
>
108+
{firstIsland.icon}
130109
</motion.div>
110+
)}
111+
<motion.div layout>
112+
{children}
113+
</motion.div>
114+
115+
{firstIsland?.message && <motion.div layout className={"island__message"}>
116+
{firstIsland?.message}
117+
</motion.div>}
118+
</Flex>
131119

132-
{firstIsland?.message && <motion.div layout className={"island__message"}>
133-
{firstIsland?.message}
134-
</motion.div>}
135-
</Flex>
120+
<div style={{
121+
maxWidth: `${containerRef.current?.offsetWidth}px`,
122+
}}>
136123

137-
{/* --- LARGE CONTENT --- */}
138-
{isExpanded && <motion.div
139-
initial={{y: 50, opacity: 0}}
124+
{isHovered && !!firstIsland?.content && <motion.div
125+
initial={{y: 10, opacity: 0}}
140126
animate={{
141-
y: isExpanded ? 0 : 50,
142-
opacity: isExpanded ? 1 : 0
127+
y: isHovered && !!firstIsland?.content ? 0 : 100,
128+
opacity: isHovered && !!firstIsland?.content ? 1 : 0
143129
}}
144130
transition={{duration: 0.25, ease: "easeInOut"}}
145131
className={"island__content"}
146-
style={{
147-
position: "relative",
148-
width: "100%",
149-
height: "100%",
150-
pointerEvents: isExpanded ? "auto" : "none"
151-
}}
152132
>
153-
{firstIsland?.largeContent}
133+
{firstIsland?.content}
154134
</motion.div>}
155135
</div>
156136

157-
158137
</motion.div>
159-
);
160-
};
161-
162-
export const useIsland = create<{
163-
toasts: IslandToast[],
164-
addToast: (toast: IslandToast) => void,
165-
removeToast: (id: number) => void
166-
}>((set) => ({
167-
toasts: [],
168-
addToast: (toast: IslandToast) => {
169-
const id = Date.now();
170-
set(state => ({toasts: [...state.toasts, {id, ...toast}]}));
171-
},
172-
removeToast: (id: number) => {
173-
set(state => ({toasts: state.toasts.filter(t => t.id !== id)}));
174-
}
175-
}));
138+
)
139+
}

0 commit comments

Comments
 (0)