Form

여러 개의 입력 단위를 하나로 묶어 사용자로부터 정보를 수집하는 폼 영역

Form preview

Type: Login

import './login-form.css';

import {
    Box,
    Button,
    Checkbox,
    Field,
    Form,
    HStack,
    Text,
    TextInput,
    VStack,
} from '@vapor-ui/core';

export default function LoginForm() {
    return (
        <VStack
            gap="$250"
            width="400px"
            padding="$300"
            borderRadius="$300"
            border="1px solid var(--vapor-color-border-normal)"
            className="login"
            render={<Form onSubmit={(event) => event.preventDefault()} />}
        >
            <VStack gap="$200">
                <Field.Root>
                    <Box
                        render={<Field.Label />}
                        flexDirection="column"
                        justifyContent="space-between"
                    >
                        <Text typography="subtitle2" foreground="normal-200">
                            이메일
                        </Text>
                        <TextInput id="login-email" size="lg" required type="email" />
                    </Box>
                    <Field.Error match="valueMissing">이메일을 입력해주세요.</Field.Error>
                    <Field.Error match="typeMismatch">유효한 이메일 형식이 아닙니다.</Field.Error>
                </Field.Root>

                <Field.Root>
                    <Box
                        render={<Field.Label />}
                        flexDirection="column"
                        justifyContent="space-between"
                    >
                        <Text typography="subtitle2" foreground="normal-200">
                            비밀번호
                        </Text>
                        <TextInput
                            id="login-password"
                            type="password"
                            required
                            pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,16}"
                            size="lg"
                        />
                    </Box>
                    <Field.Description>8~16자, 대소문자 영문, 특수문자 포함</Field.Description>
                    <Field.Error match="valueMissing">비밀번호를 입력해주세요.</Field.Error>
                    <Field.Error match="patternMismatch">
                        유효한 비밀번호 형식이 아닙니다.
                    </Field.Error>
                </Field.Root>
            </VStack>
            <VStack gap="$100">
                <HStack justifyContent="space-between">
                    <Field.Root>
                        <Box render={<Field.Label />} alignItems="center">
                            <Checkbox.Root id="login-auto-login" />
                            자동 로그인
                        </Box>
                    </Field.Root>

                    <Button type="button" variant="ghost" colorPalette="secondary">
                        ID/비밀번호 찾기
                    </Button>
                </HStack>

                <Button size="lg">로그인</Button>
                <Button size="lg" colorPalette="secondary" variant="outline">
                    회원가입
                </Button>
            </VStack>
        </VStack>
    );
}
.input-label {
    color: var(--vapor-color-foreground-normal-100, #525463);

    font-size: var(--vapor-typography-fontSize-050, 0.75rem);
    font-weight: var(--vapor-typography-fontWeight-500);
    line-height: var(--vapor-typography-lineHeight-050, 1.125rem); /* 150% */
    letter-spacing: var(--vapor-typography-letterSpacing-000, 0);
}

.checkbox-label {
    color: var(--vapor-color-foreground-normal-100, #2b2d36);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

.helper-text {
    color: var(--vapor-color-foreground-hint, #6c6e7e);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

Type: SignUp

import './signup-form.css';

import { useState } from 'react';

import {
    Box,
    Button,
    Checkbox,
    Field,
    Form,
    HStack,
    IconButton,
    Select,
    Text,
    TextInput,
    VStack,
} from '@vapor-ui/core';
import { ChevronRightOutlineIcon } from '@vapor-ui/icons';

const jobs = [
    { label: '개발자', value: 'developer' },
    { label: '디자이너', value: 'designer' },
    { label: '프로덕트 매니저', value: 'product-manager' },
    { label: '기타', value: 'etc' },
];

export default function SignupForm() {
    const [passwordCheck, setPasswordCheck] = useState('');
    // const passwordCheck = useRef<string>('');

    return (
        <VStack
            gap="$250"
            width="400px"
            padding="$300"
            borderRadius="$300"
            border="1px solid var(--vapor-color-border-normal)"
            className="login"
            render={<Form onSubmit={(event) => event.preventDefault()} />}
        >
            <VStack gap="$400">
                <VStack gap="$200">
                    <Field.Root>
                        <Box
                            render={<Field.Label />}
                            flexDirection="column"
                            className="input-label"
                        >
                            이메일
                            <TextInput id="signup-email" size="lg" required type="email" />
                        </Box>
                        <Field.Error match="valueMissing">이메일을 입력해주세요.</Field.Error>
                        <Field.Error match="typeMismatch">
                            유효한 이메일 형식이 아닙니다.
                        </Field.Error>
                    </Field.Root>

                    <Field.Root>
                        <Box
                            render={<Field.Label />}
                            flexDirection="column"
                            className="input-label"
                        >
                            비밀번호
                            <TextInput
                                id="signup-password"
                                size="lg"
                                type="password"
                                onValueChange={(value) => setPasswordCheck(value)}
                                required
                                pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,16}"
                            />
                        </Box>
                        <Field.Description>
                            8~16자, 대소문자 영문, 숫자, 특수문자 포함
                        </Field.Description>
                        <Field.Error match="valueMissing">비밀번호를 입력해주세요.</Field.Error>
                        <Field.Error match="patternMismatch">
                            유효한 비밀번호 형식이 아닙니다.
                        </Field.Error>
                    </Field.Root>

                    <Field.Root>
                        <Box
                            render={<Field.Label />}
                            flexDirection="column"
                            className="input-label"
                        >
                            비밀번호 확인
                            <TextInput
                                id="signup-password-check"
                                size="lg"
                                type="password"
                                required
                                pattern={passwordCheck}
                            />
                        </Box>
                        <Field.Description>8~16자, 대소문자 영문, 특수문자 포함</Field.Description>
                        <Field.Error match="valueMissing">비밀번호를 입력해주세요.</Field.Error>
                        <Field.Error match="patternMismatch">
                            비밀번호를 다시 확인해주세요.
                        </Field.Error>
                    </Field.Root>

                    <Field.Root>
                        <Box
                            render={<Field.Label />}
                            flexDirection="column"
                            className="input-label"
                        >
                            이름
                            <TextInput id="signup-name" size="lg" required />
                        </Box>
                        <Field.Error match="valueMissing">이름을 입력해주세요.</Field.Error>
                    </Field.Root>

                    <Field.Root>
                        <Select.Root items={jobs} placeholder="직업을 선택해주세요." size="lg">
                            <Box
                                render={<Field.Label htmlFor="signup-jobs" />}
                                flexDirection="column"
                                className="input-label"
                            >
                                직업
                                <Select.Trigger id="signup-jobs" />
                            </Box>

                            <Select.Popup>
                                {jobs.map((job) => (
                                    <Select.Item key={job.value} value={job.value}>
                                        {job.label}
                                    </Select.Item>
                                ))}
                            </Select.Popup>
                        </Select.Root>
                    </Field.Root>
                </VStack>

                <VStack gap="$300">
                    <VStack justifyContent="space-between" gap="$050">
                        <Field.Root>
                            <Box
                                render={<Field.Label />}
                                alignItems="center"
                                className="checkbox-label"
                            >
                                <Checkbox.Root id="signup-agree-all" />
                                필수 약관에 모두 동의
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <HStack width="100%" justifyContent="space-between" alignItems="center">
                                <Box
                                    render={<Field.Label />}
                                    alignItems="center"
                                    className="checkbox-label"
                                >
                                    <Checkbox.Root id="signup-terms-of-service" />
                                    이용 약관 동의
                                </Box>
                                <IconButton
                                    type="button"
                                    size="sm"
                                    colorPalette="secondary"
                                    variant="ghost"
                                    aria-label="약관 보기"
                                >
                                    <ChevronRightOutlineIcon />
                                </IconButton>
                            </HStack>
                        </Field.Root>
                        <Field.Root>
                            <HStack width="100%" justifyContent="space-between" alignItems="center">
                                <Box
                                    render={<Field.Label />}
                                    alignItems="center"
                                    className="checkbox-label"
                                >
                                    <Checkbox.Root id="signup-personal-info-collection" />
                                    개인 정보 수집 이용 동의
                                </Box>
                                <IconButton
                                    type="button"
                                    size="sm"
                                    colorPalette="secondary"
                                    variant="ghost"
                                    aria-label="개인 정보 수집 이용 보기"
                                >
                                    <ChevronRightOutlineIcon />
                                </IconButton>
                            </HStack>
                        </Field.Root>
                    </VStack>

                    <Button size="lg">회원가입</Button>
                </VStack>
            </VStack>

            <HStack justifyContent={'center'}>
                <Text typography="body2">이미 계정이 있으세요?</Text>
                <Button type="button" size="sm" variant="ghost">
                    로그인
                </Button>
            </HStack>
        </VStack>
    );
}
.input-label {
    color: var(--vapor-color-foreground-normal-100, #525463);

    font-size: var(--vapor-typography-fontSize-050, 0.75rem);
    font-weight: var(--vapor-typography-fontWeight-500);
    line-height: var(--vapor-typography-lineHeight-050, 1.125rem); /* 150% */
    letter-spacing: var(--vapor-typography-letterSpacing-000, 0);
}

.checkbox-label {
    color: var(--vapor-color-foreground-normal-100, #2b2d36);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

.helper-text {
    color: var(--vapor-color-foreground-hint, #6c6e7e);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

Type: Authentication

import { Children, cloneElement, isValidElement, useState } from 'react';

import './authentication-form.css';

import { Box, Button, Field, Form, Select, Text, TextInput, VStack } from '@vapor-ui/core';

const codes = {
    '+82': '🇰🇷 +82',
    '+1': '🇺🇸 +1',
    '+34': '🇪🇸 +34',
    '+33': '🇫🇷 +33',
    '+39': '🇮🇹 +39',
    '+44': '🇬🇧 +44',
    '+81': '🇯🇵 +81',
    '+86': '🇨🇳 +86',
    '+7': '🇷🇺 +7',
};

export default function AuthenticationForm() {
    const [phoneNumber, setPhoneNumber] = useState('');
    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        setPhoneNumber(e.target.value);
    };

    const regex = /^[0-9\s-()]{6,20}$/;

    return (
        <VStack
            gap="$400"
            width="400px"
            padding="$300"
            borderRadius="$300"
            border="1px solid var(--vapor-color-border-normal)"
            render={<Form onSubmit={(e) => e.preventDefault()} />}
        >
            <VStack gap="$200">
                <Field.Root>
                    <Box render={<Field.Label htmlFor="auth-phone" />} flexDirection="column">
                        <Text typography="subtitle2" foreground="normal-200">
                            핸드폰 번호
                        </Text>
                        <Select.Root defaultValue={codes['+82']} size="lg">
                            <Group attached>
                                <Select.Trigger />

                                <Select.Popup>
                                    {Object.entries(codes).map(([value, label]) => (
                                        <Select.Item key={value} value={value}>
                                            {label}
                                        </Select.Item>
                                    ))}
                                </Select.Popup>

                                <TextInput
                                    style={{ flex: 1, width: '100%' }}
                                    id="auth-phone"
                                    type="tel"
                                    value={phoneNumber}
                                    onChange={handleChange}
                                    required
                                    pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"
                                    size="lg"
                                />

                                <Button type="button" size="lg" disabled={!regex.test(phoneNumber)}>
                                    인증번호 받기
                                </Button>
                            </Group>
                        </Select.Root>
                    </Box>

                    <Field.Error match="valueMissing">핸드폰 번호를 입력해주세요.</Field.Error>
                    <Field.Error match="patternMismatch">
                        올바른 핸드폰 번호를 입력해주세요.
                    </Field.Error>
                </Field.Root>

                <Field.Root>
                    <Box render={<Field.Label />} flexDirection="column">
                        <Text typography="subtitle2" foreground="normal-200">
                            인증번호
                        </Text>
                        <TextInput id="auth-verification-code" size="lg" required />
                    </Box>
                    <Field.Error match="valueMissing">인증번호를 입력해주세요.</Field.Error>
                </Field.Root>
            </VStack>

            <Button size="lg">인증 완료</Button>
        </VStack>
    );
}

interface GroupProps {
    attached?: boolean;
    children?: React.ReactNode;
}

const Group = ({ attached = false, children: childrenProp }: GroupProps) => {
    const children = Children.map(childrenProp, (child, index) => {
        if (!isValidElement(child)) return;

        return cloneElement(child as React.ReactElement, {
            style: { '--group-index': index, ...child.props.style },
            ...(index === 0 ? { 'data-first-item': '' } : {}),
            ...(index === Children.count(childrenProp) - 1 ? { 'data-last-item': '' } : {}),
        });
    });

    return (
        <div data-part="group" className={`group` + (attached ? ' attached' : '')}>
            {children}
        </div>
    );
};
.input-label {
    color: var(--vapor-color-foreground-normal-100, #525463);

    font-size: var(--vapor-typography-fontSize-050, 0.75rem);
    font-weight: var(--vapor-typography-fontWeight-500);
    line-height: var(--vapor-typography-lineHeight-050, 1.125rem); /* 150% */
    letter-spacing: var(--vapor-typography-letterSpacing-000, 0);
}

.group {
    display: flex;
    align-items: center;
    gap: 0;

    &.attached {
        & > [data-first-item] {
            border-right: none;
            border-top-right-radius: 0;
            border-bottom-right-radius: 0;
        }

        & > [data-last-item] {
            border: 1px solid var(--vapor-color-border-normal, #d1d2d8);
            border-top-left-radius: 0;
            border-bottom-left-radius: 0;
        }

        & > *:not([data-first-item]):not([data-last-item]) {
            border: 1px solid var(--vapor-color-border-normal, #d1d2d8);
            border-right: none;
            border-radius: 0;
        }
    }
}

Type: Research

import './research-form.css';

import {
    Box,
    Button,
    Checkbox,
    Field,
    Form,
    HStack,
    MultiSelect,
    Radio,
    RadioGroup,
    Select,
    Switch,
    Text,
    TextInput,
    VStack,
} from '@vapor-ui/core';

const jobs = [
    { label: '프론트엔드 개발자', value: 'frontend-engineer' },
    { label: '백엔드 개발자', value: 'backend-engineer' },
    { label: '풀스택 개발자', value: 'fullstack-engineer' },
    { label: '모바일 앱 개발자', value: 'app-engineer' },
    { label: 'DevOps/클라우드 엔지니어', value: 'devops-engineer' },
];

const stacks = [
    { label: 'HTML/CSS', value: 'html-css' },
    { label: 'JavaScript', value: 'javascript' },
    { label: 'React', value: 'react' },
    { label: 'Vue.js', value: 'vue' },
    { label: 'Next.js', value: 'nextjs' },
];

export default function ResearchForm() {
    return (
        <VStack
            gap="$500"
            width="400px"
            padding="$300"
            borderRadius="$300"
            border="1px solid var(--vapor-color-border-normal)"
            render={<Form onSubmit={(event) => event.preventDefault()} />}
        >
            <VStack gap="$200">
                <Text typography="heading5">기본 정보를 입력해주세요.</Text>

                <Field.Root>
                    <Box render={<Field.Label />} flexDirection="column">
                        <Text typography="subtitle2" foreground="normal-200">
                            이름
                        </Text>
                        <TextInput id="research-name" required size="lg" />
                    </Box>
                    <Field.Error match="valueMissing">이름을 입력해주세요.</Field.Error>
                </Field.Root>

                <Field.Root>
                    <Box render={<Field.Label htmlFor="research-jobs" />} flexDirection="column">
                        <Text typography="subtitle2" foreground="normal-200">
                            직업
                        </Text>
                        <Select.Root items={jobs} placeholder="직업을 선택해주세요." size="lg">
                            <Select.Trigger id="research-jobs" />
                            <Select.Popup>
                                {jobs.map((job) => (
                                    <Select.Item key={job.value} value={job.value}>
                                        {job.label}
                                    </Select.Item>
                                ))}
                            </Select.Popup>
                        </Select.Root>
                    </Box>
                </Field.Root>

                <Field.Root>
                    <Box render={<Field.Label htmlFor="research-stack" />} flexDirection="column">
                        <Text typography="subtitle2" foreground="normal-200">
                            스택
                        </Text>
                        <MultiSelect.Root
                            items={stacks}
                            placeholder="자주 사용하는 스택을 선택해주세요."
                            size="lg"
                        >
                            <MultiSelect.Trigger id="research-stack" />
                            <MultiSelect.Popup>
                                {stacks.map((stack) => (
                                    <MultiSelect.Item key={stack.value} value={stack.value}>
                                        {stack.label}
                                    </MultiSelect.Item>
                                ))}
                            </MultiSelect.Popup>
                        </MultiSelect.Root>
                    </Box>
                </Field.Root>
            </VStack>

            <Field.Root>
                <RadioGroup.Root>
                    <RadioGroup.Label>만족도를 선택해주세요.</RadioGroup.Label>
                    <Box render={<Field.Label />} alignItems="center">
                        <Radio.Root
                            id="research-fully-satisfied"
                            value="fully-satisfied"
                            size="lg"
                        />
                        매우 만족
                    </Box>

                    <Box render={<Field.Label />} alignItems="center">
                        <Radio.Root id="research-neutral" value="neutral" size="lg" />
                        보통
                    </Box>

                    <Box render={<Field.Label />} alignItems="center">
                        <Radio.Root id="research-not-satisfied" value="not-satisfied" size="lg" />
                        불만족
                    </Box>
                </RadioGroup.Root>
            </Field.Root>

            <VStack gap="$100">
                <VStack marginBottom="$050">
                    <Text typography="heading5">좋았던 강의는 무엇인가요?</Text>
                    <Text typography="body2" foreground="normal-100">
                        중복 선택 가능
                    </Text>
                </VStack>

                <Field.Root>
                    <Box render={<Field.Label />} alignItems="center">
                        <Checkbox.Root id="research-mentoring" size="lg" />
                        멘토님 강연 능력
                    </Box>
                </Field.Root>

                <Field.Root>
                    <Box render={<Field.Label />} alignItems="center">
                        <Checkbox.Root id="research-topic" size="lg" />
                        주제(협업 및 커뮤니케이션 스킬)
                    </Box>
                </Field.Root>

                <Field.Root>
                    <Box render={<Field.Label />} alignItems="center">
                        <Checkbox.Root id="research-content" size="lg" />
                        전반적인 강의 내용
                    </Box>
                </Field.Root>

                <Field.Root>
                    <Box render={<Field.Label />} alignItems="center">
                        <Checkbox.Root id="research-seminar" size="lg" />
                        세미나 자료
                    </Box>
                </Field.Root>

                <Field.Root>
                    <Box render={<Field.Label />} alignItems="center">
                        <Checkbox.Root id="research-etc" size="lg" />
                        기타
                    </Box>
                </Field.Root>
            </VStack>

            <VStack gap="$100">
                <Text typography="heading5">개인 정보 수신 동의</Text>

                <Field.Root>
                    <HStack width="100%" justifyContent="space-between" render={<Field.Label />}>
                        서비스 메일 수신 동의
                        <Switch.Root defaultChecked id="research-service" />
                    </HStack>
                </Field.Root>
                <Field.Root>
                    <HStack width="100%" justifyContent="space-between" render={<Field.Label />}>
                        이벤트성 광고 수신 동의
                        <Switch.Root defaultChecked id="research-advertising" />
                    </HStack>
                </Field.Root>
            </VStack>

            <Button size="lg">제출하기</Button>
        </VStack>
    );
}
.input-label {
    color: var(--vapor-color-foreground-normal-100, #525463);

    font-size: var(--vapor-typography-fontSize-050, 0.75rem);
    font-weight: var(--vapor-typography-fontWeight-500);
    line-height: var(--vapor-typography-lineHeight-050, 1.125rem); /* 150% */
    letter-spacing: var(--vapor-typography-letterSpacing-000, 0);
}

.checkbox-label {
    color: var(--vapor-color-foreground-normal-100, #2b2d36);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

.radio-label {
    color: var(--vapor-color-foreground-normal-100, #2b2d36);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

Type: Filter

import './filter-form.css';

import type { FormEvent } from 'react';
import { useCallback, useRef, useState } from 'react';

import {
    Box,
    Button,
    Checkbox,
    Collapsible,
    Field,
    Form,
    HStack,
    Radio,
    RadioGroup,
    Text,
    VStack,
} from '@vapor-ui/core';
import { ChevronDownOutlineIcon, RefreshOutlineIcon } from '@vapor-ui/icons';

// 초기값 정의
type FormData = typeof FORM_SCHEME;

const FORM_SCHEME = {
    view: 'recent',
    sort: {
        feedback: false,
        buttons: false,
        'data-display': false,
        overlay: false,
        inputs: false,
        navigation: false,
        utils: false,
    },
    packs: {
        'goorm-dev/vapor-core': false,
        'goorm-dev/vapor-component': false,
        'vapor-ui/core': false,
    },
    status: { active: false, inactive: false, draft: false },
    tag: { ui: false, 'open-source': false, performance: false },
};

export default function FilterForm() {
    const formRef = useRef<HTMLFormElement>(null);
    const [formData, setFormData] = useState<FormData>({
        view: FORM_SCHEME.view,
        sort: { ...FORM_SCHEME.sort },
        packs: { ...FORM_SCHEME.packs },
        status: { ...FORM_SCHEME.status },
        tag: { ...FORM_SCHEME.tag },
    });

    const getFieldValues = useCallback(
        <T extends keyof FormData>(fieldName: T): FormData[T] => formData[fieldName],
        [formData],
    );

    const updateFormData = useCallback(
        (fieldName: keyof FormData, key: string, checked: boolean) => {
            setFormData((prev) => {
                const field = prev[fieldName];

                if (typeof field !== 'object') return prev;

                return { ...prev, [fieldName]: { ...field, [key]: checked } };
            });
        },
        [],
    );

    // 라디오 버튼 변경 핸들러
    const handleRadioChange = useCallback((fieldName: keyof FormData, value: string) => {
        setFormData((prev) => ({ ...prev, [fieldName]: value }));
    }, []);

    // 체크박스 변경 핸들러
    const handleCheckboxChange = useCallback(
        (fieldName: keyof FormData, key: string) => (checked: boolean) => {
            updateFormData(fieldName, key, checked);
        },
        [updateFormData],
    );

    const selectedSortCount = Object.values(getFieldValues('sort')).filter(Boolean).length;

    const handleReset = (event: FormEvent<HTMLFormElement>) => {
        event.preventDefault();

        setFormData({
            view: FORM_SCHEME.view,
            sort: { ...FORM_SCHEME.sort },
            packs: { ...FORM_SCHEME.packs },
            status: { ...FORM_SCHEME.status },
            tag: { ...FORM_SCHEME.tag },
        });
    };

    return (
        <VStack width="17.625rem" render={<Form ref={formRef} onReset={handleReset} />}>
            <HStack justifyContent="space-between">
                <Text typography="heading5">Filter</Text>
                <Button type="reset" size="sm" variant="ghost" colorPalette="secondary">
                    <RefreshOutlineIcon />
                    Refresh
                </Button>
            </HStack>

            <Box
                render={<hr />}
                border="none"
                marginY="$150"
                height="1px"
                width="100%"
                backgroundColor="$gray-300"
            />

            <VStack gap="$300">
                <Collapsible.Root>
                    <Collapsible.Trigger className="collapsible-trigger">
                        <Text typography="heading6">View</Text>

                        <ChevronDownOutlineIcon className="trigger-icon" />
                    </Collapsible.Trigger>

                    <Collapsible.Panel>
                        <Field.Root render={<Box marginTop="$150" />}>
                            <RadioGroup.Root
                                value={getFieldValues('view')}
                                onValueChange={(value: unknown) =>
                                    handleRadioChange('view', value as string)
                                }
                            >
                                <Box render={<Field.Label />} alignItems="center">
                                    <Radio.Root id="filter-recent" value="recent" />
                                    Recent
                                </Box>
                                <Box render={<Field.Label />} alignItems="center">
                                    <Radio.Root id="filter-popular" value="popular" />
                                    Most Popular
                                </Box>
                            </RadioGroup.Root>
                        </Field.Root>
                    </Collapsible.Panel>
                </Collapsible.Root>

                <Collapsible.Root>
                    <Collapsible.Trigger className="collapsible-trigger">
                        <Text typography="heading6">
                            Sort <Text foreground="primary-100">{selectedSortCount}</Text>
                        </Text>
                        <ChevronDownOutlineIcon className="trigger-icon" />
                    </Collapsible.Trigger>

                    <Collapsible.Panel>
                        <Field.Root render={<Box marginTop="$150" />}>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-feedback"
                                    checked={getFieldValues('sort').feedback}
                                    onCheckedChange={handleCheckboxChange('sort', 'feedback')}
                                />
                                Feedback
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-buttons"
                                    checked={getFieldValues('sort').buttons}
                                    onCheckedChange={handleCheckboxChange('sort', 'buttons')}
                                />
                                Buttons
                            </Box>
                        </Field.Root>

                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-data-display"
                                    checked={getFieldValues('sort')['data-display']}
                                    onCheckedChange={handleCheckboxChange('sort', 'data-display')}
                                />
                                Data Display
                            </Box>
                        </Field.Root>

                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-overlay"
                                    checked={getFieldValues('sort').overlay}
                                    onCheckedChange={handleCheckboxChange('sort', 'overlay')}
                                />
                                Overlay
                            </Box>
                        </Field.Root>

                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-inputs"
                                    checked={getFieldValues('sort').inputs}
                                    onCheckedChange={handleCheckboxChange('sort', 'inputs')}
                                />
                                Inputs
                            </Box>
                        </Field.Root>

                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-navigation"
                                    checked={getFieldValues('sort').navigation}
                                    onCheckedChange={handleCheckboxChange('sort', 'navigation')}
                                />
                                Navigation
                            </Box>
                        </Field.Root>

                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-utils"
                                    checked={getFieldValues('sort').utils}
                                    onCheckedChange={handleCheckboxChange('sort', 'utils')}
                                />
                                Utils
                            </Box>
                        </Field.Root>
                    </Collapsible.Panel>
                </Collapsible.Root>

                <Collapsible.Root>
                    <Collapsible.Trigger className="collapsible-trigger">
                        <Text typography="heading6">Package</Text>
                        <ChevronDownOutlineIcon className="trigger-icon" />
                    </Collapsible.Trigger>

                    <Collapsible.Panel>
                        <Field.Root render={<Box marginTop="$150" />}>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-goorm-dev/vapor-core"
                                    checked={getFieldValues('packs')['goorm-dev/vapor-core']}
                                    onCheckedChange={handleCheckboxChange(
                                        'packs',
                                        'goorm-dev/vapor-core',
                                    )}
                                />
                                goorm-dev/vapor-core
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-goorm-dev/vapor-component"
                                    checked={getFieldValues('packs')['goorm-dev/vapor-component']}
                                    onCheckedChange={handleCheckboxChange(
                                        'packs',
                                        'goorm-dev/vapor-component',
                                    )}
                                />
                                goorm-dev/vapor-component
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-vapor-ui/core"
                                    checked={getFieldValues('packs')['vapor-ui/core']}
                                    onCheckedChange={handleCheckboxChange('packs', 'vapor-ui/core')}
                                />
                                vapor-ui/core
                            </Box>
                        </Field.Root>
                    </Collapsible.Panel>
                </Collapsible.Root>

                <Collapsible.Root>
                    <Collapsible.Trigger className="collapsible-trigger">
                        <Text typography="heading6">Status</Text>
                        <ChevronDownOutlineIcon className="trigger-icon" />
                    </Collapsible.Trigger>

                    <Collapsible.Panel>
                        <Field.Root render={<Box marginTop="$150" />}>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-active"
                                    checked={getFieldValues('status').active}
                                    onCheckedChange={handleCheckboxChange('status', 'active')}
                                />
                                Active
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-inactive"
                                    checked={getFieldValues('status').inactive}
                                    onCheckedChange={handleCheckboxChange('status', 'inactive')}
                                />
                                Inactive
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-draft"
                                    checked={getFieldValues('status').draft}
                                    onCheckedChange={handleCheckboxChange('status', 'draft')}
                                />
                                Draft
                            </Box>
                        </Field.Root>
                    </Collapsible.Panel>
                </Collapsible.Root>

                <Collapsible.Root>
                    <Collapsible.Trigger className="collapsible-trigger">
                        <Text typography="heading6">Tag</Text>
                        <ChevronDownOutlineIcon className="trigger-icon" />
                    </Collapsible.Trigger>

                    <Collapsible.Panel>
                        <Field.Root render={<Box marginTop="$150" />}>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-ui"
                                    checked={getFieldValues('tag').ui}
                                    onCheckedChange={handleCheckboxChange('tag', 'ui')}
                                />
                                UI
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-open-source"
                                    checked={getFieldValues('tag')['open-source']}
                                    onCheckedChange={handleCheckboxChange('tag', 'open-source')}
                                />
                                Open Source
                            </Box>
                        </Field.Root>
                        <Field.Root>
                            <Box render={<Field.Label />} alignItems="center">
                                <Checkbox.Root
                                    id="filter-performance"
                                    checked={getFieldValues('tag').performance}
                                    onCheckedChange={handleCheckboxChange('tag', 'performance')}
                                />
                                Performance
                            </Box>
                        </Field.Root>
                    </Collapsible.Panel>
                </Collapsible.Root>
            </VStack>
        </VStack>
    );
}
.checkbox-label {
    color: var(--vapor-color-foreground-normal-100, #2b2d36);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

.radio-label {
    color: var(--vapor-color-foreground-normal-100, #2b2d36);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

.collapsible-trigger {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: space-between;
    transition: transform 150ms ease;
}

.trigger-icon {
    transition: transform 150ms ease;

    &:where([data-open] &) {
        transform: rotate(180deg);
    }

    &:where([data-closed] &) {
        transform: rotate(0);
    }
}

Type: BottomSheetFilter

import './sheet-form.css';

import type { FormEvent } from 'react';
import { useCallback, useState } from 'react';

import { Box, Button, Checkbox, Field, Form, Sheet, Tabs, VStack } from '@vapor-ui/core';
import { RefreshOutlineIcon } from '@vapor-ui/icons';

type FormData = typeof FORM_SCHEME;

const FORM_SCHEME = {
    sort: {
        feedback: false,
        buttons: true,
        'data-display': false,
        overlay: false,
        inputs: true,
        navigation: false,
        utils: false,
    },
    packs: {
        'goorm-dev/vapor-core': true,
        'goorm-dev/vapor-component': false,
        'vapor-ui/core': false,
    },
    status: {
        active: true,
        inactive: false,
        draft: false,
    },
    tag: {
        ui: true,
        'open-source': false,
        performance: false,
    },
};

export default function SheetForm() {
    const [formData, setFormData] = useState<FormData>(() => {
        return {
            sort: { ...FORM_SCHEME.sort },
            packs: { ...FORM_SCHEME.packs },
            status: { ...FORM_SCHEME.status },
            tag: { ...FORM_SCHEME.tag },
        };
    });

    const getFieldValues = useCallback(
        <T extends keyof FormData>(fieldName: T): FormData[T] => formData[fieldName],
        [formData],
    );

    const updateFormData = useCallback(
        (fieldName: keyof FormData, key: string, checked: boolean) => {
            setFormData((prev) => {
                const field = prev[fieldName];

                if (typeof field !== 'object') return prev;

                return { ...prev, [fieldName]: { ...field, [key]: checked } };
            });
        },
        [],
    );

    const handleCheckboxChange = useCallback(
        (fieldName: keyof FormData, key: string) => (checked: boolean) => {
            updateFormData(fieldName, key, checked);
        },
        [updateFormData],
    );

    const handleReset = useCallback((event: FormEvent<HTMLFormElement>) => {
        event.preventDefault();

        setFormData({
            sort: { ...FORM_SCHEME.sort },
            packs: { ...FORM_SCHEME.packs },
            status: { ...FORM_SCHEME.status },
            tag: { ...FORM_SCHEME.tag },
        });
    }, []);

    return (
        <VStack
            gap="$250"
            width="400px"
            padding="$300"
            borderRadius="$300"
            border="1px solid #eee"
            className="sheet-form"
            render={<Form id="sheet-form" onReset={handleReset} />}
        >
            <Sheet.Root>
                <Sheet.Trigger render={<Button />}>Open Filter</Sheet.Trigger>

                <Sheet.Popup
                    positionerElement={<Sheet.PositionerPrimitive side="bottom" />}
                    className={'popup'}
                >
                    <Sheet.Header className="header">
                        <Sheet.Title>Filter</Sheet.Title>
                    </Sheet.Header>
                    <Sheet.Body className="body">
                        <Tabs.Root defaultValue={'sort'} className={'tabs'}>
                            <Tabs.List className={'tabs-list'}>
                                <Tabs.Trigger value="sort">Sort</Tabs.Trigger>
                                <Tabs.Trigger value="package">Package</Tabs.Trigger>
                                <Tabs.Trigger value="status">Status</Tabs.Trigger>
                                <Tabs.Trigger value="tag">Tag</Tabs.Trigger>
                                <Tabs.Indicator />
                            </Tabs.List>
                            <Tabs.Panel value="sort" className={'tabs-panel'}>
                                <VStack gap="$100">
                                    {/* Sort */}
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-feedback"
                                                size="lg"
                                                checked={getFieldValues('sort').feedback}
                                                onCheckedChange={handleCheckboxChange(
                                                    'sort',
                                                    'feedback',
                                                )}
                                            />
                                            Feedback
                                        </Box>
                                    </Field.Root>

                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-buttons"
                                                size="lg"
                                                checked={getFieldValues('sort').buttons}
                                                onCheckedChange={handleCheckboxChange(
                                                    'sort',
                                                    'buttons',
                                                )}
                                            />
                                            Buttons
                                        </Box>
                                    </Field.Root>

                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-data-display"
                                                size="lg"
                                                checked={getFieldValues('sort')['data-display']}
                                                onCheckedChange={handleCheckboxChange(
                                                    'sort',
                                                    'data-display',
                                                )}
                                            />
                                            Data Display
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-overlay"
                                                size="lg"
                                                checked={getFieldValues('sort').overlay}
                                                onCheckedChange={handleCheckboxChange(
                                                    'sort',
                                                    'overlay',
                                                )}
                                            />
                                            Overlay
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-inputs"
                                                size="lg"
                                                checked={getFieldValues('sort').inputs}
                                                onCheckedChange={handleCheckboxChange(
                                                    'sort',
                                                    'inputs',
                                                )}
                                            />
                                            Inputs
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-navigation"
                                                size="lg"
                                                checked={getFieldValues('sort').navigation}
                                                onCheckedChange={handleCheckboxChange(
                                                    'sort',
                                                    'navigation',
                                                )}
                                            />
                                            Navigation
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-utils"
                                                size="lg"
                                                checked={getFieldValues('sort').utils}
                                                onCheckedChange={handleCheckboxChange(
                                                    'sort',
                                                    'utils',
                                                )}
                                            />
                                            Utils
                                        </Box>
                                    </Field.Root>
                                </VStack>
                            </Tabs.Panel>
                            {/* Package */}
                            <Tabs.Panel value="package" className={'tabs-panel'}>
                                <VStack gap="$100">
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-goorm-dev/vapor-core"
                                                size="lg"
                                                checked={
                                                    getFieldValues('packs')['goorm-dev/vapor-core']
                                                }
                                                onCheckedChange={handleCheckboxChange(
                                                    'packs',
                                                    'goorm-dev/vapor-core',
                                                )}
                                            />
                                            goorm-dev/vapor-core
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-goorm-dev/vapor-component"
                                                size="lg"
                                                checked={
                                                    getFieldValues('packs')[
                                                        'goorm-dev/vapor-component'
                                                    ]
                                                }
                                                onCheckedChange={handleCheckboxChange(
                                                    'packs',
                                                    'goorm-dev/vapor-component',
                                                )}
                                            />
                                            goorm-dev/vapor-component
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-vapor-ui/core"
                                                size="lg"
                                                checked={getFieldValues('packs')['vapor-ui/core']}
                                                onCheckedChange={handleCheckboxChange(
                                                    'packs',
                                                    'vapor-ui/core',
                                                )}
                                            />
                                            vapor-ui/core
                                        </Box>
                                    </Field.Root>
                                </VStack>
                            </Tabs.Panel>
                            {/* Status */}
                            <Tabs.Panel value="status" className={'tabs-panel'}>
                                <VStack gap="$100">
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-active"
                                                size="lg"
                                                checked={getFieldValues('status').active}
                                                onCheckedChange={handleCheckboxChange(
                                                    'status',
                                                    'active',
                                                )}
                                            />
                                            Active
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-inactive"
                                                size="lg"
                                                checked={getFieldValues('status').inactive}
                                                onCheckedChange={handleCheckboxChange(
                                                    'status',
                                                    'inactive',
                                                )}
                                            />
                                            Inactive
                                        </Box>
                                    </Field.Root>
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-draft"
                                                size="lg"
                                                checked={getFieldValues('status').draft}
                                                onCheckedChange={handleCheckboxChange(
                                                    'status',
                                                    'draft',
                                                )}
                                            />
                                            Draft
                                        </Box>
                                    </Field.Root>
                                </VStack>
                            </Tabs.Panel>
                            {/* Tag */}
                            <Tabs.Panel value="tag" className={'tabs-panel'}>
                                <VStack gap="$100">
                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-ui"
                                                size="lg"
                                                checked={getFieldValues('tag').ui}
                                                onCheckedChange={handleCheckboxChange('tag', 'ui')}
                                            />
                                            UI
                                        </Box>
                                    </Field.Root>

                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-open-source"
                                                size="lg"
                                                checked={getFieldValues('tag')['open-source']}
                                                onCheckedChange={handleCheckboxChange(
                                                    'tag',
                                                    'open-source',
                                                )}
                                            />
                                            Open Source
                                        </Box>
                                    </Field.Root>

                                    <Field.Root>
                                        <Box render={<Field.Label />} alignItems="center">
                                            <Checkbox.Root
                                                id="sheet-performance"
                                                size="lg"
                                                checked={getFieldValues('tag').performance}
                                                onCheckedChange={handleCheckboxChange(
                                                    'tag',
                                                    'performance',
                                                )}
                                            />
                                            Performance
                                        </Box>
                                    </Field.Root>
                                </VStack>
                            </Tabs.Panel>
                        </Tabs.Root>
                    </Sheet.Body>
                    <Sheet.Footer className="footer">
                        <Button
                            type="reset"
                            size="lg"
                            colorPalette="secondary"
                            className="refresh-button"
                            form="sheet-form"
                        >
                            <RefreshOutlineIcon />
                            Refresh
                        </Button>
                        <Sheet.Close render={<Button size="lg" className="apply-button" />}>
                            Apply
                        </Sheet.Close>
                    </Sheet.Footer>
                </Sheet.Popup>
            </Sheet.Root>
        </VStack>
    );
}
.checkbox-label {
    color: var(--vapor-color-foreground-normal-100, #2b2d36);

    font-size: var(--vapor-typography-fontSize-075, 0.875rem);
    font-weight: var(--vapor-typography-fontWeight-400);
    line-height: var(--vapor-typography-lineHeight-075, 1.375rem); /* 157.143% */
    letter-spacing: var(--vapor-typography-letterSpacing-100, -0.00625rem);
}

.popup {
    border-top-left-radius: var(--vapor-size-borderRadius-300);
    border-top-right-radius: var(--vapor-size-borderRadius-300);
}

.tabs {
    height: 100%;
}

.tabs-list {
    padding-inline: 0;
}

.tabs-panel {
    padding-block: var(--vapor-size-space-200);
}

.header {
    padding: var(--vapor-size-space-250) var(--vapor-size-space-200) 0;
}

.body {
    padding-inline: var(--vapor-size-space-200);
}

.footer {
    gap: var(--vapor-size-space-100);
    padding-top: var(--vapor-size-space-100);
}

.refresh-button {
    flex-shrink: 0;
}

.apply-button {
    flex-shrink: 0;
    flex-grow: 1;
}