feat(frontend): Add animations for form elements

This commit is contained in:
2026-02-15 12:23:30 +01:00
parent 05b89778cf
commit a931275d09
3 changed files with 177 additions and 123 deletions

View File

@@ -49,7 +49,11 @@
/* Global focus styles for elements without built-in focus styling. /* Global focus styles for elements without built-in focus styling.
Nuxt UI components (inputs, buttons, selects, etc.) have their own Nuxt UI components (inputs, buttons, selects, etc.) have their own
focus ring styling, so we exclude them to avoid double borders. */ focus ring styling, so we exclude them to avoid double borders. */
:focus-visible:not(input):not(textarea):not(select):not(button):not([role="combobox"]):not([role="listbox"]):not([role="option"]):not([role="checkbox"]):not([role="radio"]):not([role="switch"]):not([role="slider"]):not([role="tab"]):not([role="menuitem"]) { :focus-visible:not(input):not(textarea):not(select):not(button):not([role='combobox']):not([role='listbox']):not(
[role='option']
):not([role='checkbox']):not([role='radio']):not([role='switch']):not([role='slider']):not([role='tab']):not(
[role='menuitem']
) {
outline: 2px solid var(--color-teal-500); outline: 2px solid var(--color-teal-500);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -95,3 +99,38 @@
.dark .nav-separator { .dark .nav-separator {
background: linear-gradient(90deg, transparent, var(--color-gray-700), transparent); background: linear-gradient(90deg, transparent, var(--color-gray-700), transparent);
} }
/* ============================================
FORM ELEMENT VISIBILITY TRANSITIONS
============================================ */
/* Subtle fade + slide animation for form elements appearing/disappearing */
.form-element-enter-active {
transition:
opacity 200ms ease-out,
transform 200ms ease-out;
}
.form-element-leave-active {
transition:
opacity 200ms ease-out,
transform 200ms ease-out;
/* Take leaving elements out of flow so remaining elements reposition smoothly */
position: absolute;
width: 100%;
}
.form-element-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.form-element-leave-to {
opacity: 0;
transform: translateY(8px);
}
/* Smooth repositioning when elements are added/removed */
.form-element-move {
transition: transform 200ms ease-out;
}

View File

@@ -1,102 +1,107 @@
<template> <template>
<template <TransitionGroup name="form-element" tag="div" class="relative" @before-enter="applyCascadingEntryDelay">
v-for="(formElementItem, visibleIndex) in visibleFormElements" <div
:key="getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)" v-for="(formElementItem, visibleIndex) in visibleFormElements"
> :key="getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)"
<div class="group flex py-3 lg:py-4"> :data-index="visibleIndex"
<div class="flex-auto min-w-0"> >
<p <div class="group flex py-3 lg:py-4">
v-if="formElementItem.formElement.title" <div class="flex-auto min-w-0">
:class="['font-semibold', formElementItem.formElement.description ? '' : 'pb-3']" <p
> v-if="formElementItem.formElement.title"
{{ formElementItem.formElement.title }} :class="['font-semibold', formElementItem.formElement.description ? '' : 'pb-3']"
</p>
<p v-if="formElementItem.formElement.description" class="text-dimmed pb-3">
{{ formElementItem.formElement.description }}
</p>
<component
:is="getResolvedComponent(formElementItem.formElement)"
:form-options="formElementItem.formElement.options"
:disabled="props.disabled"
:all-form-elements="props.allFormElements"
:table-row-preset="formElementItem.formElement.tableRowPreset"
:application-form-id="props.applicationFormId"
:form-element-reference="formElementItem.formElement.reference"
@update:form-options="updateFormOptions($event, formElementItem)"
/>
<div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
<UButton
variant="outline"
size="sm"
leading-icon="i-lucide-copy-plus"
@click="handleCloneElement(formElementItem.formElement, formElementItem.indexInSubsection)"
> >
{{ $t('applicationForms.formElements.addAnother') }} {{ formElementItem.formElement.title }}
</UButton> </p>
</div> <p v-if="formElementItem.formElement.description" class="text-dimmed pb-3">
<TheComment {{ formElementItem.formElement.description }}
v-if=" </p>
applicationFormId && formElementItem.formElement.id && activeFormElement === formElementItem.formElement.id <component
" :is="getResolvedComponent(formElementItem.formElement)"
:form-element-id="formElementItem.formElement.id" :form-options="formElementItem.formElement.options"
:application-form-id="applicationFormId" :disabled="props.disabled"
:comments="comments?.[formElementItem.formElement.id]" :all-form-elements="props.allFormElements"
:total-count=" :table-row-preset="formElementItem.formElement.tableRowPreset"
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0 :application-form-id="props.applicationFormId"
" :form-element-reference="formElementItem.formElement.reference"
@close="activeFormElement = ''" @update:form-options="updateFormOptions($event, formElementItem)"
/> />
</div> <div v-if="formElementItem.formElement.isClonable && !props.disabled" class="mt-3">
<div class="flex items-start gap-1"> <UButton
<div class="min-w-9"> variant="outline"
<UButton size="sm"
leading-icon="i-lucide-copy-plus"
@click="handleCloneElement(formElementItem.formElement, formElementItem.indexInSubsection)"
>
{{ $t('applicationForms.formElements.addAnother') }}
</UButton>
</div>
<TheComment
v-if=" v-if="
applicationFormId && applicationFormId &&
formElementItem.formElement.id && formElementItem.formElement.id &&
(commentCounts?.[formElementItem.formElement.id] ?? 0) > 0 activeFormElement === formElementItem.formElement.id
" "
color="neutral" :form-element-id="formElementItem.formElement.id"
variant="soft" :application-form-id="applicationFormId"
size="xs" :comments="comments?.[formElementItem.formElement.id]"
icon="i-lucide-message-square" :total-count="
class="w-full justify-center" commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
@click="toggleComments(formElementItem.formElement.id)" "
> @close="activeFormElement = ''"
{{ commentCounts?.[formElementItem.formElement.id] ?? 0 }} />
</UButton>
</div> </div>
<div <div class="flex items-start gap-1">
:class="[ <div class="min-w-9">
'transition-opacity duration-200', <UButton
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection) v-if="
? 'opacity-100' applicationFormId &&
: 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100' formElementItem.formElement.id &&
]" (commentCounts?.[formElementItem.formElement.id] ?? 0) > 0
> "
<UDropdownMenu color="neutral"
:items=" variant="soft"
getDropdownItems( size="xs"
formElementItem.formElement, icon="i-lucide-message-square"
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection), class="w-full justify-center"
formElementItem.indexInSubsection @click="toggleComments(formElementItem.formElement.id)"
) >
" {{ commentCounts?.[formElementItem.formElement.id] ?? 0 }}
:content="{ align: 'end' }" </UButton>
@update:open=" </div>
(isOpen: boolean) => <div
handleDropdownToggle( :class="[
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection), 'transition-opacity duration-200',
isOpen openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
) ? 'opacity-100'
" : 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
]"
> >
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" /> <UDropdownMenu
</UDropdownMenu> :items="
getDropdownItems(
formElementItem.formElement,
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
formElementItem.indexInSubsection
)
"
:content="{ align: 'end' }"
@update:open="
(isOpen: boolean) =>
handleDropdownToggle(
getElementKey(formElementItem.formElement, formElementItem.indexInSubsection),
isOpen
)
"
>
<UButton icon="i-lucide-ellipsis-vertical" color="neutral" variant="ghost" />
</UDropdownMenu>
</div>
</div> </div>
</div> </div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
</div> </div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" /> </TransitionGroup>
</template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -245,4 +250,9 @@ async function toggleComments(formElementId: string) {
function handleCloneElement(formElement: FormElementDto, position: number) { function handleCloneElement(formElement: FormElementDto, position: number) {
emit('clone:element', formElement, position) emit('clone:element', formElement, position)
} }
function applyCascadingEntryDelay(el: Element) {
const index = parseInt((el as HTMLElement).dataset.index ?? '0', 10)
;(el as HTMLElement).style.transitionDelay = `${index * 30}ms`
}
</script> </script>

View File

@@ -57,40 +57,45 @@
{{ currentFormElementSection.title }} {{ currentFormElementSection.title }}
</h1> </h1>
<UCard <TransitionGroup name="form-element" tag="div" class="relative">
v-for="{ subsection, sectionIndex } in visibleSubsections" <UCard
:key="getSubsectionKey(currentFormElementSection, sectionIndex, subsection)" v-for="{ subsection, sectionIndex } in visibleSubsections"
variant="subtle" :key="getSubsectionKey(currentFormElementSection, sectionIndex, subsection)"
class="mb-6" variant="subtle"
> class="mb-6"
<div class="mb-4"> >
<h2 class="text-lg font-semibold text-highlighted">{{ subsection.title }}</h2> <div class="mb-4">
<p v-if="subsection.subtitle" class="text-sm text-dimmed">{{ subsection.subtitle }}</p> <h2 class="text-lg font-semibold text-highlighted">{{ subsection.title }}</h2>
</div> <p v-if="subsection.subtitle" class="text-sm text-dimmed">{{ subsection.subtitle }}</p>
<FormEngine </div>
:model-value="subsection.formElements" <FormEngine
:visibility-map="visibilityMap" :model-value="subsection.formElements"
:application-form-id="applicationFormId" :visibility-map="visibilityMap"
:disabled="disabled" :application-form-id="applicationFormId"
:all-form-elements="allFormElements" :disabled="disabled"
@update:model-value=" :all-form-elements="allFormElements"
(elements) => @update:model-value="
handleFormElementUpdate(elements, getSubsectionKey(currentFormElementSection, sectionIndex, subsection)) (elements) =>
" handleFormElementUpdate(
@add:input-form=" elements,
(position) => getSubsectionKey(currentFormElementSection, sectionIndex, subsection)
handleAddInputForm(position, getSubsectionKey(currentFormElementSection, sectionIndex, subsection)) )
" "
@clone:element=" @add:input-form="
(element, position) => (position) =>
handleCloneElement( handleAddInputForm(position, getSubsectionKey(currentFormElementSection, sectionIndex, subsection))
element, "
position, @clone:element="
getSubsectionKey(currentFormElementSection, sectionIndex, subsection) (element, position) =>
) handleCloneElement(
" element,
/> position,
</UCard> getSubsectionKey(currentFormElementSection, sectionIndex, subsection)
)
"
/>
</UCard>
</TransitionGroup>
<UCard v-if="visibleSubsections.length === 0" variant="subtle" class="mb-6"> <UCard v-if="visibleSubsections.length === 0" variant="subtle" class="mb-6">
<div class="text-center py-8 text-dimmed"> <div class="text-center py-8 text-dimmed">