diff --git "a/\354\240\225\354\234\240\354\206\241/shadcn-ui/add.md" "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/add.md" new file mode 100644 index 0000000..4ea9a78 --- /dev/null +++ "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/add.md" @@ -0,0 +1,225 @@ +## shadcn/ui `add` + +컴포넌트와 종속성을 추가하기 위해 사용 + +`npx shadcn-ui@latest add [component]` + +## 코드 살펴보기 + +```tsx +export const add = new Command() + .name("add") + .description("add a component to your project") + .argument("[components...]", "the components to add") + .option("-y, --yes", "skip confirmation prompt.", true) + .option("-o, --overwrite", "overwrite existing files.", false) + .option( + "-c, --cwd ", + "the working directory. defaults to the current directory.", + process.cwd() + ) + .option("-a, --all", "add all available components", false) + .option("-p, --path ", "the path to add the component to.") + + .action(async (components, opts) => { + try { + const options = addOptionsSchema.parse({ + components, + ...opts, + }) + + const cwd = path.resolve(options.cwd) + + if (!existsSync(cwd)) { + logger.error(`The path ${cwd} does not exist. Please try again.`) + process.exit(1) + } + + const config = await getConfig(cwd) + if (!config) { + logger.warn( + `Configuration is missing. Please run ${chalk.green( + `init` + )} to create a components.json file.` + ) + process.exit(1) + } +``` + +`commander`를 활용해 CLI를 작성 + +- `name`, `description`, `argument`, `option` : 명령어 정의 및 설명, 옵션 설정 등 +- `action` : 명령어 실행 시 action 함수 호출 + +```tsx + const registryIndex = await getRegistryIndex() + + let selectedComponents = options.all + ? registryIndex.map((entry) => entry.name) + : options.components + if (!options.components?.length && !options.all) { + const { components } = await prompts({ + type: "multiselect", + name: "components", + message: "Which components would you like to add?", + hint: "Space to select. A to toggle all. Enter to submit.", + instructions: false, + choices: registryIndex.map((entry) => ({ + title: entry.name, + value: entry.name, + selected: options.all + ? true + : options.components?.includes(entry.name), + })), + }) + selectedComponents = components + } + + if (!selectedComponents?.length) { + logger.warn("No components selected. Exiting.") + process.exit(0) + } +``` + +- registry에서 컴포넌트 목록을 가져옴 + - 옵션에서 지정했다면? 사용자가 선택한 컴포넌트 설정 + - 지정하지 않았다면? [`prompt`](https://github.com/terkelg/prompts)를 활용해 컴포넌트 추가 관련 유저 입력을 받게 됨 + +```tsx + const tree = await resolveTree(registryIndex, selectedComponents) + const payload = await fetchTree(config.style, tree) + const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) + + if (!payload.length) { + logger.warn("Selected components not found. Exiting.") + process.exit(0) + } + + if (!options.yes) { + const { proceed } = await prompts({ + type: "confirm", + name: "proceed", + message: `Ready to install components and dependencies. Proceed?`, + initial: true, + }) + + if (!proceed) { + process.exit(0) + } + } + + const spinner = ora(`Installing components...`).start() + for (const item of payload) { + spinner.text = `Installing ${item.name}...` + const targetDir = await getItemTargetPath( + config, + item, + options.path ? path.resolve(cwd, options.path) : undefined + ) + + if (!targetDir) { + continue + } + + if (!existsSync(targetDir)) { + await fs.mkdir(targetDir, { recursive: true }) + } + + const existingComponent = item.files.filter((file) => + existsSync(path.resolve(targetDir, file.name)) + ) + + if (existingComponent.length && !options.overwrite) { + if (selectedComponents.includes(item.name)) { + spinner.stop() + const { overwrite } = await prompts({ + type: "confirm", + name: "overwrite", + message: `Component ${item.name} already exists. Would you like to overwrite?`, + initial: false, + }) + + if (!overwrite) { + logger.info( + `Skipped ${item.name}. To overwrite, run with the ${chalk.green( + "--overwrite" + )} flag.` + ) + continue + } + + spinner.start(`Installing ${item.name}...`) + } else { + continue + } + } + + for (const file of item.files) { + let filePath = path.resolve(targetDir, file.name) + + // Run transformers. + const content = await transform({ + filename: file.name, + raw: file.content, + config, + baseColor, + }) + + if (!config.tsx) { + filePath = filePath.replace(/\.tsx$/, ".jsx") + filePath = filePath.replace(/\.ts$/, ".js") + } + + await fs.writeFile(filePath, content) + } + + const packageManager = await getPackageManager(cwd) + + // Install dependencies. + if (item.dependencies?.length) { + await execa( + packageManager, + [ + packageManager === "npm" ? "install" : "add", + ...item.dependencies, + ], + { + cwd, + } + ) + } + + // Install devDependencies. + if (item.devDependencies?.length) { + await execa( + packageManager, + [ + packageManager === "npm" ? "install" : "add", + "-D", + ...item.devDependencies, + ], + { + cwd, + } + ) + } + } + spinner.succeed(`Done.`) + } +``` + +- 선택한 컴포넌트 기반으로 파일 트리 구성, 파일 변환 +- [ora 스피너](https://github.com/sindresorhus/ora)를 활용해 설치 진행 상태 표시 +- 파일 존재 여부 확인 → 덮어 쓸 것인지? +- 파일 작성 +- 종속성 설치 +- 등등 + +```tsx + } catch (error) { + handleError(error) + } + }) +``` + +- 에러 발생 시 handleError 호출해 에러 처리 \ No newline at end of file diff --git "a/\354\240\225\354\234\240\354\206\241/shadcn-ui/button.md" "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/button.md" new file mode 100644 index 0000000..602d310 --- /dev/null +++ "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/button.md" @@ -0,0 +1,198 @@ +## shadcn/ui/button + +```tsx +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } +``` + +## `asChild` prop + +https://www.radix-ui.com/primitives/docs/guides/composition + +`acChild`가 false이면 기본 컴포넌트를 렌더링 + +`asChild`가 true이면 자식 컴포넌트 복제 & 해당 컴포넌트에 필요한 prop과 동작을 전달 + +컴포넌트에 ref를 전달해야 함 → [`forwardRef`](https://ko.react.dev/reference/react/forwardRef)를 사용 + +## radix/ui/Slot + +https://www.radix-ui.com/primitives/docs/utilities/slot + +props를 자식에 병합하는 역할을 하는 컴포넌트 + +```tsx +import React from 'react'; +import { Slot } from '@radix-ui/react-slot'; + +function Button({ asChild, ...props }) { + const Comp = asChild ? Slot : 'button'; + return ; +} +``` + +```jsx +import { Button } from './your-button'; + +export default () => ( + + +); +``` + +https://github.com/radix-ui/primitives/blob/main/packages/react/slot/src/Slot.tsx + +### Slot + +```tsx +const Slot = React.forwardRef((props, forwardedRef) => { + const { children, ...slotProps } = props; + const childrenArray = React.Children.toArray(children); + const slottable = childrenArray.find(isSlottable); + + if (slottable) { + const newElement = slottable.props.children as React.ReactNode; + + 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; + } + }); + + return ( + + {React.isValidElement(newElement) + ? React.cloneElement(newElement, undefined, newChildren) + : null} + + ); + } + + return ( + + {children} + + ); +}); +``` + +- `slottable` 컴포넌트가 있는지 확인 + - `slottable` 컴포넌트가 있다면, 자식 요소를 `newElement`에 할당 + - `slottable` 컴포넌트가 없다면, 기존 `children` 렌더링 + +### SlotClone + +```tsx +const SlotClone = React.forwardRef((props, forwardedRef) => { + const { children, ...slotProps } = props; + + if (React.isValidElement(children)) { + const childrenRef = getElementRef(children); + return React.cloneElement(children, { + ...mergeProps(slotProps, children.props), + // @ts-ignore + ref: forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef, + }); + } + + return React.Children.count(children) > 1 ? React.Children.only(null) : null; +}); +``` + +- `children`이 유효한 React 요소라면, `cloneElement`로 `children` 복제 + - `mergeProps` 함수로 `slotProps`와 `children.props`를 병합 + - `composeRefs` 함수로 `ref` 결합 + + ⇒ 자식 요소를 복제하고 해당 요소에 속성을 병합해 렌더링 + +- `children`이 유효한 React 요소가 아니라면 `null` 반환 / `children`의 개수가 1 초과라면 오류 + - `React.Children.only(childeren)` → `null`은 유효한 값이 아니므로 에러 발생 + - `null`은 React 요소가 아님 + + ⇒ children이 단일 요소일 때만 처리 되도록 설정 + + +### Slottable + +```tsx +const Slottable = ({ children }: { children: React.ReactNode }) => { + return <>{children}; +}; + +function isSlottable(child: React.ReactNode): child is React.ReactElement { + return React.isValidElement(child) && child.type === Slottable; +} +``` + +- `child`가 React 요소인지, type이 `Slottable`인지 확인한다. + - `Slottable`은 `children`을 가진 요소 → `Slot`이 렌더링할 대상 + +## 이런 패턴을 사용하는 이유? + +- 유연한 컴포넌트 사용 +- 자식 요소에 props와 동작(기능) 병합 용이 + +⇒ 재사용성 \ No newline at end of file diff --git "a/\354\240\225\354\234\240\354\206\241/shadcn-ui/cva.md" "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/cva.md" new file mode 100644 index 0000000..6b67692 --- /dev/null +++ "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/cva.md" @@ -0,0 +1,273 @@ +# cva + +> Creating variants with the "traditional" CSS approach can become an arduous task; manually matching classes to props and manually adding types. +`cva` aims to take those pain points away, allowing you to focus on the more fun aspects of UI development. +*"전통적인" CSS 접근 방식으로 variants를 만드는 것은 class를 props에 수동으로 일치시키고 types를 수동으로 추가하는 등 힘든 작업이 될 수 있습니다. cva는 이러한 문제점을 없애고 UI 개발의 더 재미있는 측면에 집중할 수 있도록 하는 것을 목표로 합니다.* +> + +⇒ (Tailwind CSS나 기본 CSS에서) variants를 편리하게 부여하기 위한 기능 + +## Variants + +### **variants** + +### **compoundVariants** + +다른 변형 조건이 충족될 때 적용되는 변형 + +다양한 조건의 조합이 가능 + +### **defaultVariants** + +기본값, `null`로 설정해 제거 가능 + +## 구현 코드 + +- 전체 코드 [링크](https://github.com/joe-bell/cva/blob/main/packages/class-variance-authority/src/index.ts) + + ```tsx + export const cva = + (base?: ClassValue, config?: Config) => + (props?: Props) => { + if (config?.variants == null) + return cx(base, props?.class, props?.className); + + const { variants, defaultVariants } = config; + + const getVariantClassNames = Object.keys(variants).map( + (variant: keyof typeof variants) => { + const variantProp = props?.[variant as keyof typeof props]; + const defaultVariantProp = defaultVariants?.[variant]; + + if (variantProp === null) return null; + + const variantKey = (falsyToString(variantProp) || + falsyToString( + defaultVariantProp, + )) as keyof (typeof variants)[typeof variant]; + + return variants[variant][variantKey]; + }, + ); + + const propsWithoutUndefined = + props && + Object.entries(props).reduce( + (acc, [key, value]) => { + if (value === undefined) { + return acc; + } + + acc[key] = value; + return acc; + }, + {} as Record, + ); + + const getCompoundVariantClassNames = config?.compoundVariants?.reduce( + ( + acc, + { class: cvClass, className: cvClassName, ...compoundVariantOptions }, + ) => + Object.entries(compoundVariantOptions).every(([key, value]) => + Array.isArray(value) + ? value.includes( + { + ...defaultVariants, + ...propsWithoutUndefined, + }[key], + ) + : { + ...defaultVariants, + ...propsWithoutUndefined, + }[key] === value, + ) + ? [...acc, cvClass, cvClassName] + : acc, + [] as ClassValue[], + ); + + return cx( + base, + getVariantClassNames, + getCompoundVariantClassNames, + props?.class, + props?.className, + ); + }; + ``` + + +### getVariantClassNames + +```tsx +const getVariantClassNames = Object.keys(variants).map( + (variant: keyof typeof variants) => { + const variantProp = props?.[variant as keyof typeof props]; + const defaultVariantProp = defaultVariants?.[variant]; + + if (variantProp === null) return null; + + const variantKey = (falsyToString(variantProp) || + falsyToString( + defaultVariantProp, + )) as keyof (typeof variants)[typeof variant]; + + return variants[variant][variantKey]; + }, +); +``` + +- `varints`에 정의된 `key`값을 가져오는 함수 + - `defaultVariants`의 값이 정의되었다면 `defaultVariants`의 `variant key`값을 + - 아니라면 `variants`의 `variant key`값을 반환한다 +- `keyof typeof` + - 객체의 키로 이루어진 **유니온 타입**을 의미 + - 객체의 키들 중 하나만 값으로 들어올 수 있게 되어 타입 안정성 보장 + + ```tsx + variants: { + intent: { + primary: [ + "bg-blue-500", + "text-white", + "border-transparent", + "hover:bg-blue-600", + ], + // **or** + // primary: "bg-blue-500 text-white border-transparent hover:bg-blue-600", + secondary: [ + "bg-white", + "text-gray-800", + "border-gray-400", + "hover:bg-gray-100", + ], + }, + size: { + small: ["text-sm", "py-1", "px-2"], + medium: ["text-base", "py-2", "px-4"], + }, + }, + + const variantKey = (falsyToString(variantProp) || + falsyToString( + defaultVariantProp, + )) as keyof (typeof variants)[typeof variant]; + + + typeof variants = + { + intent: { + primary: string[]; + secondary: string[]; + }; + size: { + small: string[]; + medium: string[]; + }; + } + + typeof variant = 'intent' | 'size' + + (typeof variants)[typeof variant] = + { primary: string[]; secondary: string[] } | { small: string[]; medium: string[] } + + keyof (typeof variants)[typeof variant] = 'primary' | 'secondary' | 'small' | 'medium' + ``` + + - `variantKey`가 `variants`객체의 특정 `variant`에 유효한 `key`값인 것을 보장 + - `(typeof variants)`인 것은 우선 순위를 위한 것..! + +### propsWithoutUndefined + +```tsx +const propsWithoutUndefined = + props && + Object.entries(props).reduce( + (acc, [key, value]) => { + if (value === undefined) { + return acc; + } + + acc[key] = value; + return acc; + }, + {} as Record, + ); +``` + +- `props`의 `undefined`가 있다면 이를 제거한 새로운 객체를 반환하는 역할 + - `value`가 `undefined`가 아닌 경우 `acc`에 새로운 `key`, `value`을 추가하지만 + - `value`가 `undefined`인 경우 `acc`를 그대로 반환, 초기값은 빈 객체 + +### getCompoundVariantClassNames + +```tsx +const getCompoundVariantClassNames = config?.compoundVariants?.reduce( + ( + acc, + { class: cvClass, className: cvClassName, ...compoundVariantOptions }, + ) => + Object.entries(compoundVariantOptions).every(([key, value]) => + Array.isArray(value) + ? value.includes( + { + ...defaultVariants, + ...propsWithoutUndefined, + }[key], + ) + : { + ...defaultVariants, + ...propsWithoutUndefined, + }[key] === value, + ) + ? [...acc, cvClass, cvClassName] + : acc, + [] as ClassValue[], +); +``` + +- `config?.compoundVariants?.reduce((acc, cur) ⇒ (acc + cur), int)` + - 초기값: `[] as ClassValue[]` +- `Object.entries(compoundVariantOptions)` + - `compoundVariantOptions`의 `key`, `value`값을 `[key, value]` 쌍의 배열로 반환 + + ```tsx + compoundVariants: [ + { + intent: "primary", + size: "medium", + class: "uppercase", + }, + ] + + compoundVariantsOptions = { + intent: "primary", + size: "medium", + } + + Object.entires(compoundVariantsOptions) // ['intent': 'primary', 'size': 'medium'] + ``` + +- `Object.entries(compoundVariantOptions).every(([key, value]) ⇒ …` + - `[key, value]` 쌍의 배열 조건을 검증 + + ```tsx + Array.isArray(value) + ? value.includes( + { + ...defaultVariants, + ...propsWithoutUndefined, + }[key], + ) + : { + ...defaultVariants, + ...propsWithoutUndefined, + }[key] === value, + ) + ``` + + - `value`가 배열인 경우, `value`값이 포함되는지 확인 + - 배열이 아닌 경우, `value`값과 일치하는지 확인 + - `every`에서 검증한 조건이 `true`인 경우, `acc`에 `cvClass`와 `cvClassName` 추가 + - `false`이 경우, `acc`반환 \ No newline at end of file diff --git "a/\354\240\225\354\234\240\354\206\241/shadcn-ui/fs.writeFile.md" "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/fs.writeFile.md" new file mode 100644 index 0000000..1cd6d53 --- /dev/null +++ "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/fs.writeFile.md" @@ -0,0 +1,95 @@ +https://nodejs.org/api/fs.html + +## **`fs.writeFile(file, data[, options], callback)`** + +https://nodejs.org/api/fs.html#fswritefilefile-data-options-callback + +```tsx +function writeFile(path, data, options, callback) { + callback ||= options; + validateFunction(callback, 'cb'); + options = getOptions(options, { + encoding: 'utf8', + mode: 0o666, + flag: 'w', + flush: false, + }); + const flag = options.flag || 'w'; + const flush = options.flush ?? false; + + validateBoolean(flush, 'options.flush'); + + if (!isArrayBufferView(data)) { + validateStringAfterArrayBufferView(data, 'data'); + data = Buffer.from(data, options.encoding || 'utf8'); + } + + if (isFd(path)) { + const isUserFd = true; + const signal = options.signal; + writeAll(path, isUserFd, data, 0, data.byteLength, signal, flush, callback); + return; + } + + if (checkAborted(options.signal, callback)) + return; + + fs.open(path, flag, options.mode, (openErr, fd) => { + if (openErr) { + callback(openErr); + } else { + const isUserFd = false; + const signal = options.signal; + writeAll(fd, isUserFd, data, 0, data.byteLength, signal, flush, callback); + } + }); +} +``` + +https://github.com/nodejs/node/blob/main/lib/fs.js#L811 + +### `||=` + +https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Logical_OR_assignment + +**Logical OR assignment** (논리 OR 할당) + +단축 연산자: `x ||= y` 뜻: `x || (x = y)` + +- `x ||= y`는 `x = x || y`를 축약해서 표현한 것 + - `x || y`의 결과가 `x`에 할당됨 + - `x`가 거짓이 아니라면 `y`가 할당되지 않음 + + ```tsx + const x = 1; + x ||= 2; + // x = 1 + ``` + + - 따라서 `const`로 변수를 선언했어도 오류가 발생하지 않음 + +**`callback ||= options`** + +- options가 없는 경우 callback으로 사용 + +cf) `||` Logical OR `??` Nullish coalescing 널 병합 연산자 + +- falsy일 때 / null이나 undefined일 때 + +### flag, flush + +- flag: 파일을 열 때 동작 방식 정의 + - `r` 읽기 전용 `w` 쓰기 모드 등 +- flush: 파일에 데이터를 쓴 후 즉시 디스크에 반영할지 여부 + - `false`(기본값): 캐시에 저장되나 디스크에 즉시 반영되지 않음. 일정 시간 후 자동으로 디스크에 반영 + - `true`: 데이터를 디스크에 즉시 반영 + +### validate + +https://github.com/nodejs/node/blob/main/lib/internal/validators.js + +validate를 위한 함수들 존재 + +### writeAll + +파일에 데이터를 쓰는 역할 \ No newline at end of file diff --git "a/\354\240\225\354\234\240\354\206\241/shadcn-ui/shadcn-ui.md" "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/shadcn-ui.md" new file mode 100644 index 0000000..ed9faf3 --- /dev/null +++ "b/\354\240\225\354\234\240\354\206\241/shadcn-ui/shadcn-ui.md" @@ -0,0 +1,79 @@ +# 구현 + +radix ui를 기반으로 함 + +radix ui에 구현되어 있지 않은 컴포넌트의 경우 (필요 시 합성 컴포넌트 형태로) 직접 구현하거나 다른 라이브러리 활용 + +직접 구현: button, card, input, pagination, [skeleton](https://tailwindcss.com/docs/animation#pulse), table, textarea + +다른 라이브러리 활용: calendar, carousel, drawer, resizable, sooner + +[react-day-picker](https://daypicker.dev/), [embla-carousel-react](https://www.embla-carousel.com/get-started/react/), [vaul](https://vaul.emilkowal.ski/), [react-resizable-panels](https://react-resizable-panels.vercel.app/), [sooner](https://sonner.emilkowal.ski/) + +## 사용된 라이브러리 + +### [class-variance-authority](https://cva.style/docs) + +variant 처리, 동적 스타일링에 도움 + +```tsx +import { cva, type VariantProps } from "class-variance-authority" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) +``` + +### [lucide-react](https://lucide.dev/guide/packages/lucide-react) + +아이콘 관리: 컴포넌트화 + +https://lucide.dev/icons/ + +```tsx +import { ChevronLeft, ChevronRight } from "lucide-react" + +function Calendar({ + ... + }) { + return ( + , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +``` + +### react-hook-form + +form 관리에 사용