Skip to content

Commit 1e0b026

Browse files
authored
Merge pull request #695 from code0-tech/feat/island
Adding island component
2 parents bfa64c0 + fe86208 commit 1e0b026

7 files changed

Lines changed: 671 additions & 300 deletions

File tree

package-lock.json

Lines changed: 330 additions & 294 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"@radix-ui/react-tabs": "^1.1.13",
4242
"@radix-ui/react-toggle-group": "^1.1.11",
4343
"@radix-ui/react-tooltip": "^1.2.8",
44+
"@radix-ui/react-progress": "^1.1.8",
4445
"@rollup/plugin-commonjs": "^29.0.2",
4546
"@rollup/plugin-node-resolve": "^16.0.3",
4647
"@rollup/plugin-terser": "^1.0.0",
@@ -93,7 +94,9 @@
9394
"vite": "^7.3.1",
9495
"vite-plugin-dts": "^4.5.4",
9596
"vite-plugin-lib-inject-css": "^2.2.2",
96-
"vitest": "^4.1.2"
97+
"vitest": "^4.1.2",
98+
"motion": "^12.40.0",
99+
"zustand": "^5.0.13"
97100
},
98101
"main": "dist/index.js",
99102
"repository": {
@@ -124,6 +127,7 @@
124127
"@radix-ui/react-tabs": "^1.1.13",
125128
"@radix-ui/react-toggle-group": "^1.1.11",
126129
"@radix-ui/react-tooltip": "^1.2.8",
130+
"@radix-ui/react-progress": "^1.1.8",
127131
"@tabler/icons-react": "3.41.1",
128132
"@uiw/codemirror-themes": "^4.25.4",
129133
"@uiw/react-codemirror": "^4.25.4",
@@ -136,12 +140,11 @@
136140
"react-dom": "^18.0.0 || ^19.0.0",
137141
"react-resizable-panels": "^4.3.1",
138142
"react-zoom-pan-pinch": "^3.7.0",
139-
"sonner": "^2.0.7"
143+
"sonner": "^2.0.7",
144+
"motion": "^12.40.0",
145+
"zustand": "^5.0.13"
140146
},
141147
"publishConfig": {
142148
"access": "public"
143-
},
144-
"dependencies": {
145-
"@radix-ui/react-progress": "^1.1.8"
146149
}
147150
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {create} from "zustand";
2+
import React from "react";
3+
import {IconAlertCircle, IconCircleCheck, IconCircleX, IconInfoCircle} from "@tabler/icons-react";
4+
import {Text} from "../text/Text";
5+
6+
export interface IslandNotificationProps {
7+
id?: number
8+
icon: React.ReactNode
9+
message: React.ReactNode
10+
content?: React.ReactNode
11+
duration?: number
12+
index?: number
13+
}
14+
15+
export const useIsland = create<{
16+
toasts: IslandNotificationProps[],
17+
addToast: (toast: IslandNotificationProps) => void,
18+
removeToast: (id: number) => void
19+
}>((set) => ({
20+
toasts: [],
21+
addToast: (toast: IslandNotificationProps) => {
22+
const id = Date.now()
23+
set(state => ({toasts: [...state.toasts, {id, ...toast}]}))
24+
},
25+
removeToast: (id: number) => {
26+
set(state => ({toasts: state.toasts.filter(t => t.id !== id)}))
27+
}
28+
}))
29+
30+
export const addIslandNotification = (toast: IslandNotificationProps) => {
31+
useIsland.getState().addToast(toast)
32+
}
33+
34+
export const addIslandInfoNotification = (toast: Partial<IslandNotificationProps>) => {
35+
useIsland.getState().addToast({
36+
icon: <IconInfoCircle color={"#70ffb2"} size={16}/>,
37+
message: <Text c={"#70ffb2"}>{toast.message}</Text>,
38+
...toast
39+
})
40+
}
41+
42+
export const addIslandSuccessNotification = (toast: Partial<IslandNotificationProps>) => {
43+
useIsland.getState().addToast({
44+
icon: <IconCircleCheck color={"#29BF12"} size={16}/>,
45+
message: <Text c={"#29BF12"}>{toast.message}</Text>,
46+
index: 1,
47+
...toast
48+
})
49+
}
50+
51+
export const addIslandWarningNotification = (toast: Partial<IslandNotificationProps>) => {
52+
useIsland.getState().addToast({
53+
icon: <IconAlertCircle color={"#FFBE0B"} size={16}/>,
54+
message: <Text c={"#FFBE0B"}>{toast.message}</Text>,
55+
index: 2,
56+
...toast
57+
})
58+
}
59+
60+
export const addIslandErrorNotification = (toast: Partial<IslandNotificationProps>) => {
61+
useIsland.getState().addToast({
62+
icon: <IconCircleX color={"#D90429"} size={16}/>,
63+
message: <Text c={"#D90429"}>{toast.message}</Text>,
64+
index: 3,
65+
...toast
66+
})
67+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {Meta} from "@storybook/react-vite";
2+
import React from "react";
3+
import {Island} from "./Island";
4+
import {ButtonGroup} from "../button-group/ButtonGroup";
5+
import {Button} from "../button/Button";
6+
import {Text} from "../text/Text";
7+
import {IconCircleCheck, IconCircleX} from "@tabler/icons-react";
8+
import {Flex} from "../flex/Flex";
9+
import {FullScreen} from "../fullscreen/FullScreen";
10+
import {Progress} from "../progress/Progress";
11+
import {addIslandNotification} from "./Island.hook";
12+
13+
export default {
14+
title: "Island",
15+
parameters: {
16+
visualTest: {
17+
selector: 'body'
18+
},
19+
layout: "fullscreen"
20+
},
21+
} as Meta
22+
23+
export const IslandExample = () => {
24+
25+
return <FullScreen>
26+
<Flex pos={"fixed"} top={"1rem"} left={"0"} w={"100%"} justify={"center"} style={{zIndex: 9999}}>
27+
<Island>
28+
<ButtonGroup color={"primary"} bg={"transparent"} style={{boxShadow: "none"}}>
29+
<Button paddingSize={"xxs"} variant={"none"}>
30+
<Text>Home</Text>
31+
</Button>
32+
<Button paddingSize={"xxs"} variant={"none"}>
33+
<Text>Organizations</Text>
34+
</Button>
35+
<Button paddingSize={"xxs"} variant={"none"}>
36+
<Text>Projects</Text>
37+
</Button>
38+
</ButtonGroup>
39+
</Island>
40+
</Flex>
41+
<Flex pos={"fixed"} bottom={"1rem"} left={"0"} w={"100%"} justify={"center"} style={{zIndex: 9999}}>
42+
<ButtonGroup color={"primary"}>
43+
<Button onClick={() => {
44+
addIslandNotification({
45+
icon: <Text c={"#70ffb2"}>Approaching limit</Text>,
46+
message: <Progress w={"100px"} h={"7.5px"} value={30} predictionValue={75} max={100}
47+
color={"#70ffb2"}/>,
48+
content: <Flex w={"100%"} style={{flexDirection: "column", gap: "0.7rem"}}>
49+
<Text>
50+
You used 50% of your available workflow executions and will used 75% until its reseted.
51+
</Text>
52+
<Flex align={"center"} style={{gap: "0.7rem"}}>
53+
<Button w={"100%"}>
54+
Add new license
55+
</Button>
56+
<Button w={"100%"}>
57+
Buy new license
58+
</Button>
59+
</Flex>
60+
</Flex>
61+
})
62+
}}>
63+
Info
64+
</Button>
65+
<Button onClick={() => {
66+
addIslandNotification({
67+
icon: <IconCircleCheck color={"#29BF12"} size={16}/>,
68+
message: <Text c={"#29BF12"}>Added license</Text>,
69+
})
70+
}}>
71+
Success
72+
</Button>
73+
<Button onClick={() => {
74+
addIslandNotification({
75+
icon: <IconCircleX color={"#D90429"} size={16}/>,
76+
message: <Text c={"#D90429"}>Internal error</Text>,
77+
index: 1
78+
})
79+
}}>
80+
Error
81+
</Button>
82+
</ButtonGroup>
83+
</Flex>
84+
</FullScreen>
85+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
@use "../../styles/helpers";
2+
@use "../../styles/box";
3+
@use "../../styles/variables";
4+
5+
.island {
6+
@include box.box(variables.$primary, variables.$white, variables.$primary);
7+
@include helpers.borderRadius();
8+
cursor: pointer;
9+
overflow: hidden;
10+
max-width: max-content;
11+
min-height: max-content;
12+
13+
* {
14+
box-sizing: border-box;
15+
}
16+
17+
&__icon {
18+
--path-offset: 300%;
19+
display: inline-flex;
20+
21+
padding-left: variables.$xs;
22+
23+
& svg path {
24+
stroke-dasharray: 300%;
25+
stroke-dashoffset: inherit;
26+
}
27+
}
28+
29+
&__message {
30+
padding-right: variables.$xs;
31+
}
32+
33+
&__content {
34+
padding: variables.$xs;
35+
position: relative;
36+
width: 100%;
37+
max-width: 100%;
38+
height: 100%;
39+
overflow: hidden;
40+
}
41+
}

src/components/island/Island.tsx

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import {Component, mergeComponentProps} from "../../utils"
2+
import React, {useCallback, useEffect, useRef, useState} from "react"
3+
import {motion} from "motion/react"
4+
import "./Island.style.scss"
5+
import {Flex} from "../flex/Flex"
6+
import {useIsland} from "./Island.hook";
7+
8+
export interface IslandProps extends Component<HTMLDivElement> {
9+
children?: React.ReactNode
10+
}
11+
12+
export const Island: React.FC<IslandProps> = (props) => {
13+
14+
const {children, ...rest} = props
15+
16+
const toasts = useIsland(state => state.toasts)
17+
const removeToast = useIsland(state => state.removeToast)
18+
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)
24+
25+
const firstIsland = toasts.length > 0
26+
? toasts.reduce((highest, current) =>
27+
((current.index ?? 0) > (highest.index ?? 0)) ? current : highest
28+
)
29+
: undefined
30+
31+
const stopTimer = useCallback(() => {
32+
if (timeoutIdRef.current) {
33+
clearTimeout(timeoutIdRef.current)
34+
timeoutIdRef.current = null
35+
}
36+
}, [])
37+
38+
const startTimer = useCallback(() => {
39+
const toastId = firstIsland?.id
40+
if (!toastId) return
41+
42+
stopTimer()
43+
startTimeRef.current = Date.now()
44+
45+
if (isFinite(remainingTimeRef.current)) {
46+
timeoutIdRef.current = setTimeout(() => {
47+
removeToast(toastId)
48+
}, remainingTimeRef.current)
49+
}
50+
}, [firstIsland?.id, removeToast, stopTimer])
51+
52+
useEffect(() => {
53+
if (firstIsland?.id) {
54+
remainingTimeRef.current = firstIsland?.duration ?? 4000
55+
startTimer()
56+
} else {
57+
stopTimer()
58+
setIsHovered(false)
59+
}
60+
return () => stopTimer()
61+
}, [firstIsland?.id, firstIsland?.duration, startTimer, stopTimer])
62+
63+
const handleMouseEnter = () => {
64+
if (!firstIsland?.id) return
65+
66+
stopTimer()
67+
const elapsedTime = Date.now() - startTimeRef.current
68+
remainingTimeRef.current = Math.max(0, remainingTimeRef.current - elapsedTime)
69+
70+
if (!!firstIsland?.content) {
71+
setIsHovered(true)
72+
}
73+
}
74+
75+
const handleMouseLeave = () => {
76+
setIsHovered(false)
77+
if (!firstIsland?.id) return
78+
79+
if (remainingTimeRef.current > 0) {
80+
startTimer()
81+
}
82+
}
83+
84+
return (
85+
<motion.div
86+
layout
87+
onMouseEnter={handleMouseEnter}
88+
onMouseLeave={handleMouseLeave}
89+
transition={{
90+
type: "spring",
91+
stiffness: 300,
92+
damping: 20,
93+
mass: 0.8
94+
}}
95+
{...mergeComponentProps(`island`, rest)}
96+
>
97+
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}
109+
</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>
119+
120+
<div style={{
121+
maxWidth: `${containerRef.current?.offsetWidth}px`,
122+
}}>
123+
124+
{isHovered && !!firstIsland?.content && <motion.div
125+
initial={{y: 10, opacity: 0}}
126+
animate={{
127+
y: isHovered && !!firstIsland?.content ? 0 : 100,
128+
opacity: isHovered && !!firstIsland?.content ? 1 : 0
129+
}}
130+
transition={{duration: 0.25, ease: "easeInOut"}}
131+
className={"island__content"}
132+
>
133+
{firstIsland?.content}
134+
</motion.div>}
135+
</div>
136+
137+
</motion.div>
138+
)
139+
}

src/components/progress/Progress.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const ProgressExample = () => {
1919
You used 50% of your available workflow executions and will used 75% until its reseted.
2020
</Text>
2121
<Spacing spacing={"xl"}/>
22-
<Progress value={30} predictionValue={70} max={100} color={"#70ffb2"}/>
22+
<Progress value={30} max={100} color={"#70ffb2"}/>
2323
<Spacing spacing={"xs"}/>
2424
<Text>
2525
You used 30% of your available workflow executions and will used 120% until its reseted.

0 commit comments

Comments
 (0)