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'], + ]; + } +}