From 8b2284590c8f42edb0cf749f21305ee6c8319638 Mon Sep 17 00:00:00 2001 From: minseong Date: Fri, 17 Jan 2025 04:43:16 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore(apps/web):=20react-hook-form=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 3 ++- pnpm-lock.yaml | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/web/package.json b/apps/web/package.json index 95c48a3..550f144 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/pnpm-lock.yaml b/pnpm-lock.yaml index 47ac5d0..13be7cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: react-dom: specifier: ^18 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.54.2 + version: 7.54.2(react@18.3.1) devDependencies: '@repo/eslint-config': specifier: workspace:* @@ -2251,6 +2254,12 @@ packages: peerDependencies: react: ^18.3.1 + react-hook-form@7.54.2: + resolution: {integrity: sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5016,6 +5025,10 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hook-form@7.54.2(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react@18.3.1: From fbc642f5be1a093e5864801f4e434214768cc860 Mon Sep 17 00:00:00 2001 From: minseong Date: Fri, 17 Jan 2025 04:43:49 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(packages/ui):=20isNill=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/utils/isNill.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 packages/ui/src/utils/isNill.ts diff --git a/packages/ui/src/utils/isNill.ts b/packages/ui/src/utils/isNill.ts new file mode 100644 index 0000000..332c2b9 --- /dev/null +++ b/packages/ui/src/utils/isNill.ts @@ -0,0 +1,3 @@ +export function isNil(value: unknown): value is null | undefined { + return value == null; +} From d92e8e36fbfdbe685ace0d8f27abaf9b79c69afd Mon Sep 17 00:00:00 2001 From: minseong Date: Fri, 17 Jan 2025 04:44:13 +0900 Subject: [PATCH 3/6] chore(packages/ui): isNill export --- packages/ui/src/utils/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 7d5fa86..c05cb7a 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -1 +1,2 @@ -export { mergeRefs } from '@/utils/mergeRefs'; +export { mergeRefs } from './mergeRefs'; +export { isNil } from './isNill'; From 973b3086ce4bd292d92f0256bf4ff49738f00452 Mon Sep 17 00:00:00 2001 From: minseong Date: Fri, 17 Jan 2025 04:44:38 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(packages/ui):=20TextField=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/TextField/TextField.css.ts | 114 ++++++++++++++++++ .../ui/src/components/TextField/TextField.tsx | 55 +++++++++ .../components/TextField/TextFieldCounter.tsx | 27 +++++ .../components/TextField/TextFieldInput.tsx | 98 +++++++++++++++ .../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 ++ 9 files changed, 402 insertions(+) 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 diff --git a/packages/ui/src/components/TextField/TextField.css.ts b/packages/ui/src/components/TextField/TextField.css.ts new file mode 100644 index 0000000..08ce2a1 --- /dev/null +++ b/packages/ui/src/components/TextField/TextField.css.ts @@ -0,0 +1,114 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { vars } from '@repo/theme'; + +export const textFieldWrapperStyle = style({ + position: 'relative', + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: vars.space[8], +}); + +export const textFieldStyle = recipe({ + base: { + width: '100%', + minHeight: '59px', + padding: '16px', + borderRadius: '12px', + border: 'none', + outline: 'none', + resize: 'none', + overflow: 'hidden', + color: vars.colors.grey700, + fontSize: vars.typography.fontSize[18], + fontWeight: vars.typography.fontWeight.medium, + lineHeight: '150%', + fontFamily: 'inherit', + transition: 'all 0.2s ease', + }, + variants: { + variant: { + default: { + backgroundColor: vars.colors.grey25, + color: vars.colors.grey900, + paddingRight: '16px', + '::placeholder': { + color: vars.colors.grey400, + }, + }, + button: { + backgroundColor: vars.colors.grey50, + color: vars.colors.grey900, + paddingRight: '48px', + '::placeholder': { + color: vars.colors.grey400, + }, + }, + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +export const submitButtonStyle = recipe({ + base: { + position: 'absolute', + bottom: '45px', + right: vars.space[12], + width: '32px', + height: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + background: 'transparent', + padding: 0, + cursor: 'pointer', + + ':hover': { + opacity: 0.8, + }, + }, + variants: { + isError: { + true: { + cursor: 'not-allowed', + }, + }, + }, +}); + +export const counterStyle = recipe({ + base: { + fontSize: vars.typography.fontSize[16], + fontWeight: vars.typography.fontWeight.medium, + margin: `0 ${vars.space[8]}`, + lineHeight: '1.5', + textAlign: 'right', + }, + variants: { + isError: { + false: { + color: vars.colors.grey500, + }, + true: { + color: vars.colors.warning, + }, + }, + }, + defaultVariants: { + isError: false, + }, +}); + +export const labelStyle = recipe({ + variants: { + isError: { + true: { + color: vars.colors.warning, + }, + }, + }, +}); diff --git a/packages/ui/src/components/TextField/TextField.tsx b/packages/ui/src/components/TextField/TextField.tsx new file mode 100644 index 0000000..3cdad17 --- /dev/null +++ b/packages/ui/src/components/TextField/TextField.tsx @@ -0,0 +1,55 @@ +import { TextFieldRoot } from './TextFieldRoot'; +import { TextFieldLabel } from './TextFieldLabel'; +import { TextFieldInput } from './TextFieldInput'; +import { TextFieldSubmit } from './TextFieldSubmit'; + +/** + * + * @example + * // 1. 기본값이 있는 비제어 컴포넌트 + * + * 메시지 + * + * + * + * + * // 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 0000000..6af0856 --- /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 0000000..823fe7e --- /dev/null +++ b/packages/ui/src/components/TextField/TextFieldInput.tsx @@ -0,0 +1,98 @@ +import { + forwardRef, + ComponentPropsWithoutRef, + ChangeEvent, + useState, + useRef, + useEffect, + useContext, +} from 'react'; +import { TextFieldContext } from './context'; +import { 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 ( + <> +