diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bdb1bc9..acb9ec3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,11 +64,6 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- - name: Install Selenium
- run: |
- curl -LsSfO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
- sudo dpkg -i google-chrome-stable_current_amd64.deb || sudo apt-get -f install -y
- if: matrix.os == 'ubuntu-latest'
- uses: astral-sh/setup-uv@v7
- run: uv run pytest -m selenium
- uses: codecov/codecov-action@v5
diff --git a/s3file/forms.py b/s3file/forms.py
index a9f3453..0565602 100644
--- a/s3file/forms.py
+++ b/s3file/forms.py
@@ -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' {
+ 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))
+ }
+
+ /**
+ * 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)
diff --git a/tests/__tests__/s3file.test.js b/tests/__tests__/s3file.test.js
index c5582d5..d63d794 100644
--- a/tests/__tests__/s3file.test.js
+++ b/tests/__tests__/s3file.test.js
@@ -26,7 +26,6 @@ describe("getKeyFromResponse", () => {
describe("S3FileInput", () => {
test("constructor", () => {
const input = new s3file.S3FileInput()
- assert.strictEqual(input.type, "file")
assert.deepStrictEqual(input.keys, [])
assert.strictEqual(input.upload, null)
})
@@ -35,11 +34,11 @@ describe("S3FileInput", () => {
const form = document.createElement("form")
document.body.appendChild(form)
const input = new s3file.S3FileInput()
- input.addEventListener = mock.fn(input.addEventListener)
form.addEventListener = mock.fn(form.addEventListener)
form.appendChild(input)
assert(form.addEventListener.mock.calls.length === 3)
- assert(input.addEventListener.mock.calls.length === 1)
+ assert(input._fileInput !== null)
+ assert(input._fileInput.type === "file")
})
test("changeHandler", () => {
diff --git a/tests/test_forms.py b/tests/test_forms.py
index e05d586..7afca9a 100644
--- a/tests/test_forms.py
+++ b/tests/test_forms.py
@@ -127,7 +127,6 @@ def test_clear(self, filemodel):
def test_build_attr(self, freeze_upload_folder):
assert set(ClearableFileInput().build_attrs({}).keys()) == {
- "is",
"data-url",
"data-fields-x-amz-algorithm",
"data-fields-x-amz-date",
@@ -141,7 +140,6 @@ def test_build_attr(self, freeze_upload_folder):
ClearableFileInput().build_attrs({})["data-s3f-signature"]
== "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI"
)
- assert ClearableFileInput().build_attrs({})["is"] == "s3-file"
def test_get_conditions(self, freeze_upload_folder):
conditions = ClearableFileInput().get_conditions(None)
@@ -182,6 +180,12 @@ def test_accept(self):
"application/pdf,image/*"
)
+ def test_render_wraps_in_s3_file_element(self, freeze_upload_folder):
+ widget = ClearableFileInput()
+ html = widget.render(name="file", value=None)
+ # Check that the output is the s3-file custom element
+ assert html.startswith("