bard-attachment_field 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.nvmrc +1 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/Appraisals +17 -0
  6. data/CHANGELOG.md +15 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE +21 -0
  9. data/README.md +39 -0
  10. data/Rakefile +10 -0
  11. data/app/assets/javascripts/input-attachment.js +6021 -0
  12. data/app/controllers/bard/attachment_field/blobs_controller.rb +11 -0
  13. data/bard-attachment_field.gemspec +56 -0
  14. data/config/cucumber.yml +1 -0
  15. data/config/routes.rb +6 -0
  16. data/gemfiles/rails_7.1.gemfile +7 -0
  17. data/gemfiles/rails_7.2.gemfile +7 -0
  18. data/gemfiles/rails_8.0.gemfile +7 -0
  19. data/gemfiles/rails_8.1.gemfile +7 -0
  20. data/input-attachment/.editorconfig +15 -0
  21. data/input-attachment/.github/workflows/test.yml +21 -0
  22. data/input-attachment/.gitignore +27 -0
  23. data/input-attachment/.prettierrc.json +13 -0
  24. data/input-attachment/CLAUDE.md +63 -0
  25. data/input-attachment/LICENSE +21 -0
  26. data/input-attachment/README.md +288 -0
  27. data/input-attachment/bin/log +2 -0
  28. data/input-attachment/bin/server +1 -0
  29. data/input-attachment/bin/setup +4 -0
  30. data/input-attachment/bun.lockb +0 -0
  31. data/input-attachment/bundle.js +3 -0
  32. data/input-attachment/jest-setup.js +24 -0
  33. data/input-attachment/package.json +56 -0
  34. data/input-attachment/src/components/attachment-file/accepts.ts +32 -0
  35. data/input-attachment/src/components/attachment-file/attachment-file.css +89 -0
  36. data/input-attachment/src/components/attachment-file/attachment-file.e2e.ts +11 -0
  37. data/input-attachment/src/components/attachment-file/attachment-file.spec.tsx +20 -0
  38. data/input-attachment/src/components/attachment-file/attachment-file.tsx +157 -0
  39. data/input-attachment/src/components/attachment-file/direct-upload-controller.tsx +100 -0
  40. data/input-attachment/src/components/attachment-file/extensions.ts +13 -0
  41. data/input-attachment/src/components/attachment-file/max.ts +46 -0
  42. data/input-attachment/src/components/attachment-file/readme.md +55 -0
  43. data/input-attachment/src/components/attachment-preview/attachment-preview.css +8 -0
  44. data/input-attachment/src/components/attachment-preview/attachment-preview.e2e.ts +11 -0
  45. data/input-attachment/src/components/attachment-preview/attachment-preview.spec.tsx +19 -0
  46. data/input-attachment/src/components/attachment-preview/attachment-preview.tsx +42 -0
  47. data/input-attachment/src/components/attachment-preview/readme.md +31 -0
  48. data/input-attachment/src/components/input-attachment/form-controller.tsx +146 -0
  49. data/input-attachment/src/components/input-attachment/input-attachment.css +100 -0
  50. data/input-attachment/src/components/input-attachment/input-attachment.e2e.ts +11 -0
  51. data/input-attachment/src/components/input-attachment/input-attachment.spec.tsx +37 -0
  52. data/input-attachment/src/components/input-attachment/input-attachment.tsx +353 -0
  53. data/input-attachment/src/components/input-attachment/readme.md +45 -0
  54. data/input-attachment/src/components.d.ts +175 -0
  55. data/input-attachment/src/global.d.ts +3 -0
  56. data/input-attachment/src/images/example.jpg +0 -0
  57. data/input-attachment/src/index.html +36 -0
  58. data/input-attachment/src/index.ts +1 -0
  59. data/input-attachment/src/utils/utils.spec.ts +19 -0
  60. data/input-attachment/src/utils/utils.ts +14 -0
  61. data/input-attachment/stencil.config.ts +43 -0
  62. data/input-attachment/test-mocks/file-drop.cjs +7 -0
  63. data/input-attachment/test-mocks/progress-bar.cjs +9 -0
  64. data/input-attachment/tsconfig.json +32 -0
  65. data/lib/bard/attachment_field/cucumber.rb +277 -0
  66. data/lib/bard/attachment_field/field.rb +33 -0
  67. data/lib/bard/attachment_field/form_builder.rb +12 -0
  68. data/lib/bard/attachment_field/version.rb +7 -0
  69. data/lib/bard/attachment_field.rb +20 -0
  70. data/lib/bard-attachment_field.rb +1 -0
  71. metadata +409 -0
@@ -0,0 +1,146 @@
1
+ import DirectUploadController from "../attachment-file/direct-upload-controller"
2
+
3
+ export default class FormController {
4
+ static instance(form) {
5
+ return form.inputAttachmentFormController ||= new FormController(form)
6
+ }
7
+
8
+ progressContainerTarget: HTMLElement
9
+ dialog: HTMLDialogElement
10
+
11
+ element: HTMLFormElement
12
+ progressTargetMap: {}
13
+ controllers: Array<DirectUploadController>
14
+ submitted: boolean
15
+ processing: boolean
16
+ errors: boolean
17
+
18
+ constructor(form) {
19
+ this.element = form
20
+ this.progressTargetMap = {}
21
+ this.controllers = []
22
+ this.submitted = false
23
+ this.processing = false
24
+ this.errors = false
25
+
26
+ this.element.insertAdjacentHTML("beforeend",
27
+ `<dialog id="form-controller-dialog">
28
+ <div class="direct-upload-wrapper">
29
+ <div class="direct-upload-content">
30
+ <h3>Uploading your media</h3>
31
+ <div id="progress-container"></div>
32
+ </div>
33
+ </div>
34
+ </dialog>`)
35
+
36
+ this.dialog = this.element.querySelector("#form-controller-dialog")
37
+ this.progressContainerTarget = this.dialog.querySelector("#progress-container")
38
+
39
+ if(this.element.dataset.remote !== "true" && (this.element.dataset.turbo == "false" || !(window as any).Turbo?.session?.enabled)) {
40
+ this.element.addEventListener("submit", event => this.submit(event))
41
+ }
42
+ window.addEventListener("beforeunload", event => this.beforeUnload(event))
43
+
44
+ this.element.addEventListener("direct-upload:initialize", event => this.init(event))
45
+ this.element.addEventListener("direct-upload:start", event => this.start(event))
46
+ this.element.addEventListener("direct-upload:progress", event => this.progress(event))
47
+ this.element.addEventListener("direct-upload:error", event => this.error(event))
48
+ this.element.addEventListener("direct-upload:end", event => this.end(event))
49
+
50
+ this.element.addEventListener("attachment-file:remove", event => this.removeUploadedFile(event))
51
+ }
52
+
53
+ beforeUnload(event) {
54
+ if(this.processing) {
55
+ event.preventDefault()
56
+ return (event.returnValue = "")
57
+ }
58
+ }
59
+
60
+ submit(event) {
61
+ event.preventDefault()
62
+ this.submitted = true
63
+ this.startNextController()
64
+ if(this.processing) {
65
+ this.dialog.showModal()
66
+ }
67
+ }
68
+
69
+ startNextController() {
70
+ if(this.processing) return
71
+
72
+ const controller = this.controllers.shift()
73
+ if(controller) {
74
+ this.processing = true
75
+ this.setInputAttachmentsDisabled(true)
76
+ controller.start(error => {
77
+ if(error) {
78
+ this.setInputAttachmentsDisabled(false)
79
+ }
80
+ this.processing = false
81
+ this.startNextController()
82
+ })
83
+ } else {
84
+ this.submitForm()
85
+ }
86
+ }
87
+
88
+ submitForm() {
89
+ if(this.submitted) {
90
+ this.setInputAttachmentsDisabled(true)
91
+ window.setTimeout(() => { // allow other async tasks to complete
92
+ this.element.submit()
93
+ }, 10)
94
+ }
95
+ }
96
+
97
+ setInputAttachmentsDisabled(disabled: boolean) {
98
+ Array.from(this.element.querySelectorAll("input-attachment"))
99
+ .forEach((el: any) => {
100
+ el.disabled = disabled
101
+ })
102
+ }
103
+
104
+ init(event) {
105
+ const { id, file, controller } = event.detail
106
+
107
+ this.progressContainerTarget.insertAdjacentHTML("beforebegin", `
108
+ <progress-bar id="direct-upload-${id}" class="direct-upload--pending">${file?.name || 'Uploading...'}</progress-bar>
109
+ `)
110
+ const progressTarget = document.getElementById(`direct-upload-${id}`)
111
+ this.progressTargetMap[id] = progressTarget
112
+
113
+ this.controllers.push(controller)
114
+ this.startNextController()
115
+ }
116
+
117
+ start(event) {
118
+ this.progressTargetMap[event.detail.id].classList.remove("direct-upload--pending")
119
+ }
120
+
121
+ progress(event) {
122
+ const { id, progress } = event.detail
123
+ this.progressTargetMap[id].percent = progress
124
+ }
125
+
126
+ error(event) {
127
+ event.preventDefault()
128
+ const { id, error } = event.detail
129
+ const target = this.progressTargetMap[id]
130
+ target.classList.add("direct-upload--error")
131
+ target.title = error
132
+ }
133
+
134
+ end(event) {
135
+ this.progressTargetMap[event.detail.id].classList.add("direct-upload--complete")
136
+ }
137
+
138
+ removeUploadedFile(event) {
139
+ const uploadedFile = event.detail
140
+ const id = uploadedFile.controller?.directUpload?.id
141
+ if(id) {
142
+ document.getElementById(`direct-upload-${id}`).remove()
143
+ delete this.progressTargetMap[id]
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,100 @@
1
+ :host {
2
+ display: block;
3
+ padding: 25px;
4
+ color: var(--input-attachment-text-color, #000);
5
+ font-size: 13px;
6
+ }
7
+
8
+ :host *{
9
+ box-sizing: border-box;
10
+ position: relative;
11
+ }
12
+
13
+ file-drop {
14
+ cursor: pointer;
15
+ display: block;
16
+ outline-offset: -10px;
17
+ background: rgba(255,255,255, 0.25);
18
+ padding: 20px;
19
+ text-align: center;
20
+ transition: all 0.15s;
21
+ outline: 2px dashed rgba(0,0,0,0.25);
22
+ color: #444;
23
+ font-size: 14px;
24
+ }
25
+
26
+ file-drop.-full{
27
+ width: 100%;
28
+ }
29
+
30
+ p{
31
+ padding: 10px 20px;
32
+ margin: 0;
33
+ }
34
+
35
+ .-dragover{
36
+ background: rgba(255,255,255,0.5);
37
+ outline: 2px dashed rgba(0,0,0,0.25);
38
+ }
39
+
40
+ .media-preview {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ align-items: flex-start;
44
+ justify-content: center;
45
+ }
46
+
47
+ // UPLOADER
48
+
49
+ .direct-upload-wrapper{
50
+ position: fixed;
51
+ z-index: 9999;
52
+ top: 0;
53
+ left: 0;
54
+ width: 100vw;
55
+ height: 100vh;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ background: rgba(#333, 0.9);
60
+ }
61
+
62
+ .direct-upload-content{
63
+ display: block;
64
+ background: #fcfcfc;
65
+ padding: 40px 60px 60px;
66
+ border-radius: 3px;
67
+ width: 60vw;
68
+ }
69
+ .direct-upload-content h3{
70
+ border-bottom: 2px solid #1f1f1f;
71
+ margin-bottom: 20px;
72
+ }
73
+
74
+ .separate-upload{
75
+ padding: 0 10px;
76
+ margin-top: 10px;
77
+ font-size: 0.9em;
78
+ }
79
+
80
+ .direct-upload--pending{
81
+ opacity: 0.6;
82
+ }
83
+
84
+ .direct-upload--complete {
85
+ opacity: 0.4;
86
+ }
87
+
88
+ .direct-upload--error{
89
+ border-color: red;
90
+ }
91
+
92
+ input[type=file][data-direct-upload-url][disabled]{
93
+ display: none;
94
+ }
95
+
96
+ :host.separate-upload{
97
+ padding: 0 10px;
98
+ margin-top: 10px;
99
+ font-size: 0.9em;
100
+ }
@@ -0,0 +1,11 @@
1
+ import { newE2EPage } from '@stencil/core/testing';
2
+
3
+ describe('input-attachment', () => {
4
+ it('renders', async () => {
5
+ const page = await newE2EPage();
6
+ await page.setContent('<input-attachment></input-attachment>');
7
+
8
+ const element = await page.find('input-attachment');
9
+ expect(element).toHaveClass('hydrated');
10
+ });
11
+ });
@@ -0,0 +1,37 @@
1
+
2
+ // Mock rails-request-json to avoid ES module issues
3
+ jest.mock('rails-request-json', () => ({
4
+ get: jest.fn(() => Promise.resolve({}))
5
+ }));
6
+
7
+ // Mock @botandrose/file-drop to avoid ES module issues
8
+ jest.mock('@botandrose/file-drop', () => ({}));
9
+
10
+ import { newSpecPage } from '@stencil/core/testing';
11
+ import { InputAttachment } from './input-attachment';
12
+
13
+ describe('input-attachment', () => {
14
+
15
+ it('renders', async () => {
16
+ const page = await newSpecPage({
17
+ components: [InputAttachment],
18
+ html: `<form><input-attachment></input-attachment></form>`,
19
+ });
20
+ expect(page.root).toEqualHtml(`
21
+ <input-attachment>
22
+ <mock:shadow-root>
23
+ <input type="file" style="opacity: 0.01; width: 1px; height: 1px; z-index: -999;">
24
+ <file-drop>
25
+ <p part="title">
26
+ <strong>Choose file </strong>
27
+ <span>or drag it here.</span>
28
+ </p>
29
+ <div class="media-preview">
30
+ <slot></slot>
31
+ </div>
32
+ </file-drop>
33
+ </mock:shadow-root>
34
+ </input-attachment>
35
+ `);
36
+ });
37
+ });
@@ -0,0 +1,353 @@
1
+ import { Component, Element, Prop, Listen, Host, h, forceUpdate } from '@stencil/core';
2
+ import FormController from "./form-controller"
3
+ import { AttachmentFile } from "../attachment-file/attachment-file"
4
+ import { arrayRemove } from "../../utils/utils"
5
+ import '@botandrose/file-drop'
6
+ import '@botandrose/progress-bar'
7
+
8
+ @Component({
9
+ tag: 'input-attachment',
10
+ styleUrl: 'input-attachment.css',
11
+ shadow: true,
12
+ formAssociated: true,
13
+ })
14
+ export class InputAttachment {
15
+ @Element() el: HTMLElement
16
+
17
+ @Prop() name: string
18
+ @Prop() directupload: string
19
+ @Prop() multiple: boolean = false
20
+
21
+ @Prop() required: boolean = false
22
+ @Prop() accepts: string
23
+ @Prop() max: number
24
+ @Prop() preview: boolean = true
25
+ @Prop() disabled: boolean = false
26
+
27
+
28
+ form: HTMLFormElement
29
+ internals: ElementInternals
30
+ fileInput: HTMLInputElement
31
+ _files: Array<any> = []
32
+
33
+ constructor() {
34
+ this.internals = (this.el as any).attachInternals()
35
+ }
36
+
37
+ componentWillLoad() {
38
+ this.form = this.internals.form
39
+ if (this.form) {
40
+ this.form.addEventListener("reset", () => this.reset())
41
+ FormController.instance(this.form)
42
+ }
43
+
44
+ // Note: Server-rendered children may not be available yet during componentWillLoad
45
+ // when using lazy-loaded Stencil components. We'll check again in componentDidRender.
46
+ const existingFiles = Array.from(this.el.children).filter(e => e.tagName == "ATTACHMENT-FILE");
47
+ if(existingFiles.length > 0) this.files = existingFiles
48
+
49
+ // Restore from localStorage BEFORE setting initial validity (which triggers saveToLocalStorage)
50
+ this.restoreFromLocalStorage()
51
+
52
+ // Set initial validity state (only if we have files from above)
53
+ if(this.files.length > 0) this.updateFormValue()
54
+ }
55
+
56
+ componentDidLoad() {
57
+ // Clear persistence data when form is submitted
58
+ if (this.form) {
59
+ this.form.addEventListener('submit', () => this.clearLocalStorage())
60
+ }
61
+
62
+ // Listen for file input changes directly (JSX onChange doesn't reliably work in shadow DOM)
63
+ this.fileInput?.addEventListener('change', this.handleFileInputChange)
64
+ }
65
+
66
+ get localStorageKey() {
67
+ if (!this.form || !this.name) return null
68
+ const formId = this.form.id || this.form.action || 'form'
69
+ return `input-attachment:${formId}:${this.name}`
70
+ }
71
+
72
+ saveToLocalStorage() {
73
+ const key = this.localStorageKey
74
+ if (!key) return
75
+
76
+ const data = this.persistenceData
77
+ if (data.length > 0) {
78
+ localStorage.setItem(key, JSON.stringify(data))
79
+ } else {
80
+ localStorage.removeItem(key)
81
+ }
82
+ }
83
+
84
+ restoreFromLocalStorage() {
85
+ const key = this.localStorageKey
86
+ if (!key || this.files.length > 0) return
87
+
88
+ try {
89
+ const stored = localStorage.getItem(key)
90
+ if (stored) {
91
+ const data = JSON.parse(stored)
92
+ if (Array.isArray(data) && data.length > 0) {
93
+ this.restoreFromPersistence(data)
94
+ }
95
+ }
96
+ } catch (e) {
97
+ // Invalid JSON, ignore
98
+ }
99
+ }
100
+
101
+ clearLocalStorage() {
102
+ const key = this.localStorageKey
103
+ if (key) {
104
+ localStorage.removeItem(key)
105
+ }
106
+ }
107
+
108
+ // Methods
109
+
110
+ get files() {
111
+ return this._files
112
+ }
113
+
114
+ set files(val) {
115
+ this._files = val
116
+ if(!this.multiple) this._files = this._files.slice(-1)
117
+ forceUpdate(this.el)
118
+ this.fireChangeEvent()
119
+ }
120
+
121
+ get value() {
122
+ return this.files.map(e => e.value)
123
+ }
124
+
125
+ set value(val) {
126
+ const newValue = val || []
127
+ if(JSON.stringify(this.value) !== JSON.stringify(newValue)) { // this is insane. javascript is fucking garbage.
128
+ this.files = newValue.map(signedId => Object.assign(new AttachmentFile(), {
129
+ name: this.name,
130
+ preview: this.preview,
131
+ signedId,
132
+ }))
133
+ }
134
+ }
135
+
136
+ // For form-persistence: store complete attachment data (not just signed_ids)
137
+ get persistenceData() {
138
+ return this.files.map(f => ({
139
+ value: f.value,
140
+ filename: f.filename,
141
+ src: f.src,
142
+ state: f.state,
143
+ percent: f.percent,
144
+ size: f.size,
145
+ filetype: f.filetype,
146
+ }))
147
+ }
148
+
149
+ restoreFromPersistence(data: any[]) {
150
+ if (!Array.isArray(data) || data.length === 0) return
151
+
152
+ this.files = data.map(item => {
153
+ const attachmentFile = document.createElement('attachment-file') as any
154
+ attachmentFile.name = this.name
155
+ attachmentFile.preview = this.preview
156
+ attachmentFile.value = item.value
157
+ attachmentFile.filename = item.filename
158
+ attachmentFile.src = item.src
159
+ attachmentFile.state = item.state || 'complete'
160
+ attachmentFile.percent = item.percent || 100
161
+ attachmentFile.size = item.size
162
+ attachmentFile.filetype = item.filetype
163
+ return attachmentFile
164
+ })
165
+ requestAnimationFrame(() => this.componentDidRender())
166
+ }
167
+
168
+ updateFormValue() {
169
+ if (!this.name || !this.internals?.setFormValue) return
170
+ const formData = new FormData()
171
+ const values = this.value.filter(v => v) // filter out empty values
172
+ if (this.multiple) {
173
+ // For has_many_attached: append each signed_id separately
174
+ values.forEach(signedId => formData.append(this.name, signedId))
175
+ // If empty, append empty string so Rails gets an empty array
176
+ if (values.length === 0) formData.append(this.name, '')
177
+ } else {
178
+ // For has_one_attached: set single value (or empty string if none)
179
+ formData.set(this.name, values[0] || '')
180
+ }
181
+ this.internals.setFormValue(formData)
182
+
183
+ // Save to localStorage for persistence across page reloads
184
+ this.saveToLocalStorage()
185
+
186
+ // Update validity state - check for required and child validation errors
187
+ if (this.required && this.files.length === 0) {
188
+ this.internals.setValidity({ valueMissing: true }, "Please select a file.", this.fileInput)
189
+ } else {
190
+ // Check if any child attachment-file has validation errors
191
+ const childErrors = this.files
192
+ .map(f => f.validationError)
193
+ .filter(e => e && e.length > 0)
194
+ if (childErrors.length > 0) {
195
+ this.internals.setValidity({ customError: true }, childErrors[0], this.fileInput)
196
+ } else {
197
+ this.internals.setValidity({})
198
+ }
199
+ }
200
+ }
201
+
202
+ reset() {
203
+ this.value = []
204
+ this.clearLocalStorage()
205
+ }
206
+
207
+ handleFileInputChange = () => {
208
+ if (!this.fileInput?.files?.length) return
209
+ this.addFiles(this.fileInput.files)
210
+ this.fileInput.value = null
211
+ }
212
+
213
+ handleDrop = (event: DragEvent) => {
214
+ event.preventDefault()
215
+ if (this.isDisabled) return
216
+ if (event.dataTransfer?.files?.length) {
217
+ this.addFiles(event.dataTransfer.files)
218
+ }
219
+ }
220
+
221
+ @Listen("attachment-file:remove")
222
+ removeUploadedFile(event) {
223
+ arrayRemove(this.files, event.detail)
224
+ this.files = this.files
225
+ }
226
+
227
+ @Listen("attachment-file:validation")
228
+ handleChildValidation(_event) {
229
+ // Re-check form validity when a child's validation state changes
230
+ this.updateFormValue()
231
+ }
232
+
233
+ @Listen("direct-upload:end")
234
+ fireChangeEvent() {
235
+ requestAnimationFrame(() => {
236
+ this.updateFormValue()
237
+ this.el.dispatchEvent(new Event("change", { bubbles: true }))
238
+ })
239
+ }
240
+
241
+ // Rendering
242
+
243
+ get isDisabled() {
244
+ return this.disabled || !!this.el.closest('fieldset[disabled]')
245
+ }
246
+
247
+ render() {
248
+ return (
249
+ <Host>
250
+ <input
251
+ ref={el => this.fileInput = el}
252
+ type="file"
253
+ multiple={this.multiple}
254
+ accept={this.accepts}
255
+ required={this.required && this.files.length === 0}
256
+ disabled={this.isDisabled}
257
+ onChange={() => this.handleFileInputChange()}
258
+ style={{
259
+ opacity: '0.01',
260
+ width: '1px',
261
+ height: '1px',
262
+ zIndex: '-999'
263
+ }}
264
+ />
265
+ <file-drop onClick={() => this.fileInput?.click()} onDrop={this.handleDrop}>
266
+ <p part="title">
267
+ <strong>Choose {this.multiple ? "files" : "file"} </strong>
268
+ <span>or drag {this.multiple ? "them" : "it"} here.</span>
269
+ </p>
270
+
271
+ <div class={`media-preview ${this.multiple ? '-stacked' : ''}`}>
272
+ <slot></slot>
273
+ </div>
274
+ </file-drop>
275
+ </Host>
276
+ )
277
+ }
278
+
279
+ componentDidRender() {
280
+ // Check for server-rendered children that we haven't captured yet
281
+ // This handles the case where children aren't available in componentWillLoad
282
+ if (this.files.length === 0) {
283
+ const existingFiles = Array.from(this.el.children).filter(e => e.tagName == "ATTACHMENT-FILE");
284
+ if (existingFiles.length > 0) {
285
+ this._files = existingFiles as any[]
286
+ }
287
+ }
288
+
289
+ const wrapper = document.createElement("div")
290
+ this.files.forEach(file => wrapper.appendChild(file))
291
+
292
+ let needsUpdate = false
293
+ if (wrapper.children.length !== this.el.children.length) {
294
+ needsUpdate = true
295
+ } else {
296
+ for (let i = 0; i < wrapper.children.length; i++) {
297
+ if (wrapper.children[i] !== this.el.children[i]) {
298
+ needsUpdate = true
299
+ break
300
+ }
301
+ }
302
+ }
303
+
304
+ if (needsUpdate) {
305
+ while (this.el.firstChild) {
306
+ this.el.removeChild(this.el.firstChild)
307
+ }
308
+ this.el.appendChild(wrapper)
309
+ }
310
+
311
+ this.updateFormValue()
312
+ }
313
+
314
+ // Test helper - adds files programmatically with proper lifecycle
315
+ addFiles(files: FileList | File[]) {
316
+ const fileArray = Array.from(files)
317
+ fileArray.forEach(file => {
318
+ const attachmentFile = document.createElement('attachment-file') as any
319
+ attachmentFile.name = this.name
320
+ attachmentFile.preview = this.preview
321
+ attachmentFile.url = this.directupload
322
+ attachmentFile.accepts = this.accepts
323
+ attachmentFile.max = this.max
324
+ attachmentFile.file = file
325
+ this._files.push(attachmentFile)
326
+ })
327
+ this.files = this._files
328
+ // Manually trigger DOM update since Stencil's state change may not re-render
329
+ // when called from external JavaScript
330
+ requestAnimationFrame(() => this.componentDidRender())
331
+ }
332
+
333
+ // Validations
334
+
335
+ checkValidity() {
336
+ if (this.required && this.files.length === 0) {
337
+ return false
338
+ }
339
+ return true
340
+ }
341
+
342
+ setCustomValidity(msg: string) {
343
+ this.internals.setValidity(msg ? { customError: true } : {}, msg, this.fileInput)
344
+ }
345
+
346
+ reportValidity() {
347
+ return this.internals.reportValidity()
348
+ }
349
+
350
+ get validationMessage() {
351
+ return this.internals.validationMessage
352
+ }
353
+ }
@@ -0,0 +1,45 @@
1
+ # input-attachment
2
+
3
+
4
+
5
+ <!-- Auto Generated Below -->
6
+
7
+
8
+ ## Properties
9
+
10
+ | Property | Attribute | Description | Type | Default |
11
+ | -------------- | -------------- | ----------- | --------- | ----------- |
12
+ | `accepts` | `accepts` | | `string` | `undefined` |
13
+ | `directupload` | `directupload` | | `string` | `undefined` |
14
+ | `disabled` | `disabled` | | `boolean` | `false` |
15
+ | `max` | `max` | | `number` | `undefined` |
16
+ | `multiple` | `multiple` | | `boolean` | `false` |
17
+ | `name` | `name` | | `string` | `undefined` |
18
+ | `preview` | `preview` | | `boolean` | `true` |
19
+ | `required` | `required` | | `boolean` | `false` |
20
+
21
+
22
+ ## Shadow Parts
23
+
24
+ | Part | Description |
25
+ | --------- | ----------- |
26
+ | `"title"` | |
27
+
28
+
29
+ ## Dependencies
30
+
31
+ ### Depends on
32
+
33
+ - [attachment-file](../attachment-file)
34
+
35
+ ### Graph
36
+ ```mermaid
37
+ graph TD;
38
+ input-attachment --> attachment-file
39
+ attachment-file --> attachment-preview
40
+ style input-attachment fill:#f9f,stroke:#333,stroke-width:4px
41
+ ```
42
+
43
+ ----------------------------------------------
44
+
45
+ *Built with [StencilJS](https://stenciljs.com/)*