VBsbForm Component Documentation
Overview
The VBsbForm
component is a dynamic and flexible form generator built using Vuetify components. It is designed to handle a wide variety of form inputs and actions, making it suitable for complex forms that can be customized through props like fields
, options
, and actions
. This component dynamically generates form elements based on the configuration passed via props and supports validation, submit, and cancel actions.
Features
Automatic Behaviors
- First Field Auto-Focus: Automatically focuses the first enabled and visible field when the form loads
- Enter Key Submission: Pressing Enter on the last field triggers form submission
- Password Visibility Toggle: Password fields include a show/hide toggle button
- Data Synchronization: Automatically syncs form field values with the provided data prop
- Translation Support: Built-in support for internationalization of labels, placeholders, and messages
- Default Actions: Pre-configured submit, cancel, and validate actions with customizable appearance
Form Validation
- Validates on invalid input by default
- Supports extensive validation rules
- Provides field-level and form-level error handling
- Real-time validation feedback
Usage Examples
Basic Example
vue
<template>
<VBsbForm
:data="formData"
:options="formOptions"
@submit="onSubmit"
@cancel="onCancel"
@validate="onValidate"
/>
</template>
<script setup lang="ts">
const formData = {
username: '',
email: '',
password: '',
}
const formOptions = {
cols: 2,
fields: [
{ name: 'username', type: 'text', label: 'Username', required: true },
{ name: 'email', type: 'email', label: 'Email', required: true },
{ name: 'password', type: 'password', label: 'Password', required: true },
],
actions: [
{ type: 'submit', title: 'Submit', color: 'success' },
{ type: 'cancel', title: 'Cancel', color: 'error', variant: 'outlined' },
],
}
const onSubmit = (data: any) => {
console.log('Form submitted with data:', data)
}
const onCancel = () => {
console.log('Form cancelled')
}
const onValidate = ({ valid, errors }: { valid: boolean; errors: any[] }) => {
console.log('Form validation result:', valid, errors)
}
</script>
Example with Complex Validation and Custom Actions
vue
<template>
<VBsbForm :data="formData" :options="formOptions" @submit="onSubmit" />
</template>
<script setup lang="ts">
const formData = {
username: '',
email: '',
password: '',
confirmPassword: '',
}
const formOptions = {
fields: [
{
name: 'username',
type: 'text',
label: 'Username',
required: true,
rules: [
{ type: 'min-length', value: 3, message: 'Username must be at least 3 characters long' },
],
},
{
name: 'email',
type: 'email',
label: 'Email',
required: true,
rules: [{ type: 'email', message: 'Email is not valid' }],
},
{
name: 'password',
type: 'password',
label: 'Password',
required: true,
rules: [
{
type: 'password',
message: 'Password must be at least 8 characters with letters and numbers',
},
],
},
{
name: 'confirmPassword',
type: 'password',
label: 'Confirm Password',
required: true,
rules: [{ type: 'same-as', value: 'password', message: 'Passwords must match' }],
},
],
actions: [
{ type: 'submit', title: 'Create Account', color: 'success', icon: '$mdiAccountPlus' },
{ type: 'validate', title: 'Check Form', color: 'info', variant: 'outlined' },
{ type: 'cancel', title: 'Reset', color: 'error', variant: 'text' },
],
actionsAlign: 'right',
actionsClass: 'mt-4',
}
</script>
API
Props
Prop | Type | Required | Default | Description |
---|---|---|---|---|
data | Object | No | {} | Initial form data object. This object is reactive and updated as the form inputs change. |
options | BsbFormOptions | Yes | {} | Configuration object that defines the fields, actions, and settings for the form. |
BsbFormOptions
Property | Type | Required | Default | Description |
---|---|---|---|---|
fields | Array<BsbFormField> | Yes | [] | Defines the fields to be rendered in the form. |
cols | number | No | 1 | Number of columns the form should span. |
variant | string | No | outlined | The visual variant of form fields (e.g., outlined , filled , etc.). |
density | 'default' | 'comfortable' | 'compact' | No | 'default' | Density of the form fields (spacing and padding). |
actions | Array<BsbFormAction> | No | [] | Defines the action buttons that are rendered below the form fields. |
actionsAlign | 'left' | 'right' | No | 'left' | Alignment of action buttons. |
actionsClass | string | No | '' | CSS class to apply to the actions container. |
errors | Array<BsbFormError> | No | [] | Array of form-level errors. This is used to display error messages at the field level. |
BsbFormField
Property | Type | Required | Default | Description |
---|---|---|---|---|
type | 'text' | 'number' | 'password' | 'email' | 'textarea' | 'switch' | 'rating' | 'checkbox' | 'select' | 'combobox' | 'autocomplete' | 'file' | 'date' | 'time' | 'datetime' | Yes | text | The type of form input to render. |
name | string | Yes | '' | The unique name identifier for the field. |
value | any | No | '' | The current value of the field. |
label | string | No | '' | Label for the form field. Supports translation. |
placeholder | string | No | '' | Placeholder text for the field. Supports translation. |
required | boolean | No | false | Whether the field is required. |
readonly | boolean | No | false | Whether the field is read-only. |
hidden | boolean | No | false | Whether the field is hidden. |
disabled | boolean | No | false | Whether the field is disabled. |
clearable | boolean | No | false | Whether the field has a clear button. |
prefix | string | No | '' | Prefix text or icon for the field. |
suffix | string | No | '' | Suffix text or icon for the field. |
variant | 'outlined' | 'filled' | 'underlined' | 'solo' | 'solo-inverted' | 'solo-filled' | 'plain' | No | 'outlined' | The visual variant of the input field. |
density | 'default' | 'comfortable' | 'compact' | No | 'default' | Density of the field. |
color | string | No | undefined | Color of the field when active. |
hint | string | No | '' | Hint text to display below the field. Supports translation. |
prependIcon | string | No | undefined | Icon to display before the field. |
appendIcon | string | No | undefined | Icon to display after the field. |
prependInnerIcon | string | No | undefined | Icon to display inside the field before the content. |
appendInnerIcon | string | No | undefined | Icon to display inside the field after the content. For password fields, this shows the visibility toggle. |
rules | Array<BsbFormValidationRule> | No | [] | Array of validation rules applied to the field. |
errors | string[] | No | [] | Array of error messages to display for this field. |
rows | number | No | 5 | Number of rows for textarea fields. |
counter | boolean | number | string | No | undefined | Character counter for textarea fields. |
noResize | boolean | No | false | Prevents textarea resizing. |
autoGrow | boolean | No | false | Enables textarea auto-growing. |
length | number | No | 5 | Number of stars for rating fields. |
size | number | No | 24 | Size of rating stars in pixels. |
items | string[] | No | [] | Array of items for select, combobox, and autocomplete fields. |
chips | boolean | No | false | Display selected items as chips. |
multiple | boolean | No | false | Allow multiple selections. |
BsbFormAction
Property | Type | Required | Default | Description |
---|---|---|---|---|
type | 'submit' | 'cancel' | 'validate' | Yes | submit | The type of action button. Determines its functionality (e.g., submitting the form). |
title | string | No | '' | The text label of the action button. Supports translation. |
icon | string | No | '' | Icon name to show on the button. |
color | string | No | 'primary' | The color of the action button. |
variant | 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain' | No | 'text' | The visual style of the action button. |
density | 'default' | 'comfortable' | 'compact' | No | 'default' | Density of the action button (spacing and padding). |
BsbFormValidationRule
Property | Type | Required | Default | Description |
---|---|---|---|---|
type | 'required' | 'min-length' | 'max-length' | 'equals' | 'equals-not' | 'starts-with' | 'ends-with' | 'greater-than' | 'less-than' | 'in-range' | 'set' | 'password' | 'email' | 'url' | 'ip' | 'regexp' | 'same-as' | 'is-json' | 'custom' | Yes | required | The type of validation rule applied to the form field. |
value | any | No | null | The value associated with the validation rule (e.g., minimum length, comparison value). |
message | string | Yes | '' | The validation error message to show if the rule is violated. Supports translation. |
Events
Event | Payload | Description |
---|---|---|
submit | data: Record<string, any> | Emitted when the form is successfully validated and submitted. |
cancel | void | Emitted when the form's cancel action is triggered. |
validate | { valid: boolean, errors: any[] } | Emitted after form validation occurs, providing whether the form is valid and any errors. |
action | { action: string, data: Record<string, any> } | Emitted when a custom action is triggered. |
Form Behavior
Validation
- The form validates on invalid input by default (
validate-on="invalid-input"
) - Validation is performed when:
- A field value changes
- The form is submitted
- The validate action is triggered
- Field-level validation uses the rules defined in the field configuration
- Form-level validation aggregates all field validations
Data Synchronization
- The form watches for changes in the
data
prop and updates field values accordingly - When form values change, the parent component is updated through events
- The
refresh()
method is called internally to sync data and field values
Focus Management
- On mount, the first enabled and visible field is automatically focused
- Enter key on the last field triggers form submission
- Password fields include a visibility toggle button
Default Actions
The form comes with three pre-configured actions:
- Submit (
type: 'submit'
)- Icon: check mark
- Color: success
- Triggers form validation and submission
- Cancel (
type: 'cancel'
)- Icon: close
- Color: error
- Variant: outlined
- Resets form to initial values
- Validate (
type: 'validate'
)- Icon: check mark
- Color: info
- Variant: outlined
- Triggers form validation
These default actions can be:
- Used directly by specifying action types as strings
- Customized by providing full action objects
- Mixed with custom actions
- Reordered or subset selected
Internationalization
The component supports translation through the t()
function for:
- Field labels
- Placeholders
- Hints
- Validation messages
- Action button texts
- Error messages
Source
Component 'components/VBsbForm.vue
' (see below for file content)
vue
<template>
<v-form
:v-model="valid"
:data="data"
:options="options"
ref="form"
@submit.prevent
validate-on="invalid-input"
>
<v-row>
<v-col v-for="field in options.fields" :key="field.name" :cols="12 / (options.cols || 1)">
<v-defaults-provider
:defaults="{ VTextField: { variant: options.variant, density: options.density } }"
>
<v-text-field
v-if="
field.type == 'text' ||
field.type == 'number' ||
field.type == 'password' ||
field.type == 'email' ||
field.type == 'date' ||
field.type == 'time' ||
field.type == 'datetime'
"
:id="field.name"
:type="
field.type == 'password'
? showPwd
? 'text'
: 'password'
: field.type == 'datetime'
? 'datetime-local'
: field.type
"
v-model="field.value"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:placeholder="field.placeholder && t(field.placeholder)"
:clearable="field.clearable"
:prefix="field.prefix"
:suffix="field.suffix"
:required="field.required"
:readonly="field.readonly"
:hidden="field.hidden"
:disabled="field.disabled"
:variant="field.variant"
:density="options.density"
:color="field.color"
:hint="field.hint && t(field.hint)"
:prepend-icon="field.prependIcon"
:prepend-inner-icon="field.prependInnerIcon"
:append-icon="field.appendIcon"
:append-inner-icon="
field.appendInnerIcon
? field.appendInnerIcon
: field.type == 'password'
? showPwd
? '$mdiEye'
: '$mdiEyeOff'
: ''
"
@click:append-inner="showPwd = field.type == 'password' ? !showPwd : showPwd"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
/>
<v-textarea
v-if="field.type == 'textarea'"
:id="field.name"
v-model="field.value"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:placeholder="field.placeholder && t(field.placeholder)"
:clearable="field.clearable"
:prefix="field.prefix"
:suffix="field.suffix"
:required="field.required"
:readonly="field.readonly"
:hidden="field.hidden"
:disabled="field.disabled"
:variant="field.variant"
:density="options.density"
:color="field.color"
:hint="field.hint && t(field.hint)"
:prepend-icon="field.prependIcon"
:prepend-inner-icon="field.prependInnerIcon"
:append-icon="field.appendIcon"
:append-inner-icon="field.appendInnerIcon"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
:rows="field.rows || 5"
:counter="field.counter"
:no-resize="field.noResize"
:auto-grow="field.autoGrow"
/>
<v-switch
v-if="field.type == 'switch'"
:id="field.name"
v-model="field.value"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:required="field.required"
:readonly="field.readonly"
:disabled="field.disabled"
:color="field.color || 'primary'"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
/>
<v-label class="d-block" v-if="field.type == 'rating'" :for="field.name">{{
t(field.label || '')
}}</v-label>
<v-rating
v-if="field.type == 'rating'"
:color="field.color || 'primary'"
:id="field.name"
v-model="field.value as number"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:required="field.required"
:readonly="field.readonly"
:disabled="field.disabled"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
:length="field.length || 5"
:size="field.size || 24"
/>
<v-checkbox
v-if="field.type == 'checkbox'"
:id="field.name"
v-model="field.value"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:required="field.required"
:readonly="field.readonly"
:disabled="field.disabled"
:color="field.color || 'primary'"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
/>
<v-select
v-if="field.type == 'select'"
:id="field.name"
v-model="field.value as string"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:required="field.required"
:readonly="field.readonly"
:disabled="field.disabled"
:variant="field.variant"
:density="options.density"
:color="field.color"
:hint="field.hint && t(field.hint)"
:items="field.items || []"
:multiple="field.multiple || false"
:chips="field.chips || false"
:clearable="field.clearable || true"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
/>
<v-combobox
v-if="field.type == 'combobox'"
:id="field.name"
v-model="field.value as string"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:required="field.required"
:readonly="field.readonly"
:disabled="field.disabled"
:variant="field.variant"
:density="options.density"
:color="field.color"
:hint="field.hint && t(field.hint)"
:items="field.items || []"
:multiple="field.multiple || false"
:chips="field.chips || false"
:clearable="field.clearable || true"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
/>
<v-autocomplete
v-if="field.type == 'autocomplete'"
:id="field.name"
v-model="field.value as string"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:required="field.required"
:readonly="field.readonly"
:disabled="field.disabled"
:variant="field.variant"
:density="options.density"
:color="field.color"
:hint="field.hint && t(field.hint)"
:items="field.items || []"
:multiple="field.multiple || false"
:chips="field.chips || false"
:clearable="field.clearable || true"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
/>
<v-file-input
v-if="field.type == 'file'"
:id="field.name"
v-model="field.value as File | File[] | null"
v-show="!field.hidden"
:label="field.label && t(field.label)"
:required="field.required"
:readonly="field.readonly"
:disabled="field.disabled"
:variant="field.variant"
:density="options.density"
:color="field.color"
:hint="field.hint && t(field.hint)"
:multiple="field.multiple || false"
:chips="field.chips || false"
:clearable="field.clearable || true"
:prepend-icon="field.prependIcon"
:prepend-inner-icon="field.prependInnerIcon"
:rules="rules(field.rules)"
:error-messages="errorMessages(field.name)"
/>
</v-defaults-provider>
</v-col>
</v-row>
<v-row v-if="options.actions">
<v-col :class="options.actionsAlign == 'right' ? 'text-right' : ''">
<v-btn
v-for="action in options.actions"
:key="action.type"
@click="onAction(action.type as string)"
:color="action.color"
:prepend-icon="action.icon"
:variant="action.variant"
:density="action.density"
:class="options.actionsClass"
>{{ t(action.title || '') }}</v-btn
>
</v-col>
</v-row>
</v-form>
</template>
<script setup lang="ts">
export type BsbFormValidationRule = {
type:
| 'required'
| 'min-length'
| 'max-length'
| 'equals'
| 'equals-not'
| 'starts-with'
| 'ends-with'
| 'greater-than'
| 'less-than'
| 'in-range'
| 'set'
| 'password'
| 'email'
| 'url'
| 'ip'
| 'regexp'
| 'same-as'
| 'is-json'
| 'custom'
value?: unknown
message: string
}
export type BsbFormField = {
type:
| 'text'
| 'number'
| 'password'
| 'email'
| 'textarea'
| 'switch'
| 'rating'
| 'checkbox'
| 'select'
| 'combobox'
| 'autocomplete'
| 'file'
| 'date'
| 'time'
| 'datetime'
name: string
value?: unknown
label?: string
required?: boolean
readonly?: boolean
hidden?: boolean
disabled?: boolean
placeholder?: string
clearable?: boolean
prefix?: string
suffix?: string
variant?:
| 'underlined'
| 'outlined'
| 'filled'
| 'solo'
| 'solo-inverted'
| 'solo-filled'
| 'plain'
density?: 'default' | 'comfortable' | 'compact'
color?: string
hint?: string
prependIcon?: string
appendIcon?: string
prependInnerIcon?: string
appendInnerIcon?: string
rules?: Array<BsbFormValidationRule>
errors?: string[]
//textarea
rows?: number
counter?: string | number | true | undefined
noResize?: boolean
autoGrow?: boolean
//rating
length?: number
size?: number
//select, combobox, autocomplete
items?: string[]
chips?: boolean
multiple?: boolean
}
export type BsbFormAction = {
type: 'submit' | 'cancel' | 'validate'
title?: string
icon?: string
color?: string
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
density?: 'default' | 'comfortable' | 'compact'
}
export type BsbFormError = {
name?: string
message?: string
errors?: string[]
}
export type BsbFormOptions = {
fields: Array<BsbFormField>
cols?: number
variant?:
| 'underlined'
| 'outlined'
| 'filled'
| 'solo'
| 'solo-inverted'
| 'solo-filled'
| 'plain'
density?: 'default' | 'comfortable' | 'compact'
actions?: Array<BsbFormAction> | []
actionsAlign?: 'left' | 'right'
actionsClass?: string
errors: Array<BsbFormError>
api?: string
}
const props = defineProps({
data: {
type: Object as PropType<Record<string, unknown>>,
default: () => ({ cols: 1 }),
required: false,
},
options: {
type: Object as PropType<BsbFormOptions>,
default: () => ({ fields: [] }),
required: true,
},
})
const emits = defineEmits(['submit', 'cancel', 'validate', 'action'])
const http = props.options.api ? useHttp({ baseURL: props.options.api }) : undefined
const { t, locale } = useI18n()
watch(
() => locale.value,
async () => {
await validate()
},
)
const data = ref(props.data)
watch(
() => props.data,
async (newData: Record<string, unknown>) => {
data.value = newData
await refresh()
},
)
const options = ref(props.options)
watch(
() => props.options,
(newOptions: BsbFormOptions) => {
options.value = newOptions
},
)
import { VForm } from 'vuetify/components'
const form = ref<InstanceType<typeof VForm> | null>(null)
const valid = ref(false)
const showPwd = ref(false)
const defaultActions: Array<BsbFormAction> = [
{
type: 'submit',
title: 'Submit',
icon: '$mdiCheck',
color: 'success',
},
{
type: 'cancel',
title: 'Cancel',
icon: '$mdiClose',
color: 'error',
variant: 'outlined',
},
{
type: 'validate',
title: 'Validate',
icon: '$mdiCheck',
color: 'info',
variant: 'outlined',
},
]
function rules(r?: BsbFormValidationRule[]): (() => boolean | string)[] {
function isValidJson(str: string) {
try {
const result = JSON.parse(str)
const type = Object.prototype.toString.call(result)
return type === '[object Object]' || type === '[object Array]'
} catch {
return false
}
}
const result: (() => boolean | string)[] = []
r?.map((rule) => {
let f: (v: string) => boolean | string
switch (rule.type) {
case 'required':
f = (v: string) => !!v || t(rule.message)
break
case 'min-length':
f = (v: string) =>
v.length >= Number(rule.value) ||
t(rule.message).replace('{{ value }}', String(rule.value))
break
case 'max-length':
f = (v: string) =>
v.length <= Number(rule.value) ||
t(rule.message).replace('{{ value }}', String(rule.value))
break
case 'equals':
f = (v: string) => v === rule.value || t(rule.message)
break
case 'equals-not':
f = (v: string) => v !== rule.value || t(rule.message)
break
case 'starts-with':
f = (v: string) => v.startsWith(String(rule.value)) || t(rule.message)
break
case 'ends-with':
f = (v: string) => v.endsWith(String(rule.value)) || t(rule.message)
break
case 'greater-than':
f = (v: string) => Number(v) > Number(rule.value) || t(rule.message)
break
case 'less-than':
f = (v: string) => Number(v) < Number(rule.value) || t(rule.message)
break
case 'in-range':
f = (v: string) => {
const [min, max] = rule.value as [number, number]
return (Number(v) >= min && Number(v) <= max) || t(rule.message)
}
break
case 'set':
f = (v: string) => (Array.isArray(rule.value) && rule.value.includes(v)) || t(rule.message)
break
case 'password':
f = (v: string) => /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(v) || t(rule.message)
break
case 'email':
f = (v: string) => /^[\w.-]+@([\w-]+\.)+[\w-]{2,4}$/.test(v) || t(rule.message)
break
case 'url':
f = (v: string) => /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/\S*)?$/.test(v) || t(rule.message)
break
case 'ip':
f = (v: string) =>
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
v,
) || t(rule.message)
break
case 'regexp':
f = (v: string) => new RegExp(String(rule.value)).test(v) || t(rule.message)
break
case 'same-as':
f = (v: string) =>
v == options.value.fields.find((field) => field.name == String(rule.value))?.value ||
t(rule.message)
break
case 'is-json':
f = (v: string) => isValidJson(v) || t(rule.message)
break
case 'custom':
f = (v: string) => {
const validationResult = typeof rule.value === 'function' ? rule.value(v) : true
return validationResult === true ? true : t(validationResult || rule.message)
}
break
}
result.push(f! as () => boolean | string)
})
return result
}
function errorMessages(name: string): string[] {
if (!options.value.errors) return []
return options.value.errors
.filter((error) => error.name == name)
.map((error) => t(error.message || '')) as string[]
}
const validate = async () => {
if (!form.value) return false
const result = await form.value.validate()
valid.value = result.valid
emits('validate', { valid: valid.value, errors: result.errors })
return valid.value
}
const cancel = async () => {
data.value = props.data
options.value.errors = []
refresh()
emits('cancel')
}
import { type HttpResponse } from '@/composables/http'
const submit = async () => {
if (!form.value) return false
if (await validate()) {
const newData = { ...data.value }
options.value.fields.forEach((field) => {
newData[field.name] = field.value
})
if (props.options.api) {
const response = (await http?.post('', newData)) as HttpResponse<{ errors: [BsbFormError] }>
if (response.data?.errors && response.data.errors.length > 0) {
options.value.errors = response.data.errors
valid.value = false
return false
} else {
data.value = { ...newData }
valid.value = true
options.value.errors = []
emits('submit', newData)
}
} else {
options.value.errors = []
emits('submit', newData)
}
}
}
const action = async (actionType: string) => {
const newData = { ...data.value }
options.value.fields.forEach((field) => {
newData[field.name] = field.value
})
emits('action', { action: actionType, data: newData })
}
const onAction = async (actionType: string) => {
switch (actionType) {
case 'submit':
await submit()
break
case 'cancel':
await cancel()
break
case 'validate':
await validate()
break
default:
action(actionType)
break
}
}
const refresh = async () => {
if (
Array.isArray(options.value.actions) &&
options.value.actions.every((item) => typeof item === 'string')
) {
const actions = options.value.actions as string[]
options.value.actions = defaultActions.filter((item: BsbFormAction) =>
actions.includes(item.type),
)
}
options.value.fields.forEach((field) => {
if (field.name in data.value) {
field.value = data.value[field.name]
}
})
}
const focusFirstField = () => {
const firstField = options.value.fields.find((field) => !field.hidden && !field.disabled)
if (firstField) {
const inputElement = document.getElementById(firstField.name) as HTMLInputElement | null
if (inputElement) {
inputElement.focus()
}
}
}
let lastFieldInputElement: HTMLInputElement | null = null
let enterKeyListener: ((event: KeyboardEvent) => Promise<void>) | null = null
const addEnterKeyListenerToLastField = () => {
const lastField = [...options.value.fields]
.reverse()
.find((field) => !field.hidden && !field.disabled)
if (lastField) {
lastFieldInputElement = document.getElementById(lastField.name) as HTMLInputElement | null
if (lastFieldInputElement) {
enterKeyListener = async (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
await submit()
}
}
lastFieldInputElement.addEventListener('keydown', enterKeyListener)
}
}
}
onMounted(async () => {
await refresh()
focusFirstField()
addEnterKeyListenerToLastField()
})
onBeforeUnmount(() => {
if (lastFieldInputElement && enterKeyListener) {
lastFieldInputElement.removeEventListener('keydown', enterKeyListener)
}
})
</script>
Test 'components/__tests__/VBsbForm.test.ts
' (see below for file content)
ts
// tests/VBsbForm.spec.ts
import { mount, VueWrapper } from '@vue/test-utils'
import { describe, expect, it, beforeEach } from 'vitest'
import VBsbForm from '../VBsbForm.vue'
import vuetify from '../../plugins/vuetify'
describe('VBsbForm', () => {
let wrapper: VueWrapper
const options = {
fields: [
{
name: 'username',
type: 'text' as const,
label: 'Username',
value: '',
required: true,
errors: [],
},
{
name: 'checkbox',
type: 'checkbox' as const,
label: 'Checkbox',
value: false,
required: true,
errors: [],
},
{
name: 'password',
type: 'password' as const,
label: 'Password',
value: '',
required: true,
errors: [],
},
],
actions: [
{ type: 'submit' as const, title: 'Submit', color: 'success' },
{ type: 'cancel' as const, title: 'Cancel', color: 'error', variant: 'outlined' as const },
],
errors: [],
}
const data = {
username: '',
email: '',
password: '',
}
beforeEach(() => {
wrapper = mount(VBsbForm, {
props: {
data,
options,
},
global: {
plugins: [vuetify],
},
})
})
it('renders form fields correctly', () => {
const textFields = wrapper.findAllComponents({ name: 'VTextField' })
expect(textFields).toHaveLength(2)
expect(textFields[0].props('label')).toBe('Username')
expect(textFields[1].props('label')).toBe('Password')
const passwordField = wrapper.findComponent({ name: 'VCheckbox' })
expect(passwordField.props('label')).toBe('Checkbox')
})
it('renders action buttons correctly', () => {
const buttons = wrapper.findAllComponents({ name: 'VBtn' })
expect(buttons).toHaveLength(2) // Submit, Cancel
expect(buttons[0].text()).toBe('Submit')
expect(buttons[1].text()).toBe('Cancel')
})
})