mirror of
https://github.com/fantastic-admin/basic.git
synced 2026-10-07 16:28:49 +08:00
feat: 新增 FaForm 表单组件及示例,并更新登录注册相关页面
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"filesize": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"tailwind-merge": "catalog:",
|
||||
"vee-validate": "catalog:",
|
||||
"vue-input-otp": "catalog:",
|
||||
"vue-sonner": "catalog:"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user