@hmlr/govuk-react-components-library 1.0.1 → 1.0.3

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.esm.js CHANGED
@@ -2,7 +2,7 @@ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
2
  import { Accordion as Accordion$1, createAll, Button as Button$1, Checkboxes as Checkboxes$1, ErrorSummary as ErrorSummary$1, Header as Header$1, Radios as Radios$1, SkipLink as SkipLink$1, Tabs as Tabs$1 } from 'govuk-frontend';
3
3
  import { Link, useLocation } from 'react-router-dom';
4
4
  import * as React from 'react';
5
- import React__default, { useContext, useMemo, createElement, Component, useState, useEffect } from 'react';
5
+ import React__default, { useContext, useMemo, createElement, Component, useState, useRef, useEffect } from 'react';
6
6
 
7
7
  function ConfigureOverallAccordion($scope, config) {
8
8
  if (JSON.stringify(config) === JSON.stringify({})) {
@@ -484,7 +484,10 @@ const CardLink = /*#__PURE__*/React.forwardRef(({
484
484
  });
485
485
  CardLink.displayName = 'CardLink';
486
486
 
487
- var divWithClassName = (className => /*#__PURE__*/React.forwardRef((p, ref) => /*#__PURE__*/jsx("div", {
487
+ var divWithClassName = (className =>
488
+ /*#__PURE__*/
489
+ // eslint-disable-next-line react/display-name
490
+ React.forwardRef((p, ref) => /*#__PURE__*/jsx("div", {
488
491
  ...p,
489
492
  ref: ref,
490
493
  className: classNames(p.className, className)
@@ -1008,28 +1011,281 @@ function Slugify(str) {
1008
1011
  return str;
1009
1012
  }
1010
1013
 
1014
+ /**
1015
+ * Convert a base64 string to an ArrayBuffer
1016
+ */
1017
+ const _base64ToArrayBuffer = (base64) => {
1018
+ const binaryString = window.atob(base64);
1019
+ const len = binaryString.length;
1020
+ const bytes = new Uint8Array(len);
1021
+ for (let i = 0; i < len; i++) {
1022
+ bytes[i] = binaryString.charCodeAt(i);
1023
+ }
1024
+ return bytes.buffer;
1025
+ };
1026
+ /**
1027
+ * Very small heuristic to detect a plain base64 string
1028
+ */
1029
+ const isBase64 = (value) => /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/.test(value);
1030
+ /**
1031
+ * ResolvePDFSource
1032
+ *
1033
+ * Accepts a source that may be:
1034
+ * - a plain base64 string (PDF binary encoded as base64)
1035
+ * - a data: URL containing base64 (data:application/pdf;base64,...)
1036
+ * - an existing blob: URL (blob:...)
1037
+ * - a Blob/File object
1038
+ * - a regular URL string (http(s) or same-origin path)
1039
+ *
1040
+ * Returns a SourceDeterminer:
1041
+ * - { isBase64Source: true, source: string } when an object URL was created
1042
+ * - { isBase64Source: false, source: string } when source can be used directly
1043
+ *
1044
+ * If an object URL is created, callers should revoke it when no longer needed:
1045
+ * `URL.revokeObjectURL(source)`
1046
+ */
1047
+ const ResolvePDFSource = (source) => {
1048
+ // Nothing provided
1049
+ if (source === undefined || source === null || source === "") {
1050
+ return { isBase64Source: false, source: "" };
1051
+ }
1052
+ // If source is already a Blob (File), create an object URL
1053
+ if (typeof source.arrayBuffer === "function" ||
1054
+ source instanceof Blob) {
1055
+ const blob = source;
1056
+ const objectUrl = URL.createObjectURL(blob);
1057
+ return { isBase64Source: true, source: objectUrl };
1058
+ }
1059
+ // If source is a string:
1060
+ if (typeof source === "string") {
1061
+ // If it's already a blob URL, return as-is
1062
+ if (source.startsWith("blob:")) {
1063
+ return { isBase64Source: false, source };
1064
+ }
1065
+ // If it's a data URL (data:application/pdf;base64,....)
1066
+ if (source.startsWith("data:")) {
1067
+ // data:[<mediatype>][;base64],<data>
1068
+ const commaIndex = source.indexOf(",");
1069
+ if (commaIndex > -1) {
1070
+ const meta = source.substring(0, commaIndex);
1071
+ const dataPart = source.substring(commaIndex + 1);
1072
+ // If it contains base64 marker, decode accordingly
1073
+ if (meta.indexOf(";base64") !== -1) {
1074
+ try {
1075
+ const arrayBuffer = _base64ToArrayBuffer(dataPart);
1076
+ const blob = new Blob([arrayBuffer], { type: "application/pdf" });
1077
+ const objectUrl = URL.createObjectURL(blob);
1078
+ return { isBase64Source: true, source: objectUrl };
1079
+ }
1080
+ catch (err) {
1081
+ console.error("ResolvePDFSource: failed to convert data: URL to blob", err);
1082
+ // fallback to returning original data URL
1083
+ return { isBase64Source: false, source };
1084
+ }
1085
+ }
1086
+ else {
1087
+ // Not base64-encoded data URL — return as is
1088
+ return { isBase64Source: false, source };
1089
+ }
1090
+ }
1091
+ }
1092
+ // If it's a plain base64 string (no data: prefix), convert
1093
+ if (isBase64(source)) {
1094
+ try {
1095
+ const arrayBuffer = _base64ToArrayBuffer(source);
1096
+ const blob = new Blob([arrayBuffer], { type: "application/pdf" });
1097
+ const objectUrl = URL.createObjectURL(blob);
1098
+ return { isBase64Source: true, source: objectUrl };
1099
+ }
1100
+ catch (err) {
1101
+ console.error("ResolvePDFSource: failed to convert base64 to blob", err);
1102
+ return { isBase64Source: false, source };
1103
+ }
1104
+ }
1105
+ // Otherwise treat as a normal URL (http(s) or same-origin path)
1106
+ return { isBase64Source: false, source };
1107
+ }
1108
+ // Fallback: convert to string
1109
+ try {
1110
+ const asString = String(source);
1111
+ return { isBase64Source: false, source: asString };
1112
+ }
1113
+ catch (err) {
1114
+ console.error("ResolvePDFSource: failed to convert source to string", err);
1115
+ return { isBase64Source: false, source: "" };
1116
+ }
1117
+ };
1118
+
1011
1119
  const PDFViewer = (props) => {
1012
1120
  const viewerRef = React__default.useRef(null);
1013
- const { iframeId, src, viewerLocation, documentName, documentNameColour, backend, toolbar = "minimal", additionalBackendAttributes, ...attributes } = props;
1121
+ const { iframeId, src, viewerLocation, documentName, documentNameColour, backend, toolbar = "minimal", additionalBackendAttributes,
1122
+ // optional: lets callers skip client-side fetch for remote resources
1123
+ disableClientFetch = false, ...attributes } = props;
1014
1124
  const [loading, setLoading] = useState(false);
1125
+ // ref for object URL (either created by ResolvePDFSource or by us after fetching)
1126
+ const objectUrlRef = useRef(null);
1127
+ // did ResolvePDFSource create the object URL? (helps understanding where to revoke)
1128
+ const createdByResolveRef = useRef(false);
1129
+ // fetch abort controller for cross-origin remote fetches
1130
+ const fetchControllerRef = useRef(null);
1131
+ const isRemoteUrl = (url) => {
1132
+ if (!url)
1133
+ return false;
1134
+ // Check if it's an HTTP(S) URL
1135
+ return /^https?:\/\//i.test(url);
1136
+ };
1015
1137
  useEffect(() => {
1138
+ let mounted = true;
1016
1139
  setLoading(true);
1017
1140
  const element = viewerRef.current;
1018
- if (!element)
1019
- return; // Guard against null
1020
- const jsProps = {
1021
- iframeId,
1022
- source: src,
1023
- viewerLocation,
1024
- documentName,
1025
- documentNameColour,
1026
- element,
1027
- toolbar,
1028
- ...additionalBackendAttributes,
1141
+ if (!element) {
1142
+ setLoading(false);
1143
+ return;
1144
+ }
1145
+ // cleanup previous URL/fetch if any
1146
+ if (fetchControllerRef.current) {
1147
+ try {
1148
+ fetchControllerRef.current.abort();
1149
+ }
1150
+ catch (err) {
1151
+ console.error("PDFViewer: fetch abort error:", err);
1152
+ /* ignore */
1153
+ }
1154
+ fetchControllerRef.current = null;
1155
+ }
1156
+ if (objectUrlRef.current) {
1157
+ try {
1158
+ URL.revokeObjectURL(objectUrlRef.current);
1159
+ }
1160
+ catch (err) {
1161
+ console.error("PDFViewer: revokeObjectURL error:", err);
1162
+ }
1163
+ objectUrlRef.current = null;
1164
+ createdByResolveRef.current = false;
1165
+ }
1166
+ const init = async () => {
1167
+ let sourceToUse = src;
1168
+ // 1) Let ResolvePDFSource handle base64/data/blob inputs synchronously.
1169
+ try {
1170
+ const resolved = ResolvePDFSource(src);
1171
+ if (resolved && resolved.source) {
1172
+ sourceToUse = resolved.source;
1173
+ if (resolved.isBase64Source) {
1174
+ // ResolvePDFSource created an object URL for us (data/blob/base64)
1175
+ objectUrlRef.current = resolved.source;
1176
+ createdByResolveRef.current = true;
1177
+ }
1178
+ }
1179
+ }
1180
+ catch (err) {
1181
+ // If ResolvePDFSource throws, just ignore and fall back to src
1182
+ console.error("PDFViewer: ResolvePDFSource error:", err);
1183
+ }
1184
+ // 2) If ResolvePDFSource didn't create an object URL and the src is cross-origin HTTP(S),
1185
+ // attempt a client-side fetch to create an object URL (so viewer can load it safely).
1186
+ if (!objectUrlRef.current &&
1187
+ !disableClientFetch &&
1188
+ typeof src === "string" &&
1189
+ /^https?:\/\//i.test(src) &&
1190
+ isRemoteUrl(src)) {
1191
+ const controller = new AbortController();
1192
+ fetchControllerRef.current = controller;
1193
+ try {
1194
+ const resp = await fetch(src, {
1195
+ signal: controller.signal,
1196
+ credentials: "omit",
1197
+ });
1198
+ if (!resp.ok) {
1199
+ console.warn(`PDFViewer: fetch returned ${resp.status} ${resp.statusText} for ${src}`);
1200
+ // fallback: keep using original src
1201
+ }
1202
+ else {
1203
+ const blob = await resp.blob();
1204
+ if (blob && blob.size > 0) {
1205
+ const obj = URL.createObjectURL(blob);
1206
+ objectUrlRef.current = obj;
1207
+ createdByResolveRef.current = false; // we created it via fetch
1208
+ sourceToUse = obj;
1209
+ }
1210
+ else {
1211
+ console.warn("PDFViewer: fetched blob is empty - falling back to original src");
1212
+ }
1213
+ }
1214
+ }
1215
+ catch (err) {
1216
+ if (err?.name === "AbortError") {
1217
+ // aborted, nothing to do
1218
+ if (!mounted)
1219
+ return;
1220
+ }
1221
+ else {
1222
+ console.error("PDFViewer: error fetching cross-origin PDF:", err);
1223
+ }
1224
+ // fallback to source as-is (original remote URL)
1225
+ }
1226
+ }
1227
+ // 3) Prepare and call backend
1228
+ if (!mounted)
1229
+ return;
1230
+ const jsProps = {
1231
+ iframeId,
1232
+ source: sourceToUse,
1233
+ viewerLocation,
1234
+ documentName,
1235
+ documentNameColour,
1236
+ element,
1237
+ toolbar,
1238
+ ...additionalBackendAttributes,
1239
+ };
1240
+ try {
1241
+ backend(jsProps);
1242
+ }
1243
+ catch (err) {
1244
+ console.error("PDFViewer: backend threw an error:", err);
1245
+ }
1246
+ finally {
1247
+ if (mounted)
1248
+ setLoading(false);
1249
+ }
1250
+ }; // end init
1251
+ init();
1252
+ return () => {
1253
+ mounted = false;
1254
+ // cancel any ongoing fetch
1255
+ if (fetchControllerRef.current) {
1256
+ try {
1257
+ fetchControllerRef.current.abort();
1258
+ }
1259
+ catch (err) {
1260
+ console.error("PDFViewer: fetch abort error during cleanup:", err);
1261
+ }
1262
+ fetchControllerRef.current = null;
1263
+ }
1264
+ // revoke any object URL we created
1265
+ if (objectUrlRef.current) {
1266
+ try {
1267
+ URL.revokeObjectURL(objectUrlRef.current);
1268
+ }
1269
+ catch (err) {
1270
+ console.error("PDFViewer: fetch abort error during cleanup:", err);
1271
+ }
1272
+ objectUrlRef.current = null;
1273
+ createdByResolveRef.current = false;
1274
+ }
1275
+ // Note: PDFViewerBackend also attaches an iframe to the element. If backend does not
1276
+ // remove previous iframes, you might want to clear element.children here.
1029
1277
  };
1030
- backend(jsProps);
1031
- setLoading(false);
1032
- }, []);
1278
+ // re-run when these inputs change
1279
+ }, [
1280
+ src,
1281
+ iframeId,
1282
+ viewerLocation,
1283
+ documentName,
1284
+ documentNameColour,
1285
+ toolbar,
1286
+ backend,
1287
+ disableClientFetch,
1288
+ ]);
1033
1289
  return (jsxs(Fragment, { children: [loading && jsx(Loading, { message: "Loading PDF Document" }), jsx("div", { ref: viewerRef, id: `viewer ${documentName ? Slugify(documentName) : ""}`, "data-testid": `viewer ${documentName ? Slugify(documentName) : ""}`, style: { width: "100%", height: "100%" }, ...attributes })] }));
1034
1290
  };
1035
1291
 
@@ -1043,27 +1299,6 @@ const ObjectToQueryString = (queryParameters) => {
1043
1299
  : "";
1044
1300
  };
1045
1301
 
1046
- const _base64ToArrayBuffer = (base64) => {
1047
- const binaryString = window.atob(base64);
1048
- const len = binaryString.length;
1049
- const bytes = new Uint8Array(len);
1050
- for (let i = 0; i < len; i++) {
1051
- bytes[i] = binaryString.charCodeAt(i);
1052
- }
1053
- return bytes.buffer;
1054
- };
1055
- const isBase64 = (value) => /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/.test(value);
1056
- const ResolvePDFSource = (source) => {
1057
- const isBase64Source = isBase64(source);
1058
- if (!isBase64Source) {
1059
- return { isBase64Source, source };
1060
- }
1061
- const blob = new Blob([_base64ToArrayBuffer(source)], {
1062
- type: "application/pdf",
1063
- });
1064
- return { isBase64Source, source: URL.createObjectURL(blob) };
1065
- };
1066
-
1067
1302
  const PDFViewerBackend = ({ iframeId, source, viewerLocation = "/pdfjs-4.4.168-dist/web/viewer.html", documentName, documentNameColour = "black", toolbar = "minimal", element, ...remaining }) => {
1068
1303
  const sourceDeterminer = ResolvePDFSource(source);
1069
1304
  const iframe = document.createElement("iframe");
@@ -1080,6 +1315,8 @@ const PDFViewerBackend = ({ iframeId, source, viewerLocation = "/pdfjs-4.4.168-d
1080
1315
  iframe.src = `${viewerLocation}${queryString}`;
1081
1316
  iframe.style.width = "100%";
1082
1317
  iframe.style.height = "94%";
1318
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-downloads");
1319
+ iframe.setAttribute("allow", "cross-origin-isolated");
1083
1320
  if (sourceDeterminer.isBase64Source) {
1084
1321
  iframe.addEventListener("beforeunload", (event) => {
1085
1322
  event.preventDefault();
@@ -1104,13 +1341,22 @@ const Radios = (props) => {
1104
1341
  const processedItems = items
1105
1342
  ? items.map((item) => {
1106
1343
  if (item) {
1107
- return {
1108
- ...item,
1109
- ...(value != null && { checked: item.value === value }),
1110
- ...(defaultValue != null && {
1344
+ // Only set checked/defaultChecked - not both
1345
+ if (value != null) {
1346
+ return {
1347
+ ...item,
1348
+ checked: item.value === value,
1349
+ defaultChecked: undefined, // Remove defaultChecked if value is provided
1350
+ };
1351
+ }
1352
+ else if (defaultValue != null) {
1353
+ return {
1354
+ ...item,
1111
1355
  defaultChecked: item.value === defaultValue,
1112
- }),
1113
- };
1356
+ checked: undefined, // Remove checked if defaultValue is provided
1357
+ };
1358
+ }
1359
+ return item;
1114
1360
  }
1115
1361
  return item;
1116
1362
  })