feat: Make stepper draggable and take up more width

This commit is contained in:
2026-01-23 17:24:03 +01:00
parent 312aa0efbc
commit 24bb0f220f
4 changed files with 315 additions and 105 deletions

View File

@@ -1,9 +1,62 @@
<template>
<div class="flex flex-col w-full lg:max-w-4xl mx-auto">
<div class="flex flex-col w-full">
<div class="w-full p-4">
<div class="flex flex-col gap-4 sm:gap-6 w-full">
<slot />
<UStepper ref="stepper" v-model="activeStepperItemIndex" :items="stepperItems" class="w-full" />
<div class="lch-stepper relative">
<div
ref="stepperScrollEl"
:class="['lch-stepper-scroll overflow-x-auto overflow-y-visible scroll-smooth', cursorClass]"
>
<UStepper
ref="stepper"
v-model="activeStepperItemIndex"
:items="stepperItems"
:ui="{
header: 'flex w-full flex-nowrap',
item: 'w-auto shrink-0 flex-[0_0_calc(100%/6)] min-w-[14rem]'
}"
:linear="false"
class="w-full"
/>
</div>
<div
v-if="canScrollLeft"
class="pointer-events-none absolute inset-y-0 left-0 w-20 bg-linear-to-r from-default to-transparent"
/>
<div
v-if="canScrollRight"
class="pointer-events-none absolute inset-y-0 right-0 w-20 bg-linear-to-l from-default to-transparent"
/>
<UButton
v-if="canScrollLeft"
icon="i-lucide-chevron-left"
color="neutral"
variant="ghost"
size="md"
class="absolute left-1 top-1/2 -translate-y-1/2"
aria-label="Scroll steps left"
@click="scrollStepperBy(-1)"
/>
<UButton
v-if="canScrollRight"
icon="i-lucide-chevron-right"
color="neutral"
variant="ghost"
size="md"
class="absolute right-1 top-1/2 -translate-y-1/2"
aria-label="Scroll steps right"
@click="scrollStepperBy(1)"
/>
</div>
</div>
</div>
<div class="w-full p-4">
<div class="flex flex-col gap-4 sm:gap-6 w-full lg:max-w-4xl mx-auto">
<h1 v-if="currentFormElementSection?.title" class="text-xl text-pretty font-bold text-highlighted">
{{ currentFormElementSection.title }}
</h1>
@@ -34,7 +87,11 @@
"
@clone:element="
(element, position) =>
handleCloneElement(element, position, getSubsectionKey(currentFormElementSection, sectionIndex, subsection))
handleCloneElement(
element,
position,
getSubsectionKey(currentFormElementSection, sectionIndex, subsection)
)
"
/>
</UCard>
@@ -52,7 +109,13 @@
</UButton>
<div class="flex flex-wrap items-center gap-1.5">
<UButton trailing-icon="i-lucide-save" :disabled="disabled" variant="outline" size="lg" @click="emit('save')">
<UButton
trailing-icon="i-lucide-save"
:disabled="disabled"
variant="outline"
size="lg"
@click="emit('save')"
>
{{ $t('applicationForms.navigation.save') }}
</UButton>
<UButton
@@ -75,9 +138,12 @@
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { usePointerSwipe } from '@vueuse/core'
import type {
ApplicationFormDto,
FormElementSectionDto,
@@ -104,11 +170,39 @@ const { stepper, activeStepperItemIndex, stepperItems, currentFormElementSection
computed(() => props.formElementSections)
)
const stepperScrollEl = ref<HTMLElement | null>(null)
const initialScrollLeft = ref(0)
const initialPointerX = ref(0)
const canScrollLeft = ref(false)
const canScrollRight = ref(false)
const { evaluateFormElementVisibility } = useFormElementVisibility()
const { clearHiddenFormElementValues } = useFormElementValueClearing()
const { processSpawnTriggers } = useSectionSpawning()
const { cloneElement } = useClonableElements()
const { isSwiping } = usePointerSwipe(stepperScrollEl, {
threshold: 0,
disableTextSelect: true,
onSwipeStart: (e: PointerEvent) => {
// Capture initial scroll position and pointer position when drag starts
initialScrollLeft.value = stepperScrollEl.value?.scrollLeft ?? 0
initialPointerX.value = e.clientX
},
onSwipe: (e: PointerEvent) => {
// Calculate how far the pointer has moved from initial position
const deltaX = initialPointerX.value - e.clientX
// Update scroll position with direct 1:1 tracking
const el = stepperScrollEl.value
if (el) {
el.scrollLeft = initialScrollLeft.value + deltaX
}
}
})
const previousVisibilityMap = ref<Map<string, boolean>>(new Map())
const allFormElements = computed(() => {
@@ -121,6 +215,13 @@ const visibilityMap = computed(() => {
return evaluateFormElementVisibility(allFormElements.value)
})
const cursorClass = computed(
() =>
isSwiping.value
? 'cursor-grabbing select-none' // During drag: closed fist + no selection
: 'cursor-grab' // Ready to drag: open hand
)
const visibleSubsections = computed(() => {
if (!currentFormElementSection.value?.formElementSubSections) {
return []
@@ -146,8 +247,105 @@ onMounted(() => {
activeStepperItemIndex.value = props.initialSectionIndex
}
previousVisibilityMap.value = visibilityMap.value
stepperScrollEl.value?.addEventListener('scroll', updateStepperOverflowIndicators, { passive: true })
updateStepperOverflowIndicators()
window.addEventListener('resize', scrollToActiveStep)
})
onUnmounted(() => {
stepperScrollEl.value?.removeEventListener('scroll', updateStepperOverflowIndicators)
window.removeEventListener('resize', scrollToActiveStep)
})
watch(
() => activeStepperItemIndex.value,
async () => {
await nextTick()
scrollToActiveStep()
}
)
watch(
() => stepperItems.value.length,
async () => {
await nextTick()
updateStepperOverflowIndicators()
}
)
function getActiveStepElement(): HTMLElement | null {
const root = stepperScrollEl.value
if (!root) return null
const selectors = [
// Nuxt UI / Reka often exposes state via data attributes
'[data-state="active"]',
'[data-active="true"]',
// ARIA patterns
'[aria-current="step"]',
'[aria-selected="true"]'
]
for (const selector of selectors) {
const el = root.querySelector(selector) as HTMLElement | null
if (!el) continue
// Prefer the list item (stable width) if present
const li = el.closest('li') as HTMLElement | null
return li ?? el
}
return null
}
function scrollToActiveStep() {
const scroller = stepperScrollEl.value
if (!scroller) return
const activeIndex = activeStepperItemIndex.value
if (activeIndex < 3) {
scroller.scrollTo({ left: 0, behavior: 'smooth' })
return
}
const activeEl = getActiveStepElement()
if (!activeEl) return
const scrollerRect = scroller.getBoundingClientRect()
const activeRect = activeEl.getBoundingClientRect()
const activeCenterInScroller = activeRect.left - scrollerRect.left + activeRect.width / 2 + scroller.scrollLeft
const targetScrollLeft = activeCenterInScroller - scroller.clientWidth / 2
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth)
const clamped = Math.max(0, Math.min(maxScrollLeft, targetScrollLeft))
scroller.scrollTo({ left: clamped, behavior: 'smooth' })
}
function updateStepperOverflowIndicators() {
const scroller = stepperScrollEl.value
if (!scroller) {
canScrollLeft.value = false
canScrollRight.value = false
return
}
const maxScrollLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth)
canScrollLeft.value = scroller.scrollLeft > 1
canScrollRight.value = scroller.scrollLeft < maxScrollLeft - 1
}
function scrollStepperBy(direction: -1 | 1) {
const scroller = stepperScrollEl.value
if (!scroller) return
const delta = Math.max(200, Math.round(scroller.clientWidth * 0.6))
scroller.scrollBy({ left: direction * delta, behavior: 'smooth' })
}
async function handleAddInputForm(position: number, subsectionKey: string) {
const foundSubsection = findSubsectionByKey(subsectionKey)
if (!foundSubsection) return
@@ -248,3 +446,17 @@ function updateSubsectionElements(
}))
}
</script>
<style scoped>
.lch-stepper-scroll {
-webkit-overflow-scrolling: touch;
}
.lch-stepper-scroll::-webkit-scrollbar {
height: 0px;
}
.lch-stepper-scroll {
scrollbar-width: none;
}
</style>

View File

@@ -1,5 +1,9 @@
import type { FormElementDto, FormElementVisibilityCondition, VisibilityConditionOperator } from '~~/.api-client'
import { VisibilityConditionOperator as VCOperator, VisibilityConditionType as VCType, FormElementType } from '~~/.api-client'
import {
VisibilityConditionOperator as VCOperator,
VisibilityConditionType as VCType,
FormElementType
} from '~~/.api-client'
export function useFormElementVisibility() {
/**
@@ -60,13 +64,13 @@ export function useFormElementVisibility() {
// Special handling for CHECKBOX with multiple options
if (sourceElement.type === FormElementType.Checkbox && sourceElement.options.length > 1) {
const operator = condition.formElementOperator || VCOperator.Equals
const conditionMet = evaluateCheckboxCondition(sourceElement, condition.formElementExpectedValue, operator)
const conditionMet = evaluateCheckboxCondition(sourceElement, condition.formElementExpectedValue || '', operator)
return condition.formElementConditionType === VCType.Show ? conditionMet : !conditionMet
}
const sourceValue = getFormElementValue(sourceElement)
const operator = condition.formElementOperator || VCOperator.Equals
const conditionMet = evaluateCondition(sourceValue, condition.formElementExpectedValue, operator)
const conditionMet = evaluateCondition(sourceValue, condition.formElementExpectedValue || '', operator)
return condition.formElementConditionType === VCType.Show ? conditionMet : !conditionMet
}
@@ -91,9 +95,7 @@ export function useFormElementVisibility() {
expectedValue: string,
operator: VisibilityConditionOperator
): boolean {
const selectedLabels = element.options
.filter((option) => option.value === 'true')
.map((option) => option.label)
const selectedLabels = element.options.filter((option) => option.value === 'true').map((option) => option.label)
switch (operator) {
case VCOperator.Equals:

View File

@@ -30,7 +30,6 @@
</template>
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
<FormStepperWithNavigation
:form-element-sections="applicationForm.formElementSections"
:initial-section-index="sectionIndex"
@@ -42,7 +41,6 @@
@add-input-form="handleAddInputForm"
@update:form-element-sections="handleFormElementSectionsUpdate"
/>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -15,7 +15,6 @@
</template>
<template #body>
<div class="flex flex-col gap-4 sm:gap-6 lg:gap-12 w-full lg:max-w-4xl mx-auto">
<div v-if="!canWriteApplicationForms" class="text-center py-12">
<UIcon name="i-lucide-shield-x" class="w-16 h-16 mx-auto text-red-400 mb-4" />
<h2 class="text-2xl font-semibold text-gray-700 mb-2">{{ $t('applicationForms.noPermission') }}</h2>
@@ -35,7 +34,6 @@
</UFormField>
</FormStepperWithNavigation>
</div>
</div>
</template>
</UDashboardPanel>
</template>