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.
Nuxt UI components (inputs, buttons, selects, etc.) have their own
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-offset: 2px;
}
@@ -95,3 +99,38 @@
.dark .nav-separator {
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
v-for="(formElementItem, visibleIndex) in visibleFormElements"
:key="getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)"
>
<div class="group flex py-3 lg:py-4">
<div class="flex-auto min-w-0">
<p
v-if="formElementItem.formElement.title"
:class="['font-semibold', formElementItem.formElement.description ? '' : 'pb-3']"
>
{{ formElementItem.formElement.title }}
</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)"
<TransitionGroup name="form-element" tag="div" class="relative" @before-enter="applyCascadingEntryDelay">
<div
v-for="(formElementItem, visibleIndex) in visibleFormElements"
:key="getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)"
:data-index="visibleIndex"
>
<div class="group flex py-3 lg:py-4">
<div class="flex-auto min-w-0">
<p
v-if="formElementItem.formElement.title"
:class="['font-semibold', formElementItem.formElement.description ? '' : 'pb-3']"
>
{{ $t('applicationForms.formElements.addAnother') }}
</UButton>
</div>
<TheComment
v-if="
applicationFormId && formElementItem.formElement.id && activeFormElement === formElementItem.formElement.id
"
:form-element-id="formElementItem.formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElementItem.formElement.id]"
:total-count="
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
"
@close="activeFormElement = ''"
/>
</div>
<div class="flex items-start gap-1">
<div class="min-w-9">
<UButton
{{ formElementItem.formElement.title }}
</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') }}
</UButton>
</div>
<TheComment
v-if="
applicationFormId &&
formElementItem.formElement.id &&
(commentCounts?.[formElementItem.formElement.id] ?? 0) > 0
activeFormElement === formElementItem.formElement.id
"
color="neutral"
variant="soft"
size="xs"
icon="i-lucide-message-square"
class="w-full justify-center"
@click="toggleComments(formElementItem.formElement.id)"
>
{{ commentCounts?.[formElementItem.formElement.id] ?? 0 }}
</UButton>
:form-element-id="formElementItem.formElement.id"
:application-form-id="applicationFormId"
:comments="comments?.[formElementItem.formElement.id]"
:total-count="
commentCounts?.[formElementItem.formElement.id] ?? comments?.[formElementItem.formElement.id]?.length ?? 0
"
@close="activeFormElement = ''"
/>
</div>
<div
:class="[
'transition-opacity duration-200',
openDropdownId === getElementKey(formElementItem.formElement, formElementItem.indexInSubsection)
? 'opacity-100'
: 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-within:opacity-100'
]"
>
<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
)
"
<div class="flex items-start gap-1">
<div class="min-w-9">
<UButton
v-if="
applicationFormId &&
formElementItem.formElement.id &&
(commentCounts?.[formElementItem.formElement.id] ?? 0) > 0
"
color="neutral"
variant="soft"
size="xs"
icon="i-lucide-message-square"
class="w-full justify-center"
@click="toggleComments(formElementItem.formElement.id)"
>
{{ commentCounts?.[formElementItem.formElement.id] ?? 0 }}
</UButton>
</div>
<div
:class="[
'transition-opacity duration-200',
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>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
</div>
<USeparator v-if="visibleIndex < visibleFormElements.length - 1" />
</template>
</TransitionGroup>
</template>
<script setup lang="ts">
@@ -245,4 +250,9 @@ async function toggleComments(formElementId: string) {
function handleCloneElement(formElement: FormElementDto, position: number) {
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>

View File

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