Skip to content

Commit 2218cd3

Browse files
committed
Fix #327 -- Swith to autonomous custom element for Safari support
Apple decided they don't want to support customized built-in elements, see also: WebKit/standards-positions#97
1 parent 52cd30b commit 2218cd3

File tree

5 files changed

+242
-45
lines changed

5 files changed

+242
-45
lines changed

.github/workflows/ci.yml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,6 @@ jobs:
6464
runs-on: ${{ matrix.os }}
6565
steps:
6666
- uses: actions/checkout@v6
67-
- name: Install Selenium
68-
run: |
69-
curl -LsSfO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
70-
sudo dpkg -i google-chrome-stable_current_amd64.deb || sudo apt-get -f install -y
71-
if: matrix.os == 'ubuntu-latest'
7267
- uses: astral-sh/setup-uv@v7
7368
- run: uv run pytest -m selenium
7469
- uses: codecov/codecov-action@v5

s3file/forms.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.templatetags.static import static
88
from django.utils.functional import cached_property
99
from django.utils.html import format_html, html_safe
10+
from django.utils.safestring import mark_safe
1011
from storages.utils import safe_join
1112

1213
from s3file.middleware import S3FileMiddleware
@@ -75,7 +76,6 @@ def client(self):
7576

7677
def build_attrs(self, *args, **kwargs):
7778
attrs = super().build_attrs(*args, **kwargs)
78-
attrs["is"] = "s3-file"
7979

8080
accept = attrs.get("accept")
8181
response = self.client.generate_presigned_post(
@@ -97,6 +97,14 @@ def build_attrs(self, *args, **kwargs):
9797

9898
return defaults
9999

100+
def render(self, name, value, attrs=None, renderer=None):
101+
"""Render the widget as a custom element for Safari compatibility."""
102+
return mark_safe( # noqa: S308
103+
str(super().render(name, value, attrs=attrs, renderer=renderer)).replace(
104+
f'<input type="{self.input_type}"', "<s3-file"
105+
)
106+
)
107+
100108
def get_conditions(self, accept):
101109
conditions = [
102110
{"bucket": self.bucket_name},

s3file/static/s3file/js/s3file.js

Lines changed: 206 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Parse XML response from AWS S3 and return the file key.
33
*
4-
* @param {string} responseText - XML response form AWS S3.
4+
* @param {string} responseText - XML response from AWS S3.
55
* @return {string} - Key from response.
66
*/
77
export function getKeyFromResponse(responseText) {
@@ -11,33 +11,201 @@ export function getKeyFromResponse(responseText) {
1111

1212
/**
1313
* Custom element to upload files to AWS S3.
14+
* Safari-compatible autonomous custom element that acts as 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 {
19+
static passThroughAttributes = ["accept", "required", "multiple", "class", "style"]
1820
constructor() {
1921
super()
20-
this.type = "file"
2122
this.keys = []
2223
this.upload = null
24+
this._files = []
25+
this._validationMessage = ""
26+
this._internals = null
27+
28+
// Try to attach ElementInternals for form participation
29+
try {
30+
this._internals = this.attachInternals?.()
31+
} catch (e) {
32+
// ElementInternals not supported
33+
}
2334
}
2435

2536
connectedCallback() {
26-
this.form.addEventListener("formdata", this.fromDataHandler.bind(this))
27-
this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true })
28-
this.form.addEventListener("upload", this.uploadHandler.bind(this))
29-
this.addEventListener("change", this.changeHandler.bind(this))
37+
// Create a hidden file input for the file picker functionality
38+
this._fileInput = document.createElement("input")
39+
this._fileInput.type = "file"
40+
41+
// Sync attributes to hidden input
42+
this._syncAttributesToHiddenInput()
43+
44+
// Listen for file selection on hidden input
45+
this._fileInput.addEventListener("change", () => {
46+
this._files = this._fileInput.files
47+
this.dispatchEvent(new Event("change", { bubbles: true }))
48+
this.changeHandler()
49+
})
50+
51+
// Append elements
52+
this.appendChild(this._fileInput)
53+
54+
// Setup form event listeners
55+
this.form?.addEventListener("formdata", this.fromDataHandler.bind(this))
56+
this.form?.addEventListener("submit", this.submitHandler.bind(this), {
57+
once: true,
58+
})
59+
this.form?.addEventListener("upload", this.uploadHandler.bind(this))
60+
}
61+
62+
/**
63+
* Sync attributes from custom element to hidden input.
64+
*/
65+
_syncAttributesToHiddenInput() {
66+
if (!this._fileInput) return
67+
68+
S3FileInput.passThroughAttributes.forEach((attr) => {
69+
if (this.hasAttribute(attr)) {
70+
this._fileInput.setAttribute(attr, this.getAttribute(attr))
71+
} else {
72+
this._fileInput.removeAttribute(attr)
73+
}
74+
})
75+
76+
this._fileInput.disabled = this.hasAttribute("disabled")
77+
}
78+
79+
/**
80+
* Implement HTMLInputElement-like properties.
81+
*/
82+
get files() {
83+
return this._files
84+
}
85+
86+
get type() {
87+
return "file"
88+
}
89+
90+
get name() {
91+
return this.getAttribute("name") || ""
92+
}
93+
94+
set name(value) {
95+
this.setAttribute("name", value)
96+
}
97+
98+
get value() {
99+
if (this._files && this._files.length > 0) {
100+
return this._files[0].name
101+
}
102+
return ""
103+
}
104+
105+
set value(val) {
106+
// Setting value on file inputs is restricted for security
107+
if (val === "" || val === null) {
108+
this._files = []
109+
if (this._fileInput) {
110+
this._fileInput.value = ""
111+
}
112+
}
113+
}
114+
115+
get form() {
116+
return this._internals?.form || this.closest("form")
117+
}
118+
119+
get disabled() {
120+
return this.hasAttribute("disabled")
121+
}
122+
123+
set disabled(value) {
124+
if (value) {
125+
this.setAttribute("disabled", "")
126+
} else {
127+
this.removeAttribute("disabled")
128+
}
129+
}
130+
131+
get required() {
132+
return this.hasAttribute("required")
133+
}
134+
135+
set required(value) {
136+
if (value) {
137+
this.setAttribute("required", "")
138+
} else {
139+
this.removeAttribute("required")
140+
}
141+
}
142+
143+
get validity() {
144+
if (this._internals) {
145+
return this._internals.validity
146+
}
147+
// Create a basic ValidityState-like object
148+
const isValid = !this.required || (this._files && this._files.length > 0)
149+
return {
150+
valid: isValid && !this._validationMessage,
151+
valueMissing: this.required && (!this._files || this._files.length === 0),
152+
customError: !!this._validationMessage,
153+
badInput: false,
154+
patternMismatch: false,
155+
rangeOverflow: false,
156+
rangeUnderflow: false,
157+
stepMismatch: false,
158+
tooLong: false,
159+
tooShort: false,
160+
typeMismatch: false,
161+
}
162+
}
163+
164+
get validationMessage() {
165+
return this._validationMessage
166+
}
167+
168+
setCustomValidity(message) {
169+
this._validationMessage = message || ""
170+
if (this._internals && typeof this._internals.setValidity === "function") {
171+
if (message) {
172+
this._internals.setValidity({ customError: true }, message)
173+
} else {
174+
this._internals.setValidity({})
175+
}
176+
}
177+
}
178+
179+
reportValidity() {
180+
const validity = this.validity
181+
if (validity && !validity.valid) {
182+
this.dispatchEvent(new Event("invalid", { bubbles: false, cancelable: true }))
183+
return false
184+
}
185+
return true
186+
}
187+
188+
checkValidity() {
189+
return this.validity.valid
190+
}
191+
192+
click() {
193+
if (this._fileInput) {
194+
this._fileInput.click()
195+
}
30196
}
31197

32198
changeHandler() {
33199
this.keys = []
34200
this.upload = null
35201
try {
36-
this.form.removeEventListener("submit", this.submitHandler.bind(this))
202+
this.form?.removeEventListener("submit", this.submitHandler.bind(this))
37203
} catch (error) {
38204
console.debug(error)
39205
}
40-
this.form.addEventListener("submit", this.submitHandler.bind(this), { once: true })
206+
this.form?.addEventListener("submit", this.submitHandler.bind(this), {
207+
once: true,
208+
})
41209
}
42210

43211
/**
@@ -48,15 +216,15 @@ export class S3FileInput extends globalThis.HTMLInputElement {
48216
*/
49217
async submitHandler(event) {
50218
event.preventDefault()
51-
this.form.dispatchEvent(new window.CustomEvent("upload"))
52-
await Promise.all(this.form.pendingRquests)
53-
this.form.requestSubmit(event.submitter)
219+
this.form?.dispatchEvent(new window.CustomEvent("upload"))
220+
await Promise.all(this.form?.pendingRquests)
221+
this.form?.requestSubmit(event.submitter)
54222
}
55223

56224
uploadHandler() {
57225
if (this.files.length && !this.upload) {
58226
this.upload = this.uploadFiles()
59-
this.form.pendingRquests = this.form.pendingRquests || []
227+
this.form.pendingRquests = this.form?.pendingRquests || []
60228
this.form.pendingRquests.push(this.upload)
61229
}
62230
}
@@ -99,7 +267,10 @@ export class S3FileInput extends globalThis.HTMLInputElement {
99267
s3Form.append("file", file)
100268
console.debug("uploading", this.dataset.url, file)
101269
try {
102-
const response = await fetch(this.dataset.url, { method: "POST", body: s3Form })
270+
const response = await fetch(this.dataset.url, {
271+
method: "POST",
272+
body: s3Form,
273+
})
103274
if (response.status === 201) {
104275
this.keys.push(getKeyFromResponse(await response.text()))
105276
} else {
@@ -108,11 +279,29 @@ export class S3FileInput extends globalThis.HTMLInputElement {
108279
}
109280
} catch (error) {
110281
console.error(error)
111-
this.setCustomValidity(error)
282+
this.setCustomValidity(String(error))
112283
this.reportValidity()
113284
}
114285
}
115286
}
287+
288+
/**
289+
* Called when observed attributes change.
290+
*/
291+
static get observedAttributes() {
292+
return this.passThroughAttributes.concat(["name", "id"])
293+
}
294+
295+
attributeChangedCallback(name, oldValue, newValue) {
296+
this._syncAttributesToHiddenInput()
297+
}
298+
299+
/**
300+
* Declare this element as a form-associated custom element.
301+
*/
302+
static get formAssociated() {
303+
return true
304+
}
116305
}
117306

118-
globalThis.customElements.define("s3-file", S3FileInput, { extends: "input" })
307+
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
@@ -26,7 +26,6 @@ describe("getKeyFromResponse", () => {
2626
describe("S3FileInput", () => {
2727
test("constructor", () => {
2828
const input = new s3file.S3FileInput()
29-
assert.strictEqual(input.type, "file")
3029
assert.deepStrictEqual(input.keys, [])
3130
assert.strictEqual(input.upload, null)
3231
})
@@ -35,11 +34,11 @@ describe("S3FileInput", () => {
3534
const form = document.createElement("form")
3635
document.body.appendChild(form)
3736
const input = new s3file.S3FileInput()
38-
input.addEventListener = mock.fn(input.addEventListener)
3937
form.addEventListener = mock.fn(form.addEventListener)
4038
form.appendChild(input)
4139
assert(form.addEventListener.mock.calls.length === 3)
42-
assert(input.addEventListener.mock.calls.length === 1)
40+
assert(input._fileInput !== null)
41+
assert(input._fileInput.type === "file")
4342
})
4443

4544
test("changeHandler", () => {

0 commit comments

Comments
 (0)