diff --git a/.changeset/pretty-windows-sort.md b/.changeset/pretty-windows-sort.md new file mode 100644 index 000000000..e14013771 --- /dev/null +++ b/.changeset/pretty-windows-sort.md @@ -0,0 +1,5 @@ +--- +'@modern-kit/react': minor +--- + +feat(react): AspectRatio Slot 및 polymorphicForwardRef 활용을 통한 다형성 지원 - @ssi02014 diff --git a/docs/docs/react/components/AspectRatio.mdx b/docs/docs/react/components/AspectRatio.mdx index 432e7d27b..1bf68cfa1 100644 --- a/docs/docs/react/components/AspectRatio.mdx +++ b/docs/docs/react/components/AspectRatio.mdx @@ -3,10 +3,18 @@ import { AspectRatio } from '@modern-kit/react'; # AspectRatio -주어진 **[aspect-ratio](https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio)** 비율을 맞춰주기 위해 선언적으로 사용하는 유틸 컴포넌트입니다. +주어진 **[aspect-ratio](https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio)** 비율을 맞춰주기 위해 선언적으로 사용하는 유틸 컴포넌트입니다. 미리 영역을 확보하여 `Layout Shift`를 방지하는데 효과적입니다. +다형성을 지원하기 때문에 `as`, `asChild` 속성을 지원합니다. + +- 기본적으로 `div` 태그로 자식 요소를 감싸서 렌더링하며, `as` 속성을 통해 감싸는 요소를 특정 요소로 변경해 렌더링할 수 있습니다. 해당 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. +- `asChild` 속성이 `true`라면 **[Slot](https://modern-agile-team.github.io/modern-kit/docs/react/components/Slot)** 을 통해 자식 요소를 그대로 렌더링하고, 자식 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. + - Slot은 자식으로 `단일 요소`만 허용됩니다. + - Slot은 자식으로 컴포넌트가 올 경우 `forwardRef`, `props`를 허용해야 합니다. 허용하지 않으면 정상적으로 동작하지 않습니다. + - `asChild` 속성을 사용 할 경우 **[Slot](https://modern-agile-team.github.io/modern-kit/docs/react/components/Slot)** 문서를 참고해주세요. +
## Code @@ -17,36 +25,100 @@ import { AspectRatio } from '@modern-kit/react'; interface AspectRatioProps { children: JSX.Element; ratio: number; + asChild?: boolean; + style?: CSSProperties; + className?: string; } ``` ```tsx title="typescript" -const AspectRatio: ({ ratio, children }: AspectRatioProps) => JSX.Element; +const AspectRatio: PolyForwardComponent<"div", AspectRatioProps, React.ElementType> ``` ## Usage +### Default +- 기본적으로 `div` 요소에 감싸지며 해당 `div`에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. ```tsx title="typescript" import { AspectRatio } from '@modern-kit/react' const Example = () => { + const imgUrl = 'https://github.com/user-attachments/assets/dd60ec12-afd7-44c9-bd6b-0069e16bf2c9'; + return ( -
+
- +
); }; ``` -export const Example1 = () => { +### as +- `as` 속성을 통해 감싸는 요소를 `div`가 아닌 특정 요소로 변경해 렌더링할 수 있으며, 해당 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. +```tsx title="typescript" +import { AspectRatio } from '@modern-kit/react' + +const Example = () => { + const imgUrl = 'https://github.com/user-attachments/assets/dd60ec12-afd7-44c9-bd6b-0069e16bf2c9'; + return ( -
+
+ + + +
+ ); +}; +``` + +### asChild +- `asChild` 속성이 `true`라면 **[Slot](https://modern-agile-team.github.io/modern-kit/docs/react/components/Slot)** 을 통해 자식 요소를 그대로 렌더링하고, 자식 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. +```tsx title="typescript" +import { AspectRatio } from '@modern-kit/react' + +const Example = () => { + const imgUrl = 'https://github.com/user-attachments/assets/dd60ec12-afd7-44c9-bd6b-0069e16bf2c9'; + + return ( +
+ + + +
+ ); +}; +``` + +export const Example = () => { + const imgUrl = 'https://github.com/user-attachments/assets/dd60ec12-afd7-44c9-bd6b-0069e16bf2c9'; + + return ( +
+

Default

- + + + +
+ +

as article

+ + + + +
+ +

asChild

+ +
); }; - +## Example + + + + diff --git a/docs/docs/react/components/InView.mdx b/docs/docs/react/components/InView.mdx index 55f89f9ee..b708db3e1 100644 --- a/docs/docs/react/components/InView.mdx +++ b/docs/docs/react/components/InView.mdx @@ -4,11 +4,18 @@ import { InView } from '@modern-kit/react'; `InView`는 `IntersectionObserver`를 선언적으로 활용 할 수 있는 컴포넌트입니다. +- `@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다. + 관찰 대상이 `Viewport`에 노출될 때(`onIntersectStart`) 혹은 나갈 때(`onIntersectEnd`) 특정 action 함수를 호출 할 수 있는 컴포넌트입니다. -다형성을 지원하기 때문에 `as` 속성을 통해 특정 요소로 렌더링할 수 있습니다. +다형성을 지원하기 때문에 `as`, `asChild` 속성을 지원합니다. + +- 기본적으로 `div` 태그로 자식 요소를 감싸서 렌더링하며, `as` 속성을 통해 감싸는 요소를 특정 요소로 변경해 렌더링할 수 있습니다. 이때 `해당 요소가 관찰 대상`입니다. +- `asChild` 속성이 true라면 **[Slot](https://modern-agile-team.github.io/modern-kit/docs/react/components/Slot)** 을 통해 래퍼 요소 없이 자식 요소를 그대로 렌더링하고, `자식 요소를 관찰 대상으로 설정`할 수 있습니다. + - Slot의 자식은 `단일 요소`만 허용됩니다. + - Slot의 자식으로 컴포넌트가 올 경우 `forwardRef`, `props`를 허용해야 합니다. + - `asChild` 속성을 사용 할 경우 **[Slot](https://modern-agile-team.github.io/modern-kit/docs/react/components/Slot)** 문서를 참고해주세요. -`@modern-kit/react`의 **[useIntersectionObserver](https://modern-agile-team.github.io/modern-kit/docs/react/hooks/useIntersectionObserver)** 훅을 사용하여 구현되었습니다.
@@ -19,6 +26,7 @@ import { InView } from '@modern-kit/react'; ```ts title="typescript" interface InViewProps extends UseIntersectionObserverProps { children: React.ReactNode; + asChild?: boolean; } ``` ```ts title="typescript" @@ -33,53 +41,54 @@ const InView: PolyForwardComponent<"div", InViewProps, React.ElementType> import { InView } from '@modern-kit/react'; const Example = () => { - const handleIntersectStart = () => { - /* action */ - } + const handleIntersectStart = () => {/* action */} + const handleIntersectEnd = () => {/* action */} - const handleIntersectEnd = () => { - /* action */ - } + return ( + +
Box1
+
+ ); +}; +``` + +### as +- `as` 속성을 통해 특정 요소로 렌더링할 수 있으며, 해당 요소가 관찰 대상입니다. +- 해당 요소가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다. +```tsx title="typescript" +import { InView } from '@modern-kit/react'; + +const Example = () => { + const handleIntersectStart = () => {/* action */} + const handleIntersectEnd = () => {/* action */} return ( -
- -
Box1
-
-
; + +
  • List Item1
  • +
  • List Item2
  • +
    ); }; ``` ### asChild -- 자식 요소를 그대로 렌더링하고, 해당 요소를 관찰 대상으로 설정합니다. +- `asChild` 속성이 true라면 **[Slot](https://modern-agile-team.github.io/modern-kit/docs/react/components/Slot)** 을 통해 자식 요소를 그대로 렌더링하고, 해당 자식 요소를 관찰 대상으로 설정합니다. - 자식 요소가 viewport에 노출되거나 숨겨질 때 `onIntersectStart/onIntersectEnd` 콜백 함수를 호출합니다. -- 이때 자식 요소는 단일 요소만 허용됩니다. ```tsx title="typescript" import { InView } from '@modern-kit/react'; const Example = () => { - const ref = useRef(null); - - const handleIntersectStart = () => { - /* action */ - } - - const handleIntersectEnd = () => { - /* action */ - } + const handleIntersectStart = () => {/* action */} + const handleIntersectEnd = () => {/* action */} return ( -
    - -
  • List Item1
  • -
  • List Item2
  • -
    -
    ; + +
    Box1
    +
    ); }; ``` @@ -100,21 +109,19 @@ export const Example = () => { style={{ height: '500px', textAlign: 'center', - fontSize: '2rem', + fontSize: '1.7rem', }}> - 스크롤 해주세요. +

    스크롤 해주세요.

    +

    브라우저 개발자 도구의 콘솔을 확인해주세요.

    + console.log('action onIntersectStart(1)')} onIntersectEnd={() => console.log('action onIntersectEnd(1)')} - style={{ - ...inViewStyle, - background: '#c0392B', - }} + style={{ ...inViewStyle, background: '#c0392B' }} calledOnce >

    Box1

    -

    브라우저 개발자 도구의 콘솔을 확인해주세요.

    calledOnce: true

    as: div

    @@ -122,16 +129,25 @@ export const Example = () => { console.log('action onIntersectStart(2)')} onIntersectEnd={() => console.log('action onIntersectEnd(2)')} - style={{ - ...inViewStyle, - background: '#89a5ea', - }} + style={{ ...inViewStyle, background: '#89a5ea' }} >
  • Box2
  • -
  • 브라우저 개발자 도구의 콘솔을 확인해주세요.
  • calledOnce: false
  • as: ul
  • +
    + console.log('action onIntersectStart(3)')} + onIntersectEnd={() => console.log('action onIntersectEnd(3)')} + style={{ ...inViewStyle, background: '#77DD77' }} + > +
    +

    Box3

    +

    calledOnce: false

    +

    asChild: true

    +
    +
    ); diff --git a/docs/docs/react/components/Slot.mdx b/docs/docs/react/components/Slot.mdx index 09c398527..f2d51cb9d 100644 --- a/docs/docs/react/components/Slot.mdx +++ b/docs/docs/react/components/Slot.mdx @@ -2,7 +2,53 @@ import { Slot, Slottable } from '@modern-kit/react' # Slot -주어진 Props를 직계 자식 컴포넌트에 병합하는 컴포넌트입니다. +주어진 Props를 직계 자식 컴포넌트에 병합하고, 자식 컴포넌트를 렌더링하는 컴포넌트입니다. + +Slot은 부모 컴포넌트의 기능을 자식 컴포넌트에 전달하는 합성 패턴을 구현합니다. 이를 통해: + +- 부모 컴포넌트의 `props`, `ref`, `이벤트 핸들러` 등을 자식 컴포넌트에 전달할 수 있습니다 +- 자식 컴포넌트의 구현을 변경하지 않고도 새로운 기능을 추가할 수 있습니다 +- 컴포넌트 간의 결합도를 낮추고 재사용성을 높일 수 있습니다 + +예를 들어 **[InView](https://modern-agile-team.github.io/modern-kit/docs/react/components/InView)** 나 **[AspectRatio](https://modern-agile-team.github.io/modern-kit/docs/react/components/AspectRatio)** 와 같은 컴포넌트에서 `asChild` prop을 사용해서, 래퍼 요소 없이 자식 컴포넌트에 직접 기능을 주입할 수 있습니다. + +Slot은 아래와 같은 특징이 있습니다. +1. 자식 요소로 `단일 요소`만 허용됩니다. +```tsx title="typescript" +// 가능 + +
    Contents
    +
    + +// 가능 + +
    +
    Contents1
    +
    Contents2
    +
    +
    +``` + +```tsx title="typescript" +// 불가능 + +
    Contents1
    +
    Contents2
    +
    +``` + +2. 자식 요소로 컴포넌트가 온다면 해당 컴포넌트는 필수적으로 `forwardRef`, `props`를 허용해야 합니다. 허용하지 않으면 기능이 정상적으로 동작하지 않습니다. +- **[radix-ui#your-component-must-spread-props](https://www.radix-ui.com/primitives/docs/guides/composition#your-component-must-spread-props)** +- **[radix-ui#your-component-must-forward-ref](https://www.radix-ui.com/primitives/docs/guides/composition#your-component-must-forward-ref)** +```tsx title="typescript" +const MyButton = React.forwardRef((props, forwardedRef) => ( + diff --git a/packages/react/package.json b/packages/react/package.json index 5bb6fb017..236024dfa 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -40,7 +40,6 @@ "@types/react-dom": "^18.2.7", "@types/ua-parser-js": "^0.7.39", "@vitest/coverage-istanbul": "^2.1.3", - "classnames": "^2.5.1", "esbuild": "^0.24.0", "jsdom": "^25.0.0", "postcss": "^8.4.41", @@ -57,6 +56,7 @@ }, "dependencies": { "@types/lodash-es": "^4.17.12", + "classnames": "^2.5.1", "lodash-es": "^4.17.21", "ua-parser-js": "^1.0.39" }, diff --git a/packages/react/src/components/AspectRatio/AspectRatio.modules.css b/packages/react/src/components/AspectRatio/AspectRatio.modules.css new file mode 100644 index 000000000..98ece68d1 --- /dev/null +++ b/packages/react/src/components/AspectRatio/AspectRatio.modules.css @@ -0,0 +1,4 @@ +.aspectRatioWrapper > * { + width: 100%; + display: block; +} diff --git a/packages/react/src/components/AspectRatio/AspectRatio.spec.tsx b/packages/react/src/components/AspectRatio/AspectRatio.spec.tsx index a1aa7b702..8c3f88788 100644 --- a/packages/react/src/components/AspectRatio/AspectRatio.spec.tsx +++ b/packages/react/src/components/AspectRatio/AspectRatio.spec.tsx @@ -3,15 +3,45 @@ import { describe, it, expect } from 'vitest'; import { AspectRatio } from '.'; describe('AspectRatio', () => { - it('paragraph 요소에 aspect-ratio 설정이 적용되어야 합니다.', () => { + it('기본 적으로 div 요소로 감싸지며, 해당 요소에 aspect-ratio 속성이 적용되어야 합니다.', () => { render(

    Content

    ); - const paragraph = screen.getByRole('paragraph'); + const parentElement = screen.getByRole('paragraph') + .parentElement as HTMLElement; + + expect(parentElement).toHaveStyle( + 'padding-top: calc(100% * (1 / 1.7777777777777777))' + ); + }); + + it('as props를 통해 특정 요소로 렌더링할 수 있습니다.', () => { + render( + +

    Content

    +
    + ); + const parentElement = screen.getByRole('paragraph') + .parentElement as HTMLElement; + expect(parentElement.tagName).toBe('UL'); + expect(parentElement).toHaveStyle( + 'padding-top: calc(100% * (1 / 1.7777777777777777))' + ); + }); + + it('asChild props를 통해 자식 요소를 그대로 렌더링하고, 자식 요소에 aspect-ratio 속성을 적용할 수 있습니다.', () => { + render( + +

    Content

    +
    + ); + + const paragraph = screen.getByRole('paragraph'); + expect(paragraph.tagName).toBe('P'); expect(paragraph).toHaveStyle('aspect-ratio: 1.7777777777777777'); }); }); diff --git a/packages/react/src/components/AspectRatio/index.tsx b/packages/react/src/components/AspectRatio/index.tsx index 93871f571..508dcc8f3 100644 --- a/packages/react/src/components/AspectRatio/index.tsx +++ b/packages/react/src/components/AspectRatio/index.tsx @@ -1,41 +1,84 @@ -import { Slot } from '../Slot'; -import { CSSProperties, Children, useMemo } from 'react'; +import { CSSProperties } from 'react'; +import { polymorphicForwardRef } from '../../utils/polymorphicForwardRef'; +import { Slot } from '../../components/Slot'; +import styles from './AspectRatio.modules.css'; +import classNames from 'classnames'; interface AspectRatioProps { children: JSX.Element; ratio: number; + asChild?: boolean; + style?: CSSProperties; + className?: string; } /** * @description 주어진 aspect-ratio 비율을 맞춰주기 위해 선언적으로 사용하는 유틸 컴포넌트입니다. * * 미리 영역을 확보하여 `Layout Shift`를 방지하는데 효과적입니다. - * * @see https://developer.mozilla.org/en-US/docs/Web/CSS/aspect-ratio * + * 다형성을 지원하기 때문에 `as`, `asChild` 속성을 지원합니다. + * + * - 기본적으로 `div` 태그로 자식 요소를 감싸서 렌더링하며, `as` 속성을 통해 감싸는 요소를 특정 요소로 변경해 렌더링할 수 있습니다. 이때, 해당 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. + * - `asChild` 속성이 `true`라면 `Slot`을 통해 자식 요소를 그대로 렌더링하고, 자식 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. + * - `asChild` 속성을 사용 할 경우 아래 링크를 참고하세요. + * + * @see https://modern-agile-team.github.io/modern-kit/docs/react/components/Slot + * * @param {AspectRatioProps} props - 컴포넌트에 전달되는 속성들입니다. * @param {number} props.ratio - 자식 요소의 가로 세로 비율을 지정합니다. * @param {JSX.Element} props.children - 렌더링 할 자식요소 입니다. - * + * @param {CSSProperties} props.style - 추가적인 스타일을 지정합니다. + * @param {string} props.className - 추가적인 클래스를 지정합니다. + * @param {string} props.as - 감싸는 요소를 지정합니다. 기본 값은 `div`입니다. 해당 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. + * @param {boolean} props.asChild - `true`일 경우 `Slot`을 통해 자식 요소를 그대로 렌더링하고, 자식 요소에 `aspect-ratio` 속성을 적용해 영역을 확보합니다. * @returns {JSX.Element} 주어진 aspect-ratio 비율에 맞춰 스타일이 적용된 자식 요소를 반환합니다. * * @example * ```tsx + * // Default * * image * * ``` + * + * @example + * ```tsx + * // as article + * + * image + * + * ``` + * + * @example + * ```tsx + * // asChild + * + * image + * + * ``` */ -export const AspectRatio = ({ - ratio, - children, -}: AspectRatioProps): JSX.Element => { - const customStyle: CSSProperties = useMemo( - () => ({ +export const AspectRatio = polymorphicForwardRef<'div', AspectRatioProps>( + ({ ratio, style, as = 'div', asChild = false, ...props }, ref) => { + const AspectRatioWrapper = asChild ? Slot : as; + + const slotStyle: CSSProperties = { aspectRatio: ratio, - }), - [ratio] - ); + ...style, + }; + + const className = asChild + ? props.className + : classNames(styles.aspectRatioWrapper, props.className); - return {Children.only(children)}; -}; + return ( + + ); + } +); diff --git a/packages/react/src/components/InView/InView.spec.tsx b/packages/react/src/components/InView/InView.spec.tsx index d4dd664fb..fcff80c44 100644 --- a/packages/react/src/components/InView/InView.spec.tsx +++ b/packages/react/src/components/InView/InView.spec.tsx @@ -22,6 +22,7 @@ interface TestComponentProps { onIntersectEnd: () => void; as?: ElementType; calledOnce?: boolean; + asChild?: boolean; } const TestComponent = ({ @@ -89,6 +90,29 @@ describe('InView', () => { expect(intersectEndMock).toBeCalledTimes(1); }); + it('asChild props를 통해 자식 요소를 그대로 렌더링하고, 자식 요소를 관찰 대상으로 설정할 수 있습니다.', async () => { + renderSetup( + + ); + + const boxWrapper = screen.getByText('box'); + expect(boxWrapper.tagName).toBe('DIV'); + + await waitFor(() => + mockIntersecting({ type: 'view', element: boxWrapper }) + ); + expect(intersectStartMock).toBeCalledTimes(1); + + await waitFor(() => + mockIntersecting({ type: 'hide', element: boxWrapper }) + ); + expect(intersectEndMock).toBeCalledTimes(1); + }); + it('calledOnce 프로퍼티가 true이면 onIntersect 콜백 함수를 한 번 호출해야 합니다.', async () => { renderSetup( void} props.onIntersectStart - 타겟 요소가 viewport 내에 들어갈 때 호출되는 콜백 함수입니다. * @param {(entry: IntersectionObserverEntry) => void} props.onIntersectEnd - 타겟 요소가 viewport에서 나갈 때 호출되는 콜백 함수입니다. * @param {number | number[]} props.threshold - 관찰을 시작할 viewport의 가시성 비율입니다. @@ -34,31 +46,39 @@ interface InViewProps extends UseIntersectionObserverProps { * * @example * ```tsx - * // 기본적으로 div로 감싸지며, 해당 div를 관찰 대상으로 설정합니다. - * // 해당 div가 viewport에 노출되거나 숨겨질 때 onIntersectStart/onIntersectEnd 콜백 함수를 호출합니다. + * // 기본적으로 `div`로 감싸지며, 해당 `div`를 관찰 대상으로 설정합니다. * - *
    Content1
    + *
    Contents
    *
    * ``` * * @example * ```tsx - * // as 속성을 통해 특정 요소로 렌더링할 수 있습니다. + * // `as` 속성을 통해 감싸는 요소를 특정 요소로 변경해 렌더링할 수 있습니다. 해당 요소를 관찰 대상으로 설정합니다. * *
  • List Item1
  • *
  • List Item2
  • *
    * ``` + * + * @example + * ```tsx + * // `asChild`가 `true`라면 `Slot`을 통해 래퍼 요소 없이 자식 요소를 그대로 렌더링하고, 자식 요소를 관찰 대상으로 설정합니다. + * + *
    Contents
    + *
    + * ``` */ -export const InView = polymorphicForwardRef<'div', InViewProps>( - ({ children, as = 'div', ...props }, ref) => { - const Wrapper = as ?? 'div'; +export const InView = polymorphicForwardRef<'button', InViewProps>( + ({ as = 'div', asChild = false, ...props }, ref) => { + const InViewWrapper = asChild ? Slot : as; const { ref: intersectionObserverRef } = useIntersectionObserver(props); return ( - - {children} - + ); } ); diff --git a/packages/react/src/components/Slot/index.tsx b/packages/react/src/components/Slot/index.tsx index a9ae40a20..d1a6cfd37 100644 --- a/packages/react/src/components/Slot/index.tsx +++ b/packages/react/src/components/Slot/index.tsx @@ -7,7 +7,39 @@ export type SlotProps = React.PropsWithChildren< >; /** - * @description 주어진 Props를 직계 자식 컴포넌트에 병합하는 컴포넌트입니다. + * @description 주어진 Props를 직계 자식 컴포넌트에 병합하고, 자식 컴포넌트를 렌더링하는 컴포넌트입니다. + * + * Slot은 부모 컴포넌트의 기능을 자식 컴포넌트에 전달하는 합성 패턴을 구현합니다. 이를 통해: + * - 부모 컴포넌트의 `props`, `ref`, `이벤트 핸들러` 등을 자식 컴포넌트에 전달할 수 있습니다. + * - 자식 컴포넌트의 구현을 변경하지 않고도 새로운 기능을 추가할 수 있습니다. + * - 컴포넌트 간의 결합도를 낮추고 재사용성을 높일 수 있습니다. + * + * Slot은 아래와 같은 특징이 있습니다. + * - 자식 요소로 `단일 요소`만 허용됩니다. + * - 자식 요소로 컴포넌트가 온다면 해당 컴포넌트는 필수적으로 `forwardRef`, `props`를 허용해야 합니다. 허용하지 않으면 기능이 정상적으로 동작하지 않습니다. + * - Slot을 사용 할 경우 아래 링크를 참고하세요. + * + * @see https://www.radix-ui.com/primitives/docs/guides/composition#your-component-must-spread-props + * @see https://www.radix-ui.com/primitives/docs/guides/composition#your-component-must-forward-ref + * + * @example + * ```tsx + * import React from "react"; + * import { Slot } from "@modern-kit/react"; + * + * function Button({ asChild, ...props }) { + * const Comp = asChild ? Slot : "button"; + * return ; + * } + * + * // default + * + * + * // asChild + * + * ``` */ export const Slot = React.forwardRef( (props, forwardedRef) => { diff --git a/packages/react/src/utils/polymorphicForwardRef.tsx b/packages/react/src/utils/polymorphicForwardRef.tsx index 22269bdb7..d4934bba8 100644 --- a/packages/react/src/utils/polymorphicForwardRef.tsx +++ b/packages/react/src/utils/polymorphicForwardRef.tsx @@ -10,8 +10,9 @@ import { } from 'react'; /** - * @description 유니온 타입에서 각각의 타입에 대해 Omit을 적용하는 타입입니다. - * `조건부 타입`을 사용하여 분배법칙처럼 동작합니다. + * @description 유니온 타입에서 각각의 타입에 대해 `Omit`을 적용하는 타입입니다. + * + * `조건부 타입`을 사용하여 `분배 법칙`처럼 동작합니다. * * @template T - 분배 대상이 되는 유니온 타입 * @template K - 제거할 프로퍼티 키 @@ -49,8 +50,9 @@ type DistributiveOmit = T extends any type Merge = Omit & B; /** - * @description 유니온 타입의 각 구성 요소에 대해 B 타입과의 병합을 수행하는 타입입니다. - * 각 유니온 멤버에서 B의 키들을 제거한 후, B 타입과 병합합니다. + * @description 유니온 타입의 각 구성 요소에 대해 `B` 타입과의 병합을 수행하는 타입입니다. + * + * 각 유니온 멤버에서 `B`의 키들을 제거한 후, `B` 타입과 병합합니다. * * @template A - 병합의 대상이 되는 유니온 타입 * @template B - 각 유니온 멤버와 병합될 타입 @@ -68,11 +70,13 @@ type Merge = Omit & B; type DistributiveMerge = DistributiveOmit & B; /** - * @description 다형성 컴포넌트의 props 타입을 정의하는 타입입니다. 이때, `as` 프로퍼티를 포함합니다. + * @description 다형성 컴포넌트의 `props`의 타입을 정의하는 타입입니다. + * + * `as`를 포함해 고정 `props`와 지정된 요소 타입에 기본적으로 제공하는 `props`를 병합합니다. * * @template Component - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. - * @template PermanentProps - 컴포넌트의 고정 props 타입입니다. - * @template ComponentProps - 지정된 요소 타입에 기본적으로 제공하는 props 타입입니다. 예를 들어 button 요소의 경우 type, disabled 등이 있습니다. + * @template PermanentProps - 컴포넌트의 고정 `props` 타입입니다. + * @template ComponentProps - 지정된 요소 타입에 기본적으로 제공하는 `props` 타입입니다. 예를 들어 button 요소의 경우 type, disabled 등이 있습니다. * * @example * interface ButtonProps { @@ -96,12 +100,12 @@ type AsProps< > = DistributiveMerge; /** - * @description ref를 포함한 다형성 컴포넌트의 함수 시그니처를 정의하는 타입입니다. + * @description `ref`를 포함한 다형성 컴포넌트의 함수 시그니처를 정의하는 타입입니다. * 하나의 컴포넌트가 여러 HTML 요소로 렌더링될 수 있도록 하며, 각 요소에 맞는 props와 ref를 자동으로 처리합니다. * * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. - * @template Props - 컴포넌트의 커스텀 props 타입입니다. - * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) + * @template Props - 컴포넌트의 커스텀 `props` 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 예를 들어, 'button' | 'a' 를 넣으면 `as`로 'button' | 'a'만 넣을 수 있게 제한됩니다. */ type PolymorphicWithRef< Default extends OnlyAs, @@ -118,12 +122,12 @@ type PolymorphicWithRef< ) => ReactElement | null; /** - * @description React.forwardRef를 사용한 다형성 컴포넌트의 전체 타입을 정의합니다. - * ForwardRefExoticComponent와 PolymorphicWithRef를 결합합니다. + * @description `React.forwardRef`를 사용한 다형성 컴포넌트의 전체 타입을 정의합니다. + * `ForwardRefExoticComponent`와 `PolymorphicWithRef`를 결합합니다. * * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. - * @template Props - 컴포넌트의 커스텀 props 타입입니다. - * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) + * @template Props - 컴포넌트의 커스텀 `props` 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 예를 들어, 'button' | 'a' 를 넣으면 `as`로 'button' | 'a'만 넣을 수 있게 제한됩니다. */ type PolyForwardComponent< Default extends OnlyAs, @@ -142,14 +146,14 @@ type PolyForwardComponent< >; /** - * @description React.forwardRef를 위한 다형성 타입 래퍼입니다. - * 컴포넌트에 다형성과 ref 전달 기능을 모두 부여합니다. + * @description `React.forwardRef`를 위한 다형성 타입 래퍼입니다. + * 컴포넌트에 다형성과 `ref` 전달 기능을 모두 부여합니다. * * @template Default - 렌더링할 요소의 타입을 지정합니다. 예를 들어, 'button', 'div' 등 HTML 요소가 될 수 있습니다. - * @template Props - 컴포넌트의 커스텀 props 타입입니다. - * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. (선택적, 기본값: ElementType) + * @template Props - 컴포넌트의 커스텀 `props` 타입입니다. + * @template OnlyAs - 컴포넌트가 렌더링될 수 있는 요소 타입을 제한합니다. 예를 들어, 'button' | 'a' 를 넣으면 `as`로 'button' | 'a'만 넣을 수 있게 제한됩니다. * - * @returns 다형성과 ref 전달이 가능한 새로운 컴포넌트 타입을 반환합니다. + * @returns 다형성과 `ref` 전달이 가능한 새로운 컴포넌트 타입을 반환합니다. */ type PolyRefFunction = < Default extends OnlyAs, @@ -160,10 +164,11 @@ type PolyRefFunction = < ) => PolyForwardComponent; /** - * @description React.forwardRef를 다형성 컴포넌트를 위한 타입으로 캐스팅하는 유틸리티입니다. - * 기존의 forwardRef를 PolyRefFunction 타입으로 변환하여 다형성과 ref 전달을 모두 지원하는 컴포넌트를 생성할 수 있게 합니다. + * @description `React.forwardRef`를 다형성 컴포넌트를 위한 타입으로 캐스팅하는 유틸리티입니다. + * 기존의 `forwardRef`를 `PolyRefFunction` 타입으로 변환하여 다형성과 `ref` 전달을 모두 지원하는 컴포넌트를 생성할 수 있게 합니다. * * @example + * // 기본 사용 * interface ButtonProps { * variant: 'primary' | 'secondary'; * size: 'sm' | 'md' | 'lg'; @@ -173,5 +178,12 @@ type PolyRefFunction = < * const Component = props.as ?? 'button'; * return ; * }); + * + * @example + * // OnlyAs로 요소 타입 제한 + * const Button = polymorphicForwardRef<'button', ButtonProps, 'button' | 'a'>((props, ref) => { + * const Component = props.as ?? 'button'; + * return ; + * }); */ export const polymorphicForwardRef = forwardRef as PolyRefFunction;