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

[TASK-46, 47] style: Pagination, FilterDropdown 구현 #10

Merged
merged 12 commits into from
Dec 23, 2024
Merged
6 changes: 2 additions & 4 deletions src/components/Button/CategoryButton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { CategoryButtonProps } from '@/types';

import { FC } from 'react';

const CategoryButton: FC<CategoryButtonProps> = ({
const CategoryButton = ({
backgroundColor = 'bg-gray-800',
textColor = 'text-gray-300',
textSize,
children,

onClick,
ariaLabel,
}) => {
}: CategoryButtonProps) => {
return (
<button
onClick={onClick}
Expand Down
6 changes: 3 additions & 3 deletions src/components/Button/FollowButton.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
'use client';

import { FC, useState } from 'react';
import { useState } from 'react';

import { FollowButtonProps } from '@/types/buttons/FollowButtonProps';

import Icon from '../Icon/Icon';

const FollowButton: FC<FollowButtonProps> = ({
const FollowButton = ({
backgroundColor = 'bg-gray-900',
textColor = 'text-white',
textSize = 'button-s',

onClick,
ariaLabel,
}) => {
}: FollowButtonProps) => {
const [isFollowing, setIsFollowing] = useState(false);

const toggleFollow = () => {
Expand Down
6 changes: 2 additions & 4 deletions src/components/Button/SquareButtonL.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { SquareButtonLProps } from '@/types';

import { FC } from 'react';

const SquareButtonL: FC<SquareButtonLProps> = ({
const SquareButtonL = ({
backgroundColor = 'bg-gray-800',
textColor = 'text-white',
textSize,
Expand All @@ -15,7 +13,7 @@ const SquareButtonL: FC<SquareButtonLProps> = ({
icon,
iconPosition,
type = 'button',
}) => {
}: SquareButtonLProps) => {
return (
<button
type={type}
Expand Down
83 changes: 83 additions & 0 deletions src/components/FilterDropdown/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use client';

import { useEffect, useRef, useState } from 'react';

import Icon from '../Icon/Icon';

interface FilterDropdownProps {
options: string[];
selected: string;
onChange: (value: string) => void;
}

const FilterDropdown = ({
options,
selected,
onChange,
}: FilterDropdownProps) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
Comment on lines +21 to +33
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에서 직접 이벤트를 할당해주신 이유가 있을까요?

Copy link
Collaborator Author

@dahyeo-n dahyeo-n Dec 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event를 직접 할당한 이유는 mousedown 이벤트 객체를 활용하기 위해서예요!

더 자세히 설명하면,

  1. event.target을 확인하기 위해서
    : event.target은 사용자가 클릭한 HTML 요소를 참조해요.

  2. 타입 안정성을 위해서 eventMouseEvent 타입을 명시했어요.

따라서, event를 명시적으로 사용하지 않으면 클릭된 요소를 알 수 없어서 외부 클릭 감지 기능이 제대로 작동되지 않을 수도 있어요!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

설명 감사합니다! 코드를 리뷰하면서 onBlur를 활용할 수 있지 않을까 생각도 해봤는데 onBlur를 포커싱이 불가능한 요소에 적용했을 때는 별도로 추가 처리가 필요해져서 onBlur의 의도와는 다르게 억지로 활용하는 느낌이라 다현님께서 구현한 방식이 더 괜찮겠다는 생각을 했습니다! 👍👍👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오옹 감사합니다! 답변드리며 저도 더 공부되는 느낌이라 너무 좋습니다 👍🏻✨


const handleOptionSelect = (option: string) => {
onChange(option);
setIsDropdownOpen(false);
};

return (
<div className='body2 relative inline-block text-left' ref={dropdownRef}>
<button
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
aria-label={`Currently selected filter: ${selected}`}
aria-expanded={isDropdownOpen}
className='flex items-center justify-between w-[149px] h-[44px] px-[23px] py-[10px]
text-white bg-gray-900 rounded-[5px] gap-[36px] shadow-sm
hover:bg-gray-800 focus:outline-none'
>
{selected}
<span className='text-gray-300'>
{isDropdownOpen ? (
<Icon name='ChevronUp' size='m' />
) : (
<Icon name='ChevronDown' size='m' />
)}
</span>
</button>

{isDropdownOpen && (
<div
className='w-full mt-3 bg-gray-900 rounded-[5px] shadow-lg'
role='menu'
>
<ul className='py-1'>
{options.map((option, index) => (
<li
key={index}
onClick={() => handleOptionSelect(option)}
aria-current={option === selected ? 'true' : undefined}
className={`block w-[149px] h-[44px] px-[23px] py-[10px] text-gray-400 cursor-pointer hover:bg-gray-800`}
>
{option}
</li>
))}
</ul>
</div>
)}
</div>
);
};

export default FilterDropdown;
9 changes: 2 additions & 7 deletions src/components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ComponentType, FC } from 'react';
import { ComponentType } from 'react';

import iconSizes, { IconSize } from './iconSizes';
import { iconsNames } from './iconsNames';
Expand All @@ -10,12 +10,7 @@ interface IconProps {
onClick?: () => void;
}

const Icon: FC<IconProps> = ({
name,
size = 'm',
className = '',
onClick,
}: IconProps) => {
const Icon = ({ name, size = 'm', className = '', onClick }: IconProps) => {
const Component = iconsNames[name] as ComponentType<{
className?: string;
onClick?: () => void;
Expand Down
9 changes: 6 additions & 3 deletions src/components/Icon/icons/Close.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { FC } from 'react';
import { ReactNode } from 'react';

const Close: FC<{ className?: string; onClick?: () => void }> = ({
const Close = ({
className,
onClick,
}) => {
}: {
className?: string;
onClick?: () => void;
}): ReactNode => {
return (
<svg
className={className}
Expand Down
7 changes: 4 additions & 3 deletions src/components/Icon/icons/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { FC } from 'react';

const Menu: FC<{ className?: string; onClick?: () => void }> = ({
const Menu = ({
className,
onClick,
}: {
className?: string;
onClick?: () => void;
}) => {
return (
<svg
Expand Down
10 changes: 8 additions & 2 deletions src/components/Icon/iconsNames.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC } from 'react';
import { ReactNode } from 'react';

import AlertCircle from './icons/AlertCircle';
import AlternateShare from './icons/AlternateShare';
Expand Down Expand Up @@ -39,7 +39,13 @@ import UploadShare from './icons/UploadShare';

export const iconsNames: Record<
string,
FC<{ className?: string; onClick?: () => void }>
({
className,
onClick,
}: {
className?: string;
onClick?: () => void;
}) => ReactNode
> = {
AlertCircle,
AlternateShare,
Expand Down
1 change: 1 addition & 0 deletions src/components/Layout/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const AppShell = ({ children }: { children: ReactNode }) => {
<NextUIProvider>
<NextThemesProvider
attribute='class'
defaultTheme='dark'
value={{
dark: 'custom-dark',
}}
Expand Down
107 changes: 107 additions & 0 deletions src/components/Pagination/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Icon from '../Icon/Icon';

interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}

const Pagination = ({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) => {
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;

const calculateVisiblePages = (
currentPage: number,
totalPages: number,
maxVisiblePages: number,
): number[] => {
const startPage = Math.max(
1,
currentPage - Math.floor(maxVisiblePages / 2),
);
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
const adjustedStartPage = Math.max(1, endPage - maxVisiblePages + 1);

return Array.from(
{ length: endPage - adjustedStartPage + 1 },
(_, i) => adjustedStartPage + i,
);
};

const visiblePages = calculateVisiblePages(currentPage, totalPages, 5);

return (
<div className='flex items-center justify-center gap-6'>
<div className='flex gap-1' id='first-previous-buttons'>
<button
onClick={() => onPageChange(1)}
disabled={isFirstPage}
aria-label='Go to first page'
className={`p-2 rounded ${
isFirstPage ? 'text-gray-600 cursor-not-allowed' : 'text-gray-300'
}`}
>
<Icon name='ChevronDoubleLeft' size='s' />
</button>

<button
onClick={() => onPageChange(currentPage - 1)}
disabled={isFirstPage}
aria-label='Go to previous page'
className={`p-2 rounded ${
isFirstPage ? 'text-gray-600 cursor-not-allowed' : 'text-gray-300'
}`}
>
<Icon name='ChevronLeft' size='s' />
</button>
</div>

<div className='flex gap-1' id='number-buttons'>
{visiblePages.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
aria-current={page === currentPage ? 'page' : undefined}
className={`button-s p-3 w-[46px] rounded-full transition-colors ${
page === currentPage
? 'bg-main text-white'
: 'text-gray-600 hover:bg-gray-900'
}`}
>
{page}
</button>
))}
</div>

<div className='flex gap-1' id='next-last-buttons'>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={isLastPage}
aria-label='Go to next page'
className={`p-2 rounded ${
isLastPage ? 'text-gray-600 cursor-not-allowed' : 'text-gray-300'
}`}
>
<Icon name='ChevronRight' size='s' />
</button>

<button
onClick={() => onPageChange(totalPages)}
disabled={isLastPage}
aria-label='Go to last page'
className={`p-2 rounded ${
isLastPage ? 'text-gray-600 cursor-not-allowed' : 'text-gray-300'
}`}
>
<Icon name='ChevronDoubleRight' size='s' />
</button>
</div>
</div>
);
};

export default Pagination;
Loading