diff --git a/s3file/forms.py b/s3file/forms.py
index a5006bb..c545527 100644
--- a/s3file/forms.py
+++ b/s3file/forms.py
@@ -75,7 +75,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 +96,53 @@ 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."""
+ # Build attributes for the render - this includes data-* attributes
+ if attrs is None:
+ attrs = {}
+
+ # Ensure name is in attrs
+ attrs = attrs.copy() if attrs else {}
+ attrs['name'] = name
+
+ # Get all the attributes including data-* attributes from build_attrs
+ final_attrs = self.build_attrs(self.attrs, attrs)
+
+ # Build attributes string for the s3-file element
+ from django.utils.html import format_html_join
+ from django.utils.safestring import mark_safe
+ attrs_html = format_html_join(
+ ' ',
+ '{}="{}"',
+ final_attrs.items()
+ )
+
+ # Render the s3-file custom element
+ # For ClearableFileInput, we also need to show the current value and clear checkbox
+ output = []
+ if value and hasattr(value, 'url'):
+ # Show currently uploaded file (similar to ClearableFileInput)
+ output.append(format_html(
+ '
',
+ value.url,
+ value
+ ))
+ # Add clear checkbox
+ clear_checkbox_name = self.clear_checkbox_name(name)
+ clear_checkbox_id = self.clear_checkbox_id(clear_checkbox_name)
+ output.append(format_html(
+ ' Clear
',
+ clear_checkbox_name,
+ clear_checkbox_id,
+ clear_checkbox_id
+ ))
+
+ # Add the s3-file custom element
+ output.append(format_html(' ', attrs_html))
+
+ return mark_safe(''.join(str(part) for part in output))
+
def get_conditions(self, accept):
conditions = [
{"bucket": self.bucket_name},
diff --git a/s3file/static/s3file/js/s3file.js b/s3file/static/s3file/js/s3file.js
index 43d62cc..2f83883 100644
--- a/s3file/static/s3file/js/s3file.js
+++ b/s3file/static/s3file/js/s3file.js
@@ -11,33 +11,263 @@ 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 {
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._hiddenInput = document.createElement('input')
+ this._hiddenInput.type = 'file'
+ this._hiddenInput.style.display = 'none'
+
+ // Sync attributes to hidden input
+ this._syncAttributesToHiddenInput()
+
+ // Listen for file selection on hidden input
+ this._hiddenInput.addEventListener('change', () => {
+ this._files = this._hiddenInput.files
+ this.dispatchEvent(new Event('change', { bubbles: true }))
+ this.changeHandler()
+ })
+
+ // Create visible button for file selection
+ this._createButton()
+
+ // Append elements
+ this.appendChild(this._hiddenInput)
+
+ // 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))
+ }
+
+ /**
+ * Create the visible button for file selection.
+ */
+ _createButton () {
+ this._button = document.createElement('button')
+ this._button.type = 'button'
+ this._button.textContent = 'Choose File'
+ this._button.style.cssText = `
+ padding: 6px 12px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background: white;
+ cursor: pointer;
+ font-size: 14px;
+ `
+
+ this._button.addEventListener('click', () => {
+ if (!this.disabled) {
+ this._hiddenInput.click()
+ }
+ })
+
+ this.appendChild(this._button)
+
+ // Add file name display
+ this._fileNameDisplay = document.createElement('span')
+ this._fileNameDisplay.style.cssText = 'margin-left: 8px; color: #666;'
+ this.appendChild(this._fileNameDisplay)
+
+ this._updateDisplay()
+ }
+
+ /**
+ * Update the display of selected files.
+ */
+ _updateDisplay () {
+ if (!this._fileNameDisplay) return
+
+ if (this._files && this._files.length > 0) {
+ const names = Array.from(this._files).map(f => f.name).join(', ')
+ this._fileNameDisplay.textContent = names
+ } else {
+ this._fileNameDisplay.textContent = 'No file chosen'
+ }
+ }
+
+ /**
+ * Sync attributes from custom element to hidden input.
+ */
+ _syncAttributesToHiddenInput () {
+ if (!this._hiddenInput) return
+
+ const attrsToSync = ['accept', 'required', 'multiple']
+ attrsToSync.forEach(attr => {
+ if (this.hasAttribute(attr)) {
+ this._hiddenInput.setAttribute(attr, this.getAttribute(attr))
+ } else {
+ this._hiddenInput.removeAttribute(attr)
+ }
+ })
+
+ if (this.hasAttribute('disabled')) {
+ this._hiddenInput.disabled = true
+ if (this._button) {
+ this._button.disabled = true
+ this._button.style.cursor = 'not-allowed'
+ this._button.style.opacity = '0.6'
+ }
+ } else {
+ this._hiddenInput.disabled = false
+ if (this._button) {
+ this._button.disabled = false
+ this._button.style.cursor = 'pointer'
+ this._button.style.opacity = '1'
+ }
+ }
+ }
+
+ /**
+ * 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._hiddenInput) {
+ this._hiddenInput.value = ''
+ }
+ this._updateDisplay()
+ }
+ }
+
+ 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._hiddenInput) {
+ this._hiddenInput.click()
+ }
}
changeHandler () {
this.keys = []
this.upload = null
+ this._updateDisplay()
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 })
}
/**
@@ -108,11 +338,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 ['name', 'accept', 'required', 'multiple', 'disabled', '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 d46564c..84c3654 100644
--- a/tests/__tests__/s3file.test.js
+++ b/tests/__tests__/s3file.test.js
@@ -24,7 +24,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)
})
@@ -33,11 +32,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._hiddenInput !== null)
+ assert(input._hiddenInput.type === 'file')
})
test('changeHandler', () => {
diff --git a/tests/test_forms.py b/tests/test_forms.py
index c2d33ba..5e38ab2 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,20 @@ 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("")
+ # Check that data attributes are on the element
+ assert "data-url=" in html
+ assert "data-s3f-signature=" in html
+ # Check that name attribute is on the element
+ assert 'name="file"' in html
+ # The custom element will create its own UI, no input inside
+ assert ' ' in html
+
@pytest.mark.selenium
def test_no_js_error(self, driver, live_server):
driver.get(live_server + self.create_url)