Skip to content
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

Open
wants to merge 21 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cbf45b3
feat: Slot 컴포넌트 구현
minai621 Sep 9, 2024
8624223
refactor: kebab case로 파일명 수정
minai621 Sep 10, 2024
f2766d9
docs: slot 컴포넌트 README 작성
minai621 Sep 10, 2024
d229ccb
docs: divider 문서의 prefix로 "primitive-" 추가
minai621 Sep 10, 2024
7a92f4b
chore: package.json 템플릿 repository directory lowerCase로 수정
minai621 Sep 10, 2024
116e247
refactor: README의 예시 중 Button 컴포넌트의 props인 iconLeft와 iconRight props…
minai621 Sep 27, 2024
a0e256a
refactor: fireEvent를 @testing-library/user-event로 변경
minai621 Sep 27, 2024
7a179f3
refactor: Slot 컴포넌트의 테스트에서 getByTestId를 getByText로 변경
minai621 Sep 27, 2024
249c541
refactor: "asChild prop이 true이면 Slot으로 래핑되어야 합니다" 테스트에서 a태그가 아님을 추가해 …
minai621 Sep 27, 2024
b4f6691
refactor: "asChild prop이 true일 때 전달된 props가 자식 요소에 병합되어야 합니다"로 테스트 설명 수정
minai621 Sep 27, 2024
ecfad60
refactor: merge-props에 남아있는 불필요한 console.log 삭제
minai621 Sep 27, 2024
46086fa
refactor: SlotCloneProps 를 ComposedChildProps 로 작명 변경
minai621 Sep 27, 2024
878f757
refactor: Slot의 display name의 prefix로 WarrrUI 추가
minai621 Sep 27, 2024
389b17d
refactor: given-when-then 줄바꿈 적용
minai621 Sep 27, 2024
405854e
refactor: Slot 컴포넌트의 description 업데이트
minai621 Sep 27, 2024
a0b4c40
refactor: Slot 컴포넌트의 package.json.hbs 수정
minai621 Sep 27, 2024
ddcdd63
refactor: react/display-name rule을 *.stories.tsx 파일에 대해서만 off
minai621 Sep 27, 2024
c9c52eb
refactor: slot story 파일의 react import 수정
minai621 Sep 27, 2024
c1445db
refactor: props를 구조분해할당에서 restProps로 변경
minai621 Sep 27, 2024
c984c57
refactor: ComposedChild의 props를 구조분해할당에서 restProps로 변경
minai621 Sep 27, 2024
1283efa
docs: README에서 package 설치 방식 안내 수정
minai621 Sep 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
"rules": {
"import/default": "off"
}
},
{
"files": ["*.stories.tsx"],
"rules": {
"react/display-name": "off"
}
}
]
}
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
8 changes: 4 additions & 4 deletions packages/primitive/components/divider/README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# @warrr-ui/divider
# @warrr-ui/primitive-divider

Divider 컴포넌트는 콘텐츠 섹션을 시각적으로 구분할 때 사용합니다.
또한 레이아웃의 구조를 명확히 하며, 사용자의 정보 탐색을 돕습니다.

## 설치 방법

```bash
pnpm add @warrr-ui/divider
pnpm add @warrr-ui/primitive-divider
# or
yarn add @warrr-ui/divider
yarn add @warrr-ui/primitive-divider
# or
npm i @warrr-ui/divider
npm i @warrr-ui/primitive-divider
```

## 사용 예시
Expand Down
143 changes: 143 additions & 0 deletions packages/primitive/components/slot/README.md
Copy link
Contributor

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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. 엔터로 줄바꿈 vs 이어 적기
  2. 설치 방법 코드 개별 제공 vs 한 코드 블럭으로 제공
  3. md 목차

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드리뷰가 길어질 것 같아 회의에서 논하는 것으로 하면 어떨까요?

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>
);
};
```
169 changes: 169 additions & 0 deletions packages/primitive/components/slot/__tests__/slot.test.tsx
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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

userEvent 모듈의 올바른 임포트 필요

userEvent는 디폴트 내보내기이므로 중괄호 없이 임포트해야 합니다. 그렇지 않으면 테스트에서 userEvent의 메서드를 사용할 때 오류가 발생할 수 있습니다.

- import { userEvent } from "@testing-library/user-event";
+ import userEvent from "@testing-library/user-event";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { userEvent } from "@testing-library/user-event";
import userEvent from "@testing-library/user-event";


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} />;
};
Loading
Loading