From c8a6918497912f40187ef656861626cae87b95b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:03:50 +0000 Subject: [PATCH 01/14] Create draft PR for #803 From 14c8aae360d13c944c6c000aeaa997814505634e Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Mon, 14 Oct 2024 15:17:37 +0900 Subject: [PATCH 02/14] =?UTF-8?q?chore:=20storybook=20action=20addom=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=84=A4=EC=B9=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.storybook/main.ts | 1 + frontend/package-lock.json | 43 +++++++++++++++++++++++++++---------- frontend/package.json | 1 + 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index a84acf84f..134227923 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -10,6 +10,7 @@ const config: StorybookConfig = { '@storybook/addon-essentials', '@chromatic-com/storybook', '@storybook/addon-interactions', + '@storybook/addon-actions', 'storybook-addon-remix-react-router', ], framework: { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 48e914f10..74e17c747 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.6.0", "@playwright/test": "^1.47.2", + "@storybook/addon-actions": "^8.3.5", "@storybook/addon-essentials": "^8.2.1", "@storybook/addon-interactions": "^8.2.1", "@storybook/addon-links": "^8.2.1", @@ -2708,9 +2709,9 @@ } }, "node_modules/@storybook/addon-actions": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.3.3.tgz", - "integrity": "sha512-cbpksmld7iADwDGXgojZ4r8LGI3YA3NP68duAHg2n1dtnx1oUaFK5wd6dbNuz7GdjyhIOIy3OKU1dAuylYNGOQ==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.3.5.tgz", + "integrity": "sha512-t8D5oo+4XfD+F8091wLa2y/CDd/W2lExCeol5Vm1tp5saO+u6f2/d7iykLhTowWV84Uohi3D073uFeyTAlGebg==", "dev": true, "dependencies": { "@storybook/global": "^5.0.0", @@ -2724,7 +2725,7 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^8.3.3" + "storybook": "^8.3.5" } }, "node_modules/@storybook/addon-backgrounds": { @@ -2816,6 +2817,26 @@ "storybook": "^8.3.3" } }, + "node_modules/@storybook/addon-essentials/node_modules/@storybook/addon-actions": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.3.3.tgz", + "integrity": "sha512-cbpksmld7iADwDGXgojZ4r8LGI3YA3NP68duAHg2n1dtnx1oUaFK5wd6dbNuz7GdjyhIOIy3OKU1dAuylYNGOQ==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0", + "@types/uuid": "^9.0.1", + "dequal": "^2.0.2", + "polished": "^4.2.2", + "uuid": "^9.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^8.3.3" + } + }, "node_modules/@storybook/addon-highlight": { "version": "8.3.3", "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.3.3.tgz", @@ -3250,9 +3271,9 @@ } }, "node_modules/@storybook/core": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.3.3.tgz", - "integrity": "sha512-pmf2bP3fzh45e56gqOuBT8sDX05hGdUKIZ/hcI84d5xmd6MeHiPW8th2v946wCHcxHzxib2/UU9vQUh+mB4VNw==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.3.5.tgz", + "integrity": "sha512-GOGfTvdioNa/n+Huwg4u/dsyYyBcM+gEcdxi3B7i5x4yJ3I912KoVshumQAOF2myKSRdI8h8aGWdx7nnjd0+5Q==", "dev": true, "dependencies": { "@storybook/csf": "^0.1.11", @@ -18567,12 +18588,12 @@ } }, "node_modules/storybook": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.3.3.tgz", - "integrity": "sha512-FG2KAVQN54T9R6voudiEftehtkXtLO+YVGP2gBPfacEdDQjY++ld7kTbHzpTT/bpCDx7Yq3dqOegLm9arVJfYw==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.3.5.tgz", + "integrity": "sha512-hYQVtP2l+3kO8oKDn4fjXXQYxgTRsj/LaV6lUMJH0zt+OhVmDXKJLxmdUP4ieTm0T8wEbSYosFavgPcQZlxRfw==", "dev": true, "dependencies": { - "@storybook/core": "8.3.3" + "@storybook/core": "8.3.5" }, "bin": { "getstorybook": "bin/index.cjs", diff --git a/frontend/package.json b/frontend/package.json index 891345116..d6e3fff49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.6.0", "@playwright/test": "^1.47.2", + "@storybook/addon-actions": "^8.3.5", "@storybook/addon-essentials": "^8.2.1", "@storybook/addon-interactions": "^8.2.1", "@storybook/addon-links": "^8.2.1", From 5cca360634271894fc2368652ce202bcef5edd9c Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Mon, 14 Oct 2024 15:18:59 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20Slider=20Atom=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_common/atoms/Slider/Slider.stories.tsx | 83 +++++++++++++++++++ .../components/_common/atoms/Slider/index.tsx | 57 +++++++++++++ .../components/_common/atoms/Slider/style.ts | 78 +++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 frontend/src/components/_common/atoms/Slider/Slider.stories.tsx create mode 100644 frontend/src/components/_common/atoms/Slider/index.tsx create mode 100644 frontend/src/components/_common/atoms/Slider/style.ts diff --git a/frontend/src/components/_common/atoms/Slider/Slider.stories.tsx b/frontend/src/components/_common/atoms/Slider/Slider.stories.tsx new file mode 100644 index 000000000..5373cb8db --- /dev/null +++ b/frontend/src/components/_common/atoms/Slider/Slider.stories.tsx @@ -0,0 +1,83 @@ +/* eslint-disable react/jsx-one-expression-per-line */ +import { action } from '@storybook/addon-actions'; +import { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import Slider from '.'; + +const meta: Meta = { + title: 'Common/Atoms/Slider', + component: Slider, + argTypes: { + onRangeChange: { action: 'rangeChanged' }, + }, + decorators: [ + (Story, context) => { + const [range, setRange] = useState({ + min: context.args.initialMin, + max: context.args.initialMax, + }); + + const handleRangeChange = (min: number, max: number) => { + setRange({ min, max }); + action('rangeChanged')(`최대값은 ${max}, 최소값은 ${min}`); + }; + + return ( +
+ +
+ Current Range: {range.min.toFixed(2)} - {range.max.toFixed(2)} +
+
+ ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + min: 0, + max: 5, + step: 0.5, + initialMin: 0, + initialMax: 5, + }, +}; + +export const DecimalStep: Story = { + args: { + min: 0, + max: 5, + step: 0.1, + initialMin: 0, + initialMax: 5, + }, +}; + +export const NarrowRange: Story = { + args: { + min: 0, + max: 100, + step: 1, + initialMin: 40, + initialMax: 60, + }, +}; + +export const CustomRange: Story = { + args: { + min: -50, + max: 50, + step: 5, + initialMin: -25, + initialMax: 25, + }, +}; diff --git a/frontend/src/components/_common/atoms/Slider/index.tsx b/frontend/src/components/_common/atoms/Slider/index.tsx new file mode 100644 index 000000000..f320de9ed --- /dev/null +++ b/frontend/src/components/_common/atoms/Slider/index.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import S from './style'; + +interface SliderProps { + min: number; + max: number; + step: number; + initialMin: number; + initialMax: number; + onRangeChange: (min: number, max: number) => void; +} + +export default function Slider({ min, max, step, initialMin, initialMax, onRangeChange }: SliderProps) { + const [minValue, setMinValue] = useState(initialMin); + const [maxValue, setMaxValue] = useState(initialMax); + + const handleMinChange = (e: React.ChangeEvent) => { + const newMinValue = Math.min(Number(e.target.value), Number(maxValue - step)); + setMinValue(newMinValue); + onRangeChange(newMinValue, maxValue); + }; + + const handleMaxChange = (e: React.ChangeEvent) => { + const newMaxValue = Math.max(Number(e.target.value), Number(minValue + step)); + setMaxValue(newMaxValue); + onRangeChange(minValue, newMaxValue); + }; + + const sliderRangeLeft = ((minValue - min) / (max - min)) * 100; + const sliderRangeRight = 100 - ((maxValue - min) / (max - min)) * 100; + + return ( + + + + + + + ); +} diff --git a/frontend/src/components/_common/atoms/Slider/style.ts b/frontend/src/components/_common/atoms/Slider/style.ts new file mode 100644 index 000000000..4d51b6f18 --- /dev/null +++ b/frontend/src/components/_common/atoms/Slider/style.ts @@ -0,0 +1,78 @@ +import styled from '@emotion/styled'; + +const SliderContainer = styled.div` + position: relative; + width: 100%; + height: 2rem; +`; + +const SliderTrack = styled.div` + position: absolute; + top: 50%; + transform: translateY(-50%); + + width: 100%; + height: 0.6rem; + border-radius: 0.3rem; + + background-color: ${({ theme }) => theme.baseColors.grayscale[200]}; +`; + +const SliderRange = styled.div<{ left: number; right: number }>` + position: absolute; + top: 50%; + left: ${({ left }) => `${left}%`}; + right: ${({ right }) => `${right}%`}; + transform: translateY(-50%); + + height: 0.6rem; + border-radius: 0.3rem; + background-color: ${({ theme }) => theme.baseColors.purplescale[200]}; +`; + +const SliderThumb = styled.input` + position: absolute; + top: 50%; + width: 100%; + height: 0; + background: transparent; + pointer-events: none; + + -webkit-appearance: none; + appearance: none; + + &::-moz-range-thumb { + width: 1.6rem; + aspect-ratio: 1/1; + background: ${({ theme }) => theme.baseColors.grayscale[50]}; + cursor: pointer; + pointer-events: auto; + + border: 0.4rem solid ${({ theme }) => theme.baseColors.purplescale[700]}; + border-radius: 100%; + } + + &::-webkit-slider-thumb { + width: 1.6rem; + aspect-ratio: 1/1; + background: ${({ theme }) => theme.baseColors.grayscale[50]}; + cursor: pointer; + border-radius: 100%; + pointer-events: auto; + + border: 0.4rem solid ${({ theme }) => theme.baseColors.purplescale[700]}; + border-radius: 100%; + + -webkit-appearance: none; + appearance: none; + } +`; + +const S = { + SliderContainer, + SliderTrack, + SliderRange, + SliderThumb, +}; + +export default S; From 9d98100fe70da2ee22807f32028b3730c349de57 Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:54:46 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20RatingFilterContext=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/contexts/RatingFilterContext.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 frontend/src/contexts/RatingFilterContext.tsx diff --git a/frontend/src/contexts/RatingFilterContext.tsx b/frontend/src/contexts/RatingFilterContext.tsx new file mode 100644 index 000000000..d3f7e1a00 --- /dev/null +++ b/frontend/src/contexts/RatingFilterContext.tsx @@ -0,0 +1,78 @@ +import { createContext, useContext, useState, ReactNode, useMemo, useCallback } from 'react'; + +interface RatingRange { + min: number; + max: number; +} + +export type RatingFilterType = 'All' | 'Pending' | 'InProgress'; + +interface InitRatingFilterContext { + ratingRange: RatingRange; + ratingFilterType: RatingFilterType; +} + +interface RatingFilterContext extends InitRatingFilterContext { + setRatingMinRange: (min: number) => void; + setRatingMaxRange: (max: number) => void; + setRatingFilterType: (type: RatingFilterType) => void; + reset: () => void; +} + +const INIT_MIN = 0; +const INIT_MAX = 5; +const INIT_TYPE: RatingFilterType = 'All'; + +const RatingFilterContext = createContext({ + ratingRange: { + min: INIT_MIN, + max: INIT_MAX, + }, + ratingFilterType: INIT_TYPE, +}); + +export function RatingFilterProvider({ children }: { children: ReactNode }) { + const [ratingRange, _setRatingRange] = useState({ + min: INIT_MIN, + max: INIT_MAX, + }); + const [ratingFilterType, _setRatingFilterType] = useState(INIT_TYPE); + + const setRatingMinRange = useCallback((min: number) => _setRatingRange((props) => ({ ...props, min })), []); + const setRatingMaxRange = useCallback((max: number) => _setRatingRange((props) => ({ ...props, max })), []); + const setRatingFilterType = useCallback((type: RatingFilterType) => _setRatingFilterType(type), []); + const reset = useCallback(() => { + _setRatingRange({ min: INIT_MIN, max: INIT_MAX }); + _setRatingFilterType(INIT_TYPE); + }, []); + + const obj = useMemo( + () => ({ + ratingRange, + ratingFilterType, + setRatingMinRange, + setRatingMaxRange, + setRatingFilterType, + reset, + }), + [ratingRange, ratingFilterType, setRatingMinRange, setRatingMaxRange, setRatingFilterType, reset], + ); + + return {children}; +} + +const isRatingFilterContext = ( + context: RatingFilterContext | InitRatingFilterContext, +): context is RatingFilterContext => + 'setRatingMinRange' in context && + 'setRatingMaxRange' in context && + 'setRatingFilterType' in context && + 'reset' in context; + +export const useRatingFilter = (): RatingFilterContext => { + const context = useContext(RatingFilterContext); + if (!context || !isRatingFilterContext(context)) { + throw new Error('useRatingFilter 훅은 RatingFilterProvider 내부에서 사용되어야 합니다.'); + } + return context; +}; From 6d4aeb1c65c7c17a4309dbca873981347f96ca36 Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:55:02 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20RatingFilter=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sqaush --- .../RatingFilter/RatingFilter.stories.tsx | 44 +++++++++ .../_common/molecules/RatingFilter/index.tsx | 97 +++++++++++++++++++ .../_common/molecules/RatingFilter/style.ts | 57 +++++++++++ 3 files changed, 198 insertions(+) create mode 100644 frontend/src/components/_common/molecules/RatingFilter/RatingFilter.stories.tsx create mode 100644 frontend/src/components/_common/molecules/RatingFilter/index.tsx create mode 100644 frontend/src/components/_common/molecules/RatingFilter/style.ts diff --git a/frontend/src/components/_common/molecules/RatingFilter/RatingFilter.stories.tsx b/frontend/src/components/_common/molecules/RatingFilter/RatingFilter.stories.tsx new file mode 100644 index 000000000..129a9ea42 --- /dev/null +++ b/frontend/src/components/_common/molecules/RatingFilter/RatingFilter.stories.tsx @@ -0,0 +1,44 @@ +/* eslint-disable no-shadow */ +import type { Meta, StoryObj } from '@storybook/react'; +import { RatingFilterProvider, useRatingFilter } from '@contexts/RatingFilterContext'; +import { action } from '@storybook/addon-actions'; +import RatingFilter from '.'; + +const meta: Meta = { + title: 'Organisms/RatingFilter', + component: RatingFilter, + parameters: { + layout: 'centered', + docs: { + description: { + component: '평점에 따른 filter 기능을 가진 컴포넌트입니다.', + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => { + const { ratingFilterType, ratingRange } = useRatingFilter(); + action('ratingFilter state가 변경되었습니다.')( + `RatingFilterType: ${ratingFilterType}, 범위 최소값:${ratingRange.min}, 범위 최댓값: ${ratingRange.max}`, + ); + + return ; + }, + ], +}; diff --git a/frontend/src/components/_common/molecules/RatingFilter/index.tsx b/frontend/src/components/_common/molecules/RatingFilter/index.tsx new file mode 100644 index 000000000..08c7c23bd --- /dev/null +++ b/frontend/src/components/_common/molecules/RatingFilter/index.tsx @@ -0,0 +1,97 @@ +/* eslint-disable react/jsx-one-expression-per-line */ +import RadioLabelField from '@components/_common/molecules/RadioLabelField'; +import Slider from '@components/_common/atoms/Slider'; +import { useRatingFilter } from '@contexts/RatingFilterContext'; +import type { RatingFilterType } from '@contexts/RatingFilterContext'; +import Button from '@components/_common/atoms/Button'; +import { useState } from 'react'; +import S from './style'; + +export default function RatingFilter() { + const { ratingFilterType, ratingRange, setRatingFilterType, setRatingMaxRange, setRatingMinRange, reset } = + useRatingFilter(); + + const [currentRatingFilterType, setCurrentRatingFilterType] = useState(ratingFilterType); + const [currentRatingRangeMin, setCurrentRatingRangeMin] = useState(ratingRange.min); + const [currentRatingRangeMax, setCurrentRatingRangeMax] = useState(ratingRange.max); + + const handleRangeChange = (min: number, max: number) => { + setCurrentRatingRangeMax(max); + setCurrentRatingRangeMin(min); + }; + + const sliderProps = { + min: 0, + max: 5, + step: 0.5, + initialMin: 0, + initialMax: 5, + }; + + const handleRadioClick = (type: RatingFilterType) => { + setCurrentRatingFilterType(type); + }; + + const radioLabelOptions = [ + { optionLabel: '전체 선택', isChecked: currentRatingFilterType === 'All', onToggle: () => handleRadioClick('All') }, + { + optionLabel: '평가 없음', + isChecked: currentRatingFilterType === 'Pending', + onToggle: () => handleRadioClick('Pending'), + }, + { + optionLabel: '평가 진행/완료', + isChecked: currentRatingFilterType === 'InProgress', + onToggle: () => handleRadioClick('InProgress'), + }, + ]; + + const handleApplyClick = () => { + setRatingFilterType(currentRatingFilterType); + setRatingMaxRange(currentRatingRangeMax); + setRatingMinRange(currentRatingRangeMin); + }; + + const handleResetClick = () => { + reset(); + }; + + return ( + + + +
평점 범위
+ + {currentRatingRangeMin.toFixed(1)} - {currentRatingRangeMax.toFixed(1)} + +
+ +
+ + + + + + + +
+ ); +} diff --git a/frontend/src/components/_common/molecules/RatingFilter/style.ts b/frontend/src/components/_common/molecules/RatingFilter/style.ts new file mode 100644 index 000000000..05d23bac6 --- /dev/null +++ b/frontend/src/components/_common/molecules/RatingFilter/style.ts @@ -0,0 +1,57 @@ +import styled from '@emotion/styled'; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1.6rem; + padding: 2rem 1.6rem; + background-color: ${({ theme }) => theme.baseColors.grayscale[50]}; +`; + +const RangeWrapper = styled.div` + display: flex; + flex-direction: column; + width: 24rem; + gap: 0.8rem; +`; + +const RangeLabel = styled.span` + display: flex; + justify-content: space-between; + ${({ theme }) => theme.typography.heading[500]}; +`; + +const RatingNumbers = styled.div` + display: flex; + align-items: center; + gap: 0.4rem; + ${({ theme }) => theme.typography.heading[400]}; +`; + +const OptionsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.8rem; +`; + +const ButtonWrapper = styled.div` + display: flex; + gap: 0.8rem; +`; + +const ButtonInner = styled.div` + ${({ theme }) => theme.typography.heading[300]} + padding: 0.4rem; +`; + +const S = { + Wrapper, + RangeWrapper, + RangeLabel, + RatingNumbers, + OptionsWrapper, + ButtonWrapper, + ButtonInner, +}; + +export default S; From 8e8ca62c0c03c65e59d0080c2d92307f29643d61 Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:58:54 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20Popover=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../molecules/Popover/Popover.stories.tsx | 91 +++++++++++++++++++ .../_common/molecules/Popover/index.tsx | 50 ++++++++++ .../_common/molecules/Popover/style.ts | 17 ++++ 3 files changed, 158 insertions(+) create mode 100644 frontend/src/components/_common/molecules/Popover/Popover.stories.tsx create mode 100644 frontend/src/components/_common/molecules/Popover/index.tsx create mode 100644 frontend/src/components/_common/molecules/Popover/style.ts diff --git a/frontend/src/components/_common/molecules/Popover/Popover.stories.tsx b/frontend/src/components/_common/molecules/Popover/Popover.stories.tsx new file mode 100644 index 000000000..d325ff358 --- /dev/null +++ b/frontend/src/components/_common/molecules/Popover/Popover.stories.tsx @@ -0,0 +1,91 @@ +/* eslint-disable react/button-has-type */ +import { useState, useRef } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import Popover from '.'; + +const meta: Meta = { + title: 'Common/Atoms/Popover', + component: Popover, + parameters: { + layout: 'centered', + }, + decorators: [ + (Story, context) => { + const [isOpen, setIsOpen] = useState(false); + const buttonRef = useRef(null); + + const handleToggle = () => setIsOpen(!isOpen); + const handleClose = () => setIsOpen(false); + + return ( +
+ + +
+ ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( +
+

팝오버 내용

+

이것은 팝오버의 내용입니다.

+
+ ), + }, +}; + +export const LongDescription: Story = { + args: { + children: ( +
+

스크롤 가능한 팝오버

+ {Array(10) + .fill(null) + .map((_, i) => ( + // eslint-disable-next-line react/jsx-one-expression-per-line, react/no-array-index-key +

이것은 스크롤 가능한 팝오버 내용의 {i + 1}번째 문단입니다.

+ ))} +
+ ), + }, +}; + +export const CustomStyle: Story = { + args: { + children: ( +
+

스타일이 적용된 팝오버

+

이 팝오버에는 사용자 정의 스타일이 적용되었습니다.

+
+ ), + }, +}; diff --git a/frontend/src/components/_common/molecules/Popover/index.tsx b/frontend/src/components/_common/molecules/Popover/index.tsx new file mode 100644 index 000000000..a43b654ab --- /dev/null +++ b/frontend/src/components/_common/molecules/Popover/index.tsx @@ -0,0 +1,50 @@ +import { PropsWithChildren, useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import S from './style'; + +interface PopoverProps extends PropsWithChildren { + isOpen: boolean; + onClose: () => void; + anchorEl: HTMLElement | null; +} + +export default function Popover({ isOpen, onClose, anchorEl, children }: PopoverProps) { + const popoverRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(event.target as Node) && event.target !== anchorEl) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen, onClose, anchorEl]); + + useEffect(() => { + if (isOpen && popoverRef.current && anchorEl) { + const anchorRect = anchorEl.getBoundingClientRect(); + popoverRef.current.style.top = `${anchorRect.bottom + window.scrollY}px`; + popoverRef.current.style.left = `${anchorRect.left + window.scrollX}px`; + } + }, [isOpen, anchorEl]); + + if (!isOpen) return null; + + return ReactDOM.createPortal( + + {children} + , + document.body, + ); +} diff --git a/frontend/src/components/_common/molecules/Popover/style.ts b/frontend/src/components/_common/molecules/Popover/style.ts new file mode 100644 index 000000000..45a6174a2 --- /dev/null +++ b/frontend/src/components/_common/molecules/Popover/style.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/styled'; + +const PopoverWrapper = styled.div` + position: absolute; + z-index: 1000; + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +`; + +// Add PopoverWrapper to your S object +const S = { + // ... existing styles + PopoverWrapper, +}; + +export default S; From 1d8b4a3bbe45025edf12a2ec3dacc683086e0b38 Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:22:21 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20Slider=20isDIsabled=20prop=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_common/atoms/Slider/Slider.stories.tsx | 11 +++++++ .../components/_common/atoms/Slider/index.tsx | 30 ++++++++++++++----- .../components/_common/atoms/Slider/style.ts | 21 +++++++------ 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/_common/atoms/Slider/Slider.stories.tsx b/frontend/src/components/_common/atoms/Slider/Slider.stories.tsx index 5373cb8db..5115dfe9e 100644 --- a/frontend/src/components/_common/atoms/Slider/Slider.stories.tsx +++ b/frontend/src/components/_common/atoms/Slider/Slider.stories.tsx @@ -9,6 +9,7 @@ const meta: Meta = { component: Slider, argTypes: { onRangeChange: { action: 'rangeChanged' }, + isDisabled: { description: '비활성화 여부' }, }, decorators: [ (Story, context) => { @@ -81,3 +82,13 @@ export const CustomRange: Story = { initialMax: 25, }, }; + +export const Disabled: Story = { + args: { + min: -50, + max: 50, + step: 5, + initialMin: -25, + initialMax: 25, + }, +}; diff --git a/frontend/src/components/_common/atoms/Slider/index.tsx b/frontend/src/components/_common/atoms/Slider/index.tsx index f320de9ed..0210437ae 100644 --- a/frontend/src/components/_common/atoms/Slider/index.tsx +++ b/frontend/src/components/_common/atoms/Slider/index.tsx @@ -7,23 +7,36 @@ interface SliderProps { step: number; initialMin: number; initialMax: number; + isDisabled?: boolean; onRangeChange: (min: number, max: number) => void; } -export default function Slider({ min, max, step, initialMin, initialMax, onRangeChange }: SliderProps) { +export default function Slider({ + min, + max, + step, + initialMin, + initialMax, + isDisabled = false, + onRangeChange, +}: SliderProps) { const [minValue, setMinValue] = useState(initialMin); const [maxValue, setMaxValue] = useState(initialMax); const handleMinChange = (e: React.ChangeEvent) => { - const newMinValue = Math.min(Number(e.target.value), Number(maxValue - step)); - setMinValue(newMinValue); - onRangeChange(newMinValue, maxValue); + if (!isDisabled) { + const newMinValue = Math.min(Number(e.target.value), Number(maxValue - step)); + setMinValue(newMinValue); + onRangeChange(newMinValue, maxValue); + } }; const handleMaxChange = (e: React.ChangeEvent) => { - const newMaxValue = Math.max(Number(e.target.value), Number(minValue + step)); - setMaxValue(newMaxValue); - onRangeChange(minValue, newMaxValue); + if (!isDisabled) { + const newMaxValue = Math.max(Number(e.target.value), Number(minValue + step)); + setMaxValue(newMaxValue); + onRangeChange(minValue, newMaxValue); + } }; const sliderRangeLeft = ((minValue - min) / (max - min)) * 100; @@ -35,6 +48,7 @@ export default function Slider({ min, max, step, initialMin, initialMax, onRange ); diff --git a/frontend/src/components/_common/atoms/Slider/style.ts b/frontend/src/components/_common/atoms/Slider/style.ts index 4d51b6f18..a18c4c264 100644 --- a/frontend/src/components/_common/atoms/Slider/style.ts +++ b/frontend/src/components/_common/atoms/Slider/style.ts @@ -15,10 +15,10 @@ const SliderTrack = styled.div` height: 0.6rem; border-radius: 0.3rem; - background-color: ${({ theme }) => theme.baseColors.grayscale[200]}; + background-color: ${({ theme }) => theme.baseColors.grayscale[300]}; `; -const SliderRange = styled.div<{ left: number; right: number }>` +const SliderRange = styled.div<{ left: number; right: number; isDisabled: boolean }>` position: absolute; top: 50%; left: ${({ left }) => `${left}%`}; @@ -27,10 +27,11 @@ const SliderRange = styled.div<{ left: number; right: number }>` height: 0.6rem; border-radius: 0.3rem; - background-color: ${({ theme }) => theme.baseColors.purplescale[200]}; + background-color: ${({ theme, isDisabled }) => + isDisabled ? theme.baseColors.grayscale[400] : theme.baseColors.purplescale[200]}; `; -const SliderThumb = styled.input` +const SliderThumb = styled.input<{ isDisabled: boolean }>` position: absolute; top: 50%; width: 100%; @@ -42,13 +43,14 @@ const SliderThumb = styled.input` appearance: none; &::-moz-range-thumb { - width: 1.6rem; + width: 1rem; aspect-ratio: 1/1; background: ${({ theme }) => theme.baseColors.grayscale[50]}; - cursor: pointer; + cursor: ${({ isDisabled }) => (isDisabled ? 'default' : 'pointer')}; pointer-events: auto; - border: 0.4rem solid ${({ theme }) => theme.baseColors.purplescale[700]}; + border: 0.4rem solid + ${({ theme, isDisabled }) => (isDisabled ? theme.baseColors.grayscale[600] : theme.baseColors.purplescale[700])}; border-radius: 100%; } @@ -56,11 +58,12 @@ const SliderThumb = styled.input` width: 1.6rem; aspect-ratio: 1/1; background: ${({ theme }) => theme.baseColors.grayscale[50]}; - cursor: pointer; + cursor: ${({ isDisabled }) => (isDisabled ? 'default' : 'pointer')}; border-radius: 100%; pointer-events: auto; - border: 0.4rem solid ${({ theme }) => theme.baseColors.purplescale[700]}; + border: 0.4rem solid + ${({ theme, isDisabled }) => (isDisabled ? theme.baseColors.grayscale[600] : theme.baseColors.purplescale[700])}; border-radius: 100%; -webkit-appearance: none; From 4d7bb473b65925ead94619fd2c363c834f4fadc2 Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:51:47 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20Popover=20Provider=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RatingFilter/RatingFilter.stories.tsx | 13 ++++++---- .../molecules => }/RatingFilter/index.tsx | 26 +++++++++++++------ .../molecules => }/RatingFilter/style.ts | 0 .../Popover/Popover.stories.tsx | 0 .../{molecules => atoms}/Popover/index.tsx | 17 +++++++----- .../{molecules => atoms}/Popover/style.ts | 0 frontend/src/contexts/PopoverContext.tsx | 24 +++++++++++++++++ frontend/src/contexts/RatingFilterContext.tsx | 8 +++--- 8 files changed, 64 insertions(+), 24 deletions(-) rename frontend/src/components/{_common/molecules => }/RatingFilter/RatingFilter.stories.tsx (76%) rename frontend/src/components/{_common/molecules => }/RatingFilter/index.tsx (82%) rename frontend/src/components/{_common/molecules => }/RatingFilter/style.ts (100%) rename frontend/src/components/_common/{molecules => atoms}/Popover/Popover.stories.tsx (100%) rename frontend/src/components/_common/{molecules => atoms}/Popover/index.tsx (82%) rename frontend/src/components/_common/{molecules => atoms}/Popover/style.ts (100%) create mode 100644 frontend/src/contexts/PopoverContext.tsx diff --git a/frontend/src/components/_common/molecules/RatingFilter/RatingFilter.stories.tsx b/frontend/src/components/RatingFilter/RatingFilter.stories.tsx similarity index 76% rename from frontend/src/components/_common/molecules/RatingFilter/RatingFilter.stories.tsx rename to frontend/src/components/RatingFilter/RatingFilter.stories.tsx index 129a9ea42..a9372c1c2 100644 --- a/frontend/src/components/_common/molecules/RatingFilter/RatingFilter.stories.tsx +++ b/frontend/src/components/RatingFilter/RatingFilter.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import { RatingFilterProvider, useRatingFilter } from '@contexts/RatingFilterContext'; import { action } from '@storybook/addon-actions'; +import { PopoverProvider } from '@contexts/PopoverContext'; import RatingFilter from '.'; const meta: Meta = { @@ -18,11 +19,13 @@ const meta: Meta = { tags: ['autodocs'], decorators: [ (Story) => ( - -
- -
-
+ + +
+ +
+
+
), ], }; diff --git a/frontend/src/components/_common/molecules/RatingFilter/index.tsx b/frontend/src/components/RatingFilter/index.tsx similarity index 82% rename from frontend/src/components/_common/molecules/RatingFilter/index.tsx rename to frontend/src/components/RatingFilter/index.tsx index 08c7c23bd..42742f66c 100644 --- a/frontend/src/components/_common/molecules/RatingFilter/index.tsx +++ b/frontend/src/components/RatingFilter/index.tsx @@ -1,10 +1,11 @@ /* eslint-disable react/jsx-one-expression-per-line */ +import { useState } from 'react'; import RadioLabelField from '@components/_common/molecules/RadioLabelField'; import Slider from '@components/_common/atoms/Slider'; -import { useRatingFilter } from '@contexts/RatingFilterContext'; +import { INIT_MAX, INIT_MIN, useRatingFilter } from '@contexts/RatingFilterContext'; import type { RatingFilterType } from '@contexts/RatingFilterContext'; import Button from '@components/_common/atoms/Button'; -import { useState } from 'react'; +import { usePopover } from '@contexts/PopoverContext'; import S from './style'; export default function RatingFilter() { @@ -15,17 +16,20 @@ export default function RatingFilter() { const [currentRatingRangeMin, setCurrentRatingRangeMin] = useState(ratingRange.min); const [currentRatingRangeMax, setCurrentRatingRangeMax] = useState(ratingRange.max); + const { close } = usePopover(); + const handleRangeChange = (min: number, max: number) => { setCurrentRatingRangeMax(max); setCurrentRatingRangeMin(min); }; const sliderProps = { - min: 0, - max: 5, + min: INIT_MIN, + max: INIT_MAX, step: 0.5, - initialMin: 0, - initialMax: 5, + initialMin: INIT_MIN, + initialMax: INIT_MAX, + isDisabled: currentRatingFilterType === 'Pending', }; const handleRadioClick = (type: RatingFilterType) => { @@ -47,9 +51,15 @@ export default function RatingFilter() { ]; const handleApplyClick = () => { + if (currentRatingFilterType === 'Pending') { + setRatingMaxRange(INIT_MAX); + setRatingMinRange(INIT_MIN); + } else { + setRatingMaxRange(currentRatingRangeMax); + setRatingMinRange(currentRatingRangeMin); + } setRatingFilterType(currentRatingFilterType); - setRatingMaxRange(currentRatingRangeMax); - setRatingMinRange(currentRatingRangeMin); + close(); }; const handleResetClick = () => { diff --git a/frontend/src/components/_common/molecules/RatingFilter/style.ts b/frontend/src/components/RatingFilter/style.ts similarity index 100% rename from frontend/src/components/_common/molecules/RatingFilter/style.ts rename to frontend/src/components/RatingFilter/style.ts diff --git a/frontend/src/components/_common/molecules/Popover/Popover.stories.tsx b/frontend/src/components/_common/atoms/Popover/Popover.stories.tsx similarity index 100% rename from frontend/src/components/_common/molecules/Popover/Popover.stories.tsx rename to frontend/src/components/_common/atoms/Popover/Popover.stories.tsx diff --git a/frontend/src/components/_common/molecules/Popover/index.tsx b/frontend/src/components/_common/atoms/Popover/index.tsx similarity index 82% rename from frontend/src/components/_common/molecules/Popover/index.tsx rename to frontend/src/components/_common/atoms/Popover/index.tsx index a43b654ab..730af705c 100644 --- a/frontend/src/components/_common/molecules/Popover/index.tsx +++ b/frontend/src/components/_common/atoms/Popover/index.tsx @@ -1,5 +1,6 @@ import { PropsWithChildren, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; +import { PopoverProvider } from '@contexts/PopoverContext'; import S from './style'; interface PopoverProps extends PropsWithChildren { @@ -38,13 +39,15 @@ export default function Popover({ isOpen, onClose, anchorEl, children }: Popover if (!isOpen) return null; return ReactDOM.createPortal( - - {children} - , + + + {children} + + , document.body, ); } diff --git a/frontend/src/components/_common/molecules/Popover/style.ts b/frontend/src/components/_common/atoms/Popover/style.ts similarity index 100% rename from frontend/src/components/_common/molecules/Popover/style.ts rename to frontend/src/components/_common/atoms/Popover/style.ts diff --git a/frontend/src/contexts/PopoverContext.tsx b/frontend/src/contexts/PopoverContext.tsx new file mode 100644 index 000000000..b6b9cf178 --- /dev/null +++ b/frontend/src/contexts/PopoverContext.tsx @@ -0,0 +1,24 @@ +/* eslint-disable react/jsx-no-constructed-context-values */ +import { createContext, PropsWithChildren, useContext } from 'react'; + +interface PopoverContextProps { + close: () => void; +} + +const PopoverContext = createContext(undefined); + +export const usePopover = (): PopoverContextProps => { + const context = useContext(PopoverContext); + if (!context) { + throw new Error('usePopover은 Popover내부에서 사용되어야 합니다.'); + } + return context; +}; + +interface PopoverProviderProps extends PropsWithChildren { + onClose: () => void; +} + +export function PopoverProvider({ onClose, children }: PopoverProviderProps) { + return {children}; +} diff --git a/frontend/src/contexts/RatingFilterContext.tsx b/frontend/src/contexts/RatingFilterContext.tsx index d3f7e1a00..3ac78616e 100644 --- a/frontend/src/contexts/RatingFilterContext.tsx +++ b/frontend/src/contexts/RatingFilterContext.tsx @@ -7,6 +7,10 @@ interface RatingRange { export type RatingFilterType = 'All' | 'Pending' | 'InProgress'; +export const INIT_MIN = 0; +export const INIT_MAX = 5; +export const INIT_TYPE: RatingFilterType = 'All'; + interface InitRatingFilterContext { ratingRange: RatingRange; ratingFilterType: RatingFilterType; @@ -19,10 +23,6 @@ interface RatingFilterContext extends InitRatingFilterContext { reset: () => void; } -const INIT_MIN = 0; -const INIT_MAX = 5; -const INIT_TYPE: RatingFilterType = 'All'; - const RatingFilterContext = createContext({ ratingRange: { min: INIT_MIN, From bc92eca8cd253bb8210ab761a275d92c7be1a417 Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:05:09 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20ratingFilter=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B3=84=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/RatingFilter/index.tsx | 2 ++ frontend/src/components/RatingFilter/style.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/frontend/src/components/RatingFilter/index.tsx b/frontend/src/components/RatingFilter/index.tsx index 42742f66c..98d7aaafb 100644 --- a/frontend/src/components/RatingFilter/index.tsx +++ b/frontend/src/components/RatingFilter/index.tsx @@ -6,6 +6,7 @@ import { INIT_MAX, INIT_MIN, useRatingFilter } from '@contexts/RatingFilterConte import type { RatingFilterType } from '@contexts/RatingFilterContext'; import Button from '@components/_common/atoms/Button'; import { usePopover } from '@contexts/PopoverContext'; +import { HiStar } from 'react-icons/hi'; import S from './style'; export default function RatingFilter() { @@ -72,6 +73,7 @@ export default function RatingFilter() {
평점 범위
+ {currentRatingRangeMin.toFixed(1)} - {currentRatingRangeMax.toFixed(1)}
diff --git a/frontend/src/components/RatingFilter/style.ts b/frontend/src/components/RatingFilter/style.ts index 05d23bac6..dd0c15fe7 100644 --- a/frontend/src/components/RatingFilter/style.ts +++ b/frontend/src/components/RatingFilter/style.ts @@ -26,6 +26,10 @@ const RatingNumbers = styled.div` align-items: center; gap: 0.4rem; ${({ theme }) => theme.typography.heading[400]}; + + & > svg:first-child { + fill: ${({ theme }) => theme.colors.brand.primary}; + } `; const OptionsWrapper = styled.div` From 7b6abaa0ea38b083134d3183fa0d5725691a4c14 Mon Sep 17 00:00:00 2001 From: Jeongwoo Park <121204715+lurgi@users.noreply.github.com> Date: Tue, 15 Oct 2024 22:19:03 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20RatingFilter=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B2=84=ED=8A=BC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/RatingFilter/index.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/RatingFilter/index.tsx b/frontend/src/components/RatingFilter/index.tsx index 98d7aaafb..bda091689 100644 --- a/frontend/src/components/RatingFilter/index.tsx +++ b/frontend/src/components/RatingFilter/index.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import RadioLabelField from '@components/_common/molecules/RadioLabelField'; import Slider from '@components/_common/atoms/Slider'; -import { INIT_MAX, INIT_MIN, useRatingFilter } from '@contexts/RatingFilterContext'; +import { INIT_MAX, INIT_MIN, INIT_TYPE, useRatingFilter } from '@contexts/RatingFilterContext'; import type { RatingFilterType } from '@contexts/RatingFilterContext'; import Button from '@components/_common/atoms/Button'; import { usePopover } from '@contexts/PopoverContext'; @@ -17,6 +17,9 @@ export default function RatingFilter() { const [currentRatingRangeMin, setCurrentRatingRangeMin] = useState(ratingRange.min); const [currentRatingRangeMax, setCurrentRatingRangeMax] = useState(ratingRange.max); + // [24.10.15 - lurgi] Slider 컴포넌트의 강제 재 렌더링을 위한 key값을 저장하는 state + const [sliderKey, setSliderKey] = useState(0); + const { close } = usePopover(); const handleRangeChange = (min: number, max: number) => { @@ -65,6 +68,12 @@ export default function RatingFilter() { const handleResetClick = () => { reset(); + setCurrentRatingFilterType(INIT_TYPE); + setCurrentRatingRangeMin(INIT_MIN); + setCurrentRatingRangeMax(INIT_MAX); + + // [24.10.15 - lurgi] Slider 컴포넌트의 강제 재 렌더링을 위해 state를 변경합니다. + setSliderKey(sliderKey + 1); }; return ( @@ -78,6 +87,7 @@ export default function RatingFilter() { @@ -99,6 +109,7 @@ export default function RatingFilter() {