Form
여러 개의 입력 단위를 하나로 묶어 사용자로부터 정보를 수집하는 폼 영역
Type: Login
import './login-form.css';
import { Button, Checkbox, Field, Form, HStack, 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 render={<VStack gap="$100" />}>
<Field.Label className="input-label">이메일</Field.Label>
<TextInput id="login-email" size="lg" required type="email" />
<Field.Error match="valueMissing">이메일을 입력해주세요.</Field.Error>
<Field.Error match="typeMismatch">유효한 이메일 형식이 아닙니다.</Field.Error>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">비밀번호</Field.Label>
<TextInput
id="login-password"
size="lg"
type="password"
required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,16}"
/>
<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 render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="login-auto-login" />
<Field.Label className="checkbox-label">자동 로그인</Field.Label>
</Field.Root>
<Button type="button" variant="ghost" color="secondary">
ID/비밀번호 찾기
</Button>
</HStack>
<Button size="lg">로그인</Button>
<Button size="lg" color="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 {
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 render={<VStack gap="$100" />}>
<Field.Label className="input-label">이메일</Field.Label>
<TextInput id="signup-email" size="lg" required type="email" />
<Field.Error match="valueMissing">이메일을 입력해주세요.</Field.Error>
<Field.Error match="typeMismatch">
유효한 이메일 형식이 아닙니다.
</Field.Error>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">비밀번호</Field.Label>
<TextInput
id="signup-password"
size="lg"
type="password"
onValueChange={(value) => {
setPasswordCheck(value);
}}
required
pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W_]).{8,16}"
/>
<Field.Description>
8~16자, 대소문자 영문, 숫자, 특수문자 포함
</Field.Description>
<Field.Error match="valueMissing">비밀번호를 입력해주세요.</Field.Error>
<Field.Error match="patternMismatch">
유효한 비밀번호 형식이 아닙니다.
</Field.Error>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">비밀번호 확인</Field.Label>
<TextInput
id="signup-password-check"
size="lg"
type="password"
required
pattern={passwordCheck}
/>
<Field.Description>8~16자, 대소문자 영문, 특수문자 포함</Field.Description>
<Field.Error match="valueMissing">비밀번호를 입력해주세요.</Field.Error>
<Field.Error match="patternMismatch">
비밀번호를 다시 확인해주세요.
</Field.Error>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">이름</Field.Label>
<TextInput id="signup-name" size="lg" required />
<Field.Error match="valueMissing">이름을 입력해주세요.</Field.Error>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">직업</Field.Label>
<Select.Root items={jobs} placeholder="직업을 선택해주세요." size="lg">
<Select.Trigger id="signup-jobs">
<Select.Value />
<Select.TriggerIcon />
</Select.Trigger>
<Select.Content>
{jobs.map((job) => (
<Select.Item key={job.value} value={job.value}>
{job.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Field.Root>
</VStack>
<VStack gap="$300">
<VStack justifyContent="space-between" gap="$050">
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="signup-agree-all" />
<Field.Label className="checkbox-label">
필수 약관에 모두 동의
</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="signup-terms-of-service" />
<HStack width="100%" justifyContent="space-between" alignItems="center">
<Field.Label className="checkbox-label">이용 약관 동의</Field.Label>
<IconButton
size="sm"
color="secondary"
variant="ghost"
aria-label="약관 보기"
>
<ChevronRightOutlineIcon />
</IconButton>
</HStack>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="signup-personal-info-collection" />
<HStack width="100%" justifyContent="space-between" alignItems="center">
<Field.Label className="checkbox-label">
개인 정보 수집 이용 동의
</Field.Label>
<IconButton
size="sm"
color="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 { Button, Field, Form, Select, 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 render={<VStack gap="$100" />}>
<Field.Label className="input-label">핸드폰 번호</Field.Label>
<Select.Root defaultValue={codes['+82']} size="lg">
<Group attached>
<Select.Trigger>
<Select.Value />
<Select.TriggerIcon />
</Select.Trigger>
<Select.Content>
{Object.entries(codes).map(([value, label]) => (
<Select.Item key={value} value={value}>
{label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
<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>
<Field.Error match="valueMissing">핸드폰 번호를 입력해주세요.</Field.Error>
<Field.Error match="patternMismatch">
올바른 핸드폰 번호를 입력해주세요.
</Field.Error>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">인증번호</Field.Label>
<TextInput id="auth-verification-code" size="lg" required />
<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 {
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 render={<VStack gap="$100" />}>
<Field.Label className="input-label">이름 </Field.Label>
<TextInput id="research-name" required size="lg" />
<Field.Error match="valueMissing">이름을 입력해주세요.</Field.Error>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">직업</Field.Label>
<Select.Root items={jobs} placeholder="직업을 선택해주세요." size="lg">
<Select.Trigger id="research-jobs">
<Select.Value />
<Select.TriggerIcon />
</Select.Trigger>
<Select.Content>
{jobs.map((job) => (
<Select.Item key={job.value} value={job.value}>
{job.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Root>
</Field.Root>
<Field.Root render={<VStack gap="$100" />}>
<Field.Label className="input-label">스택</Field.Label>
<MultiSelect.Root
items={stacks}
placeholder="자주 사용하는 스택을 선택해주세요."
size="lg"
>
<MultiSelect.Trigger id="research-stack">
<MultiSelect.Value />
<MultiSelect.TriggerIcon />
</MultiSelect.Trigger>
<MultiSelect.Content>
{stacks.map((stack) => (
<MultiSelect.Item key={stack.value} value={stack.value}>
{stack.label}
<MultiSelect.ItemIndicator />
</MultiSelect.Item>
))}
</MultiSelect.Content>
</MultiSelect.Root>
</Field.Root>
</VStack>
<Field.Root render={<VStack gap="$150" />}>
<RadioGroup.Root>
<RadioGroup.Label>만족도를 선택해주세요.</RadioGroup.Label>
<HStack
render={<Field.Label />}
alignItems="center"
gap="$100"
className="radio-label"
>
<Radio.Root
id="research-fully-satisfied"
value="fully-satisfied"
size="lg"
/>
매우 만족
</HStack>
<HStack
render={<Field.Label />}
alignItems="center"
gap="$100"
className="radio-label"
>
<Radio.Root id="research-neutral" value="neutral" size="lg" />
매우 만족
</HStack>
<HStack
render={<Field.Label />}
alignItems="center"
gap="$100"
className="radio-label"
>
<Radio.Root id="research-not-satisfied" value="not-satisfied" size="lg" />
불만족
</HStack>
</RadioGroup.Root>
</Field.Root>
<VStack gap="$100">
<VStack marginBottom="$050">
<Text typography="heading5">좋았던 강의는 무엇인가요?</Text>
<Text typography="body2" foreground="normal-100">
중복 선택 가능
</Text>
</VStack>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="research-mentoring" size="lg" />
<Field.Label className="checkbox-label">멘토님 강연 능력</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="research-topic" size="lg" />
<Field.Label className="checkbox-label">
주제(협업 및 커뮤니케이션 스킬)
</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="research-content" size="lg" />
<Field.Label className="checkbox-label">전반적인 강의 내용</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="research-seminar" size="lg" />
<Field.Label className="checkbox-label">세미나 자료</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root id="research-etc" size="lg" />
<Field.Label className="checkbox-label">기타</Field.Label>
</Field.Root>
</VStack>
<VStack gap="$100">
<Text typography="heading5">개인 정보 수신 동의</Text>
<Field.Root render={<HStack justifyContent="space-between" alignItems="center" />}>
<Field.Label className="checkbox-label">서비스 메일 수신 동의</Field.Label>
<Switch.Root defaultChecked id="research-service" />
</Field.Root>
<Field.Root render={<HStack justifyContent="space-between" alignItems="center" />}>
<Field.Label htmlFor="research-advertising" className="checkbox-label">
이벤트성 광고 수신 동의
</Field.Label>
<Switch.Root defaultChecked id="research-advertising" />
</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"
className="filter"
render={<Form ref={formRef} onReset={handleReset} />}
>
<HStack justifyContent="space-between">
<Text typography="heading5">Filter</Text>
<Button type="reset" size="sm" variant="ghost" color="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)
}
>
<HStack
render={<Field.Label />}
gap="$100"
alignItems="center"
className="radio-label"
>
<Radio.Root id="filter-recent" value="recent" />
Recent
</HStack>
<HStack
render={<Field.Label />}
gap="$100"
alignItems="center"
className="radio-label"
>
<Radio.Root id="filter-popular" value="popular" />
Most Popular
</HStack>
</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={<HStack alignItems="center" gap="$100" marginTop="$150" />}
>
<Checkbox.Root
id="filter-feedback"
checked={getFieldValues('sort').feedback}
onCheckedChange={handleCheckboxChange('sort', 'feedback')}
/>
<Field.Label className="checkbox-label">Feedback</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-buttons"
checked={getFieldValues('sort').buttons}
onCheckedChange={handleCheckboxChange('sort', 'buttons')}
/>
<Field.Label className="checkbox-label">Buttons</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-data-display"
checked={getFieldValues('sort')['data-display']}
onCheckedChange={handleCheckboxChange('sort', 'data-display')}
/>
<Field.Label className="checkbox-label">Data Display</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-overlay"
checked={getFieldValues('sort').overlay}
onCheckedChange={handleCheckboxChange('sort', 'overlay')}
/>
<Field.Label className="checkbox-label">Overlay</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-inputs"
checked={getFieldValues('sort').inputs}
onCheckedChange={handleCheckboxChange('sort', 'inputs')}
/>
<Field.Label className="checkbox-label">Inputs</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-navigation"
checked={getFieldValues('sort').navigation}
onCheckedChange={handleCheckboxChange('sort', 'navigation')}
/>
<Field.Label className="checkbox-label">Navigation</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-utils"
checked={getFieldValues('sort').utils}
onCheckedChange={handleCheckboxChange('sort', 'utils')}
/>
<Field.Label className="checkbox-label">Utils</Field.Label>
</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={<HStack alignItems="center" gap="$100" marginTop="$150" />}
>
<Checkbox.Root
id="filter-goorm-dev/vapor-core"
checked={getFieldValues('packs')['goorm-dev/vapor-core']}
onCheckedChange={handleCheckboxChange(
'packs',
'goorm-dev/vapor-core',
)}
/>
<Field.Label className="checkbox-label">
goorm-dev/vapor-core
</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-goorm-dev/vapor-component"
checked={getFieldValues('packs')['goorm-dev/vapor-component']}
onCheckedChange={handleCheckboxChange(
'packs',
'goorm-dev/vapor-component',
)}
/>
<Field.Label className="checkbox-label">
goorm-dev/vapor-component
</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-vapor-ui/core"
checked={getFieldValues('packs')['vapor-ui/core']}
onCheckedChange={handleCheckboxChange('packs', 'vapor-ui/core')}
/>
<Field.Label className="checkbox-label">vapor-ui/core</Field.Label>
</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={<HStack alignItems="center" gap="$100" marginTop="$150" />}
>
<Checkbox.Root
id="filter-active"
checked={getFieldValues('status').active}
onCheckedChange={handleCheckboxChange('status', 'active')}
/>
<Field.Label className="checkbox-label">Active</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-inactive"
checked={getFieldValues('status').inactive}
onCheckedChange={handleCheckboxChange('status', 'inactive')}
/>
<Field.Label className="checkbox-label">Inactive</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-draft"
checked={getFieldValues('status').draft}
onCheckedChange={handleCheckboxChange('status', 'draft')}
/>
<Field.Label className="checkbox-label">Draft</Field.Label>
</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={<HStack alignItems="center" gap="$100" marginTop="$150" />}
>
<Checkbox.Root
id="filter-ui"
checked={getFieldValues('tag').ui}
onCheckedChange={handleCheckboxChange('tag', 'ui')}
/>
<Field.Label className="checkbox-label">UI</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-open-source"
checked={getFieldValues('tag')['open-source']}
onCheckedChange={handleCheckboxChange('tag', 'open-source')}
/>
<Field.Label className="checkbox-label">Open Source</Field.Label>
</Field.Root>
<Field.Root render={<HStack alignItems="center" gap="$100" />}>
<Checkbox.Root
id="filter-performance"
checked={getFieldValues('tag').performance}
onCheckedChange={handleCheckboxChange('tag', 'performance')}
/>
<Field.Label className="checkbox-label">Performance</Field.Label>
</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 { Button, Checkbox, Field, Form, HStack, 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.Portal>
<Sheet.Overlay />
<Sheet.Positioner side="bottom">
<Sheet.Popup 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
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-feedback"
size="lg"
checked={getFieldValues('sort').feedback}
onCheckedChange={handleCheckboxChange(
'sort',
'feedback',
)}
/>
<Field.Label className="checkbox-label">
Feedback
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-buttons"
size="lg"
checked={getFieldValues('sort').buttons}
onCheckedChange={handleCheckboxChange(
'sort',
'buttons',
)}
/>
<Field.Label className="checkbox-label">
Buttons
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-data-display"
size="lg"
checked={getFieldValues('sort')['data-display']}
onCheckedChange={handleCheckboxChange(
'sort',
'data-display',
)}
/>
<Field.Label className="checkbox-label">
Data Display
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-overlay"
size="lg"
checked={getFieldValues('sort').overlay}
onCheckedChange={handleCheckboxChange(
'sort',
'overlay',
)}
/>
<Field.Label className="checkbox-label">
Overlay
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-inputs"
size="lg"
checked={getFieldValues('sort').inputs}
onCheckedChange={handleCheckboxChange(
'sort',
'inputs',
)}
/>
<Field.Label className="checkbox-label">
Inputs
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-navigation"
size="lg"
checked={getFieldValues('sort').navigation}
onCheckedChange={handleCheckboxChange(
'sort',
'navigation',
)}
/>
<Field.Label className="checkbox-label">
Navigation
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-utils"
size="lg"
checked={getFieldValues('sort').utils}
onCheckedChange={handleCheckboxChange(
'sort',
'utils',
)}
/>
<Field.Label className="checkbox-label">
Utils
</Field.Label>
</Field.Root>
</VStack>
</Tabs.Panel>
{/* Package */}
<Tabs.Panel value="package" className={'tabs-panel'}>
<VStack gap="$100">
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-goorm-dev/vapor-core"
size="lg"
checked={
getFieldValues('packs')[
'goorm-dev/vapor-core'
]
}
onCheckedChange={handleCheckboxChange(
'packs',
'goorm-dev/vapor-core',
)}
/>
<Field.Label className="checkbox-label">
goorm-dev/vapor-core
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-goorm-dev/vapor-component"
size="lg"
checked={
getFieldValues('packs')[
'goorm-dev/vapor-component'
]
}
onCheckedChange={handleCheckboxChange(
'packs',
'goorm-dev/vapor-component',
)}
/>
<Field.Label className="checkbox-label">
goorm-dev/vapor-component
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-vapor-ui/core"
size="lg"
checked={
getFieldValues('packs')['vapor-ui/core']
}
onCheckedChange={handleCheckboxChange(
'packs',
'vapor-ui/core',
)}
/>
<Field.Label className="checkbox-label">
vapor-ui/core
</Field.Label>
</Field.Root>
</VStack>
</Tabs.Panel>
{/* Status */}
<Tabs.Panel value="status" className={'tabs-panel'}>
<VStack gap="$100">
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-active"
size="lg"
checked={getFieldValues('status').active}
onCheckedChange={handleCheckboxChange(
'status',
'active',
)}
/>
<Field.Label className={'checkbox-label'}>
Active
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-inactive"
size="lg"
checked={getFieldValues('status').inactive}
onCheckedChange={handleCheckboxChange(
'status',
'inactive',
)}
/>
<Field.Label className={'checkbox-label'}>
Inactive
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-draft"
size="lg"
checked={getFieldValues('status').draft}
onCheckedChange={handleCheckboxChange(
'status',
'draft',
)}
/>
<Field.Label className="checkbox-label">
Draft
</Field.Label>
</Field.Root>
</VStack>
</Tabs.Panel>
{/* Tag */}
<Tabs.Panel value="tag" className={'tabs-panel'}>
<VStack gap="$100">
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-ui"
size="lg"
checked={getFieldValues('tag').ui}
onCheckedChange={handleCheckboxChange(
'tag',
'ui',
)}
/>
<Field.Label className="checkbox-label">
UI
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-open-source"
size="lg"
checked={getFieldValues('tag')['open-source']}
onCheckedChange={handleCheckboxChange(
'tag',
'open-source',
)}
/>
<Field.Label className="checkbox-label">
Open Source
</Field.Label>
</Field.Root>
<Field.Root
render={<HStack alignItems="center" gap="$100" />}
>
<Checkbox.Root
id="sheet-performance"
size="lg"
checked={getFieldValues('tag').performance}
onCheckedChange={handleCheckboxChange(
'tag',
'performance',
)}
/>
<Field.Label className="checkbox-label">
Performance
</Field.Label>
</Field.Root>
</VStack>
</Tabs.Panel>
</Tabs.Root>
</Sheet.Body>
<Sheet.Footer className="footer">
<Button
type="reset"
size="lg"
color="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.Positioner>
</Sheet.Portal>
</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;
}