<template> <div class="form-group tw-block" :class="formGroupClass"> <div class="tw-flex tw-items-baseline tw-gap-2"> <label v-if="label" :for="id" class="tw-text-gray-500 tw-font-medium tw-pt-1 tw-flex tw-gap-2 tw-items-center tw-grow" > <span>{{ label }}</span> <button v-if="withEditButton" type="button" class="btn btn-sm tw-p-0" @click="emit('edit')"> <icon-system-uicons-pen /> </button> </label> <Transition enter-active-class="tw-transition tw-duration-200 tw-ease-out" enter-from-class="tw-opacity-0" enter-to-class="tw-opacity-100" leave-active-class="tw-transition tw-duration-150 tw-ease-in" leave-from-class="tw-opacity-100" leave-to-class="tw-opacity-0" > <SaveIndicator v-if="isSavingDebounced" /> </Transition> </div> <div class="tw-flex tw-flex-col"> <slot v-bind="controlAttributes" /> <div v-if="hasErrors" :id="errorsId" class="invalid-feedback" :class="{ 'tw-block': hasErrors }" > <template v-for="(error, index) in errorList" :key="index"> <p class="last:tw-mb-0"> {{ error.code ? t(`error.${error.code}`) : error.message ? error.message : t('error.unknown') }} </p> </template> </div> </div> </div> </template> <script lang="ts" setup> import { computed, inject, ref, watchEffect } from 'vue' import { computedDebounced, useId } from '@/util' import { useI18n } from '@/i18n' import SaveIndicator from '@/components/generic/SaveIndicator.vue' defineOptions({ compatConfig: { MODE: 3 } }) type Error = { code?: string message?: string } const props = defineProps<{ label?: string customControl?: boolean errors?: undefined | (Error | undefined | null)[] withEditButton?: boolean isSaving?: boolean }>() const emit = defineEmits<{ edit: [] }>() const { t } = useI18n() const formGroupClass = inject('formGroupClass', undefined) const id = useId('form-group-control') const errorsId = useId('form-group-errors') const errorList = computed<Error[]>(() => (props.errors ?? []).filter((e): e is Error => !!e)) const hasErrors = computed(() => errorList.value.length > 0) const controlAttributes = computed(() => ({ class: [ 'tw-order-first', { 'is-invalid': hasErrors.value, 'form-control': !props.customControl }, ], 'aria-describedby': hasErrors.value ? errorsId.value : '', id: id.value, })) let isSavingStart = new Date() const isSavingDebounced = computedDebounced( () => props.isSaving, (isSaving) => { if (isSaving) return 0 const now = new Date() const timePassed = (now.getTime() - isSavingStart.getTime()) / 1000 return timePassed < 1 ? 1 - timePassed : 0 }, ) watchEffect(() => { if (props.isSaving) isSavingStart = new Date() }) </script>