Skip to content
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

feat: Sidebar에 스크롤 시 특정 offset에서 화면에 고정되는 sticky 기능을 추가합니다. #124

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions app/(home)/@tags/(tags)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import type { PropsWithChildren } from "react";

import { Sidebar } from "components/shared/ui/sidebar";
import { HEADER_HEIGHT } from "constants/header";

const TAGS_TOP = 52;
const TAGS_STICKY_OFFSET = TAGS_TOP + HEADER_HEIGHT;

const TagsLayout = ({ children }: PropsWithChildren) => {
return (
<div className="absolute left-[100%] top-[52px] pl-10">
<div className="w-56 rounded-md bg-zinc-100 px-3 py-2">
<p className="mb-3 text-base font-medium text-zinc-800">Popular Tags</p>
{children}
</div>
</div>
<Sidebar.Root align="right" top={TAGS_TOP}>
<Sidebar.Sticky offset={TAGS_STICKY_OFFSET}>
<Sidebar.Content>
<Sidebar.Title>Popular Tags</Sidebar.Title>

{children}
</Sidebar.Content>
</Sidebar.Sticky>
</Sidebar.Root>
);
};

Expand Down
14 changes: 14 additions & 0 deletions components/shared/ui/sidebar/content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ComponentPropsWithoutRef } from "react";
import { forwardRef } from "react";

import { clsx } from "lib/clsx";

type Props = ComponentPropsWithoutRef<"div">;

export const Content = forwardRef<HTMLDivElement, Props>(({ children, className, ...rest }, ref) => {
return (
<div ref={ref} {...rest} className={clsx("w-56 rounded-md bg-zinc-100 px-3 py-2", className)}>
{children}
</div>
);
});
11 changes: 11 additions & 0 deletions components/shared/ui/sidebar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Content } from "./content";
import { Root } from "./root";
import { Sticky } from "./sticky";
import { Title } from "./title";

export const Sidebar = {
Root,
Content,
Title,
Sticky,
};
35 changes: 35 additions & 0 deletions components/shared/ui/sidebar/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ComponentPropsWithoutRef } from "react";
import { forwardRef } from "react";

import { clsx } from "lib/clsx";
import type { Override } from "types/utilities";

const enum Align {
LEFT = "left",
RIGHT = "right",
}

type AlignValue = "left" | "right";

type BaseProps = {
align: AlignValue;
top?: number;
};

type Props = Override<ComponentPropsWithoutRef<"div">, BaseProps>;

export const Root = forwardRef<HTMLDivElement, Props>(({ children, className, align, top = 0, ...rest }, ref) => {
return (
<div ref={ref} style={{ top }} {...rest} className={clsx(styles.base, styles.align[align], className)}>
<div className="relative">{children}</div>
</div>
);
});

const styles = {
base: "absolute px-10",
align: {
[Align.LEFT]: "right-[100%]",
[Align.RIGHT]: "left-[100%]",
},
} as const;
28 changes: 28 additions & 0 deletions components/shared/ui/sidebar/sticky.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import type { ComponentPropsWithoutRef } from "react";
import { forwardRef } from "react";

import { useIsOverlap } from "hooks/use-is-overlap";
import { clsx } from "lib/clsx";

type Props = ComponentPropsWithoutRef<"div"> & {
offset: number;
};

export const Sticky = forwardRef<HTMLDivElement, Props>(({ children, className, offset, ...rest }, ref) => {
const [targetRef, isOverlap] = useIsOverlap<HTMLDivElement>(offset);

return (
<div ref={targetRef} className="absolute inset-0">
<div
ref={ref}
style={{ top: isOverlap ? offset : "auto" }}
{...rest}
className={clsx({ fixed: isOverlap }, className)}
>
{children}
</div>
</div>
);
});
14 changes: 14 additions & 0 deletions components/shared/ui/sidebar/title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { ComponentPropsWithoutRef } from "react";
import { forwardRef } from "react";

import { clsx } from "lib/clsx";

type Props = ComponentPropsWithoutRef<"h3">;

export const Title = forwardRef<HTMLHeadingElement, Props>(({ children, className, ...rest }, ref) => {
return (
<h3 ref={ref} {...rest} className={clsx("mb-3 text-base font-medium text-zinc-800", className)}>
{children}
</h3>
);
});
1 change: 1 addition & 0 deletions constants/header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const HEADER_HEIGHT = 40;
24 changes: 24 additions & 0 deletions hooks/use-is-overlap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { MutableRefObject } from "react";
import { useCallback, useRef, useState } from "react";

import { useWindowEventListener } from "hooks/use-window-event-listener";

export const useIsOverlap = <T extends HTMLElement>(offset: number): [ref: MutableRefObject<T | null>, boolean] => {
const [isOverlap, setIsOverlap] = useState<boolean>(false);
const ref = useRef<T | null>(null);

const handleScroll = useCallback(() => {
if (ref.current === null) {
return;
}

const rect = ref.current.getBoundingClientRect();

setIsOverlap(rect.top <= offset);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

useWindowEventListener("scroll", handleScroll);

return [ref, isOverlap];
};
14 changes: 14 additions & 0 deletions hooks/use-window-event-listener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* global WindowEventMap */

import { useEffect } from "react";

export const useWindowEventListener = <K extends keyof WindowEventMap>(
type: K,
listener: (ev: WindowEventMap[K]) => any,
) => {
useEffect(() => {
window.addEventListener(type, listener);

return () => window.removeEventListener(type, listener);
}, [type, listener]);
};
Loading