<script setup lang="ts"> import { computed, getCurrentInstance, onMounted, ref, watch } from "vue"; import { type Constraint, forConstraint } from "@/shared/validation/validator"; import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue"; type InputType = "text" | "number" | "password" | "email" | "tel" | "url"; interface Props { name: string; modelValue?: string; label?: string; type?: InputType; placeholder: string; constraint: Constraint; validationError: string; resetIconTitle?: string; help?: string; } const props = defineProps<Props>(); const emit = defineEmits<{ (e: "update:modelValue", value: string): void; }>(); const displayLabel = computed(() => props.label ? props.constraint.optional ? `${props.label}:` : `${props.label}*:` : undefined ); const label = ref<HTMLInputElement>(); const input = ref<HTMLInputElement>(); const valid = ref(true); const validated = ref(false); const hasResetIcon = computed( () => !!(props.modelValue && props.resetIconTitle) ); watch(props, () => { onValueChange(); }); function registerValidationComponent() { const instance = getCurrentInstance(); let parent = instance?.parent; while (parent) { if (parent.exposed?.registerValidationComponent) { parent.exposed.registerValidationComponent(instance); return; } parent = parent.parent; } throw new Error( "Could not find matching ValidationForm for ValidationFormInpunt." ); } function withInputElement(callback: (element: HTMLInputElement) => void): void { const element = input.value; if (!element) { console.warn("Could not get referenced input element."); return; } callback(element); } function onValueChange() { if (validated.value) { validate(); } } function onInput() { if (validated.value) { validate(); } withInputElement((element) => { emit("update:modelValue", element.value); }); } function validate(): boolean { const element = input.value; if (!element) { console.warn("Could not get referenced input element."); return false; } valid.value = forConstraint(props.constraint, false)(element.value); validated.value = true; return valid.value; } function reset() { withInputElement((element) => { element.value = ""; onInput(); }); } function focus() { label.value?.scrollIntoView(); input.value?.focus(); } defineExpose({ focus, validate, }); onMounted(() => { registerValidationComponent(); }); </script> <template> <div class="validation-form-input"> <label ref="label"> {{ displayLabel }} <ExpandableHelpBox v-if="props.help" :text="props.help" /> <span class="input-wrapper"> <input ref="input" :class="{ 'has-reset-icon': hasResetIcon }" :name="props.name" :value="props.modelValue" @input="onInput" :type="props.type || 'text'" :placeholder="props.placeholder" /> <i v-if="hasResetIcon" class="fa fa-times reset-icon" aria-hidden="true" :title="props.resetIconTitle" @click.prevent="reset" /> </span> </label> <div class="validation-error" v-if="!valid"> {{ props.validationError }} </div> </div> </template> <style scoped lang="scss"> @import "../../scss/variables"; .validation-form-input { margin: $validation-form-input-margin; } label { position: relative; display: block; font-weight: $label-font-weight; cursor: pointer; } .input-wrapper { display: flex; align-items: center; input { box-sizing: border-box; width: 100%; margin: 0.25em 0; &.has-reset-icon { padding-right: $input-with-reset-icon-padding-right; } } .reset-icon { cursor: pointer; width: 0; // Allow input to really take up 100% width within flexbox. margin-left: -$input-reset-icon-position-right; font-size: $input-font-size; color: $input-reset-icon-color; } } .validation-error { color: $variant-color-danger; margin: 0.25em 0; } </style>