From ed7efd670bb5d55e0a1c28b7bd5c555cc8fea6d0 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 16 Jul 2026 16:27:02 -0500 Subject: [PATCH 1/2] Style the attachments upload control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native file input read as unstyled chrome next to the rest of the page. Replace it with a button-styled label wrapping a visually-hidden input, a filename readout, and an Upload button that stays disabled until files are chosen (required can't be used on a hidden input — Chrome refuses to submit when the invalid control isn't focusable). Co-Authored-By: Claude Fable 5 --- .../assets/stylesheets/coplan/application.css | 29 +++++++++++++++++++ .../coplan/file_input_controller.js | 27 +++++++++++++++++ .../views/coplan/plans/_attachments.html.erb | 14 ++++++--- 3 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 engine/app/javascript/controllers/coplan/file_input_controller.js diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index a145536f..103ce3b8 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2467,8 +2467,37 @@ body:has(.comment-toolbar) .main-content { flex-wrap: wrap; } +/* The real input is visually hidden (not display:none, so it stays focusable); + the wrapping .attachments-upload__choose label is the visible control. */ .attachments-upload__input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.attachments-upload__choose { + position: relative; +} + +.attachments-upload__choose:focus-within { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.attachments-upload__filenames { font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.attachments-upload .btn:disabled { + opacity: 0.5; + cursor: not-allowed; } .attachments-upload__hint { diff --git a/engine/app/javascript/controllers/coplan/file_input_controller.js b/engine/app/javascript/controllers/coplan/file_input_controller.js new file mode 100644 index 00000000..63283a88 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/file_input_controller.js @@ -0,0 +1,27 @@ +import { Controller } from "@hotwired/stimulus" + +// Styled replacement for a native file input: a button-styled label wraps the +// visually-hidden input, with a filename readout beside it. `required` can't +// be used on a visually-hidden input (Chrome refuses to submit a form whose +// invalid control isn't focusable), so the submit button stays disabled until +// files are chosen instead. +export default class extends Controller { + static targets = ["input", "filenames", "submit"] + + connect() { + this.changed() + } + + changed() { + const files = Array.from(this.inputTarget.files || []) + if (this.hasSubmitTarget) this.submitTarget.disabled = files.length === 0 + if (!this.hasFilenamesTarget) return + if (files.length === 0) { + this.filenamesTarget.textContent = "No files selected" + } else if (files.length === 1) { + this.filenamesTarget.textContent = files[0].name + } else { + this.filenamesTarget.textContent = `${files.length} files selected` + } + } +} diff --git a/engine/app/views/coplan/plans/_attachments.html.erb b/engine/app/views/coplan/plans/_attachments.html.erb index 8604d833..0077c7b3 100644 --- a/engine/app/views/coplan/plans/_attachments.html.erb +++ b/engine/app/views/coplan/plans/_attachments.html.erb @@ -2,10 +2,16 @@
<% if can_edit %>
- <%= form_with url: plan_attachments_path(plan), method: :post, multipart: true, class: "attachments-upload" do |f| %> - <%= f.file_field :files, multiple: true, name: "files[]", required: true, class: "attachments-upload__input", - accept: CoPlan::Plan::ATTACHMENT_CONTENT_TYPES.join(",") %> - <%= f.submit "Upload", class: "btn btn--primary btn--sm" %> + <%= form_with url: plan_attachments_path(plan), method: :post, multipart: true, + class: "attachments-upload", data: { controller: "coplan--file-input" } do |f| %> + + No files selected + <%= f.submit "Upload", class: "btn btn--primary btn--sm", data: { "coplan--file-input-target": "submit" } %> <% end %>

Up to <%= CoPlan::Plan::ATTACHMENT_MAX_BYTES / 1.megabyte %> MB per file. From 83bea39f331f4be9634a9e1f3162ee5c52fcfe35 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 16 Jul 2026 17:12:15 -0500 Subject: [PATCH 2/2] Redesign the attachments upload as a drop zone The button-row version still read as jammed-in chrome. Replace it with a proper drop zone: dashed full-width target with drag-over highlight, click-anywhere browse, and immediate upload on selection or drop (the separate Upload button is gone). Attachment rows become bordered cards with hover elevation. Co-Authored-By: Claude Fable 5 --- .../assets/stylesheets/coplan/application.css | 90 ++++++++++++------- .../controllers/coplan/dropzone_controller.js | 38 ++++++++ .../coplan/file_input_controller.js | 27 ------ .../views/coplan/plans/_attachments.html.erb | 27 +++--- spec/requests/attachments_spec.rb | 4 +- 5 files changed, 114 insertions(+), 72 deletions(-) create mode 100644 engine/app/javascript/controllers/coplan/dropzone_controller.js delete mode 100644 engine/app/javascript/controllers/coplan/file_input_controller.js diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 103ce3b8..dba4f92d 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2460,68 +2460,94 @@ body:has(.comment-toolbar) .main-content { margin-bottom: var(--space-md); } -.attachments-upload { +/* Drag-and-drop upload zone (see dropzone_controller.js). The whole inner + area is a

and posts via +// requestSubmit(), letting Turbo handle the redirect. +export default class extends Controller { + static targets = ["input", "text"] + + dragOver(event) { + event.preventDefault() + this.element.classList.add("attachments-dropzone--active") + } + + dragLeave(event) { + event.preventDefault() + this.element.classList.remove("attachments-dropzone--active") + } + + drop(event) { + event.preventDefault() + this.element.classList.remove("attachments-dropzone--active") + if (!event.dataTransfer?.files?.length) return + this.inputTarget.files = event.dataTransfer.files + this.changed() + } + + changed() { + const files = this.inputTarget.files + if (!files.length) return + if (this.hasTextTarget) { + this.textTarget.textContent = + files.length === 1 ? `Uploading ${files[0].name}…` : `Uploading ${files.length} files…` + } + this.element.classList.add("attachments-dropzone--uploading") + this.element.requestSubmit() + } +} diff --git a/engine/app/javascript/controllers/coplan/file_input_controller.js b/engine/app/javascript/controllers/coplan/file_input_controller.js deleted file mode 100644 index 63283a88..00000000 --- a/engine/app/javascript/controllers/coplan/file_input_controller.js +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Styled replacement for a native file input: a button-styled label wraps the -// visually-hidden input, with a filename readout beside it. `required` can't -// be used on a visually-hidden input (Chrome refuses to submit a form whose -// invalid control isn't focusable), so the submit button stays disabled until -// files are chosen instead. -export default class extends Controller { - static targets = ["input", "filenames", "submit"] - - connect() { - this.changed() - } - - changed() { - const files = Array.from(this.inputTarget.files || []) - if (this.hasSubmitTarget) this.submitTarget.disabled = files.length === 0 - if (!this.hasFilenamesTarget) return - if (files.length === 0) { - this.filenamesTarget.textContent = "No files selected" - } else if (files.length === 1) { - this.filenamesTarget.textContent = files[0].name - } else { - this.filenamesTarget.textContent = `${files.length} files selected` - } - } -} diff --git a/engine/app/views/coplan/plans/_attachments.html.erb b/engine/app/views/coplan/plans/_attachments.html.erb index 0077c7b3..99fc33e3 100644 --- a/engine/app/views/coplan/plans/_attachments.html.erb +++ b/engine/app/views/coplan/plans/_attachments.html.erb @@ -3,20 +3,25 @@ <% if can_edit %>
<%= form_with url: plan_attachments_path(plan), method: :post, multipart: true, - class: "attachments-upload", data: { controller: "coplan--file-input" } do |f| %> -
<% end %> diff --git a/spec/requests/attachments_spec.rb b/spec/requests/attachments_spec.rb index b6f86301..db9e391b 100644 --- a/spec/requests/attachments_spec.rb +++ b/spec/requests/attachments_spec.rb @@ -97,7 +97,7 @@ expect(response.body).to include("diagram.png") expect(response.body).to include("disposition=attachment") # Viewers can't upload or delete. - expect(response.body).not_to include("attachments-upload") + expect(response.body).not_to include("attachments-dropzone") end it "shows the upload form to the plan author" do @@ -105,7 +105,7 @@ get plan_path(plan, tab: "attachments") expect(response).to have_http_status(:ok) - expect(response.body).to include("attachments-upload") + expect(response.body).to include("attachments-dropzone") end end end