Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/shared/styles/base/scroll.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
.textarea-scrollbar {
scrollbar-color: var(--color-gray-300) transparent;
scrollbar-width: thin;
}

.textarea-scrollbar::-webkit-scrollbar {
width: 10px;
}

.textarea-scrollbar::-webkit-scrollbar-track {
margin-block: 8px;
background: transparent;
}

.textarea-scrollbar::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 9999px;
background-color: var(--color-gray-300);
background-clip: content-box;
}

.textarea-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--color-gray-200);
}

.textarea-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}

.textarea-scrollbar::-webkit-resizer {
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M5 11L11 5M1 11L11 1' fill='none' stroke='%23a6a6a6' stroke-linecap='round' stroke-width='1.5'/%3E%3C/svg%3E");
background-position: right bottom;
background-repeat: no-repeat;
background-size: 12px 12px;
}

.textarea-scrollbar:disabled {
scrollbar-color: var(--color-gray-500) transparent;
}

.textarea-scrollbar:disabled::-webkit-scrollbar-thumb {
background-color: var(--color-gray-500);
}
1 change: 1 addition & 0 deletions src/shared/styles/globals.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@import 'tailwindcss';
@import './base/fonts.css';
@import './base/colors.css';
@import './base/scroll.css';

button {
cursor: pointer;
Expand Down
40 changes: 40 additions & 0 deletions src/shared/ui/form-control/FormControl.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';

/** Input과 TextArea Root가 Label, Field, ErrorMessage에 제공하는 공통 상태입니다. */
interface FormControlState {
disabled: boolean;
errorMessageId: string;
fieldId: string;
invalid: boolean;
required: boolean;
}

/** FormControl 하위 컴포넌트가 공유하는 상태와 ErrorMessage 등록 동작입니다. */
interface FormControlContextValue extends FormControlState {
hasErrorMessage: boolean;
registerErrorMessage: () => () => void;
}

/** ErrorMessage 등록 상태를 관리하는 내부 Provider props입니다. */
interface FormControlProviderProps {
children: ReactNode;
value: FormControlState;
}

/** Root의 fieldId와 required 상태를 상속하는 공통 Label props입니다. */
interface FormControlLabelProps extends Omit<ComponentPropsWithoutRef<'label'>, 'htmlFor'> {
children: ReactNode;
}

/** Root의 invalid 상태와 errorMessageId를 상속하는 공통 오류 문구 props입니다. */
interface FormControlErrorMessageProps extends Omit<ComponentPropsWithoutRef<'p'>, 'id'> {
children?: ReactNode;
}

export type {
FormControlContextValue,
FormControlErrorMessageProps,
FormControlLabelProps,
FormControlProviderProps,
FormControlState,
};
39 changes: 39 additions & 0 deletions src/shared/ui/form-control/FormControlContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createContext, use } from 'react';

import type { FormControlContextValue } from './FormControl.types';

const FormControlContext = createContext<FormControlContextValue | null>(null);

/**
* ## useFormControlContext
*
* @description
* Input과 TextArea의 compound 하위 컴포넌트가 Root의 id 및 폼 상태를 읽는 내부 훅입니다.
* Label, Field, ErrorMessage가 동일한 접근성 연결 정보와 상태를 사용하도록 합니다.
*
* ### 주의할 점
*
* 반드시 `FormControlContext`를 제공하는 Input 또는 TextArea Root 내부에서 사용해야 합니다.
* 일반 화면 컴포넌트에서는 이 훅을 직접 사용하지 않고 `Input.*` 또는 `TextArea.*` API를
* 통해 FormControl 기반 컴포넌트를 조합합니다.
*
* @example
* ```tsx
* function CustomField() {
* const { disabled, fieldId } = useFormControlContext();
*
* return <input disabled={disabled} id={fieldId} />;
* }
* ```
*/
export function useFormControlContext() {
const context = use(FormControlContext);

if (!context) {
throw new Error('Form control compound components must be used within a form control root.');
}

return context;
}

export { FormControlContext };
68 changes: 68 additions & 0 deletions src/shared/ui/form-control/FormControlErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useEffect } from 'react';

import { cn } from '@/shared/styles/utils/cn';

import { useFormControlContext } from './FormControlContext';

import type { FormControlErrorMessageProps } from './FormControl.types';

/**
* ## FormControlErrorMessage
*
* @description
* Input과 TextArea가 공유하는 유효성 오류 문구 기반 컴포넌트입니다. Root의 `invalid`가
* true이고 오류 문구가 있을 때만 렌더링하여 정상 상태에서 불필요한 빈 영역을 만들지 않습니다.
*
* ### 주의할 점
*
* 이 컴포넌트는 FormControl의 내부 기반이므로 직접 사용하기보다 `Input.ErrorMessage` 또는
* `TextArea.ErrorMessage`를 사용합니다. 오류 여부는 사용하는 폼에서 Root의 `invalid`로 전달합니다.
*
* ### 접근성
*
* Root가 생성한 `errorMessageId`를 사용하며 `role="alert"`로 오류를 알립니다. 메시지 노드가
* 실제 렌더링된 동안에만 Field가 동일한 id를 `aria-errormessage`로 참조하도록 등록합니다.
*
* @param children - 유효성 검증 실패 원인과 해결 방법을 설명하는 문구
* @param className - 공통 오류 문구 스타일을 확장하는 클래스 이름
*
* @example
* ```tsx
* <TextArea invalid>
* <TextArea.Label>자기소개서 내용</TextArea.Label>
* <TextArea.Field />
* <TextArea.ErrorMessage>내용을 입력해 주세요.</TextArea.ErrorMessage>
* </TextArea>
* ```
*/
export function FormControlErrorMessage({
children,
className,
...props
}: FormControlErrorMessageProps) {
const { errorMessageId, invalid, registerErrorMessage } = useFormControlContext();
const hasMessage = Boolean(children);

useEffect(() => {
if (!invalid || !hasMessage) {
return;
}

return registerErrorMessage();
}, [hasMessage, invalid, registerErrorMessage]);

if (!invalid || !hasMessage) {
return null;
}

return (
<p
{...props}
className={cn('mt-2 mb-0 body-14 text-error-500', className)}
id={errorMessageId}
role="alert"
>
{children}
</p>
);
}
53 changes: 53 additions & 0 deletions src/shared/ui/form-control/FormControlLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { cn } from '@/shared/styles/utils/cn';

import { useFormControlContext } from './FormControlContext';

import type { FormControlLabelProps } from './FormControl.types';

/**
* ## FormControlLabel
*
* @description
* Input과 TextArea가 공유하는 Label 기반 컴포넌트입니다. Root가 제공하는 `fieldId`를
* `htmlFor`에 적용하고 `required` 상태에 따라 시각적인 필수 표시를 추가합니다.
*
* ### 주의할 점
*
* 이 컴포넌트는 FormControl의 내부 기반이므로 직접 사용하기보다 `Input.Label` 또는
* `TextArea.Label`을 사용합니다. `htmlFor`는 Root에서 관리하므로 외부에서 전달하지 않습니다.
*
* ### 접근성
*
* 필수 표시 `*`는 장식 요소이므로 스크린 리더에서 제외합니다. 실제 필수 상태는 Root와
* 연결된 Field의 네이티브 `required` 및 `aria-required` 속성으로 전달합니다.
*
* @param children - 입력 요소의 목적을 설명하는 Label 문구
* @param className - 공통 Label 스타일을 확장하는 클래스 이름
*
* @example
* ```tsx
* <Input required>
* <Input.Label>회사명</Input.Label>
* <Input.Field />
* </Input>
* ```
*/
export function FormControlLabel({ children, className, ...props }: FormControlLabelProps) {
const { fieldId, required } = useFormControlContext();

return (
<label
{...props}
className={cn('mb-6 w-fit body-18 font-semibold text-white', className)}
htmlFor={fieldId}
>
{children}
{required ? (
<span aria-hidden="true" className="text-primary-500">
{' '}
*
</span>
) : null}
</label>
);
}
35 changes: 35 additions & 0 deletions src/shared/ui/form-control/FormControlProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useCallback, useState } from 'react';

import { FormControlContext } from './FormControlContext';

import type { FormControlContextValue, FormControlProviderProps } from './FormControl.types';

/**
* ## FormControlProvider
*
* @description
* Input과 TextArea Root의 공통 상태를 제공하고 실제 렌더링된 ErrorMessage의 수를 관리합니다.
* Field는 등록된 오류 메시지가 있을 때만 해당 id를 ARIA 속성으로 참조합니다.
*
* @param children - FormControl Context를 사용하는 compound 하위 컴포넌트
* @param value - Root가 제공하는 id, required, disabled, invalid 상태
*/
export function FormControlProvider({ children, value }: FormControlProviderProps) {
const [errorMessageCount, setErrorMessageCount] = useState(0);

const registerErrorMessage = useCallback(() => {
setErrorMessageCount((currentCount) => currentCount + 1);

return () => {
setErrorMessageCount((currentCount) => Math.max(0, currentCount - 1));
};
}, []);

const contextValue: FormControlContextValue = {
...value,
hasErrorMessage: errorMessageCount > 0,
registerErrorMessage,
};

return <FormControlContext value={contextValue}>{children}</FormControlContext>;
}
9 changes: 9 additions & 0 deletions src/shared/ui/form-control/formControlStyles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Input과 TextArea Field가 공유하는 크기, 배경, 테두리, 글자 스타일입니다. */
const fieldBaseClassName =
'min-w-0 w-full rounded-lg border border-transparent bg-gray-600 px-5 body-16 text-white outline-none';

/** Input과 TextArea Field가 공유하는 focus, invalid, disabled 상태 스타일입니다. */
const fieldInteractionClassName =
'transition-[border-color,box-shadow,background-color,color] duration-150 placeholder:text-gray-200 focus-visible:border-primary-500 focus-visible:shadow-[0_0_12px_2px] focus-visible:shadow-primary-500/25 data-[invalid=true]:border-error-500 data-[invalid=true]:focus-visible:shadow-error-500/25 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-300 disabled:placeholder:text-gray-500';

export { fieldBaseClassName, fieldInteractionClassName };
17 changes: 17 additions & 0 deletions src/shared/ui/form-field/FormField.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,34 @@ import type {
UseFormStateReturn,
} from 'react-hook-form';

/**
* FormField의 render 함수에 전달하는 React Hook Form 상태입니다.
*
* Controller의 원본 상태를 유지하면서 공통 UI가 바로 사용할 수 있도록 `invalid`와
* `errorMessage`를 편의 값으로 제공합니다.
*/
interface FormFieldRenderProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> {
/** 현재 Field의 validation 오류 메시지입니다. */
errorMessage?: string;
/** 실제 입력 요소에 전달할 name, value, 이벤트 핸들러, ref입니다. */
field: ControllerRenderProps<TFieldValues, TName>;
/** touched, dirty, invalid 등 현재 Field 단위 상태입니다. */
fieldState: ControllerFieldState;
/** submit, validating 등 폼 전체 상태입니다. */
formState: UseFormStateReturn<TFieldValues>;
/** 현재 Field에 validation 오류가 있는지 나타내는 편의 값입니다. */
invalid: boolean;
}

/**
* React Hook Form의 Controller props를 사용하는 공통 FormField adapter 타입입니다.
*
* Controller의 기본 `render` 대신 공통 UI에 필요한 편의 값이 포함된
* `FormFieldRenderProps`를 전달하는 render 함수를 사용합니다.
*/
type FormFieldProps<TFieldValues extends FieldValues, TName extends FieldPath<TFieldValues>> = Omit<
ControllerProps<TFieldValues, TName>,
'render'
Expand Down
6 changes: 3 additions & 3 deletions src/shared/ui/input/Input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ const meta = {
docs: {
description: {
component:
'Label, Field, ErrorMessage를 조합하는 Compound Input입니다. 버튼은 Input.Control 안에서 함께 구성하며, 폼 상태는 FormField를 통해 React Hook Form과 연결합니다.',
'Label, Field, ErrorMessage를 조합하는 Compound Input입니다. 버튼은 Input.FieldGroup 안에서 함께 구성하며, 폼 상태는 FormField를 통해 React Hook Form과 연결합니다.',
},
},
},
Expand Down Expand Up @@ -251,12 +251,12 @@ export const WithButton: Story = {
render: () => (
<Input>
<Input.Label>제한 글자 수</Input.Label>
<Input.Control>
<Input.FieldGroup>
<Input.Field min={0} placeholder="1000" type="number" />
<Button size="sm" type="button">
적용
</Button>
</Input.Control>
</Input.FieldGroup>
</Input>
),
};
Expand Down
Loading
Loading