Skip to content

FileInput Component Code ​

Dependencies

This component requires:

  • Vue 3 with Composition API
  • BasicInput component for the input wrapper

Full Component Code ​

Due to the length of this component, only key sections are shown here. For the complete code, please refer to the source file.

vue
<script setup>
import { ref, useAttrs, computed, watch } from "vue";
import BasicInput from "./BasicInput.vue";

const emit = defineEmits([
  "prepend:click",
  "prependInner:click",
  "clear:click",
  "append:click",
  "appendInner:click",
  "update:modelValue",
  "validate",
  "focus",
  "blur",
  "input",
  "change",
  "keydown",
  "keyup",
  "keypress",
  "click",
  "dblclick",
  "mousedown",
  "mouseup",
  "mouseenter",
  "mouseleave",
  "copy",
  "cut",
  "paste",
  "compositionstart",
  "compositionupdate",
  "compositionend",
  "dragenter",
  "dragover",
  "dragleave",
  "drop",
]);

const props = defineProps({
  label: {
    type: String,
    required: false,
    default: (props) =>
      props.multiple ? "Upload your files" : "Upload your file",
  },
  maxFiles: { type: Number, required: false },
  maxSize: { type: Number, required: false },
  multiple: { type: Boolean, required: false, default: false },
  modelValue: { type: [Object, Array], required: false },
  value: [Object, Array],
  chip: { type: Boolean, required: false, default: false },
  persistentDetails: { type: Boolean, required: false, default: true },
  accept: { type: String, required: false },
});

// Use attributes passed to the component
const attrs = useAttrs();

// Internal state
const fileInputRef = ref(null);
const fileInputComponent = ref(null);
const isDragging = ref(false);

// Computed properties
const internalValue = computed({
  get: () => {
    if (props.modelValue !== undefined) return props.modelValue;
    if (props.value !== undefined) return props.value;
    return props.multiple ? [] : null;
  },
  set: (val) => {
    emit("update:modelValue", val);
  },
});

// Validation rules
const validationRules = computed(() => {
  const rules = [];

  // Add file count validation if maxFiles is set
  if (props.maxFiles && props.multiple) {
    rules.push({
      rule: "custom",
      validator: (value) => {
        if (!value) return true;
        return Array.isArray(value) && value.length <= props.maxFiles;
      },
      message: `Maximum ${props.maxFiles} files allowed`,
    });
  }

  // Add file size validation if maxSize is set
  if (props.maxSize) {
    rules.push({
      rule: "custom",
      validator: (value) => {
        if (!value) return true;
        if (props.multiple) {
          return (
            Array.isArray(value) &&
            value.every((file) => file.size <= props.maxSize)
          );
        }
        return value.size <= props.maxSize;
      },
      message: `Maximum file size is ${formatFileSize(props.maxSize)}`,
    });
  }

  return rules;
});

// Methods
const triggerFileInput = () => {
  if (fileInputRef.value) {
    fileInputRef.value.click();
  }
};

const handleFileChange = (event) => {
  const files = event.target.files;
  if (!files || files.length === 0) return;

  if (props.multiple) {
    internalValue.value = Array.from(files);
  } else {
    internalValue.value = files[0];
  }

  // Reset the input to allow selecting the same file again
  if (fileInputRef.value) {
    fileInputRef.value.value = "";
  }

  emit("change", event);
};

const handleRemoveFile = (file) => {
  if (props.multiple) {
    internalValue.value = internalValue.value.filter((f) => f !== file);
  } else {
    internalValue.value = null;
  }
};

const handleClearClick = () => {
  internalValue.value = props.multiple ? [] : null;
  emit("clear:click");
};

// Drag and drop handlers
const handleDragEnter = (event) => {
  event.preventDefault();
  isDragging.value = true;
};

const handleDragOver = (event) => {
  event.preventDefault();
  isDragging.value = true;
};

const handleDragLeave = (event) => {
  event.preventDefault();
  isDragging.value = false;
};

const handleDrop = (event) => {
  event.preventDefault();
  isDragging.value = false;

  const files = event.dataTransfer.files;
  if (!files || files.length === 0) return;

  if (props.multiple) {
    internalValue.value = Array.from(files);
  } else {
    internalValue.value = files[0];
  }

  emit("drop", event);
};

// Helper functions
const formatFileSize = (bytes) => {
  if (bytes === 0) return "0 Bytes";
  const k = 1024;
  const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
};

// Event delegation
const triggerEvent = (eventName, event) => {
  emit(eventName, event);
};

watch(
  () => props.modelValue ?? props.value ?? '',
  (newVal) => {
    internalValue.value = newVal;
  }
);
</script>

<template>
  <OInput
    ref="fileInputComponent"
    class="file-input-wrapper"
    v-bind="attrs"
    :label="label"
    :value="internalValue"
    :rules="validationRules"
    :persistentDetails="persistentDetails"
    :class="{ 'is-dragging': isDragging }"
    @prepend:click="triggerFileInput"
    @prependInner:click="triggerFileInput"
    @clear:click="handleClearClick"
  >
    <template
      #input-field="{
        props: { readonly, disabled, attrs, triggerEvent, validate },
      }"
    >
      <input
        ref="fileInput"
        type="file"
        v-bind="attrs"
        :readonly="readonly"
        :disabled="disabled"
        :multiple="multiple"
        :accept="accept"
        @input="handleInput"
        @change="triggerEvent('change', $event)"
        @focus="triggerEvent('focus', $event)"
        @blur="triggerEvent('blur', $event)"
        @keydown="triggerEvent('keydown', $event)"
        @keyup="triggerEvent('keyup', $event)"
        @keypress="triggerEvent('keypress', $event)"
        @click="triggerEvent('click', $event)"
        @dblclick="triggerEvent('dblclick', $event)"
        @mousedown="triggerEvent('mousedown', $event)"
        @mouseup="triggerEvent('mouseup', $event)"
        @mouseenter="triggerEvent('mouseenter', $event)"
        @mouseleave="triggerEvent('mouseleave', $event)"
        @compositionstart="triggerEvent('compositionstart', $event)"
        @compositionupdate="triggerEvent('compositionupdate', $event)"
        @compositionend="triggerEvent('compositionend', $event)"
        @dragenter="triggerEvent('dragenter', $event)"
        @dragover="triggerEvent('dragover', $event)"
        @dragleave="triggerEvent('dragleave', $event)"
        @drop="triggerEvent('drop', $event)"
      />
      <div
        class="file-input-placeholder"
        v-if="!(multiple ? internalValue?.length > 0 : internalValue)"
        @click="triggerFileInput"
      ></div>
      <div v-else class="file-input-selected-files" @click="triggerFileInput">
        <template v-if="chip">
          <template v-if="multiple">
            <div
              class="file-chip"
              v-for="file in internalValue"
              :key="file.name"
              @click.stop
            >
              <span class="file-name">{{ file.name }}</span>
              <button class="remove-btn" @click.stop="handleRemoveFile(file)">
                &times;
              </button>
            </div>
          </template>
          <div v-else class="file-chip" @click.stop>
            <span class="file-name">{{ internalValue?.name }}</span>
            <button
              class="remove-btn"
              @click.stop="handleRemoveFile(internalValue)"
            >
              &times;
            </button>
          </div>
        </template>
        <template v-else>
          <template v-if="multiple">
            {{ internalValue?.map((file) => file.name)?.join(", ") }}
          </template>
          <template v-else> {{ internalValue?.name }} </template>
        </template>
      </div>
    </template>
    <template #prepend="{ props }">
      <slot name="prepend" :props="props">
        <svg
          @click="props?.click?.()"
          class="prepend-svg"
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          role="img"
          aria-hidden="true"
        >
          <path
            d="M16.5,6V17.5A4,4 0 0,1 12.5,21.5A4,4 0 0,1 8.5,17.5V5A2.5,2.5 0 0,1 11,2.5A2.5,2.5 0 0,1 13.5,5V15.5A1,1 0 0,1 12.5,16.5A1,1 0 0,1 11.5,15.5V6H10V15.5A2.5,2.5 0 0,0 12.5,18A2.5,2.5 0 0,0 15,15.5V5A4,4 0 0,0 11,1A4,4 0 0,0 7,5V17.5A5.5,5.5 0 0,0 12.5,23A5.5,5.5 0 0,0 18,17.5V6H16.5Z"
          ></path>
        </svg>
      </slot>
    </template>
    <template #prepend-inner="{ props }">
      <slot name="prepend-inner" :props="{ props }">
        <svg
          @click="props?.click?.()"
          class="prepend-inner-svg"
          width="64"
          height="64"
          viewBox="0 0 32 32"
          xmlns="http://www.w3.org/2000/svg"
          fill="#000000"
        >
          <path
            d="M20,8 C18.896,8 18,7.104 18,6 L18,2 L24,8 L20,8 Z M18,0 L18,0.028 C17.872,0.028 4,0 4,0 C1.791,0 0,1.791 0,4 L0,28 C0,30.209 1.791,32 4,32 L22,32 C24.209,32 26,30.209 26,28 L26,10 L26,8 L18,0 Z"
          />
        </svg>
      </slot>
    </template>
    <template #append-inner="{ props }">
      <slot name="append-inner" :props="props">
        <svg
          class="append-inner-svg"
          @click="props?.click?.()"
          xmlns="http://www.w3.org/2000/svg"
          fill="#000000"
          width="64px"
          height="64px"
          viewBox="0 0 24 24"
        >
          <g id="SVGRepo_bgCarrier" stroke-width="0"></g>
          <g
            id="SVGRepo_tracerCarrier"
            stroke-linecap="round"
            stroke-linejoin="round"
          ></g>
          <g id="SVGRepo_iconCarrier">
            <path d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"></path>
          </g>
        </svg>
      </slot>
    </template>
    <template #append="{ props }">
      <slot name="append" :props="props">
        <svg
          class="append-svg"
          @click="props?.click?.()"
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          role="img"
          aria-hidden="true"
        >
          <path
            d="M16.5,6V17.5A4,4 0 0,1 12.5,21.5A4,4 0 0,1 8.5,17.5V5A2.5,2.5 0 0,1 11,2.5A2.5,2.5 0 0,1 13.5,5V15.5A1,1 0 0,1 12.5,16.5A1,1 0 0,1 11.5,15.5V6H10V15.5A2.5,2.5 0 0,0 12.5,18A2.5,2.5 0 0,0 15,15.5V5A4,4 0 0,0 11,1A4,4 0 0,0 7,5V17.5A5.5,5.5 0 0,0 12.5,23A5.5,5.5 0 0,0 18,17.5V6H16.5Z"
          ></path>
        </svg>
      </slot>
    </template>
    <template
      #details-right-content="{
        props: { hint, error, errorMessage, persistentDetails, focused },
      }"
    >
      <div
        class="details-right-content"
        :class="{ persistentDetails, focused }"
      >
        <span v-if="internalValue?.length > 0"
          >{{ internalValue?.length }} files</span
        >
        <span v-if="totalFileSizes > 0"
          >({{ formatFileSize(totalFileSizes) }} in total)
        </span>
      </div>
    </template>
  </OInput>
</template>

<style lang="scss" scoped>
.file-input-wrapper {
  .prepend-svg {
    width: 100%;
    height: 100%;
    cursor: pointer;
  }
  .prepend-inner-svg {
    width: 100%;
    height: 100%;
    cursor: pointer;
  }
  .append-inner-svg {
    width: 100%;
    height: 100%;
    cursor: pointer;
  }
  .append-svg {
    width: 100%;
    height: 100%;
    cursor: pointer;
  }
  .file-picker-opener {
    width: 100%;
    height: 100%;
    cursor: pointer;
  }

  .file-input-placeholder {
    padding: 1rem 0;
    cursor: pointer;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    position: relative;
    min-height: 3.25rem;
  }

  .file-input-selected-files {
    padding: 1rem 0;
    cursor: pointer;
    width: 100%;
    height: 100%;
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;

    .file-chip {
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
      padding: 0.25rem 0.75rem;
      background-color: #f1f1f1;
      border-radius: 9999px;
      font-size: 0.875rem;
      color: #333;
      border: 1px solid #ccc;

      .file-name {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        max-width: 150px;
      }

      .remove-btn {
        background: none;
        border: none;
        font-size: 1rem;
        line-height: 1;
        cursor: pointer;
        color: #666;
        transition: color 0.2s;

        &:hover {
          color: #333;
        }
      }
    }
  }

  input {
    position: relative; /* Relative positioning */
    z-index: 1; /* Layering */
    background: transparent; /* Transparent background */
    padding: 0; /* No padding */
    margin: 0; /* No margin */
    border: none; /* No border */
    outline: 0; /* No outline */
    padding: 1rem 0; /* Padding */
    width: 100%; /* Full width */
    cursor: pointer;
    width: 0;
    height: 0;
    opacity: 0;
    position: absolute;
    &:focus {
      outline: none; /* No outline on focus */
    }
  }

  .details-right-content {
    display: flex;
    // align-items: center;
    gap: 0.5rem;
    color: #666;
    font-weight: 300;
    font-size: 0.75rem;
    flex-shrink: 0;

    opacity: 0; /* Hidden by default */
    visibility: hidden; /* Hidden by default */
    min-height: 0.875rem; /* Minimum height */
    min-width: 0.0625rem; /* Minimum width */
    position: relative; /* Relative positioning */
    transform: translateY(-100%); /* Move up */
    transition: all 0.2s ease-in-out; /* Smooth transition */
    span {
      white-space: nowrap;
    }

    &.focused,
    &.persistentDetails {
      opacity: 1;
      visibility: visible;
      transform: translateY(0);
    }
  }
}
</style>
<style lang="scss">
.file-input-wrapper {
  &.is-dragging {
    .input-wrapper {
      border-color: #000000;
    }
  }
  &.basic-input-wrapper {
    &.readonly {
      input {
        pointer-events: none;
      }
    }

    /* Input wrapper styles */
    .input-wrapper {
      grid-template-areas: "prepend-inner field clear append-inner"; /* Define grid areas */

      /* Input field wrapper styles */
      .input-field-wrapper {
        /* Label styles */
        &:has(.label.floating) {
          .file-input-placeholder {
            opacity: 0;
          }
        }
        .label {
          &.has-inner-prepend {
            &.floating {
              animation: float-diagonal 0.2s ease forwards; /* Animation for floating */
              font-size: 0.625rem; /* Smaller font size */
              height: max-content; /* Adjust height */
              transform: translateY(-50%); /* Center vertically */
              left: 0.625rem; /* Left position */
              background: white; /* Background color */
              padding: 0 0.25rem; /* Padding */
            }
            &.un-floating {
              animation: reverse-float-diagonal 0.2s ease forwards; /* Animation for un-floating */
              font-size: 0.875rem; /* Normal font size */
              height: 100%; /* Full height */
              left: calc(
                0.625rem + 0.375rem + 1.5rem
              ); /* Adjust left position */
              top: 50%; /* Center vertically */
              transform: translateY(-50%); /* Center vertically */
              padding: 0; /* No padding */
            }
          }

          /* Keyframes for floating animation */
          @keyframes float-diagonal {
            0% {
              left: calc(0.625rem + 0.375rem + 1.5rem); /* Start position */
              top: 40%; /* Start vertical position */
            }
            100% {
              left: 0.625rem; /* End position */
              top: 0; /* End vertical position */
            }
          }

          /* Keyframes for reverse floating animation */
          @keyframes reverse-float-diagonal {
            0% {
              left: calc(0.625rem + 0.375rem); /* Start position */
              top: 0%; /* Start vertical position */
            }
            100% {
              left: calc(0.625rem + 0.375rem + 1.5rem); /* End position */
              top: 50%; /* End vertical position */
            }
          }

          /* Input field styles */
          &:has(.label) {
            .input-field {
              &:not(:focus) {
                &::placeholder {
                  width: 0; /* Hide placeholder when not focused */
                }
              }
            }
          }

          /* Clear button styles */
          &:has(.clear-wrapper) {
            input:focus {
              visibility: visible;
              width: calc(100% - 2rem); /* Adjust width when focused */
            }
          }

          /* Show clear button when focused */
          &:focus-within {
            &:has(.has-value) {
              input {
                width: calc(100% - 2rem); /* Adjust width when focused */
              }
              .clear-wrapper {
                opacity: 1; /* Show clear button */
                right: 2.5rem; /* Adjust right position */
                visibility: visible; /* Make visible */
              }
            }
          }
        }
      }
    }

    .details-wrapper {
      align-items: initial;
    }
  }
}
</style>