diff --git a/src/components/common/Select/index.tsx b/src/components/common/Select/index.tsx new file mode 100644 index 00000000..9480af8a --- /dev/null +++ b/src/components/common/Select/index.tsx @@ -0,0 +1,62 @@ +import useOutsideClickListener from '@src/hooks/useOutsideClickListener'; +import { LabelKeyType } from '@src/lib/types/universal'; +import { useCallback, useRef, useState } from 'react'; +import { S } from './style'; + +interface SelectProps { + options: T[]; + baseValue: T; + baseLabel: string; + selectedValue: T; + setSelectedValue: React.Dispatch>; + labels: Record; +} + +export default function Select({ + options, + selectedValue, + setSelectedValue, + baseValue, + baseLabel, + labels, +}: SelectProps) { + const [isOpen, setIsOpen] = useState(false); + + const toggleSelect = () => setIsOpen(!isOpen); + + const handleSelect = (value: T) => { + setSelectedValue(value); + }; + + const closeSelectItem = useCallback(() => setIsOpen(false), []); + + const selectItemWrapperRef = useRef(null); + const selectTriggerRef = useRef(null); + useOutsideClickListener([selectItemWrapperRef, selectTriggerRef], closeSelectItem); + + return ( +
+ + {selectedValue === baseValue ? baseLabel : labels[selectedValue]} + + {isOpen && ( + + {options.map((option, index) => ( + handleSelect(option)} + > + {labels[option]} + + ))} + + )} +
+ ); +} diff --git a/src/components/common/Select/style.ts b/src/components/common/Select/style.ts new file mode 100644 index 00000000..16f58b4d --- /dev/null +++ b/src/components/common/Select/style.ts @@ -0,0 +1,70 @@ +import styled from '@emotion/styled'; +import arrowDown from '@src/assets/icons/arrow_down.svg'; +import { colors } from '@src/lib/styles/colors'; + +const SelectWrapper = styled.div` + position: relative; +`; + +const SelectTrigger = styled.button<{ isSelectionExist: boolean; isOpened: boolean }>` + cursor: pointer; + position: relative; + width: 110px; + font-size: 16px; + font-weight: 500; + padding: 9px 22px; + text-align: left; + color: white; + border-radius: 20px; + background-color: ${({ isSelectionExist }) => + isSelectionExist ? colors.gray700 : colors.gray600}; + border: 1px solid; + border-color: ${({ isSelectionExist }) => (isSelectionExist ? colors.gray200 : colors.gray700)}; + &::after { + content: ''; + background-repeat: no-repeat; + background-position: center; + position: absolute; + right: 22px; + top: 9px; + transition: 0.2s; + width: 10px; + height: 18px; + background-image: url(${arrowDown}); + transform: ${({ isOpened }) => (isOpened ? 'rotate(180deg)' : 'none')}; + } +`; + +const SelectItem = styled.div<{ isSelected: boolean }>` + background-color: ${({ isSelected }) => (isSelected ? colors.gray400 : 'transparent')}; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + transition: 0.1s; + color: ${colors.gray30}; +`; + +const SelectItemWrapper = styled.div` + display: flex; + flex-direction: column; + + position: absolute; + background-color: ${colors.gray600}; + z-index: 200; + width: 110px; + border-radius: 20px; + padding: 9px 12px; + margin-top: 8px; + gap: 8px; + + &:hover { + ${SelectItem} { + background-color: transparent; + &:hover { + background-color: ${colors.gray400}; + } + } + } +`; + +export const S = { SelectWrapper, SelectTrigger, SelectItem, SelectItemWrapper }; diff --git a/src/hooks/useOutsideClickListener.ts b/src/hooks/useOutsideClickListener.ts new file mode 100644 index 00000000..4fcb5d96 --- /dev/null +++ b/src/hooks/useOutsideClickListener.ts @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; + +const useOutsideClickListener = (targetRefs: React.RefObject[], callback: () => void) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target; + if (target instanceof Node) { + const isOutsideClick = targetRefs.every( + (targetRef: React.RefObject) => + targetRef.current === null || !targetRef.current.contains(target), + ); + if (isOutsideClick) { + callback(); + } + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [targetRefs, callback]); +}; + +export default useOutsideClickListener; diff --git a/src/lib/constants/gtmClass.ts b/src/lib/constants/gtmClass.ts index cfa5568e..1e474bec 100644 --- a/src/lib/constants/gtmClass.ts +++ b/src/lib/constants/gtmClass.ts @@ -4,7 +4,6 @@ type GtmClass = { informationCard프로젝트: string; informationCardFAQ: string; informationCardYoutube: string; - projectFilter: string; }; export const GTM_CLASS: GtmClass = { @@ -12,5 +11,4 @@ export const GTM_CLASS: GtmClass = { informationCard프로젝트: 'information_card_project', informationCardFAQ: 'information_card_faq', informationCardYoutube: 'information_card_youtube', - projectFilter: 'project_filter', }; diff --git a/src/lib/constants/project.ts b/src/lib/constants/project.ts index 1899a556..5f776bec 100644 --- a/src/lib/constants/project.ts +++ b/src/lib/constants/project.ts @@ -1,14 +1,18 @@ import { ProjectCategoryType } from '@src/lib/types/project'; -export const projectCategoryList: { - type: ProjectCategoryType; - name: string; -}[] = [ - { type: ProjectCategoryType.ALL, name: '전체' }, - { type: ProjectCategoryType.APPJAM, name: '🎊 앱잼' }, - { type: ProjectCategoryType.SOPKATHON, name: '💡 솝커톤' }, - { type: ProjectCategoryType.SOPTERM, name: '🛎 솝텀 프로젝트' }, - { type: ProjectCategoryType.STUDY, name: '📖 스터디' }, - { type: ProjectCategoryType.JOINTSEMINAR, name: '👥 합동 세미나' }, - { type: ProjectCategoryType.ETC, name: '💬 기타' }, +export const activeProjectCategoryList: ProjectCategoryType[] = [ + ProjectCategoryType.ALL, + ProjectCategoryType.APPJAM, + ProjectCategoryType.SOPKATHON, + ProjectCategoryType.SOPTERM, ]; + +export const projectCategoryLabel: Record = { + [ProjectCategoryType.ALL]: '전체', + [ProjectCategoryType.APPJAM]: '앱잼', + [ProjectCategoryType.SOPKATHON]: '솝커톤', + [ProjectCategoryType.SOPTERM]: '솝텀', + [ProjectCategoryType.STUDY]: '스터디', + [ProjectCategoryType.JOINTSEMINAR]: '합동 세미나', + [ProjectCategoryType.ETC]: '기타', +}; diff --git a/src/lib/types/universal.ts b/src/lib/types/universal.ts index e54adf8f..abf8a68f 100644 --- a/src/lib/types/universal.ts +++ b/src/lib/types/universal.ts @@ -34,3 +34,5 @@ type TabTypeOption = { export type TabType = TabTypeOption; export type ExtraTabType = TabTypeOption; + +export type LabelKeyType = string | number | symbol; diff --git a/src/views/ProjectPage/ProjectPage.tsx b/src/views/ProjectPage/ProjectPage.tsx index abe9bc0c..1a3b8bbb 100644 --- a/src/views/ProjectPage/ProjectPage.tsx +++ b/src/views/ProjectPage/ProjectPage.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; import PageLayout from '@src/components/common/PageLayout'; +import Select from '@src/components/common/Select'; +import { activeProjectCategoryList, projectCategoryLabel } from '@src/lib/constants/project'; import { ProjectCategoryType } from '@src/lib/types/project'; -import { ProjectFilter, ProjectList } from './components'; +import { ProjectList } from './components'; import useFetch from './hooks/useFetch'; +import { ContentWrapper, Root, SectionTitle } from './styles'; function Projects() { const [selectedCategory, setCategory] = useState(ProjectCategoryType.ALL); @@ -10,8 +13,23 @@ function Projects() { return ( - - + + + SOPT에서 진행된 프로젝트 둘러보기 +