๐ ์์ํ๊ธฐ์ ์์์
์ต๊ทผ Storybook์ ํตํด์ ๋์์ธ ์์คํ ์ ๊ตฌ์ถํ๋ค๊ฐ ๊ณตํต ์ปดํฌ๋ํธ์ ํ๊ทธ๋ฅผ ๋ณ๊ฒฝํ๊ฑฐ๋ ๊ณตํต ์ปดํฌ๋ํธ์ ๋์์ children์ผ๋ก ๋ฐ๋ ์์ ์ปดํฌ๋ํธ๋ก ๋๊ธฐ๊ณ ์ถ์ ๊ฒฝ์ฐ๊ฐ ์์๋ค.
์๋ฅผ ๋ค์ด, ๊ณต์ฉ Button ์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฉํ๊ณ ์๋๋ฐ ํด๋ฆญ ์ ๋ค๋ฅธ ํ์ด์ง๋ก ์ด๋ํด์ผ ํ๊ธฐ ๋๋ฌธ์ button
ํ๊ทธ๊ฐ ์๋ Link
(a
ํ๊ทธ)๋ฅผ ์ฌ์ฉํ๊ณ ์ถ์ ์ ์๋ค.
<Link href='...'>
<Button>
๋งํฌ ์ด๋
</Button>
<Link>
์ด๋ ๊ฒ ๊ตฌํํด๋ ์๋ํ์ง๋ง, HTML5 specification์ ๋ฐ๋ฅด๋ฉด button
ํ๊ทธ ๋ด์ a
ํ๊ทธ๋ฅผ ์ค์ฒฉํ๋ ๊ฒ์ ๋ช
์ธ๋ฅผ ์๋ฐํ๋ ๋ฐฉ๋ฒ์ด๋ค. (๋ฐ๋๋ก button
ํ๊ทธ ๋ด์ a
ํ๊ทธ๋ฅผ ์ค์ฒฉํด์ ์ฌ์ฉํ๋ ๊ฒ๋)
Content model:
Transparent, but there must be no interactive content descendant,
a
element descendant, or descendant with thetabindex
attribute specified.
a
ํ๊ทธ ๋ด๋ถ์๋ ์ํธ ์์ฉ์ด ๊ฐ๋ฅํ ํ์ ์์, a
ํ๊ทธ ํ์ ์์ ๋๋ tabindex
์์ฑ์ด ์ง์ ๋ ํ์ ์์๊ฐ ์์ด์ผ ํ๋ค. ์ฌ๊ธฐ์ ์ํธ ์์ฉ์ด ๊ฐ๋ฅํ ํ์ ์์๋ button
, input
(type ์์ฑ์ด hidden์ด ์๋), select
๋ฑ๊ณผ ๊ฐ์ด ์ํธ์์ฉ์ ์ํด ๋ง๋ค์ด์ง ํ๊ทธ๋ค์ ๋งํ๋ค.
์ฐธ๊ณ ๋ก Next.js์์๋ HTML ๋ช ์ธ์ ์๋ฐํ์ฌ ์ค์ฒฉ๋ HTML ํ๊ทธ๋ฅผ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ, Hydration ์๋ฌ๋ฅผ ๋ฐ์์ํค๋ฉฐ ์์ ๋น์ทํ ์ค๋ช ์ด ๊ณต์๋ฌธ์์ ์จ์ ธ์๋ค.
Hydration errors can occur from:
Incorrect nesting of HTML tags
<p>
nested in another<p>
tag
<div>
nested in a<p>
tag
<ul>
or<ol>
nested in a<p>
tagInteractive Content cannot be nested (
<a>
nested in a<a>
tag,<button>
nested in a<button>
tag, etc.)
์์ ๊ฐ์ ์ํฉ ๋๋ฌธ์ ํ์ ํ ๊ฒฝ์ฐ ์ปดํฌ๋ํธ์ ๊ธฐ๋ณธ ํ๊ทธ๋ฅผ ๋ณ๊ฒฝ(button
-> a
)ํด์ ์ฌ์ฉํ๋ ๊ฒ์ด ์ข์ผ๋ฉฐ, Chakra UI๋ Reakit์์ ์์ฃผ ์ฌ์ฉํ๋ as
prop pattern์ ์ฌ์ฉํ ์ ์๋ค.
export function Button({ as, ...props }) {
const Comp = as ?? "button"
return <Comp {...props} />
}
<Button as={Link} />
๊ทธ๋ฌ๋ ๋ ๋ง์ ์ฌ์ฉ์ ์ ์๋ฅผ ํ์ฉํ๋ ค๋ฉด, ์๋ฅผ ๋ค์ด ํ์ ์ปดํฌ๋ํธ์ props์ ์ ๋ฌํ๋ ค๋ฉด as
prop์ด ๋ฌธ์ ๊ฐ ๋ ์ ์๋ค. TypeScript๋ฅผ ํตํด์ ์ถฉ๋ถํ ๊ตฌํ ๊ฐ๋ฅํ์ง๋ง ์ค์ ํ๊ธฐ๊ฐ ๋ณต์กํด์ง๊ณ ๋ฐํ์์ ๋๋ ค์ง ์ ์๋ค.
<Button
as='a'
target='_blank'
variant='outline'
href='...'
...
>
Hello
</Button>
๐ค asChild pattern
asChild
pattern์ Radix์์ ๋ง์ด ์ฌ์ฉ๋๋ฉฐ, as
prop pattern์ ๋น๊ตํ๋ฉด ๊ตฌํํ๊ธฐ ์ฌ์ฐ๋ฉฐ ์ดํดํ๊ธฐ์๋ ์ฝ๋ค.
<Button asChild>
<a target='_blank' href="..." />
</Button>
asChild
๊ฐ false์ผ ๋, ๊ธฐ๋ณธ ์ปดํฌ๋ํธ(button)๋ฅผ ๋ ๋๋งํ๋ค.asChild
๊ฐ true์ผ ๋๋ ์์ ์ปดํฌ๋ํธ(a)๋ฅผ ๋ ๋๋งํ๋ค.
Radix๋ฅผ ์ฌ์ฉํ๋ค ๋ณด๋ฉด ์ฃผ๋ก Trigger ๊ฐ์ ์ปดํฌ๋ํธ์์ asChild
props๋ฅผ ๋ง๋๊ฒ ๋๋๋ฐ, ์ค๋ช
๊ณผ ํจ๊ป ์ด๋ป๊ฒ ์ฌ์ฉํ๊ณ ์๋์ง ์ดํด๋ณด์. Radix์์ asChild์ ํดํ์๋ ๋ค์๊ณผ ๊ฐ์ด ์ค๋ช
๋์ด ์๋ค.
์์์ผ๋ก ์ ๋ฌ๋ ์์์ ๊ธฐ๋ณธ ๋ ๋๋ง ์์๋ฅผ ๋ณ๊ฒฝํ์ฌ props์ ๋์์ ๋ณํฉํ๋ค.
์ค๋ช ๋ง ๋ค์ผ๋ฉด ๋ํดํ๋ฐ, ์์ ์ฝ๋๋ฅผ ์ดํด๋ณด์.
import * as React from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
export default () => (
<Tooltip.Root>
<Tooltip.Trigger asChild>
โ
a ํ๊ทธ์ Tooltip.Trigger์ props์ ๋์์ด ์์ ํ๊ทธ์ธ a๋ก ๋ณํฉ๋๋ค.
<a href="https://www.radix-ui.com/">Radix UI</a>
</Tooltip.Trigger>
<Tooltip.Portal>โฆ</Tooltip.Portal>
</Tooltip.Root>
);
๋ค๋ง, ์ ๊ฐ์ด ๊ธฐ๋ณธ ํ๊ทธ๋ฅผ ๋ณ๊ฒฝ(button
-> a
)ํ๊ธฐ๋ก ๊ฒฐ์ ํ๋ค๋ฉด, ์ ๊ทผ์ฑ๊ณผ ๊ธฐ๋ฅ์ ์ ์งํ ์ ์๋๋ก ํ๋ ๊ฒ์ ์ฌ์ฉ์์ ์ฑ
์์ด๋ค. ์๋ฅผ ๋ค์ด Tooltip.Trigger
๋ ํฌ์ธํฐ ๋ฐ ํค๋ณด๋ ์ด๋ฒคํธ์ ๋ฐ์ํ ์ ์๋ ํฌ์ปค์ค ๊ฐ๋ฅํ ์์์ฌ์ผ ํ๋ค. ๋ง์ฝ ์ด๋ฅผ div
ํ๊ทธ๋ก ๋ณ๊ฒฝํ๋ฉด ๋ ์ด์ ์ก์ธ์คํ ์ ์๊ฒ ๋๋ค.
์ค์ ๋ก๋ ์์ฒ๋ผ ๊ธฐ๋ณธ DOM ์์๋ฅผ ์ง์ ์์ ํด์ผ ํ๋ ์ผ์ ๊ฑฐ์ ์๊ณ , ๋์ ์์ฒด React ์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฉํ๋ ๊ฒ์ด ๋ ์ผ๋ฐ์ ์ด๋ค. Trigger
์ ๊ฒฝ์ฐ ์ฃผ๋ก ์ฌ์ฉ๋๋ ์ด์ ๊ฐ ๋์์ธ ์์คํ
์ ์ฌ์ฉ์ ์ ์ ๋ฒํผ, ๋งํฌ์ ํจ๊ป Trigger
์ ๊ธฐ๋ฅ์ ๋ณํฉํด์ ์ฌ์ฉํ๊ณ ์ ํ๊ธฐ ๋๋ฌธ์ด๋ค.
import * as React from 'react';
import * as Tooltip from '@radix-ui/react-tooltip';
export default () => (
<Tooltip.Root>
<Tooltip.Trigger asChild>
โ
Button ์ปดํฌ๋ํธ์ Trigger์ ๊ธฐ๋ฅ์ด ๋ณํฉ๋๋ค.
<Button>Radix UI</Button>
</Tooltip.Trigger>
<Tooltip.Portal>โฆ</Tooltip.Portal>
</Tooltip.Root>
);
๐ Slot Component
๊ทธ๋ ๋ค๋ฉด asChild
pattern์ ์ด๋ป๊ฒ ๊ตฌํํ ์ ์์๊น? Radix์ ์์ค์ฝ๋๋ฅผ ํตํด ์ด๋ป๊ฒ ๊ตฌํ๋์ด ์๋์ง ์ดํด๋ณด์.
radix-ui/themes์ base-button.tsx ์์ค์ฝ๋๋ฅผ ๋ณด๋ฉด Slot ์ปดํฌ๋ํธ๋ฅผ ํตํด์ ๊ตฌํ๋์ด ์๋๊ฑธ ํ์ธํ ์ ์๋ค.
- https://github.com/radix-ui/themes/blob/main/packages/radix-ui-themes/src/components/base-button.tsx
const BaseButton = React.forwardRef<BaseButtonElement, BaseButtonProps>((props, forwardedRef) => {
...
const {
...
asChild,
} = extractProps(props, baseButtonPropDefs, marginPropDefs);
const Comp = asChild ? Slot : 'button';
return (
<Comp>
{props.loading ? (
<>
...
</>
) : (
children
)}
</Comp>
);
});
Radix ๊ณต์๋ฌธ์์ ๋ฐ๋ฅด๋ฉด, Slot ์ปดํฌ๋ํธ๋ props๋ฅผ ์์์ ๋ณํฉํ๊ธฐ ์ํ ์ปดํฌ๋ํธ๋ค.
import React from 'react';
import { Slot } from '@radix-ui/react-slot';
function Button({ asChild, ...props }) {
const Comp = asChild ? Slot : 'button';
return <Comp {...props} />;
}
์ฆ, asChild
pattern์ ์ง์ํ๊ธฐ ์ํด ๋ง๋ค์ด์ง ์ปดํฌ๋ํธ์ธ๋ฐ ๋ด๋ถ ๊ตฌํ์ ์ด๋ป๊ฒ ๋์ด์์๊น?
const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
// 1. ์์์์ ์ค์ Slottable ์ปดํฌ๋ํธ๊ฐ ์๋์ง ํ์ธํ๋ค.
const childrenArray = React.Children.toArray(children);
const slottable = childrenArray.find(isSlottable);
// 2. ๋ง์ฝ Slottable ์ปดํฌ๋ํธ๊ฐ ์์ผ๋ฉด,
if (slottable) {
// Slottable ์ปดํฌ๋ํธ์ ์์ ์์๋ฅผ newElement์ ํ ๋นํ๋ค.
const newElement = slottable.props.children as React.ReactNode;
// childrenArray๋ฅผ ์ํํ๋ฉด์ ์๋ก์ด ์์ ์์๋ฅผ ์์ฑํ๋ค.
// Slottable ์ปดํฌ๋ํธ๋ฅผ ๋ฐ๊ฒฌํ๋ฉด ํด๋น ์์ ์์๋ฅผ newElement๋ก ๊ต์ฒดํฉ๋๋ค.
const newChildren = childrenArray.map((child) => {
if (child === slottable) {
// because the new element will be the one rendered, we are only interested
// in grabbing its children (`newElement.props.children`)
if (React.Children.count(newElement) > 1) return React.Children.only(null);
return React.isValidElement(newElement)
? (newElement.props.children as React.ReactNode)
: null;
} else {
return child;
}
});
// 3. Slottable ์ปดํฌ๋ํธ๊ฐ ์กด์ฌํ์ง ์์ ๊ฒฝ์ฐ, ๊ธฐ์กด์ children์ ๊ทธ๋๋ก ๋ ๋๋งํฉ๋๋ค.
return (
<SlotClone {...slotProps} ref={forwardedRef}>
{React.isValidElement(newElement)
? React.cloneElement(newElement, undefined, newChildren)
: null}
</SlotClone>
);
}
return (
<SlotClone {...slotProps} ref={forwardedRef}>
{children}
</SlotClone>
);
});
const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
// children์ด ์ ํจํ React ์์์ธ์ง ํ์ธํ๋ค.
if (React.isValidElement(children)) {
// React.cloneElement์ ์ฌ์ฉํ์ฌ children์ ๋ณต์ ํ๋ค. ์ด๋, ์๋ก์ด ์์ฑ์ ์ ์ฉํ์ฌ ๋ฐํํ๋ค.
return React.cloneElement(children, {
// mergeProps ํจ์๋ฅผ ์ฌ์ฉํ์ฌ slotProps์ children.props๋ฅผ ๋ณํฉํ๋ค.
// ์ด๋ฅผ ํตํด Slot ์ปดํฌ๋ํธ์ ์์ฑ๊ณผ ํด๋น ์์ ์์์ ์์ฑ์ ํจ๊ป ์ ์ฉํ ์ ์๋ค.
...mergeProps(slotProps, children.props),
// ๋ถ๋ชจ ์ปดํฌ๋ํธ์์ ์ ๋ฌ๋ ref์ ํจ๊ป ๋์ํ๋๋ก ํ๋ค.
// composeRefs ํจ์๋ฅผ ์ฌ์ฉํ์ฌ ๋ ๊ฐ์ ref๋ฅผ ๊ฒฐํฉํ๋ค.
ref: forwardedRef ? composeRefs(forwardedRef, (children as any).ref) : (children as any).ref,
});
}
// children์ด ์ ํจํ React ์์๊ฐ ์๋๊ฑฐ๋, children์ด ์ฌ๋ฌ ๊ฐ์ผ ๊ฒฝ์ฐ์๋ null์ ๋ฐํํ๋ค.
// ์ด๋ ๊ฒ ํจ์ผ๋ก์จ children์ด ๋จ์ผ ์์์ผ ๋๋ง ์ฒ๋ฆฌ๋๋๋ก ํ๋ค.
return React.Children.count(children) > 1 ? React.Children.only(null) : null;
});
const Slottable = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
// child๊ฐ React ์์์ธ์ง ํ์ธํ๊ณ , type์ด 'Slottable' ์ปดํฌ๋ํธ์ธ์ง ํ์ธํ๋ค.
function isSlottable(child: React.ReactNode): child is React.ReactElement {
return React.isValidElement(child) && child.type === Slottable;
}
...
์ ๋ฆฌํด๋ณด๋ฉด,
Slot: ์ฃผ์ด์ง ์์ ์์์ ์์ฑ์ ๋ณํฉํ๊ณ ํด๋น ์์๋ฅผ ๋ ๋๋งํ๋ค. ์ด ์ปดํฌ๋ํธ๋ SlotClone ์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฉํ์ฌ ๋ด๋ถ์ ์ผ๋ก ๋ ๋๋งํ๋ค.
SlotClone: Slot์ ์์ ์์๋ฅผ ๋ณต์ ํ๊ณ ํด๋น ์์์ ์์ฑ์ ๋ณํฉํ ํ ๋ ๋๋งํ๋ค.
Slottable: Slot ์ปดํฌ๋ํธ์ ์์ ์์๋ก ์ฌ์ฉ๋๋ฉฐ, Slot์ด ๋ ๋๋งํ ๋์์ด๋ค.
โจ ๋๋ด๋ฉฐ
์ด๋ ๊ฒ as
prop pattern์ผ๋ก ์์ํด์, Radix์ asChild
prop pattern๊น์ง ์ดํด๋ณด์๋ค. Slot ์ปดํฌ๋ํธ์ ๋ด๋ถ ๊ตฌํ์ ๋ณด๋ฉด์ props๋ฅผ ๋ณํฉํ๋ mergeProps
๋ ref๋ฅผ ๊ฒฐํฉํ๋ composeRefs
๊ฐ์ ํฌํผ ํจ์๋ ์ ์ ์์๊ณ , React.isValidElement
, React.cloneElement
, React.Children.count
, React.Children.toArray
๊ฐ์ ์์ฃผ ์ฌ์ฉํ์ง ์๊ฑฐ๋ ์ฒ์ ๋ณด๋ ๋ฉ์๋๋ค๋ ๋ง๋๊ฒ ๋์ด ํฅ๋ฏธ๋ก์ ๋ค.
Slot ์ปดํฌ๋ํธ ์ธ์๋ ๋ค๋ฅธ UI ์ปดํฌ๋ํธ ๋ด๋ถ ๊ตฌํ๋ ์ดํด๋ณด๋ฉด์ ํฅ๋ฏธ๋ก์ด ์ ์ด ์์ผ๋ฉด ๋ค์ ํฌ์คํ ์ ์ด์ด์ง์ง ์์๊น ์ถ๋ค. ๋!