Skip to content

Commit

Permalink
feat: optimize school activity button
Browse files Browse the repository at this point in the history
  • Loading branch information
jbj338033 committed Dec 10, 2024
1 parent e618e29 commit d8ebd90
Show file tree
Hide file tree
Showing 2 changed files with 263 additions and 69 deletions.
263 changes: 202 additions & 61 deletions src/components/home/SchoolActivityButton/index.tsx
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);
Loading

0 comments on commit d8ebd90

Please sign in to comment.