<template> <div class="form-group last:tw-mb-0" :class="[ formGroupClass, { 'tw-block': !inline, 'tw-flex tw-gap-2 tw-items-center': inline, }, ]" > <div class="form-group-description tw-flex tw-gap-x-2 tw-gap-y-1 empty:tw-hidden" :class="[ center ? 'tw-items-center tw-self-center' : 'tw-items-baseline', { 'tw-mb-2': !inline }, ]" > <label v-if="label" :for="id" class="tw-text-gray-500 tw-font-medium tw-flex tw-gap-2 tw-items-center tw-grow tw-m-0" > <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> <SaveIndicator v-if="typeof isSaving === 'boolean'" class="tw-transition-opacity tw-ease-in-out tw-duration-150" :class="{ 'tw-invisible tw-opacity-0': !isSavingDebounced }" /> </div> <div class="tw-flex tw-flex-col"> <div class="tw-grid tw-order-first"> <slot v-bind="controlAttributes" /> <slot name="iconLeft" :class="[iconClasses, 'tw-ml-2']" role="presentation" /> <slot name="iconRight" :class="[iconClasses, 'tw-mr-2']" role="presentation" /> </div> <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, useSlots, 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 = withDefaults( defineProps<{ label?: string customControl?: boolean errors?: undefined | (Error | undefined | null)[] withEditButton?: boolean isSaving?: boolean | undefined center?: boolean inline?: boolean }>(), { isSaving: undefined, label: '', errors: undefined, center: false, }, ) const emit = defineEmits<{ edit: [] }>() const { t } = useI18n() const slots = useSlots() 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 iconClasses = 'tw-pointer-events-none tw-grid-area-cover tw-self-center' const controlAttributes = computed(() => ({ class: [ 'tw-grid-area-cover', { 'is-invalid': hasErrors.value, 'form-control': !props.customControl, 'tw-pl-8': slots.iconLeft, 'tw-pr-8': slots.iconRight, }, ], '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>