Skip to content

Commit 8878e3e

Browse files
Copilotcodingjoe
andcommitted
Implement Safari-compatible autonomous custom element for S3 file upload
Co-authored-by: codingjoe <1772890+codingjoe@users.noreply.github.com>
1 parent 602816e commit 8878e3e

File tree

4 files changed

+133
-11
lines changed

4 files changed

+133
-11
lines changed

s3file/forms.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ def client(self):
7575

7676
def build_attrs(self, *args, **kwargs):
7777
attrs = super().build_attrs(*args, **kwargs)
78-
attrs["is"] = "s3-file"
7978

8079
accept = attrs.get("accept")
8180
response = self.client.generate_presigned_post(
@@ -97,6 +96,40 @@ def build_attrs(self, *args, **kwargs):
9796

9897
return defaults
9998

99+
def render(self, name, value, attrs=None, renderer=None):
100+
"""Render the widget wrapped in a custom element for Safari compatibility."""
101+
# Build attributes for the render
102+
if attrs is None:
103+
attrs = {}
104+
105+
# Get all the attributes including data-* attributes
106+
final_attrs = self.build_attrs(self.attrs, attrs)
107+
108+
# Separate data-* attributes for the wrapper from other attributes for the input
109+
wrapper_attrs = {k: v for k, v in final_attrs.items() if k.startswith("data-")}
110+
input_attrs = {k: v for k, v in final_attrs.items() if not k.startswith("data-")}
111+
112+
# Call parent's render with only non-data attributes
113+
# We need to temporarily set attrs to avoid double-adding data attributes
114+
original_attrs = self.attrs
115+
self.attrs = {}
116+
input_html = super().render(name, value, input_attrs, renderer)
117+
self.attrs = original_attrs
118+
119+
# Build wrapper attributes string
120+
from django.utils.html import format_html_join
121+
wrapper_attrs_html = format_html_join(
122+
' ',
123+
'{}="{}"',
124+
wrapper_attrs.items()
125+
)
126+
127+
# Wrap the input in the s3-file custom element
128+
if wrapper_attrs_html:
129+
return format_html('<s3-file {}>{}</s3-file>', wrapper_attrs_html, input_html)
130+
else:
131+
return format_html('<s3-file>{}</s3-file>', input_html)
132+
100133
def get_conditions(self, accept):
101134
conditions = [
102135
{"bucket": self.bucket_name},

s3file/static/s3file/js/s3file.js

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,83 @@ export function getKeyFromResponse (responseText) {
1111

1212
/**
1313
* Custom element to upload files to AWS S3.
14+
* Safari-compatible autonomous custom element that wraps a file input.
1415
*
15-
* @extends HTMLInputElement
16+
* @extends HTMLElement
1617
*/
17-
export class S3FileInput extends globalThis.HTMLInputElement {
18+
export class S3FileInput extends globalThis.HTMLElement {
1819
constructor () {
1920
super()
20-
this.type = 'file'
2121
this.keys = []
2222
this.upload = null
2323
}
2424

2525
connectedCallback () {
26+
// Find or create the input element
27+
this._input = this.querySelector('input[type="file"]')
28+
if (!this._input) {
29+
this._input = document.createElement('input')
30+
this._input.type = 'file'
31+
this._syncAttributes()
32+
this.appendChild(this._input)
33+
}
34+
2635
this.form.addEventListener('formdata', this.fromDataHandler.bind(this))
2736
this.form.addEventListener('submit', this.submitHandler.bind(this), { once: true })
2837
this.form.addEventListener('upload', this.uploadHandler.bind(this))
29-
this.addEventListener('change', this.changeHandler.bind(this))
38+
this._input.addEventListener('change', this.changeHandler.bind(this))
39+
}
40+
41+
/**
42+
* Sync attributes from the custom element to the internal input element.
43+
*/
44+
_syncAttributes () {
45+
const attrsToSync = ['name', 'accept', 'required', 'multiple', 'disabled', 'id']
46+
attrsToSync.forEach(attr => {
47+
if (this.hasAttribute(attr)) {
48+
this._input.setAttribute(attr, this.getAttribute(attr))
49+
}
50+
})
51+
}
52+
53+
/**
54+
* Proxy properties to the internal input element.
55+
*/
56+
get files () {
57+
return this._input ? this._input.files : []
58+
}
59+
60+
get name () {
61+
return this._input ? this._input.name : this.getAttribute('name') || ''
62+
}
63+
64+
set name (value) {
65+
if (this._input) {
66+
this._input.name = value
67+
}
68+
this.setAttribute('name', value)
69+
}
70+
71+
get form () {
72+
return this._input ? this._input.form : this.closest('form')
73+
}
74+
75+
get validity () {
76+
return this._input ? this._input.validity : { valid: true }
77+
}
78+
79+
get validationMessage () {
80+
return this._input ? this._input.validationMessage : ''
81+
}
82+
83+
setCustomValidity (message) {
84+
if (this._input) {
85+
this._input.setCustomValidity(message)
86+
}
87+
}
88+
89+
reportValidity () {
90+
return this._input ? this._input.reportValidity() : true
3091
}
3192

3293
changeHandler () {
@@ -113,6 +174,23 @@ export class S3FileInput extends globalThis.HTMLInputElement {
113174
}
114175
}
115176
}
177+
178+
/**
179+
* Called when observed attributes change.
180+
*/
181+
static get observedAttributes () {
182+
return ['name', 'accept', 'required', 'multiple', 'disabled', 'id']
183+
}
184+
185+
attributeChangedCallback (name, oldValue, newValue) {
186+
if (this._input && oldValue !== newValue) {
187+
if (newValue === null) {
188+
this._input.removeAttribute(name)
189+
} else {
190+
this._input.setAttribute(name, newValue)
191+
}
192+
}
193+
}
116194
}
117195

118-
globalThis.customElements.define('s3-file', S3FileInput, { extends: 'input' })
196+
globalThis.customElements.define('s3-file', S3FileInput)

tests/__tests__/s3file.test.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ describe('getKeyFromResponse', () => {
2424
describe('S3FileInput', () => {
2525
test('constructor', () => {
2626
const input = new s3file.S3FileInput()
27-
assert.strictEqual(input.type, 'file')
2827
assert.deepStrictEqual(input.keys, [])
2928
assert.strictEqual(input.upload, null)
3029
})
@@ -33,11 +32,11 @@ describe('S3FileInput', () => {
3332
const form = document.createElement('form')
3433
document.body.appendChild(form)
3534
const input = new s3file.S3FileInput()
36-
input.addEventListener = mock.fn(input.addEventListener)
3735
form.addEventListener = mock.fn(form.addEventListener)
3836
form.appendChild(input)
3937
assert(form.addEventListener.mock.calls.length === 3)
40-
assert(input.addEventListener.mock.calls.length === 1)
38+
assert(input._input !== null)
39+
assert(input._input.type === 'file')
4140
})
4241

4342
test('changeHandler', () => {

tests/test_forms.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ def test_clear(self, filemodel):
127127

128128
def test_build_attr(self, freeze_upload_folder):
129129
assert set(ClearableFileInput().build_attrs({}).keys()) == {
130-
"is",
131130
"data-url",
132131
"data-fields-x-amz-algorithm",
133132
"data-fields-x-amz-date",
@@ -141,7 +140,6 @@ def test_build_attr(self, freeze_upload_folder):
141140
ClearableFileInput().build_attrs({})["data-s3f-signature"]
142141
== "VRIPlI1LCjUh1EtplrgxQrG8gSAaIwT48mMRlwaCytI"
143142
)
144-
assert ClearableFileInput().build_attrs({})["is"] == "s3-file"
145143

146144
def test_get_conditions(self, freeze_upload_folder):
147145
conditions = ClearableFileInput().get_conditions(None)
@@ -182,6 +180,20 @@ def test_accept(self):
182180
"application/pdf,image/*"
183181
)
184182

183+
def test_render_wraps_in_s3_file_element(self, freeze_upload_folder):
184+
widget = ClearableFileInput()
185+
html = widget.render(name="file", value=None)
186+
# Check that the output is wrapped in s3-file custom element
187+
assert html.startswith("<s3-file")
188+
assert html.endswith("</s3-file>")
189+
# Check that data attributes are on the wrapper
190+
assert "data-url=" in html
191+
assert "data-s3f-signature=" in html
192+
# Check that input element is inside
193+
assert '<input' in html
194+
assert 'type="file"' in html
195+
assert 'name="file"' in html
196+
185197
@pytest.mark.selenium
186198
def test_no_js_error(self, driver, live_server):
187199
driver.get(live_server + self.create_url)

0 commit comments

Comments
 (0)