@ukhomeoffice/formio-gds-template 2.0.2 → 3.0.0-bravo

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 (150) hide show
  1. package/.github/workflows/build-and-test.yml +3 -3
  2. package/LICENCE.md +21 -0
  3. package/README.md +8 -0
  4. package/eslint.config.mjs +59 -0
  5. package/lib/components/datamap/GDSDataMap.d.ts +5 -14
  6. package/lib/components/datamap/GDSDataMap.d.ts.map +1 -0
  7. package/lib/components/datamap/GDSDataMap.js +9 -29
  8. package/lib/components/datetime/GDSDatetimeComponent.d.ts +43 -22
  9. package/lib/components/datetime/GDSDatetimeComponent.d.ts.map +1 -0
  10. package/lib/components/datetime/GDSDatetimeComponent.js +167 -178
  11. package/lib/components/index.d.ts +3 -0
  12. package/lib/components/index.d.ts.map +1 -0
  13. package/lib/components/index.js +6 -4
  14. package/lib/components/select/GDSSelect.d.ts +143 -0
  15. package/lib/components/select/GDSSelect.d.ts.map +1 -0
  16. package/lib/components/select/GDSSelect.js +340 -0
  17. package/lib/components/selectboxes/GDSCheckBoxes.d.ts +5 -2
  18. package/lib/components/selectboxes/GDSCheckBoxes.d.ts.map +1 -0
  19. package/lib/components/selectboxes/GDSCheckBoxes.js +7 -33
  20. package/lib/components/time/GDSTimeComponent.d.ts +13 -10
  21. package/lib/components/time/GDSTimeComponent.d.ts.map +1 -0
  22. package/lib/components/time/GDSTimeComponent.js +59 -84
  23. package/lib/components/util/TimeHelper.d.ts +6 -4
  24. package/lib/components/util/TimeHelper.d.ts.map +1 -0
  25. package/lib/components/util/TimeHelper.js +13 -16
  26. package/lib/index.d.ts +4 -2
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +2 -2
  29. package/lib/templates/gds/button/index.d.ts +1 -0
  30. package/lib/templates/gds/button/index.d.ts.map +1 -0
  31. package/lib/templates/gds/button/index.js +1 -1
  32. package/lib/templates/gds/checkbox/index.d.ts +1 -0
  33. package/lib/templates/gds/checkbox/index.d.ts.map +1 -0
  34. package/lib/templates/gds/checkbox/index.js +1 -1
  35. package/lib/templates/gds/columns/index.d.ts +1 -0
  36. package/lib/templates/gds/columns/index.d.ts.map +1 -0
  37. package/lib/templates/gds/columns/index.js +1 -1
  38. package/lib/templates/gds/component/index.d.ts +1 -0
  39. package/lib/templates/gds/component/index.d.ts.map +1 -0
  40. package/lib/templates/gds/component/index.js +1 -1
  41. package/lib/templates/gds/container/index.d.ts +1 -0
  42. package/lib/templates/gds/container/index.d.ts.map +1 -0
  43. package/lib/templates/gds/container/index.js +1 -1
  44. package/lib/templates/gds/cssClasses.d.ts +1 -0
  45. package/lib/templates/gds/cssClasses.d.ts.map +1 -0
  46. package/lib/templates/gds/datagrid/form.ejs.js +2 -2
  47. package/lib/templates/gds/datagrid/index.d.ts +1 -0
  48. package/lib/templates/gds/datagrid/index.d.ts.map +1 -0
  49. package/lib/templates/gds/datagrid/index.js +1 -1
  50. package/lib/templates/gds/datamap/index.d.ts +1 -0
  51. package/lib/templates/gds/datamap/index.d.ts.map +1 -0
  52. package/lib/templates/gds/datamap/index.js +1 -1
  53. package/lib/templates/gds/datetime/index.d.ts +1 -0
  54. package/lib/templates/gds/datetime/index.d.ts.map +1 -0
  55. package/lib/templates/gds/datetime/index.js +1 -1
  56. package/lib/templates/gds/day/index.d.ts +1 -0
  57. package/lib/templates/gds/day/index.d.ts.map +1 -0
  58. package/lib/templates/gds/day/index.js +1 -1
  59. package/lib/templates/gds/editgrid/index.d.ts +1 -0
  60. package/lib/templates/gds/editgrid/index.d.ts.map +1 -0
  61. package/lib/templates/gds/editgrid/index.js +1 -1
  62. package/lib/templates/gds/field/index.d.ts +1 -0
  63. package/lib/templates/gds/field/index.d.ts.map +1 -0
  64. package/lib/templates/gds/field/index.js +1 -1
  65. package/lib/templates/gds/fieldset/index.d.ts +1 -0
  66. package/lib/templates/gds/fieldset/index.d.ts.map +1 -0
  67. package/lib/templates/gds/fieldset/index.js +1 -1
  68. package/lib/templates/gds/file/index.d.ts +1 -0
  69. package/lib/templates/gds/file/index.d.ts.map +1 -0
  70. package/lib/templates/gds/file/index.js +1 -1
  71. package/lib/templates/gds/iconClass.d.ts +2 -1
  72. package/lib/templates/gds/iconClass.d.ts.map +1 -0
  73. package/lib/templates/gds/iconClass.js +3 -3
  74. package/lib/templates/gds/index.d.ts +3 -2
  75. package/lib/templates/gds/index.d.ts.map +1 -0
  76. package/lib/templates/gds/index.js +72 -64
  77. package/lib/templates/gds/input/index.d.ts +1 -0
  78. package/lib/templates/gds/input/index.d.ts.map +1 -0
  79. package/lib/templates/gds/input/index.js +1 -1
  80. package/lib/templates/gds/label/index.d.ts +1 -0
  81. package/lib/templates/gds/label/index.d.ts.map +1 -0
  82. package/lib/templates/gds/label/index.js +1 -1
  83. package/lib/templates/gds/message/index.d.ts +1 -0
  84. package/lib/templates/gds/message/index.d.ts.map +1 -0
  85. package/lib/templates/gds/message/index.js +1 -1
  86. package/lib/templates/gds/panel/index.d.ts +1 -0
  87. package/lib/templates/gds/panel/index.d.ts.map +1 -0
  88. package/lib/templates/gds/panel/index.js +1 -1
  89. package/lib/templates/gds/radio/index.d.ts +1 -0
  90. package/lib/templates/gds/radio/index.d.ts.map +1 -0
  91. package/lib/templates/gds/radio/index.js +1 -1
  92. package/lib/templates/gds/select/index.d.ts +1 -0
  93. package/lib/templates/gds/select/index.d.ts.map +1 -0
  94. package/lib/templates/gds/select/index.js +1 -1
  95. package/lib/templates/gds/selectOption/index.d.ts +1 -0
  96. package/lib/templates/gds/selectOption/index.d.ts.map +1 -0
  97. package/lib/templates/gds/selectOption/index.js +1 -1
  98. package/lib/templates/gds/selectboxes/index.d.ts +1 -0
  99. package/lib/templates/gds/selectboxes/index.d.ts.map +1 -0
  100. package/lib/templates/gds/selectboxes/index.js +1 -1
  101. package/lib/templates/gds/survey/index.d.ts +1 -0
  102. package/lib/templates/gds/survey/index.d.ts.map +1 -0
  103. package/lib/templates/gds/survey/index.js +1 -1
  104. package/lib/templates/gds/tab/index.d.ts +1 -0
  105. package/lib/templates/gds/tab/index.d.ts.map +1 -0
  106. package/lib/templates/gds/tab/index.js +1 -1
  107. package/lib/templates/gds/table/index.d.ts +1 -0
  108. package/lib/templates/gds/table/index.d.ts.map +1 -0
  109. package/lib/templates/gds/table/index.js +1 -1
  110. package/lib/templates/gds/template.css +27 -0
  111. package/lib/templates/gds/template.css.map +1 -1
  112. package/lib/templates/gds/time/index.d.ts +1 -0
  113. package/lib/templates/gds/time/index.d.ts.map +1 -0
  114. package/lib/templates/gds/time/index.js +1 -1
  115. package/lib/templates/gds/warning/index.d.ts +1 -0
  116. package/lib/templates/gds/warning/index.d.ts.map +1 -0
  117. package/lib/templates/gds/warning/index.js +1 -1
  118. package/lib/templates/gds/wizard/index.d.ts +1 -0
  119. package/lib/templates/gds/wizard/index.d.ts.map +1 -0
  120. package/lib/templates/gds/wizard/index.js +1 -1
  121. package/lib/templates/gds/wizardHeader/index.d.ts +1 -0
  122. package/lib/templates/gds/wizardHeader/index.d.ts.map +1 -0
  123. package/lib/templates/gds/wizardHeader/index.js +1 -1
  124. package/lib/templates/gds/wizardNav/index.d.ts +1 -0
  125. package/lib/templates/gds/wizardNav/index.d.ts.map +1 -0
  126. package/lib/templates/gds/wizardNav/index.js +1 -1
  127. package/lib/templates/index.d.ts +3 -2
  128. package/lib/templates/index.d.ts.map +1 -0
  129. package/lib/templates/index.js +1 -1
  130. package/lib/types.d.ts +37 -0
  131. package/lib/types.d.ts.map +1 -0
  132. package/lib/types.js +2 -0
  133. package/package.json +14 -7
  134. package/src/components/datamap/GDSDataMap.ts +5 -14
  135. package/src/components/datetime/GDSDatetimeComponent.ts +128 -91
  136. package/src/components/index.ts +2 -0
  137. package/src/components/select/GDSSelect.spec.ts +538 -0
  138. package/src/components/select/GDSSelect.ts +418 -0
  139. package/src/components/selectboxes/GDSCheckBoxes.ts +7 -8
  140. package/src/components/time/GDSTimeComponent.ts +37 -27
  141. package/src/components/util/TimeHelper.ts +6 -5
  142. package/src/css.d.ts +4 -0
  143. package/src/templates/gds/datagrid/form.ejs +1 -2
  144. package/src/templates/gds/iconClass.ts +1 -1
  145. package/src/templates/gds/index.ts +43 -33
  146. package/src/templates/gds/template.scss +48 -1
  147. package/src/types.ts +56 -0
  148. package/tsconfig.json +21 -5
  149. package/webpack.config.js +1 -1
  150. package/tslint.json +0 -22
@@ -0,0 +1,418 @@
1
+ import { Components } from '@formio/js';
2
+ import _ from 'lodash';
3
+ import { SelectComponent as CoreSelectComponent } from '../../types';
4
+
5
+ const Select = Components.components.select;
6
+
7
+ // Choices.js choice object (internal shape used by the store).
8
+ interface ChoiceItem {
9
+ label: string;
10
+ active: boolean;
11
+ rank: number;
12
+ id: string | number;
13
+ choiceEl?: HTMLElement;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ interface SearchResult {
18
+ item: ChoiceItem;
19
+ score: number;
20
+ rank: number;
21
+ }
22
+
23
+ // Minimal interface for the choices.js instance attached as `this.choices`.
24
+ interface ChoicesInstance {
25
+ _isSearching: boolean;
26
+ _currentValue: string;
27
+ _store: {
28
+ choices: ChoiceItem[];
29
+ dispatch(action: { type: string; active?: boolean; results?: SearchResult[] }): void;
30
+ };
31
+ _searchChoices?: (needle: string) => number | null;
32
+ input?: { element?: HTMLInputElement };
33
+ }
34
+
35
+ /**
36
+ * A formio item from a URL datasource — at minimum it has an id and arbitrary
37
+ * string fields (e.g. name, code). We only constrain what we actually access.
38
+ */
39
+ interface ReferenceItem {
40
+ id?: string | number;
41
+ label?: string;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ /**
46
+ * Shape of the `value` argument passed to itemTemplate / getOptionTemplate.
47
+ * For reference selects (valueProperty = '') this is the full item object;
48
+ * for string selects it is a plain string.
49
+ */
50
+ type SelectValue = ReferenceItem | string | number | null | undefined;
51
+
52
+ export default class GDSSelect extends Select {
53
+ declare component: CoreSelectComponent;
54
+
55
+ // Keep the patched instance, rather than a boolean, because redraw can
56
+ // destroy and recreate Choices.
57
+ protected _gdsSearchPatchedChoices?: ChoicesInstance;
58
+ // The interpolated `filter` that produced the currently loaded options.
59
+ protected _gdsFilterKey?: string;
60
+
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
62
+ constructor(component?: any, options?: any, data?: any) {
63
+ super(component, options, data);
64
+ }
65
+
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ init(): any {
68
+ // Clear searchField so Form.io does not trigger server-side queries while
69
+ // the complete reference dataset is filtered locally.
70
+ if (this.component.searchField) {
71
+ this.component.searchField = '';
72
+ }
73
+ // Reference-data selects are filtered locally. Do not truncate the
74
+ // alphabetically sorted API response before local filtering.
75
+ const url = (this.component.data as { url?: unknown } | undefined)?.url;
76
+ if (typeof url === 'string' && url.includes('/v2/entities/') && (this.component.limit ?? 100) < 1000) {
77
+ this.component.disableLimit = true;
78
+ }
79
+ return super.init();
80
+ }
81
+
82
+ /**
83
+ * Patch the choices.js instance attached to this component so that every
84
+ * search query uses a plain case-insensitive substring filter instead of
85
+ * fuse.js. The patch is applied lazily the first time items are set so
86
+ * that `this.choices` is guaranteed to exist.
87
+ */
88
+ protected _patchChoicesSearch(): void {
89
+ const choices = (this as unknown as { choices: ChoicesInstance }).choices;
90
+ if (!choices || choices === this._gdsSearchPatchedChoices) {
91
+ return;
92
+ }
93
+ this._gdsSearchPatchedChoices = choices;
94
+
95
+ // Replace the internal search method with a substring implementation.
96
+ (choices as unknown as Record<string, unknown>)._searchChoices = (needle: string): number | null => {
97
+ if (!needle) {
98
+ choices._isSearching = false;
99
+ choices._currentValue = '';
100
+ choices._store.dispatch({ type: 'ACTIVATE_CHOICES', active: true });
101
+ return 0;
102
+ }
103
+
104
+ const normalizedNeedle = needle.trim().toLowerCase().replace(/\s{2,}/g, ' ');
105
+ if (!normalizedNeedle || normalizedNeedle === choices._currentValue) {
106
+ return null;
107
+ }
108
+
109
+ const words = normalizedNeedle.split(/\s+/).filter(Boolean);
110
+ const allChoices: ChoiceItem[] = choices._store.choices;
111
+
112
+ const matched: SearchResult[] = [];
113
+ const prefix: ChoiceItem[] = [];
114
+ const contains: ChoiceItem[] = [];
115
+
116
+ for (const choice of allChoices) {
117
+ const renderedLabel = choice.choiceEl?.textContent || '';
118
+ const label = [choice.label, renderedLabel, choice.value]
119
+ .map((value) => String(value ?? '').toLowerCase())
120
+ .join(' ');
121
+ const allMatch = words.every((w) => label.includes(w));
122
+ if (!allMatch) {
123
+ choice.active = false;
124
+ choice.rank = 0;
125
+ continue;
126
+ }
127
+ if (label.startsWith(normalizedNeedle)) {
128
+ prefix.push(choice);
129
+ } else {
130
+ contains.push(choice);
131
+ }
132
+ }
133
+
134
+ // Prefix matches first, then other substring matches, both sorted
135
+ // alphabetically within their group.
136
+ prefix.sort((a, b) => a.label.localeCompare(b.label));
137
+ contains.sort((a, b) => a.label.localeCompare(b.label));
138
+ [...prefix, ...contains].forEach((choice, index) => {
139
+ matched.push({
140
+ item: choice,
141
+ score: 0,
142
+ rank: index + 1,
143
+ });
144
+ });
145
+
146
+ choices._isSearching = true;
147
+ choices._currentValue = normalizedNeedle;
148
+ choices._store.dispatch({ type: 'FILTER_CHOICES', results: matched });
149
+
150
+ return matched.length;
151
+ };
152
+ }
153
+
154
+ /**
155
+ * True when this select's `filter` is interpolated from other form data
156
+ * (e.g. a port list filtered by the selected country).
157
+ */
158
+ protected get _gdsHasDynamicFilter(): boolean {
159
+ return typeof this.component.filter === 'string' && this.component.filter.includes('{{');
160
+ }
161
+
162
+ /** The interpolated filter, used to detect when the dependency changed. */
163
+ protected _gdsCurrentFilterKey(): string {
164
+ return this._gdsHasDynamicFilter ? this.interpolate(this.component.filter as string) : '';
165
+ }
166
+
167
+ // Preserve Form.io's item lifecycle, then patch the Choices instance that
168
+ // Form.io has populated.
169
+ setItems(items: unknown, fromSearch?: boolean): void {
170
+ super.setItems(items, fromSearch);
171
+ if (!fromSearch) {
172
+ this._gdsFilterKey = this._gdsCurrentFilterKey();
173
+ }
174
+ this._patchChoicesSearch();
175
+ }
176
+
177
+ // Also patch on attach in case setItems was already called.
178
+ attach(element: HTMLElement): Promise<unknown> {
179
+ const result = super.attach(element);
180
+ this._patchChoicesSearch();
181
+ return Promise.resolve(result);
182
+ }
183
+
184
+ // Form.io only refreshes a URL select with an interpolated filter when the
185
+ // component is redrawn, or when it transitions hidden -> visible (see the
186
+ // `visible` setter in Select). A select that is already visible when its
187
+ // filter dependency changes is therefore never re-fetched, leaving stale
188
+ // options (e.g. changing country a second time keeps the first country's
189
+ // ports). checkComponentConditions runs for every component on every data
190
+ // change, so compare the interpolated filter here and reload when it moves.
191
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
192
+ checkComponentConditions(data: any, flags: any, row: any): boolean {
193
+ const visible = super.checkComponentConditions(data, flags, row);
194
+
195
+ // `_gdsFilterKey` is only set once options have been loaded, so this
196
+ // cannot force a premature fetch for a lazyLoad select that the user has
197
+ // not opened yet. Note `active` is deliberately not checked: Form.io's
198
+ // `visible` setter calls triggerUpdate() without calling activate(), so
199
+ // a loaded lazyLoad select can still report active === false.
200
+ if (
201
+ visible &&
202
+ this.component.dataSrc === 'url' &&
203
+ this._gdsHasDynamicFilter &&
204
+ this._gdsFilterKey !== undefined &&
205
+ !this.loading
206
+ ) {
207
+ const filterKey = this._gdsCurrentFilterKey();
208
+ if (filterKey !== this._gdsFilterKey) {
209
+ // Record before requesting so a change that arrives while the
210
+ // request is in flight does not queue a duplicate fetch.
211
+ this._gdsFilterKey = filterKey;
212
+ this.triggerUpdate?.(null, true);
213
+ }
214
+ }
215
+
216
+ return visible;
217
+ }
218
+
219
+ // formio's redraw() and activate() both call triggerUpdate() -> updateItems(null, false),
220
+ // which would cause a URL re-fetch even when items are already cached. We suppress
221
+ // the re-fetch when the cache is valid, but we must still re-populate choices.js
222
+ // with the cached items. This matters because activate() calls setLoadingItem()
223
+ // before triggerUpdate(), which replaces the choices list with a spinner. Without
224
+ // re-calling setItems the spinner stays even though data is already available.
225
+ //
226
+ // Filter-dependency changes (refreshOn) always arrive with forceUpdate=true via
227
+ // Select.refresh(), so they bypass this guard and fetch fresh data as expected.
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
+ updateItems(searchInput: unknown, forceUpdate: boolean): any {
230
+ if (
231
+ !forceUpdate &&
232
+ !searchInput &&
233
+ this.component.dataSrc === 'url' &&
234
+ this._gdsFilterKey === this._gdsCurrentFilterKey() &&
235
+ Array.isArray(this.downloadedResources) &&
236
+ this.downloadedResources.length > 0 &&
237
+ !this.loading
238
+ ) {
239
+ // Re-populate choices from the cache so any spinner set by activate()
240
+ // is replaced with the actual items, then resolve the loaded promise.
241
+ this.setItems(this.downloadedResources, false);
242
+ return;
243
+ }
244
+ return super.updateItems(searchInput, forceUpdate);
245
+ }
246
+
247
+ /**
248
+ * Override getOptionTemplate to escape item data values with `_.escape()`
249
+ * before interpolating them into the template.
250
+ *
251
+ * ## Security model
252
+ *
253
+ * formio 5.x `getOptionTemplate` (ListComponent) passes the assembled template
254
+ * string through `DOMPurify.sanitize()` — which internally calls
255
+ * `DOMParser.parseFromString()`. With thousands of reference-data items this
256
+ * is the dominant performance cost (~300 ms).
257
+ *
258
+ * We replace that post-hoc sanitization with pre-interpolation escaping:
259
+ *
260
+ * 1. Every string value in the item object is passed through `_.escape()`
261
+ * **before** it is substituted into the template. `_.escape` converts
262
+ * `< > & " '` to their HTML-entity equivalents (`&lt;` etc.).
263
+ * 2. The assembled template string — now containing only safe entity-encoded
264
+ * text where user/API data appears — is returned directly without any
265
+ * further DOMPurify call.
266
+ *
267
+ * ### Why this is equivalent security to DOMPurify for text-content templates
268
+ *
269
+ * A template such as `<span>{{ item.name }}</span>` embeds the API value as
270
+ * the *text content* of an element. The only injection vector is:
271
+ *
272
+ * ```
273
+ * item.name = '</span><script>alert(1)</script>'
274
+ * ```
275
+ *
276
+ * After `_.escape()` this becomes:
277
+ *
278
+ * ```
279
+ * &lt;/span&gt;&lt;script&gt;alert(1)&lt;/script&gt;
280
+ * ```
281
+ *
282
+ * The browser renders this as literal text, never as markup or script.
283
+ * DOMPurify would produce the same effective result by stripping the tags.
284
+ *
285
+ * ### Limitation — attribute / URL contexts
286
+ *
287
+ * `_.escape()` is **not** sufficient if the template places a value inside
288
+ * an HTML attribute or a URL (e.g. `<a href="{{ item.url }}">`).
289
+ * HTML-entity encoding does not prevent `javascript:` injection in `href`.
290
+ * If any form schema introduces such a template, this override must be
291
+ * revisited and either DOMPurify reinstated or the attribute value
292
+ * sanitized separately.
293
+ *
294
+ * Current GDS templates are all text-content only (`<span>{{ item.name }}</span>`
295
+ * etc.) so this limitation does not apply today.
296
+ */
297
+ getOptionTemplate(data: ReferenceItem, value: SelectValue, index?: number): string {
298
+ if (!this.component.template) {
299
+ return String(data.label ?? '');
300
+ }
301
+ const options = { noeval: true, data: {} as Record<string, unknown> };
302
+ // Escape all top-level string values in the item before interpolation.
303
+ // Nested objects are left as-is; they would need to be accessed via
304
+ // item.nested.field in the template, which is not used by current schemas.
305
+ const escapedData: ReferenceItem = _.mapValues(
306
+ data as Record<string, unknown>,
307
+ (v): unknown => (typeof v === 'string' ? _.escape(v) : v)
308
+ ) as ReferenceItem;
309
+ const template: string = this.component.template
310
+ ? this.interpolate(this.component.template, { item: escapedData }, options)
311
+ : String(data.label ?? '');
312
+ // Store template data for reference-field selected-value display.
313
+ // The `reference` flag is not in the generated CoreSelectComponent type
314
+ // but is a valid runtime formio property.
315
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
316
+ const isReference = Boolean((this.component as any).reference);
317
+ const rawId = (value as { _id?: unknown } | null | undefined)?._id;
318
+ const templateValue: SelectValue = isReference && rawId != null
319
+ ? String(rawId)
320
+ : value;
321
+ if (
322
+ templateValue != null &&
323
+ !_.isObject(templateValue) &&
324
+ (options.data as Record<string, unknown>).item
325
+ ) {
326
+ this.templateData[templateValue as string] = (options.data as Record<string, unknown>).item;
327
+ }
328
+ if (_.isNumber(index)) {
329
+ this.templateData[index] = (options.data as Record<string, unknown>).item;
330
+ }
331
+ return template;
332
+ }
333
+
334
+ /**
335
+ * Override itemTemplate to skip the redundant outer `sanitize()` call that
336
+ * formio 5.x `ListComponent.itemTemplate` wraps around `getOptionTemplate()`.
337
+ *
338
+ * ## Why the outer sanitize is now safe to skip
339
+ *
340
+ * Our `getOptionTemplate` override (above) pre-escapes all API data values
341
+ * with `_.escape()` before they are interpolated into the template. The
342
+ * assembled string therefore contains no unescaped HTML from variable sources.
343
+ * Running DOMPurify on that string a second time is idempotent — it produces
344
+ * the same output — but costs a full `DOMParser.parseFromString` call per item.
345
+ *
346
+ * This override handles only the common URL-datasource path (object data with
347
+ * a component template). All other branches — primitives, arrays, read-only,
348
+ * selectData, reference fields — fall through to `super.itemTemplate` which
349
+ * retains its full sanitize() call unchanged.
350
+ */
351
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
352
+ itemTemplate(data: ReferenceItem | string | number | unknown[], value: SelectValue, index?: number): any {
353
+ if (
354
+ typeof data === 'object' &&
355
+ data !== null &&
356
+ !Array.isArray(data) &&
357
+ this.component.template
358
+ ) {
359
+ // getOptionTemplate pre-escapes data values — no further sanitize needed.
360
+ const template = this.getOptionTemplate(data as ReferenceItem, value, index);
361
+ if (!template) {
362
+ return this.sanitize(JSON.stringify(data), this.shouldSanitizeValue);
363
+ }
364
+ // Strip tags to extract the visible label text for i18n translation,
365
+ // then re-embed it. This mirrors ListComponent.itemTemplate exactly.
366
+ const label = template.replace(/<\/?[^>]+(>|$)/g, '');
367
+ if (!label) return template;
368
+ return template.replace(label, this.t(label, { _userInput: true }));
369
+ }
370
+
371
+ // Fall back to the full Select → ListComponent chain for all other cases.
372
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
373
+ return super.itemTemplate(data as any, value, index);
374
+ }
375
+
376
+ // Disable Choices' fuzzy search and use the deterministic matcher above.
377
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
378
+ choicesOptions(): Record<string, any> {
379
+ const options = super.choicesOptions();
380
+ // Force Choices.js client-side searching.
381
+ // The instance method is replaced in _patchChoicesSearch with the GDS matcher.
382
+ options.searchChoices = true;
383
+ options.searchFloor = 0;
384
+ options.searchFields = ['label', 'value'];
385
+
386
+ // If searchResultLimit is configured too low, raise it to at least limit or 100.
387
+ const itemLimit: number = this.component.limit || 100;
388
+ if (typeof options.searchResultLimit === 'number' && options.searchResultLimit < itemLimit) {
389
+ options.searchResultLimit = itemLimit;
390
+ }
391
+ if (typeof options.renderChoiceLimit === 'number' && options.renderChoiceLimit < itemLimit) {
392
+ options.renderChoiceLimit = itemLimit;
393
+ }
394
+
395
+ // formio's default valueComparer is lodash.isEqual — a deep recursive comparison.
396
+ // choices.js calls this for every choice in the store when resolving which item
397
+ // is selected (setChoices / _isSelectedByValue). With thousands of object values
398
+ // {id, name} this produces O(n × depth) recursive comparisons (~5 s).
399
+ //
400
+ // For reference-data objects we compare by `id` alone: each port/currency/etc.
401
+ // has a unique id, so two values are equal iff their ids match — O(1) per call.
402
+ // Primitives fall back to ===, matching choices.js's own default.
403
+ options.valueComparer = (a: unknown, b: unknown): boolean => {
404
+ if (a === b) return true;
405
+ if (a == null || b == null) return false;
406
+ if (typeof a === 'object' && typeof b === 'object') {
407
+ const aId = (a as Record<string, unknown>).id;
408
+ const bId = (b as Record<string, unknown>).id;
409
+ if (aId !== undefined && bId !== undefined) {
410
+ return aId === bId;
411
+ }
412
+ }
413
+ return false;
414
+ };
415
+
416
+ return options;
417
+ }
418
+ }
@@ -1,20 +1,19 @@
1
- import {Components} from 'formiojs';
1
+ import { Components } from '@formio/js';
2
+ import { SelectBoxesComponent } from '../../types';
3
+
2
4
  const Field = Components.components.field;
3
5
  const SelectBoxes = Components.components.selectboxes;
4
6
 
5
7
  export default class GDSCheckBoxes extends SelectBoxes {
6
- public render() {
7
- // @ts-ignore
8
+ // Narrow the inherited `component: any` using the @formio/core SelectBoxesComponent schema.
9
+ declare component: SelectBoxesComponent;
10
+
11
+ public render(): string {
8
12
  return Field.prototype.render.call(this, this.renderTemplate('selectboxes', {
9
- // @ts-ignore
10
13
  input: this.inputInfo,
11
- // @ts-ignore
12
14
  inline: this.component.inline,
13
- // @ts-ignore
14
15
  values: this.component.values,
15
- // @ts-ignore
16
16
  value: this.dataValue,
17
- // @ts-ignore
18
17
  row: this.row,
19
18
  }));
20
19
  }
@@ -1,23 +1,31 @@
1
- import {Components} from 'formiojs';
1
+ import { Components } from '@formio/js';
2
2
  import moment from 'moment';
3
3
  import TimeHelper from '../util/TimeHelper';
4
+ import { CoreTimeComponent } from '../../types';
4
5
 
5
6
  const Field = Components.components.field;
6
7
  const Time = Components.components.time;
7
8
 
8
- // @ts-ignore
9
9
  export default class GDSTimeComponent extends Time {
10
10
  public static MAX_LENGTH = 2;
11
11
 
12
- private refs: any;
13
- private component: any;
14
- private loadRefs: any;
15
- private checkComponentValidity: any;
16
- private updateValue: any;
12
+ // Narrow the inherited `component: any` using the @formio/core TimeComponent schema.
13
+ declare component: CoreTimeComponent;
14
+
15
+ // Narrow `refs: {}` to the specific refs loaded by this component.
16
+ declare refs: {
17
+ hour: HTMLInputElement;
18
+ minute: HTMLInputElement;
19
+ };
20
+
21
+ // Narrow `data: any` to a typed record.
22
+ declare data: Record<string, unknown>;
17
23
 
18
24
  private timeHelper: TimeHelper = new TimeHelper();
19
25
 
20
- public attach(element) {
26
+ // element typed as `any` to match the base component attach(element: any) override signature
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
28
+ public attach(element: any): Promise<void> {
21
29
  this.loadRefs(element, {
22
30
  hour: 'single',
23
31
  minute: 'single',
@@ -27,47 +35,48 @@ export default class GDSTimeComponent extends Time {
27
35
  this.timeHelper.checkAndValidateHour(this.refs.hour);
28
36
  this.setPristine(false);
29
37
  this.checkComponentValidity(this.data, true);
30
- this.updateValue(null, {modified: true});
38
+ this.updateValue(null, { modified: true });
31
39
  });
32
40
 
33
41
  this.addEventListener(this.refs.minute, 'input', () => {
34
42
  this.timeHelper.checkAndValidateMinutes(this.refs.minute);
35
43
  this.setPristine(false);
36
44
  this.checkComponentValidity(this.data, true);
37
- this.updateValue(null, {modified: true});
45
+ this.updateValue(null, { modified: true });
38
46
  });
39
47
 
40
- this.addEventListener(this.refs.hour, 'keypress', (evt) => {
48
+ this.addEventListener(this.refs.hour, 'keypress', (evt: KeyboardEvent) => {
41
49
  this.timeHelper.preventNonNumericKeyPress(evt);
42
50
  });
43
51
 
44
- this.addEventListener(this.refs.minute, 'keypress', (evt) => {
52
+ this.addEventListener(this.refs.minute, 'keypress', (evt: KeyboardEvent) => {
45
53
  this.timeHelper.preventNonNumericKeyPress(evt);
46
54
  });
55
+
47
56
  return super.attach(element);
48
57
  }
49
58
 
50
- public setErrorClasses(elements, dirty, hasError) {
51
- // @ts-ignore
52
- super.setErrorClasses( [this.refs.hour, this.refs.minute, ...elements], dirty, hasError);
59
+ // elements typed as `any` to match the base Day.setErrorClasses(elements: any, ...) override signature
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
61
+ public setErrorClasses(elements: any, dirty: any, hasError: any): void {
62
+ super.setErrorClasses([this.refs.hour, this.refs.minute, ...elements], dirty, hasError);
53
63
  }
54
64
 
55
- // @ts-ignore
56
- public addInputError(message: any, dirty: boolean, element: any): void {
57
- // @ts-ignore
65
+ // message/dirty/element typed as `any` to match the base Component.addInputError(message: any, dirty: any, elements: any) signature
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
67
+ public addInputError(message: any, dirty: any, element: any): void {
58
68
  super.addInputError(message, dirty, [this.refs.hour, this.refs.minute]);
59
- // @ts-ignore
60
69
  super.addInputError(message, dirty, element);
61
70
  }
62
71
 
63
- public removeInputError(elements) {
64
- // @ts-ignore
72
+ // elements typed as `any` to match the base Component.removeInputError(elements: any) signature
73
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
74
+ public removeInputError(elements: any): void {
65
75
  super.removeInputError([this.refs.hour, this.refs.minute]);
66
- // @ts-ignore
67
76
  super.removeInputError(elements);
68
77
  }
69
78
 
70
- public getValue() {
79
+ public getValue(): string {
71
80
  if (!this.refs.hour.value || !this.refs.minute.value) {
72
81
  return '';
73
82
  }
@@ -75,10 +84,11 @@ export default class GDSTimeComponent extends Time {
75
84
  return moment(value, this.component.format).format(this.component.dataFormat);
76
85
  }
77
86
 
78
- public setValue(value: any, flags: any) {
79
-
87
+ // value/flags typed as `any` to match the base Component.setValue(value: any, flags: any) override signature
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
89
+ public setValue(value: any, _flags: any): boolean {
80
90
  if (this.refs.hour && this.refs.minute && value) {
81
- const parts = value.split(':');
91
+ const parts = (value as string).split(':');
82
92
  this.refs.hour.value = parts[0];
83
93
  this.refs.minute.value = parts[1];
84
94
  return true;
@@ -86,7 +96,7 @@ export default class GDSTimeComponent extends Time {
86
96
  return false;
87
97
  }
88
98
 
89
- public render() {
99
+ public render(): string {
90
100
  return Field.prototype.render.call(this, this.renderTemplate('time', {
91
101
  hour: super.getValue().split(':')[0],
92
102
  minute: super.getValue().split(':')[1],
@@ -1,8 +1,10 @@
1
+ import { InputElementLike } from '../../types';
2
+
1
3
  export default class TimeHelper {
2
4
 
3
5
  public static MAX_LENGTH = 2;
4
6
 
5
- public checkAndValidateMinutes(element: any): void {
7
+ public checkAndValidateMinutes(element: InputElementLike): void {
6
8
  if (element.value) {
7
9
  if (element.value.length > TimeHelper.MAX_LENGTH) {
8
10
  element.value = element.value.slice(0, 2);
@@ -17,8 +19,7 @@ export default class TimeHelper {
17
19
  }
18
20
  }
19
21
 
20
- public checkAndValidateHour(element: any): void {
21
-
22
+ public checkAndValidateHour(element: InputElementLike): void {
22
23
  if (element.value) {
23
24
  if (element.value.length > TimeHelper.MAX_LENGTH) {
24
25
  element.value = element.value.slice(0, 2);
@@ -34,13 +35,13 @@ export default class TimeHelper {
34
35
  }
35
36
  }
36
37
 
37
- public preventNonNumericKeyPress(evt: any) {
38
+ public preventNonNumericKeyPress(evt: KeyboardEvent): void {
38
39
  if (evt.which !== 8 && evt.which !== 0 && evt.which < 48 || evt.which > 57) {
39
40
  evt.preventDefault();
40
41
  }
41
42
  }
42
43
 
43
- validateDateInput(element: any, length: number) {
44
+ public validateDateInput(element: InputElementLike, length: number): void {
44
45
  if (element.value && element.value.length > length) {
45
46
  element.value = element.value.slice(0, length);
46
47
  }
package/src/css.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module '*.css' {
2
+ const content: string;
3
+ export default content;
4
+ }
@@ -23,7 +23,7 @@ columnWidth = 'govuk-grid-column-full';
23
23
  }
24
24
  %}
25
25
 
26
- <div class="{{ctx.component.customClass}}" ref="{{ctx.datagridKey}}-tbody">
26
+ <div class="datagrid-tbody {{ctx.component.customClass}}" ref="{{ctx.datagridKey}}-tbody">
27
27
  {% if (ctx.hasAddButton && ctx.hasTopSubmit) { %}
28
28
  <button class="govuk-button govuk-button--secondary"
29
29
  aria-label="{{ctx.t(ctx.component.addAnother || 'Add Another')}}"
@@ -75,4 +75,3 @@ columnWidth = 'govuk-grid-column-full';
75
75
  </button>
76
76
  {% } %}
77
77
  </div>
78
-
@@ -1,4 +1,4 @@
1
- export default (iconset, name, spinning) => {
1
+ export default (iconset: string, name: string, spinning: boolean): string => {
2
2
  if (iconset === 'fa') {
3
3
  switch (name) {
4
4
  case 'save':