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( + '
Currently: {}
', + 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_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)