-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: optimize school activity button
- Loading branch information
Showing
2 changed files
with
263 additions
and
69 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,130 +1,271 @@ | ||
import { useState } from "react"; | ||
import { useState, useCallback, memo, useEffect, useRef } from "react"; | ||
import { IoArrowForward } from "react-icons/io5"; | ||
import { BiCodeAlt, BiMath } from "react-icons/bi"; | ||
import { HiOutlineAcademicCap } from "react-icons/hi"; | ||
import { DiDatabase } from "react-icons/di"; | ||
import * as S from "./style"; | ||
import { AnimatePresence } from "framer-motion"; | ||
import { AiFillRobot } from "react-icons/ai"; | ||
import { AnimatePresence } from "framer-motion"; | ||
import * as S from "./style"; | ||
|
||
// Types | ||
interface Activity { | ||
id: string; | ||
name: string; | ||
description: string; | ||
path: string; | ||
icon: JSX.Element; | ||
readonly id: string; | ||
readonly name: string; | ||
readonly description: string; | ||
readonly path: string; | ||
readonly icon: JSX.Element; | ||
} | ||
|
||
const activities: Activity[] = [ | ||
interface ActivityItemProps extends Omit<Activity, "id"> { | ||
onClick?: () => void; | ||
} | ||
|
||
// Constants | ||
const EMPHASIS_THRESHOLD = 0.9; // 90% for emphasis | ||
|
||
// Animation variants | ||
const menuVariants = { | ||
hidden: { opacity: 0, y: 20, scale: 0.95 }, | ||
visible: { | ||
opacity: 1, | ||
y: 0, | ||
scale: 1, | ||
transition: { duration: 0.2 }, | ||
}, | ||
exit: { | ||
opacity: 0, | ||
y: 20, | ||
scale: 0.95, | ||
transition: { duration: 0.2 }, | ||
}, | ||
}; | ||
|
||
const arrowVariants = { | ||
hidden: { opacity: 0, x: 0 }, | ||
visible: { opacity: 1, x: 4 }, | ||
}; | ||
|
||
const emphasisVariants = { | ||
normal: { | ||
scale: 1, | ||
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)", | ||
}, | ||
emphasis: { | ||
scale: 1.05, | ||
boxShadow: "0 8px 12px rgba(0, 0, 0, 0.15)", | ||
transition: { | ||
duration: 0.3, | ||
ease: "easeOut", | ||
}, | ||
}, | ||
}; | ||
|
||
const activities: readonly Activity[] = [ | ||
{ | ||
id: "narsha", | ||
name: "나르샤", | ||
description: "교내 IT 프로젝트", | ||
path: "/narsha", | ||
icon: <BiCodeAlt size={20} />, | ||
icon: <BiCodeAlt size={20} aria-hidden="true" />, | ||
}, | ||
{ | ||
id: "database", | ||
name: "데이터베이스 수업", | ||
description: "데이터베이스 수업 프로젝트", | ||
path: "/database", | ||
icon: <DiDatabase size={20} />, | ||
icon: <DiDatabase size={20} aria-hidden="true" />, | ||
}, | ||
{ | ||
id: "algorithm", | ||
name: "자료구조 수업", | ||
description: "자료구조 수업 프로젝트", | ||
path: "/algorithm", | ||
icon: <HiOutlineAcademicCap size={20} />, | ||
icon: <HiOutlineAcademicCap size={20} aria-hidden="true" />, | ||
}, | ||
{ | ||
id: "ai", | ||
name: "인공지능 수업", | ||
description: "인공지능 수업 프로젝트", | ||
path: "/ai", | ||
icon: <AiFillRobot size={20} />, | ||
icon: <AiFillRobot size={20} aria-hidden="true" />, | ||
}, | ||
{ | ||
id: "math", | ||
name: "수학 수업", | ||
description: "수학 수업 프로젝트", | ||
path: "/math", | ||
icon: <BiMath size={20} />, | ||
icon: <BiMath size={20} aria-hidden="true" />, | ||
}, | ||
]; | ||
|
||
const ActivityItem = ({ | ||
name, | ||
description, | ||
path, | ||
icon, | ||
}: Omit<Activity, "id">) => { | ||
const [showArrow, setShowArrow] = useState(false); | ||
const ActivityItem = memo( | ||
({ name, description, path, icon, onClick }: ActivityItemProps) => { | ||
const [showArrow, setShowArrow] = useState(false); | ||
|
||
return ( | ||
<S.Item | ||
to={path} | ||
onMouseEnter={() => setShowArrow(true)} | ||
onMouseLeave={() => setShowArrow(false)} | ||
whileHover={{ scale: 1.02 }} | ||
whileTap={{ scale: 0.98 }} | ||
> | ||
<S.IconBox>{icon}</S.IconBox> | ||
<S.Content> | ||
<S.Name>{name}</S.Name> | ||
<S.ItemDescription>{description}</S.ItemDescription> | ||
</S.Content> | ||
<S.Arrow | ||
animate={{ | ||
opacity: showArrow ? 1 : 0, | ||
x: showArrow ? 4 : 0, | ||
}} | ||
initial={false} | ||
return ( | ||
<S.Item | ||
to={path} | ||
onMouseEnter={() => setShowArrow(true)} | ||
onMouseLeave={() => setShowArrow(false)} | ||
onClick={onClick} | ||
whileHover={{ scale: 1.02 }} | ||
whileTap={{ scale: 0.98 }} | ||
role="link" | ||
aria-label={`${name} - ${description}`} | ||
> | ||
<IoArrowForward /> | ||
</S.Arrow> | ||
</S.Item> | ||
); | ||
}; | ||
<S.IconBox>{icon}</S.IconBox> | ||
<S.Content> | ||
<S.Name>{name}</S.Name> | ||
<S.ItemDescription>{description}</S.ItemDescription> | ||
</S.Content> | ||
<S.Arrow | ||
variants={arrowVariants} | ||
animate={showArrow ? "visible" : "hidden"} | ||
initial={false} | ||
> | ||
<IoArrowForward aria-hidden="true" /> | ||
</S.Arrow> | ||
</S.Item> | ||
); | ||
} | ||
); | ||
|
||
ActivityItem.displayName = "ActivityItem"; | ||
|
||
const SchoolActivityButton = () => { | ||
const [isOpen, setIsOpen] = useState(false); | ||
const [scrollProgress, setScrollProgress] = useState(0); | ||
const [showEmphasis, setShowEmphasis] = useState(false); | ||
const buttonRef = useRef<HTMLButtonElement>(null); | ||
const hasShownEmphasis = useRef(false); | ||
|
||
useEffect(() => { | ||
const handleScroll = () => { | ||
const windowHeight = window.innerHeight; | ||
const documentHeight = document.documentElement.scrollHeight; | ||
const scrollTop = window.scrollY; | ||
const progress = scrollTop / (documentHeight - windowHeight); | ||
|
||
setScrollProgress(progress); | ||
|
||
if (progress >= EMPHASIS_THRESHOLD && !hasShownEmphasis.current) { | ||
setShowEmphasis(true); | ||
hasShownEmphasis.current = true; | ||
|
||
setTimeout(() => { | ||
setShowEmphasis(false); | ||
}, 3000); | ||
} | ||
}; | ||
|
||
window.addEventListener("scroll", handleScroll); | ||
handleScroll(); // Initial check | ||
|
||
return () => window.removeEventListener("scroll", handleScroll); | ||
}, []); | ||
|
||
const handleToggle = useCallback(() => { | ||
setIsOpen((prev) => !prev); | ||
setShowEmphasis(false); | ||
}, []); | ||
|
||
const handleItemClick = useCallback(() => { | ||
setIsOpen(false); | ||
}, []); | ||
|
||
useEffect(() => { | ||
const handleEscape = (e: KeyboardEvent) => { | ||
if (e.key === "Escape" && isOpen) { | ||
setIsOpen(false); | ||
} | ||
}; | ||
|
||
const handleClickOutside = (e: MouseEvent) => { | ||
if ( | ||
buttonRef.current && | ||
!buttonRef.current.contains(e.target as Node) && | ||
isOpen | ||
) { | ||
setIsOpen(false); | ||
} | ||
}; | ||
|
||
window.addEventListener("keydown", handleEscape); | ||
document.addEventListener("mousedown", handleClickOutside); | ||
|
||
return () => { | ||
window.removeEventListener("keydown", handleEscape); | ||
document.removeEventListener("mousedown", handleClickOutside); | ||
}; | ||
}, [isOpen]); | ||
|
||
return ( | ||
<S.Container> | ||
<AnimatePresence> | ||
{isOpen && ( | ||
<S.Menu | ||
initial={{ opacity: 0, y: 20, scale: 0.95 }} | ||
animate={{ opacity: 1, y: 0, scale: 1 }} | ||
exit={{ opacity: 0, y: 20, scale: 0.95 }} | ||
transition={{ duration: 0.2 }} | ||
variants={menuVariants} | ||
initial="hidden" | ||
animate="visible" | ||
exit="exit" | ||
role="dialog" | ||
aria-label="School Activities Menu" | ||
> | ||
<S.Header> | ||
<S.Title>School Activities</S.Title> | ||
<S.Description> | ||
학교 생활과 관련된 다양한 활동들을 소개합니다. | ||
</S.Description> | ||
</S.Header> | ||
<S.List> | ||
<S.List role="list"> | ||
{activities.map((activity) => ( | ||
<ActivityItem key={activity.id} {...activity} /> | ||
<ActivityItem | ||
key={activity.id} | ||
{...activity} | ||
onClick={handleItemClick} | ||
/> | ||
))} | ||
</S.List> | ||
</S.Menu> | ||
)} | ||
</AnimatePresence> | ||
<S.Button | ||
onClick={() => setIsOpen(!isOpen)} | ||
isOpen={isOpen} | ||
whileHover={{ scale: 1.05 }} | ||
whileTap={{ scale: 0.95 }} | ||
> | ||
<HiOutlineAcademicCap size={20} /> | ||
<span>School Activities</span> | ||
</S.Button> | ||
<S.ButtonWrapper> | ||
{showEmphasis && ( | ||
<S.EmphasisHint | ||
initial={{ opacity: 0, y: 10 }} | ||
animate={{ opacity: 1, y: 0 }} | ||
exit={{ opacity: 0, y: 10 }} | ||
> | ||
클릭해서 더 많은 활동을 확인해보세요! | ||
</S.EmphasisHint> | ||
)} | ||
<S.Button | ||
ref={buttonRef} | ||
onClick={handleToggle} | ||
isOpen={isOpen} | ||
visibility={scrollProgress} | ||
animate={{ | ||
...(scrollProgress >= EMPHASIS_THRESHOLD | ||
? emphasisVariants.emphasis | ||
: emphasisVariants.normal), | ||
transition: { | ||
type: "spring", | ||
stiffness: 260, | ||
damping: 20, | ||
}, | ||
}} | ||
whileHover={{ scale: 1.05 }} | ||
whileTap={{ scale: 0.95 }} | ||
aria-expanded={isOpen} | ||
aria-haspopup="dialog" | ||
initial={{ opacity: 1, scale: 1 }} | ||
> | ||
<HiOutlineAcademicCap size={20} aria-hidden="true" /> | ||
<span>School Activities</span> | ||
</S.Button> | ||
</S.ButtonWrapper> | ||
</S.Container> | ||
); | ||
}; | ||
|
||
export default SchoolActivityButton; | ||
export default memo(SchoolActivityButton); |
Oops, something went wrong.