feat: 新增 FaForm 表单组件及示例,并更新登录注册相关页面

This commit is contained in:
Hooray
2026-06-27 17:31:07 +08:00
parent e1ae0a1251
commit 29193e47e2
72 changed files with 4021 additions and 2645 deletions
+1
View File
@@ -37,6 +37,7 @@
"filesize": "catalog:",
"reka-ui": "catalog:",
"tailwind-merge": "catalog:",
"vee-validate": "catalog:",
"vue-input-otp": "catalog:",
"vue-sonner": "catalog:"
}
+2
View File
@@ -22,6 +22,8 @@ const BASIC_COMPONENT_NAMES = [
'FaDropdown',
'FaFileUpload',
'FaFixedBar',
'FaForm',
'FaFormItem',
'FaHoverCard',
'FaIcon',
'FaImagePreview',
@@ -167,11 +167,10 @@ function handleCustomOptionKeydown(option: TOption) {
@keydown.space.prevent="handleCustomOptionKeydown(option)"
>
<Checkbox
v-show="!slots.option"
:id="getOptionId(option, index)"
:model-value="isOptionChecked(option)"
:disabled="isOptionDisabled(option)"
:class="props.itemClass"
:class="cn(props.itemClass, slots.option && 'hidden')"
@update:model-value="handleOptionModelValueChange(option, $event)"
/>
<Label
@@ -0,0 +1,312 @@
<script lang="ts">
import type {
ComponentFieldBindingObject,
RuleExpression,
} from 'vee-validate'
import type { CSSProperties, HTMLAttributes, VNode, VNodeChild } from 'vue'
import type { FormLabelPlacement } from './context'
</script>
<script setup lang="ts" generic="TValue = unknown">
import { useId } from 'reka-ui'
import { useField } from 'vee-validate'
import { cloneVNode, Comment, computed, defineComponent, Fragment, h, inject, isVNode, onBeforeUnmount, onMounted, provide, toRef, useTemplateRef, watch } from 'vue'
import { cn } from '#utils'
import { FORM_LAYOUT_INJECTION_KEY } from './context'
import FormControl from './form/FormControl.vue'
import FormDescription from './form/FormDescription.vue'
import FormLabel from './form/FormLabel.vue'
import FormMessage from './form/FormMessage.vue'
import { FORM_ITEM_INJECTION_KEY } from './form/injectionKeys'
export interface FormItemProps<TValue = unknown> {
name: string
label?: string
description?: string
rules?: RuleExpression<TValue>
required?: boolean
autoBind?: boolean
labelPlacement?: FormLabelPlacement
labelWidth?: string | number
class?: HTMLAttributes['class']
labelClass?: HTMLAttributes['class']
contentClass?: HTMLAttributes['class']
messageClass?: HTMLAttributes['class']
descriptionClass?: HTMLAttributes['class']
}
export interface FormItemSlotProps<TValue = unknown> {
componentField: ComponentFieldBindingObject<TValue>
}
type EventHandler = ((...args: any[]) => unknown) | ((...args: any[]) => unknown)[]
defineOptions({
name: 'BuiltInFormItem',
})
const props = withDefaults(defineProps<FormItemProps<TValue>>(), {
autoBind: true,
})
const slots = defineSlots<{
default?: (props: FormItemSlotProps<TValue>) => any
label?: (props: FormItemSlotProps<TValue>) => any
description?: (props: FormItemSlotProps<TValue>) => any
message?: (props: FormItemSlotProps<TValue>) => any
}>()
const injectedFormContext = inject(FORM_LAYOUT_INJECTION_KEY)
if (!injectedFormContext) {
throw new Error('FaFormItem should be used within <FaForm>.')
}
const formContext = injectedFormContext
const id = useId()
provide(FORM_ITEM_INJECTION_KEY, id)
const itemRef = useTemplateRef<HTMLElement>('itemRef')
const field = useField<TValue>(toRef(props, 'name'), toRef(props, 'rules'), {
label: toRef(props, 'label'),
validateOnValueUpdate: true,
})
const resolvedLabelPlacement = computed(() => props.labelPlacement ?? formContext.labelPlacement.value)
const resolvedLabelWidth = computed(() => props.labelWidth ?? formContext.labelWidth.value)
const resolvedDisabled = computed(() => formContext.disabled.value)
const rootClass = computed(() => cn(
'min-w-0',
resolvedLabelPlacement.value === 'top'
? 'grid gap-2'
: 'grid gap-2 sm:grid-cols-[var(--fa-form-label-width)_minmax(0,1fr)] sm:items-start sm:gap-x-3',
props.class,
))
const rootStyle = computed<CSSProperties>(() => ({
'--fa-form-label-width': resolveSize(resolvedLabelWidth.value),
}))
const labelClass = computed(() => cn(
'text-sm font-medium leading-none',
resolvedLabelPlacement.value === 'top'
? 'inline-flex items-center gap-1 px-1'
: 'flex min-h-9 items-center gap-1 sm:pt-0',
resolvedLabelPlacement.value === 'right' && 'sm:justify-end sm:text-right',
resolvedLabelPlacement.value === 'left' && 'sm:justify-start sm:text-left',
props.labelClass,
))
const contentClass = computed(() => cn(
'grid min-w-0 gap-1',
resolvedLabelPlacement.value !== 'top' && 'sm:col-start-2',
props.contentClass,
))
const hasMessage = computed(() => Boolean(field.errorMessage.value))
const hasDescription = computed(() => Boolean(props.description || slots.description))
const feedbackClass = computed(() => cn('grid min-h-4 px-1'))
const messageClass = computed(() => cn('col-start-1 row-start-1 text-xs', props.messageClass))
const descriptionClass = computed(() => cn(
'col-start-1 row-start-1 text-xs',
hasMessage.value && 'invisible',
props.descriptionClass,
))
function handleUpdateModelValue(value: TValue) {
field.handleChange(value, true)
}
function handleBlur(event: Event) {
field.handleBlur(event, true)
}
function handleInput(event: Event | unknown) {
field.handleChange(event, true)
}
function handleChange(event: Event | unknown) {
field.handleChange(event, true)
}
const componentField = computed<ComponentFieldBindingObject<TValue>>(() => ({
'name': props.name,
'modelValue': field.value.value,
'onBlur': handleBlur,
'onInput': handleInput,
'onChange': handleChange,
'onUpdate:modelValue': handleUpdateModelValue,
}))
const slotProps = computed<FormItemSlotProps<TValue>>(() => ({
componentField: componentField.value,
}))
const ControlSlot = defineComponent({
name: 'BuiltInFormItemControlSlot',
setup() {
return () => {
const nodes = normalizeSlotNodes(slots.default?.(slotProps.value) ?? [])
if (!nodes.length) {
return null
}
const [firstNode, ...restNodes] = nodes
const controlNode = cloneVNode(firstNode, resolveControlProps(firstNode))
return h(FormControl, null, {
default: () => [controlNode, ...restNodes],
})
}
},
})
function resolveSize(size?: string | number) {
if (size == null || size === '') {
return '96px'
}
return typeof size === 'number' ? `${size}px` : size
}
function normalizeSlotNodes(nodes: VNodeChild): VNode[] {
const result: VNode[] = []
const source = Array.isArray(nodes) ? nodes : [nodes]
source.forEach((node) => {
if (Array.isArray(node)) {
result.push(...normalizeSlotNodes(node))
return
}
if (!isVNode(node) || node.type === Comment) {
return
}
if (node.type === Fragment && Array.isArray(node.children)) {
result.push(...normalizeSlotNodes(node.children as VNodeChild[]))
return
}
result.push(node)
})
return result
}
function resolveControlProps(vnode: VNode) {
if (!props.autoBind) {
return {}
}
const vnodeProps = vnode.props ?? {}
const nextProps: Record<string, unknown> = {
'name': props.name,
'disabled': resolvedDisabled.value || Boolean(vnodeProps.disabled),
'aria-required': props.required ? 'true' : undefined,
'onBlur': chainHandlers(vnodeProps.onBlur as EventHandler | undefined, componentField.value.onBlur),
'onInput': chainHandlers(vnodeProps.onInput as EventHandler | undefined, componentField.value.onInput),
'onChange': chainHandlers(vnodeProps.onChange as EventHandler | undefined, componentField.value.onChange),
'onUpdate:modelValue': chainHandlers(vnodeProps['onUpdate:modelValue'] as EventHandler | undefined, componentField.value['onUpdate:modelValue']!),
}
if (!Object.hasOwn(vnodeProps, 'modelValue')) {
nextProps.modelValue = componentField.value.modelValue
}
return nextProps
}
function chainHandlers<T extends (...args: any[]) => unknown>(existing: EventHandler | undefined, next: T) {
if (!existing || existing === next) {
return next
}
return (...args: Parameters<T>) => {
next(...args)
callHandler(existing, ...args)
}
}
function callHandler(handler: EventHandler, ...args: any[]) {
if (Array.isArray(handler)) {
handler.forEach(item => item(...args))
return
}
handler(...args)
}
function registerCurrentField() {
if (itemRef.value) {
formContext.registerField(props.name, itemRef.value)
}
}
function unregisterCurrentField(name = props.name) {
formContext.unregisterField(name, itemRef.value ?? undefined)
}
onMounted(() => {
registerCurrentField()
})
watch(() => props.name, (name, oldName) => {
if (oldName) {
unregisterCurrentField(oldName)
}
if (name) {
registerCurrentField()
}
})
onBeforeUnmount(() => {
unregisterCurrentField()
})
</script>
<template>
<div
ref="itemRef"
data-slot="form-item"
:class="rootClass"
:style="rootStyle"
>
<FormLabel
v-if="props.label || $slots.label"
:class="labelClass"
>
<slot name="label" v-bind="slotProps">
<span>{{ props.label }}</span>
<span v-if="props.required" class="text-destructive" aria-hidden="true">*</span>
</slot>
</FormLabel>
<div :class="contentClass">
<ControlSlot />
<div :class="feedbackClass">
<FormDescription
v-if="hasDescription"
:class="descriptionClass"
:aria-hidden="hasMessage ? 'true' : undefined"
>
<slot name="description" v-bind="slotProps">
{{ props.description }}
</slot>
</FormDescription>
<FormMessage
v-if="hasMessage"
:class="messageClass"
>
<template #default="{ message }">
<slot name="message" v-bind="slotProps">
{{ message }}
</slot>
</template>
</FormMessage>
</div>
</div>
</div>
</template>
@@ -0,0 +1,68 @@
# FaForm 表单
基于 `vee-validate` 的表单组件,提供接近 Element Plus / Naive UI 的声明式写法。
## 基础用法
```vue
<FaForm :validation-schema="schema" :model="model" @submit="onSubmit">
<FaFormItem name="account" label="账号" required>
<FaInput />
</FaFormItem>
<FaButton type="submit">
提交
</FaButton>
</FaForm>
```
## model 与 initialValues
常规业务表单推荐使用 `model` 传入整个表单对象。字段变化会同步回该对象,也可以使用 `v-model:model` 接收完整表单对象更新。
`initialValues` 仍然保留,适合只需要初始化表单、不关心外部表单对象同步的非受控用法。`model` 和 `initialValues` 同时传入时,以 `model` 为准。
## FaForm Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `as` | `'form' \| 'div'` | `'form'` | 根节点类型 |
| `model` | `FormOptions['initialValues']` | - | 表单数据对象,推荐用法,字段变化会同步回该对象 |
| `validationSchema` | `Partial<Record<Path, Rule>> \| TypedSchema \| YupSchema` | - | vee-validate 校验 schema |
| `initialValues` | `FormOptions['initialValues']` | - | 非受控表单初始值;同时传入 `model` 时以 `model` 为准 |
| `labelPlacement` | `'top' \| 'left' \| 'right'` | `'top'` | 标签位置 |
| `labelWidth` | `string \| number` | `'96px'` | 横向布局标签宽度 |
| `disabled` | `boolean` | `false` | 是否禁用表单控件 |
| `scrollToError` | `boolean` | `false` | 提交失败时滚动到首个错误字段 |
## FaFormItem Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `name` | `string` | - | 字段路径,支持 vee-validate 路径语法 |
| `label` | `string` | - | 标签 |
| `description` | `string` | - | 辅助说明 |
| `rules` | `RuleExpression` | - | 字段级校验规则 |
| `required` | `boolean` | `false` | 是否显示必填标记 |
| `autoBind` | `boolean` | `true` | 是否自动向默认插槽第一个节点注入字段值、事件、禁用状态等绑定 |
| `labelPlacement` | `'top' \| 'left' \| 'right'` | - | 局部覆盖标签位置 |
| `labelWidth` | `string \| number` | - | 局部覆盖标签宽度 |
## 复合输入
`FaFormItem` 默认会向默认插槽的第一个节点自动注入表单绑定,让普通输入控件无需手写 `v-model`。
当字段由多个控件共同维护,或默认插槽第一个节点只是布局容器时,可设置 `auto-bind="false"` 关闭自动绑定。关闭后,`FaFormItem` 不会自动向默认插槽第一个节点注入字段值、事件、禁用状态和 `name` 等属性,仅保留表单项的说明、错误信息和无障碍状态关联。
默认插槽仅暴露 `componentField`。普通自定义控件可手动 `v-bind="componentField"`,复合输入在 `auto-bind="false"` 时直接维护传入 `FaForm` 的 `model`。
## Expose
| 方法 | 说明 |
|------|------|
| `submit()` | 触发表单提交 |
| `validate()` | 校验整个表单 |
| `resetFields()` | 重置表单 |
| `clearValidate(name?)` | 清空单个或全部字段错误 |
| `setFieldValue(name, value, shouldValidate?)` | 设置字段值 |
| `setFieldError(name, message?)` | 设置字段错误 |
| `scrollToField(name)` | 滚动并聚焦到字段 |
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import FaButton from '../../button/index.vue'
import FaInput from '../../input/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
const submitted = shallowRef('')
const model = ref({
username: '',
email: '',
})
const unavailableNames = ['admin', 'root', 'fantastic']
const validationSchema = {
async username(value: string) {
if (!value) {
return '请输入用户名'
}
if (value.length < 3) {
return '用户名至少 3 个字符'
}
const available = await checkUsernameAvailable(value)
return available ? true : '该用户名已被占用'
},
email(value: string) {
return /^\S[^\s@]*@\S[^\s.]*\.\S+$/.test(value) ? true : '请输入有效邮箱'
},
}
async function checkUsernameAvailable(value: string) {
await wait(2000)
return !unavailableNames.includes(value.trim().toLowerCase())
}
function wait(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms))
}
function handleSubmit(values: Record<string, unknown>) {
submitted.value = JSON.stringify(values, null, 2)
}
</script>
<template>
<div class="gap-4 grid max-w-160">
<FaForm
v-slot="{ isSubmitting }"
:model="model"
:validation-schema="validationSchema"
@submit="handleSubmit"
>
<FaFormItem
name="username"
label="用户名"
required
description="输入 admin / root / fantastic 会返回占用错误"
>
<FaInput placeholder="请输入用户名" class="w-full" />
</FaFormItem>
<FaFormItem name="email" label="邮箱" required>
<FaInput type="email" placeholder="name@example.com" class="w-full" />
</FaFormItem>
<FaButton type="submit" :loading="isSubmitting">
提交
</FaButton>
</FaForm>
<pre v-if="submitted" class="text-sm m-0 p-4 rounded-md bg-muted">{{ submitted }}</pre>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import FaButton from '../../button/index.vue'
import FaInput from '../../input/index.vue'
import FaSelect from '../../select/index.vue'
import FaTextarea from '../../textarea/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
const submitted = shallowRef('')
const model = ref({
account: '',
role: '',
remark: '',
})
const roleOptions = [
{ label: '管理员', value: 'admin' },
{ label: '运营', value: 'operator' },
{ label: '访客', value: 'guest' },
]
const validationSchema = {
account(value: string) {
return value ? true : '请输入账号'
},
role(value: string) {
return value ? true : '请选择角色'
},
remark(value: string) {
return !value || value.length >= 6 ? true : '备注至少 6 个字符'
},
}
function handleSubmit(values: Record<string, unknown>) {
submitted.value = JSON.stringify(values, null, 2)
}
</script>
<template>
<div class="gap-4 grid max-w-160">
<FaForm
:model="model"
:validation-schema="validationSchema"
@submit="handleSubmit"
>
<FaFormItem name="account" label="账号" required description="账号将作为登录名使用">
<FaInput placeholder="请输入账号" class="w-full" />
</FaFormItem>
<FaFormItem name="role" label="角色" required>
<FaSelect :options="roleOptions" placeholder="请选择角色" class="w-full" />
</FaFormItem>
<FaFormItem name="remark" label="备注">
<FaTextarea placeholder="请输入至少 6 个字符" class="w-full" />
</FaFormItem>
<div class="flex gap-2">
<FaButton type="submit">
提交
</FaButton>
<FaButton type="reset" variant="outline">
重置
</FaButton>
</div>
</FaForm>
<pre v-if="submitted" class="text-sm m-0 p-4 rounded-md bg-muted">{{ submitted }}</pre>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import FaButton from '../../button/index.vue'
import FaIcon from '../../icon/index.vue'
import FaInput from '../../input/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
const result = shallowRef('')
const inputAuth = shallowRef('')
const model = ref({
auths: [] as string[],
})
const validationSchema = {
auths(value: string[]) {
return value.length ? true : '请至少添加一个权限标识'
},
}
function addAuth() {
const auth = inputAuth.value.trim()
if (!auth) {
return
}
if (model.value.auths.includes(auth)) {
inputAuth.value = ''
return
}
model.value.auths = [...model.value.auths, auth]
inputAuth.value = ''
}
function removeAuth(auth: string) {
model.value.auths = model.value.auths.filter(item => item !== auth)
}
function handleSubmit(values: Record<string, unknown>) {
result.value = JSON.stringify(values, null, 2)
}
</script>
<template>
<div class="gap-4 grid max-w-160">
<FaForm
:model="model"
:validation-schema="validationSchema"
@submit="handleSubmit"
>
<FaFormItem name="auths" label="权限标识" required :auto-bind="false">
<div class="flex flex-wrap gap-2 max-w-full items-center">
<div
v-for="item in model.auths"
:key="item"
class="text-xs text-secondary-foreground px-2.5 py-2 border rounded-lg bg-secondary inline-flex gap-1.5 max-w-full items-center"
>
<span class="truncate" :title="item">{{ item }}</span>
<button
type="button"
class="flex-center size-4"
@click="removeAuth(item)"
>
<FaIcon name="i-ep:close" />
</button>
</div>
<FaInput
v-model="inputAuth"
placeholder="请输入权限标识"
class="w-50"
@keyup.enter="addAuth"
/>
<FaButton
type="button"
variant="outline"
@click="addAuth"
>
新增
</FaButton>
</div>
</FaFormItem>
<FaButton type="submit">
提交
</FaButton>
</FaForm>
<pre v-if="result" class="text-sm m-0 p-4 rounded-md bg-muted">{{ result }}</pre>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import FaButton from '../../button/index.vue'
import FaInput from '../../input/index.vue'
import FaSelect from '../../select/index.vue'
import FaSwitch from '../../switch/index.vue'
import FaTextarea from '../../textarea/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
const disabled = shallowRef(true)
const submitted = shallowRef('')
const model = ref({
name: 'Fantastic-admin',
role: 'admin',
remark: '禁用状态下,表单项内的控件会同步不可编辑。',
})
const roleOptions = [
{ label: '管理员', value: 'admin' },
{ label: '运营', value: 'operator' },
{ label: '访客', value: 'guest' },
]
function handleSubmit(values: Record<string, unknown>) {
submitted.value = JSON.stringify(values, null, 2)
}
</script>
<template>
<div class="gap-4 grid max-w-160">
<div class="flex gap-2 items-center">
<FaSwitch v-model="disabled" />
<span class="text-sm">禁用表单</span>
</div>
<FaForm
:disabled="disabled"
:model="model"
@submit="handleSubmit"
>
<FaFormItem name="name" label="项目名称">
<FaInput class="w-full" />
</FaFormItem>
<FaFormItem name="role" label="角色">
<FaSelect :options="roleOptions" class="w-full" />
</FaFormItem>
<FaFormItem name="remark" label="备注">
<FaTextarea class="w-full" />
</FaFormItem>
<div class="flex gap-2">
<FaButton type="submit" :disabled="disabled">
提交
</FaButton>
<FaButton type="reset" variant="outline" :disabled="disabled">
重置
</FaButton>
</div>
</FaForm>
<pre v-if="submitted" class="text-sm m-0 p-4 rounded-md bg-muted">{{ submitted }}</pre>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { FormExpose } from '../index'
import { ref, shallowRef, useTemplateRef } from 'vue'
import FaButton from '../../button/index.vue'
import FaInput from '../../input/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
interface ContactValue {
name: string
phone: string
}
const formRef = useTemplateRef<FormExpose>('formRef')
const submitted = shallowRef('')
const model = ref<{ contacts: ContactValue[] }>({
contacts: [
createContact('张三', '13800138000'),
],
})
function createContact(name = '', phone = ''): ContactValue {
return { name, phone }
}
function addContact() {
model.value.contacts.push(createContact())
formRef.value?.clearValidate()
submitted.value = ''
}
function removeContact(index: number) {
if (model.value.contacts.length <= 1) {
return
}
model.value.contacts.splice(index, 1)
formRef.value?.clearValidate()
submitted.value = ''
}
function validateName(value?: string) {
return value?.trim() ? true : '请输入联系人姓名'
}
function validatePhone(value?: string) {
return /^1[3-9]\d{9}$/.test(value ?? '') ? true : '请输入有效手机号'
}
function handleSubmit(values: Record<string, unknown>) {
submitted.value = JSON.stringify(values, null, 2)
}
</script>
<template>
<div class="gap-4 grid max-w-170">
<FaForm
ref="formRef"
:model="model"
@submit="handleSubmit"
>
<div
v-for="(_contact, index) in model.contacts"
:key="index"
class="p-3 border rounded-md gap-3 grid"
>
<div class="flex gap-3 items-center justify-between">
<div class="text-sm font-medium">
联系人 {{ index + 1 }}
</div>
<FaButton
type="button"
variant="outline"
size="sm"
:disabled="model.contacts.length <= 1"
@click="removeContact(index)"
>
删除
</FaButton>
</div>
<FaFormItem
:name="`contacts[${index}].name`"
label="姓名"
required
:rules="validateName"
>
<FaInput placeholder="请输入姓名" class="w-full" />
</FaFormItem>
<FaFormItem
:name="`contacts[${index}].phone`"
label="手机号"
required
:rules="validatePhone"
>
<FaInput type="tel" placeholder="请输入手机号" class="w-full" />
</FaFormItem>
</div>
<div class="flex flex-wrap gap-2">
<FaButton type="button" variant="outline" @click="addContact">
新增联系人
</FaButton>
<FaButton type="submit">
提交
</FaButton>
</div>
</FaForm>
<pre v-if="submitted" class="text-sm m-0 p-4 rounded-md bg-muted">{{ submitted }}</pre>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { FormLabelPlacement } from '../context'
import { ref, shallowRef } from 'vue'
import FaInput from '../../input/index.vue'
import FaNumberField from '../../number-field/index.vue'
import FaRadioGroup from '../../radio-group/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
const labelPlacement = shallowRef<FormLabelPlacement>('left')
const labelWidth = shallowRef(150)
const model = ref({
name: 'Fantastic-admin',
email: 'admin@example.com',
phone: '13800138000',
})
const labelPlacementOptions: { label: string, value: FormLabelPlacement }[] = [
{ label: 'top', value: 'top' },
{ label: 'left', value: 'left' },
{ label: 'right', value: 'right' },
]
</script>
<template>
<div class="gap-4 grid max-w-170">
<div class="gap-4 grid sm:grid-cols-[minmax(0,1fr)_200px]">
<div class="gap-2 grid">
<div class="text-sm font-medium">
label-placement
</div>
<FaRadioGroup v-model="labelPlacement" :options="labelPlacementOptions" class="flex flex-wrap gap-4" />
</div>
<div class="gap-2 grid">
<div class="text-sm font-medium">
label-width
</div>
<FaNumberField v-model="labelWidth" :min="80" :max="240" :step="10" class="w-full" />
</div>
</div>
<FaForm
:model="model"
:label-placement="labelPlacement"
:label-width="labelWidth"
>
<FaFormItem name="name" label="项目名称">
<FaInput class="w-full" />
</FaFormItem>
<FaFormItem name="email" label="邮箱地址">
<FaInput type="email" class="w-full" />
</FaFormItem>
<FaFormItem name="phone" label="联系电话">
<FaInput type="tel" class="w-full" />
</FaFormItem>
</FaForm>
</div>
</template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { FormExpose } from '../index'
import { ref, shallowRef, useTemplateRef } from 'vue'
import FaButton from '../../button/index.vue'
import FaInput from '../../input/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
const formRef = useTemplateRef<FormExpose>('formRef')
const message = shallowRef('')
const model = ref({
email: '',
nickname: '',
})
const validationSchema = {
email(value: string) {
return /^\S[^\s@]*@\S[^\s.]*\.\S+$/.test(value) ? true : '请输入有效邮箱'
},
nickname(value: string) {
return value.length >= 2 ? true : '昵称至少 2 个字符'
},
}
async function submit() {
await formRef.value?.submit()
}
function handleSubmit() {
message.value = '提交成功'
}
async function validate() {
const result = await formRef.value?.validate()
message.value = result?.valid ? '校验通过' : '校验未通过'
}
function fillDemo() {
formRef.value?.setFieldValue('email', 'admin@example.com')
formRef.value?.setFieldValue('nickname', 'Fantastic')
}
function resetFields() {
formRef.value?.resetFields()
message.value = ''
}
</script>
<template>
<div class="gap-4 grid max-w-160">
<FaForm
ref="formRef"
:model="model"
:validation-schema="validationSchema"
scroll-to-error
@submit="handleSubmit"
>
<FaFormItem name="email" label="邮箱" required>
<FaInput type="email" class="w-full" />
</FaFormItem>
<FaFormItem name="nickname" label="昵称" required>
<FaInput class="w-full" />
</FaFormItem>
<div class="flex flex-wrap gap-2">
<FaButton type="button" @click="submit">
提交
</FaButton>
<FaButton type="button" @click="validate">
校验
</FaButton>
<FaButton type="button" variant="outline" @click="fillDemo">
填充
</FaButton>
<FaButton type="button" variant="outline" @click="resetFields">
重置
</FaButton>
</div>
</FaForm>
<div v-if="message" class="text-sm text-muted-foreground">
{{ message }}
</div>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import FaButton from '../../button/index.vue'
import { Checkbox as FaCheckbox } from '../../checkbox/checkbox'
import FaInput from '../../input/index.vue'
import FaFormItem from '../FormItem.vue'
import FaForm from '../index.vue'
const result = shallowRef('')
const model = ref({
email: '',
agreement: false,
})
const validationSchema = {
email(value: string) {
return /^\S[^\s@]*@\S[^\s.]*\.\S+$/.test(value) ? true : '请输入有效邮箱'
},
agreement(value: boolean) {
return value ? true : '请先同意服务协议'
},
}
function handleSubmit(values: Record<string, unknown>) {
result.value = JSON.stringify(values, null, 2)
}
</script>
<template>
<div class="gap-4 grid max-w-160">
<FaForm
:model="model"
:validation-schema="validationSchema"
@submit="handleSubmit"
>
<FaFormItem name="email" required>
<template #label>
<span>邮箱</span>
<span class="text-destructive">*</span>
</template>
<FaInput type="email" placeholder="name@example.com" class="w-full" />
<template #description>
用于接收账号通知和安全提醒
</template>
</FaFormItem>
<FaFormItem name="agreement">
<template #default="{ componentField }">
<div class="flex gap-2 items-center">
<FaCheckbox
:model-value="componentField.modelValue as boolean"
@update:model-value="componentField['onUpdate:modelValue']?.($event as boolean)"
/>
<span class="text-sm">我已阅读并同意服务协议</span>
</div>
</template>
</FaFormItem>
<FaButton type="submit">
提交
</FaButton>
</FaForm>
<pre v-if="result" class="text-sm m-0 p-4 rounded-md bg-muted">{{ result }}</pre>
</div>
</template>
@@ -0,0 +1,59 @@
import AsyncValidation from './_async.vue'
import AsyncValidationRaw from './_async.vue?raw'
import Basic from './_basic.vue'
import BasicRaw from './_basic.vue?raw'
import Composite from './_composite.vue'
import CompositeRaw from './_composite.vue?raw'
import Disabled from './_disabled.vue'
import DisabledRaw from './_disabled.vue?raw'
import Dynamic from './_dynamic.vue'
import DynamicRaw from './_dynamic.vue?raw'
import Layout from './_layout.vue'
import LayoutRaw from './_layout.vue?raw'
import Methods from './_methods.vue'
import MethodsRaw from './_methods.vue?raw'
import Slot from './_slot.vue'
import SlotRaw from './_slot.vue?raw'
export default [
{
title: '基础',
component: Basic,
componentRaw: BasicRaw,
},
{
title: '禁用',
component: Disabled,
componentRaw: DisabledRaw,
},
{
title: '异步验证',
component: AsyncValidation,
componentRaw: AsyncValidationRaw,
},
{
title: '动态表单',
component: Dynamic,
componentRaw: DynamicRaw,
},
{
title: '复合输入',
component: Composite,
componentRaw: CompositeRaw,
},
{
title: '布局',
component: Layout,
componentRaw: LayoutRaw,
},
{
title: '插槽',
component: Slot,
componentRaw: SlotRaw,
},
{
title: '方法',
component: Methods,
componentRaw: MethodsRaw,
},
]
@@ -0,0 +1,13 @@
import type { ComputedRef, InjectionKey } from 'vue'
export type FormLabelPlacement = 'top' | 'left' | 'right'
export interface FormLayoutContext {
labelPlacement: ComputedRef<FormLabelPlacement>
labelWidth: ComputedRef<string | number | undefined>
disabled: ComputedRef<boolean>
registerField: (name: string, el: HTMLElement) => void
unregisterField: (name: string, el?: HTMLElement) => void
}
export const FORM_LAYOUT_INJECTION_KEY: InjectionKey<FormLayoutContext> = Symbol('form-layout')
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { Slot } from 'reka-ui'
import { useFormField } from './useFormField'
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
</script>
<template>
<Slot
:id="formItemId"
data-slot="form-control"
:aria-describedby="!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '#utils'
import { useFormField } from './useFormField'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { formDescriptionId } = useFormField()
</script>
<template>
<p
:id="formDescriptionId"
data-slot="form-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useId } from 'reka-ui'
import { provide } from 'vue'
import { cn } from '#utils'
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const id = useId()
provide(FORM_ITEM_INJECTION_KEY, id)
</script>
<template>
<div
data-slot="form-item"
:class="cn('grid gap-2', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '#utils'
import { Label } from '../../label/label'
import { useFormField } from './useFormField'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const { error, formItemId } = useFormField()
</script>
<template>
<Label
data-slot="form-label"
:data-error="!!error"
:class="cn(
'data-[error=true]:text-destructive',
props.class,
)"
:for="formItemId"
>
<slot />
</Label>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { ErrorMessage } from 'vee-validate'
import { toValue } from 'vue'
import { cn } from '#utils'
import { useFormField } from './useFormField'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
defineSlots<{
default?: (props: { message: string | undefined }) => any
}>()
const { name, formMessageId } = useFormField()
</script>
<template>
<ErrorMessage
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive text-sm', props.class)"
>
<template #default="{ message }">
<slot :message="message">
{{ message }}
</slot>
</template>
</ErrorMessage>
</template>
@@ -0,0 +1,7 @@
export { default as FormControl } from './FormControl.vue'
export { default as FormDescription } from './FormDescription.vue'
export { default as FormItem } from './FormItem.vue'
export { default as FormLabel } from './FormLabel.vue'
export { default as FormMessage } from './FormMessage.vue'
export { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
export { Form, Field as FormField, FieldArray as FormFieldArray } from 'vee-validate'
@@ -0,0 +1,3 @@
import type { InjectionKey } from 'vue'
export const FORM_ITEM_INJECTION_KEY: InjectionKey<string> = Symbol('form-item')
@@ -0,0 +1,31 @@
import { FieldContextKey } from 'vee-validate'
import { computed, inject } from 'vue'
import { FORM_ITEM_INJECTION_KEY } from './injectionKeys'
export function useFormField() {
const fieldContext = inject(FieldContextKey)
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { name, errorMessage: error, meta } = fieldContext
const id = fieldItemContext
const fieldState = {
valid: computed(() => meta.valid),
isDirty: computed(() => meta.dirty),
isTouched: computed(() => meta.touched),
error,
}
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
@@ -0,0 +1,5 @@
export type { FormLabelPlacement } from './context'
export { default as FormItem } from './FormItem.vue'
export type { FormItemProps, FormItemSlotProps } from './FormItem.vue'
export { default as Form } from './index.vue'
export type { FormEmits, FormExpose, FormProps, FormSlotProps } from './index.vue'
@@ -0,0 +1,319 @@
<script lang="ts">
import type {
FormErrors,
FormMeta,
FormOptions,
FormState,
FormValidationResult,
GenericObject,
GenericValidateFunction,
InvalidSubmissionContext,
Path,
ResetFormOpts,
SubmissionContext,
TypedSchema,
YupSchema,
} from 'vee-validate'
import type { HTMLAttributes, MaybeRef } from 'vue'
import type { FormLabelPlacement } from './context'
</script>
<script setup lang="ts" generic="TValues extends GenericObject = GenericObject, TOutput extends GenericObject = TValues">
import { cloneDeep, isEqual, isPlainObject } from 'es-toolkit'
import { useForm } from 'vee-validate'
import { computed, nextTick, provide, toRaw, toRef, watch } from 'vue'
import { cn } from '#utils'
import { FORM_LAYOUT_INJECTION_KEY } from './context'
export interface FormProps<
TValues extends GenericObject = GenericObject,
TOutput extends GenericObject = TValues,
> {
as?: 'form' | 'div'
model?: FormOptions<TValues, TOutput>['initialValues']
validationSchema?: FormValidationSchema<TValues, TOutput>
initialValues?: FormOptions<TValues, TOutput>['initialValues']
initialErrors?: FormOptions<TValues, TOutput>['initialErrors']
initialTouched?: FormOptions<TValues, TOutput>['initialTouched']
validateOnMount?: FormOptions<TValues, TOutput>['validateOnMount']
keepValuesOnUnmount?: FormOptions<TValues, TOutput>['keepValuesOnUnmount']
name?: FormOptions<TValues, TOutput>['name']
labelPlacement?: FormLabelPlacement
labelWidth?: string | number
disabled?: boolean
scrollToError?: boolean
class?: HTMLAttributes['class']
}
export type FormValidationSchema<
TValues extends GenericObject = GenericObject,
TOutput extends GenericObject = TValues,
> = MaybeRef<
| Partial<Record<Path<TValues>, string | GenericValidateFunction<any> | GenericObject>>
| TypedSchema<TValues, TOutput>
| YupSchema<TValues>
| undefined
>
export interface FormEmits<
TValues extends GenericObject = GenericObject,
TOutput extends GenericObject = TValues,
> {
'update:model': [model: TValues]
'submit': [values: TOutput, ctx: SubmissionContext<TValues>]
'invalidSubmit': [ctx: InvalidSubmissionContext<TValues, TOutput>]
}
export interface FormSlotProps<
TValues extends GenericObject = GenericObject,
TOutput extends GenericObject = TValues,
> {
values: TValues
errors: FormErrors<TValues>
meta: FormMeta<TValues>
isSubmitting: boolean
submitCount: number
submit: () => Promise<void>
validate: () => Promise<FormValidationResult<TValues, TOutput>>
resetFields: (state?: Partial<FormState<TValues>>, opts?: ResetFormOpts) => void
clearValidate: (name?: Path<TValues> | string) => void
scrollToField: (name: Path<TValues> | string) => boolean
}
export interface FormExpose<
TValues extends GenericObject = GenericObject,
TOutput extends GenericObject = TValues,
> {
submit: () => Promise<void>
validate: () => Promise<FormValidationResult<TValues, TOutput>>
resetFields: (state?: Partial<FormState<TValues>>, opts?: ResetFormOpts) => void
clearValidate: (name?: Path<TValues> | string) => void
setFieldValue: (name: Path<TValues> | string, value: unknown, shouldValidate?: boolean) => void
setFieldError: (name: Path<TValues> | string, message?: string) => void
scrollToField: (name: Path<TValues> | string) => boolean
}
defineOptions({
name: 'BuiltInForm',
})
const props = withDefaults(defineProps<FormProps<TValues, TOutput>>(), {
as: 'form',
labelPlacement: 'top',
})
const emit = defineEmits<FormEmits<TValues, TOutput>>()
const fieldElements = new Map<string, HTMLElement>()
const form = useForm<TValues, TOutput>({
validationSchema: toRef(props, 'validationSchema') as FormProps<TValues, TOutput>['validationSchema'],
initialValues: cloneFormValue(props.model ?? props.initialValues),
initialErrors: props.initialErrors,
initialTouched: props.initialTouched,
validateOnMount: props.validateOnMount,
keepValuesOnUnmount: props.keepValuesOnUnmount,
name: props.name,
})
let syncingFromModel = false
let syncingToModel = false
const resolvedLabelPlacement = computed(() => props.labelPlacement)
const resolvedLabelWidth = computed(() => props.labelWidth ?? '96px')
const resolvedDisabled = computed(() => Boolean(props.disabled))
const rootClass = computed(() => cn('grid gap-1', props.class))
const slotProps = computed<FormSlotProps<TValues, TOutput>>(() => ({
values: form.values,
errors: form.errors.value,
meta: form.meta.value,
isSubmitting: form.isSubmitting.value,
submitCount: form.submitCount.value,
submit,
validate,
resetFields,
clearValidate,
scrollToField,
}))
function registerField(name: string, el: HTMLElement) {
fieldElements.set(name, el)
}
function unregisterField(name: string, el?: HTMLElement) {
if (!el || fieldElements.get(name) === el) {
fieldElements.delete(name)
}
}
watch(
() => props.model,
(model) => {
if (!model || syncingToModel) {
return
}
const nextValues = cloneFormValue(model) as NonNullable<FormProps<TValues, TOutput>['model']>
if (isEqual(toRaw(form.values), nextValues)) {
return
}
syncingFromModel = true
form.setValues(nextValues, false)
void nextTick(() => {
syncingFromModel = false
})
},
{ deep: true },
)
watch(
form.values,
(values) => {
if (!props.model || syncingFromModel) {
return
}
const nextModel = cloneFormValue(values) as TValues
if (isEqual(toRaw(props.model), nextModel)) {
return
}
syncingToModel = true
syncModel(props.model as GenericObject, nextModel)
emit('update:model', nextModel)
void nextTick(() => {
syncingToModel = false
})
},
{ deep: true },
)
provide(FORM_LAYOUT_INJECTION_KEY, {
labelPlacement: resolvedLabelPlacement,
labelWidth: resolvedLabelWidth,
disabled: resolvedDisabled,
registerField,
unregisterField,
})
function validate() {
return form.validate()
}
function resetFields(state?: Partial<FormState<TValues>>, opts?: ResetFormOpts) {
form.resetForm(state, opts)
}
function clearValidate(name?: Path<TValues> | string) {
if (name) {
form.setFieldError(name as Path<TValues>, undefined)
return
}
form.setErrors({})
}
function setFieldValue(name: Path<TValues> | string, value: unknown, shouldValidate = true) {
form.setFieldValue(name as Path<TValues>, value as never, shouldValidate)
}
function setFieldError(name: Path<TValues> | string, message?: string) {
form.setFieldError(name as Path<TValues>, message)
}
function cloneFormValue<T>(value: T): T {
return cloneDeep(toRaw(value)) as T
}
function syncModel(target: GenericObject, source: GenericObject) {
Object.keys(target).forEach((key) => {
if (!Object.hasOwn(source, key)) {
delete target[key]
}
})
Object.entries(source).forEach(([key, value]) => {
const currentValue = target[key]
const rawCurrentValue = toRaw(currentValue)
if (isPlainObject(rawCurrentValue) && isPlainObject(value)) {
syncModel(currentValue as GenericObject, value as GenericObject)
return
}
if (!isEqual(rawCurrentValue, value)) {
target[key] = value
}
})
}
function scrollToField(name: Path<TValues> | string) {
const el = fieldElements.get(String(name))
if (!el) {
return false
}
el.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
focusFieldControl(el)
return true
}
function focusFieldControl(el: HTMLElement) {
const focusable = el.querySelector<HTMLElement>(
'[data-slot="form-control"], input, textarea, select, button, [tabindex]:not([tabindex="-1"])',
)
focusable?.focus?.({ preventScroll: true })
}
async function handleInvalidSubmit(ctx: InvalidSubmissionContext<TValues, TOutput>) {
emit('invalidSubmit', ctx)
if (!props.scrollToError) {
return
}
await nextTick()
const [firstFieldName] = Object.keys(ctx.errors)
if (firstFieldName) {
scrollToField(firstFieldName)
}
}
const submitForm = form.handleSubmit(
(values, ctx) => emit('submit', values, ctx),
ctx => void handleInvalidSubmit(ctx),
)
function submit() {
return submitForm()
}
defineExpose<FormExpose<TValues, TOutput>>({
submit,
validate,
resetFields,
clearValidate,
setFieldValue,
setFieldError,
scrollToField,
})
</script>
<template>
<component
:is="props.as"
data-slot="form"
:class="rootClass"
@submit.prevent="submitForm"
>
<slot v-bind="slotProps" />
</component>
</template>
@@ -95,11 +95,10 @@ function isOptionChecked(option: TOption) {
"
>
<RadioGroupItem
v-show="!slots.option"
:id="getOptionId(option, index)"
:value="option.value"
:disabled="isOptionDisabled(option)"
:class="props.itemClass"
:class="cn(props.itemClass, slots.option && 'hidden')"
/>
<Label
:for="getOptionId(option, index)"
+1
View File
@@ -12,6 +12,7 @@ export { default as drawer } from './basic/drawer/_examples'
export { default as dropdown } from './basic/dropdown/_examples'
export { default as fileUpload } from './basic/file-upload/_examples'
export { default as fixedBar } from './basic/fixed-bar/_examples'
export { default as form } from './basic/form/_examples'
export { default as hoverCard } from './basic/hover-card/_examples'
export { default as icon } from './basic/icon/_examples'
export { default as imagePreview } from './basic/image-preview/_examples'
+2
View File
@@ -17,6 +17,8 @@ export { Dropdown as FaDropdown } from './basic/dropdown'
export { FileUpload as FaFileUpload } from './basic/file-upload'
export type { FileItem, FileUploadRequestOptions } from './basic/file-upload'
export { FixedBar as FaFixedBar } from './basic/fixed-bar'
export { Form as FaForm, FormItem as FaFormItem } from './basic/form'
export type { FormEmits, FormExpose, FormItemProps, FormItemSlotProps, FormLabelPlacement, FormProps, FormSlotProps } from './basic/form'
export { HoverCard as FaHoverCard } from './basic/hover-card'
export { Icon as FaIcon } from './basic/icon'
export { ImagePreview as FaImagePreview } from './basic/image-preview'