-
Notifications
You must be signed in to change notification settings - Fork 2
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
feat/#87 slot 컴포넌트 개발 #100
base: develop
Are you sure you want to change the base?
Changes from all commits
cbf45b3
8624223
f2766d9
d229ccb
7a92f4b
116e247
a0e256a
7a179f3
249c541
b4f6691
ecfad60
46086fa
878f757
389b17d
405854e
a0b4c40
ddcdd63
c9c52eb
c1445db
c984c57
1283efa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,7 +16,9 @@ | |
"@axe-core/playwright": "^4.9.1", | ||
"@playwright/test": "^1.46.0", | ||
"@testing-library/dom": "^10.4.0", | ||
"@testing-library/jest-dom": "^6.5.0", | ||
"@testing-library/react": "^16.0.0", | ||
"@testing-library/user-event": "^14.5.2", | ||
"@turbo/gen": "^2.0.9", | ||
"@types/jest": "^29.5.12", | ||
"@types/node": "^20.14.9", | ||
|
@@ -39,8 +41,7 @@ | |
"ts-node": "^10.9.2", | ||
"tsup": "^8.2.1", | ||
"turbo": "^2.0.4", | ||
"typescript": "^5.4.5", | ||
"@testing-library/jest-dom": "^6.5.0" | ||
"typescript": "^5.4.5" | ||
}, | ||
"packageManager": "[email protected]", | ||
"engines": { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
# @warrr-ui/primitive-slot | ||
|
||
`slot` 컴포넌트를 사용하면 코드의 반복을 줄이고, 응집력 있는 UI 구조를 유지하면서도 다양한 사용자 인터페이스 요구사항을 충족할 수 있습니다. 컴포넌트 간의 props 전달과 병합, 슬롯을 통한 유연한 구조 변경, ref 핸들링 등의 기능을 제공하여 React 컴포넌트 개발을 더욱 효율적으로 만들어줍니다. | ||
|
||
## 설치 방법 | ||
|
||
```bash | ||
pnpm add @warrr-ui/primitive-slot | ||
# or | ||
yarn add @warrr-ui/primitive-slot | ||
# or | ||
npm install @warrr-ui/primitive-slot | ||
``` | ||
|
||
## 주요 기능 | ||
|
||
### 1. Props 위임 | ||
|
||
`Slot` 컴포넌트를 사용하면 부모 컴포넌트와 자식 컴포넌트 간의 관계를 재정의하거나 스타일을 변경할 수 있습니다. 특정 컴포넌트를 다양한 방식으로 래핑하거나 이벤트 핸들러 또는 props를 일관되게 전달할 수 있습니다. | ||
|
||
```tsx | ||
const Button = React.forwardRef< | ||
HTMLButtonElement, | ||
React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean } | ||
>(({ asChild, ...props }, ref) => { | ||
const Comp = asChild ? Slot : "button"; | ||
return <Comp {...props} ref={ref} />; | ||
}); | ||
|
||
<Button | ||
asChild | ||
style={{ padding: "10px", borderRadius: "5px", background: "blue", color: "white" }} | ||
> | ||
<a | ||
href="#" | ||
onClick={(e) => { | ||
e.preventDefault(); | ||
alert("Slot 버튼이 클릭되었습니다!"); | ||
}} | ||
style={{ textDecoration: "none", color: "inherit" }} | ||
> | ||
클릭하세요 (버튼 스타일의 a 태그입니다) | ||
</a> | ||
</Button>; | ||
``` | ||
|
||
### 2. Props 병합 | ||
|
||
`Slot` 컴포넌트는 부모와 자식 컴포넌트의 props를 자동으로 병합합니다. 이벤트 핸들러, 스타일, 클래스 이름 등의 props가 병합되어 부모와 자식 컴포넌트에 모두 적용됩니다. | ||
|
||
```tsx | ||
const Parent = ({ children, ...parentProps }) => { | ||
return <Slot {...parentProps}>{children}</Slot>; | ||
}; | ||
|
||
const Child = ({ className, onClick, style, ...rest }) => ( | ||
<button className={className} onClick={onClick} style={style} {...rest}> | ||
Child 버튼 | ||
</button> | ||
); | ||
|
||
const App = () => ( | ||
<Parent | ||
className="parent-class" | ||
onClick={() => console.log("Parent 클릭")} | ||
style={{ padding: "10px", color: "red" }} | ||
data-test="parent" | ||
> | ||
<Child | ||
className="child-class" | ||
onClick={() => console.log("Child 클릭")} | ||
style={{ backgroundColor: "blue" }} | ||
id="child-button" | ||
/> | ||
</Parent> | ||
); | ||
|
||
/* | ||
병합 결과: | ||
{ | ||
id: "child-button", | ||
"data-test": "parent" | ||
className: "child-class parent-class", | ||
onClick: chainFunctions([() => console.log('Child 클릭'), () => console.log('Parent 클릭')]), | ||
style: { | ||
padding: "10px", | ||
color: "red", | ||
backgroundColor: "blue" | ||
}, | ||
} | ||
*/ | ||
``` | ||
|
||
### 3. Slottable 컴포넌트 | ||
|
||
`Slottable` 컴포넌트를 사용하면 컴포넌트의 특정 부분을 유연하게 대체할 수 있습니다. 이를 통해 컴포넌트의 구조를 유지하면서도 특정 부분만 커스터마이징할 수 있습니다. | ||
|
||
```tsx | ||
const Button = React.forwardRef< | ||
HTMLButtonElement, | ||
React.ButtonHTMLAttributes<HTMLButtonElement> & { | ||
asChild?: boolean; | ||
leftIcon?: React.ReactNode; | ||
rightIcon?: React.ReactNode; | ||
} | ||
>(({ asChild = false, iconLeft, iconRight, ...props }, forwardedRef) => { | ||
const Comp = asChild ? Slot : "button"; | ||
return ( | ||
<Comp {...props} ref={forwardedRef}> | ||
{iconLeft} | ||
<Slottable>{children}</Slottable> | ||
{iconRight} | ||
</Comp> | ||
); | ||
}); | ||
|
||
// 사용 예시 | ||
<Button asChild iconLeft={<Icon name="link" />}> | ||
<a href="https://example.com">Visit Website</a> | ||
</Button>; | ||
``` | ||
|
||
### 4. Ref 핸들링 | ||
|
||
`Slot` 컴포넌트는 상위 컴포넌트에서 전달된 ref를 받아 슬롯에 전달된 요소의 ref와 병합합니다. 함수 타입의 ref는 해당 함수를 호출하여 DOM 노드를 전달하고, 객체 타입의 ref는 해당 객체의 current 속성에 DOM 노드를 할당합니다. | ||
|
||
```tsx | ||
const Parent = () => { | ||
const inputRef = useRef<HTMLInputElement>(null); | ||
|
||
useEffect(() => { | ||
if (inputRef.current) { | ||
inputRef.current.focus(); | ||
} | ||
}, []); | ||
|
||
return ( | ||
<Slot ref={inputRef}> | ||
<input type="text" placeholder="포커스가 잡힙니다." /> | ||
</Slot> | ||
); | ||
}; | ||
``` |
minai621 marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,169 @@ | ||||||
import { ComponentProps, createRef } from "react"; | ||||||
|
||||||
import { render, screen } from "@testing-library/react"; | ||||||
import { userEvent } from "@testing-library/user-event"; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
- import { userEvent } from "@testing-library/user-event";
+ import userEvent from "@testing-library/user-event"; 📝 Committable suggestion
Suggested change
|
||||||
|
||||||
import { Slot, Slottable } from "../src"; | ||||||
|
||||||
describe("Slot 컴포넌트", () => { | ||||||
it("정상적으로 렌더링되어야 합니다", () => { | ||||||
const { container } = render(<Slot>Hello</Slot>); | ||||||
|
||||||
expect(container).toBeInTheDocument(); | ||||||
}); | ||||||
|
||||||
it("ref가 전달되어야 합니다", () => { | ||||||
const ref = createRef<HTMLElement>(); | ||||||
|
||||||
render( | ||||||
<Slot ref={ref}> | ||||||
<div>Hello</div> | ||||||
</Slot> | ||||||
); | ||||||
|
||||||
expect(ref.current).not.toBeNull(); | ||||||
}); | ||||||
|
||||||
describe("Slottable 컴포넌트", () => { | ||||||
it("Slottable로 감싼 요소가 렌더링되어야 합니다", () => { | ||||||
render( | ||||||
<Slot> | ||||||
<div> | ||||||
Hello | ||||||
<Slottable> | ||||||
<strong>World</strong> | ||||||
</Slottable> | ||||||
</div> | ||||||
</Slot> | ||||||
); | ||||||
|
||||||
expect(screen.getByText("World")).toBeInTheDocument(); | ||||||
}); | ||||||
}); | ||||||
|
||||||
describe("이벤트 핸들러", () => { | ||||||
const handleClick = jest.fn(); | ||||||
const handleSlotClick = jest.fn(); | ||||||
|
||||||
beforeEach(() => { | ||||||
handleClick.mockReset(); | ||||||
handleSlotClick.mockReset(); | ||||||
}); | ||||||
|
||||||
it("Slot에 전달된 onClick 핸들러가 호출되어야 합니다", async () => { | ||||||
render( | ||||||
<Slot onClick={handleClick}> | ||||||
<div>Click me</div> | ||||||
</Slot> | ||||||
); | ||||||
|
||||||
await userEvent.click(screen.getByText("Click me")); | ||||||
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1); | ||||||
}); | ||||||
|
||||||
it("Slot의 자식 요소에 전달된 onClick 핸들러가 호출되어야 합니다", async () => { | ||||||
render( | ||||||
<Slot> | ||||||
<button onClick={handleClick}>Click me</button> | ||||||
</Slot> | ||||||
); | ||||||
|
||||||
await userEvent.click(screen.getByText("Click me")); | ||||||
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1); | ||||||
}); | ||||||
|
||||||
it("Slot과 자식 요소 모두에 onClick 핸들러가 전달되었을 때, 두 핸들러 모두 호출되어야 합니다", async () => { | ||||||
render( | ||||||
<Slot onClick={handleSlotClick}> | ||||||
<button onClick={handleClick}>Click me</button> | ||||||
</Slot> | ||||||
); | ||||||
|
||||||
await userEvent.click(screen.getByText("Click me")); | ||||||
|
||||||
expect(handleSlotClick).toHaveBeenCalledTimes(1); | ||||||
expect(handleClick).toHaveBeenCalledTimes(1); | ||||||
}); | ||||||
}); | ||||||
|
||||||
describe("Props 전달", () => { | ||||||
it("Slot에 전달된 props가 자식 요소에 전달되어야 합니다", () => { | ||||||
render( | ||||||
<Slot className="parent" data-testid="child"> | ||||||
<div>Child</div> | ||||||
</Slot> | ||||||
); | ||||||
|
||||||
const child = screen.getByText("Child"); | ||||||
expect(child).toHaveClass("parent"); | ||||||
}); | ||||||
|
||||||
it("Slot에 전달된 style props가 자식 요소에 전달되어야 합니다", () => { | ||||||
render( | ||||||
<Slot style={{ color: "red" }}> | ||||||
<div>Child</div> | ||||||
</Slot> | ||||||
); | ||||||
|
||||||
expect(screen.getByText("Child")).toHaveStyle({ color: "red" }); | ||||||
}); | ||||||
}); | ||||||
|
||||||
describe("Slot을 사용하는 Link 컴포넌트", () => { | ||||||
it("asChild prop이 없으면 a 태그로 렌더링되어야 합니다", () => { | ||||||
render(<Link href="#">Link</Link>); | ||||||
|
||||||
expect(screen.getByRole("link")).toHaveAttribute("href", "#"); | ||||||
}); | ||||||
|
||||||
it("asChild prop이 true이면 Slot으로 래핑되어야 합니다", () => { | ||||||
const { container } = render( | ||||||
<Link asChild> | ||||||
<button>Button</button> | ||||||
</Link> | ||||||
); | ||||||
|
||||||
const button = screen.getByRole("button"); | ||||||
expect(button).toBeInTheDocument(); | ||||||
expect((container.firstChild as Element).tagName.toLowerCase()).not.toBe("a"); | ||||||
}); | ||||||
|
||||||
it("asChild prop이 true일 때 전달된 props가 자식 요소에 병합되어야 합니다", () => { | ||||||
render( | ||||||
<Link asChild className="link" style={{ color: "red" }}> | ||||||
<button className="button">Button</button> | ||||||
</Link> | ||||||
); | ||||||
|
||||||
const button = screen.getByRole("button"); | ||||||
expect(button).toHaveClass("link"); | ||||||
expect(button).toHaveClass("button"); | ||||||
expect(button).toHaveStyle({ color: "red" }); | ||||||
}); | ||||||
|
||||||
it("asChild prop이 true일 때 이벤트 핸들러가 자식 요소에 전달되어야 합니다", async () => { | ||||||
const handleClick = jest.fn(); | ||||||
render( | ||||||
<Link asChild onClick={handleClick}> | ||||||
<button>Button</button> | ||||||
</Link> | ||||||
); | ||||||
|
||||||
await userEvent.click(screen.getByRole("button")); | ||||||
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1); | ||||||
}); | ||||||
}); | ||||||
}); | ||||||
|
||||||
// Link 컴포넌트 테스트 | ||||||
|
||||||
interface LinkProps extends ComponentProps<"a"> { | ||||||
asChild?: boolean; | ||||||
} | ||||||
const Link = ({ asChild, ...props }: LinkProps) => { | ||||||
const Comp = asChild ? Slot : "a"; | ||||||
return <Comp {...props} />; | ||||||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
divider와 md 문서 작성하는 방식을 통일시킬 필요가 있을 것 같습니다! 아직 합의된 방식이 없어 추후 진행될 회의에서 합의하거나 해당 코드 리뷰에서 합의해야할 것 같은데 다들 어떻게 생각하세요?
@minai621 @ghdtjgus76 @gs0428
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
코드리뷰가 길어질 것 같아 회의에서 논하는 것으로 하면 어떨까요?