diff --git a/packages/web/src/common/components/Select/Form/index.tsx b/packages/web/src/common/components/Select/Form/index.tsx new file mode 100644 index 000000000..9c007ff5e --- /dev/null +++ b/packages/web/src/common/components/Select/Form/index.tsx @@ -0,0 +1,198 @@ +import { useEffect, useRef, useState } from "react"; + +import isPropValid from "@emotion/is-prop-valid"; +import styled, { css } from "styled-components"; + +import FormError from "@sparcs-clubs/web/common/components/FormError"; +import Label from "@sparcs-clubs/web/common/components/FormLabel"; +import Icon from "@sparcs-clubs/web/common/components/Icon"; + +import NoOption from "../_atomic/NoOption"; +import Dropdown from "../Dropdown"; +import SelectOption from "../SelectOption"; + +export interface SelectItem { + label: string; + value: T; + selectable?: boolean; +} + +interface SelectProps { + items: SelectItem[]; + label?: string; + errorMessage?: string; + disabled?: boolean; + value: T; + onChange?: (value: T) => void; + placeholder?: string; +} + +const SelectInner = styled.div` + gap: 4px; + position: relative; +`; + +const disabledStyle = css` + background-color: ${({ theme }) => theme.colors.GRAY[100]}; + border-color: ${({ theme }) => theme.colors.GRAY[200]}; + color: ${({ theme }) => theme.colors.GRAY[300]}; + pointer-events: none; +`; + +const StyledSelect = styled.div.withConfig({ + shouldForwardProp: prop => isPropValid(prop), +})<{ hasError?: boolean; disabled?: boolean; isOpen?: boolean }>` + width: 100%; + padding: 8px 12px; + outline: none; + cursor: pointer; + background-color: ${({ theme }) => theme.colors.WHITE}; + border: 1px solid + ${({ theme, hasError, isOpen }) => { + if (isOpen) return theme.colors.PRIMARY; + return hasError ? theme.colors.RED[600] : theme.colors.GRAY[200]; + }}; + border-radius: 4px; + font-family: ${({ theme }) => theme.fonts.FAMILY.PRETENDARD}; + font-size: 16px; + line-height: 20px; + font-weight: ${({ theme }) => theme.fonts.WEIGHT.REGULAR}; + + &:focus, + &:hover:not(:focus) { + border-color: ${({ theme, isOpen }) => + isOpen ? theme.colors.PRIMARY : theme.colors.GRAY[300]}; + } + + ${({ disabled }) => disabled && disabledStyle} +`; + +const IconWrapper = styled.div` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; +`; + +const SelectWrapper = styled.div` + width: 100%; + flex-direction: column; + display: flex; + gap: 4px; +`; + +const SelectValue = styled.span.withConfig({ + shouldForwardProp: prop => isPropValid(prop), +})<{ isSelected: boolean; disabled: boolean }>` + color: ${({ theme, isSelected, disabled }) => { + if (disabled) { + return theme.colors.GRAY[300]; + } + if (isSelected) { + return theme.colors.BLACK; + } + return theme.colors.GRAY[200]; + }}; +`; + +const FormSelect = ({ + items, + errorMessage = "", + label = "", + disabled = false, + value, + onChange = () => {}, + placeholder = "항목을 선택해주세요", +}: SelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + if (isOpen) { + setIsOpen(false); + } + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [containerRef, isOpen, items.length, value]); + + const handleSelectClick = () => { + if (!disabled) { + setIsOpen(!isOpen); + } + }; + + const handleOptionClick = (item: SelectItem) => { + if (item.selectable || item.selectable === undefined) { + onChange(item.value); + setIsOpen(false); + } + }; + + const selectedLabel = + items.find(item => item.value === value)?.label || placeholder; + + return ( + + {label && } + + + 0} + disabled={disabled} + onClick={handleSelectClick} + isOpen={isOpen} + > + + {selectedLabel} + + + {isOpen ? ( + + ) : ( + + )} + + + {isOpen && ( + + {items.length > 0 ? ( + items.map(item => ( + handleOptionClick(item)} + > + {item.label} + + )) + ) : ( + 항목이 존재하지 않습니다 + )} + + )} + + {errorMessage.length > 0 && {errorMessage}} + + + ); +}; + +export default FormSelect; diff --git a/packages/web/src/features/meeting/components/MeetingInformationFrame.tsx b/packages/web/src/features/meeting/components/MeetingInformationFrame.tsx index 928f5d13d..2a538a2f4 100644 --- a/packages/web/src/features/meeting/components/MeetingInformationFrame.tsx +++ b/packages/web/src/features/meeting/components/MeetingInformationFrame.tsx @@ -14,6 +14,8 @@ import TextInput from "@sparcs-clubs/web/common/components/Forms/TextInput"; import SectionTitle from "@sparcs-clubs/web/common/components/SectionTitle"; import Select from "@sparcs-clubs/web/common/components/Select"; +import FormSelect from "@sparcs-clubs/web/common/components/Select/Form"; + import { meetingEnumToText } from "../constants/getEnumType"; interface MeetingInformationFrameProps { @@ -95,7 +97,7 @@ const MeetingInformationFrame: React.FC = ({ validate: value => typeof value === "boolean", }} renderItem={props => ( -