Skip to content

Commit 0304d88

Browse files
committed
[WRFE-91](refactor): Popover 리팩토링
1 parent 01b4fe1 commit 0304d88

File tree

4 files changed

+287
-0
lines changed

4 files changed

+287
-0
lines changed
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
{/* Popover.mdx */}
2+
3+
import * as stories from './Popover.stories';
4+
import {Canvas, Meta, Primary, Controls, Description} from '@storybook/blocks';
5+
6+
<Meta of={stories} />
7+
8+
# Popover
9+
10+
`Popover`는 합성 컴포넌트입니다. 버튼 클릭 시 추가적인 정보를 보여주는 데 사용됩니다.
11+
12+
- [Overview](#overview)
13+
- [Props](#props)
14+
- [Description](#description)
15+
16+
## Overview
17+
18+
<Primary />
19+
20+
## Props
21+
22+
<Controls />
23+
24+
## 컴포넌트 구조
25+
26+
`Popover`는 다음과 같은 하위 컴포넌트들로 구성됩니다:
27+
28+
- `Popover`: 최상위 컴포넌트
29+
- `PopoverTrigger`: Popover를 열고 닫는 트리거 컴포넌트
30+
- `PopoverContent`: Popover의 내용을 표시하는 컴포넌트
31+
- `PopoverAnchor`: Popover의 위치를 지정하는 기준점 컴포넌트
32+
33+
## PopoverContent Props
34+
35+
PopoverContent 컴포넌트는 다음과 같은 props를 받습니다:
36+
37+
- `align`: Popover content의 정렬 방향 ('start' | 'center' | 'end', 기본값: 'center')
38+
- `sideOffset`: Trigger와 content 사이의 거리 (number, 기본값: 6)
39+
- `className`: 커스텀 스타일링을 위한 클래스 (string)
40+
41+
## 스타일링
42+
43+
Popover는 기본적으로 다음과 같은 스타일이 적용됩니다:
44+
45+
- z-index: 50
46+
- 최소 너비: 트리거 요소의 너비
47+
- 둥근 모서리
48+
- 테두리
49+
- 그림자 효과
50+
- 애니메이션 효과 (열기/닫기)
51+
52+
이러한 스타일은 `className` prop을 통해 커스터마이즈할 수 있습니다.
53+
54+
## usePopover Hook
55+
56+
`usePopover` 훅을 사용하면 Popover의 상태를 더 쉽게 관리할 수 있습니다.
57+
58+
### 사용 예시
59+
60+
```tsx
61+
import {PopoverProvider, usePopover} from './use-popover';
62+
63+
const MyComponent = () => {
64+
const {openPopover, closePopover} = usePopover();
65+
66+
return (
67+
<button
68+
onClick={() =>
69+
openPopover(
70+
<div>
71+
<p>Popover Content</p>
72+
<button onClick={closePopover}>Close</button>
73+
</div>,
74+
)
75+
}
76+
>
77+
Open Popover
78+
</button>
79+
);
80+
};
81+
82+
// 최상위 컴포넌트에서 Provider로 감싸기
83+
const App = () => (
84+
<PopoverProvider>
85+
<MyComponent />
86+
</PopoverProvider>
87+
);
88+
```
89+
90+
### Hook API
91+
92+
usePopover 훅은 다음과 같은 값들을 반환합니다:
93+
94+
- `content`: 현재 Popover에 표시되는 컨텐츠 (ReactNode)
95+
- `openPopover`: Popover를 열고 컨텐츠를 설정하는 함수 ((content: ReactNode) => void)
96+
- `closePopover`: Popover를 닫는 함수 (() => void)
97+
98+
## 접근성
99+
100+
Popover는 Radix UI의 Popover 컴포넌트를 기반으로 하며, 다음과 같은 접근성 기능을 제공합니다:
101+
102+
- 키보드 네비게이션 지원
103+
- ARIA 속성 자동 관리
104+
- 포커스 관리
105+
- ESC 키를 통한 닫기
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
Popover,
3+
PopoverTrigger,
4+
PopoverContent,
5+
PopoverAnchor,
6+
} from './Popover';
7+
import {PopoverProvider, usePopover} from './use-popover';
8+
import type {Meta, StoryObj} from '@storybook/react';
9+
10+
const meta: Meta = {
11+
title: 'Components/Popover',
12+
argTypes: {
13+
align: {
14+
control: 'select',
15+
options: ['start', 'center', 'end'],
16+
description: 'Popover content alignment',
17+
},
18+
sideOffset: {
19+
control: 'number',
20+
description: 'Distance between trigger and content',
21+
},
22+
className: {
23+
control: 'text',
24+
description: 'Custom class for styling',
25+
},
26+
},
27+
};
28+
29+
export default meta;
30+
31+
export const Default: StoryObj = {
32+
render: args => (
33+
<Popover>
34+
<PopoverTrigger className='rounded border p-2'>
35+
Open Popover
36+
</PopoverTrigger>
37+
<PopoverContent {...args}>Popover Content</PopoverContent>
38+
</Popover>
39+
),
40+
};
41+
42+
export const WithAnchor: StoryObj = {
43+
render: () => (
44+
<Popover>
45+
<PopoverAnchor className='absolute right-0 top-0' />
46+
<PopoverTrigger className='rounded border p-2'>
47+
Open Popover
48+
</PopoverTrigger>
49+
<PopoverContent className='border bg-white p-4 shadow-md'>
50+
Popover Content with Anchor
51+
</PopoverContent>
52+
</Popover>
53+
),
54+
};
55+
56+
const PopoverWithHook = () => {
57+
const {openPopover, closePopover} = usePopover();
58+
59+
return (
60+
<div className='flex gap-4'>
61+
<button
62+
className='rounded border p-2'
63+
onClick={() =>
64+
openPopover(
65+
<div className='flex flex-col gap-2'>
66+
<p>This is a popover content using usePopover hook</p>
67+
<button className='rounded border p-1' onClick={closePopover}>
68+
Close
69+
</button>
70+
</div>,
71+
)
72+
}
73+
>
74+
Open Popover with Hook
75+
</button>
76+
<button
77+
className='rounded border p-2'
78+
onClick={() =>
79+
openPopover(
80+
<div className='flex flex-col gap-2'>
81+
<p>This is another popover content</p>
82+
<button className='rounded border p-1' onClick={closePopover}>
83+
Close
84+
</button>
85+
</div>,
86+
)
87+
}
88+
>
89+
Open Another Popover
90+
</button>
91+
</div>
92+
);
93+
};
94+
95+
export const WithHook: StoryObj = {
96+
render: () => (
97+
<PopoverProvider>
98+
<PopoverWithHook />
99+
</PopoverProvider>
100+
),
101+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
'use client';
2+
3+
import clsx from 'clsx';
4+
import * as React from 'react';
5+
import * as PopoverPrimitive from '@radix-ui/react-popover';
6+
7+
const Popover = PopoverPrimitive.Root;
8+
9+
const PopoverTrigger = PopoverPrimitive.Trigger;
10+
11+
const PopoverAnchor = PopoverPrimitive.Anchor;
12+
13+
const PopoverContent = React.forwardRef<
14+
React.ElementRef<typeof PopoverPrimitive.Content>,
15+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
16+
>(({className, align = 'center', sideOffset = 6, ...props}, ref) => (
17+
<PopoverPrimitive.Portal>
18+
<PopoverPrimitive.Content
19+
ref={ref}
20+
align={align}
21+
sideOffset={sideOffset}
22+
className={clsx(
23+
'z-50 w-full min-w-[var(--radix-popper-anchor-width)] rounded-md border bg-popover p-3 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
24+
className,
25+
)}
26+
{...props}
27+
>
28+
{props.children}
29+
</PopoverPrimitive.Content>
30+
</PopoverPrimitive.Portal>
31+
));
32+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
33+
34+
export {Popover, PopoverTrigger, PopoverContent, PopoverAnchor};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {Popover, PopoverContent, PopoverTrigger} from './Popover';
2+
import React, {useState} from 'react';
3+
4+
const PopoverContext = React.createContext<{
5+
content: React.ReactNode;
6+
openPopover: (content: React.ReactNode) => void;
7+
closePopover: () => void;
8+
}>({
9+
content: null,
10+
openPopover: () => {},
11+
closePopover: () => {},
12+
});
13+
14+
interface PopoverProviderProps {
15+
children: React.ReactNode;
16+
}
17+
18+
export const PopoverProvider = ({children}: PopoverProviderProps) => {
19+
const [content, setContent] = useState<React.ReactNode>(null);
20+
const [isOpen, setIsOpen] = useState(false);
21+
22+
const openPopover = (newContent: React.ReactNode) => {
23+
setContent(newContent);
24+
setIsOpen(true);
25+
};
26+
27+
const closePopover = () => {
28+
setIsOpen(false);
29+
};
30+
31+
return (
32+
<PopoverContext.Provider value={{content, openPopover, closePopover}}>
33+
<Popover open={isOpen} onOpenChange={setIsOpen}>
34+
<PopoverTrigger>{children}</PopoverTrigger>
35+
<PopoverContent>{content}</PopoverContent>
36+
</Popover>
37+
</PopoverContext.Provider>
38+
);
39+
};
40+
41+
export const usePopover = () => {
42+
const context = React.useContext(PopoverContext);
43+
if (!context) {
44+
throw new Error('usePopover must be used within a PopoverProvider');
45+
}
46+
return context;
47+
};

0 commit comments

Comments
 (0)