katalyst-govuk-formbuilder 1.30.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +141 -15
  3. data/app/assets/builds/katalyst/govuk/formbuilder.css +79 -1
  4. data/app/assets/builds/katalyst/govuk/formbuilder.js +573 -10
  5. data/app/assets/builds/katalyst/govuk/formbuilder.min.js +8 -8
  6. data/app/assets/builds/katalyst/govuk/formbuilder.min.js.map +1 -1
  7. data/app/assets/stylesheets/katalyst/govuk/components/_index.scss +1 -0
  8. data/app/assets/stylesheets/katalyst/govuk/components/attachment/_index.scss +3 -0
  9. data/app/assets/stylesheets/katalyst/govuk/components/attachment/_mixin.scss +89 -0
  10. data/app/helpers/katalyst/govuk/form_builder/builder.rb +174 -6
  11. data/app/helpers/katalyst/govuk/form_builder/elements/attachment.rb +80 -0
  12. data/app/helpers/katalyst/govuk/form_builder/frontend.rb +16 -5
  13. data/app/helpers/katalyst/govuk/form_builder/traits/attachment.rb +214 -0
  14. data/config/importmap.rb +1 -0
  15. data/config/locales/en.yml +16 -0
  16. data/lib/katalyst/govuk/form_builder/config.rb +20 -0
  17. data/lib/katalyst/govuk/form_builder/engine.rb +1 -1
  18. data/node_modules/govuk-frontend/dist/govuk/components/_index.import.scss +2 -0
  19. data/node_modules/govuk-frontend/dist/govuk/components/_index.scss +2 -0
  20. data/node_modules/govuk-frontend/dist/govuk/components/cookie-banner/_mixin.scss +6 -0
  21. data/node_modules/govuk-frontend/dist/govuk/components/feedback/_feedback.import.scss +6 -0
  22. data/node_modules/govuk-frontend/dist/govuk/components/feedback/_index.import.scss +8 -0
  23. data/node_modules/govuk-frontend/dist/govuk/components/feedback/_index.scss +5 -0
  24. data/node_modules/govuk-frontend/dist/govuk/components/feedback/_mixin.scss +48 -0
  25. data/node_modules/govuk-frontend/dist/govuk/components/footer/_mixin.scss +6 -0
  26. data/node_modules/govuk-frontend/dist/govuk/components/language-navigation/_index.import.scss +8 -0
  27. data/node_modules/govuk-frontend/dist/govuk/components/language-navigation/_index.scss +6 -0
  28. data/node_modules/govuk-frontend/dist/govuk/components/language-navigation/_language-navigation.import.scss +6 -0
  29. data/node_modules/govuk-frontend/dist/govuk/components/language-navigation/_mixin.scss +68 -0
  30. data/node_modules/govuk-frontend/dist/govuk/components/service-navigation/_mixin.scss +48 -3
  31. data/node_modules/govuk-frontend/dist/govuk/custom-properties/_frontend-version.mixin.scss +1 -1
  32. data/node_modules/govuk-frontend/dist/govuk/custom-properties/_functional-colours.mixin.scss +1 -0
  33. data/node_modules/govuk-frontend/dist/govuk/settings/_colours-organisations.scss +16 -2
  34. metadata +28 -1
@@ -1,4 +1,6 @@
1
- import { Controller } from "@hotwired/stimulus";
1
+ import { Controller, Application } from "@hotwired/stimulus";
2
+
3
+ import { DirectUploadController } from "@rails/activestorage";
2
4
 
3
5
  function setFocus($element, options = {}) {
4
6
  var _options$onBeforeFocu;
@@ -940,7 +942,7 @@ ErrorSummary.schema = Object.freeze({
940
942
  }
941
943
  canDrop(dataTransfer) {
942
944
  if (dataTransfer.items.length) {
943
- return this.matchesInputCapacity(countFileItems(dataTransfer.items));
945
+ return this.matchesInputCapacity(countFileItems$1(dataTransfer.items));
944
946
  }
945
947
  if (dataTransfer.types.length) {
946
948
  return dataTransfer.types.includes("Files");
@@ -1024,7 +1026,7 @@ FileUpload.schema = Object.freeze({
1024
1026
  }
1025
1027
  });
1026
1028
 
1027
- function countFileItems(list) {
1029
+ function countFileItems$1(list) {
1028
1030
  let result = 0;
1029
1031
  for (let i = 0; i < list.length; i++) {
1030
1032
  if (list[i].kind === "file") {
@@ -1210,6 +1212,240 @@ PasswordInput.schema = Object.freeze({
1210
1212
 
1211
1213
  Radios.moduleName = "govuk-radios";
1212
1214
 
1215
+ const config = {
1216
+ brand: "govuk"
1217
+ };
1218
+
1219
+ const uploadButtonSelector = "[type='button']:has(+ input[type='file'])";
1220
+
1221
+ const Attachment = {
1222
+ moduleName: "govuk-attachment",
1223
+ defaults: {
1224
+ i18n: {
1225
+ ...FileUpload.defaults.i18n,
1226
+ uploadSucceeded: "Uploaded successfully",
1227
+ uploadFailed: "Upload failed — try again",
1228
+ retryButton: "Try again",
1229
+ fileRemoved: "%{filename} removed",
1230
+ removeButton: "Remove %{filename}",
1231
+ removeButtonContent: "Remove"
1232
+ }
1233
+ },
1234
+ schema: {
1235
+ properties: {
1236
+ i18n: {
1237
+ type: "object"
1238
+ }
1239
+ }
1240
+ }
1241
+ };
1242
+
1243
+ function attachmentConfig(wrapper) {
1244
+ return mergeConfigs(Attachment.defaults, normaliseDataset(Attachment, wrapper?.dataset ?? {}));
1245
+ }
1246
+
1247
+ class AttachmentUploadController extends DirectUploadController {
1248
+ async start(option) {
1249
+ this.dispatch("start");
1250
+ try {
1251
+ const attributes = await new Promise((resolve, reject) => {
1252
+ this.directUpload.create((error, attributes) => error ? reject(error) : resolve(attributes));
1253
+ });
1254
+ option.value = attributes.signed_id;
1255
+ } catch (error) {
1256
+ this.dispatch("error", {
1257
+ error: error
1258
+ });
1259
+ throw error;
1260
+ } finally {
1261
+ this.dispatch("end");
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ class AttachmentController extends Controller {
1267
+ connect() {
1268
+ this.config = attachmentConfig(this.element.closest(`.${config.brand}-file-upload-wrapper`));
1269
+ this.i18n = new I18n(this.config.i18n, {
1270
+ locale: closestAttributeValue(this.element, "lang")
1271
+ });
1272
+ this.select.addEventListener("change", this.change);
1273
+ this.previewPendingFile();
1274
+ this.uploadPendingFile();
1275
+ }
1276
+ disconnect() {
1277
+ this.select?.removeEventListener("change", this.change);
1278
+ }
1279
+ previewPendingFile() {
1280
+ const file = this.element.file;
1281
+ if (!file) return;
1282
+ if (!file.type.startsWith("image/")) {
1283
+ this.imageTag?.remove();
1284
+ return;
1285
+ }
1286
+ const preview = new FileReader;
1287
+ preview.onload = this.onPreviewReady;
1288
+ preview.readAsDataURL(file);
1289
+ }
1290
+ uploadPendingFile() {
1291
+ const file = this.element.file;
1292
+ if (!file) return;
1293
+ if (!this.input?.dataset.directUploadUrl) return;
1294
+ delete this.element.file;
1295
+ this.input.dispatchEvent(new CustomEvent("govuk:upload", {
1296
+ detail: {
1297
+ file: file
1298
+ }
1299
+ }));
1300
+ this.performUpload(file);
1301
+ }
1302
+ async performUpload(file) {
1303
+ this.uploader = new AttachmentUploadController(this.input, file);
1304
+ this.element.dataset.state = "uploading";
1305
+ this.statusText = "";
1306
+ this.retryButton?.remove();
1307
+ const progressTag = createProgressTag(this.filenameTag.id);
1308
+ this.captionTag.appendChild(progressTag);
1309
+ this.input.addEventListener("direct-upload:progress", this.progress);
1310
+ try {
1311
+ await this.uploader.start(this.inputOption);
1312
+ this.element.dataset.state = "upload-successful";
1313
+ this.statusText = this.i18n.t("uploadSucceeded");
1314
+ } catch (error) {
1315
+ console.warn(error);
1316
+ this.element.dataset.state = "upload-failed";
1317
+ this.statusText = this.i18n.t("uploadFailed");
1318
+ this.actionsTag?.prepend(createRetryButton(file.name, this.i18n));
1319
+ } finally {
1320
+ this.input.removeEventListener("direct-upload:progress", this.progress);
1321
+ progressTag.remove();
1322
+ }
1323
+ }
1324
+ onPreviewReady=e => {
1325
+ this.imageTag.src = e.target.result;
1326
+ };
1327
+ change=() => {
1328
+ if (this.select.value === "") this.destroy();
1329
+ };
1330
+ retry() {
1331
+ this.performUpload(this.directUpload.file);
1332
+ this.removeButton?.focus();
1333
+ }
1334
+ progress=({detail: detail}) => {
1335
+ if (detail.id !== this.id) return;
1336
+ if (this.progressTag) this.progressTag.value = detail.progress;
1337
+ };
1338
+ destroy() {
1339
+ const focusTarget = this.uploadButton;
1340
+ const remove = new CustomEvent("govuk:remove", {
1341
+ detail: {
1342
+ name: this.element.querySelector(".filename")?.textContent,
1343
+ file: this.element.file
1344
+ },
1345
+ cancelable: true
1346
+ });
1347
+ if (!this.input.dispatchEvent(remove)) return;
1348
+ this.element.remove();
1349
+ focusTarget?.focus();
1350
+ }
1351
+ get id() {
1352
+ return this.directUpload?.id ?? this.select.id;
1353
+ }
1354
+ get directUpload() {
1355
+ return this.uploader?.directUpload;
1356
+ }
1357
+ get input() {
1358
+ return this.element.closest(`.${config.brand}-file-upload-wrapper`)?.querySelector("input[type=file]");
1359
+ }
1360
+ get uploadButton() {
1361
+ return this.element.closest(`.${config.brand}-file-upload-wrapper`)?.querySelector(uploadButtonSelector);
1362
+ }
1363
+ set statusText(message) {
1364
+ const tag = this.element.querySelector("figcaption .status");
1365
+ if (tag) tag.textContent = message;
1366
+ }
1367
+ get captionTag() {
1368
+ return this.element.querySelector("figcaption");
1369
+ }
1370
+ get filenameTag() {
1371
+ return this.element.querySelector("figcaption .filename");
1372
+ }
1373
+ get actionsTag() {
1374
+ return this.element.querySelector(".actions");
1375
+ }
1376
+ get select() {
1377
+ return this.element.querySelector("select");
1378
+ }
1379
+ get inputOption() {
1380
+ return this.element.querySelector("option");
1381
+ }
1382
+ get imageTag() {
1383
+ return this.element.querySelector("img");
1384
+ }
1385
+ get progressTag() {
1386
+ return this.element.querySelector("progress");
1387
+ }
1388
+ get retryButton() {
1389
+ return this.element.querySelector(".actions button[data-action*='retry']");
1390
+ }
1391
+ get removeButton() {
1392
+ return this.element.querySelector(".actions button[data-action*='destroy']");
1393
+ }
1394
+ }
1395
+
1396
+ let nextAttachmentId = 0;
1397
+
1398
+ function createAttachment(input, file, i18n) {
1399
+ const template = document.createElement("TEMPLATE");
1400
+ const id = ++nextAttachmentId;
1401
+ template.innerHTML = `\n <figure class="${config.brand}-attachment" data-controller="govuk-attachment" aria-labelledby="attachment-${id}-filename">\n <img alt="" class="preview" src="">\n <figcaption class="caption" aria-atomic="true" aria-live="polite">\n <span id="attachment-${id}-filename" class="filename"></span>\n <span class="size"></span>\n <span class="status"></span>\n </figcaption>\n <div class="actions">\n <select id="attachment-${id}-input" name="${input.name}" aria-labelledby="attachment-${id}-filename">\n <option selected="selected" value=""></option>\n <option value=""></option>\n </select>\n <button type="button" class="${config.brand}-button ${config.brand}-button--secondary ${config.brand}-attachment__remove" data-action="govuk-attachment#destroy" data-module="govuk-button"></button>\n </div>\n </figure>\n `;
1402
+ const figure = template.content.firstElementChild;
1403
+ figure.querySelector(".filename").textContent = file.name;
1404
+ figure.querySelector(".size").textContent = humanSize(file.size);
1405
+ const [keep, remove] = figure.querySelectorAll("option");
1406
+ keep.textContent = file.name;
1407
+ remove.textContent = i18n.t("removeButton", {
1408
+ filename: file.name
1409
+ });
1410
+ const removeButton = figure.querySelector("button");
1411
+ removeButton.textContent = i18n.t("removeButtonContent");
1412
+ removeButton.setAttribute("aria-label", i18n.t("removeButton", {
1413
+ filename: file.name
1414
+ }));
1415
+ figure.file = file;
1416
+ return figure;
1417
+ }
1418
+
1419
+ const UNITS = [ "Bytes", "KB", "MB", "GB", "TB", "PB" ];
1420
+
1421
+ function humanSize(bytes) {
1422
+ if (bytes === 1) return "1 Byte";
1423
+ if (bytes < 1024) return `${bytes} Bytes`;
1424
+ const exp = Math.min(Math.floor(Math.log2(bytes) / 10), UNITS.length - 1);
1425
+ const value = Number((bytes / 1024 ** exp).toPrecision(3));
1426
+ return `${value} ${UNITS[exp]}`;
1427
+ }
1428
+
1429
+ function createRetryButton(filename, i18n) {
1430
+ const button = document.createElement("BUTTON");
1431
+ button.type = "button";
1432
+ button.className = `${config.brand}-button ${config.brand}-button--secondary ${config.brand}-attachment__retry`;
1433
+ button.textContent = i18n.t("retryButton");
1434
+ button.setAttribute("aria-label", `${i18n.t("retryButton")} ${filename}`);
1435
+ button.dataset.action = "govuk-attachment#retry";
1436
+ button.dataset.module = "govuk-button";
1437
+ return button;
1438
+ }
1439
+
1440
+ function createProgressTag(labelId) {
1441
+ const progress = document.createElement("PROGRESS");
1442
+ progress.className = `${config.brand}-attachment-progress`;
1443
+ if (labelId) progress.setAttribute("aria-labelledby", labelId);
1444
+ progress.value = 0;
1445
+ progress.max = 100;
1446
+ return progress;
1447
+ }
1448
+
1213
1449
  class FileFieldController extends Controller {
1214
1450
  static targets=[ "preview", "destroy" ];
1215
1451
  static values={
@@ -1343,6 +1579,250 @@ class DocumentFieldController extends FileFieldController {
1343
1579
  }
1344
1580
  }
1345
1581
 
1582
+ class FileUploadController extends Controller {
1583
+ connect() {
1584
+ if (!this.fileInput) {
1585
+ throw new Error(`Missing file input for ${this.element}`);
1586
+ }
1587
+ this.config = attachmentConfig(this.element);
1588
+ this.i18n = new I18n(this.config.i18n, {
1589
+ locale: closestAttributeValue(this.element, "lang")
1590
+ });
1591
+ this.id = this.uploadButton?.id ?? this.fileInput.id;
1592
+ document.addEventListener("dragenter", this.onDragenter);
1593
+ document.addEventListener("dragleave", this.onDragleave);
1594
+ this.element.addEventListener("dragover", this.onDragover);
1595
+ this.element.addEventListener("drop", this.onDrop);
1596
+ this.enhance();
1597
+ this.morphObserver = new MutationObserver(this.onMorph);
1598
+ this.morphObserver.observe(this.element, {
1599
+ childList: true
1600
+ });
1601
+ }
1602
+ disconnect() {
1603
+ this.morphObserver?.disconnect();
1604
+ this.disabledObserver?.disconnect();
1605
+ this.uploadButton?.removeEventListener("click", this.onClick);
1606
+ this.element.removeEventListener("dragover", this.onDragover);
1607
+ this.element.removeEventListener("drop", this.onDrop);
1608
+ this.unbindInput();
1609
+ document.removeEventListener("dragenter", this.onDragenter);
1610
+ document.removeEventListener("dragleave", this.onDragleave);
1611
+ this.announcements?.remove();
1612
+ }
1613
+ enhance() {
1614
+ const fileInput = this.fileInput;
1615
+ let uploadButton = this.uploadButton;
1616
+ if (!uploadButton) {
1617
+ this.ensureLabelId();
1618
+ fileInput.id = `${this.id}-input`;
1619
+ fileInput.setAttribute("hidden", "hidden");
1620
+ uploadButton = createUploadButton(this.id, this.i18n, fileInput);
1621
+ fileInput.insertAdjacentElement("beforebegin", uploadButton);
1622
+ uploadButton.addEventListener("click", this.onClick);
1623
+ }
1624
+ if (!this.announcements) this.element.appendChild(createAnnouncements());
1625
+ this.unbindInput();
1626
+ fileInput.addEventListener("change", this.onChange);
1627
+ fileInput.addEventListener("govuk:upload", this.onUpload);
1628
+ fileInput.addEventListener("govuk:remove", this.onRemove);
1629
+ this.updateDisabledState();
1630
+ this.observeDisabledState();
1631
+ this.updateCount();
1632
+ }
1633
+ unbindInput() {
1634
+ this.fileInput?.removeEventListener("change", this.onChange);
1635
+ this.fileInput?.removeEventListener("govuk:upload", this.onUpload);
1636
+ this.fileInput?.removeEventListener("govuk:remove", this.onRemove);
1637
+ }
1638
+ onMorph=() => {
1639
+ if (this.uploadButton) return;
1640
+ this.fileInput.value = "";
1641
+ this.element.querySelectorAll("figure").forEach(figure => {
1642
+ delete figure.file;
1643
+ });
1644
+ this.enhance();
1645
+ };
1646
+ ensureLabelId() {
1647
+ const label = document.querySelector(`label[for="${this.id}"]`);
1648
+ if (label && !label.id) label.id = `${this.id}-label`;
1649
+ }
1650
+ updateDisabledState() {
1651
+ if (!this.uploadButton) return;
1652
+ const disabled = this.fileInput.disabled;
1653
+ this.uploadButton.disabled = disabled;
1654
+ this.element.classList.toggle(`${config.brand}-file-upload-wrapper--disabled`, disabled);
1655
+ }
1656
+ observeDisabledState() {
1657
+ this.disabledObserver?.disconnect();
1658
+ this.disabledObserver = new MutationObserver(mutations => {
1659
+ for (const mutation of mutations) {
1660
+ if (mutation.attributeName === "disabled") this.updateDisabledState();
1661
+ }
1662
+ });
1663
+ this.disabledObserver.observe(this.fileInput, {
1664
+ attributes: true
1665
+ });
1666
+ }
1667
+ onClick=event => {
1668
+ this.fileInput.click();
1669
+ };
1670
+ onDragover=event => {
1671
+ if (this.fileInput.disabled) return;
1672
+ event.preventDefault();
1673
+ };
1674
+ onDragenter=event => {
1675
+ this.updateDropzoneVisibility(event);
1676
+ this.enteredAnotherElement = true;
1677
+ };
1678
+ onDragleave=() => {
1679
+ if (!this.enteredAnotherElement && !this.uploadButton.disabled) {
1680
+ this.hideDraggingState();
1681
+ this.announce(this.i18n.t("leftDropZone"));
1682
+ }
1683
+ this.enteredAnotherElement = false;
1684
+ };
1685
+ onDrop=event => {
1686
+ if (this.fileInput.disabled) return;
1687
+ event.preventDefault();
1688
+ if (event.dataTransfer && this.canFillInput(event.dataTransfer)) {
1689
+ this.fileInput.files = event.dataTransfer.files;
1690
+ this.fileInput.dispatchEvent(new CustomEvent("change"));
1691
+ this.hideDraggingState();
1692
+ }
1693
+ };
1694
+ updateDropzoneVisibility(event) {
1695
+ if (this.uploadButton.disabled) return;
1696
+ if (!(event.target instanceof Node)) return;
1697
+ if (this.element.contains(event.target)) {
1698
+ if (event.dataTransfer && this.canDrop(event.dataTransfer)) {
1699
+ if (!this.isDragging) {
1700
+ this.showDraggingState();
1701
+ this.announce(this.i18n.t("enteredDropZone"));
1702
+ }
1703
+ }
1704
+ } else if (this.isDragging) {
1705
+ this.hideDraggingState();
1706
+ this.announce(this.i18n.t("leftDropZone"));
1707
+ }
1708
+ }
1709
+ showDraggingState() {
1710
+ this.uploadButton.classList.add(`${config.brand}-file-upload-button--dragging`);
1711
+ }
1712
+ hideDraggingState() {
1713
+ this.uploadButton.classList.remove(`${config.brand}-file-upload-button--dragging`);
1714
+ }
1715
+ announce(message) {
1716
+ if (this.announcements) this.announcements.textContent = message;
1717
+ }
1718
+ get announcements() {
1719
+ return this.element.querySelector(`.${config.brand}-file-upload-announcements`);
1720
+ }
1721
+ matchesInputCapacity(numberOfFiles) {
1722
+ if (this.fileInput.multiple) return numberOfFiles > 0;
1723
+ return numberOfFiles === 1;
1724
+ }
1725
+ canDrop(dataTransfer) {
1726
+ if (dataTransfer.items.length) {
1727
+ return this.matchesInputCapacity(countFileItems(dataTransfer.items));
1728
+ }
1729
+ if (dataTransfer.types.length) {
1730
+ return dataTransfer.types.includes("Files");
1731
+ }
1732
+ return true;
1733
+ }
1734
+ canFillInput(dataTransfer) {
1735
+ return this.matchesInputCapacity(dataTransfer.files.length);
1736
+ }
1737
+ onChange=() => {
1738
+ const files = Array.from(this.fileInput.files);
1739
+ const figures = Array.from(this.element.querySelectorAll("figure"));
1740
+ figures.forEach(figure => {
1741
+ if (figure.file && !files.includes(figure.file)) figure.remove();
1742
+ });
1743
+ files.forEach(file => {
1744
+ if (figures.some(figure => figure.file === file)) return;
1745
+ const attachment = createAttachment(this.fileInput, file, this.i18n);
1746
+ this.uploadButton.insertAdjacentElement("beforebegin", attachment);
1747
+ });
1748
+ this.updateCount();
1749
+ };
1750
+ onUpload=({detail: {file: file}}) => {
1751
+ this.releaseFile(file);
1752
+ this.updateCount();
1753
+ };
1754
+ releaseFile(file) {
1755
+ const remaining = new DataTransfer;
1756
+ for (const held of this.fileInput.files) {
1757
+ if (held !== file) remaining.items.add(held);
1758
+ }
1759
+ this.fileInput.files = remaining.files;
1760
+ }
1761
+ onRemove=async event => {
1762
+ await Promise.resolve();
1763
+ if (event.defaultPrevented) return;
1764
+ const {name: name, file: file} = event.detail;
1765
+ if (file) this.releaseFile(file);
1766
+ this.announce(this.i18n.t("fileRemoved", {
1767
+ filename: name
1768
+ }));
1769
+ this.updateCount();
1770
+ };
1771
+ updateCount() {
1772
+ const count = this.fileCount;
1773
+ if (count === 0) {
1774
+ this.statusTag.innerText = this.i18n.t("noFileChosen");
1775
+ this.uploadButton.classList.add(`${config.brand}-file-upload-button--empty`);
1776
+ } else {
1777
+ this.statusTag.innerText = this.i18n.t("multipleFilesChosen", {
1778
+ count: count
1779
+ });
1780
+ this.uploadButton.classList.remove(`${config.brand}-file-upload-button--empty`);
1781
+ }
1782
+ }
1783
+ get fileInput() {
1784
+ return this.element.querySelector("input[type='file']");
1785
+ }
1786
+ get uploadButton() {
1787
+ return this.element.querySelector(uploadButtonSelector);
1788
+ }
1789
+ get isDragging() {
1790
+ return this.uploadButton.classList.contains(`${config.brand}-file-upload-button--dragging`);
1791
+ }
1792
+ get statusTag() {
1793
+ return this.element.querySelector("button [aria-live]");
1794
+ }
1795
+ get fileCount() {
1796
+ let count = this.fileInput.files.length;
1797
+ this.element.querySelectorAll(`select[name='${this.fileInput.name}']`).forEach(select => {
1798
+ if (!select.closest("figure")?.file) count += 1;
1799
+ });
1800
+ if (!this.fileInput.multiple) count = Math.min(count, 1);
1801
+ return count;
1802
+ }
1803
+ }
1804
+
1805
+ function countFileItems(items) {
1806
+ return Array.from(items).filter(item => item.kind === "file").length;
1807
+ }
1808
+
1809
+ function createAnnouncements() {
1810
+ const region = document.createElement("span");
1811
+ region.className = `${config.brand}-file-upload-announcements ${config.brand}-visually-hidden`;
1812
+ region.setAttribute("aria-live", "assertive");
1813
+ return region;
1814
+ }
1815
+
1816
+ function createUploadButton(id, i18n, fileInput) {
1817
+ const brand = config.brand;
1818
+ const template = document.createElement("TEMPLATE");
1819
+ template.innerHTML = `\n <button\n class="${brand}-file-upload-button ${brand}-file-upload-button--empty"\n type="button"\n id="${id}"\n aria-labelledby="${id}-label ${id}-comma ${id}"\n >\n <span class="${brand}-body ${brand}-file-upload-button__status"\n aria-live="polite">${i18n.t("noFileChosen")}</span>\n <span class="${brand}-visually-hidden" id="${id}-comma">, </span>\n <span class="${brand}-file-upload-button__pseudo-button-container">\n <span class="${brand}-button\n ${brand}-button--secondary\n ${brand}-file-upload-button__pseudo-button">${i18n.t("chooseFilesButton")}</span>\n <span class="${brand}-body ${brand}-file-upload-button__instruction">${i18n.t("dropInstruction")}</span>\n </span>\n </button>\n `;
1820
+ const button = template.content.firstElementChild;
1821
+ const describedBy = fileInput.getAttribute("aria-describedby");
1822
+ if (describedBy) button.setAttribute("aria-describedby", describedBy);
1823
+ return button;
1824
+ }
1825
+
1346
1826
  class ImageFieldController extends FileFieldController {
1347
1827
  connect() {
1348
1828
  super.connect();
@@ -1364,25 +1844,32 @@ class ImageFieldController extends FileFieldController {
1364
1844
  }
1365
1845
 
1366
1846
  const Definitions = [ {
1847
+ identifier: "govuk-attachment",
1848
+ controllerConstructor: AttachmentController
1849
+ }, {
1367
1850
  identifier: "govuk-document-field",
1368
1851
  controllerConstructor: DocumentFieldController
1852
+ }, {
1853
+ identifier: "govuk-file-upload",
1854
+ controllerConstructor: FileUploadController
1369
1855
  }, {
1370
1856
  identifier: "govuk-image-field",
1371
1857
  controllerConstructor: ImageFieldController
1372
1858
  } ];
1373
1859
 
1374
- function initAll(config) {
1375
- let _config$scope;
1376
- config = typeof config !== "undefined" ? config : {};
1860
+ let options = {};
1861
+
1862
+ function enhance($scope = document) {
1377
1863
  if (!isSupported()) {
1378
1864
  console.log(new SupportError);
1379
1865
  return;
1380
1866
  }
1381
- const components = [ [ Button, config.button ], [ CharacterCount, config.characterCount ], [ Checkboxes ], [ ErrorSummary, config.errorSummary ], [ FileUpload, config.fileUpload ], [ Radios ], [ PasswordInput, config.passwordInput ] ];
1382
- const $scope = (_config$scope = config.scope) != null ? _config$scope : document;
1867
+ const components = [ [ Button, options.button ], [ CharacterCount, options.characterCount ], [ Checkboxes ], [ ErrorSummary, options.errorSummary ], [ FileUpload, options.fileUpload ], [ Radios ], [ PasswordInput, options.passwordInput ] ];
1383
1868
  components.forEach(([Component, config]) => {
1384
- const $elements = $scope.querySelectorAll(`[data-module="${Component.moduleName}"]`);
1869
+ const selector = `[data-module="${Component.moduleName}"]`;
1870
+ const $elements = [ ...$scope instanceof Element && $scope.matches(selector) ? [ $scope ] : [], ...$scope.querySelectorAll(selector) ];
1385
1871
  $elements.forEach($element => {
1872
+ if (isInitialised($element, Component.moduleName)) return;
1386
1873
  try {
1387
1874
  "defaults" in Component ? new Component($element, config) : new Component($element);
1388
1875
  } catch (error) {
@@ -1392,4 +1879,80 @@ function initAll(config) {
1392
1879
  });
1393
1880
  }
1394
1881
 
1395
- export { Button, CharacterCount, Checkboxes, ErrorSummary, PasswordInput, Radios, Definitions as default, initAll };
1882
+ function markSupport(body) {
1883
+ body.classList.toggle("js-enabled", true);
1884
+ body.classList.toggle("govuk-frontend-supported", "noModule" in HTMLScriptElement.prototype);
1885
+ }
1886
+
1887
+ function supportMarked(body) {
1888
+ const supported = "noModule" in HTMLScriptElement.prototype;
1889
+ return body.classList.contains("js-enabled") && body.classList.contains("govuk-frontend-supported") === supported;
1890
+ }
1891
+
1892
+ function observe(body) {
1893
+ new MutationObserver(() => {
1894
+ if (supportMarked(body)) return;
1895
+ markSupport(body);
1896
+ enhance();
1897
+ }).observe(body, {
1898
+ attributes: true,
1899
+ attributeFilter: [ "class" ]
1900
+ });
1901
+ new MutationObserver(mutations => {
1902
+ for (const mutation of mutations) {
1903
+ for (const node of mutation.addedNodes) {
1904
+ if (node instanceof Element) enhance(node);
1905
+ }
1906
+ }
1907
+ }).observe(body, {
1908
+ childList: true,
1909
+ subtree: true
1910
+ });
1911
+ }
1912
+
1913
+ function setup(body) {
1914
+ if (!body || body.__govukFormbuilderInit) return;
1915
+ body.__govukFormbuilderInit = true;
1916
+ markSupport(body);
1917
+ enhance();
1918
+ observe(body);
1919
+ }
1920
+
1921
+ let registered = false;
1922
+
1923
+ function register(application = undefined) {
1924
+ if (registered) return;
1925
+ registered = true;
1926
+ (application ?? Application.start()).load(Definitions);
1927
+ }
1928
+
1929
+ function applyConfig(config$1) {
1930
+ if (config$1.brand) config.brand = config$1.brand;
1931
+ options = config$1;
1932
+ }
1933
+
1934
+ function initAll(config = {}) {
1935
+ applyConfig(config);
1936
+ register();
1937
+ setup(document.body);
1938
+ }
1939
+
1940
+ let watching = false;
1941
+
1942
+ function start(application = undefined, config = {}) {
1943
+ applyConfig(config);
1944
+ register(application);
1945
+ if (!watching) {
1946
+ watching = true;
1947
+ new MutationObserver(() => setup(document.body)).observe(document.documentElement, {
1948
+ childList: true
1949
+ });
1950
+ }
1951
+ setup(document.body);
1952
+ }
1953
+
1954
+ var formbuilder = {
1955
+ start: start
1956
+ };
1957
+
1958
+ export { Button, CharacterCount, Checkboxes, ErrorSummary, PasswordInput, Radios, formbuilder as default, initAll };