diff --git a/src/baklava.ts b/src/baklava.ts index fcad5f0ed..6536b337d 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -42,4 +42,5 @@ export { default as BlTableRow } from "./components/table/table-row/bl-table-row export { default as BlTag } from "./components/tag/bl-tag"; export { default as BlTextarea } from "./components/textarea/bl-textarea"; export { default as BlTooltip } from "./components/tooltip/bl-tooltip"; +export { default as BlUpload } from "./components/upload/bl-upload"; export { getIconPath, setIconPath } from "./utilities/asset-paths"; diff --git a/src/components/upload/bl-upload.css b/src/components/upload/bl-upload.css new file mode 100644 index 000000000..ac27e28b4 --- /dev/null +++ b/src/components/upload/bl-upload.css @@ -0,0 +1,250 @@ +:host { + display: flex; + flex-direction: column; + gap: 16px; +} + +:host([disabled]) { + pointer-events: none; +} + +.upload-wrapper { + --bl-upload-background-color: var(--bl-color-neutral-lightest); + --bl-upload-border-color: var(--bl-color-neutral-lighter); + --bl-upload-icon-color: var(--bl-color-primary); + --bl-upload-drag-background-color: var(--bl-color-primary-contrast); + --bl-upload-drag-border-color: var(--bl-color-primary); + + height: var(--bl-upload-height); + border-radius: var(--bl-border-radius-m); + border: 1px dashed var(--bl-upload-border-color); + background-color: var(--bl-upload-background-color); + width: var(--bl-upload-width); + display: flex; + flex-direction: column; + gap: 0; +} + +.upload-wrapper.horizontal-wrapper { + --bl-upload-height: 100px; + --bl-upload-width: 684px; +} + +.upload-wrapper.vertical-wrapper { + --bl-upload-height: 196px; + --bl-upload-width: 564px; +} + +.upload-container { + padding: var(--bl-size-xl); + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + display: flex; + justify-content: space-between; + align-items: center; + height: 100%; +} + +.upload-wrapper:hover, +.upload-wrapper.drag-over { + border-color: var(--bl-color-primary); +} + +.upload-container:focus-visible { + border-color: var(--bl-color-primary); +} + +.upload-container.disabled { + cursor: not-allowed; + opacity: 0.5; + border-color: var(--bl-color-neutral-light); + background-color: var(--bl-color-neutral-lightest); +} + +.upload-container.disabled:hover { + border-color: var(--bl-color-neutral-light); + background-color: var(--bl-color-neutral-lightest); +} + +.upload-content { + display: flex; + align-items: center; + gap: var(--bl-size-m); +} + +.upload-content .upload-icon { + font-size: var(--bl-size-xl); + color: var(--bl-upload-icon-color); + transition: transform 0.2s ease; +} + +.upload-container.disabled .upload-icon { + color: var(--bl-color-neutral-light); +} + +.upload-content .text-container { + display: flex; + gap: var(--bl-size-2xs); + flex-direction: column; +} + +.upload-content .text-container .header { + font: var(--bl-font-title-1-medium); + color: var(--bl-color-neutral-darker); +} + +.upload-container.disabled .text-container .header { + color: var(--bl-color-neutral-light); +} + +.upload-content .text-container .description { + font: var(--bl-font-title-2-regular); + color: var(--bl-color-neutral-dark); +} + +.upload-container.disabled .text-container .description { + color: var(--bl-color-neutral-light); +} + +/* Hide the file input */ +input[type="file"] { + display: none; +} + +/* ==================== VERTICAL VARIANT ==================== */ + +.upload-container.variant-vertical { + flex-direction: column; + justify-content: center; + text-align: center; + gap: 16px; +} + +.upload-container.variant-vertical .upload-content { + flex-direction: column; +} + +/* ==================== BUTTON VARIANT ==================== */ + +:host([variant="button"]) { + --bl-upload-width: 453px; +} + +.button-wrapper { + display: inline-flex; +} + +.upload-container.variant-button { + padding: 0; +} + +.file-list { + display: flex; + flex-direction: column; + gap: var(--bl-size-m); + width: var(--bl-upload-width); +} + +.file-item { + display: flex; + flex-direction: column; + gap: var(--bl-size-3xs); +} + +.file-info { + display: flex; + align-items: flex-start; + gap: var(--bl-size-xs); + justify-content: space-between; +} + +.file-details { + display: flex; + gap: var(--bl-size-xs); +} + +/* Status icon colors */ +.file-item.status-success .status-icon { + color: var(--bl-color-success); +} + +.file-item.status-error .status-icon { + color: var(--bl-color-danger); +} + +.file-item.status-pending .status-icon { + color: var(--bl-color-primary); +} + +.file-item.status-uploading .status-icon { + color: var(--bl-color-primary); +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.file-name { + font: var(--bl-font-body-text-2-medium); + color: var(--bl-color-neutral-darker); + text-decoration: underline; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +.file-name:hover { + color: var(--bl-color-primary); +} + +.file-item.status-error .file-name { + color: var(--bl-color-neutral-darker); +} + +.error-message { + font: var(--bl-font-body-text-3-regular); + color: var(--bl-color-danger); +} + +.file-info .remove-button { + flex-shrink: 0; +} + +/* Progress bar */ +.progress-container { + height: var(--bl-size-3xs); + border-radius: var(--bl-border-radius-s); + overflow: hidden; + background-color: var(--bl-color-neutral-lighter); +} + +.progress-bar { + height: 100%; + border-radius: var(--bl-border-radius-s); + transition: width 0.3s ease; +} + +.progress-bar.progress-success { + background-color: var(--bl-color-success); +} + +.progress-bar.progress-error { + background-color: var(--bl-color-danger); +} + +.progress-bar.progress-pending { + background-color: var(--bl-color-success); +} + +.progress-bar.progress-uploading { + background-color: var(--bl-color-success); +} diff --git a/src/components/upload/bl-upload.stories.mdx b/src/components/upload/bl-upload.stories.mdx new file mode 100644 index 000000000..8cf2cb960 --- /dev/null +++ b/src/components/upload/bl-upload.stories.mdx @@ -0,0 +1,384 @@ +import { Meta, Canvas, ArgsTable, Story } from "@storybook/addon-docs"; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + + + +export const TemplateHeader = (args) => html` +
+
+ ${ifDefined(args.header)} + ${ifDefined(args.description)} + ${ifDefined(args.subHeader)} +
+
+` + +export const DefaultHorizontalTemplate = (args)=> html` +${TemplateHeader({...args, header: 'Horizontal', description: 'All layouts include a title, description, icon, and button as optional variants.', subHeader: 'Default State'})} +${UploadTemplate(args)}` + +export const DefaultVerticalTemplate = (args)=> html` +${TemplateHeader({...args, header: 'Vertical', description: 'All layouts include a title, description, icon, and button as optional variants.', subHeader: 'Default State'})} +${UploadTemplate(args)}` + +export const DefaultButtonTemplate = (args)=> html` +${TemplateHeader({...args, header: 'Button-only', description: 'All layouts include a title, description, icon, and button as optional variants.', subHeader: 'Default State'})} +${UploadTemplate(args)}` + +export const DisabledHorizontalTemplate = (args)=> html` +${TemplateHeader({...args, header: '', description: '', subHeader: 'Disabled State'})} +${UploadTemplate(args)}` + +export const DisabledVerticalTemplate = (args)=> html` +${TemplateHeader({...args, header: '', description: '', subHeader: 'Disabled State'})} +${UploadTemplate(args)}` + +export const DisabledButtonTemplate = (args)=> html` +${TemplateHeader({...args, header: '', description: '', subHeader: 'Disabled State'})} +${UploadTemplate(args)}` + +export const UploadTemplate = (args) => html` +` + +export const animateProgress = (upload, fileId, targetProgress, willFail, errorMessage) => { + let progress = 0; + const step = Math.random() * 15 + 10; + + const interval = setInterval(() => { + progress += step + Math.random() * 8; + if (progress >= targetProgress) { + progress = targetProgress; + clearInterval(interval); + + const newStatus = willFail ? 'error' : 'success'; + const newErrorMessage = willFail ? errorMessage : undefined; + upload._fileItems = upload._fileItems.map(f => { + if (f.id === fileId) { + return { ...f, progress: 100, status: newStatus, errorMessage: newErrorMessage }; + } + return f; + }); + upload.requestUpdate(); + } else { + upload._fileItems = upload._fileItems.map(f => { + if (f.id === fileId) { + return { ...f, progress }; + } + return f; + }); + upload.requestUpdate(); + } + }, 150); +}; + +export const ErrorTemplate = (args) => { + const uploadId = 'upload-error-demo'; + + setTimeout(() => { + const upload = document.getElementById(uploadId); + if (upload && (!upload._fileItems || upload._fileItems.length === 0)) { + const mockFiles = [ + { + file: new File([''], 'document.xlsx', { type: 'application/vnd.ms-excel' }), + id: 'file-error-format', + name: 'document.xlsx', + size: 45000, + type: 'application/vnd.ms-excel', + status: 'uploading', + progress: 0, + }, + { + file: new File([''], 'large-image.png', { type: 'image/png' }), + id: 'file-error-size', + name: 'large-image.png', + size: 15000000, + type: 'image/png', + status: 'uploading', + progress: 0, + }, + { + file: new File([''], 'video.mp4', { type: 'video/mp4' }), + id: 'file-error-video', + name: 'video.mp4', + size: 8500000, + type: 'video/mp4', + status: 'uploading', + progress: 0, + }, + ]; + upload._fileItems = mockFiles; + upload.requestUpdate(); + + setTimeout(() => { animateProgress(upload, 'file-error-format', 100, true, 'Invalid file format, file format must be .jpg, .jpeg, .png.'); }, 100); + setTimeout(() => { animateProgress(upload, 'file-error-size', 100, true, 'File size too large: 14.31 MB (Maximum: 10 MB)'); }, 300); + setTimeout(() => { animateProgress(upload, 'file-error-video', 100, true, 'Invalid file format, file format must be .jpg, .jpeg, .png.'); }, 500); + } + }, 100); + + return html` +${TemplateHeader({...args, header: '', description: '', subHeader: 'Error State'})} + +`; +} + +export const UploadedTemplate = (args) => { + const variantName = args.variant || 'horizontal'; + const uploadId = 'upload-uploaded-' + variantName; + const successId = 'file-success-' + variantName; + const errorId = 'file-error-' + variantName; + const loadingId = 'file-loading-' + variantName; + + setTimeout(() => { + const upload = document.getElementById(uploadId); + if (upload && (!upload._fileItems || upload._fileItems.length === 0)) { + const mockFiles = [ + { + file: new File([''], 'sample-image.png', { type: 'image/png' }), + id: successId, + name: 'sample-image.png', + size: 245000, + type: 'image/png', + status: 'uploading', + progress: 0, + }, + { + file: new File([''], 'file.hec', { type: '' }), + id: errorId, + name: 'file.hec', + size: 120000, + type: '', + status: 'uploading', + progress: 0, + }, + { + file: new File([''], 'image2.jpg', { type: 'image/jpeg' }), + id: loadingId, + name: 'image2.jpg', + size: 890000, + type: 'image/jpeg', + status: 'uploading', + progress: 5, + }, + ]; + upload._fileItems = mockFiles; + upload.requestUpdate(); + + setTimeout(() => { animateProgress(upload, successId, 100, false, null); }, 100); + setTimeout(() => { animateProgress(upload, errorId, 100, true, 'Invalid file format, file format must be .jpg, .jpeg, .png.'); }, 300); + } + }, 100); + + return html` +${TemplateHeader({...args, header: '', description: '', subHeader: 'Uploaded State'})} + +`; +} + +# Upload + +[ADR](https://github.com/Trendyol/baklava/issues/1126) +[Figma](https://www.figma.com/design/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=30165-23095&t=2KatC0pHP0MvvtaW-1) + +Upload components are used for selecting and uploading files. + +## Usage + +- Clicking the "Select File" button and choosing a local file, or dragging the file into the component area, initiates the upload process. +- You can cancel the upload while the file is being transferred. +- Once the file is successfully uploaded, it will be shown with a green icon indicating approval. +- If there are any technical or formatting issues, a warning icon will appear, accompanied by a caption explaining the problem. +- If you encounter any issues, you can refresh the file by clicking the "Reload" icon. + +## Upload Components Types + +There are three different layouts for the upload component: horizontal, vertical, and button-only. + + + + {DefaultHorizontalTemplate.bind()} + + + {UploadedTemplate.bind()} + + + {DisabledHorizontalTemplate.bind()} + + + + + + {DefaultVerticalTemplate.bind()} + + + {UploadedTemplate.bind()} + + + {DisabledVerticalTemplate.bind()} + + + + + + {DefaultButtonTemplate.bind()} + + + {UploadedTemplate.bind()} + + + {DisabledButtonTemplate.bind()} + + + +## Single vs Multiple File Upload + +Upload component supports both single and multiple file selection modes. + +### Single File Upload + +When `multiple` is not set or set to `false`, only one file can be uploaded at a time. Selecting a new file replaces the previous one. + + + + {UploadTemplate.bind()} + + + +### Multiple File Upload + +When `multiple` is set to `true`, users can select and upload multiple files. You can limit the number of files with `max-files` attribute. + + + + {UploadTemplate.bind()} + + + +## Events + +The upload component emits several events for handling file operations: + +```javascript +const upload = document.querySelector('bl-upload'); + +// Fires when files are successfully selected +upload.addEventListener('bl-upload', (event) => { + console.log('Files uploaded:', event.detail.files); + // event.detail.files contains: [{ file, id, name, size, type }, ...] +}); + +// Fires when there's an error with file selection +upload.addEventListener('bl-upload-error', (event) => { + console.log('Upload errors:', event.detail.errors); + // event.detail.errors contains: [{ file, error, message }, ...] +}); + +// Fires when a file is removed +upload.addEventListener('bl-file-remove', (event) => { + console.log('File removed:', event.detail.file); +}); +``` + +## Programmatic API + +The upload component provides methods to control files programmatically: + +```javascript +const upload = document.querySelector('bl-upload'); + +// Get all uploaded files +const files = upload.files; + +// Clear all files +upload.clearFiles(); + +// Remove a specific file by id +upload.removeFile(fileId); + +// Update file status programmatically +upload.setFileStatus(fileId, 'success'); +upload.setFileStatus(fileId, 'error', 'Upload failed'); + +// Update file progress (0-100) +upload.setFileProgress(fileId, 50); +``` +## Reference + + diff --git a/src/components/upload/bl-upload.test.ts b/src/components/upload/bl-upload.test.ts new file mode 100644 index 000000000..b24a6e9e0 --- /dev/null +++ b/src/components/upload/bl-upload.test.ts @@ -0,0 +1,1521 @@ +import { assert, expect, fixture, elementUpdated, oneEvent, html } from "@open-wc/testing"; +import { spy } from "sinon"; +import BlUpload from "./bl-upload"; + +function createMockFile(name: string, size: number, type: string): File { + const content = new Array(size).fill("a").join(""); + + return new File([content], name, { type }); +} + +function createMockFileList(files: File[]): FileList { + const dataTransfer = new DataTransfer(); + + files.forEach(file => dataTransfer.items.add(file)); + return dataTransfer.files; +} + +describe("bl-upload", () => { + it("is defined", () => { + const el = document.createElement("bl-upload"); + + assert.instanceOf(el, BlUpload); + }); + + it("renders with default values", async () => { + const el = await fixture(html` + `); + + expect(el.variant).to.equal("horizontal"); + expect(el.multiple).to.equal(false); + expect(el.disabled).to.equal(false); + expect(el.showFileList).to.equal(true); + expect(el.maxFileSize).to.equal(10 * 1024 * 1024); + expect(el.maxFiles).to.equal(10); + }); + + it("renders upload container with button", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + const button = el.shadowRoot?.querySelector("bl-button"); + + expect(container).to.exist; + expect(button).to.exist; + }); + + it("renders hidden file input", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]"); + + expect(input).to.exist; + expect(input?.hasAttribute("hidden")).to.be.true; + }); + + describe("variants", () => { + it("renders horizontal variant by default", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + + expect(container?.classList.contains("variant-horizontal")).to.be.true; + }); + + it("renders vertical variant", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + + expect(container?.classList.contains("variant-vertical")).to.be.true; + }); + + it("renders button variant", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + const uploadContent = el.shadowRoot?.querySelector(".upload-content"); + + expect(container?.classList.contains("variant-button")).to.be.true; + expect(uploadContent).to.not.exist; + }); + + it("shows upload content for non-button variants", async () => { + const el = await fixture(html` + `); + const uploadContent = el.shadowRoot?.querySelector(".upload-content"); + const uploadIcon = el.shadowRoot?.querySelector(".upload-icon"); + + expect(uploadContent).to.exist; + expect(uploadIcon).to.exist; + }); + }); + + describe("attributes", () => { + it("is bound to `disabled` attribute", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]"); + const button = el.shadowRoot?.querySelector("bl-button"); + + expect(el.disabled).to.be.true; + expect(input?.hasAttribute("disabled")).to.be.true; + expect(button?.hasAttribute("disabled")).to.be.true; + }); + + it("is bound to `multiple` attribute", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]"); + + expect(el.multiple).to.be.true; + expect(input?.hasAttribute("multiple")).to.be.true; + }); + + it("is bound to `accept` attribute", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]"); + + expect(el.accept).to.equal(".jpg,.png"); + expect(input?.getAttribute("accept")).to.equal(".jpg,.png"); + }); + + it("is bound to `header-text` attribute", async () => { + const el = await fixture( + html` + ` + ); + const header = el.shadowRoot?.querySelector(".header"); + + expect(el.headerText).to.equal("Custom Header"); + expect(header?.textContent).to.equal("Custom Header"); + }); + + it("is bound to `description-text` attribute", async () => { + const el = await fixture( + html` + ` + ); + const description = el.shadowRoot?.querySelector(".description"); + + expect(el.descriptionText).to.equal("Custom Description"); + expect(description?.textContent).to.equal("Custom Description"); + }); + + it("is bound to `button-text` attribute", async () => { + const el = await fixture( + html` + ` + ); + const button = el.shadowRoot?.querySelector("bl-button"); + + expect(el.buttonText).to.equal("Upload File"); + expect(button?.textContent?.trim()).to.equal("Upload File"); + }); + + it("is bound to `max-file-size` attribute", async () => { + const el = await fixture( + html` + ` + ); + + expect(el.maxFileSize).to.equal(5242880); + }); + + it("is bound to `max-files` attribute", async () => { + const el = await fixture(html` + `); + + expect(el.maxFiles).to.equal(5); + }); + }); + + describe("file selection", () => { + it("fires bl-upload event when files are selected", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files).to.have.lengthOf(1); + expect(ev.detail.files[0].name).to.equal("test.jpg"); + }); + + it("fires bl-upload event with multiple files when multiple is true", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFiles = [ + createMockFile("test1.jpg", 1024, "image/jpeg"), + createMockFile("test2.jpg", 2048, "image/jpeg") + ]; + const fileList = createMockFileList(mockFiles); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files).to.have.lengthOf(2); + }); + + it("only accepts single file when multiple is false", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFiles = [ + createMockFile("test1.jpg", 1024, "image/jpeg"), + createMockFile("test2.jpg", 2048, "image/jpeg") + ]; + const fileList = createMockFileList(mockFiles); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev.detail.files).to.have.lengthOf(1); + }); + + it("does not process files when disabled", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + let eventFired = false; + + el.addEventListener("bl-upload", () => { + eventFired = true; + }); + + input.dispatchEvent(new Event("change")); + await elementUpdated(el); + + expect(eventFired).to.be.false; + }); + }); + + describe("file validation", () => { + it("fires bl-upload-error event for invalid file type", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.pdf", 1024, "application/pdf"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload-error"); + + expect(ev).to.exist; + expect(ev.detail.errors).to.have.lengthOf(1); + expect(ev.detail.errors[0].error).to.equal("type"); + }); + + it("fires bl-upload-error event for file exceeding max size", async () => { + const el = await fixture( + html` + ` + ); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 2048, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload-error"); + + expect(ev).to.exist; + expect(ev.detail.errors).to.have.lengthOf(1); + expect(ev.detail.errors[0].error).to.equal("size"); + }); + + it("fires bl-upload-error event when max files exceeded", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFiles = [ + createMockFile("test1.jpg", 100, "image/jpeg"), + createMockFile("test2.jpg", 100, "image/jpeg"), + createMockFile("test3.jpg", 100, "image/jpeg") + ]; + const fileList = createMockFileList(mockFiles); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload-error"); + + expect(ev).to.exist; + expect(ev.detail.errors.some((e: { error: string }) => e.error === "maxFiles")).to.be.true; + }); + + it("validates wildcard mime types correctly", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files).to.have.lengthOf(1); + }); + + it("validates extension types correctly", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("document.pdf", 1024, "application/pdf"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files).to.have.lengthOf(1); + }); + + it("validates exact mime types correctly", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("image.png", 1024, "image/png"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files).to.have.lengthOf(1); + }); + + it("allows all file types when accept is not set", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("document.xyz", 1024, "application/xyz"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files).to.have.lengthOf(1); + }); + }); + + describe("file list", () => { + it("displays uploaded files in file list", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const fileListEl = el.shadowRoot?.querySelector(".file-list"); + const fileItem = el.shadowRoot?.querySelector(".file-item"); + const fileName = el.shadowRoot?.querySelector(".file-name"); + + expect(fileListEl).to.exist; + expect(fileItem).to.exist; + expect(fileName?.textContent?.trim()).to.equal("test.jpg"); + }); + + it("does not display file list when showFileList is false", async () => { + const el = await fixture( + html` + ` + ); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const fileListEl = el.shadowRoot?.querySelector(".file-list"); + + expect(fileListEl).to.not.exist; + }); + + it("fires bl-file-remove event when remove button is clicked", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const removeButton = el.shadowRoot?.querySelector(".remove-button") as HTMLElement; + + setTimeout(() => removeButton?.click()); + const ev = await oneEvent(el, "bl-file-remove"); + + expect(ev).to.exist; + expect(ev.detail.file.name).to.equal("test.jpg"); + }); + + it("removes file from list when remove button is clicked", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(1); + + const removeButton = el.shadowRoot?.querySelector(".remove-button") as HTMLElement; + + removeButton?.click(); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(0); + }); + + it("uses bl-button for remove (not bl-icon)", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const removeControl = el.shadowRoot?.querySelector(".remove-button"); + + expect(removeControl?.tagName).to.equal("BL-BUTTON"); + }); + + it("renders file name as a button with file-name class", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("document.pdf", 1024, "application/pdf"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const fileNameButton = el.shadowRoot?.querySelector(".file-name"); + + expect(fileNameButton?.tagName).to.equal("BUTTON"); + expect(fileNameButton?.textContent?.trim()).to.equal("document.pdf"); + }); + + it("triggers download when file name button is clicked", async () => { + const createObjectURLSpy = spy(URL, "createObjectURL"); + const revokeObjectURLSpy = spy(URL, "revokeObjectURL"); + + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const fileNameButton = el.shadowRoot?.querySelector(".file-name") as HTMLButtonElement; + + fileNameButton?.click(); + + expect(createObjectURLSpy.calledOnce).to.be.true; + expect(createObjectURLSpy.firstCall.args[0]).to.be.instanceOf(File); + expect(createObjectURLSpy.firstCall.args[0]).to.have.property("name", "test.jpg"); + expect(revokeObjectURLSpy.calledOnce).to.be.true; + + createObjectURLSpy.restore(); + revokeObjectURLSpy.restore(); + }); + + it("clicking file name does not remove the file from list", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(1); + + const fileNameButton = el.shadowRoot?.querySelector(".file-name") as HTMLButtonElement; + + fileNameButton?.click(); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(1); + }); + }); + + describe("drag and drop", () => { + it("adds drag-over class to wrapper when dragging over", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + const wrapper = el.shadowRoot?.querySelector(".upload-wrapper") as HTMLElement; + + const dragOverEvent = new DragEvent("dragover", { + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(dragOverEvent); + + await elementUpdated(el); + + expect(wrapper?.classList.contains("drag-over")).to.be.true; + }); + + it("removes drag-over class from wrapper when dragging leaves", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + const wrapper = el.shadowRoot?.querySelector(".upload-wrapper") as HTMLElement; + + const dragOverEvent = new DragEvent("dragover", { + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(dragOverEvent); + await elementUpdated(el); + + const dragLeaveEvent = new DragEvent("dragleave", { + bubbles: true, + cancelable: true, + relatedTarget: document.body + }); + + container.dispatchEvent(dragLeaveEvent); + await elementUpdated(el); + + expect(wrapper?.classList.contains("drag-over")).to.be.false; + }); + + it("does not add drag-over class when disabled", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + const wrapper = el.shadowRoot?.querySelector(".upload-wrapper") as HTMLElement; + + const dragOverEvent = new DragEvent("dragover", { + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(dragOverEvent); + await elementUpdated(el); + + expect(wrapper?.classList.contains("drag-over")).to.be.false; + }); + + it("does not add drag-over class for button variant", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + const wrapper = container?.parentElement; + + const dragOverEvent = new DragEvent("dragover", { + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(dragOverEvent); + await elementUpdated(el); + + expect(wrapper?.classList.contains("drag-over")).to.be.false; + }); + }); + + describe("keyboard interaction", () => { + it("prevents default on Enter key", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + + const enterEvent = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(enterEvent); + + expect(enterEvent.defaultPrevented).to.be.true; + }); + + it("prevents default on Space key", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + + const spaceEvent = new KeyboardEvent("keydown", { + key: " ", + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(spaceEvent); + + expect(spaceEvent.defaultPrevented).to.be.true; + }); + + it("does not prevent default when disabled", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + + const enterEvent = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(enterEvent); + + expect(enterEvent.defaultPrevented).to.be.false; + }); + + it("does not prevent default for other keys", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + + const tabEvent = new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(tabEvent); + + expect(tabEvent.defaultPrevented).to.be.false; + }); + }); + + describe("public methods", () => { + it("returns files via files getter", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(1); + expect(el.files[0].name).to.equal("test.jpg"); + expect(el.files[0].size).to.equal(1024); + expect(el.files[0].type).to.equal("image/jpeg"); + }); + + it("clears all files via clearFiles method", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(1); + + el.clearFiles(); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(0); + }); + }); + + describe("callback functions", () => { + it("calls onFilesSelected callback when files are selected", async () => { + const el = await fixture(html` + `); + const callbackSpy = spy(); + + el.onFilesSelected = callbackSpy; + + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + expect(callbackSpy.calledOnce).to.be.true; + expect(callbackSpy.firstCall.args[0]).to.have.lengthOf(1); + expect(callbackSpy.firstCall.args[0][0].name).to.equal("test.jpg"); + }); + + it("calls onError callback when there are validation errors", async () => { + const el = await fixture(html` + `); + const callbackSpy = spy(); + + el.onError = callbackSpy; + + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.pdf", 1024, "application/pdf"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + expect(callbackSpy.calledOnce).to.be.true; + expect(callbackSpy.firstCall.args[0][0].error).to.equal("type"); + }); + + it("calls onFileRemoved callback when a file is removed", async () => { + const el = await fixture(html` + `); + const callbackSpy = spy(); + + el.onFileRemoved = callbackSpy; + + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const removeButton = el.shadowRoot?.querySelector(".remove-button") as HTMLElement; + + removeButton?.click(); + + await elementUpdated(el); + + expect(callbackSpy.calledOnce).to.be.true; + expect(callbackSpy.firstCall.args[0].name).to.equal("test.jpg"); + }); + }); + + describe("accessibility", () => { + it("has correct role attribute", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + + expect(container?.getAttribute("role")).to.equal("button"); + }); + + it("has correct aria-label", async () => { + const el = await fixture( + html` + ` + ); + const container = el.shadowRoot?.querySelector(".upload-container"); + + expect(container?.getAttribute("aria-label")).to.equal("Upload Files"); + }); + + it("has correct aria-disabled when disabled", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + + expect(container?.getAttribute("aria-disabled")).to.equal("true"); + }); + + it("has tabindex 0 when enabled", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + + expect(container?.getAttribute("tabindex")).to.equal("0"); + }); + + it("has tabindex -1 when disabled", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container"); + + expect(container?.getAttribute("tabindex")).to.equal("-1"); + }); + }); + + describe("upload progress simulation", () => { + it("shows spinner during uploading state", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + const spinner = el.shadowRoot?.querySelector("bl-spinner"); + + expect(spinner).to.exist; + }); + + it("transitions to success state after upload completes", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + await new Promise(resolve => setTimeout(resolve, 1500)); + await elementUpdated(el); + + const successIcon = el.shadowRoot?.querySelector("bl-icon[name=\"check_fill\"]"); + + expect(successIcon).to.exist; + }); + + it("transitions to error state for invalid files after animation", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.pdf", 1024, "application/pdf"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + await new Promise(resolve => setTimeout(resolve, 1500)); + await elementUpdated(el); + + const errorIcon = el.shadowRoot?.querySelector("bl-icon[name=\"alert\"]"); + const errorMessage = el.shadowRoot?.querySelector(".error-message"); + + expect(errorIcon).to.exist; + expect(errorMessage).to.exist; + }); + }); + + describe("drop functionality", () => { + it("processes dropped files", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const dataTransfer = new DataTransfer(); + + dataTransfer.items.add(mockFile); + + const dropEvent = new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer + }); + + setTimeout(() => container.dispatchEvent(dropEvent)); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files).to.have.lengthOf(1); + }); + + it("does not process dropped files when disabled", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const dataTransfer = new DataTransfer(); + + dataTransfer.items.add(mockFile); + + const dropEvent = new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer + }); + + let eventFired = false; + + el.addEventListener("bl-upload", () => { + eventFired = true; + }); + + container.dispatchEvent(dropEvent); + await elementUpdated(el); + + expect(eventFired).to.be.false; + }); + }); + + describe("button click handling", () => { + it("does not trigger file input when clicking bl-button element directly", async () => { + const el = await fixture(html` + `); + const button = el.shadowRoot?.querySelector("bl-button") as HTMLElement; + + const clickEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true + }); + + button.dispatchEvent(clickEvent); + await elementUpdated(el); + + expect(el).to.exist; + }); + + it("does not trigger file input when disabled and container clicked", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + + const clickEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(clickEvent); + await elementUpdated(el); + + expect(el).to.exist; + }); + }); + + describe("file type validation edge cases", () => { + it("handles file with empty type", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = new File(["content"], "test.unknown", { type: "" }); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload-error"); + + expect(ev).to.exist; + expect(ev.detail.errors[0].error).to.equal("type"); + }); + + it("shows default error message when accept is not set", async () => { + const el = await fixture(html` + `); + + el.accept = undefined; + + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(1); + }); + + it("handles zero byte file", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("empty.jpg", 0, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + + setTimeout(() => input.dispatchEvent(new Event("change"))); + const ev = await oneEvent(el, "bl-upload"); + + expect(ev).to.exist; + expect(ev.detail.files[0].size).to.equal(0); + }); + }); + + describe("button disabled state", () => { + it("does not trigger file input when button is clicked and disabled", async () => { + const el = await fixture(html` + `); + const button = el.shadowRoot?.querySelector("bl-button") as HTMLElement; + const buttonInner = button?.shadowRoot?.querySelector("button") as HTMLElement; + + if (buttonInner) { + buttonInner.click(); + } + + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(0); + }); + }); + + describe("status icon rendering", () => { + it("shows correct icon for success status", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.jpg", 1024, "image/jpeg"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await new Promise(resolve => setTimeout(resolve, 1500)); + await elementUpdated(el); + + const successIcon = el.shadowRoot?.querySelector("bl-icon[name=\"check_fill\"]"); + + expect(successIcon).to.exist; + }); + + it("shows correct icon for error status", async () => { + const el = await fixture(html` + `); + const input = el.shadowRoot?.querySelector("input[type=\"file\"]") as HTMLInputElement; + const mockFile = createMockFile("test.pdf", 1024, "application/pdf"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await new Promise(resolve => setTimeout(resolve, 1500)); + await elementUpdated(el); + + const errorIcon = el.shadowRoot?.querySelector("bl-icon[name=\"alert\"]"); + + expect(errorIcon).to.exist; + }); + }); + + describe("drop with no dataTransfer", () => { + it("handles drop event with no files gracefully", async () => { + const el = await fixture(html` + `); + const container = el.shadowRoot?.querySelector(".upload-container") as HTMLElement; + + const dropEvent = new DragEvent("drop", { + bubbles: true, + cancelable: true + }); + + container.dispatchEvent(dropEvent); + await elementUpdated(el); + + expect(el.files).to.have.lengthOf(0); + }); + }); + + describe("zero byte file formatting", () => { + it("displays 0 Bytes for zero byte file", async () => { + const el = await fixture(html` + `); + + // Access the private method directly for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (el as any)._formatFileSize(0); + + expect(result).to.equal("0 Bytes"); + }); + }); + + describe("_simulateUpload error fallback", () => { + it("uses default error message when errorMessage is undefined", async () => { + const el = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + // Add a file item manually + elAny._fileItems = [ + { + file: createMockFile("test.jpg", 1024, "image/jpeg"), + id: "test-id", + name: "test.jpg", + size: 1024, + type: "image/jpeg", + status: "uploading", + progress: 0 + } + ]; + + // Call _simulateUpload with willFail=true but no errorMessage + elAny._simulateUpload("test-id", true); + + // Wait for simulation to complete + await new Promise(resolve => setTimeout(resolve, 1500)); + await elementUpdated(el); + + // Check that the file has error status with default message + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileItem = elAny._fileItems.find((f: any) => f.id === "test-id"); + + expect(fileItem.status).to.equal("error"); + expect(fileItem.errorMessage).to.equal("Dosya yüklenemedi"); + }); + }); + + describe("_updateFileStatusWithError map branches", () => { + it("updates only the matching file and leaves others unchanged", async () => { + const el = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + // Add multiple file items + elAny._fileItems = [ + { + file: createMockFile("file1.jpg", 1024, "image/jpeg"), + id: "id-1", + name: "file1.jpg", + size: 1024, + type: "image/jpeg", + status: "uploading", + progress: 50 + }, + { + file: createMockFile("file2.jpg", 2048, "image/jpeg"), + id: "id-2", + name: "file2.jpg", + size: 2048, + type: "image/jpeg", + status: "uploading", + progress: 30 + }, + { + file: createMockFile("file3.jpg", 3072, "image/jpeg"), + id: "id-3", + name: "file3.jpg", + size: 3072, + type: "image/jpeg", + status: "uploading", + progress: 70 + } + ]; + + // Call _updateFileStatusWithError for id-2 only + elAny._updateFileStatusWithError("id-2", "error", 100, "Test error message"); + + // Check that only id-2 was updated + /* eslint-disable @typescript-eslint/no-explicit-any */ + const file1 = elAny._fileItems.find((f: any) => f.id === "id-1"); + const file2 = elAny._fileItems.find((f: any) => f.id === "id-2"); + const file3 = elAny._fileItems.find((f: any) => f.id === "id-3"); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + // file1 should be unchanged + expect(file1.status).to.equal("uploading"); + expect(file1.progress).to.equal(50); + expect(file1.errorMessage).to.be.undefined; + + // file2 should be updated + expect(file2.status).to.equal("error"); + expect(file2.progress).to.equal(100); + expect(file2.errorMessage).to.equal("Test error message"); + + // file3 should be unchanged + expect(file3.status).to.equal("uploading"); + expect(file3.progress).to.equal(70); + expect(file3.errorMessage).to.be.undefined; + }); + + it("does not modify any file when fileId does not match", async () => { + const el = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + elAny._fileItems = [ + { + file: createMockFile("file1.jpg", 1024, "image/jpeg"), + id: "id-1", + name: "file1.jpg", + size: 1024, + type: "image/jpeg", + status: "uploading", + progress: 50 + } + ]; + + // Call with non-existent id + elAny._updateFileStatusWithError("non-existent-id", "error", 100, "Error"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const file1 = elAny._fileItems.find((f: any) => f.id === "id-1"); + + expect(file1.status).to.equal("uploading"); + expect(file1.progress).to.equal(50); + expect(file1.errorMessage).to.be.undefined; + }); + }); + + describe("accept format in error message", () => { + it("formats accept prop with spaces in error message", async () => { + const el = await fixture(html``); + const input = el.shadowRoot?.querySelector('input[type="file"]') as HTMLInputElement; + const mockFile = createMockFile("test.exe", 1024, "application/exe"); + const fileList = createMockFileList([mockFile]); + + Object.defineProperty(input, "files", { value: fileList }); + input.dispatchEvent(new Event("change")); + + await new Promise(resolve => setTimeout(resolve, 1500)); + await elementUpdated(el); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileItem = elAny._fileItems.find((f: any) => f.name === "test.exe"); + + // Error message should contain formatted accept with spaces after commas + expect(fileItem.errorMessage).to.include(".jpg, .png, .pdf"); + }); + }); + + describe("_updateFileStatus progress fallback", () => { + it("preserves existing progress when progress parameter is undefined", async () => { + const el = await fixture(html``); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + // Add a file item with existing progress + elAny._fileItems = [ + { + file: createMockFile("test.jpg", 1024, "image/jpeg"), + id: "test-id", + name: "test.jpg", + size: 1024, + type: "image/jpeg", + status: "uploading", + progress: 75, + }, + ]; + + // Call _updateFileStatus without progress parameter (undefined) + elAny._updateFileStatus("test-id", "success"); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileItem = elAny._fileItems.find((f: any) => f.id === "test-id"); + + expect(fileItem.status).to.equal("success"); + expect(fileItem.progress).to.equal(75); // Should preserve original progress + }); + + it("updates progress when progress parameter is provided", async () => { + const el = await fixture(html``); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + elAny._fileItems = [ + { + file: createMockFile("test.jpg", 1024, "image/jpeg"), + id: "test-id", + name: "test.jpg", + size: 1024, + type: "image/jpeg", + status: "uploading", + progress: 75, + }, + ]; + + // Call _updateFileStatus with progress parameter + elAny._updateFileStatus("test-id", "success", 100); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileItem = elAny._fileItems.find((f: any) => f.id === "test-id"); + + expect(fileItem.status).to.equal("success"); + expect(fileItem.progress).to.equal(100); // Should update to new progress + }); + }); + + describe("_handleContainerClick bl-button check", () => { + it("does not trigger file input when clicking on bl-button element", async () => { + const el = await fixture(html``); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + let inputClicked = false; + + if (elAny._fileInput) { + elAny._fileInput.click = () => { + inputClicked = true; + }; + } + + // Create a mock event where target is BL-BUTTON + const mockTarget = document.createElement("bl-button"); + const mouseEvent = new MouseEvent("click", { bubbles: true }); + + Object.defineProperty(mouseEvent, "target", { value: mockTarget }); + + elAny._handleContainerClick(mouseEvent); + + expect(inputClicked).to.be.false; + }); + + it("does not trigger file input when clicking inside bl-button", async () => { + const el = await fixture(html``); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + let inputClicked = false; + + if (elAny._fileInput) { + elAny._fileInput.click = () => { + inputClicked = true; + }; + } + + // Create a mock target that is inside a bl-button + const blButton = document.createElement("bl-button"); + const innerSpan = document.createElement("span"); + + blButton.appendChild(innerSpan); + document.body.appendChild(blButton); + + const mouseEvent = new MouseEvent("click", { bubbles: true }); + + Object.defineProperty(mouseEvent, "target", { value: innerSpan }); + + elAny._handleContainerClick(mouseEvent); + + expect(inputClicked).to.be.false; + + // Cleanup + document.body.removeChild(blButton); + }); + + it("triggers file input when clicking on container (not bl-button)", async () => { + const el = await fixture(html``); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + let inputClicked = false; + + if (elAny._fileInput) { + elAny._fileInput.click = () => { + inputClicked = true; + }; + } + + // Create a mock event where target is a regular div + const mockTarget = document.createElement("div"); + const mouseEvent = new MouseEvent("click", { bubbles: true }); + + Object.defineProperty(mouseEvent, "target", { value: mockTarget }); + + elAny._handleContainerClick(mouseEvent); + + expect(inputClicked).to.be.true; + }); + }); + + describe("_handleButtonClick disabled branch", () => { + it("does not trigger file input when disabled", async () => { + const el = await fixture(html``); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + let inputClicked = false; + const originalClick = elAny._fileInput?.click; + + if (elAny._fileInput) { + elAny._fileInput.click = () => { + inputClicked = true; + }; + } + + // Create a MouseEvent and call _handleButtonClick directly + const mouseEvent = new MouseEvent("click", { bubbles: true }); + + elAny._handleButtonClick(mouseEvent); + + expect(inputClicked).to.be.false; + + // Restore original + if (elAny._fileInput && originalClick) { + elAny._fileInput.click = originalClick; + } + }); + + it("triggers file input when not disabled", async () => { + const el = await fixture(html``); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elAny = el as any; + + let inputClicked = false; + + if (elAny._fileInput) { + elAny._fileInput.click = () => { + inputClicked = true; + }; + } + + const mouseEvent = new MouseEvent("click", { bubbles: true }); + + elAny._handleButtonClick(mouseEvent); + + expect(inputClicked).to.be.true; + }); + }); + + describe("_getStatusIcon method", () => { + it("returns 'alert' for error status", async () => { + const el = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (el as any)._getStatusIcon("error"); + + expect(result).to.equal("alert"); + }); + + it("returns 'loading' for pending status", async () => { + const el = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (el as any)._getStatusIcon("pending"); + + expect(result).to.equal("loading"); + }); + + it("returns 'pending' for unknown status (default case)", async () => { + const el = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (el as any)._getStatusIcon("unknown"); + + expect(result).to.equal("pending"); + }); + + it("returns 'check_fill' for success status", async () => { + const el = await fixture(html` + `); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (el as any)._getStatusIcon("success"); + + expect(result).to.equal("check_fill"); + }); + }); +}); diff --git a/src/components/upload/bl-upload.ts b/src/components/upload/bl-upload.ts new file mode 100644 index 000000000..afddcc4ee --- /dev/null +++ b/src/components/upload/bl-upload.ts @@ -0,0 +1,659 @@ +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { event, EventDispatcher } from "../../utilities/event"; +import "../button/bl-button"; +import "../icon/bl-icon"; +import "../spinner/bl-spinner"; +import style from "./bl-upload.css"; + +type FileStatus = "pending" | "uploading" | "success" | "error"; + +interface FileItem { + file: File; + id: string; + name: string; + size: number; + type: string; + status: FileStatus; + progress: number; + errorMessage?: string; +} + +/** + * @tag bl-upload + * @summary Baklava Upload component with drag & drop support + * + * @cssproperty [--bl-upload-background-color=--bl-color-neutral-lightest] Background color + * @cssproperty [--bl-upload-border-color=--bl-color-neutral-lighter] Border color + * @cssproperty [--bl-upload-icon-color=--bl-color-primary] Icon color + */ +@customElement("bl-upload") +export default class BlUpload extends LitElement { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Layout variant: horizontal, vertical, or button + */ + @property({ type: String, reflect: true }) + variant: "horizontal" | "vertical" | "button" = "horizontal"; + + /** + * Accepted file types (e.g., "image/*", ".pdf,.doc", "image/png,image/jpeg") + */ + @property({ type: String }) + accept?: string; + + /** + * Allow multiple file selection + */ + @property({ type: Boolean }) + multiple = false; + + /** + * Maximum file size in bytes (default: 10MB) + */ + @property({ type: Number, attribute: "max-file-size" }) + maxFileSize = 10 * 1024 * 1024; + + /** + * Maximum number of files (only applies when multiple is true) + */ + @property({ type: Number, attribute: "max-files" }) + maxFiles = 10; + + /** + * Header text + */ + @property({ type: String, attribute: "header-text" }) + headerText = "Dosya Seç / Sürükle"; + + /** + * Description text + */ + @property({ type: String, attribute: "description-text" }) + descriptionText = "JPG, JPEG, PNG, Maksimum 10MB"; + + /** + * Button text + */ + @property({ type: String, attribute: "button-text" }) + buttonText = "Dosya Seç"; + + /** + * Disabled state + */ + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Show file list below upload area + */ + @property({ type: Boolean, attribute: "show-file-list" }) + showFileList = true; + + /** + * Auto upload files immediately after selection (simulated) + */ + @property({ type: Boolean, attribute: "auto-upload" }) + autoUpload = false; + + /** + * Callback function called when files are selected + */ + @property({ attribute: false }) + onFilesSelected?: ( + files: { file: File; id: string; name: string; size: number; type: string }[] + ) => void; + + /** + * Callback function called when there's an error + */ + @property({ attribute: false }) + onError?: ( + errors: { file: File; error: "size" | "type" | "maxFiles"; message: string }[] + ) => void; + + /** + * Callback function called when a file is removed + */ + @property({ attribute: false }) + onFileRemoved?: (file: { + file: File; + id: string; + name: string; + size: number; + type: string; + }) => void; + + @state() + private _isDragOver = false; + + @state() + private _fileItems: FileItem[] = []; + + @query('input[type="file"]') + private _fileInput!: HTMLInputElement; + + /** + * Fires when files are successfully selected or dropped + */ + @event("bl-upload") private onUpload: EventDispatcher<{ + files: { file: File; id: string; name: string; size: number; type: string }[]; + }>; + + /** + * Fires when there's an error with file selection + */ + @event("bl-upload-error") private onUploadError: EventDispatcher<{ + errors: { file: File; error: "size" | "type" | "maxFiles"; message: string }[]; + }>; + + /** + * Fires when a file is removed from the list + */ + @event("bl-file-remove") private onFileRemove: EventDispatcher<{ + file: { file: File; id: string; name: string; size: number; type: string }; + }>; + + private _generateId(): string { + return `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + private _formatFileSize(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } + + private _isValidFileType(file: File): boolean { + if (!this.accept) return true; + + const acceptedTypes = this.accept.split(",").map(t => t.trim().toLowerCase()); + + for (const acceptedType of acceptedTypes) { + if (acceptedType.endsWith("/*")) { + const category = acceptedType.slice(0, -2); + + if (file.type.toLowerCase().startsWith(category + "/")) { + return true; + } + } else if (acceptedType.startsWith(".")) { + if (file.name.toLowerCase().endsWith(acceptedType)) { + return true; + } + } else { + if (file.type.toLowerCase() === acceptedType) { + return true; + } + } + } + + return false; + } + + private _toPublicFile(item: FileItem): { + file: File; + id: string; + name: string; + size: number; + type: string; + } { + return { + file: item.file, + id: item.id, + name: item.name, + size: item.size, + type: item.type, + }; + } + + private _validateFiles(files: File[]): { + valid: FileItem[]; + errors: { file: File; error: "size" | "type" | "maxFiles"; message: string }[]; + errorFileIds: { id: string; errorMessage: string }[]; + } { + const valid: FileItem[] = []; + const errors: { file: File; error: "size" | "type" | "maxFiles"; message: string }[] = []; + const errorFileIds: { id: string; errorMessage: string }[] = []; + + const currentFileCount = this._fileItems.length; + const maxFilesToProcess = this.multiple ? this.maxFiles - currentFileCount : 1; + const filesToProcess = files.slice(0, Math.max(0, maxFilesToProcess)); + + if (files.length > maxFilesToProcess) { + for (let i = maxFilesToProcess; i < files.length; i++) { + errors.push({ + file: files[i], + error: "maxFiles", + message: `Maksimum ${this.maxFiles} dosya yüklenebilir`, + }); + } + } + + for (const file of filesToProcess) { + const fileId = this._generateId(); + + if (!this._isValidFileType(file)) { + // accept is always defined here because _isValidFileType returns true when accept is undefined + const acceptedFormats = this.accept!.replace(/,/g, ", "); + const errorMessage = `Yanlış dosya formatı, dosya formatı ${acceptedFormats} olmalıdır.`; + + valid.push({ + file, + id: fileId, + name: file.name, + size: file.size, + type: file.type, + status: "uploading", + progress: 0, + errorMessage, + }); + errorFileIds.push({ id: fileId, errorMessage }); + errors.push({ + file, + error: "type", + message: `Geçersiz dosya tipi: ${file.type || "bilinmiyor"}`, + }); + continue; + } + + if (file.size > this.maxFileSize) { + const errorMessage = `Dosya boyutu çok büyük: ${this._formatFileSize( + file.size + )} (Maksimum: ${this._formatFileSize(this.maxFileSize)})`; + + valid.push({ + file, + id: fileId, + name: file.name, + size: file.size, + type: file.type, + status: "uploading", + progress: 0, + errorMessage, + }); + errorFileIds.push({ id: fileId, errorMessage }); + errors.push({ + file, + error: "size", + message: errorMessage, + }); + continue; + } + + valid.push({ + file, + id: fileId, + name: file.name, + size: file.size, + type: file.type, + status: "uploading", + progress: 0, + }); + } + + return { valid, errors, errorFileIds }; + } + + private _simulateUpload(fileId: string, willFail: boolean = false, errorMessage?: string) { + let progress = 0; + const progressStep = willFail ? Math.random() * 15 + 10 : Math.random() * 20 + 15; + const intervalTime = 150; + + const interval = setInterval(() => { + progress += progressStep + Math.random() * 10; + + if (progress >= 100) { + progress = 100; + clearInterval(interval); + + if (willFail) { + this._updateFileStatusWithError( + fileId, + "error", + 100, + errorMessage || "Dosya yüklenemedi" + ); + } else { + this._updateFileStatus(fileId, "success", 100); + } + } else { + this._updateFileProgress(fileId, progress); + } + }, intervalTime); + } + + private _updateFileStatusWithError( + fileId: string, + status: FileStatus, + progress: number, + errorMessage: string + ) { + this._fileItems = this._fileItems.map(f => + f.id === fileId ? { ...f, status, progress, errorMessage } : f + ); + } + + private _updateFileProgress(fileId: string, progress: number) { + this._fileItems = this._fileItems.map(f => + f.id === fileId ? { ...f, progress: Math.min(100, progress) } : f + ); + } + + private _updateFileStatus(fileId: string, status: FileStatus, progress?: number) { + this._fileItems = this._fileItems.map(f => + f.id === fileId + ? { ...f, status, progress: progress !== undefined ? progress : f.progress } + : f + ); + } + + private _processFiles(files: FileList | null) { + if (!files || files.length === 0 || this.disabled) return; + + const fileArray = Array.from(files); + const { valid, errors, errorFileIds } = this._validateFiles(fileArray); + + if (valid.length > 0) { + this._fileItems = this.multiple ? [...this._fileItems, ...valid] : valid; + + valid.forEach(f => { + const errorInfo = errorFileIds.find(e => e.id === f.id); + + if (errorInfo) { + this._simulateUpload(f.id, true, errorInfo.errorMessage); + } else { + this._simulateUpload(f.id, false); + } + }); + + const publicFiles = valid.map(f => this._toPublicFile(f)); + + this.onUpload({ files: publicFiles }); + + if (this.onFilesSelected) { + this.onFilesSelected(publicFiles); + } + } + + if (errors.length > 0) { + this.onUploadError({ errors }); + + if (this.onError) { + this.onError(errors); + } + } + + if (this._fileInput) { + this._fileInput.value = ""; + } + } + + private _handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.disabled) return; + + this._isDragOver = true; + }; + + private _handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const relatedTarget = e.relatedTarget as Node; + + if (!this.shadowRoot?.contains(relatedTarget)) { + this._isDragOver = false; + } + }; + + private _handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + this._isDragOver = false; + + if (this.disabled) return; + + const files = e.dataTransfer?.files; + + this._processFiles(files ?? null); + }; + + private _handleFileInputChange = (e: Event) => { + const input = e.target as HTMLInputElement; + + this._processFiles(input.files); + }; + + private _handleButtonClick = (e: MouseEvent) => { + e.stopPropagation(); + if (this.disabled) return; + + this._fileInput?.click(); + }; + + private _handleContainerClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + if (target.tagName === "BL-BUTTON" || target.closest("bl-button")) { + return; + } + + if (this.disabled) return; + + this._fileInput?.click(); + }; + + private _handleKeyDown = (e: KeyboardEvent) => { + if (this.disabled) return; + + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + this._fileInput?.click(); + } + }; + + private _handleRemoveFile = (e: MouseEvent, fileId: string) => { + e.stopPropagation(); + const fileItem = this._fileItems.find(f => f.id === fileId); + + if (fileItem) { + this._fileItems = this._fileItems.filter(f => f.id !== fileId); + const publicFile = this._toPublicFile(fileItem); + + this.onFileRemove({ file: publicFile }); + + if (this.onFileRemoved) { + this.onFileRemoved(publicFile); + } + } + }; + + /** + * Returns the list of uploaded files + */ + get files(): { file: File; id: string; name: string; size: number; type: string }[] { + return this._fileItems.map(f => this._toPublicFile(f)); + } + + /** + * Clears all uploaded files + */ + clearFiles() { + this._fileItems = []; + if (this._fileInput) { + this._fileInput.value = ""; + } + } + + private _getStatusIcon(status: FileStatus) { + switch (status) { + case "success": + return "check_fill"; + case "error": + return "alert"; + case "pending": + return "loading"; + default: + return "pending"; + } + } + + private _renderFileList(): TemplateResult | null { + if (!this.showFileList || this._fileItems.length === 0) { + return null; + } + + return html` +
${this._fileItems.map(file => this._renderFileItem(file))}
+ `; + } + + private _renderStatusIcon(file: FileItem): TemplateResult { + if (file.status === "pending" || file.status === "uploading") { + return html` `; + } + + return html` `; + } + + private _handleFileNameClick = (e: MouseEvent, file: FileItem) => { + e.stopPropagation(); + const url = URL.createObjectURL(file.file); + const a = document.createElement("a"); + + a.href = url; + a.download = file.name; + a.rel = "download"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + private _renderFileItem(file: FileItem): TemplateResult { + const itemClasses = { + "file-item": true, + [`status-${file.status}`]: true, + }; + + const progressClasses = { + "progress-bar": true, + [`progress-${file.status}`]: true, + }; + + return html` +
+
+
+ ${this._renderStatusIcon(file)} + +
+ this._handleRemoveFile(e, file.id)} + > +
+ ${file.status === "error" && file.errorMessage + ? html`${file.errorMessage}` + : null} +
+
+
+
+ `; + } + + private _renderLayout(): TemplateResult { + const variantLayout = + this.variant !== "button" + ? html`
+ +
+ ${this.headerText} + ${this.descriptionText} +
+
` + : ""; + + return html` + ${variantLayout} + + ${this.buttonText} + + `; + } + + render(): TemplateResult { + const containerClasses = { + "upload-container": true, + "drag-over": this.variant !== "button" && this._isDragOver, + "disabled": this.disabled, + [`variant-${this.variant}`]: true, + }; + + const wrapperClasses = + `${this.variant}-wrapper ${this.variant !== "button" ? "upload-wrapper" : ""}` + + (this._isDragOver && this.variant !== "button" ? " drag-over" : ""); + + return html` +
+
+ + + ${this._renderLayout()} +
+
+ ${this._renderFileList()} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "bl-upload": BlUpload; + } +} diff --git a/src/components/upload/doc/ADR.md b/src/components/upload/doc/ADR.md new file mode 100644 index 000000000..0f1d549cb --- /dev/null +++ b/src/components/upload/doc/ADR.md @@ -0,0 +1,68 @@ +## Figma Design Document + +https://www.figma.com/design/RrcLH0mWpIUy4vwuTlDeKN/Baklava-Design-Guide?node-id=30165-23095&t=2KatC0pHP0MvvtaW-1 + +## Implementation + +General usage example: + +```html + +``` + +### Rules + +- **Variants**: Three layout variants: `horizontal`, `vertical`, and `button`. Horizontal shows title, description, icon and button in a row; vertical stacks them; button shows only the action button. +- **Selection**: Users can select files via the button or by dragging and dropping into the drop zone. +- **Single vs multiple**: When `multiple` is false, only one file is allowed; selecting again replaces the previous. When `multiple` is true, multiple files can be added, optionally limited by `max-files`. +- **Validation**: File type is validated against `accept` (e.g. `.jpg,.jpeg,.png`, `image/*`, MIME types). File size is validated against `max-file-size` (default 10MB). Errors are reported via `bl-upload-error` or `onError`. +- **File list**: When `show-file-list` is true, selected/uploaded files are listed with progress, success, or error state. Users can remove files via the remove action. +- **Progress & status**: The component does not upload by itself; the app uses `bl-upload` event (or `onFilesSelected`) to send files to the server and updates status via `setFileStatus` / `setFileProgress`. +- **Accessibility**: The drop zone and button are keyboard-accessible; file input is visually hidden but used for selection. + +### Attributes + +| Attribute | Description | Default Value | +| --------- | ----------- | ------------- | +| variant (`"horizontal" \| "vertical" \| "button"`) | Layout variant | horizontal | +| accept (`string`) | Accepted file types (e.g. `.jpg,.jpeg,.png`, `image/*`) | - | +| multiple (`boolean`) | Allow multiple file selection | false | +| max-file-size (`number`) | Maximum file size in bytes | 10485760 (10MB) | +| max-files (`number`) | Maximum number of files when multiple is true | 10 | +| header-text (`string`) | Header text | Dosya Seç / Sürükle | +| description-text (`string`) | Description text | JPG, JPEG, PNG, Maksimum 10MB | +| button-text (`string`) | Button label | Dosya Seç | +| disabled (`boolean`) | Disables the component | false | +| show-file-list (`boolean`) | Show list of selected/uploaded files | true | +| auto-upload (`boolean`) | Simulated auto-upload after selection | false | + +### Events + +| Event | Description | Payload | +| ----- | ----------- | ------- | +| `bl-upload` | Fires when files are successfully selected or dropped | `{ files: { file, id, name, size, type }[] }` | +| `bl-upload-error` | Fires when selection fails (type, size, or max count) | `{ errors: { file, error: "size" \| "type" \| "maxFiles", message }[] }` | +| `bl-file-remove` | Fires when a file is removed from the list | `{ file: { file, id, name, size, type } }` | + +### Programmatic API + +| Method / Property | Description | +| ----------------- | ----------- | +| `files` | Returns current file items (read-only). | +| `clearFiles()` | Removes all files from the list. | +| `removeFile(fileId: string)` | Removes a file by id. | +| `setFileStatus(fileId, status, errorMessage?)` | Sets file status: `'pending' \| 'uploading' \| 'success' \| 'error'`. | +| `setFileProgress(fileId, progress)` | Sets upload progress (0–100). | + +### Callbacks (alternative to events) + +| Property | Description | +| -------- | ----------- | +| onFilesSelected | Called when files are selected; receives array of `{ file, id, name, size, type }`. | +| onError | Called on validation errors; receives array of `{ file, error, message }`. | +| onFileRemoved | Called when a file is removed; receives `{ file, id, name, size, type }`. |