-
-
Notifications
You must be signed in to change notification settings - Fork 18
Fix #327 -- Swith to autonomous custom element for Safari support #357
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
2218cd3
5715ff8
2d36f16
5e843c6
0140695
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ | |
| from django.templatetags.static import static | ||
| from django.utils.functional import cached_property | ||
| from django.utils.html import format_html, html_safe | ||
| from django.utils.safestring import mark_safe | ||
| from storages.utils import safe_join | ||
|
|
||
| from s3file.middleware import S3FileMiddleware | ||
|
|
@@ -75,7 +76,6 @@ def client(self): | |
|
|
||
| def build_attrs(self, *args, **kwargs): | ||
| attrs = super().build_attrs(*args, **kwargs) | ||
| attrs["is"] = "s3-file" | ||
|
|
||
| accept = attrs.get("accept") | ||
| response = self.client.generate_presigned_post( | ||
|
|
@@ -97,6 +97,14 @@ def build_attrs(self, *args, **kwargs): | |
|
|
||
| return defaults | ||
|
|
||
| def render(self, name, value, attrs=None, renderer=None): | ||
| """Render the widget as a custom element for Safari compatibility.""" | ||
| return mark_safe( # noqa: S308 | ||
| str(super().render(name, value, attrs=attrs, renderer=renderer)).replace( | ||
| f'<input type="{self.input_type}"', "<s3-file" | ||
| ) | ||
| ) | ||
|
||
|
|
||
| def get_conditions(self, accept): | ||
| conditions = [ | ||
| {"bucket": self.bucket_name}, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| /** | ||
| * Parse XML response from AWS S3 and return the file key. | ||
| * | ||
| * @param {string} responseText - XML response form AWS S3. | ||
| * @param {string} responseText - XML response from AWS S3. | ||
| * @return {string} - Key from response. | ||
| */ | ||
| export function getKeyFromResponse(responseText) { | ||
|
|
@@ -11,33 +11,201 @@ export function getKeyFromResponse(responseText) { | |
|
|
||
| /** | ||
| * Custom element to upload files to AWS S3. | ||
| * Safari-compatible autonomous custom element that acts as a file input. | ||
| * | ||
| * @extends HTMLInputElement | ||
| * @extends HTMLElement | ||
| */ | ||
| export class S3FileInput extends globalThis.HTMLInputElement { | ||
| export class S3FileInput extends globalThis.HTMLElement { | ||
| static passThroughAttributes = ["accept", "required", "multiple", "class", "style"] | ||
| constructor() { | ||
| super() | ||
| this.type = "file" | ||
| this.keys = [] | ||
| this.upload = null | ||
| this._files = [] | ||
| this._validationMessage = "" | ||
| this._internals = null | ||
|
|
||
| // Try to attach ElementInternals for form participation | ||
| try { | ||
| this._internals = this.attachInternals?.() | ||
| } catch (e) { | ||
| // ElementInternals not supported | ||
| } | ||
| } | ||
|
|
||
| connectedCallback() { | ||
| this.form.addEventListener("formdata", this.fromDataHandler.bind(this)) | ||
| this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true }) | ||
| this.form.addEventListener("upload", this.uploadHandler.bind(this)) | ||
| this.addEventListener("change", this.changeHandler.bind(this)) | ||
| // Create a hidden file input for the file picker functionality | ||
| this._fileInput = document.createElement("input") | ||
| this._fileInput.type = "file" | ||
|
|
||
| // Sync attributes to hidden input | ||
| this._syncAttributesToHiddenInput() | ||
|
|
||
| // Listen for file selection on hidden input | ||
| this._fileInput.addEventListener("change", () => { | ||
| this._files = this._fileInput.files | ||
| this.dispatchEvent(new Event("change", { bubbles: true })) | ||
| this.changeHandler() | ||
| }) | ||
|
|
||
| // Append elements | ||
| this.appendChild(this._fileInput) | ||
|
|
||
| // Setup form event listeners | ||
| this.form?.addEventListener("formdata", this.fromDataHandler.bind(this)) | ||
| this.form?.addEventListener("submit", this.submitHandler.bind(this), { | ||
| once: true, | ||
| }) | ||
| this.form?.addEventListener("upload", this.uploadHandler.bind(this)) | ||
| } | ||
codingjoe marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Sync attributes from custom element to hidden input. | ||
| */ | ||
| _syncAttributesToHiddenInput() { | ||
| if (!this._fileInput) return | ||
|
|
||
| S3FileInput.passThroughAttributes.forEach((attr) => { | ||
| if (this.hasAttribute(attr)) { | ||
| this._fileInput.setAttribute(attr, this.getAttribute(attr)) | ||
| } else { | ||
| this._fileInput.removeAttribute(attr) | ||
| } | ||
| }) | ||
|
|
||
| this._fileInput.disabled = this.hasAttribute("disabled") | ||
| } | ||
|
|
||
| /** | ||
| * Implement HTMLInputElement-like properties. | ||
| */ | ||
| get files() { | ||
| return this._files | ||
| } | ||
|
|
||
| get type() { | ||
| return "file" | ||
| } | ||
|
|
||
| get name() { | ||
| return this.getAttribute("name") || "" | ||
| } | ||
|
|
||
| set name(value) { | ||
| this.setAttribute("name", value) | ||
| } | ||
|
|
||
| get value() { | ||
| if (this._files && this._files.length > 0) { | ||
| return this._files[0].name | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| set value(val) { | ||
| // Setting value on file inputs is restricted for security | ||
| if (val === "" || val === null) { | ||
| this._files = [] | ||
| if (this._fileInput) { | ||
| this._fileInput.value = "" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| get form() { | ||
| return this._internals?.form || this.closest("form") | ||
| } | ||
|
|
||
| get disabled() { | ||
| return this.hasAttribute("disabled") | ||
| } | ||
|
|
||
| set disabled(value) { | ||
| if (value) { | ||
| this.setAttribute("disabled", "") | ||
| } else { | ||
| this.removeAttribute("disabled") | ||
| } | ||
| } | ||
|
|
||
| get required() { | ||
| return this.hasAttribute("required") | ||
| } | ||
|
|
||
| set required(value) { | ||
| if (value) { | ||
| this.setAttribute("required", "") | ||
| } else { | ||
| this.removeAttribute("required") | ||
| } | ||
| } | ||
|
|
||
| get validity() { | ||
| if (this._internals) { | ||
| return this._internals.validity | ||
| } | ||
| // Create a basic ValidityState-like object | ||
| const isValid = !this.required || (this._files && this._files.length > 0) | ||
| return { | ||
| valid: isValid && !this._validationMessage, | ||
| valueMissing: this.required && (!this._files || this._files.length === 0), | ||
| customError: !!this._validationMessage, | ||
| badInput: false, | ||
| patternMismatch: false, | ||
| rangeOverflow: false, | ||
| rangeUnderflow: false, | ||
| stepMismatch: false, | ||
| tooLong: false, | ||
| tooShort: false, | ||
| typeMismatch: false, | ||
| } | ||
| } | ||
|
|
||
| get validationMessage() { | ||
| return this._validationMessage | ||
| } | ||
|
|
||
| setCustomValidity(message) { | ||
| this._validationMessage = message || "" | ||
| if (this._internals && typeof this._internals.setValidity === "function") { | ||
| if (message) { | ||
| this._internals.setValidity({ customError: true }, message) | ||
| } else { | ||
| this._internals.setValidity({}) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| reportValidity() { | ||
| const validity = this.validity | ||
| if (validity && !validity.valid) { | ||
| this.dispatchEvent(new Event("invalid", { bubbles: false, cancelable: true })) | ||
| return false | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| checkValidity() { | ||
| return this.validity.valid | ||
| } | ||
|
|
||
| click() { | ||
| if (this._fileInput) { | ||
| this._fileInput.click() | ||
| } | ||
| } | ||
|
|
||
| changeHandler() { | ||
| this.keys = [] | ||
| this.upload = null | ||
| try { | ||
| this.form.removeEventListener("submit", this.submitHandler.bind(this)) | ||
| this.form?.removeEventListener("submit", this.submitHandler.bind(this)) | ||
| } catch (error) { | ||
| console.debug(error) | ||
| } | ||
| this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true }) | ||
| this.form?.addEventListener("submit", this.submitHandler.bind(this), { | ||
| once: true, | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -48,15 +216,15 @@ export class S3FileInput extends globalThis.HTMLInputElement { | |
| */ | ||
| async submitHandler(event) { | ||
| event.preventDefault() | ||
| this.form.dispatchEvent(new window.CustomEvent("upload")) | ||
| await Promise.all(this.form.pendingRquests) | ||
| this.form.requestSubmit(event.submitter) | ||
| this.form?.dispatchEvent(new window.CustomEvent("upload")) | ||
| await Promise.all(this.form?.pendingRquests) | ||
|
||
| this.form?.requestSubmit(event.submitter) | ||
| } | ||
|
|
||
| uploadHandler() { | ||
| if (this.files.length && !this.upload) { | ||
| this.upload = this.uploadFiles() | ||
| this.form.pendingRquests = this.form.pendingRquests || [] | ||
| this.form.pendingRquests = this.form?.pendingRquests || [] | ||
| this.form.pendingRquests.push(this.upload) | ||
|
||
| } | ||
| } | ||
|
|
@@ -99,7 +267,10 @@ export class S3FileInput extends globalThis.HTMLInputElement { | |
| s3Form.append("file", file) | ||
| console.debug("uploading", this.dataset.url, file) | ||
| try { | ||
| const response = await fetch(this.dataset.url, { method: "POST", body: s3Form }) | ||
| const response = await fetch(this.dataset.url, { | ||
| method: "POST", | ||
| body: s3Form, | ||
| }) | ||
| if (response.status === 201) { | ||
| this.keys.push(getKeyFromResponse(await response.text())) | ||
| } else { | ||
|
|
@@ -108,11 +279,29 @@ export class S3FileInput extends globalThis.HTMLInputElement { | |
| } | ||
| } catch (error) { | ||
| console.error(error) | ||
| this.setCustomValidity(error) | ||
| this.setCustomValidity(String(error)) | ||
| this.reportValidity() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Called when observed attributes change. | ||
| */ | ||
| static get observedAttributes() { | ||
| return this.passThroughAttributes.concat(["name", "id"]) | ||
| } | ||
|
|
||
| attributeChangedCallback(name, oldValue, newValue) { | ||
| this._syncAttributesToHiddenInput() | ||
| } | ||
|
|
||
| /** | ||
| * Declare this element as a form-associated custom element. | ||
| */ | ||
| static get formAssociated() { | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| globalThis.customElements.define("s3-file", S3FileInput, { extends: "input" }) | ||
| globalThis.customElements.define("s3-file", S3FileInput) | ||
Uh oh!
There was an error while loading. Please reload this page.