feat: registration form field display_width and option descriptions

Add configurable column widths (full/half) and optional descriptions
for radio/select/checkbox options on registration form fields.

- Migration adds display_width column to both tables
- FieldDisplayWidth enum with smart defaults per field type
- normalized_options accessor for backwards-compatible option format
- Portal form renderer uses display_width for VRow/VCol grid layout
- Radio/select/checkbox options render with descriptions
- Admin field editor supports display_width toggle and description input
- System templates updated with appropriate widths and descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 07:46:36 +02:00
parent c4a23b6763
commit 9718e27029
25 changed files with 634 additions and 84 deletions

View File

@@ -1063,7 +1063,7 @@ async function onSubmit() {
v-for="field in group.fields"
:key="field.id"
cols="12"
:md="field.field_type === 'textarea' || field.field_type === 'checkbox' ? '12' : '6'"
:md="field.display_width === 'half' ? 6 : 12"
>
<div :id="`reg-field-${field.slug}`">
<VTextField
@@ -1112,13 +1112,26 @@ async function onSubmit() {
variant="outlined"
density="comfortable"
hide-details="auto"
:items="field.options ?? []"
:items="field.normalized_options ?? []"
item-title="label"
item-value="label"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
:clearable="!field.is_required"
/>
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template
v-if="item.raw.description"
#subtitle
>
{{ item.raw.description }}
</template>
</VListItem>
</template>
</VSelect>
<VSelect
v-else-if="field.field_type === 'multiselect'"
@@ -1129,12 +1142,25 @@ async function onSubmit() {
multiple
chips
closable-chips
:items="field.options ?? []"
:items="field.normalized_options ?? []"
item-title="label"
item-value="label"
:label="field.label + (field.is_required ? ' *' : '')"
:hint="field.help_text ?? undefined"
persistent-hint
:error-messages="fieldErrors[field.slug]"
/>
>
<template #item="{ props: itemProps, item }">
<VListItem v-bind="itemProps">
<template
v-if="item.raw.description"
#subtitle
>
{{ item.raw.description }}
</template>
</VListItem>
</template>
</VSelect>
<div v-else-if="field.field_type === 'checkbox'">
<div class="text-body-2 d-block mb-1 text-high-emphasis">
@@ -1147,14 +1173,25 @@ async function onSubmit() {
{{ field.help_text }}
</p>
<VCheckbox
v-for="opt in (field.options ?? [])"
:key="opt"
:model-value="isCheckboxChecked(field.slug, opt)"
v-for="opt in (field.normalized_options ?? [])"
:key="opt.label"
:model-value="isCheckboxChecked(field.slug, opt.label)"
density="comfortable"
hide-details
:label="opt"
@update:model-value="(v: boolean | null) => toggleCheckboxOption(field.slug, opt, v)"
/>
@update:model-value="(v: boolean | null) => toggleCheckboxOption(field.slug, opt.label, v)"
>
<template #label>
<div>
<span class="text-body-1">{{ opt.label }}</span>
<p
v-if="opt.description"
class="text-body-2 text-medium-emphasis mt-1 mb-0"
>
{{ opt.description }}
</p>
</div>
</template>
</VCheckbox>
<div
v-if="fieldErrors[field.slug]"
class="text-caption text-error"
@@ -1180,13 +1217,24 @@ async function onSubmit() {
:error-messages="fieldErrors[field.slug]"
>
<VRadio
v-for="opt in (field.options ?? [])"
:key="opt"
:label="opt"
:value="opt"
v-for="opt in (field.normalized_options ?? [])"
:key="opt.label"
:value="opt.label"
density="comfortable"
hide-details
/>
>
<template #label>
<div>
<span class="text-body-1">{{ opt.label }}</span>
<p
v-if="opt.description"
class="text-body-2 text-medium-emphasis mt-1 mb-0"
>
{{ opt.description }}
</p>
</div>
</template>
</VRadio>
</VRadioGroup>
</div>

View File

@@ -9,22 +9,31 @@ export type RegistrationFieldType =
| 'number'
| 'tag_picker'
export type FieldDisplayWidth = 'full' | 'half'
export interface RegistrationTagOption {
id: string
name: string
category: string | null
}
export interface NormalizedOption {
label: string
description: string | null
}
export interface RegistrationField {
id: string
label: string
slug: string
field_type: RegistrationFieldType
options: string[] | null
options: (string | { label: string; description?: string | null })[] | null
normalized_options: NormalizedOption[] | null
tag_category: string | null
is_required: boolean
section: string | null
help_text: string | null
display_width: FieldDisplayWidth
available_tags?: RegistrationTagOption[]
}