diff --git a/.changeset/chilly-signs-work.md b/.changeset/chilly-signs-work.md new file mode 100644 index 00000000..722ff5c3 --- /dev/null +++ b/.changeset/chilly-signs-work.md @@ -0,0 +1,6 @@ +--- +"@tapsioss/web-components": patch +--- + +Resolve issues related to setting files and value in file-input. + \ No newline at end of file diff --git a/packages/web-components/src/file-input/Validator.ts b/packages/web-components/src/file-input/Validator.ts index e4c177d5..74f554f4 100644 --- a/packages/web-components/src/file-input/Validator.ts +++ b/packages/web-components/src/file-input/Validator.ts @@ -21,11 +21,20 @@ class FileInputValidator extends Validator { if (!this._control) { // Lazily create the platform input this._control = document.createElement("input"); - this._control.type = "text"; + this._control.type = "file"; } - this._control.required = state.required; - this._control.value = state.value; + if (!state.value && state.required) { + const shadowControl = document.createElement("input"); + + shadowControl.type = "text"; + shadowControl.required = true; + + return { + validity: this._control.validity, + validationMessage: this._control.validationMessage, + }; + } return { validity: this._control.validity, diff --git a/packages/web-components/src/file-input/file-input.style.ts b/packages/web-components/src/file-input/file-input.style.ts index b1f700f5..5e839562 100644 --- a/packages/web-components/src/file-input/file-input.style.ts +++ b/packages/web-components/src/file-input/file-input.style.ts @@ -93,9 +93,14 @@ const styles: CSSResult = css` cursor: pointer; position: absolute; - - inset: 0; - z-index: 1; + width: 1px; + height: 1px; + padding: 0; + margin: -1; + border: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; } .input:focus-visible + .file-input { diff --git a/packages/web-components/src/file-input/file-input.test.ts b/packages/web-components/src/file-input/file-input.test.ts index 8786d7e0..d3a70a08 100644 --- a/packages/web-components/src/file-input/file-input.test.ts +++ b/packages/web-components/src/file-input/file-input.test.ts @@ -8,10 +8,19 @@ import { setupMocks, test, } from "@internals/test-helpers"; +import type { Locator } from "@playwright/test"; import * as path from "path"; import { ErrorMessages, scope } from "./constants.ts"; describe("🧩 file-input", () => { + const getSelectedFiles = async (fileInput: Locator): Promise => { + return await fileInput + .evaluateHandle((el: HTMLInputElement) => { + return el.files?.length ?? 0; + }) + .then(handle => handle.jsonValue()); + }; + afterEach(async ({ page }) => { await disposeMocks(page); }); @@ -186,7 +195,7 @@ describe("🧩 file-input", () => { const fileInput = page.getByTestId("test-file-input"); // At first, no files are selected. - await expect(fileInput).toHaveJSProperty("files", null); + expect(await getSelectedFiles(fileInput)).toBe(0); const fileChooserPromise = page.waitForEvent("filechooser"); @@ -195,7 +204,7 @@ describe("🧩 file-input", () => { // if we reach here, it means the file input has been opened! await fileChooser.setFiles(path.resolve("src", "file-input", "index.ts")); - await expect(fileInput).not.toHaveJSProperty("files", null); + expect(await getSelectedFiles(fileInput)).toBe(1); // We should show to user the file name when it's not an image. const previewSection = page.locator("tapsi-file-input .text"); @@ -214,7 +223,7 @@ describe("🧩 file-input", () => { const fileInput = page.getByTestId("test-file-input"); // At first, no files are selected. - await expect(fileInput).toHaveJSProperty("files", null); + expect(await getSelectedFiles(fileInput)).toBe(0); const fileChooserPromise = page.waitForEvent("filechooser"); diff --git a/packages/web-components/src/file-input/file-input.ts b/packages/web-components/src/file-input/file-input.ts index 353cd4b8..9431939f 100644 --- a/packages/web-components/src/file-input/file-input.ts +++ b/packages/web-components/src/file-input/file-input.ts @@ -11,7 +11,6 @@ import { } from "lit"; import { property, query, queryAssignedNodes, state } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; -import { live } from "lit/directives/live.js"; import { createValidator, dispatchActivationClick, @@ -74,6 +73,12 @@ export class FileInput extends BaseClass { /** @internal */ public static override readonly styles: CSSResultGroup = [styles]; + /** @internal */ + static override shadowRootOptions: ShadowRootInit = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + /** @internal */ declare addEventListener: ( type: K, @@ -205,15 +210,6 @@ export class FileInput extends BaseClass { @property() public capture = ""; - /** - * The list of selected files. - * - * @prop {FileList | null} files - * @default null - */ - @property({ attribute: false }) - public files: FileList | null = null; - /** * Specifying what file format does the file input accepts. * @@ -289,24 +285,19 @@ export class FileInput extends BaseClass { /** * The current value of the input. It is always a string. - * - * @prop {string} value - * @attr {string} value - * @default "" */ - @property() - public set value(newValue: string) { - if (newValue) { - logger(ErrorMessages.INVALID_VALUE, scope, "error"); - - return; - } + public get value(): string { + if (!this.renderRoot) return ""; + if (!this._input) return ""; - this._value = newValue; + return this._input.value; } - public get value(): string { - return this._value; + /** + * The list of selected files. + */ + public get files(): FileList | null { + return this._input?.files ?? null; } @state() @@ -338,15 +329,12 @@ export class FileInput extends BaseClass { @query("#input", true) private _input!: HTMLInputElement | null; - private _value: string = ""; - constructor() { super(); registerIconButton(); registerSpinner(); - this._handleDrop = this._handleDrop.bind(this); this._handleActivationClick = this._handleActivationClick.bind(this); } @@ -384,8 +372,6 @@ export class FileInput extends BaseClass { public override connectedCallback(): void { super.connectedCallback(); - this.addEventListener("drop", this._handleDrop); - // eslint-disable-next-line @typescript-eslint/no-misused-promises this.addEventListener("click", this._handleActivationClick); } @@ -394,8 +380,6 @@ export class FileInput extends BaseClass { public override disconnectedCallback(): void { super.disconnectedCallback(); - this.removeEventListener("drop", this._handleDrop); - // eslint-disable-next-line @typescript-eslint/no-misused-promises this.removeEventListener("click", this._handleActivationClick); } @@ -410,23 +394,25 @@ export class FileInput extends BaseClass { this._root?.blur(); } - private _handleInput(event: Event) { - if (this.disabled) return; + /** @internal */ + public override click(): void { + if (!this._isInteractable) return; - const target = event.target as HTMLInputElement; + this._input?.click(); + } - this.files = target.files; + private _handleInput() { + if (!this._isInteractable) return; const file = this.files?.[0]; if (file) { - this._value = target.value; this._previewSrc = URL.createObjectURL(file); - } + } else this._previewSrc = null; } private _handleChange(event: Event) { - if (this.disabled) return; + if (!this._isInteractable) return; // 'change' event is not composed, re-dispatch it. redispatchEvent(this, event); @@ -440,12 +426,14 @@ export class FileInput extends BaseClass { private _handleDrop(event: DragEvent) { event.preventDefault(); + event.stopPropagation(); if (!event.dataTransfer) return; const files = event.dataTransfer.files; if (!files || files.length === 0) return; + if (!this.multiple && files.length > 1) return; const input = this._input; @@ -453,8 +441,6 @@ export class FileInput extends BaseClass { input.files = files; - this.files = files; - this._value = input.value; this._previewSrc = URL.createObjectURL(files[0]!); this.dispatchEvent(new Event("change", { bubbles: true })); @@ -462,7 +448,7 @@ export class FileInput extends BaseClass { } private async _handleActivationClick(event: MouseEvent) { - if (this.disabled) return; + if (!this._isInteractable) return; // allow event to propagate to user code after a microtask. await waitAMicrotask(); @@ -480,12 +466,12 @@ export class FileInput extends BaseClass { /** @internal */ public override [getFormValue](): string { - return this._value; + return this.value; } /** @internal */ public override [getFormState](): string { - return String(this._value); + return String(this.value); } /** @internal */ @@ -493,16 +479,12 @@ export class FileInput extends BaseClass { this.reset(); } - /** @internal */ - public override formStateRestoreCallback(state: string): void { - this._value = state; - } - /** @internal */ public override [createValidator](): FileInputValidator { return new FileInputValidator(() => ({ required: this.required ?? false, value: this.value ?? "", + multiple: this.multiple ?? false, })); } @@ -545,7 +527,7 @@ export class FileInput extends BaseClass { } private _logErrors() { - if (this._isLoading() && this.error) { + if (this._isLoading && this.error) { logger( ErrorMessages.ERROR_AND_LOADING_ATTRIBUTES_AT_THE_SAME_TIME, scope, @@ -574,7 +556,10 @@ export class FileInput extends BaseClass { * Reset the text field to its default value. */ public reset(): void { - this._value = ""; + if (this._input) { + this._input.value = ""; + } + this._previewSrc = null; this._nativeError = false; this._nativeErrorText = ""; @@ -590,16 +575,27 @@ export class FileInput extends BaseClass { return this._hasError() && errorText ? errorText : this.supportingText; } - private _isLoading() { + private get _isLoading() { return this.loading.toString() !== "false"; } + private get _isInteractable() { + return !this.disabled && !this._isLoading; + } - private _handleClear() { - this.reset(); - const event = new ClearEvent(); + private _handleClick() { + if (!this._isInteractable) return; + + this.click(); + } + + private _handleClear(event: MouseEvent) { + if (!this._isInteractable) return; - this.dispatchEvent(event); event.stopPropagation(); + event.preventDefault(); + + this.reset(); + this.dispatchEvent(new ClearEvent()); } private _handleRetry() { @@ -811,10 +807,15 @@ export class FileInput extends BaseClass { `; } + private _preventDragMove(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + } + private _renderFileInputContent() { if (this._hasError()) return this._renderErrorState(); - if (this._isLoading()) return this._renderLoadingState(); + if (this._isLoading) return this._renderLoadingState(); if (this.value) return this._renderPreview(); @@ -822,26 +823,26 @@ export class FileInput extends BaseClass { } private _renderClearIcon() { - if (this._value) - return html` - ${clear} - `; - - return null; + if (!this.value) return null; + + return html` + ${clear} + `; } protected override render(): TemplateResult { const rootClasses = classMap({ root: true, disabled: this.disabled, - loading: this._isLoading(), + loading: this._isLoading, readonly: this.readOnly, error: this._hasError(), }); @@ -856,12 +857,15 @@ export class FileInput extends BaseClass { part="root" class=${rootClasses} ?inert=${this.disabled} - tabindex=${this.disabled ? "-1" : "0"} > ${this._renderLabel()}