From 7e20960dcc006171d742bf58257e892489e33203 Mon Sep 17 00:00:00 2001 From: MINSEONG KIM Date: Sat, 18 Jan 2025 10:42:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Feat]=20TextField=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(apps/web): react-hook-form 설치 * feat(packages/ui): isNill 함수 추가 * chore(packages/ui): isNill export * feat(packages/ui): TextField 컴포넌트 구현 * test(apps/web): 예시 추가 * fix(packages/ui): 디자인 요구사항 수정 --- apps/web/package.json | 3 +- apps/web/src/app/page.tsx | 60 +++++++- .../src/components/TextField/TextField.css.ts | 144 ++++++++++++++++++ .../ui/src/components/TextField/TextField.tsx | 55 +++++++ .../components/TextField/TextFieldCounter.tsx | 27 ++++ .../components/TextField/TextFieldInput.tsx | 100 ++++++++++++ .../components/TextField/TextFieldLabel.tsx | 22 +++ .../components/TextField/TextFieldRoot.tsx | 36 +++++ .../components/TextField/TextFieldSubmit.tsx | 31 ++++ .../ui/src/components/TextField/context.ts | 11 ++ packages/ui/src/components/index.ts | 8 + packages/ui/src/utils/index.ts | 3 +- packages/ui/src/utils/isNill.ts | 3 + pnpm-lock.yaml | 13 ++ 14 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/TextField/TextField.css.ts create mode 100644 packages/ui/src/components/TextField/TextField.tsx create mode 100644 packages/ui/src/components/TextField/TextFieldCounter.tsx create mode 100644 packages/ui/src/components/TextField/TextFieldInput.tsx create mode 100644 packages/ui/src/components/TextField/TextFieldLabel.tsx create mode 100644 packages/ui/src/components/TextField/TextFieldRoot.tsx create mode 100644 packages/ui/src/components/TextField/TextFieldSubmit.tsx create mode 100644 packages/ui/src/components/TextField/context.ts create mode 100644 packages/ui/src/utils/isNill.ts diff --git a/apps/web/package.json b/apps/web/package.json index 95c48a37..550f144d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,7 +16,8 @@ "next": "14.2.21", "overlay-kit": "^1.4.1", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "react-hook-form": "^7.54.2" }, "devDependencies": { "@repo/eslint-config": "workspace:*", diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 37f5d248..295ad2e8 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useForm } from 'react-hook-form'; import { Icon, Toast, @@ -9,18 +10,35 @@ import { Checkbox, Label, Breadcrumb, + TextField, } from '@repo/ui'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import { overlay } from 'overlay-kit'; + +type FormValues = { + topic: string; + aiUpgrade: string; +}; const LottieAnimation = dynamic( () => import('@repo/ui/LottieAnimation').then((mod) => mod.LottieAnimation), { ssr: false, } ); - export default function Home() { + const { register, handleSubmit } = useForm({ + defaultValues: { + topic: '', + aiUpgrade: '', + }, + }); + + const onSubmit = (data: FormValues) => { + console.log('Form data:', data); + notify1(); // 성공 토스트 표시 + }; + const notify1 = () => overlay.open(({ isOpen, close, unmount }) => ( 어떤 글을 생성할까요? +
+
+ + 주제 + + + + + AI 업그레이드 + + + + + + AI 업그레이드 + + + +
+
+ * 메시지 + * + * + * + * + * // 2. onChange 이벤트가 필요한 제어 컴포넌트 + * + * { + * register('message').onChange(e); + * setValue('message', e.target.value); + * }} + * /> + * + * + * // 3. 유효성 검사와 에러 상태를 포함한 컴포넌트 + * + * + * + */ +export const TextField = Object.assign(TextFieldRoot, { + Label: TextFieldLabel, + Input: TextFieldInput, + Submit: TextFieldSubmit, +}); + +export type { TextFieldProps } from './TextFieldRoot'; +export type { TextFieldLabelProps } from './TextFieldLabel'; +export type { TextFieldInputProps } from './TextFieldInput'; +export type { TextFieldSubmitProps } from './TextFieldSubmit'; +export type { TextFieldCounterProps } from './TextFieldCounter'; diff --git a/packages/ui/src/components/TextField/TextFieldCounter.tsx b/packages/ui/src/components/TextField/TextFieldCounter.tsx new file mode 100644 index 00000000..6af0856e --- /dev/null +++ b/packages/ui/src/components/TextField/TextFieldCounter.tsx @@ -0,0 +1,27 @@ +import { counterStyle } from './TextField.css'; +import { ComponentPropsWithoutRef, forwardRef, useContext } from 'react'; +import { TextFieldContext } from './context'; + +export type TextFieldCounterProps = { + current: number; + max: number; +} & ComponentPropsWithoutRef<'span'>; + +export const TextFieldCounter = forwardRef< + HTMLSpanElement, + TextFieldCounterProps +>(({ current, max, className = '', ...props }, ref) => { + const { isError } = useContext(TextFieldContext); + + return ( + + {current}/{max} + + ); +}); + +TextFieldCounter.displayName = 'TextField.Counter'; diff --git a/packages/ui/src/components/TextField/TextFieldInput.tsx b/packages/ui/src/components/TextField/TextFieldInput.tsx new file mode 100644 index 00000000..ebc940f1 --- /dev/null +++ b/packages/ui/src/components/TextField/TextFieldInput.tsx @@ -0,0 +1,100 @@ +import { + forwardRef, + ComponentPropsWithoutRef, + ChangeEvent, + useState, + useRef, + useEffect, + useContext, +} from 'react'; +import { TextFieldContext } from './context'; +import { textFieldContainerStyle, textFieldStyle } from './TextField.css'; +import { TextFieldCounter } from './TextFieldCounter'; +import { isNil, mergeRefs } from '@/utils'; + +export type TextFieldInputProps = { + maxLength?: number; + showCounter?: boolean; + value?: string; + defaultValue?: string; +} & Omit< + ComponentPropsWithoutRef<'textarea'>, + 'maxLength' | 'value' | 'defaultValue' +>; + +export const TextFieldInput = forwardRef< + HTMLTextAreaElement, + TextFieldInputProps +>( + ( + { + maxLength = 500, + showCounter = true, + value: controlledValue, + defaultValue, + className = '', + onChange, + ...props + }, + ref + ) => { + const [uncontrolledValue, setUncontrolledValue] = useState( + defaultValue ?? '' + ); + const textareaRef = useRef(null); + const { variant, id } = useContext(TextFieldContext); + const [isMultiline, setIsMultiline] = useState(false); + + const value = controlledValue ?? uncontrolledValue; + + const handleResizeHeight = () => { + const textarea = textareaRef.current; + if (isNil(textarea)) return; + + // height 초기화 + textarea.style.height = 'auto'; + + // 스크롤 높이에 따라 높이 조절 + const newHeight = textarea.scrollHeight; + textarea.style.height = `${newHeight}px`; + + // 한 줄 높이 = 상하패딩(32px) + 라인높이(27px) = 59px + setIsMultiline(newHeight > 59); + }; + + const handleChange = (e: ChangeEvent) => { + if (maxLength && e.target.value.length > maxLength) return; + if (isNil(controlledValue)) { + setUncontrolledValue(e.target.value); + } + handleResizeHeight(); + onChange?.(e); + }; + + useEffect(() => { + handleResizeHeight(); + }, [value]); + + return ( + <> +
+