diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cba953c..05f7350 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -6,6 +6,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
+ - name: Install dependencies
+ env:
+ DEPENDENCIES: libvips
+ run: sudo apt-get install -y $DEPENDENCIES || (sudo apt-get update && sudo apt-get install -y $DEPENDENCIES)
- uses: actions/checkout@v6
- uses: ruby/setup-ruby@v1
with:
@@ -19,3 +23,13 @@ jobs:
- name: Test
run: |
bin/ci
+ env:
+ FERRUM_PROCESS_TIMEOUT: 30
+ - name: Archive screenshots and logs
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-failure-outputs
+ path: |
+ spec/dummy/tmp/screenshots
+ spec/dummy/log
diff --git a/.gitignore b/.gitignore
index 9ef0c46..1006d2c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,7 +2,6 @@
/.yardoc
/_yardoc/
/coverage/
-/doc/
/pkg/
/node_modules/
/spec/reports/
diff --git a/Gemfile.lock b/Gemfile.lock
index 2de2981..c19e68f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
- katalyst-govuk-formbuilder (1.30.1)
+ katalyst-govuk-formbuilder (2.0.0)
activestorage (>= 8.0.0)
govuk_design_system_formbuilder (>= 6.2.0)
diff --git a/README.md b/README.md
index 9752091..a18f123 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,9 @@
# Katalyst::GOVUK::FormBuilder
Repacking of [GOV.UK Frontend](https://frontend.design-system.service.gov.uk) and
-[GOV.UK form components](https://govuk-form-builder.netlify.app) for use in Katalyst projects.
+[GOV.UK form components](https://govuk-form-builder.netlify.app) for use in Katalyst projects,
+extended with ActiveStorage-backed attachment fields: previews, drag-and-drop, async direct
+uploads, and a complete no-JavaScript fallback.
## Installation
@@ -21,6 +23,14 @@ Or install it yourself as:
## Usage
+Use the GOV.UK form builder for your forms, most simply as the application default:
+
+```ruby
+class ApplicationController < ActionController::Base
+ default_form_builder GOVUKDesignSystemFormBuilder::FormBuilder
+end
+```
+
Add the stylesheet to your default layout:
```erb
@@ -33,38 +43,154 @@ You can also add it to your SASS build:
@use "katalyst/govuk/formbuilder";
```
-Some GOVUK components require javascript enhancements
-(see [GOVUK docs](https://frontend.design-system.service.gov.uk/get-started/#5-get-the-javascript-working)).
+### JavaScript
+
+The attachment and file upload fields are Stimulus-powered. Load the gem's
+controllers into your Stimulus application:
+
+```js
+import { Application } from "@hotwired/stimulus";
+
+const application = Application.start();
+
+import GOVUK from "@katalyst/govuk-formbuilder";
+GOVUK.start(application);
+```
-You can use the provided helper to load the formbuilder esm from importmaps and enhance your form:
+If you want to keep GOVUK enhancements separate from your main app's Stimulus
+application, you can inject the bootstrap JS module into your body instead:
```erb
-<%= form_with ... %>
<%= govuk_formbuilder_init %>
```
-You'll need to include the helper to make this method available, which you can add to your `ApplicationController`:
+You'll need to include the helper to make this method available, which you can add
+to your `ApplicationController`:
```ruby
helper Katalyst::GOVUK::FormBuilder::Frontend
```
-## Extensions
+The snippet marks the page as JavaScript-capable and calls the module's `initAll()`,
+but will not survive a new page render (including Turbo navigation). Use this
+approach if you are only using GOVUK sparingly.
-We include some optional extensions for integrating with gems that we (Katalyst) commonly use.
+#### JavaScript dependencies
-These require additional steps to use.
+The formbuilder module imports `@hotwired/stimulus` and `@rails/activestorage`. With
+importmaps the gem pins itself and `@rails/activestorage` for you. It does not pin
+`@hotwired/stimulus` — your app provides that (`stimulus-rails` does this in a
+standard Rails app). If you use jsbundling or similar, you'll need both packages
+available at runtime; the wiring is the same `GOVUK.start(application)` shown above,
+from your own bundle.
-### File inputs
+## Attachment fields
-We've added image and document fields designed for use with Koi. These will not be suitable for
-every project. If you want to use these, you'll need to import and load their stimulus controllers:
+`govuk_image_field` and `govuk_document_field` render an upload field for
+`has_one_attached` / `has_many_attached` attributes. With JavaScript, files upload
+asynchronously as soon as they are chosen or dropped, each showing a preview figure
+with progress, retry on failure, and a remove control. Without JavaScript the same
+field is a plain file input plus a keep/remove select per attached file — no
+functionality is lost, only polish.
-```js
-import govuk from "@katalyst/govuk-formbuilder";
-application.load(govuk);
+```erb
+<%= f.govuk_image_field :avatar %>
+<%= f.govuk_document_field :cv %>
+<%= f.govuk_attachment_field :recording, accept: "audio/*" %>
```
+Both fields delegate to `govuk_attachment_field`; they differ only in the mime types
+they accept (`config.image_mime_types` / `config.document_mime_types`).
+
+- The attribute's value must be an `ActiveStorage::Attached`; anything else raises
+ `ArgumentError` at render. For plain multipart uploads without ActiveStorage, use
+ the upstream `govuk_file_field`.
+- `multiple` is inferred from the association (`has_many_attached` → multiple), and
+ an explicit `multiple:` argument is respected.
+- Attachments round-trip as blob signed ids: when validation fails, the re-rendered
+ form retains every attachment — stored, direct-uploaded, or pending multipart — so
+ a failed submit never loses an upload.
+- Removal is always offered. A required attachment should say so with a presence
+ validation; the form does not hide removal on its behalf.
+- Direct uploads post to `rails_direct_uploads_url` by default. Pass
+ `direct_upload_url:` to use a different endpoint, or `direct_upload: false` to
+ leave chosen files in the input and submit them as ordinary multipart.
+- Fields accept the standard GOV.UK options (`label:`, `hint:`, `caption:`,
+ `form_group:`, `before_input:`, `after_input:`) and a block for supplemental
+ content rendered inside the form group.
+
+Preview thumbnails are generated lazily through ActiveStorage's representation
+route. The transformation is configurable:
+
+```ruby
+GOVUKDesignSystemFormBuilder.config.attachment_preview_representation =
+ { resize_to_fill: [256, 256] } # the default
+```
+
+### Strings and internationalisation
+
+All user-facing strings resolve through Rails i18n under `katalyst.govuk.attachment.*`
+(`upload_succeeded`, `upload_failed`, `retry_button`, `file_removed`, `remove_button`,
+`remove_button_content`), alongside govuk-frontend's FileUpload strings. Each has a
+per-field text option (`upload_succeeded_text`, `upload_failed_text`,
+`retry_button_text`, `file_removed_text`, `remove_button_text`,
+`remove_button_content_text`, `choose_files_button_text`, `drop_instruction_text`,
+`no_file_chosen_text`, `multiple_files_chosen_text`, `entered_drop_zone_text`,
+`left_drop_zone_text`). Strings reach the JavaScript enhancement via the field's
+`data-i18n.*` attributes, with the locale taken from the closest `lang` attribute.
+
+## Upgrading from 1.x
+
+This major version replaces the image/document field implementations with the
+attachment field described above.
+
+- `govuk_image_field` / `govuk_document_field` now require an
+ `ActiveStorage::Attached` value and raise `ArgumentError` otherwise. The legacy
+ fields rendered a plain enhanced input for other values (e.g. form objects) — for
+ those, use `govuk_file_field`.
+- The `optional:` argument no longer does anything: removal is always offered, and
+ submitting the remove option detaches on save. A required attachment must be
+ guarded by a presence validation.
+- Dropped files are no longer filtered by mime type on the client. The `accept`
+ attribute remains a file-picker courtesy; your model's validations are the
+ authority on content.
+- `application.load(govuk)` no longer works — the default export is no longer the
+ controller definitions array, and Stimulus raises a `TypeError` at boot. Replace it
+ with `GOVUK.start(application)` (see JavaScript above), which registers the
+ controllers and keeps enhancement running across Turbo visits.
+- Remove any `turbo:render` / `turbo:frame-load` re-initialisation wiring —
+ enhancement now observes the DOM and owns re-enhancement; repeated calls are
+ harmless no-ops.
+- Text options on file fields are now honoured by the JavaScript. Previously they
+ rendered but were never read, so non-English sites got English announcements.
+- Brand (`GOVUKDesignSystemFormBuilder.brand`) now affects CSS classes only:
+ Stimulus identifiers, `data-controller`/`data-action` wiring, and events are
+ always `govuk-*`. The gem's compiled CSS remains govuk-prefixed — a non-default
+ brand presumes a consumer-supplied frontend build.
+
+As a transitional escape hatch, the legacy implementations remain available behind a
+flag:
+
+```ruby
+GOVUKDesignSystemFormBuilder.config.use_legacy_file_fields = true # default false
+```
+
+The flag flips `govuk_image_field` / `govuk_document_field` back to the legacy
+elements. It exists to stage a migration, not to stay on: the flag and the legacy
+code will be removed together in a subsequent release.
+
+## Extensions
+
+We include some optional extensions for integrating with gems that we (Katalyst)
+commonly use. These require additional steps to use.
+
+### Rich text area
+
+`govuk_rich_textarea` renders a Trix editor with GOV.UK form conventions. It
+requires ActionText to be set up in your application (`rails action_text:install`),
+including its JavaScript (`trix` and `@rails/actiontext`) in your bundle or
+importmap.
+
### Hotwire Combobox
[Hotwire Combobox](https://hotwirecombobox.com) is a promising option for adding asynchronous multi-select inputs to
diff --git a/app/assets/stylesheets/katalyst/govuk/components/_index.scss b/app/assets/stylesheets/katalyst/govuk/components/_index.scss
index c8a39f0..e06b770 100644
--- a/app/assets/stylesheets/katalyst/govuk/components/_index.scss
+++ b/app/assets/stylesheets/katalyst/govuk/components/_index.scss
@@ -1,3 +1,4 @@
+@use "attachment";
@use "govuk-frontend/dist/govuk/components/character-count";
@use "govuk-frontend/dist/govuk/components/checkboxes";
@use "combobox";
diff --git a/app/assets/stylesheets/katalyst/govuk/components/attachment/_index.scss b/app/assets/stylesheets/katalyst/govuk/components/attachment/_index.scss
new file mode 100644
index 0000000..b1eec39
--- /dev/null
+++ b/app/assets/stylesheets/katalyst/govuk/components/attachment/_index.scss
@@ -0,0 +1,3 @@
+@use "mixin";
+
+@include mixin.styles;
diff --git a/app/assets/stylesheets/katalyst/govuk/components/attachment/_mixin.scss b/app/assets/stylesheets/katalyst/govuk/components/attachment/_mixin.scss
new file mode 100644
index 0000000..177f9da
--- /dev/null
+++ b/app/assets/stylesheets/katalyst/govuk/components/attachment/_mixin.scss
@@ -0,0 +1,89 @@
+@use "govuk-frontend/dist/govuk/base";
+@use "govuk-frontend/dist/govuk/core/typography";
+
+$attachment-background-colour: base.govuk-colour("black", $variant: "tint-95");
+$attachment-border-width: 2px;
+
+@mixin styles {
+ :where(.govuk-attachment) {
+ display: grid;
+ grid-template-areas: "preview caption actions";
+ grid-template-columns: auto 1fr auto;
+ margin: 0;
+ padding: base.govuk-spacing(3) base.govuk-spacing(3);
+ grid-gap: base.govuk-spacing(2);
+ background-color: $attachment-background-colour;
+ border: $attachment-border-width solid
+ base.govuk-functional-colour("border");
+
+ .preview {
+ grid-area: preview;
+ max-width: 4rem;
+ aspect-ratio: 1/1;
+ object-fit: cover;
+ }
+
+ .caption {
+ display: flex;
+ flex-direction: column;
+ grid-area: caption;
+ @include base.govuk-font($size: 16, $line-height: 1.25);
+ }
+
+ .filename {
+ @include base.govuk-typography-weight-bold;
+ }
+
+ .size {
+ color: base.govuk-functional-colour(secondary-text);
+ }
+
+ .actions {
+ grid-area: actions;
+ }
+
+ // Buttons require JavaScript
+ .actions button {
+ display: none;
+ }
+
+ &[data-state="upload-successful"] {
+ .status {
+ color: base.govuk-functional-colour("success");
+ }
+ }
+
+ &[data-state="upload-failed"] {
+ border-color: base.govuk-functional-colour("error");
+
+ .status {
+ color: base.govuk-functional-colour("error");
+ }
+ }
+ }
+
+ .govuk-attachment {
+ margin-bottom: base.govuk-spacing(2);
+ }
+
+ // With JavaScript running the button is the figure's only interactive
+ // control; the select still carries the submitted value but leaves the
+ // display, tab order, and accessibility tree.
+ .govuk-frontend-supported :where(.govuk-attachment) {
+ .actions select {
+ display: none;
+ }
+
+ .actions button {
+ display: revert;
+ }
+ }
+
+ // Hide duplicate inputs when multiple is not enabled, this allows reverting
+ // but the value from the last select will overwrite the others in save.
+ .govuk-file-upload-wrapper:has(input[type="file"]:not([multiple])) {
+ .govuk-attachment:has(+ .govuk-attachment) {
+ display: none;
+ }
+ }
+}
diff --git a/app/helpers/katalyst/govuk/form_builder/builder.rb b/app/helpers/katalyst/govuk/form_builder/builder.rb
index fbae7e5..6f8ea9e 100644
--- a/app/helpers/katalyst/govuk/form_builder/builder.rb
+++ b/app/helpers/katalyst/govuk/form_builder/builder.rb
@@ -7,6 +7,9 @@ module Builder
extend ActiveSupport::Concern
included do
+ # Delegate image_tag for attachment previews
+ delegate :image_tag, to: :@template
+
# Overwrite GOVUK default to set small to true
# @see GOVUKDesignSystemFormBuilder::Builder#govuk_collection_radio_buttons
def govuk_collection_radio_buttons(attribute_name, collection, value_method, text_method = nil,
@@ -248,6 +251,136 @@ def govuk_combobox(attribute_name, options_or_src = [], options: {}, label: {},
).html
end
+ # Generates an input of type +file+ with active storage and preview support.
+ #
+ # @param attribute_name [Symbol] The name of the attribute
+ # @option label text [String] the label text
+ # @option label tag [Symbol,String] the label's wrapper tag, intended to allow labels to act as page headings
+ # @option label size [String] the size of the label font, can be +xl+, +l+, +m+, +s+ or nil
+ # @option label hidden [Boolean] control the visability of the label. Hidden labels will stil be read by
+ # screenreaders
+ # @option label kwargs [Hash] additional arguments are applied as attributes on the +label+ element
+ # @param caption [Hash] configures or sets the caption content which is inserted above the label
+ # @option caption text [String] the caption text
+ # @option caption size [String] the size of the caption, can be +xl+, +l+ or +m+. Defaults to +m+
+ # @option caption kwargs [Hash] additional arguments are applied as attributes on the caption +span+ element
+ # @param hint [Hash,Proc] The content of the hint. No hint will be added if 'text' is left +nil+. When a
+ # +Proc+ is supplied the hint will be wrapped in a +div+ instead of a +span+
+ # @option hint text [String] the hint text
+ # @option hint kwargs [Hash] additional arguments are applied as attributes to the hint
+ # @option kwargs [Hash] kwargs additional arguments are applied as attributes to the +input+ element
+ # @param form_group [Hash] configures the form group
+ # @option form_group kwargs [Hash] additional attributes added to the form group
+ # @param before_input [String,Proc] the content injected before the input. No content will be added if left
+ # +nil+
+ # @param after_input [String,Proc] the content injected after the input. No content will be added if left
+ # +nil+
+ # @param choose_files_button_text [String] The text of the button that opens the file picker. Default is
+ # "Choose file". If javascript is not provided, this option will be ignored.
+ # @param drop_instruction_text [String] The text informing users they can drop files. Default is
+ # "or drop file". If javascript is not provided, this option will be ignored.
+ # @param multiple_files_chosen_text [Hash] The text displayed when multiple files have been chosen by the
+ # user. The component will replace the %{count} placeholder with the number of files selected. This uses
+ # the govuk-frontend pluralisation rules. If javascript is not provided, this option will be ignored.
+ # @param multiple_files_chosen_one_text [String] The text displayed when JavaScript is enabled and one file
+ # has been chosen by the user. The component will replace the %{count} placeholder with the number of files
+ # selected. This can also be set by passing a hash with key +one:+ to +multiple_files_chosen_text+.
+ # @param multiple_files_chosen_other_text [String] The text displayed when JavaScript is enabled and multiple
+ # files have been chosen by the user. The component will replace the %{count} placeholder with the number of
+ # files selected. This can also be set by passing a hash with key +other:+ to +multiple_files_chosen_text+.
+ # @param no_file_chosen_text [String] The text displayed when no file has been chosen by the user. Default is
+ # "No file chosen". If javascript is not provided, this option will be ignored.
+ # @param entered_drop_zone_text [String] The text announced by assistive technology when user drags files and
+ # enters the drop zone. Default is "Entered drop zone". If javascript is not provided, this option will be
+ # ignored.
+ # @param left_drop_zone_text [String] The text announced by assistive technology when user drags files and
+ # leaves the drop zone without dropping. Default is "Left drop zone". If javascript is not provided, this
+ # option will be ignored.
+ # @param upload_succeeded_text [String] The status shown in a figure's caption when its direct upload
+ # completes. Default is "Uploaded successfully". If javascript is not provided, this option will be ignored.
+ # @param upload_failed_text [String] The status shown in a figure's caption when its direct upload fails.
+ # Default is "Upload failed — try again". If javascript is not provided, this option will be ignored.
+ # @param retry_button_text [String] The label of the retry control offered on a failed upload. Default is
+ # "Try again". If javascript is not provided, this option will be ignored.
+ # @param file_removed_text [String] The text announced by assistive technology when a figure is removed. The
+ # component will replace the %{filename} placeholder with the removed file's name. Default is
+ # "%{filename} removed". If javascript is not provided, this option will be ignored.
+ # @param remove_button_text [String] The accessible name of each figure's remove control and the text of its
+ # no-JavaScript remove option. The component will replace the %{filename} placeholder with the figure's
+ # file name. Default is "Remove %{filename}".
+ # @param remove_button_content_text [String] The visible content of each figure's remove button. Default is
+ # "Remove".
+ # @param & [Block] arbitrary HTML that will be rendered between the hint and the input
+ #
+ # @example A photo upload field with file type specifier and injected content
+ # = f.govuk_attachment_field :photo, label: { text: 'Upload your photo' }, accept: 'image/*' do
+ #
+ # p.govuk-inset-text
+ # | Explicit images will result in account termination
+ #
+ # @example A CV upload field with label as a proc
+ # = f.govuk_attachment_field :cv, label: -> { tag.h3('Upload your CV') }
+ #
+ # @see https://design-system.service.gov.uk/components/file-upload/ GOV.UK file upload
+ # @see https://design-system.service.gov.uk/styles/typography/#headings-with-captions Headings with captions
+ # @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file MDN documentation for file upload
+ def govuk_attachment_field(
+ attribute_name,
+ label: {},
+ caption: {},
+ hint: {},
+ form_group: {},
+ before_input: nil,
+ after_input: nil,
+ choose_files_button_text: nil,
+ drop_instruction_text: nil,
+ multiple_files_chosen_text: nil,
+ multiple_files_chosen_one_text: nil,
+ multiple_files_chosen_other_text: nil,
+ no_file_chosen_text: nil,
+ entered_drop_zone_text: nil,
+ left_drop_zone_text: nil,
+ upload_succeeded_text: nil,
+ upload_failed_text: nil,
+ retry_button_text: nil,
+ file_removed_text: nil,
+ remove_button_text: nil,
+ remove_button_content_text: nil,
+ direct_upload: true,
+ direct_upload_url: (self.direct_upload_url if direct_upload),
+ **,
+ &
+ )
+ Elements::Attachment.new(
+ self,
+ object_name,
+ attribute_name,
+ label:,
+ caption:,
+ hint:,
+ form_group:,
+ before_input:,
+ after_input:,
+ direct_upload_url:,
+ choose_files_button_text:,
+ drop_instruction_text:,
+ multiple_files_chosen_text:,
+ multiple_files_chosen_one_text:,
+ multiple_files_chosen_other_text:,
+ no_file_chosen_text:,
+ entered_drop_zone_text:,
+ left_drop_zone_text:,
+ upload_succeeded_text:,
+ upload_failed_text:,
+ retry_button_text:,
+ file_removed_text:,
+ remove_button_text:,
+ remove_button_content_text:,
+ **,
+ &
+ ).html
+ end
+
# Generates a file input element for uploading documents.
#
# @example A upload field with label as a proc
@@ -261,9 +394,15 @@ def govuk_document_field(attribute_name,
mime_types: config.document_mime_types,
**,
&)
- Elements::Document.new(
- self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
- ).html
+ if config.use_legacy_file_fields
+ Elements::Document.new(
+ self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
+ ).html
+ else
+ govuk_attachment_field(
+ attribute_name, label:, caption:, hint:, form_group:, accept: mime_types&.join(","), **, &
+ )
+ end
end
# Generates a file input element with a preview for uploading images.
@@ -309,9 +448,15 @@ def govuk_image_field(attribute_name,
mime_types: config.image_mime_types,
**,
&)
- Elements::Image.new(
- self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
- ).html
+ if config.use_legacy_file_fields
+ Elements::Image.new(
+ self, object_name, attribute_name, label:, caption:, hint:, form_group:, mime_types:, **, &
+ ).html
+ else
+ govuk_attachment_field(
+ attribute_name, label:, caption:, hint:, form_group:, accept: mime_types&.join(","), **, &
+ )
+ end
end
# Keep track of whether we are inside a fieldset
@@ -320,8 +465,31 @@ def fieldset_context
@fieldset_context ||= []
end
+ # URL for an attachment preview. ActiveStorage's representation route
+ # lives in the application's route set, so engine-mounted forms
+ # resolve it through main_app. Returns nil when no route is
+ # available, in which case the figure renders without a preview.
+ #
+ # @param [ActiveStorage::Variant,ActiveStorage::VariantWithRecord,ActiveStorage::Preview] representation
+ # @return [String,nil]
+ def attachment_preview_url(representation)
+ if @template.respond_to?(:rails_representation_path)
+ @template.rails_representation_path(representation)
+ elsif @template.respond_to?(:main_app) && @template.main_app.respond_to?(:rails_representation_path)
+ @template.main_app.rails_representation_path(representation)
+ end
+ end
+
private
+ def direct_upload_url
+ if @template.respond_to?(:rails_direct_uploads_url)
+ @template.rails_direct_uploads_url
+ elsif @template.respond_to?(:main_app) && @template.main_app.respond_to?(:rails_direct_uploads_url)
+ @template.main_app.rails_direct_uploads_url
+ end
+ end
+
def enum_values(attribute_name)
object.class.defined_enums[attribute_name.to_s].keys
end
diff --git a/app/helpers/katalyst/govuk/form_builder/elements/attachment.rb b/app/helpers/katalyst/govuk/form_builder/elements/attachment.rb
new file mode 100644
index 0000000..872707f
--- /dev/null
+++ b/app/helpers/katalyst/govuk/form_builder/elements/attachment.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module Katalyst
+ module GOVUK
+ module FormBuilder
+ module Elements
+ class Attachment < GOVUKDesignSystemFormBuilder::Elements::File
+ include FormBuilder::Traits::Attachment
+
+ def initialize(builder, object_name, attribute_name, direct_upload_url:,
+ upload_succeeded_text: nil, upload_failed_text: nil,
+ retry_button_text: nil, file_removed_text: nil,
+ remove_button_text: nil, remove_button_content_text: nil, **, &)
+ super(builder, object_name, attribute_name, javascript: true, **, &)
+
+ @direct_upload_url = direct_upload_url
+ @upload_succeeded_text = upload_succeeded_text
+ @upload_failed_text = upload_failed_text
+ @retry_button_text = retry_button_text
+ @file_removed_text = file_removed_text
+ @remove_button_text = remove_button_text
+ @remove_button_content_text = remove_button_content_text
+
+ raise ArgumentError, "Unsupported attribute type #{value.class} for #{attribute_name}" unless attachment?
+ end
+
+ def options
+ super.merge(
+ "data-direct-upload-url" => @direct_upload_url,
+ include_hidden: false, # we always render remove_field
+ multiple: many?,
+ )
+ end
+
+ private
+
+ # Extends the file element's data-i18n.* attributes with the
+ # attachment strings: an explicit option wins, else the current
+ # locale's translation. When neither adds anything beyond the
+ # gem's en defaults the attribute is omitted and the JS falls
+ # back to its bundled mirror of the same table.
+ def i18n_data
+ super.merge({
+ "data-i18n.upload-succeeded" => attachment_text(@upload_succeeded_text, :upload_succeeded),
+ "data-i18n.upload-failed" => attachment_text(@upload_failed_text, :upload_failed),
+ "data-i18n.retry-button" => attachment_text(@retry_button_text, :retry_button),
+ "data-i18n.file-removed" => attachment_text(@file_removed_text, :file_removed),
+ "data-i18n.remove-button" => attachment_text(@remove_button_text, :remove_button),
+ "data-i18n.remove-button-content" => attachment_text(@remove_button_content_text, :remove_button_content),
+ }.compact)
+ end
+
+ def attachment_text(option, key)
+ return option if option
+
+ text = attachment_translation(key)
+ text unless text == Traits::Attachment::BUNDLED_DEFAULTS[key.to_s]
+ end
+
+ def file
+ safe_join([remove_field, attachment, @builder.file_field(@attribute_name, attributes(@html_attributes))])
+ end
+
+ def file_with_javascript_markup
+ tag.div(class: "#{brand}-file-upload-wrapper", data: { controller: "govuk-file-upload" }, **i18n_data) do
+ file
+ end
+ end
+
+ # A removed figure takes its select with it and an empty file input contributes
+ # no value. This input ensures that there's always a value to process so that
+ # attachments can be removed (both one? and many? cases).
+ def remove_field
+ tag.input(name: @builder.field_name(@attribute_name, multiple: many?), type: "hidden", value: "")
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/helpers/katalyst/govuk/form_builder/frontend.rb b/app/helpers/katalyst/govuk/form_builder/frontend.rb
index dc003a9..91e91f9 100644
--- a/app/helpers/katalyst/govuk/form_builder/frontend.rb
+++ b/app/helpers/katalyst/govuk/form_builder/frontend.rb
@@ -4,18 +4,30 @@ module Katalyst
module GOVUK
module FormBuilder
module Frontend
+ # Marks the page as JS-capable and enhances govuk-frontend components,
+ # on load and as the DOM changes (Turbo morphs, lazily-loaded frames,
+ # stream inserts). Render at the end of
; on a Turbo replace
+ # render the snippet re-executes with the new body, and everything it
+ # sets up is scoped to the body element it ran against.
# rubocop:disable Rails/OutputSafety
def govuk_formbuilder_init
tag.script type: "module", nonce: request.content_security_policy_nonce do
<<~JS.html_safe
- document.body.classList.toggle("js-enabled", true);
- document.body.classList.toggle("govuk-frontend-supported", ('noModule' in HTMLScriptElement.prototype));
import {initAll} from "@katalyst/govuk-formbuilder";
- initAll();
+ initAll(#{govuk_formbuilder_init_options});
JS
end
end
# rubocop:enable Rails/OutputSafety
+
+ private
+
+ # The bundle's own default is govuk; only a non-default brand renders.
+ def govuk_formbuilder_init_options
+ brand = GOVUKDesignSystemFormBuilder.brand
+
+ brand.to_s == "govuk" ? "" : "{brand: #{brand.to_json}}"
+ end
end
end
end
diff --git a/app/helpers/katalyst/govuk/form_builder/traits/attachment.rb b/app/helpers/katalyst/govuk/form_builder/traits/attachment.rb
new file mode 100644
index 0000000..e33a1db
--- /dev/null
+++ b/app/helpers/katalyst/govuk/form_builder/traits/attachment.rb
@@ -0,0 +1,215 @@
+# frozen_string_literal: true
+
+require "govuk_design_system_formbuilder"
+
+module Katalyst
+ module GOVUK
+ module FormBuilder
+ module Traits
+ # Generates attachment inputs and previews for ActiveStorage associations.
+ module Attachment
+ extend ActiveSupport::Concern
+
+ include ActionView::Helpers::NumberHelper
+
+ # The attachment strings' canonical home (config/locales).
+ I18N_SCOPE = %i[katalyst govuk attachment].freeze
+
+ # The gem's own en strings, read straight from its locale file:
+ # resolving them through I18n would absorb a host app's en
+ # overrides, and this table is the baseline those overrides are
+ # detected against.
+ BUNDLED_DEFAULTS = YAML.load_file(
+ Engine.root.join("config/locales/en.yml"),
+ ).dig("en", *I18N_SCOPE.map(&:to_s)).freeze
+
+ def attachment?
+ value.is_a?(ActiveStorage::Attached)
+ end
+
+ def one?
+ value.is_a?(ActiveStorage::Attached::One)
+ end
+
+ def many?
+ value.is_a?(ActiveStorage::Attached::Many)
+ end
+
+ def value
+ @builder.object.send(@attribute_name)
+ end
+
+ delegate :attached?, to: :value
+
+ # @return [ActiveSupport::SafeBuffer,nil]
+ def attachment
+ return unless attached?
+
+ # Preserve unsaved multi-part form uploads before rendering
+ # mimics direct-upload for non-js consumers.
+ persist_pending_blobs
+
+ blobs = (one? ? [value.blob] : value.blobs).select(&:persisted?)
+ safe_join(blobs.map { |blob| attachment_for(blob) })
+ end
+
+ # @param [ActiveStorage::Blob] blob
+ # @return [ActiveSupport::SafeBuffer,nil]
+ def attachment_for(blob)
+ tag.figure(class: "#{brand}-attachment",
+ aria: { labelledby: attachment_id_for(blob, :filename) },
+ data: { controller: "govuk-attachment" }) do
+ safe_join([
+ attachment_preview_for(blob),
+ attachment_caption_for(blob),
+ attachment_actions_for(blob),
+ ])
+ end
+ end
+
+ # A with form elements for managing the attachment.
+ # @param [ActiveStorage::Blob] blob
+ # @return [ActiveSupport::SafeBuffer|nil]
+ def attachment_actions_for(blob)
+ return if blob.nil?
+
+ tag.div(class: "actions") do
+ safe_join([
+ attachment_input_for(blob),
+ attachment_remove_for(blob),
+ ])
+ end
+ end
+
+ # A
with options to keep or remove the attachment that can be used without JavaScript.
+ # @param [ActiveStorage::Blob] blob
+ # @return [ActiveSupport::SafeBuffer,nil]
+ def attachment_input_for(blob)
+ @builder.select(
+ @attribute_name,
+ [[blob.filename.to_s, blob.signed_id],
+ [remove_button_label(blob), ""]],
+ { selected: blob.signed_id },
+ id: attachment_id_for(blob, :input),
+ name: @builder.field_name(@attribute_name, multiple: many?),
+ aria: { labelledby: attachment_id_for(blob, :filename) },
+ )
+ end
+
+ # A that will remove the attachment when clicked (requires javascript).
+ # @param [ActiveStorage::Blob] blob
+ # @return [ActiveSupport::SafeBuffer,nil]
+ def attachment_remove_for(blob)
+ tag.button(remove_button_content,
+ type: "button",
+ class: "#{brand}-button #{brand}-button--secondary #{brand}-attachment__remove",
+ aria: { label: remove_button_label(blob) },
+ data: { action: "govuk-attachment#destroy", module: "govuk-button" })
+ end
+
+ # The remove strings render here and in the JS figure template, so
+ # both draw from the same options (or matching defaults) — the two
+ # figure sources must stay string-identical.
+ # @param [ActiveStorage::Blob] blob
+ # @return [String]
+ def remove_button_label(blob)
+ if @remove_button_text
+ # %{filename} is the option placeholder (govuk-frontend's i18n
+ # convention), substituted directly — not a Ruby format token.
+ # rubocop:disable Style/FormatStringToken
+ @remove_button_text.gsub("%{filename}", blob.filename.to_s)
+ # rubocop:enable Style/FormatStringToken
+ else
+ attachment_translation(:remove_button, filename: blob.filename.to_s)
+ end
+ end
+
+ # @return [String]
+ def remove_button_content
+ @remove_button_content_text || attachment_translation(:remove_button_content)
+ end
+
+ # The current locale's translation, falling back to the gem's en
+ # defaults rather than a "translation missing" marker.
+ # @return [String]
+ def attachment_translation(key, **)
+ I18n.t(key, scope: I18N_SCOPE, default: nil, **) ||
+ I18n.t(key, scope: I18N_SCOPE, locale: :en, **)
+ end
+
+ # The representation is rendered lazily: the variant is processed
+ # when the browser requests it, never during the form render. If the
+ # blob's bytes turn out to be missing or unprocessable the preview
+ # simply fails to load — validating attachment content is the
+ # model's responsibility, not the form's.
+ # @param [ActiveStorage::Blob] blob
+ # @return [ActiveSupport::SafeBuffer,nil]
+ def attachment_preview_for(blob)
+ return unless blob.representable?
+
+ url = @builder.attachment_preview_url(
+ blob.representation(config.attachment_preview_representation),
+ )
+ return if url.nil?
+
+ # Setting alt to "" as the details already describe the attachment, equivalent to role="presentation"
+ @builder.image_tag(url, alt: "", class: "preview")
+ end
+
+ # The caption is a polite atomic live region: JS writes upload status
+ # into the status span, and the announcement reads the whole caption
+ # so the user hears which file the status belongs to.
+ # @param [ActiveStorage::Blob] blob
+ # @return [ActiveSupport::SafeBuffer,nil]
+ def attachment_caption_for(blob)
+ tag.figcaption(class: "caption", aria: { atomic: true, live: "polite" }) do
+ safe_join([
+ tag.span(blob.filename, id: attachment_id_for(blob, :filename), class: "filename"),
+ " ",
+ tag.span(number_to_human_size(blob.byte_size), class: "size"),
+ " ",
+ tag.span(class: "status"),
+ ])
+ end
+ end
+
+ # @param [ActiveStorage::Blob] blob
+ # @return [String,nil]
+ def attachment_id_for(blob, *suffixes)
+ return nil if @html_attributes.fetch(:skip_default_ids, false)
+
+ @builder.field_id(@attribute_name, :attachment, blob.id, *suffixes)
+ end
+
+ private
+
+ def persist_pending_blobs
+ case (change = @builder.object.attachment_changes[@attribute_name.to_s])
+ when ActiveStorage::Attached::Changes::CreateOne
+ persist_pending_change(change)
+ when ActiveStorage::Attached::Changes::CreateMany
+ change.pending_uploads.each do |subchange|
+ persist_pending_change(subchange)
+ end
+ end
+ end
+
+ def persist_pending_change(change)
+ change.upload
+ change.blob.save!
+ rescue ActiveStorage::Error, Errno::ENOENT => e
+ # no recovery available (ENOENT: tempfile vanished before render)
+ log_dropped_upload(e)
+ end
+
+ def log_dropped_upload(error)
+ Rails.logger.warn(
+ "Dropped pending attachment for " \
+ "#{@builder.object.class}##{@attribute_name}: #{error.class}: #{error.message}",
+ )
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/javascript/katalyst/govuk/config.js b/app/javascript/katalyst/govuk/config.js
new file mode 100644
index 0000000..04a75c1
--- /dev/null
+++ b/app/javascript/katalyst/govuk/config.js
@@ -0,0 +1,49 @@
+import { FileUpload } from "govuk-frontend/dist/govuk/all.mjs";
+import {
+ mergeConfigs,
+ normaliseDataset,
+} from "govuk-frontend/dist/govuk/common/configuration.mjs";
+
+// CSS classes written or queried by this bundle follow the configured
+// brand, mirroring the Ruby builder's class prefixes. Behavioural wiring
+// (Stimulus identifiers, data-actions, events) is always govuk-prefixed.
+// Set from the govuk_formbuilder_init snippet via initAll({ brand }).
+const config = { brand: "govuk" };
+
+export default config;
+
+// The pseudo upload button, identified structurally — the button fronting
+// the (hidden) file input it precedes — so the selector holds under any
+// brand.
+export const uploadButtonSelector = "[type='button']:has(+ input[type='file'])";
+
+// The attachment field replaces govuk-frontend's FileUpload, so its
+// vocabulary is FileUpload's strings plus the attachment additions — one
+// table for the whole field. The inherited keys keep their upstream names:
+// the data-i18n.* attribute shapes are the compatibility bar.
+const Attachment = {
+ moduleName: "govuk-attachment",
+ defaults: {
+ i18n: {
+ ...FileUpload.defaults.i18n,
+ uploadSucceeded: "Uploaded successfully",
+ uploadFailed: "Upload failed — try again",
+ retryButton: "Try again",
+ fileRemoved: "%{filename} removed",
+ removeButton: "Remove %{filename}",
+ removeButtonContent: "Remove",
+ },
+ },
+ schema: { properties: { i18n: { type: "object" } } },
+};
+
+// The wrapper's data-i18n.* attributes (the builder's text options) merged
+// over the bundled defaults — what ConfigurableComponent would provide as
+// this.config. Each controller constructs its own I18n from it, with locale
+// resolved from its own root, matching govuk-frontend's component pattern.
+export function attachmentConfig(wrapper) {
+ return mergeConfigs(
+ Attachment.defaults,
+ normaliseDataset(Attachment, wrapper?.dataset ?? {}),
+ );
+}
diff --git a/app/javascript/katalyst/govuk/controllers/attachment_controller.js b/app/javascript/katalyst/govuk/controllers/attachment_controller.js
new file mode 100644
index 0000000..54bcee8
--- /dev/null
+++ b/app/javascript/katalyst/govuk/controllers/attachment_controller.js
@@ -0,0 +1,298 @@
+import { Controller } from "@hotwired/stimulus";
+import { DirectUploadController } from "@rails/activestorage";
+import { I18n } from "govuk-frontend/dist/govuk/i18n.mjs";
+import { closestAttributeValue } from "govuk-frontend/dist/govuk/common/closest-attribute-value.mjs";
+import config, { attachmentConfig, uploadButtonSelector } from "../config";
+
+class AttachmentUploadController extends DirectUploadController {
+ async start(option) {
+ this.dispatch("start");
+ try {
+ const attributes = await new Promise((resolve, reject) => {
+ this.directUpload.create((error, attributes) =>
+ error ? reject(error) : resolve(attributes),
+ );
+ });
+ option.value = attributes.signed_id;
+ } catch (error) {
+ this.dispatch("error", { error });
+ throw error;
+ } finally {
+ this.dispatch("end");
+ }
+ }
+}
+
+export default class AttachmentController extends Controller {
+ connect() {
+ this.config = attachmentConfig(
+ this.element.closest(`.${config.brand}-file-upload-wrapper`),
+ );
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.element, "lang"),
+ });
+
+ this.select.addEventListener("change", this.change);
+ this.previewPendingFile();
+ this.uploadPendingFile();
+ }
+
+ disconnect() {
+ this.select?.removeEventListener("change", this.change);
+ }
+
+ previewPendingFile() {
+ const file = this.element.file;
+
+ if (!file) return;
+
+ if (!file.type.startsWith("image/")) {
+ this.imageTag?.remove();
+ return;
+ }
+
+ const preview = new FileReader();
+ preview.onload = this.onPreviewReady;
+ preview.readAsDataURL(file);
+ }
+
+ uploadPendingFile() {
+ const file = this.element.file;
+
+ if (!file) return;
+ if (!this.input?.dataset.directUploadUrl) return;
+
+ delete this.element.file;
+
+ this.input.dispatchEvent(
+ new CustomEvent("govuk:upload", { detail: { file } }),
+ );
+
+ this.performUpload(file);
+ }
+
+ async performUpload(file) {
+ this.uploader = new AttachmentUploadController(this.input, file);
+
+ // Update element state, clearing any earlier failure
+ this.element.dataset.state = "uploading";
+ this.statusText = "";
+ this.retryButton?.remove();
+ const progressTag = createProgressTag(this.filenameTag.id);
+ this.captionTag.appendChild(progressTag);
+ this.input.addEventListener("direct-upload:progress", this.progress);
+
+ try {
+ await this.uploader.start(this.inputOption);
+ this.element.dataset.state = "upload-successful";
+ this.statusText = this.i18n.t("uploadSucceeded");
+ } catch (error) {
+ console.warn(error);
+ this.element.dataset.state = "upload-failed";
+ this.statusText = this.i18n.t("uploadFailed");
+ this.actionsTag?.prepend(createRetryButton(file.name, this.i18n));
+ } finally {
+ this.input.removeEventListener("direct-upload:progress", this.progress);
+ progressTag.remove();
+ }
+ }
+
+ /**
+ * @param e {ProgressEvent} file reader event
+ */
+ onPreviewReady = (e) => {
+ this.imageTag.src = e.target.result;
+ };
+
+ change = () => {
+ if (this.select.value === "") this.destroy();
+ };
+
+ retry() {
+ this.performUpload(this.directUpload.file);
+ this.removeButton?.focus();
+ }
+
+ progress = ({ detail }) => {
+ if (detail.id !== this.id) return;
+ if (this.progressTag) this.progressTag.value = detail.progress;
+ };
+
+ destroy() {
+ const focusTarget = this.uploadButton;
+
+ const remove = new CustomEvent("govuk:remove", {
+ detail: {
+ name: this.element.querySelector(".filename")?.textContent,
+ // Unclaimed figures still own a file in the input's FileList; pass
+ // it so the file-upload controller can release it.
+ file: this.element.file,
+ },
+ cancelable: true,
+ });
+
+ if (!this.input.dispatchEvent(remove)) return;
+
+ this.element.remove();
+ focusTarget?.focus();
+ }
+
+ get id() {
+ return this.directUpload?.id ?? this.select.id;
+ }
+
+ get directUpload() {
+ return this.uploader?.directUpload;
+ }
+
+ get input() {
+ return this.element
+ .closest(`.${config.brand}-file-upload-wrapper`)
+ ?.querySelector("input[type=file]");
+ }
+
+ get uploadButton() {
+ return this.element
+ .closest(`.${config.brand}-file-upload-wrapper`)
+ ?.querySelector(uploadButtonSelector);
+ }
+
+ set statusText(message) {
+ const tag = this.element.querySelector("figcaption .status");
+
+ if (tag) tag.textContent = message;
+ }
+
+ /**
+ * @returns {HTMLElement} the figure's caption, or null
+ */
+ get captionTag() {
+ return this.element.querySelector("figcaption");
+ }
+
+ /**
+ * @returns {HTMLElement} the caption's filename span, or null
+ */
+ get filenameTag() {
+ return this.element.querySelector("figcaption .filename");
+ }
+
+ /**
+ * @returns {HTMLElement} the figure's actions container, or null
+ */
+ get actionsTag() {
+ return this.element.querySelector(".actions");
+ }
+
+ get select() {
+ return this.element.querySelector("select");
+ }
+
+ /**
+ * @returns {HTMLOptionElement} the option that records the file's blob id
+ */
+ get inputOption() {
+ return this.element.querySelector("option");
+ }
+
+ /**
+ * @returns {HTMLImageElement} the preview image, or null
+ */
+ get imageTag() {
+ return this.element.querySelector("img");
+ }
+
+ get progressTag() {
+ return this.element.querySelector("progress");
+ }
+
+ get retryButton() {
+ return this.element.querySelector(".actions button[data-action*='retry']");
+ }
+
+ get removeButton() {
+ return this.element.querySelector(
+ ".actions button[data-action*='destroy']",
+ );
+ }
+}
+
+let nextAttachmentId = 0;
+
+export function createAttachment(input, file, i18n) {
+ const template = document.createElement("TEMPLATE");
+ const id = ++nextAttachmentId;
+
+ template.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const figure = template.content.firstElementChild;
+
+ figure.querySelector(".filename").textContent = file.name;
+ figure.querySelector(".size").textContent = humanSize(file.size);
+
+ const [keep, remove] = figure.querySelectorAll("option");
+ keep.textContent = file.name;
+ remove.textContent = i18n.t("removeButton", { filename: file.name });
+
+ const removeButton = figure.querySelector("button");
+ removeButton.textContent = i18n.t("removeButtonContent");
+ removeButton.setAttribute(
+ "aria-label",
+ i18n.t("removeButton", { filename: file.name }),
+ );
+
+ // The figure carries its File until an attachment controller connects and
+ // claims it for upload; while it remains, the file is still in the input's
+ // FileList and will submit as ordinary multipart.
+ figure.file = file;
+
+ return figure;
+}
+
+const UNITS = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
+
+function humanSize(bytes) {
+ if (bytes === 1) return "1 Byte";
+ if (bytes < 1024) return `${bytes} Bytes`;
+
+ const exp = Math.min(Math.floor(Math.log2(bytes) / 10), UNITS.length - 1);
+ const value = Number((bytes / 1024 ** exp).toPrecision(3));
+
+ return `${value} ${UNITS[exp]}`;
+}
+
+function createRetryButton(filename, i18n) {
+ const button = document.createElement("BUTTON");
+ button.type = "button";
+ button.className = `${config.brand}-button ${config.brand}-button--secondary ${config.brand}-attachment__retry`;
+ button.textContent = i18n.t("retryButton");
+ button.setAttribute("aria-label", `${i18n.t("retryButton")} ${filename}`);
+ button.dataset.action = "govuk-attachment#retry";
+ button.dataset.module = "govuk-button";
+ return button;
+}
+
+function createProgressTag(labelId) {
+ const progress = document.createElement("PROGRESS");
+ progress.className = `${config.brand}-attachment-progress`;
+ if (labelId) progress.setAttribute("aria-labelledby", labelId);
+ progress.value = 0;
+ progress.max = 100;
+ return progress;
+}
diff --git a/app/javascript/katalyst/govuk/controllers/file_upload_controller.js b/app/javascript/katalyst/govuk/controllers/file_upload_controller.js
new file mode 100644
index 0000000..92bff41
--- /dev/null
+++ b/app/javascript/katalyst/govuk/controllers/file_upload_controller.js
@@ -0,0 +1,410 @@
+import { Controller } from "@hotwired/stimulus";
+import { I18n } from "govuk-frontend/dist/govuk/i18n.mjs";
+import { closestAttributeValue } from "govuk-frontend/dist/govuk/common/closest-attribute-value.mjs";
+import { createAttachment } from "./attachment_controller";
+import config, { attachmentConfig, uploadButtonSelector } from "../config";
+
+export default class FileUploadController extends Controller {
+ connect() {
+ if (!this.fileInput) {
+ throw new Error(`Missing file input for ${this.element}`);
+ }
+
+ this.config = attachmentConfig(this.element);
+ this.i18n = new I18n(this.config.i18n, {
+ locale: closestAttributeValue(this.element, "lang"),
+ });
+ this.id = this.uploadButton?.id ?? this.fileInput.id;
+
+ // dragenter/dragleave are on the document so we can tell a move between
+ // child elements from truly leaving the drop zone; the document outlives
+ // any re-enhancement, so these bind once per controller lifecycle.
+ document.addEventListener("dragenter", this.onDragenter);
+ document.addEventListener("dragleave", this.onDragleave);
+
+ // The whole wrapper — figures included — is the drop target: a file
+ // dragged anywhere over the field can be dropped.
+ this.element.addEventListener("dragover", this.onDragover);
+ this.element.addEventListener("drop", this.onDrop);
+
+ this.enhance();
+
+ // A morph reconciles this element against a server response that has no
+ // JS-injected UI, in place and with no Stimulus lifecycle events (a
+ // morph that changes the surrounding structure instead recreates the
+ // element, which lands in connect()). The button vanishing without a
+ // disconnect is the signal to re-enhance.
+ this.morphObserver = new MutationObserver(this.onMorph);
+ this.morphObserver.observe(this.element, { childList: true });
+ }
+
+ disconnect() {
+ this.morphObserver?.disconnect();
+ this.disabledObserver?.disconnect();
+ this.uploadButton?.removeEventListener("click", this.onClick);
+ this.element.removeEventListener("dragover", this.onDragover);
+ this.element.removeEventListener("drop", this.onDrop);
+ this.unbindInput();
+ document.removeEventListener("dragenter", this.onDragenter);
+ document.removeEventListener("dragleave", this.onDragleave);
+ this.announcements?.remove();
+ }
+
+ // Builds the JS-only UI over the server-rendered markup: a pseudo button
+ // fronting the (hidden) input, and the assertive announcements region.
+ // Idempotent over fresh server markup or whatever a morph left behind.
+ enhance() {
+ const fileInput = this.fileInput;
+ let uploadButton = this.uploadButton;
+
+ if (!uploadButton) {
+ // The label's `for` still points at the input's original id (now the
+ // button's id), so the label labels the button. Give it an id too, so
+ // the button's `aria-labelledby` reference resolves.
+ this.ensureLabelId();
+ fileInput.id = `${this.id}-input`;
+ fileInput.setAttribute("hidden", "hidden");
+ uploadButton = createUploadButton(this.id, this.i18n, fileInput);
+ fileInput.insertAdjacentElement("beforebegin", uploadButton);
+
+ // The click listener dies with a stripped button and rebinds with
+ // its replacement; the drag listeners live on the wrapper.
+ uploadButton.addEventListener("click", this.onClick);
+ }
+
+ // Appended to the drop zone (not between button and input, whose
+ // adjacency the uploadButton getter relies on).
+ if (!this.announcements) this.element.appendChild(createAnnouncements());
+
+ // A morph may retain the input node (its listeners survive) or replace
+ // it (they vanish); removing before adding makes rebinding safe in both.
+ this.unbindInput();
+ fileInput.addEventListener("change", this.onChange);
+ fileInput.addEventListener("govuk:upload", this.onUpload);
+ fileInput.addEventListener("govuk:remove", this.onRemove);
+
+ // The injected button is not a real input, so it does not inherit the
+ // file input's disabled state; mirror it, and keep mirroring it if the
+ // input's `disabled` attribute changes at runtime.
+ this.updateDisabledState();
+ this.observeDisabledState();
+
+ this.updateCount();
+ }
+
+ unbindInput() {
+ this.fileInput?.removeEventListener("change", this.onChange);
+ this.fileInput?.removeEventListener("govuk:upload", this.onUpload);
+ this.fileInput?.removeEventListener("govuk:remove", this.onRemove);
+ }
+
+ onMorph = () => {
+ if (this.uploadButton) return;
+
+ // The morphed-in server response is the truth: files still held by the
+ // input were either persisted (they came back as server figures) or
+ // lost with the refresh, and a File handle on a retained figure node is
+ // stale either way. Discard both, then rebuild the injected UI.
+ this.fileInput.value = "";
+ this.element.querySelectorAll("figure").forEach((figure) => {
+ delete figure.file;
+ });
+
+ this.enhance();
+ };
+
+ // The label is rendered by the form group, outside the drop zone, and
+ // carries only a `for` (no id). The injected button references `${id}-label`
+ // in its `aria-labelledby`, so ensure the label has that id.
+ ensureLabelId() {
+ const label = document.querySelector(`label[for="${this.id}"]`);
+
+ if (label && !label.id) label.id = `${this.id}-label`;
+ }
+
+ updateDisabledState() {
+ if (!this.uploadButton) return;
+
+ const disabled = this.fileInput.disabled;
+
+ this.uploadButton.disabled = disabled;
+ this.element.classList.toggle(
+ `${config.brand}-file-upload-wrapper--disabled`,
+ disabled,
+ );
+ }
+
+ observeDisabledState() {
+ this.disabledObserver?.disconnect();
+ this.disabledObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ if (mutation.attributeName === "disabled") this.updateDisabledState();
+ }
+ });
+
+ this.disabledObserver.observe(this.fileInput, { attributes: true });
+ }
+
+ onClick = (event) => {
+ this.fileInput.click();
+ };
+
+ // Drag & drop: the whole wrapper is the drop target, the button shows
+ // the dragging state, and enter/leave are announced.
+ onDragover = (event) => {
+ if (this.fileInput.disabled) return;
+
+ event.preventDefault();
+ };
+
+ onDragenter = (event) => {
+ this.updateDropzoneVisibility(event);
+ // A dragenter immediately before a dragleave means the pointer moved to
+ // another element rather than leaving the window.
+ this.enteredAnotherElement = true;
+ };
+
+ onDragleave = () => {
+ if (!this.enteredAnotherElement && !this.uploadButton.disabled) {
+ this.hideDraggingState();
+ this.announce(this.i18n.t("leftDropZone"));
+ }
+
+ this.enteredAnotherElement = false;
+ };
+
+ onDrop = (event) => {
+ if (this.fileInput.disabled) return;
+
+ event.preventDefault();
+
+ if (event.dataTransfer && this.canFillInput(event.dataTransfer)) {
+ this.fileInput.files = event.dataTransfer.files;
+ this.fileInput.dispatchEvent(new CustomEvent("change"));
+ this.hideDraggingState();
+ }
+ };
+
+ updateDropzoneVisibility(event) {
+ if (this.uploadButton.disabled) return;
+ if (!(event.target instanceof Node)) return;
+
+ if (this.element.contains(event.target)) {
+ if (event.dataTransfer && this.canDrop(event.dataTransfer)) {
+ if (!this.isDragging) {
+ this.showDraggingState();
+ this.announce(this.i18n.t("enteredDropZone"));
+ }
+ }
+ } else if (this.isDragging) {
+ this.hideDraggingState();
+ this.announce(this.i18n.t("leftDropZone"));
+ }
+ }
+
+ showDraggingState() {
+ this.uploadButton.classList.add(
+ `${config.brand}-file-upload-button--dragging`,
+ );
+ }
+
+ hideDraggingState() {
+ this.uploadButton.classList.remove(
+ `${config.brand}-file-upload-button--dragging`,
+ );
+ }
+
+ announce(message) {
+ if (this.announcements) this.announcements.textContent = message;
+ }
+
+ get announcements() {
+ return this.element.querySelector(
+ `.${config.brand}-file-upload-announcements`,
+ );
+ }
+
+ // Whether a drop of this many files is allowed: any for a multiple input,
+ // exactly one otherwise.
+ matchesInputCapacity(numberOfFiles) {
+ if (this.fileInput.multiple) return numberOfFiles > 0;
+
+ return numberOfFiles === 1;
+ }
+
+ // During drag the files aren't readable, so count droppable items by kind.
+ canDrop(dataTransfer) {
+ if (dataTransfer.items.length) {
+ return this.matchesInputCapacity(countFileItems(dataTransfer.items));
+ }
+
+ if (dataTransfer.types.length) {
+ return dataTransfer.types.includes("Files");
+ }
+
+ return true;
+ }
+
+ canFillInput(dataTransfer) {
+ return this.matchesInputCapacity(dataTransfer.files.length);
+ }
+
+ onChange = () => {
+ const files = Array.from(this.fileInput.files);
+ const figures = Array.from(this.element.querySelectorAll("figure"));
+
+ // Re-selection replaces the FileList, so drop unclaimed previews whose
+ // file is no longer in the input. Claimed figures own their file (an
+ // upload is running or done) and are unaffected.
+ figures.forEach((figure) => {
+ if (figure.file && !files.includes(figure.file)) figure.remove();
+ });
+
+ // Render a figure per new file; each carries its File until a figure
+ // controller claims it for upload (govuk:upload). Unclaimed files —
+ // no endpoint, or no controller — stay in the input and submit as
+ // ordinary multipart.
+ files.forEach((file) => {
+ if (figures.some((figure) => figure.file === file)) return;
+
+ const attachment = createAttachment(this.fileInput, file, this.i18n);
+ this.uploadButton.insertAdjacentElement("beforebegin", attachment);
+ });
+
+ this.updateCount();
+ };
+
+ // A figure claimed its file for upload: release it from the FileList so
+ // the same bytes don't also submit as multipart.
+ onUpload = ({ detail: { file } }) => {
+ this.releaseFile(file);
+ this.updateCount();
+ };
+
+ releaseFile(file) {
+ const remaining = new DataTransfer();
+
+ for (const held of this.fileInput.files) {
+ if (held !== file) remaining.items.add(held);
+ }
+
+ this.fileInput.files = remaining.files;
+ }
+
+ onRemove = async (event) => {
+ // govuk:remove is dispatched before the figure is removed so listeners
+ // can cancel it; wait until the current task ends, when the removal
+ // (or the cancellation) is a fact.
+ await Promise.resolve();
+
+ if (event.defaultPrevented) return;
+
+ const { name, file } = event.detail;
+
+ // A removed figure that never claimed an upload still owns a file in
+ // the input; release it so it no longer submits.
+ if (file) this.releaseFile(file);
+
+ // The removal is an event, so it is announced through the assertive
+ // announcements region; the polite status region only ever carries
+ // state (the count), which updates in place.
+ this.announce(this.i18n.t("fileRemoved", { filename: name }));
+ this.updateCount();
+ };
+
+ updateCount() {
+ const count = this.fileCount;
+
+ if (count === 0) {
+ this.statusTag.innerText = this.i18n.t("noFileChosen");
+ this.uploadButton.classList.add(
+ `${config.brand}-file-upload-button--empty`,
+ );
+ } else {
+ this.statusTag.innerText = this.i18n.t("multipleFilesChosen", { count });
+ this.uploadButton.classList.remove(
+ `${config.brand}-file-upload-button--empty`,
+ );
+ }
+ }
+
+ get fileInput() {
+ return this.element.querySelector("input[type='file']");
+ }
+
+ get uploadButton() {
+ return this.element.querySelector(uploadButtonSelector);
+ }
+
+ get isDragging() {
+ return this.uploadButton.classList.contains(
+ `${config.brand}-file-upload-button--dragging`,
+ );
+ }
+
+ get statusTag() {
+ return this.element.querySelector("button [aria-live]");
+ }
+
+ get fileCount() {
+ let count = this.fileInput.files.length;
+
+ this.element
+ .querySelectorAll(`select[name='${this.fileInput.name}']`)
+ .forEach((select) => {
+ // A figure still holding its unclaimed File is backed by a FileList
+ // entry counted above.
+ if (!select.closest("figure")?.file) count += 1;
+ });
+
+ if (!this.fileInput.multiple) count = Math.min(count, 1);
+
+ return count;
+ }
+}
+
+// Counts DataTransferItems whose kind is "file" (ignoring dragged text etc.).
+function countFileItems(items) {
+ return Array.from(items).filter((item) => item.kind === "file").length;
+}
+
+// A visually-hidden assertive live region for event announcements (drag
+// enter/leave, removals), kept separate from the polite status region that
+// carries the file count.
+function createAnnouncements() {
+ const region = document.createElement("span");
+ region.className = `${config.brand}-file-upload-announcements ${config.brand}-visually-hidden`;
+ region.setAttribute("aria-live", "assertive");
+ return region;
+}
+
+function createUploadButton(id, i18n, fileInput) {
+ const brand = config.brand;
+ const template = document.createElement("TEMPLATE");
+ template.innerHTML = `
+
+ ${i18n.t("noFileChosen")}
+ ,
+
+ ${i18n.t("chooseFilesButton")}
+ ${i18n.t("dropInstruction")}
+
+
+ `;
+ const button = template.content.firstElementChild;
+
+ // Carry the input's hint/error descriptions onto the button, so the control
+ // the user actually operates is described the same way the input was.
+ const describedBy = fileInput.getAttribute("aria-describedby");
+ if (describedBy) button.setAttribute("aria-describedby", describedBy);
+
+ return button;
+}
diff --git a/app/javascript/katalyst/govuk/controllers/index.js b/app/javascript/katalyst/govuk/controllers/index.js
index 97dd183..a422546 100644
--- a/app/javascript/katalyst/govuk/controllers/index.js
+++ b/app/javascript/katalyst/govuk/controllers/index.js
@@ -1,11 +1,21 @@
+import AttachmentController from "./attachment_controller";
import DocumentFieldController from "./document_field_controller";
+import FileUploadController from "./file_upload_controller";
import ImageFieldController from "./image_field_controller";
const Definitions = [
+ {
+ identifier: "govuk-attachment",
+ controllerConstructor: AttachmentController,
+ },
{
identifier: "govuk-document-field",
controllerConstructor: DocumentFieldController,
},
+ {
+ identifier: "govuk-file-upload",
+ controllerConstructor: FileUploadController,
+ },
{
identifier: "govuk-image-field",
controllerConstructor: ImageFieldController,
diff --git a/app/javascript/katalyst/govuk/formbuilder.js b/app/javascript/katalyst/govuk/formbuilder.js
index ebbc06d..889a6b2 100644
--- a/app/javascript/katalyst/govuk/formbuilder.js
+++ b/app/javascript/katalyst/govuk/formbuilder.js
@@ -8,31 +8,42 @@ import {
Radios,
} from "govuk-frontend/dist/govuk/all.mjs";
import { SupportError } from "govuk-frontend/dist/govuk/errors/index.mjs";
-import { isSupported } from "govuk-frontend/dist/govuk/common/index.mjs";
+import {
+ isInitialised,
+ isSupported,
+} from "govuk-frontend/dist/govuk/common/index.mjs";
+
+// Component options captured from the initAll call, reused by every
+// observer-driven sweep.
+let options = {};
-function initAll(config) {
- let _config$scope;
- config = typeof config !== "undefined" ? config : {};
+function enhance($scope = document) {
if (!isSupported()) {
console.log(new SupportError());
return;
}
const components = [
- [Button, config.button],
- [CharacterCount, config.characterCount],
+ [Button, options.button],
+ [CharacterCount, options.characterCount],
[Checkboxes],
- [ErrorSummary, config.errorSummary],
- [FileUpload, config.fileUpload],
+ [ErrorSummary, options.errorSummary],
+ [FileUpload, options.fileUpload],
[Radios],
- [PasswordInput, config.passwordInput],
+ [PasswordInput, options.passwordInput],
];
- const $scope =
- (_config$scope = config.scope) != null ? _config$scope : document;
components.forEach(([Component, config]) => {
- const $elements = $scope.querySelectorAll(
- `[data-module="${Component.moduleName}"]`,
- );
+ const selector = `[data-module="${Component.moduleName}"]`;
+ // The scope itself can be a component root (an observed insertion is
+ // often the component element, not a container around one).
+ const $elements = [
+ ...($scope instanceof Element && $scope.matches(selector)
+ ? [$scope]
+ : []),
+ ...$scope.querySelectorAll(selector),
+ ];
$elements.forEach(($element) => {
+ if (isInitialised($element, Component.moduleName)) return;
+
try {
"defaults" in Component
? new Component($element, config)
@@ -44,11 +55,146 @@ function initAll(config) {
});
}
+// The support markers are a JS-capability probe (govuk-frontend's own
+// pattern): a browser that can run this bundle marks so component
+// initialisation and `govuk-frontend-supported`-gated CSS switch on. The
+// server never renders the markers.
+function markSupport(body) {
+ body.classList.toggle("js-enabled", true);
+ body.classList.toggle(
+ "govuk-frontend-supported",
+ "noModule" in HTMLScriptElement.prototype,
+ );
+}
+
+function supportMarked(body) {
+ const supported = "noModule" in HTMLScriptElement.prototype;
+
+ return (
+ body.classList.contains("js-enabled") &&
+ body.classList.contains("govuk-frontend-supported") === supported
+ );
+}
+
+function observe(body) {
+ // The body markers are JS-set, so a morph — which reconciles the live DOM
+ // against server HTML, with no lifecycle events — strips them, along with
+ // every component's data-*-init flag. Missing markers signal the morph:
+ // re-mark, then sweep; the isInitialised guard re-enhances only roots
+ // whose flags were stripped. Re-marking is check-then-set, so observing
+ // our own write terminates in one bounce.
+ //
+ // Registered before the arrival observer: callbacks run in creation
+ // order, so markers are back before an arrival sweep consults
+ // isSupported().
+ new MutationObserver(() => {
+ if (supportMarked(body)) return;
+
+ markSupport(body);
+ enhance();
+ }).observe(body, { attributes: true, attributeFilter: ["class"] });
+
+ // Components can also arrive after load — lazily-loaded turbo frames,
+ // stream inserts, any dynamic DOM — with no event in common. Enhance
+ // added subtrees as they land; the per-component guard makes overlapping
+ // sweeps harmless.
+ new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (node instanceof Element) enhance(node);
+ }
+ }
+ }).observe(body, { childList: true, subtree: true });
+}
+
+function setup(body) {
+ if (!body || body.__govukFormbuilderInit) return;
+ body.__govukFormbuilderInit = true;
+
+ markSupport(body);
+ enhance();
+ observe(body);
+}
+
+// The gem's Stimulus controllers register exactly once, on whichever
+// application claims them first: the consumer's (via start) or a gem-owned
+// application created on demand (the snippet path, for apps not otherwise
+// running Stimulus). Stimulus itself observes the whole document, so
+// registration — unlike the body-scoped setup — survives Turbo visits.
+let registered = false;
+
+function register(application = undefined) {
+ if (registered) return;
+ registered = true;
+
+ (application ?? Application.start()).load(controllers);
+}
+
+function applyConfig(config) {
+ if (config.brand) brandConfig.brand = config.brand;
+ options = config;
+}
+
+/**
+ * Enhance the current : mark it as JS-capable, enhance its GOV.UK
+ * components, and observe it for arrivals and morphs. Scoped to the body it
+ * ran against — it does not survive a body replacement, so render it with
+ * every page (the govuk_formbuilder_init snippet at the end of ).
+ * Registers the gem's Stimulus controllers on a gem-owned application
+ * unless start() has already claimed them.
+ *
+ * @param {object} [config] per-component config (button, characterCount,
+ * errorSummary, fileUpload, passwordInput)
+ * @param {string} [config.brand] CSS class prefix for injected UI (default "govuk")
+ */
+function initAll(config = {}) {
+ applyConfig(config);
+ register();
+ setup(document.body);
+}
+
+let watching = false;
+
+/**
+ * Wire the gem into your Stimulus application and keep the page enhanced
+ * for the life of the session, including across Turbo visits — call once
+ * from your own bundle:
+ *
+ * import GOVUK from "@katalyst/govuk-formbuilder";
+ * GOVUK.start(application);
+ *
+ * @param {object} [application] Stimulus application to register the gem's
+ * controllers on (a gem-owned application is created when omitted)
+ * @param {object} [config] as initAll's config
+ */
+function start(application = undefined, config = {}) {
+ applyConfig(config);
+ register(application);
+
+ if (!watching) {
+ watching = true;
+
+ // The body-scoped setup dies with each Turbo visit; the documentElement
+ // survives them, so watch it and set up against every new body. Also
+ // covers a start() before exists — the body's insertion is
+ // itself a childList mutation here.
+ new MutationObserver(() => setup(document.body)).observe(
+ document.documentElement,
+ { childList: true },
+ );
+ }
+
+ setup(document.body);
+}
+
// stimulus controllers
+import { Application } from "@hotwired/stimulus";
import controllers from "./controllers";
+import brandConfig from "./config";
+
+export default { start };
export {
- controllers as default,
initAll,
Button,
CharacterCount,
diff --git a/config/locales/en.yml b/config/locales/en.yml
new file mode 100644
index 0000000..200d1c5
--- /dev/null
+++ b/config/locales/en.yml
@@ -0,0 +1,16 @@
+# The attachment field's string defaults — the canonical vocabulary.
+# The JS bundle carries a mirror of this table as its offline defaults
+# (app/javascript/katalyst/govuk/config.js); keep the two in step.
+# Strings that differ from these defaults — a translation, or a host
+# app's override in any locale, en included — render onto the field's
+# data-i18n.* attributes, where the JS enhancement reads them.
+en:
+ katalyst:
+ govuk:
+ attachment:
+ upload_succeeded: "Uploaded successfully"
+ upload_failed: "Upload failed — try again"
+ retry_button: "Try again"
+ file_removed: "%{filename} removed"
+ remove_button: "Remove %{filename}"
+ remove_button_content: "Remove"
diff --git a/doc/attachment-field-spec.md b/doc/attachment-field-spec.md
new file mode 100644
index 0000000..a1f412e
--- /dev/null
+++ b/doc/attachment-field-spec.md
@@ -0,0 +1,650 @@
+# Attachment field — specification
+
+`govuk_attachment_field` (and its `govuk_image_field` / `govuk_document_field`
+wrappers) renders an ActiveStorage-backed file upload with previews, async
+direct upload, and a full no-JavaScript fallback.
+
+## Design
+
+1. **The `` is the source of truth.** Each attached blob renders a
+ per-blob `` with a *keep* option (value = blob signed id, label =
+ filename, selected) and a *remove* option (blank value). The form always
+ round-trips attachments as signed ids — never re-uploaded bytes — and the
+ same control makes attachments editable without JavaScript: there is no
+ `_destroy` mechanism, and no hidden input ever carries a signed id (the
+ only hidden input is §5's blank keeper).
+2. **We own the drop zone.** The field replaces govuk-frontend's FileUpload
+ JS behaviourally (reusing its markup conventions and i18n strings) rather
+ than running it: that component derives all of its state — status text,
+ empty styling, drop capacity — from the input's FileList, which this
+ field clears after dispatching uploads, and it initialises once with no
+ teardown, which doesn't compose with the Stimulus/Turbo lifecycle.
+ Parity with `file-upload.mjs` is pinned by the drop-zone parity system
+ spec, which runs govuk-frontend's own enhancement as the live reference
+ and diffs the two drop zones in canonical form (intended differences
+ scrubbed explicitly); purely behavioural differences remain a review
+ concern on upstream bumps. Accepted differences: plain errors rather
+ than `ElementError`; count strings even for one file (C10 records why);
+ our announcements region renders inside the wrapper — scoped finds and
+ morph re-enhancement keep it — where govuk-frontend's sits after the
+ drop zone; and our drop listeners bind to the wrapper where upstream's
+ bind to its button — equivalent zones, since upstream's button is its
+ whole drop zone while ours shares the wrapper with figures.
+ govuk-frontend's own component stays supported for plain file fields
+ (`initAll` initialises `data-module="govuk-file-upload"`), so the
+ **exclusivity invariant** is critical: every file input is enhanced by
+ exactly one implementation — attachment fields render the Stimulus
+ `data-controller` and must never emit `data-module="govuk-file-upload"`;
+ plain govuk file fields the reverse.
+3. **Uploads go through ActiveStorage's `DirectUploadController`** (public
+ export of `activestorage.esm.js`), subclassed so success writes the signed
+ id into the figure's select option. This inherits the
+ `direct-upload:start/progress/end` event lifecycle and error dispatch,
+ which drive the figure's progress display and error states.
+ `data-direct-upload-url` lives on the file input, where the controller
+ reads it.
+4. **Enhancement fails open.** The server-rendered form is complete and
+ functional on its own; JavaScript only takes ownership of a selection
+ when a figure's controller *claims* it. Selection renders one figure per
+ file, each carrying its `File` object; when the figure's controller
+ connects — and only when it can deliver an upload
+ (`data-direct-upload-url` present on the input) — it claims the file:
+ it announces `govuk:upload` on the input and starts the upload, and the
+ file-upload controller releases the claimed file (matched by object
+ identity — names can collide) from the FileList. Claiming is what
+ prevents ActiveStorage's submit-time auto-upload from sending the same
+ bytes again. Unclaimed files stay in the input and submit as ordinary
+ multipart, so the fallback holds by construction: a controller that is
+ absent, broken, or never connects claims nothing. Editing the FileList
+ programmatically (`DataTransfer` reassignment) fires no `change` event,
+ so releasing a file cannot re-trigger selection handling.
+5. **Param shape.** `name[]` for `has_many_attached` (one entry per select),
+ scalar `name` for `has_one_attached` — in both cases led by a blank
+ keeper the field renders (hidden input, same name, blank value, no id),
+ suppressing Rails' auto-blank for `file_field multiple: true`
+ (`include_hidden: false`) so one input the field owns plays that role
+ everywhere: the attribute always submits, even when a
+ removed figure has taken its select away. Scalar last-wins means any
+ select or multipart part overrides the keeper. Assignment is
+ replace-on-assign: the
+ submitted set *is* the resulting set, a lone blank clears, and one array
+ freely mixes blanks, signed ids, and multipart uploads.
+6. **The field makes pending blobs renderable.** A failed save re-renders
+ with attachment changes still pending, so generating the field persists
+ any unpersisted blob (record + bytes): its figure renders and its signed
+ id round-trips exactly like a direct upload — a failed submit never
+ loses an upload. Persistence is idempotent (already-persisted blobs are
+ never re-persisted, so controller-level persistence composes and leaves
+ the field nothing to do), and rendering never touches a blob's bytes:
+ preview URLs are lazy, so the variant is processed when the browser
+ requests the image, and a blob whose bytes are missing or unprocessable
+ (out-of-band purge, storage loss, mirror lag, bad content) costs a
+ broken image in that figure — never a failed render, keep option intact.
+ Validating attachment content is the model's responsibility, not the
+ form's.
+ Persisted-unattached blobs are the same GC category direct upload
+ already creates; purging them stays the consumer's existing
+ responsibility.
+7. **JS-injected UI is JS-only.** The pseudo button, its status region, and
+ the announcements region are injected by the controller and never
+ server-rendered: without JavaScript the browser-native file input is the
+ whole experience. A Turbo morph refresh therefore strips the injected UI
+ and any other JS-set state (including the page's support marker), and it
+ may either recreate the drop zone (lifecycle events fire) or patch it in
+ place (no events at all) — so recovery is driven by observing the DOM,
+ not framework events: losing a marker or the injected UI *is* the morph
+ signal. After any morph, both levels recover: the support marker is
+ restored (before paint) and the field re-enhances — injected UI rebuilt,
+ transient client state discarded (FileList cleared, unclaimed previews
+ dropped), count matching the server-rendered figures (C11).
+
+Form group structure (top to bottom):
+
+```
+form group (data-controller for the drop zone)
+ label
+ hint
+ error message(s)
+ before_input content
+ attachment figure(s), one per blob (0..1 for has_one, 0..n for has_many)
+ pseudo upload button + drop region ("Choose files" / drop instruction)
+ native file input (hidden when JS is active; data-direct-upload-url)
+ after_input content
+ supplemental content (block content, e.g. alt text / caption fields)
+```
+
+- Without JS the native file input is a plain visible input; with JS it is
+ hidden and fronted by the pseudo button, which triggers the browser file
+ picker. The drop target is the whole drop zone — the wrapper element,
+ figures included — not just the pseudo button (a file dragged anywhere
+ over the field can be dropped), and not the form group around it: a
+ valid drag over the wrapper adds `--dragging` to the button and is
+ announced through a JS-injected visually-hidden assertive region
+ (`.govuk-file-upload-announcements`), and a drop fills the input exactly
+ as choosing files does. A drop is accepted only within the input's
+ capacity (one file unless `multiple`). The announcements region carries
+ every *event* announcement — drag enter/leave and removals — while the
+ button's polite status region carries only *state* (the file count). The
+ button is wired to the field's existing accessibility affordances: it takes
+ the input's original id (so the label's `for` labels it), the label is given
+ a matching id for the button's `aria-labelledby`, the input's
+ `aria-describedby` (hint/error) is copied onto it, and its `disabled` state
+ is mirrored from the input (F4, F5, C12).
+- Each attachment figure: preview (`img`, when representable), `figcaption`
+ (filename, human size, and an empty status span), then an actions
+ container (`div.actions`) holding the keep/remove `` and a remove
+ `` (`type="button"`, visible text "Remove" by default — never a
+ bare glyph — accessible name "Remove "); upload state via
+ `data-state`. On upload failure a retry `` (`type="button"`,
+ "Try again", accessible name naming the file) is injected at the head of
+ the actions container and removed again when an attempt starts; it is
+ never server-rendered — failed figures only exist client-side (C3).
+ Figure buttons carry govuk-frontend's button markup — `govuk-button
+ govuk-button--secondary` plus a component class
+ (`govuk-attachment__remove` / `__retry`) and a fixed
+ `data-module="govuk-button"`, mirroring the password-input toggle —
+ leaning on the gem's styling rather than custom button treatments. Markup ships both controls unhidden; CSS scoped to
+ `.govuk-frontend-supported` (set on `` by the
+ `govuk_formbuilder_init` body-end snippet only when the browser can run
+ the bundle, and maintained across morphs by its observers) swaps them:
+ without
+ JavaScript the select is the visible control and the button is hidden,
+ with JavaScript the button is the figure's only interactive control and
+ the select is `display: none` — it still submits its value but is out of
+ the tab order and the accessibility tree, so each figure presents exactly
+ one control. The figure, its select, and the upload progress bar are
+ labelled by the caption's filename span (`aria-labelledby` to its id):
+ accessible names carry stable identity — the filename alone, matching
+ govuk-frontend's own single-file convention, with no punctuation needs —
+ never size or upload status. Status is state: it enters no name and no
+ `aria-describedby` (focus re-reads descriptions, which would reinstate
+ the residue). The caption is a polite atomic live region: JS writes
+ upload status into the status span, and the atomic announcement reads
+ the whole caption so the user hears which file the status belongs to.
+ The status is not cleaned up afterwards, deliberately: it stays visible
+ — useful context until the form is submitted — without entering any
+ accessible name; the announcements region likewise keeps its last event,
+ which live-region semantics never re-announce but the reading cursor can
+ still reach — a user who missed the message can go back and re-read it.
+- Client-inserted figures and server-rendered figures are structurally
+ identical, so re-renders and JS insertions are interchangeable. The JS
+ template (`createAttachment`) and the server trait are updated together
+ rather than generated from a shared template, and the markup-parity
+ system spec diffs the two figures' canonical forms so drift fails a test
+ (C6).
+
+### Builder syntax (reference shape)
+
+```ruby
+def govuk_image_field(attribute_name,
+ label: {}, hint: {}, form_group: {},
+ mime_types: config.image_mime_types,
+ direct_upload: true, # false renders no direct-upload-url
+ direct_upload_url: ..., # explicit endpoint override
+ before_input: nil, after_input: nil,
+ &supplemental_content)
+```
+
+`direct_upload_url` defaults through a builder method of the same name
+(`rails_direct_uploads_url`, falling back to `main_app`, omitted when no route
+is available), so engine builders (e.g. Koi admin) override the method to
+point every field at their own endpoint. `direct_upload: false` opts a single
+field out of async upload without changing the attachment markup.
+Preview URLs resolve the same way: `attachment_preview_url` (a public builder
+method) resolves the representation route with the same `main_app` fallback
+and returns nil when no route is available — the figure then renders without
+a preview — so engine builders can override preview resolution too. The
+preview transformation is configurable
+(`config.attachment_preview_representation`); the default is a crisp
+square — `resize_to_fill: [256, 256]`, centre-cropped by vips, sized with
+leeway above the preview box for dense displays. Framing is CSS-only:
+previews render `object-fit: cover` in a square box, client-inserted
+previews get the same treatment so framing doesn't change when a figure
+round-trips, and a consumer stylesheet can reframe without touching the
+gem — noting the default variant is already square-cropped, so a consumer
+wanting whole-image (`contain`) previews overrides the representation
+too.
+
+`multiple` is inferred from the attribute's ActiveStorage reflection
+(`has_many_attached` → true), and an explicit `multiple:` argument is
+respected. The attribute's value must be an `ActiveStorage::Attached`;
+anything else raises `ArgumentError` at render — the field is built from
+blob signed ids end to end (see the non-ActiveStorage rabbit hole; plain
+uploads use `govuk_file_field`). `govuk_document_field` is the same shape
+with document mime types; both delegate to `govuk_attachment_field`.
+
+## Acceptance criteria
+
+Each criterion names the test type that verifies it.
+
+### A. Server-rendered markup (builder specs)
+
+- **A1** An attached blob renders a `figure.govuk-attachment` containing, in
+ order: preview (`img` for representable blobs, omitted otherwise), a
+ `figcaption` with filename and human file size, and the keep/remove select.
+- **A2** The select's first option is the current file (label = filename,
+ value = signed id, selected); the second option removes it (blank value,
+ label includes the filename, e.g. "Remove avatar.png").
+- **A3** Select `name` is `object[attr][]` when the attribute is
+ `has_many_attached`, `object[attr]` when `has_one_attached`; ids are unique
+ per blob (`field_id(attr, :attachment, blob.id, :input)` convention).
+- **A4** With no attachments, no `figure.govuk-attachment` renders.
+- **A5** The file input carries `accept` from `mime_types`; `multiple` when
+ explicitly passed, else inferred from the attribute (`has_many_attached` →
+ true); and `data-direct-upload-url` resolved from
+ `rails_direct_uploads_url` (with `main_app` fallback; attribute omitted when
+ the route is unavailable or `direct_upload: false` is passed — neither
+ degrades the attachment markup).
+- **A6** Preview `img` has `alt=""`; the select has an accessible name that
+ includes the filename. Accessible names are built from the filename span
+ alone — identity, not state: the size and status spans stay visible (and
+ inside the caption's atomic announcements) without entering any name.
+- **A7** Non-image blobs (e.g. PDF) render figure, caption and select without
+ an `img` and without error — client-inserted figures for non-image files
+ likewise carry no preview `img`. The reserved preview space stays empty,
+ by decision: no placeholder, and a broken image from a failed lazy URL
+ is left as-is (§6's degradation).
+- **A8** The field renders exactly one blank hidden input (the keeper),
+ before any figure's select — scalar and `has_many` alike (Rails'
+ auto-blank is suppressed, so there is never a second blank).
+ Keeper-before-selects order is load-bearing for scalars: last-wins means
+ a trailing blank would detach every kept file.
+
+### B. No-JS editing (request specs)
+
+- **B1** Add one file via multipart file input: none → 1.
+- **B2** Add multiple files via multipart input: none → N.
+- **B3** Keep: submitting the existing signed id retains the attachment (1 → 1).
+- **B4** Remove: submitting blank for that blob detaches it (1 → none); the
+ lone keeper blank clears an emptied `has_many`.
+- **B5** Swap: submitting a new signed id without the old one replaces (1 → 1).
+- **B6** Mixed arrays (blank + signed ids + multipart files) attach correctly.
+
+### C. JS-enhanced upload (system specs)
+
+- **C1** Choosing/dropping a file immediately inserts a preview figure inside
+ the field showing filename (and image thumbnail when applicable) in an
+ *uploading* state (`data-state="uploading"` on the figure).
+- **C2** On direct-upload success the figure reaches
+ `data-state="upload-successful"` and its select's first option value equals
+ the new blob's signed id.
+- **C3** On direct-upload failure the figure reaches `data-state="upload-failed"`,
+ shows a human-readable message (not raw DirectUpload text), and offers
+ removal and retry. Retry re-attempts the direct upload with the file the
+ figure holds (failed figures are always client-inserted — a failed upload
+ never gains a signed id, so the server never renders one — and client
+ figures carry their `File`), re-entering the standard uploading lifecycle.
+ A failed figure never submits a signed id. The figure's message
+ is the failure UI: `direct-upload:error` is dispatched without ActiveStorage's
+ `window.alert(error)` fallback (the raw text as a native dialog). The event
+ is notification only — consumer listeners may observe it, but cancelling it
+ does not alter the failure handling; nothing may convert a failed upload
+ into an `upload-successful` figure, whose blank select would silently drop
+ the file. Activating retry removes the retry control (re-entry to
+ `uploading`), so focus must move deliberately — to a control of the same
+ figure (the remove button is the only one while uploading) — never
+ dropped to the bare page.
+- **C4** The FileList is empty after uploads are dispatched (no double
+ attach when the form is submitted).
+- **C5** For `has_one_attached`, uploading a replacement supersedes the
+ existing figure: the new figure appends after the old one, both post the
+ same scalar param and the last select wins, and CSS (scoped to
+ non-`multiple` drop zones) shows only the last figure. The superseded
+ figure stays in the DOM, so removing the replacement reveals it again —
+ replace is freely revertable before submit. Submitting before the
+ replacement's upload completes (or after it fails) follows the same rule
+ — the submitted set is the truth: the replacement has no signed id yet,
+ so its blank wins and an optional attachment detaches, while a required
+ one fails its presence validation and re-renders with an error, stored
+ file untouched. Presence validation is the guard for attachments that
+ must not be lost.
+- **C6** Client-inserted markup and server-rendered markup for the same blob
+ are structurally identical (same figure/caption/select contract). Pinned
+ by the markup-parity system spec: both figures are captured from one live
+ page and compared in canonical form — ids tokenised in encounter order
+ (the wiring is compared, the values are not), signed ids and URLs
+ tokenised, upload lifecycle scrubbed — so drift fails with a line diff.
+ The canary is a representable (image) scalar figure; changes to shapes it
+ doesn't visit (e.g. non-image figures) must still update the JS template
+ and server trait together.
+- **C7** When no controller claims a selection (JS absent, failed, or never
+ connected), the FileList is left intact and the files submit as ordinary
+ multipart (the B path) — enhancement never intercepts what it cannot
+ deliver.
+- **C8** While uploading, the figure shows a progress bar driven by the
+ `direct-upload:progress` events, exposed accessibly (`role="progressbar"`
+ with current value; completion announced per F1).
+- **C9** When the file input has no `data-direct-upload-url`, the enhanced
+ field still operates but starts no uploads and never claims the files —
+ they stay in the FileList and submit as ordinary multipart. Preview
+ figures still render (blank select values, so nothing double-submits),
+ and removing such a figure also releases its file from the FileList.
+ Because browsers replace the FileList on re-selection, unclaimed previews
+ whose file has left the input are dropped when the selection changes —
+ a preview never suggests a file that won't submit.
+- **C10** The upload button's status region describes the field's contents
+ using govuk-frontend's FileUpload i18n strings: `noFileChosen` ("No file
+ chosen") when empty, else `multipleFilesChosen` with the count of
+ attachment figures plus any files held in the FileList ("2 files chosen").
+ Count strings are used even for a single file ("1 file chosen") — a
+ deliberate divergence from govuk-frontend, which shows the filename for
+ one file: their status is the only description of the selection, whereas
+ here each attachment figure already names its file, so the status repeats
+ no filenames. The count updates in place as figures are added (server
+ render, upload) and removed; the region carries only this state — event
+ announcements such as removals go to the assertive announcements region
+ (D4).
+- **C11** After a Turbo morph refresh the field still works: the injected
+ UI (button, status, announcements region) is rebuilt, transient client
+ state is discarded — the FileList cleared, unclaimed previews dropped —
+ the count matches the server-rendered figures, and subsequent selections,
+ uploads, and removals behave as on first load.
+- **C12** The injected button mirrors the file input's `disabled` state: on
+ enhancement it is disabled iff the input is, and the drop zone carries
+ `govuk-file-upload-wrapper--disabled` (reusing govuk-frontend's disabled
+ styling). A `MutationObserver` on the input keeps the button and wrapper in
+ step when the input's `disabled` attribute changes at runtime.
+
+### D. Removal (system specs)
+
+- **D1** Each figure offers a remove control whose accessible name includes
+ the filename.
+- **D2** Removing (JS) removes the figure from the DOM so its value no longer
+ submits; the keeper still does, clearing an emptied `has_many` and
+ detaching an emptied `has_one` — with or without JS, removal ends in the
+ same blank submission (B4).
+- **D3** After removal, focus moves to the field's upload button — one
+ deliberate destination for every removal, whose focus reading is the
+ post-removal summary (its status carries the fresh count) — never lost to
+ ``. (Stepping back into the gallery between bulk removals is
+ accepted friction; multi-file fields are rare.)
+- **D4** Removal is announced to assistive technology, naming the file,
+ through the field's assertive announcements region.
+
+### E. Round-trip (system + request specs)
+
+- **E1** Submitting an invalid form re-renders every attachment — persisted,
+ direct-uploaded (signed id not yet attached), or a pending multipart
+ upload (persisted by the field at render, Design §6) — as server-rendered
+ figures whose signed ids round-trip; a failed submit never loses an
+ upload.
+- **E2** Server-side validation errors render above the input in standard
+ GOV.UK error style and appear in the error summary.
+
+### F. Accessibility (system specs / manual audit)
+
+System specs pin announced *content* (region text); what a screen reader
+actually voices is verified manually — no scripting surface can observe
+it (the AppleScript `last phrase` slot drops transient announcements, and
+VoiceOver's caption panel, the only faithful source, is unreachable).
+`script/voiceover/` (throwaway tooling, outside the gemspec) OCRs the
+caption panel speech-timed for coarse capture.
+
+- **F1** Upload progress and completion are announced via an aria-live region.
+- **F2** All controls are keyboard operable; buttons are `type="button"`.
+- **F3** Decorative images/icons are hidden from the accessibility tree.
+- **F4** The injected button's accessible name resolves: the field label
+ (rendered by the form group, outside the drop zone) is given an id and the
+ button's `aria-labelledby` references it — along with the button's own
+ content — so the label names the button, not a dangling id. The button
+ takes the input's original id, so the label's `for` labels it too.
+- **F5** The input's `aria-describedby` (its hint and error ids) is copied
+ onto the injected button, so the descriptions that applied to the input
+ reach the control the user actually operates.
+- **F6** Removing a figure announces the removal once, and the focus
+ destination is read once with post-removal state — no stale count, no
+ repeated reads.
+- **F7** Selection and upload outcomes are audible without navigating:
+ choosing files announces the updated count on dialog close (a focus
+ re-read, "dialog closed", then the count again — matching upstream
+ FileUpload's behaviour); a drop announces the count once; an upload's
+ outcome announces from the figure's caption, naming the file
+ (" … Uploaded successfully"). Selection deliberately announces
+ the count rather than naming each file — the figures name the files.
+
+### G. Internationalisation
+
+- **G1** All user-facing strings (choose files, drop instruction, remove
+ option/button labels, upload states, announcements) come from the existing
+ i18n options/data mechanism — none hardcoded in JS templates.
+
+### H. Integration & packaging
+
+- **H1** The compiled build treats `@rails/activestorage` and
+ `@hotwired/stimulus` as external (rollup `external`), leaving the bare
+ import specifiers for the consumer environment to resolve: importmap apps
+ inherit the gem's engine pins automatically; bundler apps resolve them
+ from their own `node_modules`, guided by the README's
+ JavaScript-dependencies notes. `package.json` records the two as
+ `peerDependencies` mirroring the `external` list (an in-repo record — the
+ package itself is not distributed; the gem is the only artifact).
+- **H2** All rendered/injected filenames are escaped (no `innerHTML`
+ interpolation of user-controlled strings).
+- **H3** The attachment field is the default file field: `govuk_image_field`
+ / `govuk_document_field` render it, and no backwards-compatibility shims
+ are provided. Ships as a **major version release**; consumers of the
+ previous image/document fields adapt their code, guided by the README's
+ upgrade guidance and the release PR's notes (no CHANGELOG file is kept —
+ release notes are maintained on the PR). The legacy implementations
+ remain in the tree behind `config.use_legacy_file_fields` (default
+ `false`) through the release as a documented transitional escape hatch —
+ the flag flips `govuk_image_field` / `govuk_document_field` back to the
+ legacy elements. A subsequent release removes, together and without
+ shims: the legacy elements and controllers, the flag,
+ `legacy_file_fields_spec`, and the dummy form's `optional:` arguments
+ (consumed only by the legacy elements and their spec).
+
+## Implementation constraints
+
+Normative; violating any of these reintroduces a known failure mode.
+
+- The keeper is the only blank: pass `include_hidden: false` to suppress the
+ auto-blank Rails emits for `file_field multiple: true`, so exactly one
+ blank renders in every case — never two. Its position is load-bearing for
+ scalars (it renders before the selects, or last-wins would detach every
+ kept file); in the array only its presence matters.
+- `blob.signed_id` is the only round-trip token. Render per-blob inputs
+ through form-builder methods (`select`, `field_name(attr, multiple:)`,
+ `field_id`) — `ActionView::Helpers::Tags::*` are internal and not
+ constructible directly. Rails does not disambiguate ids for `[]`-named
+ inputs; the per-blob `field_id` suffix is ours to apply.
+- One single-`` per blob, never `select multiple:` (wrong UX and
+ wrong param semantics).
+- Attachment traits must branch on `Attached::One` vs `Attached::Many`
+ (`value.blob` does not exist on `Many`).
+- Resolve focus targets *before* `element.remove()` — `closest()` on a
+ detached node returns null.
+- Live-region writes follow the interaction's last focus move. A focus
+ change discards pending not-yet-spoken live-region writes; a write
+ co-arriving in the same task survives only as VoiceOver batching, not a
+ contract screen readers share. So order focus-first, write-after (as
+ removal does), or write from handlers that only run after focus settles
+ (change, drag enter/leave — the only places govuk-frontend's FileUpload
+ writes). Announcement-before-focus would need focus held back for the
+ announcement's speech duration — seconds of keystrokes landing on
+ `` — rejected.
+- Accessible names for repeated per-file controls include the filename;
+ prefer visible or visually-hidden text / `aria-labelledby` to the
+ filename span over duplicated `aria-label` strings. Thumbnails get `alt=""`; decorative
+ icons get `aria-hidden="true"` (never a nameless `role="img"`).
+- Filenames are user-controlled: assign via `textContent`/attributes, never
+ interpolate into `innerHTML`.
+- The selection hand-off event is cancelable; un-cancelled means unhandled —
+ leave the FileList intact so the files submit as multipart (Design §4).
+- Preview URLs are lazy — render `blob.representation(...)` without
+ `.processed`, resolved through `attachment_preview_url` (main_app
+ fallback; nil renders the figure without a preview), so form render never
+ downloads, transforms, or routes bytes eagerly.
+ Render-time processing turned missing bytes or a missing image library
+ into a failed render, and serialized variant generation for every figure
+ into the request. Missing bytes cost a broken image at request time
+ (Design §6). (Mid-upload submits never reach the render: assignment
+ identifies the blob by downloading a chunk, and our JS only writes a
+ signed id into the select after the byte upload completes.)
+- Persist pending blobs upload-first: a blob whose byte upload fails is
+ simply never saved, and the render's `persisted?` filter drops it — no
+ purge-on-error step (it would run exactly when the service is failing).
+ Stranded partial writes are prevented at the service layer (Disk deletes
+ its partial write before raising `IntegrityError`; S3 PUTs are atomic).
+ Log dropped uploads — ActiveStorage's own instrumentation logs success
+ messages even for failed uploads, so nothing else records the drop.
+- `app/javascript` is the source; `app/assets/builds` is what browsers run.
+ Rebuild before trusting a system test.
+- Re-enhancement is driven by MutationObservers, never Turbo events:
+ `turbo:load` doesn't fire on morph renders, `turbo:render` listeners are
+ registration-order dependent, and a recreated element receives lifecycle
+ events instead of morph events. Observers attach to the body/drop-zone
+ *element*, so a replace render disposes them with the node it replaces.
+ Two enhancement entry points, one registration: `start(application,
+ config)` — the primary application.js wiring, and the default export —
+ registers the gem's controllers on the given Stimulus application (a
+ gem-owned application when omitted; first registration wins) and keeps
+ enhancement session-durable by watching `documentElement`, surviving
+ body replacement. `initAll(config)` — rendered per body by
+ `govuk_formbuilder_init` — is body-scoped with no self-rearming, and
+ registers the controllers on the gem-owned application for apps not
+ running Stimulus. The two compose deterministically: head modules run
+ before the body-end snippet, and registration is first-wins. Legacy
+ `application.load(govuk)` throws at boot — loud, never a silent
+ double-registration. (The gem-owned application branch is verified
+ manually: under first-wins, a host application that registers first —
+ as the dummy's does — always claims registration, so no runtime test
+ reaches it.)
+- The string vocabulary lives twice by design: `config/locales/en.yml` is
+ canonical (Rails resolves the strings and renders values onto the
+ wrapper's `data-i18n.*` attributes whenever they differ from the gem's
+ bundled defaults), and the JS bundle mirrors the defaults for enhancement
+ without attributes — keep the two tables in step. The difference check
+ reads the bundled table straight from the gem's own locale file, never
+ through I18n resolution: resolution absorbs a host app's overrides, and
+ an override in any locale — en included — must reach the attributes or
+ server-rendered and JS-created figures diverge.
+ Inherited keys keep govuk-frontend's names: the `data-i18n.*`
+ attribute shapes are the compatibility bar. Nothing diffs the two
+ default tables — a drifted default only shows on a field rendered with
+ default strings and no attributes — so review both on any vocabulary
+ change.
+- Brand follows CSS classes only; behavioural wiring is fixed. Stimulus
+ identifiers, `data-controller`/`data-action` values, and events are
+ always `govuk-*`, while the classes Ruby renders and JS injects — and the
+ class-based selectors JS queries — follow the configured brand. Brand
+ crosses to JS via the entry points' config (`start(application,
+ { brand })` / `initAll({ brand })`; the snippet omits the argument at
+ the default brand) and defaults to `govuk`. The gem's compiled CSS stays
+ govuk-prefixed: a consumer exercising brand brings their own frontend
+ CSS (upstream govuk-frontend JS likewise only matches fixed module
+ names, so brand ≠ govuk already presumes a forked frontend).
+- Enhancement sweeps overlap by design (marker-restore and arrival sweeps
+ visit the same roots in one morph batch) — check `isInitialised` before
+ constructing a component; construct-and-catch logs an error per component
+ per morph. Re-constructing on morph-retained nodes can duplicate
+ listeners; govuk components mostly bind injected elements, so this is
+ tolerable — re-check whenever a component joins the sweep. The arrival
+ observer runs a full sweep per added element: cheap at the current
+ component count, worth revisiting if the list grows.
+- Interactive controls never live inside a live region: the caption is
+ atomic, so a control there is re-read with every status update and can
+ lose focus when the region re-renders. The retry control sits in
+ `div.actions`; the failure status text ("Upload failed — try again") is
+ what tells a screen-reader user the affordance exists.
+- Stimulus connects controllers asynchronously after DOM insertion — events
+ dispatched at just-inserted figures must account for this (no bare
+ synchronous dispatch).
+
+## Non-goals
+
+- **Client-side upload errors never register in the GOV.UK error summary.**
+ The summary is the server-side validation channel (E2); upload failures are
+ widget-local by design (C3). This is explicitly undesirable, not deferred.
+- **Filename normalisation.** The caption names the file exactly as stored
+ — the same name downloads carry — so percent-encoded or otherwise noisy
+ stored filenames render (and announce) verbatim. A `%20` in a stored
+ name is indistinguishable from a legitimate literal, and browsers and
+ direct upload never produce encoded names — they enter via consumer
+ ingestion (e.g. URL-derived filenames attached from `io:`). Fix at
+ ingestion or by data migration in the consuming app; the field renders
+ the truth.
+
+## Rabbit holes
+
+Known traps we are deliberately not entering; each entry names the supported
+alternative. Where the alternative is an event we already emit, we do not
+build on top of it.
+
+- **Inferring `optional` from model validations.** Deriving the field's
+ optionality (e.g. GOV.UK's "(optional)" label convention, remove
+ affordances) from `validators_on(:attr)` looks like the `multiple`
+ inference — view behaviour from model metadata — but validation reflection
+ is unreliable where reflection isn't: presence validators can be
+ conditional (`if:`/`unless:`, `on:` contexts) or live in attachment
+ validation gems the reflection can't see, and a wrongly-inferred
+ "(optional)" label is a content failure, not a cosmetic one. Revisit when
+ `optional` drives real behaviour again; until then the supported path is
+ an explicit argument from the caller, who knows the form's context.
+- **Client-side file validation (mime type, size, dimensions).** The
+ `accept` attribute filters the picker as a courtesy and no more: drag/drop
+ bypasses it, browsers derive a file's type from its extension, and only
+ the server can verify content. Client-side enforcement with error
+ messaging would be built on a guess and wrongly reject valid files — the
+ server's validation is the authority (E2), and a round-trip that re-renders
+ every kept file (E1) is the correction loop. Consumers who want their own
+ checks can cancel the selection hand-off event (Design §4) and leave the
+ file unclaimed.
+- **Submit protection for in-flight uploads.** Submitting mid-upload is
+ allowed: the file simply isn't stored, and if that matters the server's
+ validation reports it (E2) — the visible `uploading` state and status
+ announcements give the user enough feedback to understand what happened.
+ This includes a has_one replacement, where the incomplete figure's blank
+ supersedes the stored attachment (C5): required fields are protected by
+ their presence validation; optional fields detach.
+ Blocking or deferring submission drags in disabled-submit state management,
+ re-enable-on-error paths, and double-submit interactions; consumers whose
+ use case demands it can build it from the `direct-upload:start/end` events
+ on the file input.
+- **Non-ActiveStorage attachment values.** Rendering the field for e.g. an
+ `attr_accessor`-backed form object looks like loose coupling, but
+ everything load-bearing is built from blob signed ids: direct upload
+ writes one into the select, error round-trips re-render from them (E1),
+ and figures need filename, size, content type, and a preview URL. A
+ non-AS value gets none of that — direct upload would assign signed-id
+ strings only meaningful if the form object consumes them, the keeper
+ would assign `""` when nothing is chosen, and a *present* value has no
+ figure or editing story without a duck-type interface plus a
+ `polymorphic_url`-style preview mechanism. Possible, but a project of its
+ own — out of scope. The element raises `ArgumentError` for non-`Attached`
+ values; plain multipart uploads without previews use the upstream
+ `govuk_file_field`.
+- **Sequential upload queueing.** Figures upload concurrently, each
+ starting on claim. ActiveStorage's own form-submit flow queues uploads
+ one at a time — attractive on a slow uplink, where N concurrent uploads
+ all finish late together while a queue completes the first file at ~1/N
+ of the total — but queueing drags in queue scope (per field or
+ page-wide), dequeue-and-abort teardown for removed figures, a queued
+ lifecycle state touching C1/C8's progress contract, and retry
+ re-enqueueing. Multiple-file interfaces are rare in practice, and an
+ early submit keeps completed files under either model (their signed ids
+ are already in their selects), so concurrency ships; revisit if a real
+ use case surfaces the slow-uplink problem.
+- **Renaming attachments.** `filename` is display metadata on the blob and
+ the field round-trips signed ids only, so no rename affordance exists.
+ In-place `blob.update(filename:)` mutates persisted, possibly shared
+ data outside the form's submit-time-truth model (and public-mode
+ services bake Content-Disposition into the object at upload, so it lies
+ there anyway). The viable shape — a copy-on-write rename that the
+ existing swap semantics absorb — is pitched in
+ `doc/pitches/filename-rename.md`; until pursued, renaming is out of
+ scope and rename-before-upload belongs to the client, where the name
+ rides the blob-creation request.
+- **Wrapping govuk-frontend components in Stimulus controllers.** Wrapping
+ looks like it would unify enhancement under Stimulus's observer, but
+ Stimulus never re-connects a morph-retained element and govuk components
+ have no teardown to re-enter through — morph recovery gets worse, not
+ better — while consumer-authored `data-module` markup silently stops
+ enhancing, diverging from govuk-frontend's documented conventions. The
+ supported path is the current `data-module` sweep with our own
+ observers; the full analysis is pitched in
+ `doc/pitches/components-as-controllers.md`.
+- **Reordering of multiple attachments.** `has_many_attached` order follows
+ the submitted param order, which invites drag-to-reorder UI, persistence
+ semantics, and a11y for reordering — none of it here. Figures render in
+ attachment order; that is the whole contract.
diff --git a/doc/pitches/components-as-controllers.md b/doc/pitches/components-as-controllers.md
new file mode 100644
index 0000000..b2d6ca3
--- /dev/null
+++ b/doc/pitches/components-as-controllers.md
@@ -0,0 +1,34 @@
+# Pitch: govuk-frontend components as Stimulus controllers
+
+Replace `data-module` sweeping entirely: the server emits
+`data-controller`, each govuk-frontend component wraps in a thin
+controller (`connect()` constructs it, config from the captured option bag
+or govuk's own data-attribute config), and Stimulus's observer owns
+arrival — including runtime attribute writes, which would gain a public
+contract `data-module` never had. Registration unifies under
+`application.load(govuk)` and composes with the gem-owned Stimulus
+application pitch. Not scheduled.
+
+## Why the core is small
+
+A base class plus seven registrations.
+
+## Why the edges are the real cost
+
+- **Identifier collision**: the attachment drop zone already owns the
+ `govuk-file-upload` identifier — upstream's FileUpload needs a rename,
+ and the exclusivity invariant re-encodes.
+- **Morphs get worse without work**: Stimulus never re-connects a
+ morph-retained element, so stripped injected UI stays broken unless the
+ base class self-heals à la `file_upload_controller`'s morphObserver.
+ govuk components have no teardown for `disconnect()` to call — the
+ original objection to running upstream JS, unresolved by wrapping.
+- **Listener leaks become explicit**: FileUpload's document-level drag
+ listeners leak on every re-construction in both designs, but a wrapper
+ makes the gap visible, and fixing it means forking components.
+- **Convention divergence**: consumer-authored `data-module` markup
+ silently stops enhancing — a departure from govuk-frontend's documented
+ conventions carried into every upstream bump review.
+- `initAll` shrinks but survives (markSupport + marker restore — Stimulus
+ knows nothing of the body markers). Parity spec, guide pages,
+ enhancement/morph specs, and the drop-zone design section all re-encode.
diff --git a/doc/pitches/filename-rename.md b/doc/pitches/filename-rename.md
new file mode 100644
index 0000000..6f027d1
--- /dev/null
+++ b/doc/pitches/filename-rename.md
@@ -0,0 +1,65 @@
+# Pitch: attachment filename rename (copy-on-write)
+
+Let a user rename an attachment from the field — fixing upload-time noise
+(camera names, percent-encoded filenames) without re-uploading the file.
+Not scheduled; the groundwork below is verified against activestorage
+8.1.3.
+
+## Verified groundwork
+
+- `filename` is pure blob metadata — the stored object lives at `blob.key`
+ (random secure token), so a rename never moves bytes and never
+ invalidates `signed_id` (encodes the blob id only) or variants (keyed by
+ `key`). Download names resolve at request time on the Disk and
+ private-S3 paths; **public-mode services bake Content-Disposition into
+ the object at upload**, so a plain rename leaves stale download names
+ there.
+- No client-writable rename surface exists: ActiveStorage's only writable
+ routes are `POST /direct_uploads` (create) and the Disk byte `PUT`. Any
+ JS rename needs an endpoint of ours or the consumer's.
+- In-place `blob.update(filename:)` was set aside as an option shape: it
+ mutates persisted (possibly shared — filename is per-blob, not
+ per-attachment) data outside the form's submit-time-truth model, on the
+ strength of a signed id that is not a secret in practice (every rendered
+ select carries one).
+
+## Proposed shape: copy-on-write
+
+`Blob.compose([blob], filename:)` with a single source is
+copy-with-rename — new record, new key, new signed id, bytes copied by the
+service (Disk file copy; S3 streams through the app, not a server-side
+COPY). `blob.open` + `Blob.create_and_upload!` is the alternative that
+re-runs checksum and Marcel identification (compose copies the source
+`content_type`, so extension edits want `create_and_upload!`). Never point
+a second blob record at an existing key: purging either blob deletes the
+shared bytes.
+
+A renamed copy is indistinguishable from a swap upload, so the field's
+existing contract absorbs it: JS writes the new signed id into the keep
+option (the same write direct-upload success does), submit is swap
+semantics with the old attachment's `purge_later` cleaning the old bytes,
+an abandoned form leaves only a persisted-unattached orphan (the GC
+category direct upload already creates), and an invalid submit round-trips
+the new name. The endpoint is creation-only — worst case is orphan copies,
+the same risk class as direct-upload spam — though it still wants
+rate/authz consideration.
+
+## Costs
+
+A full byte copy per rename (double storage until GC, transits the app
+server); variants regenerate lazily for the new key; each re-edit mints
+another orphan.
+
+## The client-side freebie
+
+Fresh (client-held) uploads need none of this: the name rides the
+direct-upload blob-creation request, so rename-before-upload is
+client-side only. The same hook could normalise percent-encoded filenames
+at creation — the case the field's VoiceOver review flagged as encoding
+noise.
+
+## Open if pursued
+
+The edit affordance in the figure (caption, keep option, remove/retry
+labels all regenerate), and which states offer rename (likely
+`upload-successful` and server-rendered figures only).
diff --git a/katalyst-govuk-formbuilder.gemspec b/katalyst-govuk-formbuilder.gemspec
index 2d4938b..ebb8ef7 100644
--- a/katalyst-govuk-formbuilder.gemspec
+++ b/katalyst-govuk-formbuilder.gemspec
@@ -2,7 +2,7 @@
Gem::Specification.new do |spec|
spec.name = "katalyst-govuk-formbuilder"
- spec.version = "1.30.1"
+ spec.version = "2.0.0"
spec.authors = ["Katalyst Interactive"]
spec.email = ["developers@katalyst.com.au"]
diff --git a/lib/katalyst/govuk/form_builder/config.rb b/lib/katalyst/govuk/form_builder/config.rb
index 9874dfb..764ec77 100644
--- a/lib/katalyst/govuk/form_builder/config.rb
+++ b/lib/katalyst/govuk/form_builder/config.rb
@@ -26,6 +26,26 @@ def image_mime_types=(value)
end
config.image_mime_types = %w[image/png image/gif image/jpeg image/webp].freeze
+
+ def attachment_preview_representation
+ config.attachment_preview_representation
+ end
+
+ def attachment_preview_representation=(value)
+ config.attachment_preview_representation = value
+ end
+
+ config.attachment_preview_representation = { resize_to_fill: [256, 256] }.freeze
+
+ def use_legacy_file_fields?
+ config.use_legacy_file_fields
+ end
+
+ def use_legacy_file_fields=(value)
+ config.use_legacy_file_fields = value
+ end
+
+ config.use_legacy_file_fields = false
end
end
end
diff --git a/package.json b/package.json
index 7a379c4..f27b61c 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,10 @@
"dependencies": {
"govuk-frontend": "^6.0.0"
},
+ "peerDependencies": {
+ "@hotwired/stimulus": ">= 3.0.0",
+ "@rails/activestorage": ">= 8.0.0"
+ },
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-terser": "^1.0.0",
diff --git a/script/voiceover/README.md b/script/voiceover/README.md
new file mode 100644
index 0000000..8f2def2
--- /dev/null
+++ b/script/voiceover/README.md
@@ -0,0 +1,85 @@
+# VoiceOver caption capture (spike)
+
+Throwaway tooling (outside the gemspec) to capture what VoiceOver actually
+announces during an interaction, so announcement issues can be characterised
+from real utterance sequences instead of by ear.
+
+## Why OCR
+
+VoiceOver's spoken output is only faithfully readable from its **caption
+panel**, and the panel is opaque to scripting: its AppleScript object
+exposes only `enabled` (no text), the `last phrase` object holds a single
+slot that updates at *queue* time (so it skips past transient announcements
+like " removed"), and the VoiceOver process publishes nothing to the
+Accessibility API. The panel, however, renders each statement **speech-timed**
+— held on screen for the duration it's spoken, then replaced — so screen
+**OCR** of the panel region, polled at sub-statement intervals, catches every
+statement in turn. That is the only method here that captures the transient
+announcements; the AppleScript-slot and guidepup approaches were tried and
+dropped because both read that lossy single slot.
+
+## Setup
+
+1. Enable VoiceOver AppleScript control: VoiceOver Utility (⌘F5 to start VO,
+ then VO+F8) → General → **“Allow VoiceOver to be controlled with
+ AppleScript”**.
+2. Grant the terminal **Screen Recording** permission (System Settings →
+ Privacy & Security → Screen Recording) and restart it — `screencapture`
+ needs it.
+3. Compile the OCR helper (macOS Vision framework):
+ ```
+ swiftc script/voiceover/ocr.swift -o /tmp/vo-ocr
+ ```
+
+## Calibrate the panel region
+
+The capture crops to the caption panel by screen coordinates (`screencapture
+-R x,y,w,h`, in points), which are **display- and position-specific**. Find
+yours: capture the full screen and OCR it with coordinates to locate the
+caption text, then tighten a crop around just the panel (excluding page
+content beside it):
+
+```
+screencapture -x /tmp/full.png
+/tmp/vo-ocr /tmp/full.png --coords # find the caption text's box
+screencapture -x -R 905,82,560,98 /tmp/panel.png
+/tmp/vo-ocr /tmp/panel.png # adjust R until only the panel shows
+```
+
+## Capture
+
+```
+VO_SCRATCH=/tmp VO_REGION=905,82,560,98 node script/voiceover/capture-ocr.mjs 15
+```
+
+`VO_SCRATCH` holds the compiled `vo-ocr` binary and scratch frames;
+`VO_REGION` is your calibrated crop. It polls the panel for the given
+seconds, logging each statement (wrapped lines joined) as it changes:
+
+```
+region 905,82,560,98, 15s
+
+ 0.24s 🗣 Gallery, No file chosen, Choose file or drop file, button group
+ 2.60s 🗣 bikes 2.jpg removed
+```
+
+Drive the interaction by hand with **real keys** (VoiceOver only announces
+for real key events — VO+Space to activate), then keep off the keyboard
+until the window ends so the panel isn't overwritten by other narration.
+
+Protocol learnings from the F7 runs:
+
+- **Suspect network timing before input method.** A missing outcome
+ announcement means the live-region write landed inside the selection's
+ announcement traffic — verified both ways: throttled runs voice success
+ and failure regardless of input method, unthrottled runs lose them.
+ Mouse steps do change the *focus* narration (no dialog-close or focus
+ re-read when focus stays in Finder), so keep input consistent across
+ runs being compared.
+- **Statements are held ~3.4s each**, so queued announcements drain slowly:
+ size the window generously (30s+) or trailing statements are clipped.
+- **Devtools network throttling is the timing probe.** Local uploads finish
+ inside the selection's own announcement traffic (count re-read,
+ dialog-close, focus re-read), which is exactly where live-region writes
+ get dropped; throttling moves the upload-outcome write clear of the
+ traffic so the two effects can be told apart.
diff --git a/script/voiceover/capture-ocr.mjs b/script/voiceover/capture-ocr.mjs
new file mode 100644
index 0000000..ec2f571
--- /dev/null
+++ b/script/voiceover/capture-ocr.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+// Reads VoiceOver's caption panel by OCR — the only faithful source, since
+// the panel is opaque to AppleScript/AX. The panel renders each statement
+// speech-timed (held for the duration it's spoken, then replaced), so
+// polling the region at sub-statement intervals catches every statement in
+// turn, including the transient "removed" the AppleScript `last phrase`
+// slot drops.
+//
+// Calibrate REGION (screencapture -R points: x,y,w,h) to your caption panel
+// with the calibration captures in the README. Run this, perform a removal
+// (real VO+Space so VoiceOver actually announces), then Ctrl-C or wait out
+// the window.
+//
+// node script/voiceover/capture-ocr.mjs [seconds]
+import { execFileSync } from "node:child_process";
+import { writeFileSync } from "node:fs";
+
+const SCRATCH = process.env.VO_SCRATCH ?? ".";
+const OCR = process.env.VO_OCR ?? `${SCRATCH}/vo-ocr`; // swiftc ocr.swift -o $SCRATCH/vo-ocr
+const REGION = process.env.VO_REGION ?? "905,82,560,98";
+const FRAME = `${SCRATCH}/vo-frame.png`;
+const WINDOW_S = Number(process.argv[2] ?? 20);
+
+const norm = (s) => s.replace(/\s+/g, " ").trim();
+
+// The panel's current statement = its wrapped lines joined. Ignore the
+// leading "X"/"x" close-button glyph the OCR sometimes prepends.
+function readPanel() {
+ execFileSync("screencapture", ["-x", "-R", REGION, FRAME]);
+ const lines = execFileSync(OCR, [FRAME], { encoding: "utf8" })
+ .split("\n")
+ .map(norm)
+ .filter(Boolean);
+ return norm(lines.join(" ").replace(/^[Xx]\s+/, ""));
+}
+
+const t0 = Date.now();
+const seq = [];
+let last = null;
+
+while (Date.now() - t0 < WINDOW_S * 1000) {
+ let statement = "";
+ try {
+ statement = readPanel();
+ } catch {
+ // transient capture/OCR failure; keep polling
+ }
+ if (statement && statement !== last) {
+ seq.push({ at: Date.now() - t0, statement });
+ last = statement;
+ }
+}
+
+console.log(`region ${REGION}, ${WINDOW_S}s\n`);
+for (const { at, statement } of seq) {
+ console.log(`${(at / 1000).toFixed(2).padStart(6)}s 🗣 ${statement}`);
+}
+writeFileSync(`${SCRATCH}/vo-ocr-transcript.txt`, seq.map((s) => `${(s.at / 1000).toFixed(2)}\t${s.statement}`).join("\n"));
diff --git a/script/voiceover/ocr.swift b/script/voiceover/ocr.swift
new file mode 100644
index 0000000..067a351
--- /dev/null
+++ b/script/voiceover/ocr.swift
@@ -0,0 +1,46 @@
+// Vision-framework OCR: prints the text recognised in an image, ordered
+// top-to-bottom, one observation per line. Used to read VoiceOver's caption
+// panel (which is opaque to AppleScript and the Accessibility API) from a
+// screenshot of its region.
+//
+// swift script/voiceover/ocr.swift
+import Foundation
+import Vision
+import AppKit
+
+guard CommandLine.arguments.count > 1,
+ let image = NSImage(contentsOfFile: CommandLine.arguments[1]),
+ let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)
+else {
+ FileHandle.standardError.write(Data("usage: ocr.swift \n".utf8))
+ exit(1)
+}
+
+let request = VNRecognizeTextRequest()
+request.recognitionLevel = .accurate
+request.usesLanguageCorrection = false
+
+let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
+try? handler.perform([request])
+
+let observations = (request.results ?? [])
+ // Vision's origin is bottom-left; sort descending y for reading order.
+ .sorted { $0.boundingBox.origin.y > $1.boundingBox.origin.y }
+
+// With --coords, prefix each line with its pixel bounding box (for locating
+// a region on a full-screen capture); otherwise print text only.
+let showCoords = CommandLine.arguments.contains("--coords")
+let w = CGFloat(cgImage.width)
+let h = CGFloat(cgImage.height)
+
+for observation in observations {
+ guard let text = observation.topCandidates(1).first?.string else { continue }
+ if showCoords {
+ let b = observation.boundingBox
+ let px = Int(b.origin.x * w)
+ let py = Int((1 - b.origin.y - b.height) * h) // top-left origin, pixels
+ print("[x=\(px) y=\(py) w=\(Int(b.width * w)) h=\(Int(b.height * h))] \(text)")
+ } else {
+ print(text)
+ }
+}
diff --git a/spec/builders/govuk_design_system_form_builder/form_builder_attachment_field_spec.rb b/spec/builders/govuk_design_system_form_builder/form_builder_attachment_field_spec.rb
new file mode 100644
index 0000000..146a933
--- /dev/null
+++ b/spec/builders/govuk_design_system_form_builder/form_builder_attachment_field_spec.rb
@@ -0,0 +1,845 @@
+# frozen_string_literal: true
+
+# :markup: markdown
+
+require "rails_helper"
+
+# Server-rendered markup for govuk_attachment_field.
+#
+# Tests:
+# * Profile's has_one_attached :avatar: (required)
+# one figure per attached blob (preview, caption, actions)
+# * Profile's has_many_attached :gallery: (optional)
+# multiple figures, one per attached blob (preview, caption, actions)
+# * Profile's has_one_attached :cv: (optional)
+# one figure per attached blob (caption, actions)
+# * Round-tripping multipart form inputs
+RSpec.describe GOVUKDesignSystemFormBuilder::FormBuilder do
+ let(:builder) { described_class.new(:profile, profile, helper, {}) }
+ let(:profile) { create(:profile) }
+
+ def govuk_attachment_field(...)
+ Capybara.string(builder.govuk_attachment_field(...).to_s)
+ end
+
+ describe "#govuk_attachment_field (avatar / single / required)" do
+ subject(:html) { govuk_attachment_field(:avatar) }
+
+ let(:blob) { profile.avatar.blob }
+
+ context "with no attachment" do
+ let(:profile) { Profile.new }
+
+ it "renders no attachment figures" do
+ expect(html).to have_no_css("figure.govuk-attachment")
+ end
+
+ it "renders a single file input inside the wrapper" do
+ expect(html).to have_css(".govuk-file-upload-wrapper input[type=file]", count: 1, visible: :all)
+ end
+
+ it "does not mark the file input as multiple" do
+ expect(html).to have_no_css("input[type=file][multiple]", visible: :all)
+ end
+
+ # Exclusivity invariant: initAll enhances data-module="govuk-file-upload"
+ # with govuk-frontend's own FileUpload, so an attachment field emitting it
+ # would have two implementations fighting over one input.
+ it "never emits govuk-frontend's file-upload data-module" do
+ expect(html).to have_no_css("[data-module='govuk-file-upload']", visible: :all)
+ end
+ end
+
+ # The label, caption, hint and form_group configuration must render
+ # through the element rather than being dropped; the image and document
+ # wrappers forward these options here and rely on them being consumed.
+ context "with configuration options" do
+ it "renders the hint" do
+ html = govuk_attachment_field(:avatar, hint: { text: "Max 5MB" })
+
+ expect(html).to have_css(".govuk-hint", text: "Max 5MB")
+ end
+
+ it "describes the input by the hint" do
+ html = govuk_attachment_field(:avatar, hint: { text: "Max 5MB" })
+ hint_id = html.find(".govuk-hint", visible: :all)[:id]
+
+ expect(html.find("input[type=file]", visible: :all)["aria-describedby"]).to eq(hint_id)
+ end
+
+ it "renders the supplied label text" do
+ html = govuk_attachment_field(:avatar, label: { text: "Your photo" })
+
+ expect(html).to have_css("label", text: "Your photo")
+ end
+
+ it "renders the supplied caption" do
+ html = govuk_attachment_field(:avatar, caption: { text: "Step 1" })
+
+ expect(html).to have_css(".govuk-caption-m", text: "Step 1")
+ end
+
+ it "applies form_group options" do
+ html = govuk_attachment_field(:avatar, form_group: { class: "extra-group" })
+
+ expect(html).to have_css(".govuk-form-group.extra-group")
+ end
+ end
+
+ # Each text option renders as a data-i18n.* attribute on the wrapper.
+ # The attribute names are the contract's read surface — the JS
+ # enhancement configures its strings from these exact names, so a
+ # rename here breaks localisation without failing anything else.
+ context "with i18n text options" do
+ def wrapper(html)
+ html.find(".govuk-file-upload-wrapper", visible: :all)
+ end
+
+ {
+ choose_files_button_text: "data-i18n.choose-files-button",
+ drop_instruction_text: "data-i18n.drop-instruction",
+ no_file_chosen_text: "data-i18n.no-file-chosen",
+ multiple_files_chosen_one_text: "data-i18n.multiple-files-chosen.one",
+ multiple_files_chosen_other_text: "data-i18n.multiple-files-chosen.other",
+ entered_drop_zone_text: "data-i18n.entered-drop-zone",
+ left_drop_zone_text: "data-i18n.left-drop-zone",
+ upload_succeeded_text: "data-i18n.upload-succeeded",
+ upload_failed_text: "data-i18n.upload-failed",
+ retry_button_text: "data-i18n.retry-button",
+ file_removed_text: "data-i18n.file-removed",
+ remove_button_text: "data-i18n.remove-button",
+ remove_button_content_text: "data-i18n.remove-button-content",
+ }.each do |option, attribute|
+ it "renders #{option} as #{attribute}" do
+ html = govuk_attachment_field(:avatar, option => "Custom text")
+
+ expect(wrapper(html)[attribute]).to eq("Custom text")
+ end
+ end
+
+ # %{count} is govuk-frontend's interpolation placeholder, passed
+ # through verbatim — not a Ruby format token.
+ # rubocop:disable Style/FormatStringToken
+ it "renders the one form of a multiple_files_chosen_text hash" do
+ html = govuk_attachment_field(:avatar, multiple_files_chosen_text: { one: "1 file", other: "%{count} files" })
+
+ expect(wrapper(html)["data-i18n.multiple-files-chosen.one"]).to eq("1 file")
+ end
+
+ it "renders the other form of a multiple_files_chosen_text hash" do
+ html = govuk_attachment_field(:avatar, multiple_files_chosen_text: { one: "1 file", other: "%{count} files" })
+
+ expect(wrapper(html)["data-i18n.multiple-files-chosen.other"]).to eq("%{count} files")
+ end
+ # rubocop:enable Style/FormatStringToken
+
+ # With no options the attributes are absent, leaving the strings to
+ # the enhancement's own defaults.
+ it "renders no i18n data attributes by default" do
+ attributes = wrapper(html).native.attribute_nodes.map(&:name)
+
+ expect(attributes.grep(/\Adata-i18n/)).to be_empty
+ end
+ end
+
+ # The remove strings also render into the server figures, so the option
+ # must drive both the data attribute (for client figures) and the
+ # trait's own markup — string parity between the two figure sources
+ # depends on the shared option.
+ # rubocop:disable Style/FormatStringToken
+ context "with remove control options" do
+ subject(:html) do
+ govuk_attachment_field(:avatar, remove_button_text: "Bin %{filename}", remove_button_content_text: "🗑")
+ end
+
+ it "names the remove option with the interpolated text" do
+ expect(html).to have_css(
+ "figure.govuk-attachment select option[value='']",
+ text: "Bin avatar.png",
+ visible: :all,
+ )
+ end
+
+ it "labels the remove button with the interpolated text" do
+ button = html.find("figure.govuk-attachment .actions button")
+
+ expect(button["aria-label"]).to eq("Bin avatar.png")
+ end
+
+ it "renders the configured remove button content" do
+ expect(html).to have_css("figure.govuk-attachment .actions button", text: "🗑")
+ end
+ end
+ # rubocop:enable Style/FormatStringToken
+
+ # The attachment strings live in the gem's locale files
+ # (config/locales), so a consuming app localises by adding Rails
+ # translations — no JS knowledge needed. A translated string renders
+ # into the server figures and onto the data-i18n.* attributes (where
+ # the JS reads it); the en defaults render neither, leaving the JS to
+ # its bundled mirror of the same table.
+ context "with a non-default locale" do
+ around do |example|
+ # rubocop:disable Style/FormatStringToken -- %{filename} is the i18n placeholder
+ I18n.backend.store_translations(:fr, { katalyst: { govuk: { attachment: {
+ upload_succeeded: "Téléversement réussi",
+ remove_button: "Supprimer %{filename}",
+ remove_button_content: "Retirer",
+ } } } })
+ # rubocop:enable Style/FormatStringToken
+ I18n.with_locale(:fr) { example.run }
+ ensure
+ I18n.backend.reload!
+ end
+
+ it "renders the translation onto the data-i18n attribute" do
+ wrapper = html.find(".govuk-file-upload-wrapper", visible: :all)
+
+ expect(wrapper["data-i18n.upload-succeeded"]).to eq("Téléversement réussi")
+ end
+
+ it "names the remove option from the translation" do
+ expect(html).to have_css(
+ "figure.govuk-attachment select option[value='']",
+ text: "Supprimer avatar.png",
+ visible: :all,
+ )
+ end
+
+ it "renders the remove button content from the translation" do
+ expect(html).to have_css("figure.govuk-attachment .actions button", text: "Retirer")
+ end
+
+ it "prefers an explicit option over the translation" do
+ html = govuk_attachment_field(:avatar, upload_succeeded_text: "Custom text")
+ wrapper = html.find(".govuk-file-upload-wrapper", visible: :all)
+
+ expect(wrapper["data-i18n.upload-succeeded"]).to eq("Custom text")
+ end
+ end
+
+ # Overriding the gem's en strings in the app's own locale files is the
+ # same customisation path as any other locale: the override must reach
+ # the data-i18n.* attributes, or server-rendered figures show the
+ # override while JS-created figures and announcements fall back to the
+ # gem's bundled defaults — a mixed UI.
+ context "with an app-level en override" do
+ around do |example|
+ # The backend loads locale files lazily on first lookup, clobbering
+ # anything stored beforehand — initialise it first so the override
+ # merges over the gem's en table, as an app's locale file would.
+ I18n.backend.translations(do_init: true)
+ I18n.backend.store_translations(:en, { katalyst: { govuk: { attachment: {
+ upload_succeeded: "All done!",
+ remove_button_content: "Bin",
+ } } } })
+ example.run
+ ensure
+ I18n.backend.reload!
+ end
+
+ it "renders the override onto the data-i18n attribute" do
+ wrapper = html.find(".govuk-file-upload-wrapper", visible: :all)
+
+ expect(wrapper["data-i18n.upload-succeeded"]).to eq("All done!")
+ end
+
+ it "renders the override into the server figure" do
+ expect(html).to have_css("figure.govuk-attachment .actions button", text: "Bin")
+ end
+ end
+
+ context "with an attached image" do
+ it "renders one figure with preview, caption, and actions, in order" do
+ expect(html).to have_css("figure.govuk-attachment > img + figcaption + div.actions", count: 1, visible: :all)
+ end
+
+ it "groups the select and remove button in the actions container" do
+ expect(html).to have_css("figure.govuk-attachment .actions > select + button", visible: :all)
+ end
+
+ it "does not process the preview variant at render time" do
+ expect { html }.not_to change(ActiveStorage::VariantRecord, :count)
+ end
+
+ it "captions the figure with the filename" do
+ expect(html).to have_css("figure.govuk-attachment figcaption .filename", text: "avatar.png")
+ end
+
+ it "captions the figure with the human file size" do
+ expect(html).to have_css("figure.govuk-attachment figcaption .size", text: /\A[\d.]+ (Bytes|KB|MB)\z/)
+ end
+
+ it "selects the current file, labelled with its filename" do
+ expect(html).to have_css(
+ "figure.govuk-attachment select option[selected][value='#{blob.signed_id}']",
+ text: "avatar.png",
+ visible: :all,
+ )
+ end
+
+ it "offers a remove option with a blank value naming the file" do
+ expect(html).to have_css(
+ "figure.govuk-attachment select option[value='']",
+ text: "Remove avatar.png",
+ visible: :all,
+ )
+ end
+
+ it "inputs a hidden input to track removes" do
+ expect(html).to have_css(".govuk-file-upload-wrapper input[type=hidden]", count: 1, visible: :all)
+ end
+
+ it "renders the blank keeper first, before the other input(s)" do
+ types = html.all("[name='profile[avatar]']", visible: :all).map do |node|
+ "#{node.tag_name}[type=#{node['type']}]"
+ end
+
+ expect(types).to eq(%w[input[type=hidden] select[type=] input[type=file]])
+ end
+
+ it "names the select for scalar assignment" do
+ select = html.find("figure.govuk-attachment select", visible: :all)
+
+ expect(select["name"]).to eq("profile[avatar]")
+ end
+
+ it "gives the select a unique per-blob id" do
+ select = html.find("figure.govuk-attachment select", visible: :all)
+
+ expect(select["id"]).to eq(builder.field_id(:avatar, :attachment, blob.id, :input))
+ end
+
+ # Hiding the select is CSS-gated on .govuk-frontend-supported so that
+ # without JavaScript it stays the visible control.
+ it "does not hide the select in markup" do
+ expect(html).to have_no_css("figure.govuk-attachment select.govuk-visually-hidden", visible: :all)
+ end
+
+ it "renders a remove button wired to the attachment controller" do
+ expect(html).to have_css("figure.govuk-attachment .actions button[data-action='govuk-attachment#destroy']")
+ end
+
+ it "labels the remove button with the filename" do
+ button = html.find("figure.govuk-attachment .actions button")
+
+ expect(button["aria-label"]).to eq("Remove avatar.png")
+ end
+
+ it "stops the remove button from submitting the form" do
+ button = html.find("figure.govuk-attachment .actions button")
+
+ expect(button["type"]).to eq("button")
+ end
+
+ it "styles the remove button as a secondary govuk button" do
+ button = html.find("figure.govuk-attachment .actions button")
+
+ expect(button[:class].split).to include("govuk-button", "govuk-button--secondary", "govuk-attachment__remove")
+ end
+
+ it "connects the remove button to govuk-frontend's button behaviour" do
+ button = html.find("figure.govuk-attachment .actions button")
+
+ expect(button["data-module"]).to eq("govuk-button")
+ end
+
+ it "renders Remove as the default remove button content" do
+ button = html.find("figure.govuk-attachment .actions button")
+
+ expect(button.text).to eq("Remove")
+ end
+
+ it "hides the preview image from assistive technology" do
+ expect(html.find("figure.govuk-attachment img")["alt"]).to eq("")
+ end
+
+ it "labels the figure with its filename" do
+ figure = html.find("figure.govuk-attachment")
+
+ expect(figure["aria-labelledby"]).to eq(builder.field_id(:avatar, :attachment, blob.id, :filename))
+ end
+
+ it "gives the filename span the id the figure's label references" do
+ filename_id = builder.field_id(:avatar, :attachment, blob.id, :filename)
+
+ expect(html).to have_css("figure.govuk-attachment figcaption .filename[id='#{filename_id}']")
+ end
+
+ it "makes the caption a polite live region so status changes are announced" do
+ expect(html.find("figure.govuk-attachment figcaption")["aria-live"]).to eq("polite")
+ end
+
+ it "announces the whole caption atomically, so announcements name the file" do
+ expect(html.find("figure.govuk-attachment figcaption")["aria-atomic"]).to eq("true")
+ end
+
+ it "reserves an empty status span in the caption for upload announcements" do
+ expect(html.find("figure.govuk-attachment figcaption .status").text).to eq("")
+ end
+
+ it "gives the select an accessible name that includes the filename" do
+ select = html.find("figure.govuk-attachment select", visible: :all)
+ referenced = select["aria-labelledby"].to_s.split.flat_map { |id| html.all("[id='#{id}']").map(&:text) }
+ label_texts = html.all("label[for='#{select['id']}']", visible: :all).map(&:text)
+
+ expect([select["aria-label"], *referenced, *label_texts].compact.join(" ")).to include("avatar.png")
+ end
+ end
+
+ # Brand follows CSS classes only: a rebranding consumer replaces the
+ # stylesheet, but the JS ships with the gem and registers fixed govuk
+ # controller identifiers — behavioural wiring stays govuk whatever the
+ # brand.
+ context "with a non-default brand" do
+ around do |example|
+ GOVUKDesignSystemFormBuilder.brand = "defra"
+ example.run
+ ensure
+ GOVUKDesignSystemFormBuilder.brand = "govuk"
+ end
+
+ it "prefixes the figure class with the brand" do
+ expect(html).to have_css("figure.defra-attachment", visible: :all)
+ end
+
+ it "prefixes the wrapper class with the brand" do
+ expect(html).to have_css(".defra-file-upload-wrapper", visible: :all)
+ end
+
+ it "prefixes the remove button classes with the brand" do
+ expect(html).to have_css(
+ ".defra-attachment .actions button.defra-button.defra-button--secondary.defra-attachment__remove",
+ visible: :all,
+ )
+ end
+
+ it "connects the figure to the attachment controller" do
+ expect(html).to have_css("figure.defra-attachment[data-controller='govuk-attachment']", visible: :all)
+ end
+
+ it "connects the wrapper to the file-upload controller" do
+ expect(html).to have_css(".defra-file-upload-wrapper[data-controller='govuk-file-upload']", visible: :all)
+ end
+
+ it "keeps the remove action on the govuk identifier" do
+ expect(html).to have_css("button[data-action='govuk-attachment#destroy']", visible: :all)
+ end
+ end
+
+ # The preview URL is lazy — the variant is processed when the browser
+ # requests it, so rendering the form never touches the blob's bytes and
+ # a blob that can't be processed costs a broken image, not an error.
+ context "with an attached image whose bytes are missing" do
+ before { blob.service.delete(blob.key) }
+
+ it "renders the figure" do
+ expect(html).to have_css("figure.govuk-attachment .filename", text: "avatar.png")
+ end
+
+ it "renders the preview, leaving the failure to the image request" do
+ expect(html.find("figure.govuk-attachment img")[:src]).to be_present
+ end
+
+ it "keeps the blob's signed id as the keep option" do
+ expect(html.find("figure.govuk-attachment select option[selected]", visible: :all).value)
+ .to eq(blob.signed_id)
+ end
+ end
+
+ context "with a non-image attachment" do
+ before do
+ profile.avatar.attach(
+ io: StringIO.new("not an image"),
+ filename: "notes.txt",
+ content_type: "text/plain",
+ )
+ end
+
+ it "renders the figure with caption and actions adjacent" do
+ expect(html).to have_css("figure.govuk-attachment > figcaption + div.actions", visible: :all)
+ end
+
+ it "omits the preview image" do
+ expect(html).to have_no_css("figure.govuk-attachment img")
+ end
+ end
+
+ describe "#direct_upload_url" do
+ it "adds data-direct-upload-url by default" do
+ input = html.find("input[type=file]", visible: :all)
+
+ expect(input["data-direct-upload-url"]).to eq(helper.rails_direct_uploads_url)
+ end
+
+ it "respects direct_upload: false" do
+ html = govuk_attachment_field(:avatar, direct_upload: false)
+
+ expect(html).to have_css("input[type=file]:not([data-direct-upload-url])", visible: :all)
+ end
+
+ it "still renders the attachment figure when direct_upload is false" do
+ html = govuk_attachment_field(:avatar, direct_upload: false)
+
+ expect(html).to have_css("figure.govuk-attachment > img + figcaption + div.actions", visible: :all)
+ end
+
+ it "uses direct_upload_url when provided" do
+ html = govuk_attachment_field(:avatar, direct_upload_url: "/override")
+ input = html.find("input[type=file]", visible: :all)
+
+ expect(input["data-direct-upload-url"]).to eq("/override")
+ end
+
+ it "uses direct_upload_url from form builder when overridden (i.e. Koi admin)" do
+ builder.instance_eval do
+ def direct_upload_url
+ "/extend"
+ end
+ end
+
+ input = html.find("input[type=file]", visible: :all)
+
+ expect(input["data-direct-upload-url"]).to eq("/extend")
+ end
+
+ it "resolves direct-upload-url through main_app" do
+ allow(helper).to receive(:respond_to?).and_call_original
+ allow(helper).to receive(:respond_to?).with(:rails_direct_uploads_url).and_return(false)
+
+ input = html.find("input[type=file]", visible: :all)
+
+ expect(input["data-direct-upload-url"]).to eq(helper.main_app.rails_direct_uploads_url)
+ end
+
+ it "does not set data-direct-upload-url when no direct-upload route is available" do
+ allow(helper).to receive(:respond_to?).and_call_original
+ allow(helper).to receive(:respond_to?).with(:rails_direct_uploads_url).and_return(false)
+ allow(helper).to receive(:main_app).and_return(Object.new)
+
+ expect(html).to have_css("input[type=file]:not([data-direct-upload-url])", visible: :all)
+ end
+ end
+
+ # ActiveStorage's representation route lives in the application's route
+ # set, so an engine-mounted form must resolve the preview URL through
+ # main_app — the same resolution direct_upload_url uses.
+ describe "preview URL resolution" do
+ let(:representation) { blob.representation(resize_to_fill: [256, 256]) }
+
+ it "renders the preview from the representation route" do
+ expect(html.find("figure.govuk-attachment img")[:src])
+ .to eq(helper.rails_representation_path(representation))
+ end
+
+ it "renders the preview from the configured representation" do
+ config = GOVUKDesignSystemFormBuilder.config
+ original = config.attachment_preview_representation
+ config.attachment_preview_representation = { resize_to_limit: [50, 50] }
+
+ expect(html.find("figure.govuk-attachment img")[:src])
+ .to eq(helper.rails_representation_path(blob.representation(resize_to_limit: [50, 50])))
+ ensure
+ config.attachment_preview_representation = original
+ end
+
+ it "resolves the preview URL through main_app" do
+ allow(helper).to receive(:respond_to?).and_call_original
+ allow(helper).to receive(:respond_to?).with(:rails_representation_path).and_return(false)
+
+ expect(html.find("figure.govuk-attachment img")[:src])
+ .to eq(helper.main_app.rails_representation_path(representation))
+ end
+
+ it "renders no preview when no representation route is available" do
+ allow(helper).to receive(:respond_to?).and_call_original
+ allow(helper).to receive(:respond_to?).with(:rails_representation_path).and_return(false)
+ allow(helper).to receive(:main_app).and_return(Object.new)
+
+ expect(html).to have_no_css("figure.govuk-attachment img")
+ end
+ end
+
+ context "with an unpersisted multipart upload" do
+ before do
+ profile.avatar = Rack::Test::UploadedFile.new(file_fixture("avatar.png"), "image/png")
+ end
+
+ def pending_blob
+ profile.attachment_changes["avatar"].blob
+ end
+
+ it "persists the pending blob" do
+ expect { html }.to change(pending_blob, :persisted?).to(true)
+ end
+
+ it "uploads the bytes" do
+ html
+
+ expect(pending_blob.service.exist?(pending_blob.key)).to be(true)
+ end
+
+ it "renders the preview from the uploaded bytes" do
+ expect(html.find("figure.govuk-attachment img")[:src]).to be_present
+ end
+
+ it "renders the persisted blob's signed id as the keep option" do
+ expect(html.find("figure.govuk-attachment select option[selected]", visible: :all).value)
+ .to eq(pending_blob.signed_id)
+ end
+ end
+
+ context "with a pending change whose blob is already persisted" do
+ # A direct upload arrives as a signed id: the blob already exists with
+ # bytes. Non-representable content keeps variant processing out of the
+ # render, so any upload call could only come from a wrongful re-persist.
+ let(:blob) do
+ ActiveStorage::Blob.create_and_upload!(
+ io: StringIO.new("plain text"),
+ filename: "notes.txt",
+ content_type: "text/plain",
+ )
+ end
+
+ before { profile.avatar = blob.signed_id }
+
+ it "does not upload again" do
+ allow(ActiveStorage::Blob.service).to receive(:upload)
+
+ html
+
+ expect(ActiveStorage::Blob.service).not_to have_received(:upload)
+ end
+
+ it "renders the figure" do
+ expect(html).to have_css("figure.govuk-attachment .filename", text: "notes.txt")
+ end
+ end
+
+ context "when persisting the pending blob fails" do
+ before do
+ profile.avatar = Rack::Test::UploadedFile.new(file_fixture("avatar.png"), "image/png")
+ allow(ActiveStorage::Blob.service).to receive(:upload).and_raise(ActiveStorage::IntegrityError)
+ end
+
+ it "drops the figure rather than failing the render" do
+ expect(html).to have_no_css("figure.govuk-attachment")
+ end
+
+ it "still renders the file input so the user can re-choose" do
+ expect(html).to have_field("profile[avatar]", type: :file, visible: :all)
+ end
+
+ it "leaves no half-persisted blob behind" do
+ html
+
+ expect(profile.attachment_changes["avatar"].blob).not_to be_persisted
+ end
+
+ # ActiveStorage's log subscriber only reports successful service calls
+ # (it ignores the exception payload), so once the field swallows the
+ # error, this warning is the only record that a user's file was dropped.
+ it "logs the dropped upload" do
+ allow(Rails.logger).to receive(:warn)
+
+ html
+
+ expect(Rails.logger).to have_received(:warn)
+ .with(include("avatar").and(include("ActiveStorage::IntegrityError")))
+ end
+ end
+
+ context "when the pending upload's tempfile has vanished before render" do
+ before do
+ profile.avatar = Rack::Test::UploadedFile.new(file_fixture("avatar.png"), "image/png")
+ File.unlink(profile.attachment_changes["avatar"].attachable.path)
+ end
+
+ it "drops the figure rather than failing the render" do
+ expect(html).to have_no_css("figure.govuk-attachment")
+ end
+
+ it "logs the dropped upload" do
+ allow(Rails.logger).to receive(:warn)
+
+ html
+
+ expect(Rails.logger).to have_received(:warn)
+ .with(include("avatar").and(include("Errno::ENOENT")))
+ end
+ end
+
+ context "with a mixed gallery of a persisted signed id and a multipart upload" do
+ let(:persisted) do
+ ActiveStorage::Blob.create_and_upload!(
+ io: File.open(file_fixture("avatar.png")),
+ filename: "persisted.png",
+ content_type: "image/png",
+ )
+ end
+
+ before do
+ profile.gallery = [
+ persisted.signed_id,
+ Rack::Test::UploadedFile.new(file_fixture("avatar.png").open, "image/png",
+ original_filename: "fresh.png"),
+ ]
+ end
+
+ def render_gallery_field
+ Capybara.string(builder.govuk_attachment_field(:gallery).to_s)
+ end
+
+ it "persists every pending blob in the change" do
+ render_gallery_field
+
+ expect(profile.attachment_changes["gallery"].blobs).to all(be_persisted)
+ end
+
+ it "renders a figure for each entry" do
+ html = render_gallery_field
+
+ expect(html.all("figure.govuk-attachment .filename").map(&:text))
+ .to contain_exactly("persisted.png", "fresh.png")
+ end
+ end
+ end
+
+ describe "#govuk_attachment_field (gallery / multiple)" do
+ subject(:html) { govuk_attachment_field(:gallery) }
+
+ context "with no attachments" do
+ let(:profile) { Profile.new }
+
+ it "renders no attachment figures" do
+ expect(html).to have_no_css("figure.govuk-attachment")
+ end
+
+ it "infers multiple from the has_many_attached reflection" do
+ expect(html).to have_css("input[type=file][multiple]", visible: :all)
+ end
+
+ it "renders a multiple file input when multiple is passed explicitly" do
+ html = govuk_attachment_field(:gallery, multiple: true)
+
+ expect(html).to have_css("input[type=file][multiple]", visible: :all)
+ end
+
+ it "renders without multiple file input when multiple is passed explicitly" do
+ html = govuk_attachment_field(:gallery, multiple: false)
+
+ expect(html).to have_css("input[type=file]:not([multiple])", visible: :all)
+ end
+ end
+
+ context "with several attached images" do
+ before do
+ %w[first.png second.png].each do |filename|
+ profile.gallery.attach(
+ io: File.open(file_fixture("avatar.png")),
+ filename:,
+ content_type: "image/png",
+ )
+ end
+ end
+
+ it "renders one figure per attached file, in attachment order" do
+ expect(html.all("figure.govuk-attachment .filename").map(&:text)).to eq(%w[first.png second.png])
+ end
+
+ it "round-trips each attachment via its own array-named select" do
+ profile.gallery.blobs.each do |blob|
+ expect(html).to have_css(
+ "select[name='profile[gallery][]'] option[selected][value='#{blob.signed_id}']",
+ text: blob.filename.to_s,
+ visible: :all,
+ )
+ end
+ end
+
+ it "offers a remove option naming each file" do
+ %w[first.png second.png].each do |filename|
+ expect(html).to have_css(
+ "figure.govuk-attachment select option[value='']",
+ text: "Remove #{filename}",
+ visible: :all,
+ )
+ end
+ end
+
+ it "gives each select a unique per-blob id" do
+ ids = html.all("figure.govuk-attachment select", visible: :all).map { |select| select["id"] }
+
+ expect(ids).to eq(profile.gallery.blobs.map { |blob| builder.field_id(:gallery, :attachment, blob.id, :input) })
+ end
+
+ it "renders the blank keeper first, before the other input(s)" do
+ types = html.all("[name='profile[gallery][]']", visible: :all).map do |node|
+ "#{node.tag_name}[type=#{node['type']}]"
+ end
+
+ expect(types).to eq(%w[input[type=hidden] select[type=] select[type=] input[type=file]])
+ end
+ end
+ end
+
+ # cv (optional has_one, PDF) renders the same markup as avatar (required
+ # has_one, image) — PDFs are representable wherever a previewer (poppler)
+ # is available, so even the preview appears; these examples pin only the
+ # attribute's own scalar round-trip.
+ describe "#govuk_attachment_field (cv / single / optional)" do
+ subject(:html) { govuk_attachment_field(:cv) }
+
+ context "with an attached file" do
+ before do
+ profile.cv.attach(
+ io: File.open(file_fixture("cv.pdf")),
+ filename: "cv.pdf",
+ content_type: "application/pdf",
+ )
+ end
+
+ let(:blob) { profile.cv.blob }
+
+ it "renders one figure with caption, and actions, in order" do
+ expect(html).to have_css("figure.govuk-attachment > figcaption + div.actions", count: 1, visible: :all)
+ end
+
+ it "selects the current file, labelled with its filename" do
+ expect(html).to have_css(
+ "figure.govuk-attachment select option[selected][value='#{blob.signed_id}']",
+ text: "cv.pdf",
+ visible: :all,
+ )
+ end
+
+ it "names the select for scalar assignment" do
+ select = html.find("figure.govuk-attachment select", visible: :all)
+
+ expect(select["name"]).to eq("profile[cv]")
+ end
+ end
+ end
+
+ # The field requires an ActiveStorage::Attached value: a plain attribute
+ # (e.g. a form object's attr_accessor) has no signed ids to round-trip, so
+ # the field cannot edit or re-render it — plain uploads stay with
+ # govuk_file_field. Failing fast beats a broken editing experience.
+ describe "#govuk_attachment_field (non-ActiveStorage attribute)" do
+ let(:builder) { described_class.new(:form, form_object, helper, {}) }
+ let(:form_object) { NonStorageForm.new }
+
+ before do
+ stub_const("NonStorageForm", Class.new do
+ include ActiveModel::Model
+
+ attr_accessor :upload
+ end)
+ end
+
+ it "rejects the attribute with an error naming it" do
+ expect { builder.govuk_attachment_field(:upload) }
+ .to raise_error(ArgumentError, /upload/)
+ end
+ end
+end
diff --git a/spec/builders/govuk_design_system_form_builder/form_builder_document_field_spec.rb b/spec/builders/govuk_design_system_form_builder/form_builder_document_field_spec.rb
new file mode 100644
index 0000000..24dbbb2
--- /dev/null
+++ b/spec/builders/govuk_design_system_form_builder/form_builder_document_field_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe GOVUKDesignSystemFormBuilder::FormBuilder do
+ let(:builder) { described_class.new(:profile, profile, helper, {}) }
+ let(:profile) { create(:profile) }
+
+ def govuk_document_field(...)
+ Capybara.string(builder.govuk_document_field(...).to_s)
+ end
+
+ describe "#govuk_document_field" do
+ subject(:html) { govuk_document_field(:cv) }
+
+ let(:blob) { profile.cv.blob }
+
+ context "with no attachment" do
+ let(:profile) { Profile.new }
+
+ it "renders no attachment figures" do
+ expect(html).to have_no_css("figure.govuk-attachment")
+ end
+
+ it "renders a single file input inside the wrapper" do
+ expect(html).to have_css(".govuk-file-upload-wrapper input[type=file]", count: 1, visible: :all)
+ end
+
+ it "applies the document mime types to the input's accept attribute" do
+ expect(html).to have_css("input[type=file][accept*='application/pdf']", visible: :all)
+ end
+ end
+
+ # govuk_document_field delegates to govuk_attachment_field;
+ # the label, caption, hint and form_group configuration must reach it rather
+ # than being dropped on the way through.
+ context "with configuration options" do
+ it "renders the hint" do
+ html = govuk_document_field(:cv, hint: { text: "Max 5MB" })
+
+ expect(html).to have_css(".govuk-hint", text: "Max 5MB")
+ end
+
+ it "describes the input by the hint" do
+ html = govuk_document_field(:cv, hint: { text: "Max 5MB" })
+ hint_id = html.find(".govuk-hint", visible: :all)[:id]
+
+ expect(html.find("input[type=file]", visible: :all)["aria-describedby"]).to eq(hint_id)
+ end
+
+ it "renders the supplied label text" do
+ html = govuk_document_field(:cv, label: { text: "Your CV" })
+
+ expect(html).to have_css("label", text: "Your CV")
+ end
+
+ it "renders the supplied caption" do
+ html = govuk_document_field(:cv, caption: { text: "Step 1" })
+
+ expect(html).to have_css(".govuk-caption-m", text: "Step 1")
+ end
+
+ it "applies form_group options" do
+ html = govuk_document_field(:cv, form_group: { class: "extra-group" })
+
+ expect(html).to have_css(".govuk-form-group.extra-group")
+ end
+ end
+
+ context "with an attached file" do
+ before do
+ profile.cv.attach(
+ io: File.open(file_fixture("cv.pdf")),
+ filename: "cv.pdf",
+ content_type: "application/pdf",
+ )
+ end
+
+ it "renders one figure with caption, and actions, in order" do
+ expect(html).to have_css("figure.govuk-attachment > figcaption + div.actions", count: 1, visible: :all)
+ end
+ end
+
+ context "with an image attachment" do
+ before do
+ profile.cv.attach(
+ io: File.open(file_fixture("avatar.png")),
+ filename: "avatar.png",
+ content_type: "image/png",
+ )
+ end
+
+ it "renders the figure with preview, caption, and actions adjacent" do
+ expect(html).to have_css("figure.govuk-attachment > img + figcaption + div.actions", visible: :all)
+ end
+ end
+ end
+end
diff --git a/spec/builders/govuk_design_system_form_builder/form_builder_image_field_spec.rb b/spec/builders/govuk_design_system_form_builder/form_builder_image_field_spec.rb
new file mode 100644
index 0000000..92f4583
--- /dev/null
+++ b/spec/builders/govuk_design_system_form_builder/form_builder_image_field_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe GOVUKDesignSystemFormBuilder::FormBuilder do
+ let(:builder) { described_class.new(:profile, profile, helper, {}) }
+ let(:profile) { create(:profile) }
+
+ def govuk_image_field(...)
+ Capybara.string(builder.govuk_image_field(...).to_s)
+ end
+
+ describe "#govuk_image_field" do
+ subject(:html) { govuk_image_field(:avatar) }
+
+ let(:blob) { profile.avatar.blob }
+
+ context "with no attachment" do
+ let(:profile) { Profile.new }
+
+ it "renders no attachment figures" do
+ expect(html).to have_no_css("figure.govuk-attachment")
+ end
+
+ it "renders a single file input inside the wrapper" do
+ expect(html).to have_css(".govuk-file-upload-wrapper input[type=file]", count: 1, visible: :all)
+ end
+
+ it "applies the image mime types to the input's accept attribute" do
+ expect(html).to have_css("input[type=file][accept*='image/png']", visible: :all)
+ end
+ end
+
+ # govuk_image_field delegates to govuk_attachment_field;
+ # the label, caption, hint and form_group configuration must reach it rather
+ # than being dropped on the way through.
+ context "with configuration options" do
+ it "renders the hint" do
+ html = govuk_image_field(:avatar, hint: { text: "Max 5MB" })
+
+ expect(html).to have_css(".govuk-hint", text: "Max 5MB")
+ end
+
+ it "describes the input by the hint" do
+ html = govuk_image_field(:avatar, hint: { text: "Max 5MB" })
+ hint_id = html.find(".govuk-hint", visible: :all)[:id]
+
+ expect(html.find("input[type=file]", visible: :all)["aria-describedby"]).to eq(hint_id)
+ end
+
+ it "renders the supplied label text" do
+ html = govuk_image_field(:avatar, label: { text: "Your photo" })
+
+ expect(html).to have_css("label", text: "Your photo")
+ end
+
+ it "renders the supplied caption" do
+ html = govuk_image_field(:avatar, caption: { text: "Step 1" })
+
+ expect(html).to have_css(".govuk-caption-m", text: "Step 1")
+ end
+
+ it "applies form_group options" do
+ html = govuk_image_field(:avatar, form_group: { class: "extra-group" })
+
+ expect(html).to have_css(".govuk-form-group.extra-group")
+ end
+ end
+
+ context "with a non-image attachment" do
+ before do
+ profile.avatar.attach(
+ io: StringIO.new("not an image"),
+ filename: "notes.txt",
+ content_type: "text/plain",
+ )
+ end
+
+ it "renders the figure with caption and actions adjacent" do
+ expect(html).to have_css("figure.govuk-attachment > figcaption + div.actions", visible: :all)
+ end
+
+ it "omits the preview image" do
+ expect(html).to have_no_css("figure.govuk-attachment img")
+ end
+ end
+ end
+end
diff --git a/spec/dummy/app/controllers/blocking_direct_uploads_controller.rb b/spec/dummy/app/controllers/blocking_direct_uploads_controller.rb
new file mode 100644
index 0000000..45f726d
--- /dev/null
+++ b/spec/dummy/app/controllers/blocking_direct_uploads_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+# A direct-uploads endpoint that holds each request until the test releases
+# it, so system tests can observe the transient uploading state and decide
+# how the request resolves: release with :ok to proceed normally, or with an
+# HTTP status (symbol or integer) to respond with that status instead. The
+# hold times out rather than hang the suite if a test forgets to release.
+class BlockingDirectUploadsController < ActiveStorage::DirectUploadsController
+ QUEUE = Queue.new
+
+ # Allows one held request to proceed.
+ def self.release(result = :ok)
+ QUEUE << result
+ end
+
+ # Fails every request currently held, for teardown: a test that navigates
+ # away mid-upload leaves its request blocked, and session reset would
+ # otherwise wait out the hold's full timeout.
+ def self.release_held
+ QUEUE.num_waiting.times { release(:gone) }
+ end
+
+ def create
+ result = QUEUE.pop(timeout: 5)
+
+ if result.nil? || result == :ok
+ super
+ else
+ head result
+ end
+ end
+end
diff --git a/spec/dummy/app/controllers/examples_controller.rb b/spec/dummy/app/controllers/examples_controller.rb
index 96ae873..ba5e1e9 100644
--- a/spec/dummy/app/controllers/examples_controller.rb
+++ b/spec/dummy/app/controllers/examples_controller.rb
@@ -25,12 +25,14 @@ class ExamplesController < ApplicationController
:old_department_id, :old_department_description, :laptop,
:other_language, :terms_and_conditions_agreed,
:address_one, :address_two, :address_three, :postcode, :profile_photo,
+ :avatar, :cv,
"date_of_birth(1i)", "date_of_birth(2i)", "date_of_birth(3i)",
"graduation_month(1i)", "graduation_month(2i)", "graduation_month(3i)",
"date_of_trade(1i)", "date_of_trade(2i)", "date_of_trade(3i)",
"time_of_birth(1i)", "time_of_birth(2i)", "time_of_birth(3i)",
"time_of_birth(4i)", "time_of_birth(5i)", "time_of_birth(6i)",
- { department_ids: [], lunch_ids: [], wednesday_lunch_ids: [], languages: [], countries: [] }
+ { department_ids: [], lunch_ids: [], wednesday_lunch_ids: [], languages: [], countries: [],
+ gallery: [] }
].freeze
def show
diff --git a/spec/dummy/app/controllers/profiles_controller.rb b/spec/dummy/app/controllers/profiles_controller.rb
index 96a0ec6..c747604 100644
--- a/spec/dummy/app/controllers/profiles_controller.rb
+++ b/spec/dummy/app/controllers/profiles_controller.rb
@@ -60,7 +60,9 @@ def profile_params
:name, :email, :bio, :active, :age, :status, :country,
:description, :avatar, :cv,
# govuk_date_field submits multiparameter date components
- "born_on(1i)", "born_on(2i)", "born_on(3i)"
+ "born_on(1i)", "born_on(2i)", "born_on(3i)",
+ # has_many_attached submits an array of uploads / signed ids
+ gallery: []
)
end
end
diff --git a/spec/dummy/app/javascript/application.js b/spec/dummy/app/javascript/application.js
index 6282160..e5d53e2 100644
--- a/spec/dummy/app/javascript/application.js
+++ b/spec/dummy/app/javascript/application.js
@@ -3,13 +3,3 @@ import "@hotwired/turbo-rails";
import "trix";
import "@rails/actiontext";
-
-// The page-load initAll() (see govuk_formbuilder_init) doesn't cover forms that
-// arrive later inside lazily-loaded example turbo frames, so re-initialise the
-// govuk-frontend components scoped to each frame as it loads.
-import { initAll } from "@katalyst/govuk-formbuilder";
-
-addEventListener("turbo:render", (event) => initAll({ scope: event.target }));
-addEventListener("turbo:frame-load", (event) =>
- initAll({ scope: event.target }),
-);
diff --git a/spec/dummy/app/javascript/controllers/index.js b/spec/dummy/app/javascript/controllers/index.js
index 72929fb..a4a3fc3 100644
--- a/spec/dummy/app/javascript/controllers/index.js
+++ b/spec/dummy/app/javascript/controllers/index.js
@@ -1,9 +1,12 @@
import { application } from "controllers/application";
-// Load the formbuilder stimulus controllers (image/document field previews).
-import govuk from "@katalyst/govuk-formbuilder";
+// The README's primary wiring under test: register the gem's controllers
+// (the attachment field's whole enhancement plus the legacy image/document
+// controllers) on the app's Stimulus application, with session-durable
+// page enhancement.
+import GOVUK from "@katalyst/govuk-formbuilder";
-application.load(govuk);
+GOVUK.start(application);
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading";
eagerLoadControllersFrom("controllers", application);
diff --git a/spec/dummy/app/models/profile.rb b/spec/dummy/app/models/profile.rb
index f4b9927..154bead 100644
--- a/spec/dummy/app/models/profile.rb
+++ b/spec/dummy/app/models/profile.rb
@@ -72,10 +72,11 @@ class Profile < ApplicationRecord
has_rich_text :description
has_one_attached :avatar
has_one_attached :cv
+ has_many_attached :gallery
COUNTRIES = ["Australia", "New Zealand", "United Kingdom", "Canada", "Ireland"].freeze
- validates :name, :email, presence: true
+ validates :name, :email, :avatar, presence: true
def to_s
name.presence || "New profile"
diff --git a/spec/dummy/app/views/examples/attachment/enhancement.html.erb b/spec/dummy/app/views/examples/attachment/enhancement.html.erb
new file mode 100644
index 0000000..611ef7c
--- /dev/null
+++ b/spec/dummy/app/views/examples/attachment/enhancement.html.erb
@@ -0,0 +1,21 @@
+<%# locals: (profile:) %>
+<%#
+ Test-support forms for the attachment field's JavaScript enhancement
+ (spec/system/attachment/enhancement_spec.rb). These configurations are not
+ exercised by the profile form: a field with a hint (so the input carries
+ aria-describedby to pass through to the injected button) and a disabled
+ field (so the button mirrors the disabled state).
+%>
+<%= turbo_frame_tag example_frame_id(params[:page], params[:example]) do %>
+ <%= form_with(model: profile, url: example_path(params[:page], params[:example])) do |f| %>
+ <%= f.govuk_image_field :avatar,
+ label: { text: "Profile photo" },
+ hint: { text: "Upload a clear colour photograph" } %>
+
+ <%= f.govuk_document_field :cv,
+ label: { text: "Curriculum vitae" },
+ disabled: true %>
+
+ <%= example_submit_buttons(f) %>
+ <% end %>
+<% end %>
diff --git a/spec/dummy/app/views/examples/attachment/i18n.html.erb b/spec/dummy/app/views/examples/attachment/i18n.html.erb
new file mode 100644
index 0000000..f789d1d
--- /dev/null
+++ b/spec/dummy/app/views/examples/attachment/i18n.html.erb
@@ -0,0 +1,28 @@
+<%# locals: (profile:) %>
+<%#
+ Test-support form for the attachment field's i18n text options
+ (spec/system/attachment/i18n_spec.rb). Every option is a sentinel value,
+ so an assertion that passes cannot have been met by the enhancement's
+ bundled English defaults.
+%>
+<%= turbo_frame_tag example_frame_id(params[:page], params[:example]) do %>
+ <%= form_with(model: profile, url: example_path(params[:page], params[:example])) do |f| %>
+ <%= f.govuk_attachment_field :gallery,
+ label: { text: "Gallery" },
+ choose_files_button_text: "XX pick files XX",
+ drop_instruction_text: "XX or drop XX",
+ no_file_chosen_text: "XX empty XX",
+ multiple_files_chosen_one_text: "XX one file: %{count} XX",
+ multiple_files_chosen_other_text: "XX many files: %{count} XX",
+ entered_drop_zone_text: "XX over zone XX",
+ left_drop_zone_text: "XX out of zone XX",
+ upload_succeeded_text: "XX stored XX",
+ upload_failed_text: "XX broken XX",
+ retry_button_text: "XX retry XX",
+ file_removed_text: "XX %{filename} gone XX",
+ remove_button_text: "XX bin %{filename} XX",
+ remove_button_content_text: "XX x XX" %>
+
+ <%= example_submit_buttons(f) %>
+ <% end %>
+<% end %>
diff --git a/spec/dummy/app/views/examples/attachment/multiple.html.erb b/spec/dummy/app/views/examples/attachment/multiple.html.erb
new file mode 100644
index 0000000..f6703ec
--- /dev/null
+++ b/spec/dummy/app/views/examples/attachment/multiple.html.erb
@@ -0,0 +1,10 @@
+<%# locals: (profile:) %>
+<%= turbo_frame_tag example_frame_id(params[:page], params[:example]) do %>
+ <%= form_with(model: profile, url: example_path(params[:page], params[:example])) do |f| %>
+ <%= f.govuk_image_field :gallery,
+ label: { text: "Gallery" },
+ hint: { text: "Add photographs to the gallery, as many as you like" } %>
+
+ <%= example_submit_buttons(f) %>
+ <% end %>
+<% end %>
diff --git a/spec/dummy/app/views/examples/attachment/single.html.erb b/spec/dummy/app/views/examples/attachment/single.html.erb
new file mode 100644
index 0000000..ea18941
--- /dev/null
+++ b/spec/dummy/app/views/examples/attachment/single.html.erb
@@ -0,0 +1,10 @@
+<%# locals: (profile:) %>
+<%= turbo_frame_tag example_frame_id(params[:page], params[:example]) do %>
+ <%= form_with(model: profile, url: example_path(params[:page], params[:example])) do |f| %>
+ <%= f.govuk_image_field :avatar,
+ label: { text: "Profile photo" },
+ hint: { text: "Upload a clear colour photograph" } %>
+
+ <%= example_submit_buttons(f) %>
+ <% end %>
+<% end %>
diff --git a/spec/dummy/app/views/guide/attachment.html.erb b/spec/dummy/app/views/guide/attachment.html.erb
new file mode 100644
index 0000000..386ae02
--- /dev/null
+++ b/spec/dummy/app/views/guide/attachment.html.erb
@@ -0,0 +1,14 @@
+Attachments
+
+
+ The attachment field is this gem's ActiveStorage extension — there is no
+ upstream page to mirror. Each example below is a rendered form loaded in its
+ own turbo frame: choose or drop files to see direct upload, previews, and
+ per-file keep/remove controls; submitting round-trips the selection as blob
+ signed ids.
+
+
+<%= guide_example("attachment", "single", "Single image attachment") %>
+<%= guide_example("attachment", "multiple", "Multiple image attachments") %>
+
+<%= link_to("Back to guide", root_path) %>
diff --git a/spec/dummy/app/views/guide/index.html.erb b/spec/dummy/app/views/guide/index.html.erb
index 13807fb..c422362 100644
--- a/spec/dummy/app/views/guide/index.html.erb
+++ b/spec/dummy/app/views/guide/index.html.erb
@@ -20,6 +20,11 @@
<%= link_to("Textarea", guide_page_path("textarea")) %>
+Extensions
+
+ <%= link_to("Attachments", guide_page_path("attachment")) %>
+
+
Building blocks
<%= link_to("Fieldsets", guide_page_path("fieldsets")) %>
diff --git a/spec/dummy/app/views/profiles/_form.html.erb b/spec/dummy/app/views/profiles/_form.html.erb
index be4590a..632cce1 100644
--- a/spec/dummy/app/views/profiles/_form.html.erb
+++ b/spec/dummy/app/views/profiles/_form.html.erb
@@ -13,6 +13,7 @@
<%= f.govuk_combobox :country, Profile::COUNTRIES %>
<%= f.govuk_rich_textarea :description %>
<%= f.govuk_image_field :avatar, optional: true %>
+ <%= f.govuk_image_field :gallery, optional: true %>
<%= f.govuk_document_field :cv, optional: true %>
<%= f.govuk_submit %>
diff --git a/spec/dummy/app/views/profiles/show.html.erb b/spec/dummy/app/views/profiles/show.html.erb
index 42414e6..f0ffdc5 100644
--- a/spec/dummy/app/views/profiles/show.html.erb
+++ b/spec/dummy/app/views/profiles/show.html.erb
@@ -11,6 +11,24 @@
<%= profile.country %>
Description
<%= profile.description %>
+ Avatar
+
+ <% if profile.avatar.attached? %>
+ <%= image_tag profile.avatar, alt: profile.name %>
+ <% end %>
+
+ Gallery
+
+ <% profile.gallery.each do |image| %>
+ <%= image_tag image, alt: image.filename.to_s %>
+ <% end %>
+
+ CV
+
+ <% if profile.cv.attached? %>
+ <%= link_to profile.cv.filename.to_s, profile.cv %>
+ <% end %>
+
<%= link_to("Edit", edit_profile_path(profile)) %>
diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb
index 539e994..a474e59 100644
--- a/spec/dummy/config/routes.rb
+++ b/spec/dummy/config/routes.rb
@@ -12,7 +12,10 @@
# its own request: a lazy GET to render it and a POST to round-trip it.
match "guide/:page/:example", to: "examples#show", as: :example,
via: %i[get post],
- constraints: { page: /[a-z_]+/, example: /[a-z_]+/ }
+ constraints: { page: /[a-z_]+/, example: /[a-z0-9_]+/ }
+
+ # System tests point fields here to hold direct uploads until released.
+ post "blocking_direct_uploads", to: "blocking_direct_uploads#create"
root to: "guide#index"
end
diff --git a/spec/factories/profiles.rb b/spec/factories/profiles.rb
index 8cad426..577f4d2 100644
--- a/spec/factories/profiles.rb
+++ b/spec/factories/profiles.rb
@@ -4,6 +4,7 @@
factory :profile do
name { "Ada Lovelace" }
email { "ada@example.com" }
+ avatar { Rack::Test::UploadedFile.new(file_fixture("avatar.png"), "image/png") }
bio { "Mathematician and writer." }
active { true }
born_on { Date.new(1815, 12, 10) }
diff --git a/spec/fixtures/files/banner.png b/spec/fixtures/files/banner.png
new file mode 100644
index 0000000..c5184a5
Binary files /dev/null and b/spec/fixtures/files/banner.png differ
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 4935753..8547def 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -20,12 +20,19 @@
end
RSpec.configure do |config|
- config.include FactoryBot::Syntax::Methods
-
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!
# Lets system tests attach fixtures with `file_fixture_upload("avatar.png")`.
config.file_fixture_path = File.expand_path("fixtures/files", __dir__)
+
+ config.define_derived_metadata(file_path: %r{spec/builders}) do |metadata|
+ metadata[:type] ||= :helper
+ end
+
+ # Remove active-storage uploads
+ config.after(:suite) do
+ FileUtils.rm_rf(Rails.application.root.join("tmp", "storage"))
+ end
end
diff --git a/spec/requests/profiles/avatar_spec.rb b/spec/requests/profiles/avatar_spec.rb
new file mode 100644
index 0000000..3c4ee07
--- /dev/null
+++ b/spec/requests/profiles/avatar_spec.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Updating Profile#avatar (has_one_attached, required — cv is the optional
+# counterpart) through the controller: the scalar counterpart to
+# gallery_spec.rb. The keep/remove select submits the blob's signed id to
+# retain; blank fails the presence validation rather than detaching.
+RSpec.describe "Updating a profile's avatar" do
+ include AttachmentRequestHelpers
+
+ let(:profile) { create(:profile) }
+
+ it "keeps the attachment when its signed id is submitted" do
+ patch profile_path(profile), params: { profile: { avatar: profile.avatar.signed_id } }
+
+ expect(profile.reload.avatar).to be_attached
+ end
+
+ it "rejects removal: blank fails the presence validation" do
+ patch profile_path(profile), params: { profile: { avatar: "" } }
+
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "rejects removal: blank keeps the stored file" do
+ patch profile_path(profile), params: { profile: { avatar: "" } }
+
+ expect(profile.reload.avatar).to be_attached
+ end
+
+ it "replaces when a different blob's signed id is submitted" do
+ blob = uploaded_blob(filename: "replacement.png")
+
+ patch profile_path(profile), params: { profile: { avatar: blob.signed_id } }
+
+ expect(profile.reload.avatar.blob).to eq(blob)
+ end
+
+ # A submit that fails validation re-renders the submitted signed id as a
+ # server figure with its keep option preserved — for a scalar field the
+ # last-submitted blob is the whole assignment, so a replacement supersedes
+ # the persisted avatar in the re-render.
+ describe "round-trip on an invalid submit" do
+ let(:uploaded) { uploaded_blob(filename: "updated.png") }
+
+ def submit_invalid
+ patch profile_path(profile),
+ params: { profile: { name: "", email: "ada@example.com", avatar: uploaded.signed_id } }
+ end
+
+ context "when the fresh upload replaces the persisted avatar" do
+ before { submit_invalid }
+
+ it "re-renders the replacement" do
+ expect(figure_for("updated.png")).to be_present
+ end
+
+ it "does not re-render the superseded avatar" do
+ expect(figure_for("avatar.png")).to be_nil
+ end
+ end
+ end
+
+ # Without JavaScript a file arrives as a real multipart upload; its bytes
+ # reach storage only on save, which the failing validation prevents, and
+ # browsers never repopulate file inputs — left alone, the upload would be
+ # lost. The field persists pending uploads when it renders, so the
+ # re-render turns them into figures whose signed ids round-trip like any
+ # direct upload: the user never loses the file, and the submitted set
+ # stays the truth (the superseded avatar is not resurrected, as with
+ # signed-id replacement above).
+ describe "multipart upload beside a failing validation" do
+ before do
+ patch profile_path(profile),
+ params: { profile: { name: "", email: "ada@example.com",
+ avatar: multipart_upload("updated.png") } }
+ end
+
+ it "responds unprocessable rather than failing to render" do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "re-renders the upload as a figure" do
+ expect(figure_for("updated.png")).to be_present
+ end
+
+ it "preserves the upload as a signed id that round-trips" do
+ signed_id = kept_signed_id(figure_for("updated.png"))
+
+ expect(ActiveStorage::Blob.find_signed(signed_id)&.filename&.to_s).to eq("updated.png")
+ end
+
+ it "does not re-render the superseded avatar" do
+ expect(figure_for("avatar.png")).to be_nil
+ end
+
+ it "leaves the stored avatar untouched (nothing was attached)" do
+ expect(profile.reload.avatar.filename.to_s).to eq("avatar.png")
+ end
+ end
+
+ # The GOV.UK error contract for an attachment attribute: the message
+ # renders inside the field's own form group, above the input, and the
+ # error summary links to the field.
+ describe "error placement on a blank required avatar" do
+ before { patch profile_path(profile), params: { profile: { avatar: "" } } }
+
+ def document
+ Nokogiri::HTML(response.body)
+ end
+
+ def avatar_form_group
+ document.css("div.govuk-form-group").find do |group|
+ group.at_css("input[name='profile[avatar]']")
+ end
+ end
+
+ it "marks the avatar field's form group as errored" do
+ expect(avatar_form_group["class"]).to include("govuk-form-group--error")
+ end
+
+ it "renders the validation message inside the form group" do
+ expect(avatar_form_group.at_css("p.govuk-error-message").text).to match(/blank/i)
+ end
+
+ it "renders the message above the input" do
+ first = avatar_form_group.css("p.govuk-error-message, input[name='profile[avatar]']").first
+
+ expect(first.name).to eq("p")
+ end
+
+ it "links the error summary entry to the avatar field" do
+ input = document.at_css("input[type=file][name='profile[avatar]']")
+
+ expect(document.at_css(".govuk-error-summary a[href='##{input[:id]}']").text).to match(/blank/i)
+ end
+ end
+end
diff --git a/spec/requests/profiles/cv_spec.rb b/spec/requests/profiles/cv_spec.rb
new file mode 100644
index 0000000..0740e60
--- /dev/null
+++ b/spec/requests/profiles/cv_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Updating Profile#cv (has_one_attached, optional — avatar is the required
+# counterpart). Optional scalar semantics live here: blank detaches, and a
+# fresh upload round-trips on a profile that has nothing attached yet.
+RSpec.describe "Updating a profile's cv" do
+ include AttachmentRequestHelpers
+
+ let(:profile) { create(:profile) }
+
+ def attach_cv
+ profile.cv.attach(io: File.open(file_fixture("cv.pdf")), filename: "cv.pdf", content_type: "application/pdf")
+ end
+
+ it "attaches an uploaded blob's signed id" do
+ blob = uploaded_blob(filename: "new-cv.png")
+
+ patch profile_path(profile), params: { profile: { cv: blob.signed_id } }
+
+ expect(profile.reload.cv.blob).to eq(blob)
+ end
+
+ it "detaches when blank is submitted" do
+ attach_cv
+
+ patch profile_path(profile), params: { profile: { cv: "" } }
+
+ expect(profile.reload.cv).not_to be_attached
+ end
+
+ describe "round-trip on an invalid submit" do
+ let(:uploaded) { uploaded_blob(filename: "new-cv.png") }
+
+ # A fresh upload arriving on a profile with no cv — the empty-scalar
+ # round-trip an always-attached avatar can't express.
+ before do
+ patch profile_path(profile),
+ params: { profile: { name: "", email: "ada@example.com", cv: uploaded.signed_id } }
+ end
+
+ it "re-renders the just-uploaded blob as a figure" do
+ expect(figure_for("new-cv.png")).to be_present
+ end
+
+ it "preserves its signed id as the keep option" do
+ expect(kept_signed_id(figure_for("new-cv.png"))).to eq(uploaded.signed_id)
+ end
+ end
+end
diff --git a/spec/requests/profiles/gallery_spec.rb b/spec/requests/profiles/gallery_spec.rb
new file mode 100644
index 0000000..d5d170f
--- /dev/null
+++ b/spec/requests/profiles/gallery_spec.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Exercises updating Profile#gallery (has_many_attached) through the controller.
+#
+# The gallery field round-trips as an array of `profile[gallery][]` values that
+# mirrors the rendered form: a leading blank entry, then one hidden input per
+# retained file carrying that blob's signed id, then the type=file input (empty
+# in these specs — new files arrive as signed ids from the async direct upload).
+#
+# Because has_many_attached replaces the whole collection on assignment, the set
+# submitted IS the resulting set:
+# * the blank keeps the param present so an otherwise-empty array still clears
+# * a signed id that's present is retained; one that's dropped is removed
+# * a brand-new signed id is added
+RSpec.describe "Updating a profile's gallery" do
+ include AttachmentRequestHelpers
+
+ let(:profile) { create(:profile) }
+
+ # Mirrors the submitted field order: blank first, then a signed id per file.
+ def gallery_params(*signed_ids)
+ { profile: { gallery: ["", *signed_ids] } }
+ end
+
+ # Attaches a file directly, as though it were saved on a previous request.
+ def attach_existing(filename: "avatar.png", fixture: file_fixture("avatar.png"), content_type: "image/png")
+ profile.gallery.attach(io: File.open(fixture), filename:, content_type:)
+ profile.gallery.blobs.find { |blob| blob.filename.to_s == filename }
+ end
+
+ describe "adding a single file (none => 1)" do
+ let(:blob) { uploaded_blob(filename: "added.png") }
+
+ it "attaches the newly uploaded blob" do
+ patch profile_path(profile), params: gallery_params(blob.signed_id)
+
+ expect(profile.reload.gallery.blobs).to contain_exactly(blob)
+ end
+
+ it "redirects back to the profile" do
+ patch profile_path(profile), params: gallery_params(blob.signed_id)
+
+ expect(response).to have_http_status(:see_other)
+ end
+ end
+
+ describe "removing the only file (1 => none)" do
+ it "detaches it when just the blank entry is submitted" do
+ attach_existing(filename: "existing.png")
+
+ expect do
+ patch profile_path(profile), params: gallery_params
+ end.to change { profile.reload.gallery.count }.from(1).to(0)
+ end
+ end
+
+ describe "adding one and removing another (1 => 1)" do
+ it "replaces the old blob with the new one" do
+ attach_existing(filename: "existing.png")
+ new_blob = uploaded_blob(filename: "added.png")
+
+ # The existing signed id is omitted (removed) and the new one submitted.
+ # contain_exactly is exhaustive: the new blob is the whole gallery.
+ patch profile_path(profile), params: gallery_params(new_blob.signed_id)
+
+ expect(profile.reload.gallery.blobs).to contain_exactly(new_blob)
+ end
+ end
+
+ describe "submitting an unchanged gallery (1 => 1)" do
+ it "keeps the same blob attached" do
+ existing = attach_existing(filename: "existing.png")
+
+ patch profile_path(profile), params: gallery_params(existing.signed_id)
+
+ expect(profile.reload.gallery.blobs).to contain_exactly(existing)
+ end
+ end
+
+ # Without JS there's no async direct upload, so files arrive as real multipart
+ # uploads through the type=file input, appended after the blank entry (and any
+ # retained signed ids) in the same profile[gallery][] array.
+ describe "no-JS fallback: uploading through the file input" do
+ it "adds a single file (none => 1)" do
+ patch profile_path(profile), params: gallery_params(fixture_file_upload("avatar.png", "image/png"))
+
+ expect(profile.reload.gallery.blobs.map { |blob| blob.filename.to_s }).to contain_exactly("avatar.png")
+ end
+
+ it "adds multiple files (none => 2)" do
+ uploads = [fixture_file_upload("avatar.png", "image/png"), fixture_file_upload("avatar.png", "image/png")]
+
+ expect do
+ patch profile_path(profile), params: gallery_params(*uploads)
+ end.to change { profile.reload.gallery.count }.from(0).to(2)
+ end
+ end
+
+ # A submit that fails validation re-renders every submitted signed id —
+ # persisted and not-yet-attached alike — as a server figure with its keep
+ # option preserved, so the user never re-uploads. Blank name fails presence,
+ # leaving the attachment assignment pending but unsaved.
+ describe "round-trip on an invalid submit" do
+ let!(:persisted) { attach_existing(filename: "persisted.png") }
+ let(:uploaded) { uploaded_blob(filename: "just-uploaded.png") }
+
+ def submit_invalid(*signed_ids)
+ patch profile_path(profile),
+ params: { profile: { name: "", email: "ada@example.com", gallery: ["", *signed_ids] } }
+ end
+
+ describe "keeping the persisted blob and adding a fresh upload" do
+ before { submit_invalid(persisted.signed_id, uploaded.signed_id) }
+
+ it "responds with unprocessable entity" do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "re-renders the persisted attachment as a figure" do
+ expect(figure_for("persisted.png")).to be_present
+ end
+
+ it "re-renders the just-uploaded, not-yet-attached blob as a figure" do
+ expect(figure_for("just-uploaded.png")).to be_present
+ end
+
+ it "preserves the just-uploaded blob's signed id as its keep option" do
+ expect(kept_signed_id(figure_for("just-uploaded.png"))).to eq(uploaded.signed_id)
+ end
+
+ it "leaves the stored gallery untouched (nothing was saved)" do
+ expect(profile.reload.gallery.blobs.map { |blob| blob.filename.to_s }).to contain_exactly("persisted.png")
+ end
+ end
+
+ describe "removing the persisted blob" do
+ # The persisted id is omitted (removed); only the fresh upload is submitted.
+ before { submit_invalid(uploaded.signed_id) }
+
+ it "does not re-render the removed attachment" do
+ expect(figure_for("persisted.png")).to be_nil
+ end
+
+ it "re-renders the just-uploaded blob that replaced it" do
+ expect(figure_for("just-uploaded.png")).to be_present
+ end
+ end
+
+ # Files arriving as multipart uploads (the no-JS path) have no bytes in
+ # storage until save, which the failing validation prevents — and
+ # browsers never repopulate file inputs, so left alone the upload would
+ # be lost. The field persists pending uploads when it renders, so they
+ # re-render as figures whose signed ids round-trip alongside the
+ # persisted entries.
+ describe "keeping the persisted blob and adding a multipart upload" do
+ before { submit_invalid(persisted.signed_id, multipart_upload("fresh.png")) }
+
+ it "responds unprocessable rather than failing to render" do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "re-renders the persisted attachment as a figure" do
+ expect(figure_for("persisted.png")).to be_present
+ end
+
+ it "re-renders the multipart upload as a figure" do
+ expect(figure_for("fresh.png")).to be_present
+ end
+
+ it "preserves the upload as a signed id that round-trips" do
+ signed_id = kept_signed_id(figure_for("fresh.png"))
+
+ expect(ActiveStorage::Blob.find_signed(signed_id)&.filename&.to_s).to eq("fresh.png")
+ end
+ end
+ end
+end
diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb
index c6e6d68..63650cc 100644
--- a/spec/requests/profiles_spec.rb
+++ b/spec/requests/profiles_spec.rb
@@ -37,10 +37,53 @@
it "emits the formbuilder javascript initialiser" do
expect(response.body).to include('import {initAll} from "@katalyst/govuk-formbuilder"')
end
+
+ it "omits the brand option when the brand is the default" do
+ expect(response.body).to include("initAll();")
+ end
+
+ context "with a non-default brand" do
+ around do |example|
+ GOVUKDesignSystemFormBuilder.brand = "defra"
+ get new_profile_path
+ example.run
+ ensure
+ GOVUKDesignSystemFormBuilder.brand = "govuk"
+ end
+
+ it "passes the brand to initAll" do
+ expect(response.body).to include('initAll({brand: "defra"});')
+ end
+ end
+ end
+
+ # A failed submit re-renders the form in standard GOV.UK error style: the
+ # summary lists the failure and the errored field shows its message. The
+ # attachment fields share this scaffolding (their round-trip specs live with
+ # the gallery/avatar request specs).
+ describe "POST /profiles with invalid params" do
+ before { post profiles_path, params: { profile: { name: "", email: "ada@example.com" } } }
+
+ it "responds with unprocessable entity" do
+ expect(response).to have_http_status(:unprocessable_content)
+ end
+
+ it "renders the validation message in the error summary" do
+ summary = Nokogiri::HTML(response.body).at_css(".govuk-error-summary")
+
+ expect(summary.text).to match(/can't be blank/i)
+ end
+
+ it "renders a field-level error message in GOV.UK error style" do
+ expect(Nokogiri::HTML(response.body).css("p.govuk-error-message")).to be_present
+ end
end
describe "POST /profiles" do
- let(:params) { { profile: { name: "Grace Hopper", email: "grace@example.com", status: "draft" } } }
+ let(:params) do
+ { profile: { name: "Grace Hopper", email: "grace@example.com", status: "draft",
+ avatar: fixture_file_upload("avatar.png", "image/png") } }
+ end
it "creates a profile" do
expect { post profiles_path, params: params }.to change(Profile, :count).by(1)
diff --git a/spec/support/attachment_field_helpers.rb b/spec/support/attachment_field_helpers.rb
new file mode 100644
index 0000000..567e582
--- /dev/null
+++ b/spec/support/attachment_field_helpers.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+# Finders and interactions for the profile form's attachment fields, shared
+# by the system specs in spec/system/attachment/.
+module AttachmentFieldHelpers
+ # The enhanced field (drop zone) wrapping the file input with this name.
+ def attachment_field(name)
+ find("[data-controller='govuk-file-upload']:has(input[type=file][name='#{name}'])")
+ end
+
+ def gallery_field
+ attachment_field("profile[gallery][]")
+ end
+
+ def avatar_field
+ attachment_field("profile[avatar]")
+ end
+
+ # The document (non-image) counterpart: cv is optional where avatar is
+ # required, and its figures render without a preview image.
+ def cv_field
+ attachment_field("profile[cv]")
+ end
+
+ # The upload button's status span is the field's polite live region: it
+ # carries the field's state — the file count.
+ def status_region(field)
+ field.find("button [aria-live]")
+ end
+
+ # The field's assertive live region: it carries event announcements (drag
+ # enter/leave, removals). Visually hidden, so found with visible: :all.
+ def announcements_region(field)
+ field.find(".govuk-file-upload-announcements", visible: :all)
+ end
+
+ # The file input is hidden once JavaScript enhances the field; Capybara
+ # needs it visible to attach a fixture through it.
+ def choose_file(name, fixture)
+ within(attachment_field(name)) do
+ attach_file(name, file_fixture(fixture).to_s, make_visible: true)
+ end
+ end
+
+ def choose_gallery_file(fixture)
+ choose_file("profile[gallery][]", fixture)
+ end
+
+ def choose_avatar_file(fixture)
+ choose_file("profile[avatar]", fixture)
+ end
+
+ def choose_cv_file(fixture)
+ choose_file("profile[cv]", fixture)
+ end
+end
diff --git a/spec/support/attachment_request_helpers.rb b/spec/support/attachment_request_helpers.rb
new file mode 100644
index 0000000..adb90a4
--- /dev/null
+++ b/spec/support/attachment_request_helpers.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+
+# Helpers for request specs that exercise attachment params and the figures a
+# failed submit re-renders.
+module AttachmentRequestHelpers
+ # A blob that exists but isn't attached — stands in for a file the browser
+ # has already direct-uploaded and injected as a select option, not yet saved.
+ def uploaded_blob(filename: "avatar.png", fixture: file_fixture("avatar.png"), content_type: "image/png")
+ ActiveStorage::Blob.create_and_upload!(
+ io: File.open(fixture),
+ filename:,
+ content_type:,
+ )
+ end
+
+ # A file arriving through the type=file input (the no-JS multipart path),
+ # named so specs can tell it apart from fixtures and persisted files.
+ def multipart_upload(filename, fixture: file_fixture("avatar.png"), content_type: "image/png")
+ Rack::Test::UploadedFile.new(fixture.open, content_type, original_filename: filename)
+ end
+
+ def figures
+ Nokogiri::HTML(response.body).css("figure.govuk-attachment")
+ end
+
+ def figure_for(filename)
+ figures.find { |figure| figure.css(".filename").text.include?(filename) }
+ end
+
+ # The signed id the figure would submit to keep the attachment: its select's
+ # selected option value.
+ def kept_signed_id(figure)
+ figure.at_css("select option[selected]")&.attr("value")
+ end
+end
diff --git a/spec/support/canonical_markup.rb b/spec/support/canonical_markup.rb
new file mode 100644
index 0000000..9701435
--- /dev/null
+++ b/spec/support/canonical_markup.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+# Canonical, diff-friendly renderings of captured DOM for the comparison
+# specs: one line per element (attributes sorted) or run of text, indented by
+# depth. The canonical form targets structure over incidentals:
+#
+# * id-valued attributes (`id`, `for`) and id-list references
+# (`aria-labelledby`, `aria-describedby`) are mapped to tokens in encounter
+# order — a comparison checks that elements are wired to each other the
+# same way, not that the generated ids match
+# * `name` is tokenised through its own table, so inputs sharing a name still
+# share a token while the value itself is ignored
+# * non-blank `value`s and `src` urls are tokenised: two captures never hold
+# the same blob
+# * whitespace in attribute values and text is collapsed
+#
+# Boolean attributes are NOT normalised: HTML treats `hidden=""` and
+# `hidden="hidden"` alike, but `hidden` is enumerated (`until-found` is a
+# distinct state) and every forgiven difference is a hole in the drift net —
+# implementations should match the reference spelling instead.
+module CanonicalMarkup
+ # Canonicalise one or more elements against a shared token table.
+ def canonical_markup(*nodes)
+ maps = Hash.new do |hash, kind|
+ hash[kind] = Hash.new { |map, value| map[value] = "[#{kind}-#{map.size + 1}]" }
+ end
+
+ nodes.compact.flat_map { |node| canonical_lines(node, maps) }.join("\n")
+ end
+
+ private
+
+ def canonical_lines(node, maps, depth = 0)
+ indent = " " * depth
+ attributes = node.attribute_nodes.sort_by(&:name).map do |attribute|
+ canonical_attribute(attribute, maps)
+ end
+
+ lines = ["#{indent}<#{[node.name, *attributes].join(' ')}>"]
+ node.children.each do |child|
+ if child.element?
+ lines.concat(canonical_lines(child, maps, depth + 1))
+ elsif child.text? && !child.text.squish.empty?
+ lines << "#{indent} #{child.text.squish}"
+ end
+ end
+
+ lines
+ end
+
+ def canonical_attribute(attribute, maps)
+ %(#{attribute.name}="#{canonical_value(attribute, maps)}")
+ end
+
+ def canonical_value(attribute, maps)
+ case attribute.name
+ when "id", "for" then maps["id"][attribute.value]
+ when "aria-labelledby", "aria-describedby"
+ attribute.value.split.map { |id| maps["id"][id] }.join(" ")
+ when "name" then maps["name"][attribute.value]
+ when "value" then attribute.value.empty? ? "" : "[value]"
+ when "src" then "[url]"
+ else attribute.value.squish
+ end
+ end
+end
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
index 65771fe..3ff1032 100644
--- a/spec/support/capybara.rb
+++ b/spec/support/capybara.rb
@@ -8,6 +8,7 @@
Capybara.server = :puma, { Silent: true }
Capybara.disable_animation = true
+ Capybara.enable_aria_label = true
# Rails will set `:selenium` as the runner for system tests by default, but this happens after `before` hooks.
# We want to use our configured javascript driver and ensure that this is set before
@@ -19,6 +20,10 @@
# required for docker (github-ci)
browser_options: { "no-sandbox": nil },
}
+
+ # Boot the shared browser outside any example so no spec's timing includes
+ # the first Chrome launch. The driver memoises it, so later groups no-op.
+ Capybara.current_session.driver.browser
end
config.include Capybara::RSpecMatchers, type: :request
diff --git a/spec/support/direct_upload_helpers.rb b/spec/support/direct_upload_helpers.rb
new file mode 100644
index 0000000..de25a01
--- /dev/null
+++ b/spec/support/direct_upload_helpers.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+# Steers where a form's direct uploads go, for system specs that need to
+# observe transient upload states or exercise failure paths against real
+# (unstubbed) XHRs. Call a helper before visiting the page — the endpoint is
+# rendered into the field's markup, not injected with JavaScript.
+#
+# Including this module restores the default form builder after each example.
+module DirectUploadHelpers
+ def self.included(base)
+ base.after do
+ BlockingDirectUploadsController.release_held
+ ApplicationController.default_form_builder(GOVUKDesignSystemFormBuilder::FormBuilder)
+ end
+ end
+
+ # Points direct uploads at a route that holds each request until released,
+ # so transient upload states are observable — the default endpoint can
+ # settle a small file before Capybara's first poll.
+ def block_direct_uploads
+ BlockingDirectUploadsController::QUEUE.clear
+ use_direct_upload_url("/blocking_direct_uploads")
+ end
+
+ # Releases one held direct upload: with :ok it proceeds normally, with an
+ # HTTP status the endpoint responds with that status instead.
+ def release_direct_uploads(result = :ok)
+ BlockingDirectUploadsController.release(result)
+ end
+
+ # Points direct uploads at a missing route so the create XHR fails.
+ def break_direct_uploads
+ use_direct_upload_url("/missing-direct-uploads")
+ end
+
+ # Renders fields without data-direct-upload-url, so the enhanced UI runs
+ # in no-upload mode: previews without uploads, multipart submission.
+ def disable_direct_uploads
+ use_direct_upload_url(nil)
+ end
+
+ # Swaps in a form builder that overrides direct_upload_url — the same
+ # extension point engine builders use.
+ def use_direct_upload_url(url)
+ builder = Class.new(GOVUKDesignSystemFormBuilder::FormBuilder) do
+ define_method(:direct_upload_url) { url }
+ end
+
+ ApplicationController.default_form_builder(builder)
+ end
+end
diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb
new file mode 100644
index 0000000..be46c16
--- /dev/null
+++ b/spec/support/factory_bot.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require "factory_bot_rails"
+
+FactoryBot.definition_file_paths << File.expand_path("../factories", __dir__)
+FactoryBot.reload
+
+RSpec.configure do |config|
+ config.include FactoryBot::Syntax::Methods
+
+ config.append_after do
+ FactoryBot.rewind_sequences
+ Faker::UniqueGenerator.clear
+ end
+end
diff --git a/spec/system/attachment/drag_and_drop_spec.rb b/spec/system/attachment/drag_and_drop_spec.rb
new file mode 100644
index 0000000..99e6b37
--- /dev/null
+++ b/spec/system/attachment/drag_and_drop_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Dropping files onto the attachment field, ported from govuk-frontend's
+# FileUpload with one intended difference: the drop target is the whole
+# wrapper, figures included, not just the button (upstream's button is its
+# whole zone; ours shares the wrapper with figures). A valid drag shows the
+# dragging state and is announced, and a drop fills the input exactly as
+# choosing files does. Drops are simulated with a synthetic DragEvent
+# carrying a DataTransfer, which exercises the real controller in the
+# browser (OS-level drag can't be driven from Capybara).
+RSpec.describe "Dropping files onto an attachment field", :aggregate_failures do
+ include AttachmentFieldHelpers
+ include DirectUploadHelpers
+
+ let(:profile) { create(:profile) }
+
+ it "uploads a dropped file like a chosen one" do
+ visit edit_profile_path(profile)
+
+ drop_files("profile[gallery][]", "avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+ expect(gallery_field).to have_css("figure.govuk-attachment .filename", text: "avatar.png")
+ end
+
+ context "without a direct-upload endpoint" do
+ before { disable_direct_uploads }
+
+ it "previews a dropped file" do
+ visit edit_profile_path(profile)
+
+ drop_files("profile[gallery][]", "avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment .filename", text: "avatar.png")
+ end
+
+ it "accepts several files dropped on a multiple field" do
+ visit edit_profile_path(profile)
+
+ drop_files("profile[gallery][]", "avatar.png", "cv.pdf")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment", count: 2)
+ end
+
+ it "rejects a multi-file drop on a single-file field" do
+ visit edit_profile_path(profile)
+
+ # cv: single-file and optional, so the field is verifiably empty after
+ # the rejected drop (avatar always carries its persisted figure).
+ drop_files("profile[cv]", "avatar.png", "cv.pdf")
+
+ expect(cv_field).to have_no_css("figure.govuk-attachment")
+ end
+
+ it "accepts a drop on a figure, away from the button" do
+ visit edit_profile_path(profile)
+
+ # avatar always carries its persisted figure, making the non-button
+ # surface of the drop zone real.
+ drop_files("profile[avatar]", "dropped.png", target: "figure.govuk-attachment")
+
+ expect(avatar_field).to have_css("figure.govuk-attachment .filename", text: "dropped.png")
+ end
+
+ it "ignores a drop on a disabled field" do
+ visit edit_profile_path(profile)
+
+ page.execute_script("document.querySelector(`input[type=file][name='profile[cv]']`).disabled = true")
+ drop_files("profile[cv]", "dropped.png")
+
+ expect(cv_field).to have_no_css("figure.govuk-attachment")
+ end
+ end
+
+ it "shows the dragging state and announces entering and leaving the drop zone" do
+ visit edit_profile_path(profile)
+
+ dispatch_drag("dragenter", gallery_drop_target)
+
+ expect(gallery_field).to have_css(".govuk-file-upload-button--dragging")
+ expect(announcements_region(gallery_field)).to have_text("Entered drop zone")
+
+ # A dragenter on an element outside the zone means the drag has left it.
+ dispatch_drag("dragenter", "body")
+
+ expect(gallery_field).to have_no_css(".govuk-file-upload-button--dragging")
+ expect(announcements_region(gallery_field)).to have_text("Left drop zone")
+ end
+
+ def gallery_drop_target
+ ".govuk-file-upload-wrapper:has(input[name='profile[gallery][]']) .govuk-file-upload-button"
+ end
+
+ # Dispatch a synthetic drop of `filenames` onto the field's wrapper — or
+ # onto `target`, a selector within it. The File contents are stub bytes —
+ # enough for a preview and a direct upload.
+ def drop_files(input_name, *filenames, target: nil)
+ page.execute_script(<<~JS, input_name, filenames, target)
+ const [name, names, target] = arguments;
+ const wrapper = document
+ .querySelector(`input[type=file][name="${name}"]`)
+ .closest(".govuk-file-upload-wrapper");
+ const element = target ? wrapper.querySelector(target) : wrapper;
+ const data = new DataTransfer();
+ names.forEach((n) => data.items.add(new File(["stub"], n, { type: "image/png" })));
+ element.dispatchEvent(
+ new DragEvent("drop", { dataTransfer: data, bubbles: true, cancelable: true }),
+ );
+ JS
+ end
+
+ # Dispatch a synthetic drag event (carrying one dragged file) on the element
+ # matching `selector`.
+ def dispatch_drag(type, selector)
+ page.execute_script(<<~JS, type, selector)
+ const [type, selector] = arguments;
+ const data = new DataTransfer();
+ data.items.add(new File(["stub"], "dragged.png", { type: "image/png" }));
+ document.querySelector(selector).dispatchEvent(
+ new DragEvent(type, { dataTransfer: data, bubbles: true, cancelable: true }),
+ );
+ JS
+ end
+end
diff --git a/spec/system/attachment/drop_zone_parity_spec.rb b/spec/system/attachment/drop_zone_parity_spec.rb
new file mode 100644
index 0000000..e28be91
--- /dev/null
+++ b/spec/system/attachment/drop_zone_parity_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The attachment field replaces govuk-frontend's FileUpload JavaScript,
+# borrowing its markup conventions and i18n strings: the field must look,
+# read, and announce like the component it replaces. govuk-frontend is the
+# reference implementation, so this spec enhances a plain govuk_file_field
+# with govuk-frontend's own JS beside our attachment field and compares the
+# two live drop zones. A govuk-frontend upgrade that changes the injected UI
+# — or a change of ours — surfaces as a diff instead of silent drift.
+#
+# The intended delta is scrubbed explicitly below; anything else is drift.
+RSpec.describe "Drop zone parity with govuk-frontend" do
+ include AttachmentFieldHelpers
+ include CanonicalMarkup
+
+ let(:profile) { create(:profile) }
+
+ it "builds the same drop zone govuk-frontend's FileUpload builds" do
+ visit example_path("file_upload", "javascript")
+
+ # govuk-frontend inserts its announcements region after the drop zone;
+ # capture both so the announcements comparison below sees it.
+ reference = find("[data-module='govuk-file-upload']:has(.govuk-file-upload-button)")
+ .evaluate_script("this.outerHTML + this.nextElementSibling.outerHTML")
+
+ visit example_path("attachment", "enhancement")
+
+ field = attachment_field("profile[avatar]")
+ field.find(".govuk-file-upload-button", wait: 5)
+ ours = field.evaluate_script("this.outerHTML")
+
+ expect(canonical_drop_zone(ours)).to eq(canonical_drop_zone(reference))
+ end
+
+ # Canonicalise a captured drop zone: the wrapper, intended differences
+ # scrubbed, with the announcements region in a fixed final position — ours
+ # renders inside the wrapper so scoped finds and morph re-enhancement keep
+ # it, govuk-frontend's sits just after the drop zone. Placement differs by
+ # design; presence and shape must not.
+ def canonical_drop_zone(html)
+ fragment = Nokogiri::HTML5.fragment(html)
+ announcements = fragment.at_css(".govuk-file-upload-announcements")&.unlink
+ wrapper = fragment.at_css("div")
+
+ scrub_enhancement_markers(wrapper)
+ scrub_attachment_extensions(wrapper)
+
+ canonical_markup(wrapper, announcements)
+ end
+
+ private
+
+ # The enhancement mechanism is the one wrapper-level difference:
+ # govuk-frontend enhances data-module (stamping an -init flag when done),
+ # our Stimulus controller enhances data-controller.
+ def scrub_enhancement_markers(wrapper)
+ wrapper.remove_attribute("data-module")
+ wrapper.remove_attribute("data-govuk-file-upload-init")
+ wrapper.remove_attribute("data-controller")
+ end
+
+ # The attachment field's extensions over a plain file upload: the blank
+ # keeper that lets a removed attachment detach, the direct-upload endpoint,
+ # and the accept filter the example's image field adds. Remove a line here
+ # to surface that difference in the diff instead.
+ def scrub_attachment_extensions(wrapper)
+ wrapper.css("input[type=hidden]").each(&:unlink)
+ wrapper.at_css("input[type=file]")&.remove_attribute("data-direct-upload-url")
+ wrapper.at_css("input[type=file]")&.remove_attribute("accept")
+ end
+end
diff --git a/spec/system/attachment/enhancement_spec.rb b/spec/system/attachment/enhancement_spec.rb
new file mode 100644
index 0000000..13264b1
--- /dev/null
+++ b/spec/system/attachment/enhancement_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The attachment field replaces govuk-frontend's FileUpload JavaScript, so its
+# accessibility contract is the compatibility bar. When the controller injects
+# the pseudo upload button it must, like FileUpload:
+#
+# * give the field label an id so the button's `aria-labelledby` resolves
+# (the label otherwise has only a `for`, and the button references an id
+# that would not exist)
+# * copy the input's `aria-describedby` (hint / error ids) onto the button,
+# so the same descriptions reach the control the user operates
+# * mirror the input's `disabled` state onto the button and wrapper, and
+# keep them in step when the input's `disabled` attribute changes at
+# runtime (a MutationObserver)
+#
+# These configurations are not present on the profile form, so they are driven
+# through a test-support example (examples/attachment/enhancement).
+RSpec.describe "Attachment field enhancement", :aggregate_failures do
+ include AttachmentFieldHelpers
+
+ let(:page_path) { example_path("attachment", "enhancement") }
+
+ it "gives the label an id so the button's accessible name resolves" do
+ visit page_path
+
+ field = attachment_field("profile[avatar]")
+ button = field.find(".govuk-file-upload-button", wait: 5)
+
+ # Every id the button is labelled by must resolve to an element on the
+ # page — no dangling reference.
+ button["aria-labelledby"].split.each do |id|
+ expect(page).to have_css("##{id}", visible: :all)
+ end
+
+ # ...and the first of those is the field's own label, which the enhancement
+ # has given an id.
+ expect(page).to have_css("label#profile-avatar-field-label", text: "Profile photo")
+ end
+
+ it "copies the input's aria-describedby onto the button" do
+ visit page_path
+
+ field = attachment_field("profile[avatar]")
+ button = field.find(".govuk-file-upload-button", wait: 5)
+ input = field.find("input[type=file]", visible: :all)
+
+ described = button["aria-describedby"]
+
+ expect(described).to eq(input["aria-describedby"])
+ # The referenced element is the field's hint, so the same description the
+ # input carried now describes the button.
+ expect(page).to have_css("##{described}", text: "Upload a clear colour photograph", visible: :all)
+ end
+
+ it "mirrors the input's disabled state onto the button and wrapper" do
+ visit page_path
+
+ field = attachment_field("profile[cv]")
+
+ expect(field).to have_css(".govuk-file-upload-button[disabled]", wait: 5)
+ expect(page).to have_css(
+ ".govuk-file-upload-wrapper--disabled:has(input[name='profile[cv]'])",
+ visible: :all,
+ )
+ end
+
+ it "keeps the button in step when the input is enabled at runtime" do
+ visit page_path
+
+ field = attachment_field("profile[cv]")
+ expect(field).to have_css(".govuk-file-upload-button[disabled]", wait: 5)
+
+ # A consumer (or a Turbo refresh) re-enables the input; the observer keeps
+ # the injected button and wrapper in step.
+ input = field.find("input[type=file]", visible: :all)
+ page.execute_script("arguments[0].disabled = false", input)
+
+ expect(field).to have_css(".govuk-file-upload-button:not([disabled])")
+ expect(page).to have_no_css(
+ ".govuk-file-upload-wrapper--disabled:has(input[name='profile[cv]'])",
+ visible: :all,
+ )
+ end
+end
diff --git a/spec/system/attachment/i18n_spec.rb b/spec/system/attachment/i18n_spec.rb
new file mode 100644
index 0000000..fe56c69
--- /dev/null
+++ b/spec/system/attachment/i18n_spec.rb
@@ -0,0 +1,137 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The builder's text options render as data-i18n.* attributes on the
+# wrapper, and the enhancement reads its strings from them, falling back to
+# its bundled defaults when they are absent. Driven through a test-support
+# example (examples/attachment/i18n) whose options are all sentinel values,
+# so a passing assertion cannot have been met by the English defaults.
+#
+# Locale (the closest `lang` attribute) feeds plural-form selection, but the
+# builder only renders one/other forms — every count resolves to one of them
+# in any locale, so locale handling has no observable surface to pin here.
+RSpec.describe "Attachment field i18n", :aggregate_failures do
+ include AttachmentFieldHelpers
+ include DirectUploadHelpers
+
+ let(:page_path) { example_path("attachment", "i18n") }
+
+ it "builds the upload button from the configured strings" do
+ visit page_path
+
+ field = attachment_field("profile[gallery][]")
+
+ expect(field).to have_css(".govuk-file-upload-button__pseudo-button", text: "XX pick files XX", wait: 5)
+ expect(field).to have_css(".govuk-file-upload-button__instruction", text: "XX or drop XX")
+ expect(field).to have_css(".govuk-file-upload-button__status", text: "XX empty XX")
+ end
+
+ it "counts a single chosen file with the one form" do
+ visit page_path
+
+ choose_gallery_files("avatar.png")
+
+ expect(status_region(attachment_field("profile[gallery][]"))).to have_text("XX one file: 1 XX")
+ end
+
+ it "counts several chosen files with the other form" do
+ visit page_path
+
+ choose_gallery_files("avatar.png", "cv.pdf")
+
+ expect(status_region(attachment_field("profile[gallery][]"))).to have_text("XX many files: 2 XX")
+ end
+
+ it "reports upload success with the configured string" do
+ visit page_path
+
+ choose_gallery_files("avatar.png")
+
+ figure = attachment_field("profile[gallery][]")
+ .find("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ expect(figure).to have_css("figcaption .status", text: "XX stored XX")
+ end
+
+ it "reports upload failure and offers retry with the configured strings" do
+ break_direct_uploads
+ visit page_path
+
+ choose_gallery_files("avatar.png")
+
+ figure = attachment_field("profile[gallery][]")
+ .find("figure.govuk-attachment[data-state=upload-failed]", wait: 10)
+
+ expect(figure).to have_css("figcaption .status", text: "XX broken XX")
+ expect(figure).to have_css(
+ "button[type=button][aria-label='XX retry XX avatar.png']",
+ text: "XX retry XX",
+ )
+ end
+
+ it "builds the figure's remove control from the configured strings" do
+ visit page_path
+
+ choose_gallery_files("avatar.png")
+
+ figure = attachment_field("profile[gallery][]").find("figure.govuk-attachment", wait: 10)
+
+ expect(figure).to have_css("button[aria-label='XX bin avatar.png XX']", text: "XX x XX")
+ expect(figure).to have_css("select option[value='']", text: "XX bin avatar.png XX", visible: :all)
+ end
+
+ it "announces a removal with the configured string, naming the file" do
+ visit page_path
+
+ choose_gallery_files("avatar.png")
+
+ field = attachment_field("profile[gallery][]")
+ field.find("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ field.find("button[aria-label='XX bin avatar.png XX']").click
+
+ expect(announcements_region(field)).to have_text("XX avatar.png gone XX")
+ end
+
+ it "announces the drop zone with the configured strings" do
+ visit page_path
+
+ field = attachment_field("profile[gallery][]")
+ field.find(".govuk-file-upload-button", wait: 5)
+
+ dispatch_drag("dragenter", drop_target)
+
+ expect(announcements_region(field)).to have_text("XX over zone XX")
+
+ # A dragenter outside the zone means the drag has left it.
+ dispatch_drag("dragenter", "body")
+
+ expect(announcements_region(field)).to have_text("XX out of zone XX")
+ end
+
+ def drop_target
+ ".govuk-file-upload-wrapper:has(input[name='profile[gallery][]']) .govuk-file-upload-button"
+ end
+
+ def choose_gallery_files(*fixtures)
+ paths = fixtures.map { |fixture| file_fixture(fixture).to_s }
+
+ within(attachment_field("profile[gallery][]")) do
+ attach_file("profile[gallery][]", paths, make_visible: true)
+ end
+ end
+
+ # Dispatch a synthetic drag event (carrying one dragged file) on the
+ # element matching `selector`.
+ def dispatch_drag(type, selector)
+ page.execute_script(<<~JS, type, selector)
+ const [type, selector] = arguments;
+ const data = new DataTransfer();
+ data.items.add(new File(["stub"], "dragged.png", { type: "image/png" }));
+ document.querySelector(selector).dispatchEvent(
+ new DragEvent(type, { dataTransfer: data, bubbles: true, cancelable: true }),
+ );
+ JS
+ end
+end
diff --git a/spec/system/attachment/markup_parity_spec.rb b/spec/system/attachment/markup_parity_spec.rb
new file mode 100644
index 0000000..54468a0
--- /dev/null
+++ b/spec/system/attachment/markup_parity_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# A JS-injected figure and a server-rendered figure for the same file should
+# be interchangeable: styling, the round-trip param shape, and re-enhancement
+# after a morph all assume one figure shape. This spec captures both from the
+# same live page — the server figure rendered for the persisted attachment,
+# and the client figure created by choosing the same fixture file — and
+# compares canonical forms.
+#
+# Canonicalisation (spec/support/canonical_markup.rb) makes the comparison
+# target structure, not incidentals — id wiring rather than id values, and
+# tokenised signed ids and URLs, since the two figures hold different blobs
+# by construction. On top of that, the upload lifecycle is scrubbed: only a
+# figure that lived through an upload carries data-state and status text, by
+# design.
+RSpec.describe "Attachment markup parity" do
+ include AttachmentFieldHelpers
+ include CanonicalMarkup
+
+ let(:profile) { create(:profile) }
+
+ it "renders the same figure for an upload as the server renders for its attachment" do
+ visit edit_profile_path(profile)
+
+ server_figure = avatar_field.find("figure.govuk-attachment").evaluate_script("this.outerHTML")
+
+ choose_avatar_file("avatar.png")
+
+ client_figure = avatar_field
+ .find("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+ .evaluate_script("this.outerHTML")
+
+ expect(canonical_figure(client_figure)).to eq(canonical_figure(server_figure))
+ end
+
+ def canonical_figure(html)
+ canonical_markup(scrub_upload_state(Nokogiri::HTML5.fragment(html).at_css("figure")))
+ end
+
+ private
+
+ # Only a figure that lived through an upload carries lifecycle state:
+ # server figures render with no data-state and an empty status span.
+ # Remove a line here to surface that difference in the diff instead.
+ def scrub_upload_state(figure)
+ figure.remove_attribute("data-state")
+ figure.at_css("figcaption .status")&.content = ""
+ figure
+ end
+end
diff --git a/spec/system/attachment/morph_spec.rb b/spec/system/attachment/morph_spec.rb
new file mode 100644
index 0000000..6e7de50
--- /dev/null
+++ b/spec/system/attachment/morph_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Turbo morph resilience for the attachment field, exercised through the
+# avatar (has_one_attached) field.
+#
+# The pseudo upload button, its status region, and the announcements region
+# are injected by the file-upload controller and are never server-rendered, so
+# a Turbo morph refresh — here the re-render of a failed update (the layout
+# opts into `turbo-refresh-method: morph`) — always strips them: the server
+# response contains none of that UI. Depending on whether the surrounding
+# structure changed (e.g. the error summary appearing), idiomorph either
+# destroys and recreates the drop zone (Stimulus disconnect/connect fires
+# instead of any morph event) or patches it in place (morph events, no
+# lifecycle events). The morph also strips the JS-set
+# `govuk-frontend-supported` class from ``, reverting the CSS gate to
+# the no-JS presentation page-wide. Whatever the path, the field must come
+# back working: supported marker restored, injected UI rebuilt, count
+# matching the server-rendered figures.
+RSpec.describe "Attachment field surviving a Turbo morph", :aggregate_failures do
+ include AttachmentFieldHelpers
+
+ # Attached under a distinct name so the replacement uploaded mid-test is
+ # distinguishable from it.
+ let(:profile) do
+ create(:profile).tap do |p|
+ p.avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "old-avatar.png", content_type: "image/png")
+ end
+ end
+
+ it "re-enhances the field after failed updates morph the page" do
+ visit edit_profile_path(profile)
+
+ # Enhanced on load: the injected button reports the one attached file.
+ expect(avatar_field).to have_css("button [aria-live]", text: "1 file chosen")
+
+ # First failed submit — the recreate path, not yet a morph-event test:
+ # inserting the error summary shifts the form groups onto each other's
+ # nodes, so idiomorph destroys and recreates the drop zone and
+ # disconnect/connect fires instead of any morph event. The field must be
+ # rebuilt from the degraded server markup.
+ fill_in "Name", with: ""
+ click_button "Continue"
+
+ expect(page).to have_css(".govuk-error-summary")
+
+ # The attachment comes back as a server-rendered figure whose preview
+ # points at the blob on the server (not a client-side data: URL).
+ expect(avatar_field).to have_css("figure.govuk-attachment .filename", exact_text: "old-avatar.png")
+ expect(avatar_field).to have_css("figure.govuk-attachment img[src*='/rails/active_storage']")
+ expect(avatar_field).to have_css("button [aria-live]", text: "1 file chosen")
+
+ # Upload a replacement so the next morph reconciles a field with real
+ # client-side changes — a client-inserted figure carrying the new blob's
+ # signed id — not just a re-render of what the server already knows.
+ choose_avatar_file("avatar.png")
+
+ expect(avatar_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ # Second failed submit — the patch path, the morph case proper. The
+ # summary already exists so the structures align, and the drop zone is
+ # patched in place: morph events fire but no Stimulus lifecycle events
+ # do, so re-enhancement cannot come from connect(). The name is still
+ # blank, so the submit fails again, but the replacement's signed id
+ # round-trips: the server re-renders it as the field's server figure.
+ click_button "Continue"
+
+ expect(page).to have_css(".govuk-error-summary")
+ expect(avatar_field).to have_css("figure.govuk-attachment .filename", exact_text: "avatar.png")
+ expect(avatar_field).to have_css("figure.govuk-attachment img[src*='/rails/active_storage']")
+ expect(avatar_field).to have_css("button [aria-live]", text: "1 file chosen")
+ end
+end
diff --git a/spec/system/attachment/no_upload_mode_spec.rb b/spec/system/attachment/no_upload_mode_spec.rb
new file mode 100644
index 0000000..7f6592d
--- /dev/null
+++ b/spec/system/attachment/no_upload_mode_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The enhanced field without a direct-upload endpoint (direct_upload: false,
+# or no route available): selection still previews and counts, but no upload
+# starts and nothing claims the files — they stay in the input and submit as
+# ordinary multipart.
+RSpec.describe "Attachment field without direct upload", :aggregate_failures do
+ include AttachmentFieldHelpers
+ include DirectUploadHelpers
+
+ let(:profile) { create(:profile) }
+
+ before do
+ disable_direct_uploads
+ end
+
+ it "previews the selection without starting an upload" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment .filename", text: "avatar.png")
+
+ # No upload lifecycle: the figure never enters an upload state.
+ expect(gallery_field).to have_no_css("figure.govuk-attachment[data-state]")
+ expect(gallery_field).to have_no_css("figure.govuk-attachment progress")
+ end
+
+ it "counts the selection in the status region" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ expect(status_region(gallery_field)).to have_text("1 file chosen")
+ end
+
+ it "submits the selection as ordinary multipart" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.gallery.blobs.map { |blob| blob.filename.to_s }).to eq(%w[avatar.png])
+ end
+
+ it "removes the file from the submission when its preview is removed" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+ # Scoped: the required avatar's own figure offers "Remove avatar.png" too.
+ within(gallery_field) { click_button "Remove avatar.png" }
+
+ expect(gallery_field).to have_no_css("figure.govuk-attachment")
+ expect(announcements_region(gallery_field)).to have_text("avatar.png removed")
+ expect(status_region(gallery_field)).to have_text("No file chosen")
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.gallery).not_to be_attached
+ end
+
+ # Browsers replace the FileList on re-selection, so previews of files no
+ # longer in the input must not linger (they would suggest files that won't
+ # submit).
+ it "replaces stale previews when the selection changes" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment", count: 1)
+
+ choose_gallery_file("cv.pdf")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment .filename", text: "cv.pdf")
+ expect(gallery_field).to have_no_css("figure.govuk-attachment .filename", text: "avatar.png")
+ expect(status_region(gallery_field)).to have_text("1 file chosen")
+ end
+end
diff --git a/spec/system/attachment/preview_spec.rb b/spec/system/attachment/preview_spec.rb
new file mode 100644
index 0000000..e1167f8
--- /dev/null
+++ b/spec/system/attachment/preview_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The preview box is square so figures line up down a list, and the image
+# fills it (`object-fit: cover`): centre-cropped, never squashed.
+# Client-inserted previews carry the image's natural dimensions (a data URL
+# of the chosen file), so they are the strict case — the server variant
+# arrives pre-cropped square (`resize_to_fill`).
+RSpec.describe "Attachment preview framing", :aggregate_failures do
+ include AttachmentFieldHelpers
+ include DirectUploadHelpers
+
+ let(:profile) { create(:profile) }
+
+ before { disable_direct_uploads }
+
+ # banner.png is 3:1.
+ it "fills the square preview box from a non-square image, undistorted" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("banner.png")
+
+ framing = gallery_field
+ .find("figure.govuk-attachment img.preview")
+ .evaluate_async_script(<<~JS)
+ const done = arguments[arguments.length - 1];
+ const measure = () => done({
+ box: this.clientWidth / this.clientHeight,
+ natural: this.naturalWidth / this.naturalHeight,
+ fit: getComputedStyle(this).objectFit,
+ });
+ this.complete && this.naturalWidth
+ ? measure()
+ : this.addEventListener("load", measure, { once: true });
+ JS
+
+ expect(framing["box"]).to be_within(0.01).of(1)
+ expect(framing["natural"]).to be_within(0.01).of(3)
+ expect(framing["fit"]).to eq("cover")
+ end
+
+ # Only image/* files can render as an img source. A non-image figure
+ # carries no preview img at all — the same shape the server renders for
+ # a non-representable blob.
+ it "renders a non-image figure without a preview img" do
+ visit edit_profile_path(profile)
+
+ choose_cv_file("cv.pdf")
+
+ expect(cv_field).to have_css("figure.govuk-attachment", count: 1)
+ expect(cv_field).to have_no_css("figure.govuk-attachment img")
+ end
+end
diff --git a/spec/system/attachment/remove_spec.rb b/spec/system/attachment/remove_spec.rb
new file mode 100644
index 0000000..4ac3b0f
--- /dev/null
+++ b/spec/system/attachment/remove_spec.rb
@@ -0,0 +1,122 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Removing an attachment figure with JavaScript. Together the examples pin
+# the behaviour of the remove control:
+#
+# * each figure offers a remove control named after its file
+# * removing deletes the figure from the DOM so its signed id no longer
+# submits; Rails' auto-blank still clears an emptied has_many
+# * removing a has_one's figure still submits the detach — removing with
+# JavaScript equals choosing the remove option without it, so a required
+# attachment surfaces its presence error instead of silently surviving
+# * focus moves to the field's upload button, whose status summarises
+# what remains — never lost to
+# * removal is announced through the field's announcements region, naming
+# the file — the figure's own live region disappears with the figure
+RSpec.describe "Removing an attachment", :aggregate_failures do
+ include AttachmentFieldHelpers
+
+ let(:profile) do
+ create(:profile).tap do |p|
+ %w[first.png second.png].each do |filename|
+ p.gallery.attach(io: File.open(file_fixture("avatar.png")), filename:, content_type: "image/png")
+ end
+ end
+ end
+
+ it "offers a remove control on each figure whose accessible name includes the filename" do
+ visit edit_profile_path(profile)
+
+ %w[first.png second.png].each do |filename|
+ within(".govuk-attachment", text: filename) do
+ expect(page).to have_button("Remove #{filename}")
+ end
+ end
+ end
+
+ it "removes the figure so its value no longer submits" do
+ visit edit_profile_path(profile)
+
+ click_button "Remove first.png"
+
+ expect(page).to have_no_css(".govuk-attachment", text: "first.png")
+ expect(page).to have_css(".govuk-attachment", text: "second.png")
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.gallery.blobs.map { |blob| blob.filename.to_s }).to eq(%w[second.png])
+ end
+
+ it "clears the association when every figure is removed" do
+ visit edit_profile_path(profile)
+
+ click_button "Remove first.png"
+ click_button "Remove second.png"
+
+ # Scoped: the required avatar keeps its own figure on the page.
+ expect(gallery_field).to have_no_css(".govuk-attachment")
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.gallery).not_to be_attached
+ end
+
+ it "detaches a removed has_one on submit, surfacing the required error" do
+ visit edit_profile_path(profile)
+
+ click_button "Remove avatar.png"
+
+ expect(avatar_field).to have_no_css(".govuk-attachment")
+
+ click_button "Continue"
+
+ expect(page).to have_css(".govuk-error-summary a", text: /blank/i)
+ expect(page).to have_css(
+ ".govuk-form-group--error:has(input[name='profile[avatar]']) p.govuk-error-message",
+ text: /blank/i,
+ )
+ end
+
+ it "detaches a removed optional has_one on submit" do
+ profile.cv.attach(io: File.open(file_fixture("cv.pdf")), filename: "cv.pdf", content_type: "application/pdf")
+ visit edit_profile_path(profile)
+
+ click_button "Remove cv.pdf"
+
+ expect(cv_field).to have_no_css(".govuk-attachment")
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.cv).not_to be_attached
+ end
+
+ it "moves focus to the field's upload button" do
+ visit edit_profile_path(profile)
+ on_upload_button = %(document.activeElement.matches("button#profile-gallery-field"))
+
+ click_button "Remove first.png"
+
+ # Mid-gallery and last removal alike: the upload button's status is the
+ # summary of what remains, and its focus reading carries the updated
+ # count — a sibling figure's reading would not. Never , and not
+ # some other field's input.
+ expect(page.evaluate_script(on_upload_button)).to be(true)
+
+ click_button "Remove second.png"
+
+ expect(page.evaluate_script(on_upload_button)).to be(true)
+ end
+
+ it "announces the removal, naming the file" do
+ visit edit_profile_path(profile)
+
+ click_button "Remove first.png"
+
+ expect(announcements_region(gallery_field)).to have_text("first.png removed")
+ end
+end
diff --git a/spec/system/attachment/replace_spec.rb b/spec/system/attachment/replace_spec.rb
new file mode 100644
index 0000000..672060d
--- /dev/null
+++ b/spec/system/attachment/replace_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Replacing a has_one_attached file with JavaScript, exercised through the
+# avatar field on the profile form.
+#
+# Every figure's select posts the same scalar param (profile[avatar]) and the
+# last one wins, so uploading a replacement appends a new figure rather than
+# editing the old one: CSS hides every figure but the last, and the original
+# stays in the DOM so removing the replacement reveals it again — reverting
+# is free.
+RSpec.describe "Replacing an attachment", :aggregate_failures do
+ include AttachmentFieldHelpers
+ include DirectUploadHelpers
+
+ let(:profile) do
+ create(:profile).tap do |p|
+ p.avatar.attach(io: File.open(file_fixture("avatar.png")), filename: "old-avatar.png", content_type: "image/png")
+ end
+ end
+
+ it "supersedes the existing figure, leaving one visible" do
+ visit edit_profile_path(profile)
+
+ choose_avatar_file("avatar.png")
+
+ expect(avatar_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ # Both figures stay in the DOM (the original is what makes revert
+ # possible), but only the replacement is shown.
+ expect(avatar_field).to have_css("figure.govuk-attachment", count: 2, visible: :all)
+ expect(avatar_field).to have_css("figure.govuk-attachment", count: 1)
+ expect(avatar_field).to have_css("figure.govuk-attachment .filename", exact_text: "avatar.png")
+ expect(avatar_field).to have_css("figure.govuk-attachment", text: "old-avatar.png", visible: :hidden)
+ end
+
+ it "saves the replacement" do
+ visit edit_profile_path(profile)
+
+ choose_avatar_file("avatar.png")
+
+ expect(avatar_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.avatar.filename.to_s).to eq("avatar.png")
+ end
+
+ it "clears optional inputs when a replacement is submitted during upload" do
+ profile.cv.attach(io: File.open(file_fixture("cv.pdf")), filename: "cv.pdf", content_type: "application/pdf")
+ block_direct_uploads
+ visit edit_profile_path(profile)
+
+ choose_cv_file("cv.pdf")
+
+ expect(cv_field).to have_css("figure.govuk-attachment[data-state=uploading]")
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.cv).not_to be_attached
+ end
+
+ it "keeps stored files with errors when a replacement is submitted during upload" do
+ block_direct_uploads
+ visit edit_profile_path(profile)
+
+ choose_avatar_file("avatar.png")
+
+ expect(avatar_field).to have_css("figure.govuk-attachment[data-state=uploading]")
+
+ click_button "Continue"
+
+ expect(page).to have_css(
+ ".govuk-form-group--error:has(input[name='profile[avatar]']) p.govuk-error-message",
+ text: /blank/i,
+ )
+ expect(profile.reload.avatar.filename.to_s).to eq("old-avatar.png")
+ end
+
+ it "reverts to the original when the replacement is removed" do
+ visit edit_profile_path(profile)
+
+ choose_avatar_file("avatar.png")
+
+ expect(avatar_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ click_button "Remove avatar.png"
+
+ expect(avatar_field).to have_css("figure.govuk-attachment .filename", exact_text: "old-avatar.png")
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.avatar.filename.to_s).to eq("old-avatar.png")
+ end
+end
diff --git a/spec/system/attachment/round_trip_spec.rb b/spec/system/attachment/round_trip_spec.rb
new file mode 100644
index 0000000..a1b44de
--- /dev/null
+++ b/spec/system/attachment/round_trip_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# End-to-end round-trip of a direct-uploaded attachment across an invalid
+# submit, exercised through the gallery (has_many_attached) field.
+#
+# A file is direct-uploaded with JavaScript (its figure reaches
+# upload-successful, carrying the new blob's signed id), then the form is made
+# invalid and submitted. The server re-renders the form with the just-uploaded
+# blob as a plain server-rendered figure — no upload state, its signed id
+# preserved so it still submits — so the user never re-uploads. The failure
+# also renders in the standard GOV.UK error summary.
+RSpec.describe "Attachment round-trip across an invalid submit", :aggregate_failures do
+ include AttachmentFieldHelpers
+
+ let(:profile) { create(:profile) }
+
+ it "re-renders the uploaded blob as a server figure and shows the error summary" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ signed_id = gallery_field.find(
+ "figure.govuk-attachment select[name='profile[gallery][]'] option:first-of-type",
+ visible: :all,
+ ).value
+
+ # Invalidate the form so the submit fails and re-renders.
+ fill_in "Name", with: ""
+ click_button "Continue"
+
+ # The failed submit re-renders the form in place (Turbo keeps the edit URL).
+ expect(page).to have_css(".govuk-error-summary")
+
+ # The uploaded blob comes back as a server-rendered figure: it names the
+ # file and carries no upload state (it is not re-uploaded), and its keep
+ # option still holds the signed id so it submits unchanged.
+ expect(gallery_field).to have_css("figure.govuk-attachment .filename", text: "avatar.png")
+ expect(gallery_field).to have_no_css("figure.govuk-attachment[data-state]")
+ expect(gallery_field).to have_css(
+ "figure.govuk-attachment select[name='profile[gallery][]'] option[value='#{signed_id}']",
+ visible: :all,
+ )
+
+ # The preserved figure really does submit: fix the form and the blob attaches.
+ fill_in "Name", with: "Ada Lovelace"
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.gallery.blobs.map { |blob| blob.filename.to_s }).to eq(%w[avatar.png])
+ end
+end
diff --git a/spec/system/attachment/status_spec.rb b/spec/system/attachment/status_spec.rb
new file mode 100644
index 0000000..11dbe7f
--- /dev/null
+++ b/spec/system/attachment/status_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The upload button's status region describes the field's contents using
+# govuk-frontend's FileUpload i18n strings: "No file chosen" when the field
+# is empty, else the count of attachment figures plus any files held in the
+# FileList ("2 files chosen"). The region carries only this state — event
+# announcements (e.g. removals) go to the assertive announcements region.
+RSpec.describe "Attachment field status", :aggregate_failures do
+ include AttachmentFieldHelpers
+
+ let(:profile) { create(:profile) }
+
+ it "reports an empty field as having no file chosen" do
+ visit edit_profile_path(profile)
+
+ expect(status_region(gallery_field)).to have_text("No file chosen")
+ end
+
+ it "reports an empty has_one field as having no file chosen" do
+ visit edit_profile_path(profile)
+
+ expect(status_region(cv_field)).to have_text("No file chosen")
+ end
+
+ it "counts existing attachments on load" do
+ attach_gallery_files
+ visit edit_profile_path(profile)
+
+ expect(status_region(gallery_field)).to have_text("2 files chosen")
+ end
+
+ it "counts a newly uploaded file" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+ expect(status_region(gallery_field)).to have_text("1 file chosen")
+ end
+
+ it "recounts after a removal" do
+ attach_gallery_files
+ visit edit_profile_path(profile)
+
+ click_button "Remove first.png"
+
+ expect(status_region(gallery_field)).to have_text("1 file chosen")
+ end
+
+ def attach_gallery_files
+ %w[first.png second.png].each do |filename|
+ profile.gallery.attach(io: File.open(file_fixture("avatar.png")), filename:, content_type: "image/png")
+ end
+ end
+end
diff --git a/spec/system/attachment/upload_spec.rb b/spec/system/attachment/upload_spec.rb
new file mode 100644
index 0000000..adf6416
--- /dev/null
+++ b/spec/system/attachment/upload_spec.rb
@@ -0,0 +1,186 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# Direct-upload lifecycle for the attachment field, exercised through the
+# gallery (has_many_attached) field on the profile form.
+#
+# The figure's `data-state` attribute is the observable surface, so the
+# implementation is free to change markup/classes without breaking the
+# behavioural contract:
+#
+# * choosing a file immediately inserts a preview figure in an
+# `uploading` state
+# * on direct-upload success the figure reaches `upload-successful` and
+# its select's first option carries the new blob's signed id
+# * on failure the figure reaches `upload-failed` with a human-readable
+# message and retry/remove; a failed figure never submits a signed id
+#
+RSpec.describe "Async file upload", :aggregate_failures do
+ include AttachmentFieldHelpers
+ include DirectUploadHelpers
+
+ let(:profile) { create(:profile) }
+
+ it "previews the chosen file immediately, in an uploading state" do
+ block_direct_uploads
+ visit edit_profile_path(profile)
+
+ expect(gallery_field).to have_field("profile[gallery][]", type: :file, visible: :all)
+ expect(gallery_field).to have_no_css("figure.govuk-attachment")
+
+ choose_gallery_file("avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=uploading] .filename", text: "avatar.png")
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=uploading] progress")
+
+ release_direct_uploads
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ # The progress bar is removed and the caption's live region announces the
+ # result, so screen readers hear which file finished.
+ expect(gallery_field).to have_no_css("figure.govuk-attachment progress")
+ expect(gallery_field).to have_css("figure.govuk-attachment figcaption .status", text: /uploaded/i)
+ end
+
+ it "names the progress bar after the file it reports" do
+ block_direct_uploads
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ progress = gallery_field.find("figure.govuk-attachment progress")
+ referenced = progress["aria-labelledby"].to_s.split.map { |id| page.find(id: id, visible: :all).text }
+
+ expect(referenced.join(" ")).to eq("avatar.png")
+
+ release_direct_uploads
+ end
+
+ it "reaches upload-successful with the blob's signed id in the select" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ # Generous wait: a real XHR to the direct-uploads endpoint plus a blob PUT.
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ signed_id = gallery_field.find(
+ "figure.govuk-attachment select[name='profile[gallery][]'] option:first-of-type",
+ visible: :all,
+ ).value
+ expect(ActiveStorage::Blob.find_signed(signed_id)&.filename&.to_s).to eq("avatar.png")
+ end
+
+ # The figure claims the file from the input's FileList when its upload
+ # starts, so submitting must attach the blob once — not again as multipart.
+ it "attaches an uploaded file exactly once on submit" do
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ click_button "Continue"
+
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.gallery.blobs.map { |blob| blob.filename.to_s }).to eq(%w[avatar.png])
+ end
+
+ it "reaches upload-failed with a human-readable message, and never submits a signed id" do
+ break_direct_uploads
+ visit edit_profile_path(profile)
+
+ # Record alert calls: ActiveStorage's dispatchError falls back to window.alert
+ page.execute_script("window.alertCalls = []; window.alert = (message) => window.alertCalls.push(message)")
+
+ choose_gallery_file("avatar.png")
+
+ figure = gallery_field.find("figure.govuk-attachment[data-state=upload-failed]", wait: 10)
+
+ # A friendly message, not DirectUpload's raw error/alert.
+ expect(figure.text).to include("Upload failed")
+ expect(figure.text).not_to include("Error")
+ expect(page.evaluate_script("window.alertCalls")).to eq([])
+
+ # The user can recover from the error: remove, or retry the upload with
+ # the file the figure still holds.
+ expect(figure).to have_css("button[aria-label*='Remove']")
+ expect(figure).to have_css("button[type=button][aria-label='Try again avatar.png']", text: "Try again")
+
+ # Both figure controls follow govuk-frontend's button conventions (the
+ # password toggle's markup shape).
+ expect(figure).to have_button(class: %w[govuk-button govuk-button--secondary govuk-attachment__retry])
+
+ click_button "Continue"
+
+ # Submitting without clearing the error does not save the file
+ expect(page).to have_current_path(profile_path(profile))
+ expect(profile.reload.gallery).not_to be_attached
+ end
+
+ it "retries a failed upload with the file the figure holds" do
+ block_direct_uploads
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+ release_direct_uploads(:internal_server_error)
+
+ figure = gallery_field.find("figure.govuk-attachment[data-state=upload-failed]", wait: 10)
+
+ click_button "Try again"
+
+ # Retry re-enters the standard uploading lifecycle; the retry control
+ # leaves with the failed state.
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=uploading] progress")
+ expect(figure).to have_no_button("Try again")
+
+ release_direct_uploads(:ok)
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-successful]", wait: 10)
+
+ signed_id = gallery_field.find(
+ "figure.govuk-attachment select[name='profile[gallery][]'] option:first-of-type",
+ visible: :all,
+ ).value
+ expect(ActiveStorage::Blob.find_signed(signed_id)&.filename&.to_s).to eq("avatar.png")
+ end
+
+ it "moves focus to the figure's remove button when retry starts" do
+ block_direct_uploads
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+ release_direct_uploads(:internal_server_error)
+
+ gallery_field.find("figure.govuk-attachment[data-state=upload-failed]", wait: 10)
+
+ click_button "Try again"
+
+ # Retrying removes the retry control from under the user; focus moves to
+ # the figure's remove button — the figure's only control while uploading
+ # — rather than dropping to .
+ expect(
+ page.evaluate_script("document.activeElement.getAttribute('aria-label')"),
+ ).to eq("Remove avatar.png")
+
+ release_direct_uploads(:ok)
+ end
+
+ it "offers a single retry control however many attempts fail" do
+ block_direct_uploads
+ visit edit_profile_path(profile)
+
+ choose_gallery_file("avatar.png")
+ release_direct_uploads(:internal_server_error)
+
+ gallery_field.find("figure.govuk-attachment[data-state=upload-failed]", wait: 10)
+
+ click_button "Try again"
+ release_direct_uploads(:internal_server_error)
+
+ expect(gallery_field).to have_css("figure.govuk-attachment[data-state=upload-failed]", wait: 10)
+ expect(gallery_field).to have_css("button[type=button][aria-label='Try again avatar.png']", count: 1)
+ end
+end
diff --git a/spec/system/frontend_enhancements_spec.rb b/spec/system/frontend_enhancements_spec.rb
index 2fd5510..2a9b982 100644
--- a/spec/system/frontend_enhancements_spec.rb
+++ b/spec/system/frontend_enhancements_spec.rb
@@ -3,11 +3,11 @@
require "rails_helper"
# Exercises the GOV.UK Frontend javascript that the gem bundles and wires up.
-# `initAll` (see Frontend#govuk_formbuilder_init) instantiates the upstream
-# components on page load, and the dummy app re-runs it on `turbo:frame-load`
-# (spec/dummy/app/javascript/application.js) so components inside the guide's
-# lazily-loaded example frames are enhanced too. Each example below lives in
-# its own lazy turbo frame, so a passing assertion proves both paths.
+# `govuk_formbuilder_init` enhances the page on load and observes , so
+# components arriving later — such as the guide's lazily-loaded example
+# frames — are enhanced as they land, with no wiring in the consuming app.
+# Each example below lives in its own lazy turbo frame, so a passing
+# assertion proves the arrival path, not just page load.
RSpec.describe "GOV.UK Frontend javascript enhancements" do
it "enhances a password input with a working show/hide toggle", :aggregate_failures do
visit guide_page_path("password_input")
@@ -48,4 +48,64 @@
expect(page).to have_button("Choose file")
end
end
+
+ it "stays enhanced across a Turbo visit" do
+ visit guide_page_path("textarea")
+
+ within("#textarea__max_chars_and_threshold") do
+ fill_in "profile[education_history]", with: "hello"
+ expect(page).to have_css(".govuk-character-count__status", text: "5 characters remaining", wait: 5)
+ end
+
+ # A Turbo Drive visit replaces , discarding the body-scoped
+ # observers and guard; enhancement must re-establish on the new body
+ # regardless of whether the init snippet's call is a no-op.
+ click_link "Back to guide"
+ click_link "Password input"
+
+ within("#password_input__default") do
+ toggle = find(".govuk-password-input__toggle", wait: 5)
+ expect(toggle).to be_visible
+ end
+ end
+
+ it "starts from the default export, so bundle consumers need one import" do
+ visit guide_page_path("password_input")
+
+ within("#password_input__default") do
+ find(".govuk-password-input__toggle", wait: 5)
+ end
+
+ # Bundle consumers wire everything through the default export:
+ # GOVUK.start(application). Called here without an application (and
+ # after the page's own start), it must be a harmless no-op.
+ started = page.evaluate_async_script(<<~JS)
+ const done = arguments[0];
+ import("@katalyst/govuk-formbuilder").then((m) => {
+ if (typeof m.default.start !== "function") return done(false);
+ m.default.start();
+ done(true);
+ });
+ JS
+
+ expect(started).to be(true)
+ end
+
+ it "tolerates repeated initAll calls without duplicating enhancements" do
+ visit guide_page_path("password_input")
+
+ within("#password_input__default") do
+ find(".govuk-password-input__toggle", wait: 5)
+ end
+
+ # The layout's init snippet already ran; further calls are no-ops.
+ page.evaluate_async_script(<<~JS)
+ const done = arguments[0];
+ import("@katalyst/govuk-formbuilder").then((m) => { m.initAll(); m.initAll(); done(true); });
+ JS
+
+ within("#password_input__default") do
+ expect(page).to have_css(".govuk-password-input__toggle", count: 1)
+ end
+ end
end
diff --git a/spec/system/frontend_morph_spec.rb b/spec/system/frontend_morph_spec.rb
new file mode 100644
index 0000000..5bfe93b
--- /dev/null
+++ b/spec/system/frontend_morph_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+# The `govuk_formbuilder_init` snippet at the end of runs the bundle's
+# initAll, which marks the page as JS-capable (body classes js-enabled /
+# govuk-frontend-supported), enhances it, and observes . Turbo
+# re-executes body scripts on a replace render, but a morph retains the live
+# script node, so the snippet never re-runs: the incoming server body carries
+# no class attribute, the morph strips the JS-set markers, and no lifecycle
+# event fires. The bundle's marker-restore observer is the recovery — missing
+# markers signal the morph; re-mark, then re-sweep. These examples pin that
+# recovery: support markers and component initialisation must survive morphs.
+#
+# A failed profile update is the morph driver: the layout opts into
+# `turbo-refresh-method: morph`, so the 422 re-render morphs in place. The
+# first failure also inserts the error summary (a structure change); a
+# second failure patches the existing summary in place — the two shapes a
+# morph can take.
+RSpec.describe "GOV.UK Frontend javascript re-initialising after a morph", :aggregate_failures do
+ let(:profile) { create(:profile) }
+
+ it "keeps the JS-support marker on across morphs" do
+ visit edit_profile_path(profile)
+
+ expect(page).to have_css("body.govuk-frontend-supported")
+
+ fill_in "Name", with: ""
+ click_button "Continue"
+
+ # The summary's arrival signals the first morph has completed.
+ expect(page).to have_css(".govuk-error-summary")
+ expect(page).to have_css("body.govuk-frontend-supported")
+
+ fill_in "Email", with: ""
+ click_button "Continue"
+
+ # A second, structure-stable morph; the added error entry signals it.
+ expect(page).to have_css(".govuk-error-summary li", count: 2)
+ expect(page).to have_css("body.govuk-frontend-supported")
+ end
+
+ it "re-enhances quietly, skipping already-initialised components" do
+ visit edit_profile_path(profile)
+
+ # Re-enhancement sweeps overlap by design — the marker-restore sweep and
+ # the arriving-node sweep can visit the same root in one morph — so an
+ # already-enhanced root must be skipped, not constructed-and-caught:
+ # the sweep logs every catch, which would spam the console with
+ # InitErrors on every morph.
+ page.execute_script(<<~JS)
+ window.__initErrors = [];
+ const log = console.log.bind(console);
+ console.log = (...args) => {
+ if (/InitError|SupportError/.test(String(args[0]))) {
+ window.__initErrors.push(String(args[0]));
+ }
+ log(...args);
+ };
+ JS
+
+ fill_in "Name", with: ""
+ click_button "Continue"
+
+ expect(page).to have_css(".govuk-error-summary")
+ expect(page.evaluate_script("window.__initErrors")).to eq([])
+ end
+
+ it "initialises a component that arrives with the morph" do
+ visit edit_profile_path(profile)
+
+ fill_in "Name", with: ""
+ click_button "Continue"
+
+ # The error summary first exists in the morphed response. govuk-frontend
+ # moves focus to it when it initialises, so focus is the observable
+ # outcome — initialisation failures are swallowed, only behaviour can
+ # fail loudly.
+ expect(page).to have_css(".govuk-error-summary:focus")
+ end
+end
diff --git a/spec/system/file_field_spec.rb b/spec/system/legacy_file_fields_spec.rb
similarity index 87%
rename from spec/system/file_field_spec.rb
rename to spec/system/legacy_file_fields_spec.rb
index 94a58b6..25a9e33 100644
--- a/spec/system/file_field_spec.rb
+++ b/spec/system/legacy_file_fields_spec.rb
@@ -6,12 +6,21 @@
# govuk_document_field (app/javascript/katalyst/govuk/controllers/*). These
# enhance a plain file input with a live preview, a "remove" button and
# drag-and-drop, none of which can be observed without a real browser.
-RSpec.describe "File field javascript" do
+RSpec.describe "Legacy file fields javascript" do
+ delegate :config, to: :GOVUKDesignSystemFormBuilder
+
+ around do |example|
+ config.use_legacy_file_fields = true
+ example.run
+ ensure
+ config.use_legacy_file_fields = false
+ end
+
describe "image field" do
it "previews the chosen image and can remove it", :aggregate_failures do
visit new_profile_path
- within(".govuk-image-field") do
+ within(avatar_field) do
# No file chosen yet, so the preview is hidden.
expect(page).to have_css("[data-govuk-image-field-target=preview]", visible: :hidden)
@@ -58,7 +67,7 @@
it "highlights the field on dragenter and clears it on dragleave", :aggregate_failures do
visit new_profile_path
- field = find(".govuk-image-field")
+ field = avatar_field
expect(field[:class]).not_to include("droppable")
@@ -70,6 +79,11 @@
end
end
+ # The form renders two image fields (avatar and gallery); scope to avatar's.
+ def avatar_field
+ find(".govuk-image-field:has(input[name='profile[avatar]'])")
+ end
+
# Dispatches a DragEvent carrying an (empty) DataTransfer onto the element, so
# the controller's dragenter/dragleave handlers run exactly as they would for
# a real drag interaction. Cuprite has no native drag-with-files API.