-
Notifications
You must be signed in to change notification settings - Fork 2
[feat/#37] 탭바 컴포넌트 구현 #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
4fd4d49
2c43d36
b75e2f6
ee3b3ea
9393e38
bf1ce23
dff49d0
51fa79e
0c95515
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { style } from "@vanilla-extract/css"; | ||
| import { typographyVars } from "../styles/typography.css"; | ||
|
|
||
| export const content = style({ | ||
| ...typographyVars.heading1, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import * as styles from "./content.css"; | ||
| interface ContentProps { | ||
| type: string; | ||
| } | ||
| export const Content = ({ type }: ContentProps) => { | ||
| return <div className={styles.content}>{type}</div>; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { style } from "@vanilla-extract/css"; | ||
|
|
||
| export const header = style({ | ||
| height: "5.6rem", | ||
| position: "sticky", | ||
| top: 0, | ||
| backgroundColor: "pink", | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import * as styles from "./header.css"; | ||
| export const Header = () => { | ||
| return <div className={styles.header}>헤더</div>; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { style } from "@vanilla-extract/css"; | ||
|
|
||
| export const container = style({ | ||
| border: "1px solid black", | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { Content } from "./content"; | ||
| import * as styles from "./section.css"; | ||
| function Section1() { | ||
| return ( | ||
| <div className={styles.container}> | ||
| {[...Array(20)].map((_, i) => ( | ||
| <Content key={i} type="section1" /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default Section1; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { Content } from "./content"; | ||
| import * as styles from "./section.css"; | ||
| function Section2() { | ||
| return ( | ||
| <div className={styles.container}> | ||
| {[...Array(20)].map((_, i) => ( | ||
| <Content key={i} type="section2" /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default Section2; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { Content } from "./content"; | ||
|
|
||
| import * as styles from "./section.css"; | ||
| function Section3() { | ||
| return ( | ||
| <div className={styles.container}> | ||
| {[...Array(20)].map((_, i) => ( | ||
| <Content key={i} type="section3" /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default Section3; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { color } from "@/shared/styles/tokens/color.css"; | ||
| import { typographyVars } from "@/shared/styles/typography.css"; | ||
| import { style } from "@vanilla-extract/css"; | ||
| import { recipe } from "@vanilla-extract/recipes"; | ||
|
|
||
| export const tabBar = style({ | ||
| position: "sticky", | ||
| top: "5.6rem", // 헤더 높이만큼 띄워서 헤더 바로 아래에 붙도록 | ||
| display: "flex", | ||
| backgroundColor: color.white[100], | ||
| borderBottom: `1px solid ${color.gray[200]}`, | ||
| zIndex: 10, | ||
| }); | ||
|
|
||
| export const tab = recipe({ | ||
| base: { | ||
| flex: 1, | ||
| textAlign: "center", | ||
| ...typographyVars.body1, | ||
| padding: "1.2rem 1rem", | ||
| border: "none", | ||
| background: "none", | ||
| cursor: "pointer", | ||
| color: color.gray[300], | ||
| transition: "color 0.2s", | ||
| position: "relative", | ||
| marginBottom: "-1px", // tabBar의 border를 덮기 위해 | ||
| borderBottom: `2px solid transparent`, | ||
| }, | ||
| variants: { | ||
| active: { | ||
| true: { | ||
| color: color.black[100], | ||
| borderBottom: `2px solid ${color.black[100]}`, | ||
| }, | ||
| }, | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import * as styles from "./tab-bar.css"; | ||
|
|
||
| interface TabBarProps { | ||
| activeTab: "product-info" | "review" | "recommend"; | ||
| onTabClick: (_tab: "product-info" | "review" | "recommend") => void; | ||
| } | ||
|
|
||
| export const TabBar = ({ activeTab, onTabClick }: TabBarProps) => { | ||
| const tabs = [ | ||
| { id: "product-info" as const, label: "작품 정보" }, | ||
| { id: "review" as const, label: "후기 634" }, | ||
| { id: "recommend" as const, label: "추천" }, | ||
| ]; | ||
|
|
||
| return ( | ||
| <div className={styles.tabBar}> | ||
| {tabs.map(({ id, label }) => ( | ||
| <button | ||
| key={id} | ||
| type="button" | ||
| aria-selected={activeTab === id} | ||
| className={styles.tab({ active: activeTab === id })} | ||
| onClick={() => onTabClick(id)}> | ||
| {label} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,81 @@ | ||||||||||||||||||||||||||
| import { useEffect, useRef, useState } from "react"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const HEADER_HEIGHT = 56; | ||||||||||||||||||||||||||
| const TAB_BAR_HEIGHT = 46; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function useScrollableTabs() { | ||||||||||||||||||||||||||
| const productInfoRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||||||||||
| const reviewRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||||||||||
| const recommendRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const [activeTab, setActiveTab] = useState< | ||||||||||||||||||||||||||
| "product-info" | "review" | "recommend" | ||||||||||||||||||||||||||
| >("product-info"); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // IntersectionObserver로 현재 섹션 감지 | ||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||
| const observerOptions = { | ||||||||||||||||||||||||||
| // 섹션이 화면의 중앙 즈음에 올 때 | ||||||||||||||||||||||||||
| rootMargin: `-${window.innerHeight / 2}px 0px -${window.innerHeight / 2}px 0px`, | ||||||||||||||||||||||||||
| threshold: 0, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const observerCallback = (entries: IntersectionObserverEntry[]) => { | ||||||||||||||||||||||||||
| entries.forEach((entry) => { | ||||||||||||||||||||||||||
| if (entry.isIntersecting) { | ||||||||||||||||||||||||||
| const sectionId = entry.target.getAttribute("data-section") as | ||||||||||||||||||||||||||
| | "product-info" | ||||||||||||||||||||||||||
| | "review" | ||||||||||||||||||||||||||
| | "recommend"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (sectionId) { | ||||||||||||||||||||||||||
| setActiveTab(sectionId); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const sectionObserver = new IntersectionObserver( | ||||||||||||||||||||||||||
| observerCallback, | ||||||||||||||||||||||||||
| observerOptions | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 각 섹션 관찰 시작 | ||||||||||||||||||||||||||
| if (productInfoRef.current) sectionObserver.observe(productInfoRef.current); | ||||||||||||||||||||||||||
| if (reviewRef.current) sectionObserver.observe(reviewRef.current); | ||||||||||||||||||||||||||
| if (recommendRef.current) sectionObserver.observe(recommendRef.current); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||
| sectionObserver.disconnect(); | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // 탭 클릭 시 해당 섹션으로 스크롤 | ||||||||||||||||||||||||||
| const handleTabClick = (tab: "product-info" | "review" | "recommend") => { | ||||||||||||||||||||||||||
| const sectionRefMap = { | ||||||||||||||||||||||||||
| "product-info": productInfoRef, | ||||||||||||||||||||||||||
| review: reviewRef, | ||||||||||||||||||||||||||
| recommend: recommendRef, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const targetRef = sectionRefMap[tab]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (targetRef.current) { | ||||||||||||||||||||||||||
| const offsetTop = | ||||||||||||||||||||||||||
| targetRef.current.offsetTop - HEADER_HEIGHT - TAB_BAR_HEIGHT; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| window.scrollTo({ | ||||||||||||||||||||||||||
| top: offsetTop, | ||||||||||||||||||||||||||
| behavior: "smooth", | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
Comment on lines
+62
to
+71
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 감지 대상 상단으로 부터 떨어진 Y축의 값을 나타내는
<div className={styles.test} style={{ position: "relative", marginTop: "100rem" }}>위 코드처럼 부모 요소에 -.Clipchamp.mp4이러한 문제를 방지하기 위해
Suggested change
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 예시까지 보여주셔서 바로 이해가 됐습니다!!!!! 😍 순간 그럼 IntersectionObserver는 왜 정상이지??를 고민했는데, IntersectionObserver는 실제 DOM의 top 값을 사용하는 게 아니라 브라우저가 계산한 뷰포트에 보이는지 여부만 판단해서 문제 없이 동작했던 거였네요 덕분에 아주 확실히 이해했습니다! 말씀해주신 방향으로 바로 수정하겠습니다 :) 감사합니닷 ㅎㅎㅎ |
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| productInfoRef, | ||||||||||||||||||||||||||
| reviewRef, | ||||||||||||||||||||||||||
| recommendRef, | ||||||||||||||||||||||||||
| activeTab, | ||||||||||||||||||||||||||
| handleTabClick, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useScrollableTabs내부에서"product-info" | "review" | "recommend"문자열 리터럴 타입이 많이 활용되는 것 같아요해당 타입들은 별도 타입으로 정의하여 사용하는 것이 타입 안정성면에서 좋을 것 같습니당!!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
섬세하다 섬세한 섬세하다 섬세한
(놓친 부분 세세히 봐주셔서 감사해요 😍)