feat(frontend): Add animations for form elements
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user