Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4bdc8b1
Improve CI resillience
sfnelson Jul 28, 2026
66ffd14
Attachments: tests and examples with multiple files and upstream govu…
sfnelson Jul 22, 2026
31c307f
Attachments: config for using legacy file input components and JS
sfnelson Jul 22, 2026
806e58a
Attachments: govuk_attachment_input with direct upload support
sfnelson Jul 22, 2026
eab5fe5
Attachments: round-trip multi-part form uploads
sfnelson Jul 23, 2026
9ebe113
Attachments: morph support
sfnelson Jul 23, 2026
48549ec
Attachments: retry on failure
sfnelson Jul 24, 2026
8d6c469
Attachments: refactor builder specs
sfnelson Jul 24, 2026
8c99aee
Attachments: removing singular attachments
sfnelson Jul 24, 2026
587b400
Attachments: system spec refactor
sfnelson Jul 24, 2026
19ac2fb
Attachments: lazy urls and engine support for preview links
sfnelson Jul 24, 2026
28d8b73
Attachments: code review improvements
sfnelson Jul 24, 2026
dfa3fe8
Attachments: brand and i18n
sfnelson Jul 24, 2026
a87bb32
Attachments: basic styling
sfnelson Jul 24, 2026
b5d995c
Attachments: aria focus and events for retry/remove
sfnelson Jul 28, 2026
69b61ca
Attachments: use filename instead of caption as aria-label
sfnelson Jul 28, 2026
b590c65
Attachments: follow GOVUK convention for buttons
sfnelson Jul 31, 2026
86caf7d
Attachments: development spec and future work
sfnelson Jul 22, 2026
1a46798
Attachments: accept drops anywhere in the widget
sfnelson Aug 2, 2026
3f39400
Voiceover automation helper
sfnelson Jul 28, 2026
68e966e
Release prep: documentation and examples
sfnelson Jul 31, 2026
2ba0ca3
Attachments: design feedback
sfnelson Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
/.yardoc
/_yardoc/
/coverage/
/doc/
/pkg/
/node_modules/
/spec/reports/
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
156 changes: 141 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@use "attachment";
@use "govuk-frontend/dist/govuk/components/character-count";
@use "govuk-frontend/dist/govuk/components/checkboxes";
@use "combobox";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@use "mixin";

@include mixin.styles;
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading
Loading