diff --git a/config/form-components.php b/config/form-components.php
index 64f7d39..91f9f36 100644
--- a/config/form-components.php
+++ b/config/form-components.php
@@ -109,6 +109,16 @@
'clear_icon' => 'heroicon-o-x-circle',
],
+ 'file-upload' => [
+ 'class' => Components\Files\FileUpload::class,
+ 'view' => 'form-components::components.files.file-upload',
+ ],
+
+ 'file-pond' => [
+ 'class' => Components\Files\FilePond::class,
+ 'view' => 'form-components::components.files.file-pond',
+ ],
+
],
/*
@@ -171,6 +181,11 @@
'https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css',
'https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.3/flatpickr.min.js',
],
+
+ 'filepond' => [
+ 'https://unpkg.com/filepond/dist/filepond.css',
+ 'https://unpkg.com/filepond/dist/filepond.js',
+ ],
],
];
diff --git a/resources/sass/form-components.scss b/resources/sass/form-components.scss
index ce9b1f9..0365873 100644
--- a/resources/sass/form-components.scss
+++ b/resources/sass/form-components.scss
@@ -7,3 +7,4 @@
@import 'utils/choice';
@import 'utils/addon';
@import 'utils/flatpickr';
+@import 'utils/files';
diff --git a/resources/sass/utils/_files.scss b/resources/sass/utils/_files.scss
new file mode 100644
index 0000000..c6966eb
--- /dev/null
+++ b/resources/sass/utils/_files.scss
@@ -0,0 +1,83 @@
+.file-upload {
+ @apply flex;
+ @apply items-center;
+}
+
+.file-upload__input {
+ @apply rounded-md;
+}
+
+.file-upload__label {
+ @apply cursor-pointer;
+ @apply py-2;
+ @apply px-3;
+ @apply border;
+ @apply border-gray-300;
+ @apply rounded-md;
+ @apply text-sm;
+ @apply leading-4;
+ @apply font-medium;
+ @apply text-cool-gray-700;
+ @apply transition;
+ @apply duration-150;
+ @apply ease-in-out;
+ @apply shadow-sm;
+
+ &:hover {
+ @apply text-cool-gray-500;
+ }
+
+ &:active {
+ @apply bg-gray-50;
+ @apply text-cool-gray-800;
+ }
+
+ [role="button"] {
+ @apply outline-none;
+ }
+}
+
+.file-upload__label--focused {
+ @apply outline-none;
+ @apply border-blue-300;
+ @apply shadow-outline-blue;
+}
+
+// FilePond style overrides
+/* purgecss start ignore */
+.filepond--panel-root {
+ @apply border-dashed;
+ @apply border-2;
+ @apply border-cool-gray-200;
+ @apply rounded-md;
+}
+
+.filepond--panel-root {
+ @apply bg-transparent;
+ @apply max-w-lg;
+ @apply transition;
+ @apply duration-150;
+ @apply ease-in-out;
+}
+
+.filepond--label-action {
+ @apply text-blue-600;
+ text-decoration-color: theme('colors.blue.600');
+ @apply transition;
+ @apply duration-100;
+ @apply ease-in-out;
+
+ &:hover,
+ &:focus {
+ @apply opacity-75;
+ }
+}
+
+.fc-filepond--desc {
+ @apply text-cool-gray-500;
+}
+
+.fc-filepond--sub-desc {
+ @apply text-xs #{!important};
+}
+/* purgecss end ignore */
diff --git a/resources/views/components/files/file-pond.blade.php b/resources/views/components/files/file-pond.blade.php
new file mode 100644
index 0000000..0a6c239
--- /dev/null
+++ b/resources/views/components/files/file-pond.blade.php
@@ -0,0 +1,30 @@
+
+ except('wire:model') }}
+ />
+
diff --git a/resources/views/components/files/file-upload.blade.php b/resources/views/components/files/file-upload.blade.php
new file mode 100644
index 0000000..153220a
--- /dev/null
+++ b/resources/views/components/files/file-upload.blade.php
@@ -0,0 +1,74 @@
+
+ {{ $slot }}
+
+
+
+ offsetExists('aria-describedby'))
+ aria-describedby="{{ $id }}-error"
+ @endif
+ @endif
+
+ {{ $attributes->except('class') }}
+ />
+
+
+
+
+ {{-- Upload progress --}}
+ @if ($canShowUploadProgress($attributes))
+
+
+
+ {{ __('Processing...') }}
+
+
+
+
+
+
+
+
+
+
+ @endif
+
+
+ {{ $after ?? '' }}
+
diff --git a/src/Components/Files/FilePond.php b/src/Components/Files/FilePond.php
new file mode 100644
index 0000000..05bed39
--- /dev/null
+++ b/src/Components/Files/FilePond.php
@@ -0,0 +1,84 @@
+multiple = $multiple;
+ $this->allowDrop = $allowDrop;
+ $this->name = $name;
+ $this->disabled = $disabled;
+ $this->maxFiles = $maxFiles;
+ $this->type = $type;
+ $this->options = $options;
+ $this->description = $description;
+ }
+
+ public function options(): array
+ {
+ $label = array_filter([
+ 'Upload a file or drag and drop',
+ $this->description,
+ ]);
+
+ if (isset($label[1])) {
+ $label[1] = '' . $label[1] . '';
+ }
+
+ $defaultOptions = [
+ 'allowMultiple' => $this->multiple,
+ 'allowDrop' => $this->allowDrop,
+ 'disabled' => $this->disabled,
+ ] + array_filter([
+ 'maxFiles' => $this->multiple && $this->maxFiles ? $this->maxFiles : null,
+ 'name' => $this->name,
+ 'labelIdle' => '' . implode('
', $label) . '',
+ ]);
+
+ return array_merge($defaultOptions, $this->options);
+ }
+
+ public function jsonOptions(): string
+ {
+ if (empty($this->options())) {
+ return '';
+ }
+
+ return '...' . json_encode((object) $this->options()) . ',';
+ }
+}
diff --git a/src/Components/Files/FileUpload.php b/src/Components/Files/FileUpload.php
new file mode 100644
index 0000000..ff1b571
--- /dev/null
+++ b/src/Components/Files/FileUpload.php
@@ -0,0 +1,71 @@
+name = $name;
+ $this->id = $id ?? $name;
+ $this->multiple = $multiple;
+ $this->label = $label;
+ $this->displayUploadProgress = $displayUploadProgress;
+ $this->showErrors = $showErrors;
+ $this->type = $type;
+ }
+
+ public function canShowUploadProgress($attributes)
+ {
+ if (! is_null($this->canShowUploadProgress)) {
+ return $this->canShowUploadProgress;
+ }
+
+ if (! $this->displayUploadProgress) {
+ return $this->canShowUploadProgress = false;
+ }
+
+ if (! $attributes->whereStartsWith('wire:model')->first()) {
+ return $this->canShowUploadProgress = false;
+ }
+
+ return $this->canShowUploadProgress = true;
+ }
+}
diff --git a/src/Concerns/AcceptsFiles.php b/src/Concerns/AcceptsFiles.php
new file mode 100644
index 0000000..cc02c5b
--- /dev/null
+++ b/src/Concerns/AcceptsFiles.php
@@ -0,0 +1,35 @@
+type) {
+ return null;
+ }
+
+ $excelTypes = '.csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+ return [
+ 'audio' => 'audio/*',
+ 'image' => 'image/*',
+ 'video' => 'video/*',
+ 'pdf' => '.pdf',
+ 'csv' => '.csv',
+ 'spreadsheet' => $excelTypes,
+ 'excel' => $excelTypes,
+ 'text' => 'text/plain',
+ 'html' => 'text/html',
+ ][$this->type] ?? null;
+ }
+}
diff --git a/tests/Components/Files/FileUploadTest.php b/tests/Components/Files/FileUploadTest.php
new file mode 100644
index 0000000..5cf9626
--- /dev/null
+++ b/tests/Components/Files/FileUploadTest.php
@@ -0,0 +1,377 @@
+withViewErrors([]);
+
+ $expected = <<
+
+
+
+
+
+
+
+
+ HTML;
+
+ $this->assertComponentRenders(
+ $expected,
+ ''
+ );
+ }
+
+ /** @test */
+ public function can_show_file_upload_progress_if_wire_model_is_set(): void
+ {
+ $this->withViewErrors([]);
+
+ $expected = <<
+
+
+
+
+
+
+
+
+
+
+ Processing...
+
+
+
+
+
+
+
+
+
+
+
+
+ HTML;
+
+ $this->assertComponentRenders(
+ $expected,
+ ''
+ );
+ }
+
+ /** @test */
+ public function can_have_wire_model_without_upload_progress(): void
+ {
+ $this->withViewErrors([]);
+
+ $expected = <<
+
+
+
+
+
+
+
+
+ HTML;
+
+ $this->assertComponentRenders(
+ $expected,
+ '',
+ ['show' => false],
+ );
+ }
+
+ /** @test */
+ public function can_have_an_after_slot(): void
+ {
+ $this->withViewErrors([]);
+
+ $template = <<
+
+ After slot content...
+
+
+ HTML;
+
+ $expected = <<
+
+
+
+
+
+
+
+
+ After slot content...
+
+ HTML;
+
+ $this->assertComponentRenders($expected, $template);
+ }
+
+ /** @test */
+ public function can_have_default_slotted_content(): void
+ {
+ $this->withViewErrors([]);
+
+ $template = <<
+ Default slot content...
+
+ HTML;
+
+ $expected = <<
+ Default slot content...
+
+
+
+
+
+
+
+
+
+ HTML;
+
+ $this->assertComponentRenders($expected, $template);
+ }
+
+ /** @test */
+ public function adds_class_attribute_to_root_element(): void
+ {
+ $this->withViewErrors([]);
+
+ $expected = <<
+
+
+
+
+
+
+
+
+ HTML;
+
+ $this->assertComponentRenders(
+ $expected,
+ ''
+ );
+ }
+
+ /** @test */
+ public function shows_aria_attributes_on_error(): void
+ {
+ $this->withViewErrors(['file' => 'required']);
+
+ $expected = <<
+
+
+
+
+
+
+
+
+ HTML;
+
+ $this->assertComponentRenders(
+ $expected,
+ ''
+ );
+ }
+
+ /**
+ * @test
+ * @dataProvider acceptsTypes
+ * @param string $type
+ * @param string $shouldAccept
+ */
+ public function can_be_told_to_accept_certain_preset_types(string $type, string $shouldAccept): void
+ {
+ $this->withViewErrors([]);
+
+ $expected = <<
+
+
+
+
+
+
+
+
+ HTML;
+
+ $this->assertComponentRenders(
+ $expected,
+ '',
+ compact('type')
+ );
+ }
+
+ public function acceptsTypes(): array
+ {
+ $excelTypes = '.csv,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+ return [
+ ['audio', 'audio/*'],
+ ['image', 'image/*'],
+ ['video', 'video/*'],
+ ['pdf', '.pdf'],
+ ['csv', '.csv'],
+ ['spreadsheet', $excelTypes],
+ ['excel', $excelTypes],
+ ['text', 'text/plain'],
+ ['html', 'text/html'],
+ ];
+ }
+}