@usefillo/dom 0.12.0 → 0.12.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.
package/dist/index.js CHANGED
@@ -34,6 +34,8 @@ import {
34
34
  needsExplicitSubmit,
35
35
  radioGroupStep,
36
36
  reachablePageSequence,
37
+ REQUIRED_FIELD_MESSAGE,
38
+ requiredFieldMessage,
37
39
  resolveThemeAppearance,
38
40
  shouldAutoSubmit,
39
41
  syncCodeForm,
@@ -942,7 +944,7 @@ function checkbox(context) {
942
944
  el("p", {
943
945
  className: "fillo-error",
944
946
  text: context.error,
945
- attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
947
+ attrs: { id: context.ids.errorId, "data-fillo": "error" }
946
948
  })
947
949
  );
948
950
  return wrap;
@@ -1306,8 +1308,47 @@ function formatBytes(bytes) {
1306
1308
  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
1307
1309
  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
1308
1310
  }
1309
- function fileActionBtn(className, label, text, onClick) {
1310
- const btn = el("button", { className, text, attrs: { type: "button", "aria-label": label } });
1311
+ function uploadSvg(paths, size = 22) {
1312
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1313
+ svg.setAttribute("class", "fillo-file-state-icon");
1314
+ svg.setAttribute("viewBox", "0 0 24 24");
1315
+ svg.setAttribute("width", String(size));
1316
+ svg.setAttribute("height", String(size));
1317
+ svg.setAttribute("fill", "none");
1318
+ svg.setAttribute("aria-hidden", "true");
1319
+ svg.setAttribute("focusable", "false");
1320
+ for (const d of paths) {
1321
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1322
+ path.setAttribute("d", d);
1323
+ svg.appendChild(path);
1324
+ }
1325
+ return svg;
1326
+ }
1327
+ function fileStateIcon(state) {
1328
+ const mark = el("span", {
1329
+ className: `fillo-file-state fillo-file-state--${state}`,
1330
+ attrs: { "aria-hidden": "true" }
1331
+ });
1332
+ mark.appendChild(
1333
+ state === "done" ? uploadSvg([
1334
+ "M3.75 12a8.25 8.25 0 1 0 16.5 0 8.25 8.25 0 1 0-16.5 0",
1335
+ "m8.5 12 2.25 2.25 4.75-5"
1336
+ ]) : state === "failed" ? uploadSvg([
1337
+ "M3.75 12a8.25 8.25 0 1 0 16.5 0 8.25 8.25 0 1 0-16.5 0",
1338
+ "M12 7.75v5.5",
1339
+ "M12 16.5h.01"
1340
+ ]) : uploadSvg(["M7.25 3.75h6.5l3 3v13.5h-9.5z", "M13.75 3.75v3h3"])
1341
+ );
1342
+ return mark;
1343
+ }
1344
+ function closeIcon() {
1345
+ const icon = uploadSvg(["m7 7 10 10M17 7 7 17"], 16);
1346
+ icon.setAttribute("class", "fillo-file-action-icon");
1347
+ return icon;
1348
+ }
1349
+ function fileActionBtn(className, label, content, onClick) {
1350
+ const btn = el("button", { className, attrs: { type: "button", "aria-label": label } });
1351
+ btn.append(content);
1311
1352
  btn.addEventListener("click", onClick);
1312
1353
  return btn;
1313
1354
  }
@@ -1418,35 +1459,39 @@ function fileUpload(context) {
1418
1459
  const list = el("ul", { className: "fillo-files" });
1419
1460
  for (const file of done) {
1420
1461
  const li = el("li", { className: "fillo-file fillo-file--done" });
1421
- appendChildren(li, [
1462
+ const content = el("span", { className: "fillo-file-content" });
1463
+ appendChildren(content, [
1422
1464
  el("span", { className: "fillo-file-name", text: file.name }),
1423
- el("span", { className: "fillo-file-meta", text: formatBytes(file.size) }),
1424
- el("span", { className: "fillo-sr-only", text: "Uploaded" }),
1465
+ el("span", {
1466
+ className: "fillo-file-meta",
1467
+ text: DEFAULT_FIELD_STRINGS.uploadedFile(formatBytes(file.size))
1468
+ })
1469
+ ]);
1470
+ const actions = el("span", { className: "fillo-file-actions" });
1471
+ actions.appendChild(
1425
1472
  fileActionBtn(
1426
1473
  "fillo-file-remove",
1427
- `Remove ${file.name}`,
1428
- "\xD7",
1474
+ `${DEFAULT_FIELD_STRINGS.uploadRemove} ${file.name}`,
1475
+ closeIcon(),
1429
1476
  () => context.setValue(done.filter((f) => f.fileId !== file.fileId))
1430
1477
  )
1431
- ]);
1478
+ );
1479
+ appendChildren(li, [fileStateIcon("done"), content, actions]);
1432
1480
  list.appendChild(li);
1433
1481
  }
1434
1482
  for (const row of rows) {
1435
1483
  const failed = row.error !== void 0;
1436
1484
  const li = el("li", {
1437
- className: `fillo-file${failed ? " fillo-file--failed" : ""}`,
1438
- attrs: failed ? {} : {
1439
- role: "progressbar",
1440
- "aria-label": `Uploading ${row.name}`,
1441
- "aria-valuemin": "0",
1442
- "aria-valuemax": "100",
1443
- "aria-valuenow": String(Math.round(row.fraction * 100))
1444
- }
1485
+ className: `fillo-file${failed ? " fillo-file--failed" : ""}`
1445
1486
  });
1446
- li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1487
+ li.appendChild(fileStateIcon(failed ? "failed" : "uploading"));
1488
+ const content = el("span", { className: "fillo-file-content" });
1489
+ content.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1447
1490
  if (failed) {
1448
1491
  const actions = el("span", { className: "fillo-file-actions" });
1449
- if (remaining > 0) {
1492
+ content.appendChild(el("span", { className: "fillo-file-error", text: row.error ?? "" }));
1493
+ li.appendChild(content);
1494
+ if (!row.tooLarge && remaining > 0) {
1450
1495
  actions.appendChild(
1451
1496
  fileActionBtn(
1452
1497
  "fillo-file-retry",
@@ -1459,29 +1504,47 @@ function fileUpload(context) {
1459
1504
  actions.appendChild(
1460
1505
  fileActionBtn(
1461
1506
  "fillo-file-remove",
1462
- "Dismiss",
1463
- "\xD7",
1507
+ `${DEFAULT_FIELD_STRINGS.uploadDismiss} ${row.name}`,
1508
+ closeIcon(),
1464
1509
  () => internal.uploadAction?.(row.key, "dismiss")
1465
1510
  )
1466
1511
  );
1467
1512
  li.appendChild(actions);
1468
- li.appendChild(el("span", { className: "fillo-file-error", text: row.error ?? "" }));
1469
1513
  } else {
1470
1514
  const bar = el("span", { className: "fillo-progress-bar" });
1471
1515
  bar.style.width = `${row.fraction * 100}%`;
1472
- appendChildren(li, [
1516
+ content.appendChild(
1473
1517
  el("span", {
1474
1518
  className: "fillo-file-meta",
1475
- text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}`
1476
- }),
1519
+ text: DEFAULT_FIELD_STRINGS.uploadingFile(
1520
+ Math.round(row.fraction * 100),
1521
+ formatBytes(row.size)
1522
+ )
1523
+ })
1524
+ );
1525
+ const actions = el("span", { className: "fillo-file-actions" });
1526
+ actions.appendChild(
1477
1527
  fileActionBtn(
1478
1528
  "fillo-file-remove",
1479
- `Cancel ${row.name}`,
1480
- "\xD7",
1529
+ `${DEFAULT_FIELD_STRINGS.uploadCancel} ${row.name}`,
1530
+ closeIcon(),
1481
1531
  () => internal.uploadAction?.(row.key, "cancel")
1482
- ),
1532
+ )
1533
+ );
1534
+ appendChildren(li, [
1535
+ content,
1536
+ actions,
1483
1537
  appendChildren(
1484
- el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }),
1538
+ el("span", {
1539
+ className: "fillo-progress",
1540
+ attrs: {
1541
+ role: "progressbar",
1542
+ "aria-label": `Uploading ${row.name}`,
1543
+ "aria-valuemin": "0",
1544
+ "aria-valuemax": "100",
1545
+ "aria-valuenow": String(Math.round(row.fraction * 100))
1546
+ }
1547
+ }),
1485
1548
  [bar]
1486
1549
  )
1487
1550
  ]);
@@ -2162,7 +2225,8 @@ var DomFormController = class {
2162
2225
  name: row.name,
2163
2226
  size: row.size,
2164
2227
  fraction: row.fraction,
2165
- error: row.error
2228
+ error: row.error,
2229
+ tooLarge: row.tooLarge
2166
2230
  });
2167
2231
  }
2168
2232
  return rows;
@@ -2179,6 +2243,7 @@ var DomFormController = class {
2179
2243
  } else if (action === "dismiss") {
2180
2244
  this.inFlight.delete(key);
2181
2245
  } else {
2246
+ if (row.tooLarge) return;
2182
2247
  this.inFlight.delete(key);
2183
2248
  void this.startUpload(row.fieldId, row.file, row.maxFiles);
2184
2249
  }
@@ -2242,6 +2307,7 @@ var DomFormController = class {
2242
2307
  const maxFileSizeMb = this.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, this.uploadFileSizeLimitMb) : configuredMaxMb;
2243
2308
  const maxBytes = maxFileSizeMb * 1024 * 1024;
2244
2309
  const selected = withinLimit.filter((file) => file.size <= maxBytes);
2310
+ const oversizedFiles = withinLimit.filter((file) => file.size > maxBytes);
2245
2311
  const dropped = files.length - withinLimit.length;
2246
2312
  const oversized = withinLimit.length - selected.length;
2247
2313
  if (dropped > 0 || oversized > 0) {
@@ -2251,17 +2317,29 @@ var DomFormController = class {
2251
2317
  `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
2252
2318
  );
2253
2319
  }
2254
- if (oversized > 0) {
2255
- notices.push(
2256
- `${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
2257
- );
2258
- }
2259
2320
  const text = notices.join(" ");
2260
- this.uploadNotices.set(field.id, text);
2261
- this.announce(text);
2321
+ if (text) this.uploadNotices.set(field.id, text);
2322
+ else this.uploadNotices.delete(field.id);
2323
+ if (text) this.announce(text);
2262
2324
  } else {
2263
2325
  this.uploadNotices.delete(field.id);
2264
2326
  }
2327
+ for (const file of oversizedFiles) {
2328
+ const message = DEFAULT_FIELD_STRINGS.fileTooLarge(maxFileSizeMb);
2329
+ const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
2330
+ this.inFlight.set(key, {
2331
+ fieldId: field.id,
2332
+ name: file.name,
2333
+ size: file.size,
2334
+ fraction: 0,
2335
+ error: message,
2336
+ tooLarge: true,
2337
+ file,
2338
+ controller: new AbortController(),
2339
+ maxFiles
2340
+ });
2341
+ this.announceAlert(message);
2342
+ }
2265
2343
  if (selected.length === 0) {
2266
2344
  this.queueRender();
2267
2345
  return;
@@ -2436,11 +2514,6 @@ var DomFormController = class {
2436
2514
  applyFocus(intent, captured) {
2437
2515
  if (typeof document === "undefined") return;
2438
2516
  if (intent === "error") {
2439
- const summary = this.element.querySelector(".fillo-error-summary");
2440
- if (summary) {
2441
- summary.focus();
2442
- return;
2443
- }
2444
2517
  const invalid = this.element.querySelector('[aria-invalid="true"]');
2445
2518
  invalid && focusableWithin(invalid)?.focus();
2446
2519
  return;
@@ -2770,8 +2843,6 @@ var DomFormController = class {
2770
2843
  })
2771
2844
  );
2772
2845
  }
2773
- const summary = this.renderErrorSummary(api);
2774
- if (summary) content.appendChild(summary);
2775
2846
  const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
2776
2847
  for (const block of api.blocks) {
2777
2848
  const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
@@ -2879,45 +2950,6 @@ var DomFormController = class {
2879
2950
  }
2880
2951
  return form;
2881
2952
  }
2882
- /** GOV.UK error summary (audit P2.8): scoped to the CURRENTLY RENDERED
2883
- * page — a failed submit already jumps to the first page with a visible
2884
- * error, so every link resolves to something in the live tree. Per-field
2885
- * messages lose their own role="alert"; this is the one alert. */
2886
- renderErrorSummary(api) {
2887
- const fields = api.blocks.filter(isField).filter((field) => api.errors[field.id]);
2888
- if (fields.length === 0) return null;
2889
- const box = el("div", {
2890
- className: "fillo-error-summary",
2891
- attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
2892
- });
2893
- box.appendChild(
2894
- el("p", {
2895
- className: "fillo-error-summary-title",
2896
- text: DEFAULT_FIELD_STRINGS.errorSummaryTitle
2897
- })
2898
- );
2899
- const list = el("ul");
2900
- for (const field of fields) {
2901
- const link = el("a", {
2902
- text: `${field.label}: ${api.errors[field.id]}`,
2903
- attrs: { href: `#${fieldIds(this.instanceId, field.id).inputId}` }
2904
- });
2905
- link.addEventListener("click", (event) => {
2906
- event.preventDefault();
2907
- const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
2908
- (node) => node.getAttribute("data-field") === field.id
2909
- );
2910
- if (!fieldEl) return;
2911
- const invalid = fieldEl.querySelector('[aria-invalid="true"]') ?? fieldEl;
2912
- focusableWithin(invalid)?.focus();
2913
- });
2914
- const item = el("li");
2915
- item.appendChild(link);
2916
- list.appendChild(item);
2917
- }
2918
- box.appendChild(list);
2919
- return box;
2920
- }
2921
2953
  renderBlock(block, api) {
2922
2954
  if (!isField(block)) return renderContent(block);
2923
2955
  const setValue = (value, options) => {
@@ -2936,7 +2968,7 @@ var DomFormController = class {
2936
2968
  const context = {
2937
2969
  field: block,
2938
2970
  value: api.data[block.id],
2939
- error: api.errors[block.id],
2971
+ error: api.errors[block.id] === REQUIRED_FIELD_MESSAGE ? requiredFieldMessage(block, DEFAULT_FIELD_STRINGS) : api.errors[block.id],
2940
2972
  ids: fieldIds(this.instanceId, block.id),
2941
2973
  api,
2942
2974
  setValue,