@ministryofjustice/frontend 5.0.0 → 5.1.0
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/moj/all.bundle.js +1549 -1062
- package/moj/all.bundle.js.map +1 -1
- package/moj/all.bundle.mjs +1845 -1054
- package/moj/all.bundle.mjs.map +1 -1
- package/moj/all.mjs +7 -90
- package/moj/all.mjs.map +1 -1
- package/moj/all.scss +1 -0
- package/moj/all.scss.map +1 -1
- package/moj/common/index.mjs +57 -0
- package/moj/common/index.mjs.map +1 -0
- package/moj/common/moj-frontend-version.mjs +14 -0
- package/moj/common/moj-frontend-version.mjs.map +1 -0
- package/moj/components/add-another/add-another.bundle.js +105 -76
- package/moj/components/add-another/add-another.bundle.js.map +1 -1
- package/moj/components/add-another/add-another.bundle.mjs +222 -71
- package/moj/components/add-another/add-another.bundle.mjs.map +1 -1
- package/moj/components/add-another/add-another.mjs +103 -72
- package/moj/components/add-another/add-another.mjs.map +1 -1
- package/moj/components/alert/alert.bundle.js +115 -191
- package/moj/components/alert/alert.bundle.js.map +1 -1
- package/moj/components/alert/alert.bundle.mjs +354 -186
- package/moj/components/alert/alert.bundle.mjs.map +1 -1
- package/moj/components/alert/alert.mjs +55 -140
- package/moj/components/alert/alert.mjs.map +1 -1
- package/moj/components/button-menu/README.md +3 -1
- package/moj/components/button-menu/button-menu.bundle.js +91 -120
- package/moj/components/button-menu/button-menu.bundle.js.map +1 -1
- package/moj/components/button-menu/button-menu.bundle.mjs +329 -114
- package/moj/components/button-menu/button-menu.bundle.mjs.map +1 -1
- package/moj/components/button-menu/button-menu.mjs +89 -116
- package/moj/components/button-menu/button-menu.mjs.map +1 -1
- package/moj/components/date-picker/date-picker.bundle.js +174 -154
- package/moj/components/date-picker/date-picker.bundle.js.map +1 -1
- package/moj/components/date-picker/date-picker.bundle.mjs +411 -147
- package/moj/components/date-picker/date-picker.bundle.mjs.map +1 -1
- package/moj/components/date-picker/date-picker.mjs +172 -150
- package/moj/components/date-picker/date-picker.mjs.map +1 -1
- package/moj/components/filter/template.njk +1 -1
- package/moj/components/filter-toggle-button/filter-toggle-button.bundle.js +133 -44
- package/moj/components/filter-toggle-button/filter-toggle-button.bundle.js.map +1 -1
- package/moj/components/filter-toggle-button/filter-toggle-button.bundle.mjs +374 -41
- package/moj/components/filter-toggle-button/filter-toggle-button.bundle.mjs.map +1 -1
- package/moj/components/filter-toggle-button/filter-toggle-button.mjs +131 -40
- package/moj/components/filter-toggle-button/filter-toggle-button.mjs.map +1 -1
- package/moj/components/form-validator/form-validator.bundle.js +159 -69
- package/moj/components/form-validator/form-validator.bundle.js.map +1 -1
- package/moj/components/form-validator/form-validator.bundle.mjs +399 -65
- package/moj/components/form-validator/form-validator.bundle.mjs.map +1 -1
- package/moj/components/form-validator/form-validator.mjs +134 -54
- package/moj/components/form-validator/form-validator.mjs.map +1 -1
- package/moj/components/multi-file-upload/multi-file-upload.bundle.js +291 -117
- package/moj/components/multi-file-upload/multi-file-upload.bundle.js.map +1 -1
- package/moj/components/multi-file-upload/multi-file-upload.bundle.mjs +527 -109
- package/moj/components/multi-file-upload/multi-file-upload.bundle.mjs.map +1 -1
- package/moj/components/multi-file-upload/multi-file-upload.mjs +288 -101
- package/moj/components/multi-file-upload/multi-file-upload.mjs.map +1 -1
- package/moj/components/multi-file-upload/template.njk +1 -1
- package/moj/components/multi-select/multi-select.bundle.js +106 -41
- package/moj/components/multi-select/multi-select.bundle.js.map +1 -1
- package/moj/components/multi-select/multi-select.bundle.mjs +346 -37
- package/moj/components/multi-select/multi-select.bundle.mjs.map +1 -1
- package/moj/components/multi-select/multi-select.mjs +104 -37
- package/moj/components/multi-select/multi-select.mjs.map +1 -1
- package/moj/components/password-reveal/_password-reveal.scss +3 -1
- package/moj/components/password-reveal/_password-reveal.scss.map +1 -1
- package/moj/components/password-reveal/password-reveal.bundle.js +32 -29
- package/moj/components/password-reveal/password-reveal.bundle.js.map +1 -1
- package/moj/components/password-reveal/password-reveal.bundle.mjs +149 -24
- package/moj/components/password-reveal/password-reveal.bundle.mjs.map +1 -1
- package/moj/components/password-reveal/password-reveal.mjs +30 -25
- package/moj/components/password-reveal/password-reveal.mjs.map +1 -1
- package/moj/components/rich-text-editor/README.md +4 -3
- package/moj/components/rich-text-editor/rich-text-editor.bundle.js +127 -62
- package/moj/components/rich-text-editor/rich-text-editor.bundle.js.map +1 -1
- package/moj/components/rich-text-editor/rich-text-editor.bundle.mjs +367 -58
- package/moj/components/rich-text-editor/rich-text-editor.bundle.mjs.map +1 -1
- package/moj/components/rich-text-editor/rich-text-editor.mjs +125 -58
- package/moj/components/rich-text-editor/rich-text-editor.mjs.map +1 -1
- package/moj/components/search-toggle/search-toggle.bundle.js +94 -26
- package/moj/components/search-toggle/search-toggle.bundle.js.map +1 -1
- package/moj/components/search-toggle/search-toggle.bundle.mjs +334 -22
- package/moj/components/search-toggle/search-toggle.bundle.mjs.map +1 -1
- package/moj/components/search-toggle/search-toggle.mjs +92 -22
- package/moj/components/search-toggle/search-toggle.mjs.map +1 -1
- package/moj/components/sortable-table/sortable-table.bundle.js +151 -83
- package/moj/components/sortable-table/sortable-table.bundle.js.map +1 -1
- package/moj/components/sortable-table/sortable-table.bundle.mjs +390 -78
- package/moj/components/sortable-table/sortable-table.bundle.mjs.map +1 -1
- package/moj/components/sortable-table/sortable-table.mjs +149 -79
- package/moj/components/sortable-table/sortable-table.mjs.map +1 -1
- package/moj/core/_all.scss +3 -0
- package/moj/core/_all.scss.map +1 -0
- package/moj/core/_moj-frontend-properties.scss +7 -0
- package/moj/core/_moj-frontend-properties.scss.map +1 -0
- package/moj/filters/prototype-kit-13-filters.js +4 -3
- package/moj/helpers.bundle.js +22 -77
- package/moj/helpers.bundle.js.map +1 -1
- package/moj/helpers.bundle.mjs +23 -74
- package/moj/helpers.bundle.mjs.map +1 -1
- package/moj/helpers.mjs +23 -74
- package/moj/helpers.mjs.map +1 -1
- package/moj/moj-frontend.min.css +1 -1
- package/moj/moj-frontend.min.css.map +1 -1
- package/moj/moj-frontend.min.js +1 -1
- package/moj/moj-frontend.min.js.map +1 -1
- package/package.json +1 -1
- package/moj/version.bundle.js +0 -12
- package/moj/version.bundle.js.map +0 -1
- package/moj/version.bundle.mjs +0 -4
- package/moj/version.bundle.mjs.map +0 -1
- package/moj/version.mjs +0 -4
- package/moj/version.mjs.map +0 -1
package/moj/all.bundle.mjs
CHANGED
|
@@ -1,158 +1,557 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
function isInitialised($root, moduleName) {
|
|
2
|
+
return $root instanceof HTMLElement && $root.hasAttribute(`data-${moduleName}-init`);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checks if GOV.UK Frontend is supported on this page
|
|
7
|
+
*
|
|
8
|
+
* Some browsers will load and run our JavaScript but GOV.UK Frontend
|
|
9
|
+
* won't be supported.
|
|
10
|
+
*
|
|
11
|
+
* @param {HTMLElement | null} [$scope] - (internal) `<body>` HTML element checked for browser support
|
|
12
|
+
* @returns {boolean} Whether GOV.UK Frontend is supported on this page
|
|
13
|
+
*/
|
|
14
|
+
function isSupported($scope = document.body) {
|
|
15
|
+
if (!$scope) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return $scope.classList.contains('govuk-frontend-supported');
|
|
19
|
+
}
|
|
20
|
+
function isArray(option) {
|
|
21
|
+
return Array.isArray(option);
|
|
22
|
+
}
|
|
23
|
+
function isObject(option) {
|
|
24
|
+
return !!option && typeof option === 'object' && !isArray(option);
|
|
25
|
+
}
|
|
26
|
+
function formatErrorMessage(Component, message) {
|
|
27
|
+
return `${Component.moduleName}: ${message}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class GOVUKFrontendError extends Error {
|
|
31
|
+
constructor(...args) {
|
|
32
|
+
super(...args);
|
|
33
|
+
this.name = 'GOVUKFrontendError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class SupportError extends GOVUKFrontendError {
|
|
37
|
+
/**
|
|
38
|
+
* Checks if GOV.UK Frontend is supported on this page
|
|
39
|
+
*
|
|
40
|
+
* @param {HTMLElement | null} [$scope] - HTML element `<body>` checked for browser support
|
|
41
|
+
*/
|
|
42
|
+
constructor($scope = document.body) {
|
|
43
|
+
const supportMessage = 'noModule' in HTMLScriptElement.prototype ? 'GOV.UK Frontend initialised without `<body class="govuk-frontend-supported">` from template `<script>` snippet' : 'GOV.UK Frontend is not supported in this browser';
|
|
44
|
+
super($scope ? supportMessage : 'GOV.UK Frontend initialised without `<script type="module">`');
|
|
45
|
+
this.name = 'SupportError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
class ConfigError extends GOVUKFrontendError {
|
|
49
|
+
constructor(...args) {
|
|
50
|
+
super(...args);
|
|
51
|
+
this.name = 'ConfigError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
class ElementError extends GOVUKFrontendError {
|
|
55
|
+
constructor(messageOrOptions) {
|
|
56
|
+
let message = typeof messageOrOptions === 'string' ? messageOrOptions : '';
|
|
57
|
+
if (typeof messageOrOptions === 'object') {
|
|
58
|
+
const {
|
|
59
|
+
component,
|
|
60
|
+
identifier,
|
|
61
|
+
element,
|
|
62
|
+
expectedType
|
|
63
|
+
} = messageOrOptions;
|
|
64
|
+
message = identifier;
|
|
65
|
+
message += element ? ` is not of type ${expectedType != null ? expectedType : 'HTMLElement'}` : ' not found';
|
|
66
|
+
message = formatErrorMessage(component, message);
|
|
67
|
+
}
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = 'ElementError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
class InitError extends GOVUKFrontendError {
|
|
73
|
+
constructor(componentOrMessage) {
|
|
74
|
+
const message = typeof componentOrMessage === 'string' ? componentOrMessage : formatErrorMessage(componentOrMessage, `Root element (\`$root\`) already initialised`);
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = 'InitError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class Component {
|
|
81
|
+
/**
|
|
82
|
+
* Returns the root element of the component
|
|
83
|
+
*
|
|
84
|
+
* @protected
|
|
85
|
+
* @returns {RootElementType} - the root element of component
|
|
86
|
+
*/
|
|
87
|
+
get $root() {
|
|
88
|
+
return this._$root;
|
|
89
|
+
}
|
|
90
|
+
constructor($root) {
|
|
91
|
+
this._$root = void 0;
|
|
92
|
+
const childConstructor = this.constructor;
|
|
93
|
+
if (typeof childConstructor.moduleName !== 'string') {
|
|
94
|
+
throw new InitError(`\`moduleName\` not defined in component`);
|
|
95
|
+
}
|
|
96
|
+
if (!($root instanceof childConstructor.elementType)) {
|
|
97
|
+
throw new ElementError({
|
|
98
|
+
element: $root,
|
|
99
|
+
component: childConstructor,
|
|
100
|
+
identifier: 'Root element (`$root`)',
|
|
101
|
+
expectedType: childConstructor.elementType.name
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
this._$root = $root;
|
|
105
|
+
}
|
|
106
|
+
childConstructor.checkSupport();
|
|
107
|
+
this.checkInitialised();
|
|
108
|
+
const moduleName = childConstructor.moduleName;
|
|
109
|
+
this.$root.setAttribute(`data-${moduleName}-init`, '');
|
|
110
|
+
}
|
|
111
|
+
checkInitialised() {
|
|
112
|
+
const constructor = this.constructor;
|
|
113
|
+
const moduleName = constructor.moduleName;
|
|
114
|
+
if (moduleName && isInitialised(this.$root, moduleName)) {
|
|
115
|
+
throw new InitError(constructor);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
static checkSupport() {
|
|
119
|
+
if (!isSupported()) {
|
|
120
|
+
throw new SupportError();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @typedef ChildClass
|
|
127
|
+
* @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {typeof Component & ChildClass} ChildClassConstructor
|
|
132
|
+
*/
|
|
133
|
+
Component.elementType = HTMLElement;
|
|
134
|
+
|
|
135
|
+
const configOverride = Symbol.for('configOverride');
|
|
136
|
+
class ConfigurableComponent extends Component {
|
|
137
|
+
[configOverride](param) {
|
|
138
|
+
return {};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns the root element of the component
|
|
143
|
+
*
|
|
144
|
+
* @protected
|
|
145
|
+
* @returns {ConfigurationType} - the root element of component
|
|
146
|
+
*/
|
|
147
|
+
get config() {
|
|
148
|
+
return this._config;
|
|
149
|
+
}
|
|
150
|
+
constructor($root, config) {
|
|
151
|
+
super($root);
|
|
152
|
+
this._config = void 0;
|
|
153
|
+
const childConstructor = this.constructor;
|
|
154
|
+
if (!isObject(childConstructor.defaults)) {
|
|
155
|
+
throw new ConfigError(formatErrorMessage(childConstructor, 'Config passed as parameter into constructor but no defaults defined'));
|
|
156
|
+
}
|
|
157
|
+
const datasetConfig = normaliseDataset(childConstructor, this._$root.dataset);
|
|
158
|
+
this._config = mergeConfigs(childConstructor.defaults, config != null ? config : {}, this[configOverride](datasetConfig), datasetConfig);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function normaliseString(value, property) {
|
|
162
|
+
const trimmedValue = value ? value.trim() : '';
|
|
163
|
+
let output;
|
|
164
|
+
let outputType = property == null ? void 0 : property.type;
|
|
165
|
+
if (!outputType) {
|
|
166
|
+
if (['true', 'false'].includes(trimmedValue)) {
|
|
167
|
+
outputType = 'boolean';
|
|
168
|
+
}
|
|
169
|
+
if (trimmedValue.length > 0 && isFinite(Number(trimmedValue))) {
|
|
170
|
+
outputType = 'number';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
switch (outputType) {
|
|
174
|
+
case 'boolean':
|
|
175
|
+
output = trimmedValue === 'true';
|
|
176
|
+
break;
|
|
177
|
+
case 'number':
|
|
178
|
+
output = Number(trimmedValue);
|
|
179
|
+
break;
|
|
180
|
+
default:
|
|
181
|
+
output = value;
|
|
182
|
+
}
|
|
183
|
+
return output;
|
|
184
|
+
}
|
|
185
|
+
function normaliseDataset(Component, dataset) {
|
|
186
|
+
if (!isObject(Component.schema)) {
|
|
187
|
+
throw new ConfigError(formatErrorMessage(Component, 'Config passed as parameter into constructor but no schema defined'));
|
|
188
|
+
}
|
|
189
|
+
const out = {};
|
|
190
|
+
const entries = Object.entries(Component.schema.properties);
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
const [namespace, property] = entry;
|
|
193
|
+
const field = namespace.toString();
|
|
194
|
+
if (field in dataset) {
|
|
195
|
+
out[field] = normaliseString(dataset[field], property);
|
|
196
|
+
}
|
|
197
|
+
if ((property == null ? void 0 : property.type) === 'object') {
|
|
198
|
+
out[field] = extractConfigByNamespace(Component.schema, dataset, namespace);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
function mergeConfigs(...configObjects) {
|
|
204
|
+
const formattedConfigObject = {};
|
|
205
|
+
for (const configObject of configObjects) {
|
|
206
|
+
for (const key of Object.keys(configObject)) {
|
|
207
|
+
const option = formattedConfigObject[key];
|
|
208
|
+
const override = configObject[key];
|
|
209
|
+
if (isObject(option) && isObject(override)) {
|
|
210
|
+
formattedConfigObject[key] = mergeConfigs(option, override);
|
|
211
|
+
} else {
|
|
212
|
+
formattedConfigObject[key] = override;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return formattedConfigObject;
|
|
217
|
+
}
|
|
218
|
+
function extractConfigByNamespace(schema, dataset, namespace) {
|
|
219
|
+
const property = schema.properties[namespace];
|
|
220
|
+
if ((property == null ? void 0 : property.type) !== 'object') {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const newObject = {
|
|
224
|
+
[namespace]: {}
|
|
225
|
+
};
|
|
226
|
+
for (const [key, value] of Object.entries(dataset)) {
|
|
227
|
+
let current = newObject;
|
|
228
|
+
const keyParts = key.split('.');
|
|
229
|
+
for (const [index, name] of keyParts.entries()) {
|
|
230
|
+
if (isObject(current)) {
|
|
231
|
+
if (index < keyParts.length - 1) {
|
|
232
|
+
if (!isObject(current[name])) {
|
|
233
|
+
current[name] = {};
|
|
234
|
+
}
|
|
235
|
+
current = current[name];
|
|
236
|
+
} else if (key !== namespace) {
|
|
237
|
+
current[name] = normaliseString(value);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return newObject[namespace];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Create all instances of a specific component on the page
|
|
247
|
+
*
|
|
248
|
+
* Uses the `data-module` attribute to find all elements matching the specified
|
|
249
|
+
* component on the page, creating instances of the component object for each
|
|
250
|
+
* of them.
|
|
251
|
+
*
|
|
252
|
+
* Any component errors will be caught and logged to the console.
|
|
253
|
+
*
|
|
254
|
+
* @template {CompatibleClass} ComponentClass
|
|
255
|
+
* @param {ComponentClass} Component - class of the component to create
|
|
256
|
+
* @param {ComponentConfig<ComponentClass>} [config] - Config supplied to component
|
|
257
|
+
* @param {OnErrorCallback<ComponentClass> | Element | Document | CreateAllOptions<ComponentClass> } [createAllOptions] - options for createAll including scope of the document to search within and callback function if error throw by component on init
|
|
258
|
+
* @returns {Array<InstanceType<ComponentClass>>} - array of instantiated components
|
|
259
|
+
*/
|
|
260
|
+
function createAll(Component, config, createAllOptions) {
|
|
261
|
+
let $scope = document;
|
|
262
|
+
let onError;
|
|
263
|
+
if (typeof createAllOptions === 'object') {
|
|
264
|
+
var _createAllOptions$sco;
|
|
265
|
+
createAllOptions = createAllOptions;
|
|
266
|
+
$scope = (_createAllOptions$sco = createAllOptions.scope) != null ? _createAllOptions$sco : $scope;
|
|
267
|
+
onError = createAllOptions.onError;
|
|
268
|
+
}
|
|
269
|
+
if (typeof createAllOptions === 'function') {
|
|
270
|
+
onError = createAllOptions;
|
|
271
|
+
}
|
|
272
|
+
if (createAllOptions instanceof HTMLElement) {
|
|
273
|
+
$scope = createAllOptions;
|
|
274
|
+
}
|
|
275
|
+
const $elements = $scope.querySelectorAll(`[data-module="${Component.moduleName}"]`);
|
|
276
|
+
if (!isSupported()) {
|
|
277
|
+
if (onError) {
|
|
278
|
+
onError(new SupportError(), {
|
|
279
|
+
component: Component,
|
|
280
|
+
config
|
|
281
|
+
});
|
|
282
|
+
} else {
|
|
283
|
+
console.log(new SupportError());
|
|
284
|
+
}
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
return Array.from($elements).map($element => {
|
|
288
|
+
try {
|
|
289
|
+
return typeof config !== 'undefined' ? new Component($element, config) : new Component($element);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (onError) {
|
|
292
|
+
onError(error, {
|
|
293
|
+
element: $element,
|
|
294
|
+
component: Component,
|
|
295
|
+
config
|
|
296
|
+
});
|
|
297
|
+
} else {
|
|
298
|
+
console.log(error);
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
6
301
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
302
|
+
}).filter(Boolean);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/*
|
|
306
|
+
* This variable is automatically overwritten during builds and releases.
|
|
307
|
+
* It doesn't need to be updated manually.
|
|
308
|
+
*/
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* MoJ Frontend release version
|
|
312
|
+
*
|
|
313
|
+
* {@link https://github.com/ministryofjustice/moj-frontend/releases}
|
|
314
|
+
*/
|
|
315
|
+
const version = '5.1.0';
|
|
316
|
+
|
|
317
|
+
class AddAnother extends Component {
|
|
318
|
+
/**
|
|
319
|
+
* @param {Element | null} $root - HTML element to use for add another
|
|
320
|
+
*/
|
|
321
|
+
constructor($root) {
|
|
322
|
+
super($root);
|
|
323
|
+
this.$root.addEventListener('click', this.onRemoveButtonClick.bind(this));
|
|
324
|
+
this.$root.addEventListener('click', this.onAddButtonClick.bind(this));
|
|
325
|
+
const $buttons = this.$root.querySelectorAll('.moj-add-another__add-button, moj-add-another__remove-button');
|
|
326
|
+
$buttons.forEach($button => {
|
|
327
|
+
if (!($button instanceof HTMLButtonElement)) {
|
|
13
328
|
return;
|
|
14
329
|
}
|
|
15
|
-
button.type = 'button';
|
|
330
|
+
$button.type = 'button';
|
|
16
331
|
});
|
|
17
332
|
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @param {MouseEvent} event - Click event
|
|
336
|
+
*/
|
|
18
337
|
onAddButtonClick(event) {
|
|
19
|
-
const button = event.target;
|
|
20
|
-
if (
|
|
338
|
+
const $button = event.target;
|
|
339
|
+
if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__add-button')) {
|
|
21
340
|
return;
|
|
22
341
|
}
|
|
23
|
-
const items = this.getItems();
|
|
24
|
-
const item = this.getNewItem();
|
|
25
|
-
if (
|
|
342
|
+
const $items = this.getItems();
|
|
343
|
+
const $item = this.getNewItem();
|
|
344
|
+
if (!$item || !($item instanceof HTMLElement)) {
|
|
26
345
|
return;
|
|
27
346
|
}
|
|
28
|
-
this.updateAttributes(item, items.length);
|
|
29
|
-
this.resetItem(item);
|
|
30
|
-
const firstItem = items[0];
|
|
31
|
-
if (!this.hasRemoveButton(firstItem)) {
|
|
32
|
-
this.createRemoveButton(firstItem);
|
|
347
|
+
this.updateAttributes($item, $items.length);
|
|
348
|
+
this.resetItem($item);
|
|
349
|
+
const $firstItem = $items[0];
|
|
350
|
+
if (!this.hasRemoveButton($firstItem)) {
|
|
351
|
+
this.createRemoveButton($firstItem);
|
|
33
352
|
}
|
|
34
|
-
items[items.length - 1].after(item);
|
|
35
|
-
const input = item.querySelector('input, textarea, select');
|
|
36
|
-
if (input && input instanceof HTMLInputElement) {
|
|
37
|
-
input.focus();
|
|
353
|
+
$items[$items.length - 1].after($item);
|
|
354
|
+
const $input = $item.querySelector('input, textarea, select');
|
|
355
|
+
if ($input && $input instanceof HTMLInputElement) {
|
|
356
|
+
$input.focus();
|
|
38
357
|
}
|
|
39
358
|
}
|
|
40
|
-
|
|
41
|
-
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {HTMLElement} $item - Add another item
|
|
362
|
+
*/
|
|
363
|
+
hasRemoveButton($item) {
|
|
364
|
+
return $item.querySelectorAll('.moj-add-another__remove-button').length;
|
|
42
365
|
}
|
|
43
366
|
getItems() {
|
|
44
|
-
if (!this
|
|
367
|
+
if (!this.$root) {
|
|
45
368
|
return [];
|
|
46
369
|
}
|
|
47
|
-
const items = Array.from(this.
|
|
48
|
-
return items.filter(item => item instanceof HTMLElement);
|
|
370
|
+
const $items = Array.from(this.$root.querySelectorAll('.moj-add-another__item'));
|
|
371
|
+
return $items.filter(item => item instanceof HTMLElement);
|
|
49
372
|
}
|
|
50
373
|
getNewItem() {
|
|
51
|
-
const items = this.getItems();
|
|
52
|
-
const item = items[0].cloneNode(true);
|
|
53
|
-
if (
|
|
374
|
+
const $items = this.getItems();
|
|
375
|
+
const $item = $items[0].cloneNode(true);
|
|
376
|
+
if (!$item || !($item instanceof HTMLElement)) {
|
|
54
377
|
return;
|
|
55
378
|
}
|
|
56
|
-
if (!this.hasRemoveButton(item)) {
|
|
57
|
-
this.createRemoveButton(item);
|
|
379
|
+
if (!this.hasRemoveButton($item)) {
|
|
380
|
+
this.createRemoveButton($item);
|
|
58
381
|
}
|
|
59
|
-
return item;
|
|
382
|
+
return $item;
|
|
60
383
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {HTMLElement} $item - Add another item
|
|
387
|
+
* @param {number} index - Add another item index
|
|
388
|
+
*/
|
|
389
|
+
updateAttributes($item, index) {
|
|
390
|
+
$item.querySelectorAll('[data-name]').forEach($input => {
|
|
391
|
+
if (!($input instanceof HTMLInputElement)) {
|
|
64
392
|
return;
|
|
65
393
|
}
|
|
66
|
-
const name =
|
|
67
|
-
const id =
|
|
68
|
-
const originalId =
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const label =
|
|
72
|
-
if (label && label instanceof HTMLLabelElement) {
|
|
73
|
-
label.htmlFor =
|
|
394
|
+
const name = $input.getAttribute('data-name') || '';
|
|
395
|
+
const id = $input.getAttribute('data-id') || '';
|
|
396
|
+
const originalId = $input.id;
|
|
397
|
+
$input.name = name.replace(/%index%/, `${index}`);
|
|
398
|
+
$input.id = id.replace(/%index%/, `${index}`);
|
|
399
|
+
const $label = $input.parentElement.querySelector('label') || $input.closest('label') || $item.querySelector(`[for="${originalId}"]`);
|
|
400
|
+
if ($label && $label instanceof HTMLLabelElement) {
|
|
401
|
+
$label.htmlFor = $input.id;
|
|
74
402
|
}
|
|
75
403
|
});
|
|
76
404
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @param {HTMLElement} $item - Add another item
|
|
408
|
+
*/
|
|
409
|
+
createRemoveButton($item) {
|
|
410
|
+
const $button = document.createElement('button');
|
|
411
|
+
$button.type = 'button';
|
|
412
|
+
$button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-add-another__remove-button');
|
|
413
|
+
$button.textContent = 'Remove';
|
|
414
|
+
$item.append($button);
|
|
83
415
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* @param {HTMLElement} $item - Add another item
|
|
419
|
+
*/
|
|
420
|
+
resetItem($item) {
|
|
421
|
+
$item.querySelectorAll('[data-name], [data-id]').forEach($input => {
|
|
422
|
+
if (!($input instanceof HTMLInputElement)) {
|
|
87
423
|
return;
|
|
88
424
|
}
|
|
89
|
-
if (
|
|
90
|
-
|
|
425
|
+
if ($input.type === 'checkbox' || $input.type === 'radio') {
|
|
426
|
+
$input.checked = false;
|
|
91
427
|
} else {
|
|
92
|
-
|
|
428
|
+
$input.value = '';
|
|
93
429
|
}
|
|
94
430
|
});
|
|
95
431
|
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @param {MouseEvent} event - Click event
|
|
435
|
+
*/
|
|
96
436
|
onRemoveButtonClick(event) {
|
|
97
|
-
const button = event.target;
|
|
98
|
-
if (
|
|
437
|
+
const $button = event.target;
|
|
438
|
+
if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-add-another__remove-button')) {
|
|
99
439
|
return;
|
|
100
440
|
}
|
|
101
|
-
button.closest('.moj-add-another__item').remove();
|
|
102
|
-
const items = this.getItems();
|
|
103
|
-
if (items.length === 1) {
|
|
104
|
-
items[0].querySelector('.moj-add-another__remove-button').remove();
|
|
441
|
+
$button.closest('.moj-add-another__item').remove();
|
|
442
|
+
const $items = this.getItems();
|
|
443
|
+
if ($items.length === 1) {
|
|
444
|
+
$items[0].querySelector('.moj-add-another__remove-button').remove();
|
|
105
445
|
}
|
|
106
|
-
items.forEach((
|
|
107
|
-
this.updateAttributes(
|
|
446
|
+
$items.forEach(($item, index) => {
|
|
447
|
+
this.updateAttributes($item, index);
|
|
108
448
|
});
|
|
109
449
|
this.focusHeading();
|
|
110
450
|
}
|
|
111
451
|
focusHeading() {
|
|
112
|
-
const heading = this.
|
|
113
|
-
if (heading && heading instanceof HTMLElement) {
|
|
114
|
-
heading.focus();
|
|
452
|
+
const $heading = this.$root.querySelector('.moj-add-another__heading');
|
|
453
|
+
if ($heading && $heading instanceof HTMLElement) {
|
|
454
|
+
$heading.focus();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Name for the component used when initialising using data-module attributes.
|
|
460
|
+
*/
|
|
461
|
+
}
|
|
462
|
+
AddAnother.moduleName = 'moj-add-another';
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* GOV.UK Frontend helpers
|
|
466
|
+
*
|
|
467
|
+
* @todo Import from GOV.UK Frontend
|
|
468
|
+
*/
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Move focus to element
|
|
472
|
+
*
|
|
473
|
+
* Sets tabindex to -1 to make the element programmatically focusable,
|
|
474
|
+
* but removes it on blur as the element doesn't need to be focused again.
|
|
475
|
+
*
|
|
476
|
+
* @template {HTMLElement} FocusElement
|
|
477
|
+
* @param {FocusElement} $element - HTML element
|
|
478
|
+
* @param {object} [options] - Handler options
|
|
479
|
+
* @param {function(this: FocusElement): void} [options.onBeforeFocus] - Callback before focus
|
|
480
|
+
* @param {function(this: FocusElement): void} [options.onBlur] - Callback on blur
|
|
481
|
+
*/
|
|
482
|
+
function setFocus($element, options = {}) {
|
|
483
|
+
var _options$onBeforeFocu;
|
|
484
|
+
const isFocusable = $element.getAttribute('tabindex');
|
|
485
|
+
if (!isFocusable) {
|
|
486
|
+
$element.setAttribute('tabindex', '-1');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Handle element focus
|
|
491
|
+
*/
|
|
492
|
+
function onFocus() {
|
|
493
|
+
$element.addEventListener('blur', onBlur, {
|
|
494
|
+
once: true
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Handle element blur
|
|
500
|
+
*/
|
|
501
|
+
function onBlur() {
|
|
502
|
+
var _options$onBlur;
|
|
503
|
+
(_options$onBlur = options.onBlur) == null || _options$onBlur.call($element);
|
|
504
|
+
if (!isFocusable) {
|
|
505
|
+
$element.removeAttribute('tabindex');
|
|
115
506
|
}
|
|
116
507
|
}
|
|
508
|
+
|
|
509
|
+
// Add listener to reset element on blur, after focus
|
|
510
|
+
$element.addEventListener('focus', onFocus, {
|
|
511
|
+
once: true
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Focus element
|
|
515
|
+
(_options$onBeforeFocu = options.onBeforeFocus) == null || _options$onBeforeFocu.call($element);
|
|
516
|
+
$element.focus();
|
|
117
517
|
}
|
|
118
518
|
|
|
119
|
-
|
|
519
|
+
/**
|
|
520
|
+
* @param {Element} $element - Element to remove attribute value from
|
|
521
|
+
* @param {string} attr - Attribute name
|
|
522
|
+
* @param {string} value - Attribute value
|
|
523
|
+
*/
|
|
524
|
+
function removeAttributeValue($element, attr, value) {
|
|
120
525
|
let re, m;
|
|
121
|
-
if (
|
|
122
|
-
if (
|
|
123
|
-
|
|
526
|
+
if ($element.getAttribute(attr)) {
|
|
527
|
+
if ($element.getAttribute(attr) === value) {
|
|
528
|
+
$element.removeAttribute(attr);
|
|
124
529
|
} else {
|
|
125
530
|
re = new RegExp(`(^|\\s)${value}(\\s|$)`);
|
|
126
|
-
m =
|
|
531
|
+
m = $element.getAttribute(attr).match(re);
|
|
127
532
|
if (m && m.length === 3) {
|
|
128
|
-
|
|
533
|
+
$element.setAttribute(attr, $element.getAttribute(attr).replace(re, m[1] && m[2] ? ' ' : ''));
|
|
129
534
|
}
|
|
130
535
|
}
|
|
131
536
|
}
|
|
132
537
|
}
|
|
133
|
-
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* @param {Element} $element - Element to add attribute value to
|
|
541
|
+
* @param {string} attr - Attribute name
|
|
542
|
+
* @param {string} value - Attribute value
|
|
543
|
+
*/
|
|
544
|
+
function addAttributeValue($element, attr, value) {
|
|
134
545
|
let re;
|
|
135
|
-
if (
|
|
136
|
-
|
|
546
|
+
if (!$element.getAttribute(attr)) {
|
|
547
|
+
$element.setAttribute(attr, value);
|
|
137
548
|
} else {
|
|
138
549
|
re = new RegExp(`(^|\\s)${value}(\\s|$)`);
|
|
139
|
-
if (!re.test(
|
|
140
|
-
|
|
550
|
+
if (!re.test($element.getAttribute(attr))) {
|
|
551
|
+
$element.setAttribute(attr, `${$element.getAttribute(attr)} ${value}`);
|
|
141
552
|
}
|
|
142
553
|
}
|
|
143
554
|
}
|
|
144
|
-
function dragAndDropSupported() {
|
|
145
|
-
const div = document.createElement('div');
|
|
146
|
-
return typeof div.ondrop !== 'undefined';
|
|
147
|
-
}
|
|
148
|
-
function formDataSupported() {
|
|
149
|
-
return typeof FormData === 'function';
|
|
150
|
-
}
|
|
151
|
-
function fileApiSupported() {
|
|
152
|
-
const input = document.createElement('input');
|
|
153
|
-
input.type = 'file';
|
|
154
|
-
return typeof input.files !== 'undefined';
|
|
155
|
-
}
|
|
156
555
|
|
|
157
556
|
/**
|
|
158
557
|
* Find an elements preceding sibling
|
|
@@ -213,89 +612,15 @@ function findNearestMatchingElement($element, selector) {
|
|
|
213
612
|
}
|
|
214
613
|
|
|
215
614
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* Sets tabindex to -1 to make the element programmatically focusable,
|
|
219
|
-
* but removes it on blur as the element doesn't need to be focused again.
|
|
220
|
-
*
|
|
221
|
-
* @param {HTMLElement} $element - HTML element
|
|
222
|
-
* @param {object} [options] - Handler options
|
|
223
|
-
* @param {function(this: HTMLElement): void} [options.onBeforeFocus] - Callback before focus
|
|
224
|
-
* @param {function(this: HTMLElement): void} [options.onBlur] - Callback on blur
|
|
615
|
+
* @augments {ConfigurableComponent<AlertConfig>}
|
|
225
616
|
*/
|
|
226
|
-
|
|
227
|
-
const isFocusable = $element.getAttribute('tabindex');
|
|
228
|
-
if (!isFocusable) {
|
|
229
|
-
$element.setAttribute('tabindex', '-1');
|
|
230
|
-
}
|
|
231
|
-
|
|
617
|
+
class Alert extends ConfigurableComponent {
|
|
232
618
|
/**
|
|
233
|
-
*
|
|
234
|
-
*/
|
|
235
|
-
function onFocus() {
|
|
236
|
-
$element.addEventListener('blur', onBlur, {
|
|
237
|
-
once: true
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Handle element blur
|
|
243
|
-
*/
|
|
244
|
-
function onBlur() {
|
|
245
|
-
if (options.onBlur) {
|
|
246
|
-
options.onBlur.call($element);
|
|
247
|
-
}
|
|
248
|
-
if (!isFocusable) {
|
|
249
|
-
$element.removeAttribute('tabindex');
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Add listener to reset element on blur, after focus
|
|
254
|
-
$element.addEventListener('focus', onFocus, {
|
|
255
|
-
once: true
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
// Focus element
|
|
259
|
-
if (options.onBeforeFocus) {
|
|
260
|
-
options.onBeforeFocus.call($element);
|
|
261
|
-
}
|
|
262
|
-
$element.focus();
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
class Alert {
|
|
266
|
-
/**
|
|
267
|
-
* @param {Element | null} $module - HTML element to use for alert
|
|
619
|
+
* @param {Element | null} $root - HTML element to use for alert
|
|
268
620
|
* @param {AlertConfig} [config] - Alert config
|
|
269
621
|
*/
|
|
270
|
-
constructor($
|
|
271
|
-
|
|
272
|
-
return this;
|
|
273
|
-
}
|
|
274
|
-
const schema = Object.freeze({
|
|
275
|
-
properties: {
|
|
276
|
-
dismissible: {
|
|
277
|
-
type: 'boolean'
|
|
278
|
-
},
|
|
279
|
-
dismissText: {
|
|
280
|
-
type: 'string'
|
|
281
|
-
},
|
|
282
|
-
disableAutoFocus: {
|
|
283
|
-
type: 'boolean'
|
|
284
|
-
},
|
|
285
|
-
focusOnDismissSelector: {
|
|
286
|
-
type: 'string'
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
});
|
|
290
|
-
const defaults = {
|
|
291
|
-
dismissible: false,
|
|
292
|
-
dismissText: 'Dismiss',
|
|
293
|
-
disableAutoFocus: false
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
// data attributes override JS config, which overrides defaults
|
|
297
|
-
this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
|
|
298
|
-
this.$module = $module;
|
|
622
|
+
constructor($root, config = {}) {
|
|
623
|
+
super($root, config);
|
|
299
624
|
|
|
300
625
|
/**
|
|
301
626
|
* Focus the alert
|
|
@@ -308,14 +633,14 @@ class Alert {
|
|
|
308
633
|
* do this based on user research findings, or to avoid a clash with another
|
|
309
634
|
* element which should be focused when the page loads.
|
|
310
635
|
*/
|
|
311
|
-
if (this.$
|
|
312
|
-
setFocus(this.$
|
|
636
|
+
if (this.$root.getAttribute('role') === 'alert' && !this.config.disableAutoFocus) {
|
|
637
|
+
setFocus(this.$root);
|
|
313
638
|
}
|
|
314
|
-
this.$dismissButton = this.$
|
|
639
|
+
this.$dismissButton = this.$root.querySelector('.moj-alert__dismiss');
|
|
315
640
|
if (this.config.dismissible && this.$dismissButton) {
|
|
316
641
|
this.$dismissButton.innerHTML = this.config.dismissText;
|
|
317
642
|
this.$dismissButton.removeAttribute('hidden');
|
|
318
|
-
this.$
|
|
643
|
+
this.$root.addEventListener('click', event => {
|
|
319
644
|
if (event.target instanceof Node && this.$dismissButton.contains(event.target)) {
|
|
320
645
|
this.dimiss();
|
|
321
646
|
}
|
|
@@ -336,7 +661,7 @@ class Alert {
|
|
|
336
661
|
|
|
337
662
|
// Is the next sibling another alert
|
|
338
663
|
if (!$elementToRecieveFocus) {
|
|
339
|
-
const $nextSibling = this.$
|
|
664
|
+
const $nextSibling = this.$root.nextElementSibling;
|
|
340
665
|
if ($nextSibling && $nextSibling.matches('.moj-alert')) {
|
|
341
666
|
$elementToRecieveFocus = $nextSibling;
|
|
342
667
|
}
|
|
@@ -344,13 +669,13 @@ class Alert {
|
|
|
344
669
|
|
|
345
670
|
// Else try to find any preceding sibling alert or heading
|
|
346
671
|
if (!$elementToRecieveFocus) {
|
|
347
|
-
$elementToRecieveFocus = getPreviousSibling(this.$
|
|
672
|
+
$elementToRecieveFocus = getPreviousSibling(this.$root, '.moj-alert, h1, h2, h3, h4, h5, h6');
|
|
348
673
|
}
|
|
349
674
|
|
|
350
675
|
// Else find the closest ancestor heading, or fallback to main, or last resort
|
|
351
676
|
// use the body element
|
|
352
677
|
if (!$elementToRecieveFocus) {
|
|
353
|
-
$elementToRecieveFocus = findNearestMatchingElement(this.$
|
|
678
|
+
$elementToRecieveFocus = findNearestMatchingElement(this.$root, 'h1, h2, h3, h4, h5, h6, main, body');
|
|
354
679
|
}
|
|
355
680
|
|
|
356
681
|
// If we have an element, place focus on it
|
|
@@ -359,111 +684,12 @@ class Alert {
|
|
|
359
684
|
}
|
|
360
685
|
|
|
361
686
|
// Remove the alert
|
|
362
|
-
this.$
|
|
687
|
+
this.$root.remove();
|
|
363
688
|
}
|
|
364
689
|
|
|
365
690
|
/**
|
|
366
|
-
*
|
|
367
|
-
|
|
368
|
-
* 'If it looks like a duck, and it quacks like a duck…' 🦆
|
|
369
|
-
*
|
|
370
|
-
* If the passed value looks like a boolean or a number, convert it to a boolean
|
|
371
|
-
* or number.
|
|
372
|
-
*
|
|
373
|
-
* Designed to be used to convert config passed via data attributes (which are
|
|
374
|
-
* always strings) into something sensible.
|
|
375
|
-
*
|
|
376
|
-
* @internal
|
|
377
|
-
* @param {DOMStringMap[string]} value - The value to normalise
|
|
378
|
-
* @param {SchemaProperty} [property] - Component schema property
|
|
379
|
-
* @returns {string | boolean | number | undefined} Normalised data
|
|
380
|
-
*/
|
|
381
|
-
normaliseString(value, property) {
|
|
382
|
-
const trimmedValue = value ? value.trim() : '';
|
|
383
|
-
let output;
|
|
384
|
-
let outputType;
|
|
385
|
-
if (property && property.type) {
|
|
386
|
-
outputType = property.type;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// No schema type set? Determine automatically
|
|
390
|
-
if (!outputType) {
|
|
391
|
-
if (['true', 'false'].includes(trimmedValue)) {
|
|
392
|
-
outputType = 'boolean';
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Empty / whitespace-only strings are considered finite so we need to check
|
|
396
|
-
// the length of the trimmed string as well
|
|
397
|
-
if (trimmedValue.length > 0 && Number.isFinite(Number(trimmedValue))) {
|
|
398
|
-
outputType = 'number';
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
switch (outputType) {
|
|
402
|
-
case 'boolean':
|
|
403
|
-
output = trimmedValue === 'true';
|
|
404
|
-
break;
|
|
405
|
-
case 'number':
|
|
406
|
-
output = Number(trimmedValue);
|
|
407
|
-
break;
|
|
408
|
-
default:
|
|
409
|
-
output = value;
|
|
410
|
-
}
|
|
411
|
-
return output;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Parse dataset
|
|
416
|
-
*
|
|
417
|
-
* Loop over an object and normalise each value using {@link normaliseString},
|
|
418
|
-
* optionally expanding nested `i18n.field`
|
|
419
|
-
*
|
|
420
|
-
* @param {Schema} schema - component schema
|
|
421
|
-
* @param {DOMStringMap} dataset - HTML element dataset
|
|
422
|
-
* @returns {object} Normalised dataset
|
|
423
|
-
*/
|
|
424
|
-
parseDataset(schema, dataset) {
|
|
425
|
-
const parsed = {};
|
|
426
|
-
for (const [field, property] of Object.entries(schema.properties)) {
|
|
427
|
-
if (field in dataset) {
|
|
428
|
-
if (dataset[field]) {
|
|
429
|
-
parsed[field] = this.normaliseString(dataset[field], property);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
return parsed;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Config merging function
|
|
438
|
-
*
|
|
439
|
-
* Takes any number of objects and combines them together, with
|
|
440
|
-
* greatest priority on the LAST item passed in.
|
|
441
|
-
*
|
|
442
|
-
* @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
|
|
443
|
-
* @returns {{ [key: string]: unknown }} A merged config object
|
|
444
|
-
*/
|
|
445
|
-
mergeConfigs(...configObjects) {
|
|
446
|
-
const formattedConfigObject = {};
|
|
447
|
-
|
|
448
|
-
// Loop through each of the passed objects
|
|
449
|
-
for (const configObject of configObjects) {
|
|
450
|
-
for (const key of Object.keys(configObject)) {
|
|
451
|
-
const option = formattedConfigObject[key];
|
|
452
|
-
const override = configObject[key];
|
|
453
|
-
|
|
454
|
-
// Push their keys one-by-one into formattedConfigObject. Any duplicate
|
|
455
|
-
// keys with object values will be merged, otherwise the new value will
|
|
456
|
-
// override the existing value.
|
|
457
|
-
if (typeof option === 'object' && typeof override === 'object') {
|
|
458
|
-
// @ts-expect-error Index signature for type 'string' is missing
|
|
459
|
-
formattedConfigObject[key] = this.mergeConfigs(option, override);
|
|
460
|
-
} else {
|
|
461
|
-
formattedConfigObject[key] = override;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
return formattedConfigObject;
|
|
466
|
-
}
|
|
691
|
+
* Name for the component used when initialising using data-module attributes.
|
|
692
|
+
*/
|
|
467
693
|
}
|
|
468
694
|
|
|
469
695
|
/**
|
|
@@ -474,71 +700,87 @@ class Alert {
|
|
|
474
700
|
* @property {string} [focusOnDismissSelector] - CSS Selector for element to be focused on dismiss
|
|
475
701
|
*/
|
|
476
702
|
|
|
477
|
-
|
|
703
|
+
/**
|
|
704
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
705
|
+
*/
|
|
706
|
+
Alert.moduleName = 'moj-alert';
|
|
707
|
+
/**
|
|
708
|
+
* Alert default config
|
|
709
|
+
*
|
|
710
|
+
* @type {AlertConfig}
|
|
711
|
+
*/
|
|
712
|
+
Alert.defaults = Object.freeze({
|
|
713
|
+
dismissible: false,
|
|
714
|
+
dismissText: 'Dismiss',
|
|
715
|
+
disableAutoFocus: false
|
|
716
|
+
});
|
|
717
|
+
/**
|
|
718
|
+
* Alert config schema
|
|
719
|
+
*
|
|
720
|
+
* @satisfies {Schema<AlertConfig>}
|
|
721
|
+
*/
|
|
722
|
+
Alert.schema = Object.freeze(/** @type {const} */{
|
|
723
|
+
properties: {
|
|
724
|
+
dismissible: {
|
|
725
|
+
type: 'boolean'
|
|
726
|
+
},
|
|
727
|
+
dismissText: {
|
|
728
|
+
type: 'string'
|
|
729
|
+
},
|
|
730
|
+
disableAutoFocus: {
|
|
731
|
+
type: 'boolean'
|
|
732
|
+
},
|
|
733
|
+
focusOnDismissSelector: {
|
|
734
|
+
type: 'string'
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* @augments {ConfigurableComponent<ButtonMenuConfig>}
|
|
741
|
+
*/
|
|
742
|
+
class ButtonMenu extends ConfigurableComponent {
|
|
478
743
|
/**
|
|
479
|
-
* @param {Element | null} $
|
|
744
|
+
* @param {Element | null} $root - HTML element to use for button menu
|
|
480
745
|
* @param {ButtonMenuConfig} [config] - Button menu config
|
|
481
746
|
*/
|
|
482
|
-
constructor($
|
|
483
|
-
|
|
484
|
-
return this;
|
|
485
|
-
}
|
|
486
|
-
const schema = Object.freeze({
|
|
487
|
-
properties: {
|
|
488
|
-
buttonText: {
|
|
489
|
-
type: 'string'
|
|
490
|
-
},
|
|
491
|
-
buttonClasses: {
|
|
492
|
-
type: 'string'
|
|
493
|
-
},
|
|
494
|
-
alignMenu: {
|
|
495
|
-
type: 'string'
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
});
|
|
499
|
-
const defaults = {
|
|
500
|
-
buttonText: 'Actions',
|
|
501
|
-
alignMenu: 'left',
|
|
502
|
-
buttonClasses: ''
|
|
503
|
-
};
|
|
504
|
-
// data attributes override JS config, which overrides defaults
|
|
505
|
-
this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
|
|
506
|
-
this.$module = $module;
|
|
747
|
+
constructor($root, config = {}) {
|
|
748
|
+
super($root, config);
|
|
507
749
|
|
|
508
750
|
// If only one button is provided, don't initiate a menu and toggle button
|
|
509
751
|
// if classes have been provided for the toggleButton, apply them to the single item
|
|
510
|
-
if (this.$
|
|
511
|
-
const button = this.$
|
|
512
|
-
button.classList.forEach(className => {
|
|
752
|
+
if (this.$root.children.length === 1) {
|
|
753
|
+
const $button = this.$root.children[0];
|
|
754
|
+
$button.classList.forEach(className => {
|
|
513
755
|
if (className.startsWith('govuk-button-')) {
|
|
514
|
-
button.classList.remove(className);
|
|
756
|
+
$button.classList.remove(className);
|
|
515
757
|
}
|
|
516
|
-
button.classList.remove('moj-button-menu__item');
|
|
517
|
-
button.classList.add('moj-button-menu__single-button');
|
|
758
|
+
$button.classList.remove('moj-button-menu__item');
|
|
759
|
+
$button.classList.add('moj-button-menu__single-button');
|
|
518
760
|
});
|
|
519
761
|
if (this.config.buttonClasses) {
|
|
520
|
-
button.classList.add(...this.config.buttonClasses.split(' '));
|
|
762
|
+
$button.classList.add(...this.config.buttonClasses.split(' '));
|
|
521
763
|
}
|
|
522
764
|
}
|
|
523
|
-
// Otherwise
|
|
524
|
-
if (this.$
|
|
765
|
+
// Otherwise initialise a button menu
|
|
766
|
+
if (this.$root.children.length > 1) {
|
|
525
767
|
this.initMenu();
|
|
526
768
|
}
|
|
527
769
|
}
|
|
528
770
|
initMenu() {
|
|
529
771
|
this.$menu = this.createMenu();
|
|
530
|
-
this.$
|
|
772
|
+
this.$root.insertAdjacentHTML('afterbegin', this.toggleTemplate());
|
|
531
773
|
this.setupMenuItems();
|
|
532
|
-
this.$menuToggle = this.$
|
|
533
|
-
this
|
|
774
|
+
this.$menuToggle = this.$root.querySelector(':scope > button');
|
|
775
|
+
this.$items = this.$menu.querySelectorAll('a, button');
|
|
534
776
|
this.$menuToggle.addEventListener('click', event => {
|
|
535
777
|
this.toggleMenu(event);
|
|
536
778
|
});
|
|
537
|
-
this.$
|
|
779
|
+
this.$root.addEventListener('keydown', event => {
|
|
538
780
|
this.handleKeyDown(event);
|
|
539
781
|
});
|
|
540
782
|
document.addEventListener('click', event => {
|
|
541
|
-
if (!this.$
|
|
783
|
+
if (event.target instanceof Node && !this.$root.contains(event.target)) {
|
|
542
784
|
this.closeMenu(false);
|
|
543
785
|
}
|
|
544
786
|
});
|
|
@@ -551,30 +793,30 @@ class ButtonMenu {
|
|
|
551
793
|
if (this.config.alignMenu === 'right') {
|
|
552
794
|
$menu.classList.add('moj-button-menu__wrapper--right');
|
|
553
795
|
}
|
|
554
|
-
this.$
|
|
555
|
-
while (this.$
|
|
556
|
-
$menu.appendChild(this.$
|
|
796
|
+
this.$root.appendChild($menu);
|
|
797
|
+
while (this.$root.firstChild !== $menu) {
|
|
798
|
+
$menu.appendChild(this.$root.firstChild);
|
|
557
799
|
}
|
|
558
800
|
return $menu;
|
|
559
801
|
}
|
|
560
802
|
setupMenuItems() {
|
|
561
|
-
Array.from(this.$menu.children).forEach(
|
|
803
|
+
Array.from(this.$menu.children).forEach($menuItem => {
|
|
562
804
|
// wrap item in li tag
|
|
563
|
-
const listItem = document.createElement('li');
|
|
564
|
-
this.$menu.insertBefore(listItem,
|
|
565
|
-
listItem.appendChild(
|
|
566
|
-
|
|
567
|
-
if (
|
|
568
|
-
|
|
805
|
+
const $listItem = document.createElement('li');
|
|
806
|
+
this.$menu.insertBefore($listItem, $menuItem);
|
|
807
|
+
$listItem.appendChild($menuItem);
|
|
808
|
+
$menuItem.setAttribute('tabindex', '-1');
|
|
809
|
+
if ($menuItem.tagName === 'BUTTON') {
|
|
810
|
+
$menuItem.setAttribute('type', 'button');
|
|
569
811
|
}
|
|
570
|
-
|
|
812
|
+
$menuItem.classList.forEach(className => {
|
|
571
813
|
if (className.startsWith('govuk-button')) {
|
|
572
|
-
|
|
814
|
+
$menuItem.classList.remove(className);
|
|
573
815
|
}
|
|
574
816
|
});
|
|
575
817
|
|
|
576
818
|
// add a slight delay after click before closing the menu, makes it *feel* better
|
|
577
|
-
|
|
819
|
+
$menuItem.addEventListener('click', () => {
|
|
578
820
|
setTimeout(() => {
|
|
579
821
|
this.closeMenu(false);
|
|
580
822
|
}, 50);
|
|
@@ -599,6 +841,10 @@ class ButtonMenu {
|
|
|
599
841
|
isOpen() {
|
|
600
842
|
return this.$menuToggle.getAttribute('aria-expanded') === 'true';
|
|
601
843
|
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* @param {MouseEvent} event - Click event
|
|
847
|
+
*/
|
|
602
848
|
toggleMenu(event) {
|
|
603
849
|
event.preventDefault();
|
|
604
850
|
|
|
@@ -644,18 +890,22 @@ class ButtonMenu {
|
|
|
644
890
|
* @param {number} index - the index of the item to focus
|
|
645
891
|
*/
|
|
646
892
|
focusItem(index) {
|
|
647
|
-
if (index >= this
|
|
648
|
-
if (index < 0) index = this
|
|
649
|
-
const menuItem = this
|
|
650
|
-
if (menuItem) {
|
|
651
|
-
menuItem.focus();
|
|
893
|
+
if (index >= this.$items.length) index = 0;
|
|
894
|
+
if (index < 0) index = this.$items.length - 1;
|
|
895
|
+
const $menuItem = this.$items.item(index);
|
|
896
|
+
if ($menuItem) {
|
|
897
|
+
$menuItem.focus();
|
|
652
898
|
}
|
|
653
899
|
}
|
|
654
900
|
currentFocusIndex() {
|
|
655
|
-
const activeElement = document.activeElement;
|
|
656
|
-
const menuItems = Array.from(this
|
|
657
|
-
return menuItems.indexOf(activeElement);
|
|
901
|
+
const $activeElement = document.activeElement;
|
|
902
|
+
const $menuItems = Array.from(this.$items);
|
|
903
|
+
return ($activeElement instanceof HTMLAnchorElement || $activeElement instanceof HTMLButtonElement) && $menuItems.indexOf($activeElement);
|
|
658
904
|
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* @param {KeyboardEvent} event - Keydown event
|
|
908
|
+
*/
|
|
659
909
|
handleKeyDown(event) {
|
|
660
910
|
if (event.target === this.$menuToggle) {
|
|
661
911
|
switch (event.key) {
|
|
@@ -665,11 +915,11 @@ class ButtonMenu {
|
|
|
665
915
|
break;
|
|
666
916
|
case 'ArrowUp':
|
|
667
917
|
event.preventDefault();
|
|
668
|
-
this.openMenu(this
|
|
918
|
+
this.openMenu(this.$items.length - 1);
|
|
669
919
|
break;
|
|
670
920
|
}
|
|
671
921
|
}
|
|
672
|
-
if (this.$menu.contains(event.target) && this.isOpen()) {
|
|
922
|
+
if (event.target instanceof Node && this.$menu.contains(event.target) && this.isOpen()) {
|
|
673
923
|
switch (event.key) {
|
|
674
924
|
case 'ArrowDown':
|
|
675
925
|
event.preventDefault();
|
|
@@ -689,7 +939,7 @@ class ButtonMenu {
|
|
|
689
939
|
break;
|
|
690
940
|
case 'End':
|
|
691
941
|
event.preventDefault();
|
|
692
|
-
this.focusItem(this
|
|
942
|
+
this.focusItem(this.$items.length - 1);
|
|
693
943
|
break;
|
|
694
944
|
}
|
|
695
945
|
}
|
|
@@ -702,58 +952,8 @@ class ButtonMenu {
|
|
|
702
952
|
}
|
|
703
953
|
|
|
704
954
|
/**
|
|
705
|
-
*
|
|
706
|
-
|
|
707
|
-
* Loop over an object and normalise each value using {@link normaliseString},
|
|
708
|
-
* optionally expanding nested `i18n.field`
|
|
709
|
-
*
|
|
710
|
-
* @param {Schema} schema - component schema
|
|
711
|
-
* @param {DOMStringMap} dataset - HTML element dataset
|
|
712
|
-
* @returns {object} Normalised dataset
|
|
713
|
-
*/
|
|
714
|
-
parseDataset(schema, dataset) {
|
|
715
|
-
const parsed = {};
|
|
716
|
-
for (const [field,,] of Object.entries(schema.properties)) {
|
|
717
|
-
if (field in dataset) {
|
|
718
|
-
if (dataset[field]) {
|
|
719
|
-
parsed[field] = dataset[field];
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
return parsed;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Config merging function
|
|
728
|
-
*
|
|
729
|
-
* Takes any number of objects and combines them together, with
|
|
730
|
-
* greatest priority on the LAST item passed in.
|
|
731
|
-
*
|
|
732
|
-
* @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
|
|
733
|
-
* @returns {{ [key: string]: unknown }} A merged config object
|
|
734
|
-
*/
|
|
735
|
-
mergeConfigs(...configObjects) {
|
|
736
|
-
const formattedConfigObject = {};
|
|
737
|
-
|
|
738
|
-
// Loop through each of the passed objects
|
|
739
|
-
for (const configObject of configObjects) {
|
|
740
|
-
for (const key of Object.keys(configObject)) {
|
|
741
|
-
const option = formattedConfigObject[key];
|
|
742
|
-
const override = configObject[key];
|
|
743
|
-
|
|
744
|
-
// Push their keys one-by-one into formattedConfigObject. Any duplicate
|
|
745
|
-
// keys with object values will be merged, otherwise the new value will
|
|
746
|
-
// override the existing value.
|
|
747
|
-
if (typeof option === 'object' && typeof override === 'object') {
|
|
748
|
-
// @ts-expect-error Index signature for type 'string' is missing
|
|
749
|
-
formattedConfigObject[key] = this.mergeConfigs(option, override);
|
|
750
|
-
} else {
|
|
751
|
-
formattedConfigObject[key] = override;
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
return formattedConfigObject;
|
|
756
|
-
}
|
|
955
|
+
* Name for the component used when initialising using data-module attributes.
|
|
956
|
+
*/
|
|
757
957
|
}
|
|
758
958
|
|
|
759
959
|
/**
|
|
@@ -763,69 +963,68 @@ class ButtonMenu {
|
|
|
763
963
|
* @property {string} [buttonClasses='govuk-button--secondary'] - css classes applied to the toggle button
|
|
764
964
|
*/
|
|
765
965
|
|
|
766
|
-
|
|
966
|
+
/**
|
|
967
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
968
|
+
*/
|
|
969
|
+
ButtonMenu.moduleName = 'moj-button-menu';
|
|
970
|
+
/**
|
|
971
|
+
* Button menu config
|
|
972
|
+
*
|
|
973
|
+
* @type {ButtonMenuConfig}
|
|
974
|
+
*/
|
|
975
|
+
ButtonMenu.defaults = Object.freeze({
|
|
976
|
+
buttonText: 'Actions',
|
|
977
|
+
alignMenu: 'left',
|
|
978
|
+
buttonClasses: ''
|
|
979
|
+
});
|
|
980
|
+
/**
|
|
981
|
+
* Button menu config schema
|
|
982
|
+
*
|
|
983
|
+
* @type {Schema<ButtonMenuConfig>}
|
|
984
|
+
*/
|
|
985
|
+
ButtonMenu.schema = Object.freeze(/** @type {const} */{
|
|
986
|
+
properties: {
|
|
987
|
+
buttonText: {
|
|
988
|
+
type: 'string'
|
|
989
|
+
},
|
|
990
|
+
buttonClasses: {
|
|
991
|
+
type: 'string'
|
|
992
|
+
},
|
|
993
|
+
alignMenu: {
|
|
994
|
+
type: 'string'
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* @augments {ConfigurableComponent<DatePickerConfig>}
|
|
1001
|
+
*/
|
|
1002
|
+
class DatePicker extends ConfigurableComponent {
|
|
767
1003
|
/**
|
|
768
|
-
* @param {Element | null} $
|
|
1004
|
+
* @param {Element | null} $root - HTML element to use for date picker
|
|
769
1005
|
* @param {DatePickerConfig} [config] - Date picker config
|
|
770
1006
|
*/
|
|
771
|
-
constructor($
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
const $input = $module.querySelector('.moj-js-datepicker-input');
|
|
776
|
-
|
|
777
|
-
// Check that required elements are present
|
|
1007
|
+
constructor($root, config = {}) {
|
|
1008
|
+
var _this$config$input$el;
|
|
1009
|
+
super($root, config);
|
|
1010
|
+
const $input = (_this$config$input$el = this.config.input.element) != null ? _this$config$input$el : this.$root.querySelector(this.config.input.selector);
|
|
778
1011
|
if (!$input || !($input instanceof HTMLInputElement)) {
|
|
779
1012
|
return this;
|
|
780
1013
|
}
|
|
781
|
-
this.$module = $module;
|
|
782
1014
|
this.$input = $input;
|
|
783
|
-
const schema = Object.freeze({
|
|
784
|
-
properties: {
|
|
785
|
-
excludedDates: {
|
|
786
|
-
type: 'string'
|
|
787
|
-
},
|
|
788
|
-
excludedDays: {
|
|
789
|
-
type: 'string'
|
|
790
|
-
},
|
|
791
|
-
leadingZeros: {
|
|
792
|
-
type: 'string'
|
|
793
|
-
},
|
|
794
|
-
maxDate: {
|
|
795
|
-
type: 'string'
|
|
796
|
-
},
|
|
797
|
-
minDate: {
|
|
798
|
-
type: 'string'
|
|
799
|
-
},
|
|
800
|
-
weekStartDay: {
|
|
801
|
-
type: 'string'
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
});
|
|
805
|
-
const defaults = {
|
|
806
|
-
leadingZeros: false,
|
|
807
|
-
weekStartDay: 'monday'
|
|
808
|
-
};
|
|
809
|
-
|
|
810
|
-
// data attributes override JS config, which overrides defaults
|
|
811
|
-
this.config = this.mergeConfigs(defaults, config, this.parseDataset(schema, $module.dataset));
|
|
812
1015
|
this.dayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
813
1016
|
this.monthLabels = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
814
1017
|
this.currentDate = new Date();
|
|
815
1018
|
this.currentDate.setHours(0, 0, 0, 0);
|
|
816
|
-
this.calendarDays = [];
|
|
817
|
-
this.excludedDates = [];
|
|
818
|
-
this.excludedDays = [];
|
|
1019
|
+
this.calendarDays = /** @type {DSCalendarDay[]} */[];
|
|
1020
|
+
this.excludedDates = /** @type {Date[]} */[];
|
|
1021
|
+
this.excludedDays = /** @type {number[]} */[];
|
|
819
1022
|
this.buttonClass = 'moj-datepicker__button';
|
|
820
1023
|
this.selectedDayButtonClass = 'moj-datepicker__button--selected';
|
|
821
1024
|
this.currentDayButtonClass = 'moj-datepicker__button--current';
|
|
822
1025
|
this.todayButtonClass = 'moj-datepicker__button--today';
|
|
823
|
-
if (this.$module.dataset.initialized) {
|
|
824
|
-
return this;
|
|
825
|
-
}
|
|
826
1026
|
this.setOptions();
|
|
827
1027
|
this.initControls();
|
|
828
|
-
this.$module.setAttribute('data-initialized', 'true');
|
|
829
1028
|
}
|
|
830
1029
|
initControls() {
|
|
831
1030
|
this.id = `datepicker-${this.$input.id}`;
|
|
@@ -840,15 +1039,23 @@ class DatePicker {
|
|
|
840
1039
|
$inputWrapper.appendChild(this.$input);
|
|
841
1040
|
$inputWrapper.insertAdjacentHTML('beforeend', this.toggleTemplate());
|
|
842
1041
|
$componentWrapper.insertAdjacentElement('beforeend', this.$dialog);
|
|
843
|
-
this.$calendarButton =
|
|
844
|
-
this.$
|
|
1042
|
+
this.$calendarButton = /** @type {HTMLButtonElement} */
|
|
1043
|
+
this.$root.querySelector('.moj-js-datepicker-toggle');
|
|
1044
|
+
this.$dialogTitle = /** @type {HTMLHeadingElement} */
|
|
1045
|
+
this.$dialog.querySelector('.moj-js-datepicker-month-year');
|
|
845
1046
|
this.createCalendar();
|
|
846
|
-
this.$prevMonthButton =
|
|
847
|
-
this.$
|
|
848
|
-
this.$
|
|
849
|
-
this.$
|
|
850
|
-
this.$
|
|
851
|
-
this.$
|
|
1047
|
+
this.$prevMonthButton = /** @type {HTMLButtonElement} */
|
|
1048
|
+
this.$dialog.querySelector('.moj-js-datepicker-prev-month');
|
|
1049
|
+
this.$prevYearButton = /** @type {HTMLButtonElement} */
|
|
1050
|
+
this.$dialog.querySelector('.moj-js-datepicker-prev-year');
|
|
1051
|
+
this.$nextMonthButton = /** @type {HTMLButtonElement} */
|
|
1052
|
+
this.$dialog.querySelector('.moj-js-datepicker-next-month');
|
|
1053
|
+
this.$nextYearButton = /** @type {HTMLButtonElement} */
|
|
1054
|
+
this.$dialog.querySelector('.moj-js-datepicker-next-year');
|
|
1055
|
+
this.$cancelButton = /** @type {HTMLButtonElement} */
|
|
1056
|
+
this.$dialog.querySelector('.moj-js-datepicker-cancel');
|
|
1057
|
+
this.$okButton = /** @type {HTMLButtonElement} */
|
|
1058
|
+
this.$dialog.querySelector('.moj-js-datepicker-ok');
|
|
852
1059
|
|
|
853
1060
|
// add event listeners
|
|
854
1061
|
this.$prevMonthButton.addEventListener('click', event => this.focusPreviousMonth(event, false));
|
|
@@ -862,9 +1069,9 @@ class DatePicker {
|
|
|
862
1069
|
this.$okButton.addEventListener('click', () => {
|
|
863
1070
|
this.selectDate(this.currentDate);
|
|
864
1071
|
});
|
|
865
|
-
const dialogButtons = this.$dialog.querySelectorAll('button:not([disabled="true"])');
|
|
866
|
-
this.$firstButtonInDialog = dialogButtons[0];
|
|
867
|
-
this.$lastButtonInDialog = dialogButtons[dialogButtons.length - 1];
|
|
1072
|
+
const $dialogButtons = this.$dialog.querySelectorAll('button:not([disabled="true"])');
|
|
1073
|
+
this.$firstButtonInDialog = $dialogButtons[0];
|
|
1074
|
+
this.$lastButtonInDialog = $dialogButtons[$dialogButtons.length - 1];
|
|
868
1075
|
this.$firstButtonInDialog.addEventListener('keydown', event => this.firstButtonKeydown(event));
|
|
869
1076
|
this.$lastButtonInDialog.addEventListener('keydown', event => this.lastButtonKeydown(event));
|
|
870
1077
|
this.$calendarButton.addEventListener('click', event => this.toggleDialog(event));
|
|
@@ -1010,7 +1217,6 @@ class DatePicker {
|
|
|
1010
1217
|
this.setMinAndMaxDatesOnCalendar();
|
|
1011
1218
|
this.setExcludedDates();
|
|
1012
1219
|
this.setExcludedDays();
|
|
1013
|
-
this.setLeadingZeros();
|
|
1014
1220
|
this.setWeekStartDay();
|
|
1015
1221
|
}
|
|
1016
1222
|
setMinAndMaxDatesOnCalendar() {
|
|
@@ -1035,10 +1241,10 @@ class DatePicker {
|
|
|
1035
1241
|
}
|
|
1036
1242
|
}
|
|
1037
1243
|
|
|
1038
|
-
|
|
1244
|
+
/**
|
|
1039
1245
|
* Parses a daterange string into an array of dates
|
|
1040
|
-
*
|
|
1041
|
-
* @
|
|
1246
|
+
*
|
|
1247
|
+
* @param {string} datestring - A daterange string in the format "dd/mm/yyyy-dd/mm/yyyy"
|
|
1042
1248
|
*/
|
|
1043
1249
|
parseDateRangeString(datestring) {
|
|
1044
1250
|
const dates = [];
|
|
@@ -1065,17 +1271,6 @@ class DatePicker {
|
|
|
1065
1271
|
this.excludedDays = this.config.excludedDays.replace(/\s+/, ' ').toLowerCase().split(' ').map(item => weekDays.indexOf(item)).filter(item => item !== -1);
|
|
1066
1272
|
}
|
|
1067
1273
|
}
|
|
1068
|
-
setLeadingZeros() {
|
|
1069
|
-
if (typeof this.config.leadingZeros !== 'boolean') {
|
|
1070
|
-
if (this.config.leadingZeros.toLowerCase() === 'true') {
|
|
1071
|
-
this.config.leadingZeros = true;
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
if (this.config.leadingZeros.toLowerCase() === 'false') {
|
|
1075
|
-
this.config.leadingZeros = false;
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
1274
|
setWeekStartDay() {
|
|
1080
1275
|
const weekStartDayParam = this.config.weekStartDay;
|
|
1081
1276
|
if (weekStartDayParam && weekStartDayParam.toLowerCase() === 'sunday') {
|
|
@@ -1088,7 +1283,7 @@ class DatePicker {
|
|
|
1088
1283
|
}
|
|
1089
1284
|
|
|
1090
1285
|
/**
|
|
1091
|
-
* Determine if a date is
|
|
1286
|
+
* Determine if a date is selectable
|
|
1092
1287
|
*
|
|
1093
1288
|
* @param {Date} date - the date to check
|
|
1094
1289
|
* @returns {boolean}
|
|
@@ -1154,24 +1349,36 @@ class DatePicker {
|
|
|
1154
1349
|
/**
|
|
1155
1350
|
* Get a human readable date in the format Monday 2 March 2024
|
|
1156
1351
|
*
|
|
1157
|
-
* @param {Date} date -
|
|
1352
|
+
* @param {Date} date - Date to format
|
|
1158
1353
|
* @returns {string}
|
|
1159
1354
|
*/
|
|
1160
1355
|
formattedDateHuman(date) {
|
|
1161
1356
|
return `${this.dayLabels[(date.getDay() + 6) % 7]} ${date.getDate()} ${this.monthLabels[date.getMonth()]} ${date.getFullYear()}`;
|
|
1162
1357
|
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* @param {MouseEvent} event - Click event
|
|
1361
|
+
*/
|
|
1163
1362
|
backgroundClick(event) {
|
|
1164
1363
|
if (this.isOpen() && event.target instanceof Node && !this.$dialog.contains(event.target) && !this.$input.contains(event.target) && !this.$calendarButton.contains(event.target)) {
|
|
1165
1364
|
event.preventDefault();
|
|
1166
1365
|
this.closeDialog();
|
|
1167
1366
|
}
|
|
1168
1367
|
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* @param {KeyboardEvent} event - Keydown event
|
|
1371
|
+
*/
|
|
1169
1372
|
firstButtonKeydown(event) {
|
|
1170
1373
|
if (event.key === 'Tab' && event.shiftKey) {
|
|
1171
1374
|
this.$lastButtonInDialog.focus();
|
|
1172
1375
|
event.preventDefault();
|
|
1173
1376
|
}
|
|
1174
1377
|
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* @param {KeyboardEvent} event - Keydown event
|
|
1381
|
+
*/
|
|
1175
1382
|
lastButtonKeydown(event) {
|
|
1176
1383
|
if (event.key === 'Tab' && !event.shiftKey) {
|
|
1177
1384
|
this.$firstButtonInDialog.focus();
|
|
@@ -1201,49 +1408,57 @@ class DatePicker {
|
|
|
1201
1408
|
thisDay.setDate(thisDay.getDate() + 1);
|
|
1202
1409
|
}
|
|
1203
1410
|
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* @param {boolean} [focus] - Focus the day button
|
|
1414
|
+
*/
|
|
1204
1415
|
setCurrentDate(focus = true) {
|
|
1205
1416
|
const {
|
|
1206
1417
|
currentDate
|
|
1207
1418
|
} = this;
|
|
1208
1419
|
this.calendarDays.forEach(calendarDay => {
|
|
1209
|
-
calendarDay
|
|
1210
|
-
calendarDay
|
|
1211
|
-
calendarDay
|
|
1212
|
-
calendarDay
|
|
1420
|
+
calendarDay.$button.classList.add('moj-datepicker__button');
|
|
1421
|
+
calendarDay.$button.classList.add('moj-datepicker__calendar-day');
|
|
1422
|
+
calendarDay.$button.setAttribute('tabindex', '-1');
|
|
1423
|
+
calendarDay.$button.classList.remove(this.selectedDayButtonClass);
|
|
1213
1424
|
const calendarDayDate = calendarDay.date;
|
|
1214
1425
|
calendarDayDate.setHours(0, 0, 0, 0);
|
|
1215
1426
|
const today = new Date();
|
|
1216
1427
|
today.setHours(0, 0, 0, 0);
|
|
1217
1428
|
if (calendarDayDate.getTime() === currentDate.getTime() /* && !calendarDay.button.disabled */) {
|
|
1218
1429
|
if (focus) {
|
|
1219
|
-
calendarDay
|
|
1220
|
-
calendarDay
|
|
1221
|
-
calendarDay
|
|
1430
|
+
calendarDay.$button.setAttribute('tabindex', '0');
|
|
1431
|
+
calendarDay.$button.focus();
|
|
1432
|
+
calendarDay.$button.classList.add(this.selectedDayButtonClass);
|
|
1222
1433
|
}
|
|
1223
1434
|
}
|
|
1224
1435
|
if (this.inputDate && calendarDayDate.getTime() === this.inputDate.getTime()) {
|
|
1225
|
-
calendarDay
|
|
1226
|
-
calendarDay
|
|
1436
|
+
calendarDay.$button.classList.add(this.currentDayButtonClass);
|
|
1437
|
+
calendarDay.$button.setAttribute('aria-current', 'date');
|
|
1227
1438
|
} else {
|
|
1228
|
-
calendarDay
|
|
1229
|
-
calendarDay
|
|
1439
|
+
calendarDay.$button.classList.remove(this.currentDayButtonClass);
|
|
1440
|
+
calendarDay.$button.removeAttribute('aria-current');
|
|
1230
1441
|
}
|
|
1231
1442
|
if (calendarDayDate.getTime() === today.getTime()) {
|
|
1232
|
-
calendarDay
|
|
1443
|
+
calendarDay.$button.classList.add(this.todayButtonClass);
|
|
1233
1444
|
} else {
|
|
1234
|
-
calendarDay
|
|
1445
|
+
calendarDay.$button.classList.remove(this.todayButtonClass);
|
|
1235
1446
|
}
|
|
1236
1447
|
});
|
|
1237
1448
|
|
|
1238
1449
|
// if no date is tab-able, make the first non-disabled date tab-able
|
|
1239
1450
|
if (!focus) {
|
|
1240
1451
|
const enabledDays = this.calendarDays.filter(calendarDay => {
|
|
1241
|
-
return window.getComputedStyle(calendarDay
|
|
1452
|
+
return window.getComputedStyle(calendarDay.$button).display === 'block' && !calendarDay.$button.disabled;
|
|
1242
1453
|
});
|
|
1243
|
-
enabledDays[0]
|
|
1454
|
+
enabledDays[0].$button.setAttribute('tabindex', '0');
|
|
1244
1455
|
this.currentDate = enabledDays[0].date;
|
|
1245
1456
|
}
|
|
1246
1457
|
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* @param {Date} date - Date to select
|
|
1461
|
+
*/
|
|
1247
1462
|
selectDate(date) {
|
|
1248
1463
|
if (this.isExcludedDate(date)) {
|
|
1249
1464
|
return;
|
|
@@ -1260,6 +1475,10 @@ class DatePicker {
|
|
|
1260
1475
|
isOpen() {
|
|
1261
1476
|
return this.$dialog.classList.contains('moj-datepicker__dialog--open');
|
|
1262
1477
|
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* @param {MouseEvent} event - Click event
|
|
1481
|
+
*/
|
|
1263
1482
|
toggleDialog(event) {
|
|
1264
1483
|
event.preventDefault();
|
|
1265
1484
|
if (this.isOpen()) {
|
|
@@ -1294,6 +1513,11 @@ class DatePicker {
|
|
|
1294
1513
|
this.$calendarButton.setAttribute('aria-expanded', 'false');
|
|
1295
1514
|
this.$calendarButton.focus();
|
|
1296
1515
|
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* @param {Date} date - Date to go to
|
|
1519
|
+
* @param {boolean} [focus] - Focus the day button
|
|
1520
|
+
*/
|
|
1297
1521
|
goToDate(date, focus) {
|
|
1298
1522
|
const current = this.currentDate;
|
|
1299
1523
|
this.currentDate = date;
|
|
@@ -1345,13 +1569,23 @@ class DatePicker {
|
|
|
1345
1569
|
this.goToDate(date);
|
|
1346
1570
|
}
|
|
1347
1571
|
|
|
1348
|
-
|
|
1572
|
+
/**
|
|
1573
|
+
* Month navigation
|
|
1574
|
+
*
|
|
1575
|
+
* @param {KeyboardEvent | MouseEvent} event - Key press or click event
|
|
1576
|
+
* @param {boolean} [focus] - Focus the day button
|
|
1577
|
+
*/
|
|
1349
1578
|
focusNextMonth(event, focus = true) {
|
|
1350
1579
|
event.preventDefault();
|
|
1351
1580
|
const date = new Date(this.currentDate);
|
|
1352
1581
|
date.setMonth(date.getMonth() + 1, 1);
|
|
1353
1582
|
this.goToDate(date, focus);
|
|
1354
1583
|
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* @param {KeyboardEvent | MouseEvent} event - Key press or click event
|
|
1587
|
+
* @param {boolean} [focus] - Focus the day button
|
|
1588
|
+
*/
|
|
1355
1589
|
focusPreviousMonth(event, focus = true) {
|
|
1356
1590
|
event.preventDefault();
|
|
1357
1591
|
const date = new Date(this.currentDate);
|
|
@@ -1359,13 +1593,23 @@ class DatePicker {
|
|
|
1359
1593
|
this.goToDate(date, focus);
|
|
1360
1594
|
}
|
|
1361
1595
|
|
|
1362
|
-
|
|
1596
|
+
/**
|
|
1597
|
+
* Year navigation
|
|
1598
|
+
*
|
|
1599
|
+
* @param {KeyboardEvent | MouseEvent} event - Key press or click event
|
|
1600
|
+
* @param {boolean} [focus] - Focus the day button
|
|
1601
|
+
*/
|
|
1363
1602
|
focusNextYear(event, focus = true) {
|
|
1364
1603
|
event.preventDefault();
|
|
1365
1604
|
const date = new Date(this.currentDate);
|
|
1366
1605
|
date.setFullYear(date.getFullYear() + 1, date.getMonth(), 1);
|
|
1367
1606
|
this.goToDate(date, focus);
|
|
1368
1607
|
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* @param {KeyboardEvent | MouseEvent} event - Key press or click event
|
|
1611
|
+
* @param {boolean} [focus] - Focus the day button
|
|
1612
|
+
*/
|
|
1369
1613
|
focusPreviousYear(event, focus = true) {
|
|
1370
1614
|
event.preventDefault();
|
|
1371
1615
|
const date = new Date(this.currentDate);
|
|
@@ -1374,72 +1618,70 @@ class DatePicker {
|
|
|
1374
1618
|
}
|
|
1375
1619
|
|
|
1376
1620
|
/**
|
|
1377
|
-
*
|
|
1378
|
-
|
|
1379
|
-
* @param {Schema} schema - Component class
|
|
1380
|
-
* @param {DOMStringMap} dataset - HTML element dataset
|
|
1381
|
-
* @returns {object} Normalised dataset
|
|
1382
|
-
*/
|
|
1383
|
-
parseDataset(schema, dataset) {
|
|
1384
|
-
const parsed = {};
|
|
1385
|
-
for (const [field,,] of Object.entries(schema.properties)) {
|
|
1386
|
-
if (field in dataset) {
|
|
1387
|
-
parsed[field] = dataset[field];
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
return parsed;
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
/**
|
|
1394
|
-
* Config merging function
|
|
1395
|
-
*
|
|
1396
|
-
* Takes any number of objects and combines them together, with
|
|
1397
|
-
* greatest priority on the LAST item passed in.
|
|
1398
|
-
*
|
|
1399
|
-
* @param {...{ [key: string]: unknown }} configObjects - Config objects to merge
|
|
1400
|
-
* @returns {{ [key: string]: unknown }} A merged config object
|
|
1401
|
-
*/
|
|
1402
|
-
mergeConfigs(...configObjects) {
|
|
1403
|
-
const formattedConfigObject = {};
|
|
1404
|
-
|
|
1405
|
-
// Loop through each of the passed objects
|
|
1406
|
-
for (const configObject of configObjects) {
|
|
1407
|
-
for (const key of Object.keys(configObject)) {
|
|
1408
|
-
const option = formattedConfigObject[key];
|
|
1409
|
-
const override = configObject[key];
|
|
1410
|
-
|
|
1411
|
-
// Push their keys one-by-one into formattedConfigObject. Any duplicate
|
|
1412
|
-
// keys with object values will be merged, otherwise the new value will
|
|
1413
|
-
// override the existing value.
|
|
1414
|
-
if (typeof option === 'object' && typeof override === 'object') {
|
|
1415
|
-
// @ts-expect-error Index signature for type 'string' is missing
|
|
1416
|
-
formattedConfigObject[key] = this.mergeConfigs(option, override);
|
|
1417
|
-
} else {
|
|
1418
|
-
formattedConfigObject[key] = override;
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
return formattedConfigObject;
|
|
1423
|
-
}
|
|
1621
|
+
* Name for the component used when initialising using data-module attributes.
|
|
1622
|
+
*/
|
|
1424
1623
|
}
|
|
1624
|
+
DatePicker.moduleName = 'moj-date-picker';
|
|
1625
|
+
/**
|
|
1626
|
+
* Date picker default config
|
|
1627
|
+
*
|
|
1628
|
+
* @type {DatePickerConfig}
|
|
1629
|
+
*/
|
|
1630
|
+
DatePicker.defaults = Object.freeze({
|
|
1631
|
+
leadingZeros: false,
|
|
1632
|
+
weekStartDay: 'monday',
|
|
1633
|
+
input: {
|
|
1634
|
+
selector: '.moj-js-datepicker-input'
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
/**
|
|
1638
|
+
* Date picker config schema
|
|
1639
|
+
*
|
|
1640
|
+
* @satisfies {Schema<DatePickerConfig>}
|
|
1641
|
+
*/
|
|
1642
|
+
DatePicker.schema = Object.freeze(/** @type {const} */{
|
|
1643
|
+
properties: {
|
|
1644
|
+
excludedDates: {
|
|
1645
|
+
type: 'string'
|
|
1646
|
+
},
|
|
1647
|
+
excludedDays: {
|
|
1648
|
+
type: 'string'
|
|
1649
|
+
},
|
|
1650
|
+
leadingZeros: {
|
|
1651
|
+
type: 'boolean'
|
|
1652
|
+
},
|
|
1653
|
+
maxDate: {
|
|
1654
|
+
type: 'string'
|
|
1655
|
+
},
|
|
1656
|
+
minDate: {
|
|
1657
|
+
type: 'string'
|
|
1658
|
+
},
|
|
1659
|
+
weekStartDay: {
|
|
1660
|
+
type: 'string'
|
|
1661
|
+
},
|
|
1662
|
+
input: {
|
|
1663
|
+
type: 'object'
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
});
|
|
1425
1667
|
class DSCalendarDay {
|
|
1426
1668
|
/**
|
|
1427
1669
|
*
|
|
1428
|
-
* @param {
|
|
1670
|
+
* @param {HTMLButtonElement} $button
|
|
1429
1671
|
* @param {number} index
|
|
1430
1672
|
* @param {number} row
|
|
1431
1673
|
* @param {number} column
|
|
1432
1674
|
* @param {DatePicker} picker
|
|
1433
1675
|
*/
|
|
1434
|
-
constructor(button, index, row, column, picker) {
|
|
1676
|
+
constructor($button, index, row, column, picker) {
|
|
1435
1677
|
this.index = index;
|
|
1436
1678
|
this.row = row;
|
|
1437
1679
|
this.column = column;
|
|
1438
|
-
this
|
|
1680
|
+
this.$button = $button;
|
|
1439
1681
|
this.picker = picker;
|
|
1440
1682
|
this.date = new Date();
|
|
1441
|
-
this
|
|
1442
|
-
this
|
|
1683
|
+
this.$button.addEventListener('keydown', this.keyPress.bind(this));
|
|
1684
|
+
this.$button.addEventListener('click', this.click.bind(this));
|
|
1443
1685
|
}
|
|
1444
1686
|
|
|
1445
1687
|
/**
|
|
@@ -1451,26 +1693,34 @@ class DSCalendarDay {
|
|
|
1451
1693
|
const label = day.getDate();
|
|
1452
1694
|
let accessibleLabel = this.picker.formattedDateHuman(day);
|
|
1453
1695
|
if (disabled) {
|
|
1454
|
-
this
|
|
1696
|
+
this.$button.setAttribute('aria-disabled', 'true');
|
|
1455
1697
|
accessibleLabel = `Excluded date, ${accessibleLabel}`;
|
|
1456
1698
|
} else {
|
|
1457
|
-
this
|
|
1699
|
+
this.$button.removeAttribute('aria-disabled');
|
|
1458
1700
|
}
|
|
1459
1701
|
if (hidden) {
|
|
1460
|
-
this
|
|
1702
|
+
this.$button.style.display = 'none';
|
|
1461
1703
|
} else {
|
|
1462
|
-
this
|
|
1704
|
+
this.$button.style.display = 'block';
|
|
1463
1705
|
}
|
|
1464
|
-
this
|
|
1465
|
-
this
|
|
1706
|
+
this.$button.setAttribute('data-testid', this.picker.formattedDateFromDate(day));
|
|
1707
|
+
this.$button.innerHTML = `<span class="govuk-visually-hidden">${accessibleLabel}</span><span aria-hidden="true">${label}</span>`;
|
|
1466
1708
|
this.date = new Date(day);
|
|
1467
1709
|
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* @param {MouseEvent} event - Click event
|
|
1713
|
+
*/
|
|
1468
1714
|
click(event) {
|
|
1469
1715
|
this.picker.goToDate(this.date);
|
|
1470
1716
|
this.picker.selectDate(this.date);
|
|
1471
1717
|
event.stopPropagation();
|
|
1472
1718
|
event.preventDefault();
|
|
1473
1719
|
}
|
|
1720
|
+
|
|
1721
|
+
/**
|
|
1722
|
+
* @param {KeyboardEvent} event - Keydown event
|
|
1723
|
+
*/
|
|
1474
1724
|
keyPress(event) {
|
|
1475
1725
|
let calendarNavKey = true;
|
|
1476
1726
|
switch (event.key) {
|
|
@@ -1527,45 +1777,61 @@ class DSCalendarDay {
|
|
|
1527
1777
|
* @typedef {object} DatePickerConfig
|
|
1528
1778
|
* @property {string} [excludedDates] - Dates that cannot be selected
|
|
1529
1779
|
* @property {string} [excludedDays] - Days that cannot be selected
|
|
1530
|
-
* @property {boolean} [
|
|
1780
|
+
* @property {boolean} [leadingZeros] - Whether to add leading zeroes when populating the field
|
|
1531
1781
|
* @property {string} [minDate] - The earliest available date
|
|
1532
1782
|
* @property {string} [maxDate] - The latest available date
|
|
1533
1783
|
* @property {string} [weekStartDay] - First day of the week in calendar view
|
|
1784
|
+
* @property {object} [input] - Input config
|
|
1785
|
+
* @property {string} [input.selector] - Selector for the input element
|
|
1786
|
+
* @property {Element | null} [input.element] - HTML element for the input
|
|
1534
1787
|
*/
|
|
1535
1788
|
|
|
1536
1789
|
/**
|
|
1537
|
-
* @import { Schema } from '
|
|
1790
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
1538
1791
|
*/
|
|
1539
1792
|
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1793
|
+
/**
|
|
1794
|
+
* @augments {ConfigurableComponent<FilterToggleButtonConfig>}
|
|
1795
|
+
*/
|
|
1796
|
+
class FilterToggleButton extends ConfigurableComponent {
|
|
1797
|
+
/**
|
|
1798
|
+
* @param {Element | null} $root - HTML element to use for filter toggle button
|
|
1799
|
+
* @param {FilterToggleButtonConfig} [config] - Filter toggle button config
|
|
1800
|
+
*/
|
|
1801
|
+
constructor($root, config = {}) {
|
|
1802
|
+
var _this$config$toggleBu, _this$config$closeBut;
|
|
1803
|
+
super($root, config);
|
|
1804
|
+
const $toggleButtonContainer = (_this$config$toggleBu = this.config.toggleButtonContainer.element) != null ? _this$config$toggleBu : document.querySelector(this.config.toggleButtonContainer.selector);
|
|
1805
|
+
const $closeButtonContainer = (_this$config$closeBut = this.config.closeButtonContainer.element) != null ? _this$config$closeBut : this.$root.querySelector(this.config.closeButtonContainer.selector);
|
|
1806
|
+
if (!($toggleButtonContainer instanceof HTMLElement && $closeButtonContainer instanceof HTMLElement)) {
|
|
1807
|
+
return this;
|
|
1808
|
+
}
|
|
1809
|
+
this.$toggleButtonContainer = $toggleButtonContainer;
|
|
1810
|
+
this.$closeButtonContainer = $closeButtonContainer;
|
|
1545
1811
|
this.createToggleButton();
|
|
1546
1812
|
this.setupResponsiveChecks();
|
|
1547
|
-
this.
|
|
1548
|
-
if (this.
|
|
1813
|
+
this.$root.setAttribute('tabindex', '-1');
|
|
1814
|
+
if (this.config.startHidden) {
|
|
1549
1815
|
this.hideMenu();
|
|
1550
1816
|
}
|
|
1551
1817
|
}
|
|
1552
1818
|
setupResponsiveChecks() {
|
|
1553
|
-
this.mq = window.matchMedia(this.
|
|
1819
|
+
this.mq = window.matchMedia(this.config.bigModeMediaQuery);
|
|
1554
1820
|
this.mq.addListener(this.checkMode.bind(this));
|
|
1555
|
-
this.checkMode(
|
|
1821
|
+
this.checkMode();
|
|
1556
1822
|
}
|
|
1557
1823
|
createToggleButton() {
|
|
1558
|
-
this
|
|
1559
|
-
this
|
|
1560
|
-
this
|
|
1561
|
-
this
|
|
1562
|
-
this
|
|
1563
|
-
this
|
|
1564
|
-
this
|
|
1565
|
-
this.
|
|
1566
|
-
}
|
|
1567
|
-
checkMode(
|
|
1568
|
-
if (mq.matches) {
|
|
1824
|
+
this.$menuButton = document.createElement('button');
|
|
1825
|
+
this.$menuButton.setAttribute('type', 'button');
|
|
1826
|
+
this.$menuButton.setAttribute('aria-haspopup', 'true');
|
|
1827
|
+
this.$menuButton.setAttribute('aria-expanded', 'false');
|
|
1828
|
+
this.$menuButton.className = `govuk-button ${this.config.toggleButton.classes}`;
|
|
1829
|
+
this.$menuButton.textContent = this.config.toggleButton.showText;
|
|
1830
|
+
this.$menuButton.addEventListener('click', this.onMenuButtonClick.bind(this));
|
|
1831
|
+
this.$toggleButtonContainer.append(this.$menuButton);
|
|
1832
|
+
}
|
|
1833
|
+
checkMode() {
|
|
1834
|
+
if (this.mq.matches) {
|
|
1569
1835
|
this.enableBigMode();
|
|
1570
1836
|
} else {
|
|
1571
1837
|
this.enableSmallMode();
|
|
@@ -1580,69 +1846,147 @@ class FilterToggleButton {
|
|
|
1580
1846
|
this.addCloseButton();
|
|
1581
1847
|
}
|
|
1582
1848
|
addCloseButton() {
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
this.
|
|
1587
|
-
this
|
|
1588
|
-
this.closeButton
|
|
1589
|
-
this.closeButton.textContent = this.options.closeButton.text;
|
|
1590
|
-
this.closeButton.addEventListener('click', this.onCloseClick.bind(this));
|
|
1591
|
-
this.options.closeButton.container.append(this.closeButton);
|
|
1849
|
+
this.$closeButton = document.createElement('button');
|
|
1850
|
+
this.$closeButton.setAttribute('type', 'button');
|
|
1851
|
+
this.$closeButton.className = this.config.closeButton.classes;
|
|
1852
|
+
this.$closeButton.textContent = this.config.closeButton.text;
|
|
1853
|
+
this.$closeButton.addEventListener('click', this.onCloseClick.bind(this));
|
|
1854
|
+
this.$closeButtonContainer.append(this.$closeButton);
|
|
1592
1855
|
}
|
|
1593
1856
|
onCloseClick() {
|
|
1594
1857
|
this.hideMenu();
|
|
1595
|
-
this
|
|
1858
|
+
this.$menuButton.focus();
|
|
1596
1859
|
}
|
|
1597
1860
|
removeCloseButton() {
|
|
1598
|
-
if (this
|
|
1599
|
-
this
|
|
1600
|
-
this
|
|
1861
|
+
if (this.$closeButton) {
|
|
1862
|
+
this.$closeButton.remove();
|
|
1863
|
+
this.$closeButton = null;
|
|
1601
1864
|
}
|
|
1602
1865
|
}
|
|
1603
1866
|
hideMenu() {
|
|
1604
|
-
this
|
|
1605
|
-
this.
|
|
1606
|
-
this
|
|
1867
|
+
this.$menuButton.setAttribute('aria-expanded', 'false');
|
|
1868
|
+
this.$root.classList.add('moj-js-hidden');
|
|
1869
|
+
this.$menuButton.textContent = this.config.toggleButton.showText;
|
|
1607
1870
|
}
|
|
1608
1871
|
showMenu() {
|
|
1609
|
-
this
|
|
1610
|
-
this.
|
|
1611
|
-
this
|
|
1872
|
+
this.$menuButton.setAttribute('aria-expanded', 'true');
|
|
1873
|
+
this.$root.classList.remove('moj-js-hidden');
|
|
1874
|
+
this.$menuButton.textContent = this.config.toggleButton.hideText;
|
|
1612
1875
|
}
|
|
1613
1876
|
onMenuButtonClick() {
|
|
1614
1877
|
this.toggle();
|
|
1615
1878
|
}
|
|
1616
1879
|
toggle() {
|
|
1617
|
-
if (this
|
|
1880
|
+
if (this.$menuButton.getAttribute('aria-expanded') === 'false') {
|
|
1618
1881
|
this.showMenu();
|
|
1619
|
-
this.
|
|
1882
|
+
this.$root.focus();
|
|
1620
1883
|
} else {
|
|
1621
1884
|
this.hideMenu();
|
|
1622
1885
|
}
|
|
1623
1886
|
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Name for the component used when initialising using data-module attributes.
|
|
1890
|
+
*/
|
|
1624
1891
|
}
|
|
1625
1892
|
|
|
1626
|
-
|
|
1893
|
+
/**
|
|
1894
|
+
* @typedef {object} FilterToggleButtonConfig
|
|
1895
|
+
* @property {string} [bigModeMediaQuery] - Media query for big mode
|
|
1896
|
+
* @property {boolean} [startHidden] - Whether to start hidden
|
|
1897
|
+
* @property {object} [toggleButton] - Toggle button config
|
|
1898
|
+
* @property {string} [toggleButton.showText] - Text for show button
|
|
1899
|
+
* @property {string} [toggleButton.hideText] - Text for hide button
|
|
1900
|
+
* @property {string} [toggleButton.classes] - Classes for toggle button
|
|
1901
|
+
* @property {object} [toggleButtonContainer] - Toggle button container config
|
|
1902
|
+
* @property {string} [toggleButtonContainer.selector] - Selector for toggle button container
|
|
1903
|
+
* @property {Element | null} [toggleButtonContainer.element] - HTML element for toggle button container
|
|
1904
|
+
* @property {object} [closeButton] - Close button config
|
|
1905
|
+
* @property {string} [closeButton.text] - Text for close button
|
|
1906
|
+
* @property {string} [closeButton.classes] - Classes for close button
|
|
1907
|
+
* @property {object} [closeButtonContainer] - Close button container config
|
|
1908
|
+
* @property {string} [closeButtonContainer.selector] - Selector for close button container
|
|
1909
|
+
* @property {Element | null} [closeButtonContainer.element] - HTML element for close button container
|
|
1910
|
+
*/
|
|
1911
|
+
|
|
1912
|
+
/**
|
|
1913
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
1914
|
+
*/
|
|
1915
|
+
FilterToggleButton.moduleName = 'moj-filter';
|
|
1916
|
+
/**
|
|
1917
|
+
* Filter toggle button config
|
|
1918
|
+
*
|
|
1919
|
+
* @type {FilterToggleButtonConfig}
|
|
1920
|
+
*/
|
|
1921
|
+
FilterToggleButton.defaults = Object.freeze({
|
|
1922
|
+
bigModeMediaQuery: '(min-width: 48.0625em)',
|
|
1923
|
+
startHidden: true,
|
|
1924
|
+
toggleButton: {
|
|
1925
|
+
showText: 'Show filter',
|
|
1926
|
+
hideText: 'Hide filter',
|
|
1927
|
+
classes: 'govuk-button--secondary'
|
|
1928
|
+
},
|
|
1929
|
+
toggleButtonContainer: {
|
|
1930
|
+
selector: '.moj-action-bar__filter'
|
|
1931
|
+
},
|
|
1932
|
+
closeButton: {
|
|
1933
|
+
text: 'Close',
|
|
1934
|
+
classes: 'moj-filter__close'
|
|
1935
|
+
},
|
|
1936
|
+
closeButtonContainer: {
|
|
1937
|
+
selector: '.moj-filter__header-action'
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
/**
|
|
1941
|
+
* Filter toggle button config schema
|
|
1942
|
+
*
|
|
1943
|
+
* @satisfies {Schema<FilterToggleButtonConfig>}
|
|
1944
|
+
*/
|
|
1945
|
+
FilterToggleButton.schema = Object.freeze(/** @type {const} */{
|
|
1946
|
+
properties: {
|
|
1947
|
+
bigModeMediaQuery: {
|
|
1948
|
+
type: 'string'
|
|
1949
|
+
},
|
|
1950
|
+
startHidden: {
|
|
1951
|
+
type: 'boolean'
|
|
1952
|
+
},
|
|
1953
|
+
toggleButton: {
|
|
1954
|
+
type: 'object'
|
|
1955
|
+
},
|
|
1956
|
+
toggleButtonContainer: {
|
|
1957
|
+
type: 'object'
|
|
1958
|
+
},
|
|
1959
|
+
closeButton: {
|
|
1960
|
+
type: 'object'
|
|
1961
|
+
},
|
|
1962
|
+
closeButtonContainer: {
|
|
1963
|
+
type: 'object'
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* @augments {ConfigurableComponent<FormValidatorConfig, HTMLFormElement>}
|
|
1970
|
+
*/
|
|
1971
|
+
class FormValidator extends ConfigurableComponent {
|
|
1627
1972
|
/**
|
|
1628
|
-
* @param {Element | null}
|
|
1629
|
-
* @param {FormValidatorConfig} [config] -
|
|
1973
|
+
* @param {Element | null} $root - HTML element to use for form validator
|
|
1974
|
+
* @param {FormValidatorConfig} [config] - Form validator config
|
|
1630
1975
|
*/
|
|
1631
|
-
constructor(
|
|
1632
|
-
|
|
1976
|
+
constructor($root, config = {}) {
|
|
1977
|
+
super($root, config);
|
|
1978
|
+
const $summary = this.config.summary.element || document.querySelector(this.config.summary.selector);
|
|
1979
|
+
if (!$summary || !($summary instanceof HTMLElement)) {
|
|
1633
1980
|
return this;
|
|
1634
1981
|
}
|
|
1635
|
-
this
|
|
1636
|
-
this.errors = [];
|
|
1637
|
-
this.validators = [];
|
|
1638
|
-
this.form.addEventListener('submit', this.onSubmit.bind(this));
|
|
1639
|
-
this.summary = config.summary || document.querySelector('.govuk-error-summary');
|
|
1982
|
+
this.$summary = $summary;
|
|
1983
|
+
this.errors = /** @type {ValidationError[]} */[];
|
|
1984
|
+
this.validators = /** @type {Validator[]} */[];
|
|
1640
1985
|
this.originalTitle = document.title;
|
|
1986
|
+
this.$root.addEventListener('submit', this.onSubmit.bind(this));
|
|
1641
1987
|
}
|
|
1642
|
-
escapeHtml(string) {
|
|
1643
|
-
return String(string).replace(/[&<>"'`=/]/g,
|
|
1644
|
-
return FormValidator.entityMap[s];
|
|
1645
|
-
});
|
|
1988
|
+
escapeHtml(string = '') {
|
|
1989
|
+
return String(string).replace(/[&<>"'`=/]/g, name => FormValidator.entityMap[name]);
|
|
1646
1990
|
}
|
|
1647
1991
|
resetTitle() {
|
|
1648
1992
|
document.title = this.originalTitle;
|
|
@@ -1651,10 +1995,10 @@ class FormValidator {
|
|
|
1651
1995
|
document.title = `${this.errors.length} errors - ${document.title}`;
|
|
1652
1996
|
}
|
|
1653
1997
|
showSummary() {
|
|
1654
|
-
this
|
|
1655
|
-
this
|
|
1656
|
-
this
|
|
1657
|
-
this
|
|
1998
|
+
this.$summary.innerHTML = this.getSummaryHtml();
|
|
1999
|
+
this.$summary.classList.remove('moj-hidden');
|
|
2000
|
+
this.$summary.setAttribute('aria-labelledby', 'errorSummary-heading');
|
|
2001
|
+
this.$summary.focus();
|
|
1658
2002
|
}
|
|
1659
2003
|
getSummaryHtml() {
|
|
1660
2004
|
let html = '<h2 id="error-summary-title" class="govuk-error-summary__title">There is a problem</h2>';
|
|
@@ -1672,9 +2016,13 @@ class FormValidator {
|
|
|
1672
2016
|
return html;
|
|
1673
2017
|
}
|
|
1674
2018
|
hideSummary() {
|
|
1675
|
-
this
|
|
1676
|
-
this
|
|
2019
|
+
this.$summary.classList.add('moj-hidden');
|
|
2020
|
+
this.$summary.removeAttribute('aria-labelledby');
|
|
1677
2021
|
}
|
|
2022
|
+
|
|
2023
|
+
/**
|
|
2024
|
+
* @param {SubmitEvent} event - Form submit event
|
|
2025
|
+
*/
|
|
1678
2026
|
onSubmit(event) {
|
|
1679
2027
|
this.removeInlineErrors();
|
|
1680
2028
|
this.hideSummary();
|
|
@@ -1691,25 +2039,29 @@ class FormValidator {
|
|
|
1691
2039
|
this.showInlineError(error);
|
|
1692
2040
|
}
|
|
1693
2041
|
}
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* @param {ValidationError} error
|
|
2045
|
+
*/
|
|
1694
2046
|
showInlineError(error) {
|
|
1695
|
-
const errorSpan = document.createElement('span');
|
|
1696
|
-
errorSpan.id = `${error.fieldName}-error`;
|
|
1697
|
-
errorSpan.classList.add('govuk-error-message');
|
|
1698
|
-
errorSpan.innerHTML = this.escapeHtml(error.message);
|
|
1699
|
-
const control = document.querySelector(`#${error.fieldName}`);
|
|
1700
|
-
const fieldset = control.closest('.govuk-fieldset');
|
|
1701
|
-
const fieldContainer = (fieldset || control).closest('.govuk-form-group');
|
|
1702
|
-
const label = fieldContainer.querySelector('label');
|
|
1703
|
-
const legend = fieldContainer.querySelector('legend');
|
|
1704
|
-
fieldContainer.classList.add('govuk-form-group--error');
|
|
1705
|
-
if (fieldset && legend) {
|
|
1706
|
-
legend.after(errorSpan);
|
|
1707
|
-
fieldContainer.setAttribute('aria-invalid', 'true');
|
|
1708
|
-
addAttributeValue(fieldset, 'aria-describedby', errorSpan.id);
|
|
1709
|
-
} else if (label && control) {
|
|
1710
|
-
label.after(errorSpan);
|
|
1711
|
-
control.setAttribute('aria-invalid', 'true');
|
|
1712
|
-
addAttributeValue(control, 'aria-describedby', errorSpan.id);
|
|
2047
|
+
const $errorSpan = document.createElement('span');
|
|
2048
|
+
$errorSpan.id = `${error.fieldName}-error`;
|
|
2049
|
+
$errorSpan.classList.add('govuk-error-message');
|
|
2050
|
+
$errorSpan.innerHTML = this.escapeHtml(error.message);
|
|
2051
|
+
const $control = document.querySelector(`#${error.fieldName}`);
|
|
2052
|
+
const $fieldset = $control.closest('.govuk-fieldset');
|
|
2053
|
+
const $fieldContainer = ($fieldset || $control).closest('.govuk-form-group');
|
|
2054
|
+
const $label = $fieldContainer.querySelector('label');
|
|
2055
|
+
const $legend = $fieldContainer.querySelector('legend');
|
|
2056
|
+
$fieldContainer.classList.add('govuk-form-group--error');
|
|
2057
|
+
if ($fieldset && $legend) {
|
|
2058
|
+
$legend.after($errorSpan);
|
|
2059
|
+
$fieldContainer.setAttribute('aria-invalid', 'true');
|
|
2060
|
+
addAttributeValue($fieldset, 'aria-describedby', $errorSpan.id);
|
|
2061
|
+
} else if ($label && $control) {
|
|
2062
|
+
$label.after($errorSpan);
|
|
2063
|
+
$control.setAttribute('aria-invalid', 'true');
|
|
2064
|
+
addAttributeValue($control, 'aria-describedby', $errorSpan.id);
|
|
1713
2065
|
}
|
|
1714
2066
|
}
|
|
1715
2067
|
removeInlineErrors() {
|
|
@@ -1717,33 +2069,46 @@ class FormValidator {
|
|
|
1717
2069
|
this.removeInlineError(error);
|
|
1718
2070
|
}
|
|
1719
2071
|
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* @param {ValidationError} error
|
|
2075
|
+
*/
|
|
1720
2076
|
removeInlineError(error) {
|
|
1721
|
-
const errorSpan = document.querySelector(`#${error.fieldName}-error`);
|
|
1722
|
-
const control = document.querySelector(`#${error.fieldName}`);
|
|
1723
|
-
const fieldset = control.closest('.govuk-fieldset');
|
|
1724
|
-
const fieldContainer = (fieldset || control).closest('.govuk-form-group');
|
|
1725
|
-
const label = fieldContainer.querySelector('label');
|
|
1726
|
-
const legend = fieldContainer.querySelector('legend');
|
|
1727
|
-
errorSpan.remove();
|
|
1728
|
-
fieldContainer.classList.remove('govuk-form-group--error');
|
|
1729
|
-
if (fieldset && legend) {
|
|
1730
|
-
fieldContainer.removeAttribute('aria-invalid');
|
|
1731
|
-
removeAttributeValue(fieldset, 'aria-describedby', errorSpan.id);
|
|
1732
|
-
} else if (label && control) {
|
|
1733
|
-
control.removeAttribute('aria-invalid');
|
|
1734
|
-
removeAttributeValue(control, 'aria-describedby', errorSpan.id);
|
|
2077
|
+
const $errorSpan = document.querySelector(`#${error.fieldName}-error`);
|
|
2078
|
+
const $control = document.querySelector(`#${error.fieldName}`);
|
|
2079
|
+
const $fieldset = $control.closest('.govuk-fieldset');
|
|
2080
|
+
const $fieldContainer = ($fieldset || $control).closest('.govuk-form-group');
|
|
2081
|
+
const $label = $fieldContainer.querySelector('label');
|
|
2082
|
+
const $legend = $fieldContainer.querySelector('legend');
|
|
2083
|
+
$errorSpan.remove();
|
|
2084
|
+
$fieldContainer.classList.remove('govuk-form-group--error');
|
|
2085
|
+
if ($fieldset && $legend) {
|
|
2086
|
+
$fieldContainer.removeAttribute('aria-invalid');
|
|
2087
|
+
removeAttributeValue($fieldset, 'aria-describedby', $errorSpan.id);
|
|
2088
|
+
} else if ($label && $control) {
|
|
2089
|
+
$control.removeAttribute('aria-invalid');
|
|
2090
|
+
removeAttributeValue($control, 'aria-describedby', $errorSpan.id);
|
|
1735
2091
|
}
|
|
1736
2092
|
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* @param {string} fieldName - Field name
|
|
2096
|
+
* @param {ValidationRule[]} rules - Validation rules
|
|
2097
|
+
*/
|
|
1737
2098
|
addValidator(fieldName, rules) {
|
|
1738
2099
|
this.validators.push({
|
|
1739
2100
|
fieldName,
|
|
1740
2101
|
rules,
|
|
1741
|
-
field: this.
|
|
2102
|
+
field: this.$root.elements.namedItem(fieldName)
|
|
1742
2103
|
});
|
|
1743
2104
|
}
|
|
1744
2105
|
validate() {
|
|
1745
2106
|
this.errors = [];
|
|
2107
|
+
|
|
2108
|
+
/** @type {Validator | null} */
|
|
1746
2109
|
let validator = null;
|
|
2110
|
+
|
|
2111
|
+
/** @type {boolean | string} */
|
|
1747
2112
|
let validatorReturnValue = true;
|
|
1748
2113
|
let i;
|
|
1749
2114
|
let j;
|
|
@@ -1768,11 +2133,41 @@ class FormValidator {
|
|
|
1768
2133
|
}
|
|
1769
2134
|
return this.errors.length === 0;
|
|
1770
2135
|
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* @type {Record<string, string>}
|
|
2139
|
+
*/
|
|
1771
2140
|
}
|
|
1772
2141
|
|
|
1773
2142
|
/**
|
|
1774
2143
|
* @typedef {object} FormValidatorConfig
|
|
1775
|
-
* @property {
|
|
2144
|
+
* @property {object} [summary] - Error summary config
|
|
2145
|
+
* @property {string} [summary.selector] - Selector for error summary
|
|
2146
|
+
* @property {Element | null} [summary.element] - HTML element for error summary
|
|
2147
|
+
*/
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* @typedef {object} ValidationRule
|
|
2151
|
+
* @property {(field: Validator['field'], params: Record<string, Validator['field']>) => boolean | string} method - Validation method
|
|
2152
|
+
* @property {string} message - Error message
|
|
2153
|
+
* @property {Record<string, Validator['field']>} [params] - Parameters for validation
|
|
2154
|
+
*/
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* @typedef {object} ValidationError
|
|
2158
|
+
* @property {string} fieldName - Name of the field
|
|
2159
|
+
* @property {string} message - Validation error message
|
|
2160
|
+
*/
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* @typedef {object} Validator
|
|
2164
|
+
* @property {string} fieldName - Name of the field
|
|
2165
|
+
* @property {ValidationRule[]} rules - Validation rules
|
|
2166
|
+
* @property {Element | RadioNodeList} field - Form field
|
|
2167
|
+
*/
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
1776
2171
|
*/
|
|
1777
2172
|
FormValidator.entityMap = {
|
|
1778
2173
|
'&': '&',
|
|
@@ -1784,164 +2179,218 @@ FormValidator.entityMap = {
|
|
|
1784
2179
|
'`': '`',
|
|
1785
2180
|
'=': '='
|
|
1786
2181
|
};
|
|
2182
|
+
/**
|
|
2183
|
+
* Name for the component used when initialising using data-module attributes.
|
|
2184
|
+
*/
|
|
2185
|
+
FormValidator.moduleName = 'moj-form-validator';
|
|
2186
|
+
/**
|
|
2187
|
+
* Multi file upload default config
|
|
2188
|
+
*
|
|
2189
|
+
* @type {FormValidatorConfig}
|
|
2190
|
+
*/
|
|
2191
|
+
FormValidator.defaults = Object.freeze({
|
|
2192
|
+
summary: {
|
|
2193
|
+
selector: '.govuk-error-summary'
|
|
2194
|
+
}
|
|
2195
|
+
});
|
|
2196
|
+
/**
|
|
2197
|
+
* Multi file upload config schema
|
|
2198
|
+
*
|
|
2199
|
+
* @satisfies {Schema<FormValidatorConfig>}
|
|
2200
|
+
*/
|
|
2201
|
+
FormValidator.schema = Object.freeze(/** @type {const} */{
|
|
2202
|
+
properties: {
|
|
2203
|
+
summary: {
|
|
2204
|
+
type: 'object'
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
});
|
|
1787
2208
|
|
|
1788
2209
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
1789
2210
|
|
|
1790
|
-
|
|
2211
|
+
|
|
2212
|
+
/**
|
|
2213
|
+
* @augments {ConfigurableComponent<MultiFileUploadConfig>}
|
|
2214
|
+
*/
|
|
2215
|
+
class MultiFileUpload extends ConfigurableComponent {
|
|
1791
2216
|
/**
|
|
1792
|
-
* @param {
|
|
2217
|
+
* @param {Element | null} $root - HTML element to use for multi file upload
|
|
2218
|
+
* @param {MultiFileUploadConfig} [config] - Multi file upload config
|
|
1793
2219
|
*/
|
|
1794
|
-
constructor(
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
if (!container || !(container instanceof HTMLElement) || !(dragAndDropSupported() && formDataSupported() && fileApiSupported())) {
|
|
2220
|
+
constructor($root, config = {}) {
|
|
2221
|
+
var _this$config$feedback;
|
|
2222
|
+
super($root, config);
|
|
2223
|
+
if (!MultiFileUpload.isSupported()) {
|
|
1799
2224
|
return this;
|
|
1800
2225
|
}
|
|
1801
|
-
this.
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
uploadFileErrorHook: () => {},
|
|
1807
|
-
fileDeleteHook: () => {},
|
|
1808
|
-
uploadStatusText: 'Uploading files, please wait',
|
|
1809
|
-
dropzoneHintText: 'Drag and drop files here or',
|
|
1810
|
-
dropzoneButtonText: 'Choose files'
|
|
1811
|
-
};
|
|
1812
|
-
this.params = Object.assign({}, this.defaultParams, params);
|
|
1813
|
-
this.feedbackContainer = /** @type {HTMLDivElement} */
|
|
1814
|
-
this.container.querySelector('.moj-multi-file__uploaded-files');
|
|
2226
|
+
const $feedbackContainer = (_this$config$feedback = this.config.feedbackContainer.element) != null ? _this$config$feedback : this.$root.querySelector(this.config.feedbackContainer.selector);
|
|
2227
|
+
if (!$feedbackContainer || !($feedbackContainer instanceof HTMLElement)) {
|
|
2228
|
+
return this;
|
|
2229
|
+
}
|
|
2230
|
+
this.$feedbackContainer = $feedbackContainer;
|
|
1815
2231
|
this.setupFileInput();
|
|
1816
2232
|
this.setupDropzone();
|
|
1817
2233
|
this.setupLabel();
|
|
1818
2234
|
this.setupStatusBox();
|
|
1819
|
-
this.
|
|
2235
|
+
this.$root.addEventListener('click', this.onFileDeleteClick.bind(this));
|
|
2236
|
+
this.$root.classList.add('moj-multi-file-upload--enhanced');
|
|
1820
2237
|
}
|
|
1821
2238
|
setupDropzone() {
|
|
1822
|
-
this
|
|
1823
|
-
this
|
|
1824
|
-
this
|
|
1825
|
-
this
|
|
1826
|
-
this
|
|
1827
|
-
this
|
|
1828
|
-
this
|
|
2239
|
+
this.$dropzone = document.createElement('div');
|
|
2240
|
+
this.$dropzone.classList.add('moj-multi-file-upload__dropzone');
|
|
2241
|
+
this.$dropzone.addEventListener('dragover', this.onDragOver.bind(this));
|
|
2242
|
+
this.$dropzone.addEventListener('dragleave', this.onDragLeave.bind(this));
|
|
2243
|
+
this.$dropzone.addEventListener('drop', this.onDrop.bind(this));
|
|
2244
|
+
this.$fileInput.replaceWith(this.$dropzone);
|
|
2245
|
+
this.$dropzone.appendChild(this.$fileInput);
|
|
1829
2246
|
}
|
|
1830
2247
|
setupLabel() {
|
|
1831
|
-
const label = document.createElement('label');
|
|
1832
|
-
label.setAttribute('for', this
|
|
1833
|
-
label.classList.add('govuk-button', 'govuk-button--secondary');
|
|
1834
|
-
label.textContent = this.
|
|
1835
|
-
const hint = document.createElement('p');
|
|
1836
|
-
hint.classList.add('govuk-body');
|
|
1837
|
-
hint.textContent = this.
|
|
1838
|
-
this
|
|
1839
|
-
this
|
|
1840
|
-
this
|
|
2248
|
+
const $label = document.createElement('label');
|
|
2249
|
+
$label.setAttribute('for', this.$fileInput.id);
|
|
2250
|
+
$label.classList.add('govuk-button', 'govuk-button--secondary');
|
|
2251
|
+
$label.textContent = this.config.dropzoneButtonText;
|
|
2252
|
+
const $hint = document.createElement('p');
|
|
2253
|
+
$hint.classList.add('govuk-body');
|
|
2254
|
+
$hint.textContent = this.config.dropzoneHintText;
|
|
2255
|
+
this.$label = $label;
|
|
2256
|
+
this.$dropzone.append($hint);
|
|
2257
|
+
this.$dropzone.append($label);
|
|
1841
2258
|
}
|
|
1842
2259
|
setupFileInput() {
|
|
1843
|
-
this
|
|
1844
|
-
this.
|
|
1845
|
-
this
|
|
1846
|
-
this
|
|
1847
|
-
this
|
|
2260
|
+
this.$fileInput = /** @type {HTMLInputElement} */
|
|
2261
|
+
this.$root.querySelector('.moj-multi-file-upload__input');
|
|
2262
|
+
this.$fileInput.addEventListener('change', this.onFileChange.bind(this));
|
|
2263
|
+
this.$fileInput.addEventListener('focus', this.onFileFocus.bind(this));
|
|
2264
|
+
this.$fileInput.addEventListener('blur', this.onFileBlur.bind(this));
|
|
1848
2265
|
}
|
|
1849
2266
|
setupStatusBox() {
|
|
1850
|
-
this
|
|
1851
|
-
this
|
|
1852
|
-
this
|
|
1853
|
-
this
|
|
1854
|
-
this
|
|
2267
|
+
this.$status = document.createElement('div');
|
|
2268
|
+
this.$status.classList.add('govuk-visually-hidden');
|
|
2269
|
+
this.$status.setAttribute('aria-live', 'polite');
|
|
2270
|
+
this.$status.setAttribute('role', 'status');
|
|
2271
|
+
this.$dropzone.append(this.$status);
|
|
1855
2272
|
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* @param {DragEvent} event - Drag event
|
|
2276
|
+
*/
|
|
1856
2277
|
onDragOver(event) {
|
|
1857
2278
|
event.preventDefault();
|
|
1858
|
-
this
|
|
2279
|
+
this.$dropzone.classList.add('moj-multi-file-upload--dragover');
|
|
1859
2280
|
}
|
|
1860
2281
|
onDragLeave() {
|
|
1861
|
-
this
|
|
2282
|
+
this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
|
|
1862
2283
|
}
|
|
2284
|
+
|
|
2285
|
+
/**
|
|
2286
|
+
* @param {DragEvent} event - Drag event
|
|
2287
|
+
*/
|
|
1863
2288
|
onDrop(event) {
|
|
1864
2289
|
event.preventDefault();
|
|
1865
|
-
this
|
|
1866
|
-
this
|
|
1867
|
-
this
|
|
2290
|
+
this.$dropzone.classList.remove('moj-multi-file-upload--dragover');
|
|
2291
|
+
this.$feedbackContainer.classList.remove('moj-hidden');
|
|
2292
|
+
this.$status.textContent = this.config.uploadStatusText;
|
|
1868
2293
|
this.uploadFiles(event.dataTransfer.files);
|
|
1869
2294
|
}
|
|
2295
|
+
|
|
2296
|
+
/**
|
|
2297
|
+
* @param {FileList} files - File list
|
|
2298
|
+
*/
|
|
1870
2299
|
uploadFiles(files) {
|
|
1871
2300
|
for (const file of Array.from(files)) {
|
|
1872
2301
|
this.uploadFile(file);
|
|
1873
2302
|
}
|
|
1874
2303
|
}
|
|
1875
2304
|
onFileChange() {
|
|
1876
|
-
this
|
|
1877
|
-
this
|
|
1878
|
-
this.uploadFiles(this
|
|
1879
|
-
const fileInput = this
|
|
1880
|
-
if (
|
|
2305
|
+
this.$feedbackContainer.classList.remove('moj-hidden');
|
|
2306
|
+
this.$status.textContent = this.config.uploadStatusText;
|
|
2307
|
+
this.uploadFiles(this.$fileInput.files);
|
|
2308
|
+
const $fileInput = this.$fileInput.cloneNode(true);
|
|
2309
|
+
if (!$fileInput || !($fileInput instanceof HTMLInputElement)) {
|
|
1881
2310
|
return;
|
|
1882
2311
|
}
|
|
1883
|
-
fileInput.value = '';
|
|
1884
|
-
this
|
|
2312
|
+
$fileInput.value = '';
|
|
2313
|
+
this.$fileInput.replaceWith($fileInput);
|
|
1885
2314
|
this.setupFileInput();
|
|
1886
|
-
this
|
|
2315
|
+
this.$fileInput.focus();
|
|
1887
2316
|
}
|
|
1888
2317
|
onFileFocus() {
|
|
1889
|
-
this
|
|
2318
|
+
this.$label.classList.add('moj-multi-file-upload--focused');
|
|
1890
2319
|
}
|
|
1891
2320
|
onFileBlur() {
|
|
1892
|
-
this
|
|
2321
|
+
this.$label.classList.remove('moj-multi-file-upload--focused');
|
|
1893
2322
|
}
|
|
2323
|
+
|
|
2324
|
+
/**
|
|
2325
|
+
* @param {UploadResponseSuccess['success']} success
|
|
2326
|
+
*/
|
|
1894
2327
|
getSuccessHtml(success) {
|
|
1895
2328
|
return `<span class="moj-multi-file-upload__success"> <svg class="moj-banner__icon" fill="currentColor" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25" height="25" width="25"><path d="M25,6.2L8.7,23.2L0,14.1l4-4.2l4.7,4.9L21,2L25,6.2z"/></svg>${success.messageHtml}</span>`;
|
|
1896
2329
|
}
|
|
2330
|
+
|
|
2331
|
+
/**
|
|
2332
|
+
* @param {UploadResponseError['error']} error
|
|
2333
|
+
*/
|
|
1897
2334
|
getErrorHtml(error) {
|
|
1898
2335
|
return `<span class="moj-multi-file-upload__error"> <svg class="moj-banner__icon" fill="currentColor" role="presentation" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25" height="25" width="25"><path d="M13.6,15.4h-2.3v-4.5h2.3V15.4z M13.6,19.8h-2.3v-2.2h2.3V19.8z M0,23.2h25L12.5,2L0,23.2z"/></svg>${error.message}</span>`;
|
|
1899
2336
|
}
|
|
2337
|
+
|
|
2338
|
+
/**
|
|
2339
|
+
* @param {File} file
|
|
2340
|
+
*/
|
|
1900
2341
|
getFileRow(file) {
|
|
1901
|
-
const row = document.createElement('div');
|
|
1902
|
-
row.classList.add('govuk-summary-list__row', 'moj-multi-file-upload__row');
|
|
1903
|
-
row.innerHTML = `
|
|
2342
|
+
const $row = document.createElement('div');
|
|
2343
|
+
$row.classList.add('govuk-summary-list__row', 'moj-multi-file-upload__row');
|
|
2344
|
+
$row.innerHTML = `
|
|
1904
2345
|
<div class="govuk-summary-list__value moj-multi-file-upload__message">
|
|
1905
2346
|
<span class="moj-multi-file-upload__filename">${file.name}</span>
|
|
1906
2347
|
<span class="moj-multi-file-upload__progress">0%</span>
|
|
1907
2348
|
</div>
|
|
1908
2349
|
<div class="govuk-summary-list__actions moj-multi-file-upload__actions"></div>
|
|
1909
2350
|
`;
|
|
1910
|
-
return row;
|
|
2351
|
+
return $row;
|
|
1911
2352
|
}
|
|
2353
|
+
|
|
2354
|
+
/**
|
|
2355
|
+
* @param {UploadResponseFile} file
|
|
2356
|
+
*/
|
|
1912
2357
|
getDeleteButton(file) {
|
|
1913
|
-
const button = document.createElement('button');
|
|
1914
|
-
button.setAttribute('type', 'button');
|
|
1915
|
-
button.setAttribute('name', 'delete');
|
|
1916
|
-
button.setAttribute('value', file.filename);
|
|
1917
|
-
button.classList.add('moj-multi-file-upload__delete', 'govuk-button', 'govuk-button--secondary', 'govuk-!-margin-bottom-0');
|
|
1918
|
-
button.innerHTML = `Delete <span class="govuk-visually-hidden">${file.originalname}</span>`;
|
|
1919
|
-
return button;
|
|
2358
|
+
const $button = document.createElement('button');
|
|
2359
|
+
$button.setAttribute('type', 'button');
|
|
2360
|
+
$button.setAttribute('name', 'delete');
|
|
2361
|
+
$button.setAttribute('value', file.filename);
|
|
2362
|
+
$button.classList.add('moj-multi-file-upload__delete', 'govuk-button', 'govuk-button--secondary', 'govuk-!-margin-bottom-0');
|
|
2363
|
+
$button.innerHTML = `Delete <span class="govuk-visually-hidden">${file.originalname}</span>`;
|
|
2364
|
+
return $button;
|
|
1920
2365
|
}
|
|
2366
|
+
|
|
2367
|
+
/**
|
|
2368
|
+
* @param {File} file
|
|
2369
|
+
*/
|
|
1921
2370
|
uploadFile(file) {
|
|
1922
|
-
this.
|
|
1923
|
-
const item = this.getFileRow(file);
|
|
1924
|
-
const message = item.querySelector('.moj-multi-file-upload__message');
|
|
1925
|
-
const actions = item.querySelector('.moj-multi-file-upload__actions');
|
|
1926
|
-
const progress = item.querySelector('.moj-multi-file-upload__progress');
|
|
2371
|
+
this.config.hooks.entryHook(this, file);
|
|
2372
|
+
const $item = this.getFileRow(file);
|
|
2373
|
+
const $message = $item.querySelector('.moj-multi-file-upload__message');
|
|
2374
|
+
const $actions = $item.querySelector('.moj-multi-file-upload__actions');
|
|
2375
|
+
const $progress = $item.querySelector('.moj-multi-file-upload__progress');
|
|
1927
2376
|
const formData = new FormData();
|
|
1928
2377
|
formData.append('documents', file);
|
|
1929
|
-
this
|
|
2378
|
+
this.$feedbackContainer.querySelector('.moj-multi-file-upload__list').append($item);
|
|
1930
2379
|
const xhr = new XMLHttpRequest();
|
|
1931
2380
|
const onLoad = () => {
|
|
1932
2381
|
if (xhr.status < 200 || xhr.status >= 300 || !('success' in xhr.response)) {
|
|
1933
2382
|
return onError();
|
|
1934
2383
|
}
|
|
1935
|
-
message.innerHTML = this.getSuccessHtml(xhr.response.success);
|
|
1936
|
-
this
|
|
1937
|
-
actions.append(this.getDeleteButton(xhr.response.file));
|
|
1938
|
-
this.
|
|
2384
|
+
$message.innerHTML = this.getSuccessHtml(xhr.response.success);
|
|
2385
|
+
this.$status.textContent = xhr.response.success.messageText;
|
|
2386
|
+
$actions.append(this.getDeleteButton(xhr.response.file));
|
|
2387
|
+
this.config.hooks.exitHook(this, file, xhr, xhr.responseText);
|
|
1939
2388
|
};
|
|
1940
2389
|
const onError = () => {
|
|
1941
2390
|
const error = new Error(xhr.response && 'error' in xhr.response ? xhr.response.error.message : xhr.statusText || 'Upload failed');
|
|
1942
|
-
message.innerHTML = this.getErrorHtml(error);
|
|
1943
|
-
this
|
|
1944
|
-
this.
|
|
2391
|
+
$message.innerHTML = this.getErrorHtml(error);
|
|
2392
|
+
this.$status.textContent = error.message;
|
|
2393
|
+
this.config.hooks.errorHook(this, file, xhr, xhr.responseText, error);
|
|
1945
2394
|
};
|
|
1946
2395
|
xhr.addEventListener('load', onLoad);
|
|
1947
2396
|
xhr.addEventListener('error', onError);
|
|
@@ -1950,15 +2399,19 @@ class MultiFileUpload {
|
|
|
1950
2399
|
return;
|
|
1951
2400
|
}
|
|
1952
2401
|
const percentComplete = Math.round(event.loaded / event.total * 100);
|
|
1953
|
-
progress.textContent = ` ${percentComplete}%`;
|
|
2402
|
+
$progress.textContent = ` ${percentComplete}%`;
|
|
1954
2403
|
});
|
|
1955
|
-
xhr.open('POST', this.
|
|
2404
|
+
xhr.open('POST', this.config.uploadUrl);
|
|
1956
2405
|
xhr.responseType = 'json';
|
|
1957
2406
|
xhr.send(formData);
|
|
1958
2407
|
}
|
|
2408
|
+
|
|
2409
|
+
/**
|
|
2410
|
+
* @param {MouseEvent} event - Click event
|
|
2411
|
+
*/
|
|
1959
2412
|
onFileDeleteClick(event) {
|
|
1960
|
-
const button = event.target;
|
|
1961
|
-
if (
|
|
2413
|
+
const $button = event.target;
|
|
2414
|
+
if (!$button || !($button instanceof HTMLButtonElement) || !$button.classList.contains('moj-multi-file-upload__delete')) {
|
|
1962
2415
|
return;
|
|
1963
2416
|
}
|
|
1964
2417
|
event.preventDefault(); // if user refreshes page and then deletes
|
|
@@ -1968,155 +2421,371 @@ class MultiFileUpload {
|
|
|
1968
2421
|
if (xhr.status < 200 || xhr.status >= 300) {
|
|
1969
2422
|
return;
|
|
1970
2423
|
}
|
|
1971
|
-
const rows = Array.from(this
|
|
1972
|
-
if (rows.length === 1) {
|
|
1973
|
-
this
|
|
2424
|
+
const $rows = Array.from(this.$feedbackContainer.querySelectorAll('.moj-multi-file-upload__row'));
|
|
2425
|
+
if ($rows.length === 1) {
|
|
2426
|
+
this.$feedbackContainer.classList.add('moj-hidden');
|
|
1974
2427
|
}
|
|
1975
|
-
const
|
|
1976
|
-
if (
|
|
1977
|
-
this.
|
|
2428
|
+
const $rowDelete = $rows.find($row => $row.contains($button));
|
|
2429
|
+
if ($rowDelete) $rowDelete.remove();
|
|
2430
|
+
this.config.hooks.deleteHook(this, undefined, xhr, xhr.responseText);
|
|
1978
2431
|
});
|
|
1979
|
-
xhr.open('POST', this.
|
|
2432
|
+
xhr.open('POST', this.config.deleteUrl);
|
|
1980
2433
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
|
1981
2434
|
xhr.responseType = 'json';
|
|
1982
2435
|
xhr.send(JSON.stringify({
|
|
1983
|
-
[button.name]: button.value
|
|
2436
|
+
[$button.name]: $button.value
|
|
1984
2437
|
}));
|
|
1985
2438
|
}
|
|
2439
|
+
static isSupported() {
|
|
2440
|
+
return this.isDragAndDropSupported() && this.isFormDataSupported() && this.isFileApiSupported();
|
|
2441
|
+
}
|
|
2442
|
+
static isDragAndDropSupported() {
|
|
2443
|
+
const div = document.createElement('div');
|
|
2444
|
+
return typeof div.ondrop !== 'undefined';
|
|
2445
|
+
}
|
|
2446
|
+
static isFormDataSupported() {
|
|
2447
|
+
return typeof FormData === 'function';
|
|
2448
|
+
}
|
|
2449
|
+
static isFileApiSupported() {
|
|
2450
|
+
const input = document.createElement('input');
|
|
2451
|
+
input.type = 'file';
|
|
2452
|
+
return typeof input.files !== 'undefined';
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* Name for the component used when initialising using data-module attributes.
|
|
2457
|
+
*/
|
|
1986
2458
|
}
|
|
1987
2459
|
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2460
|
+
/**
|
|
2461
|
+
* Multi file upload config
|
|
2462
|
+
*
|
|
2463
|
+
* @typedef {object} MultiFileUploadConfig
|
|
2464
|
+
* @property {string} [uploadUrl] - File upload URL
|
|
2465
|
+
* @property {string} [deleteUrl] - File delete URL
|
|
2466
|
+
* @property {string} [uploadStatusText] - Upload status text
|
|
2467
|
+
* @property {string} [dropzoneHintText] - Dropzone hint text
|
|
2468
|
+
* @property {string} [dropzoneButtonText] - Dropzone button text
|
|
2469
|
+
* @property {object} [feedbackContainer] - Feedback container config
|
|
2470
|
+
* @property {string} [feedbackContainer.selector] - Selector for feedback container
|
|
2471
|
+
* @property {Element | null} [feedbackContainer.element] - HTML element for feedback container
|
|
2472
|
+
* @property {MultiFileUploadHooks} [hooks] - Upload hooks
|
|
2473
|
+
*/
|
|
2474
|
+
|
|
2475
|
+
/**
|
|
2476
|
+
* Multi file upload hooks
|
|
2477
|
+
*
|
|
2478
|
+
* @typedef {object} MultiFileUploadHooks
|
|
2479
|
+
* @property {OnUploadFileEntryHook} [entryHook] - File upload entry hook
|
|
2480
|
+
* @property {OnUploadFileExitHook} [exitHook] - File upload exit hook
|
|
2481
|
+
* @property {OnUploadFileErrorHook} [errorHook] - File upload error hook
|
|
2482
|
+
* @property {OnUploadFileDeleteHook} [deleteHook] - File delete hook
|
|
2483
|
+
*/
|
|
2484
|
+
|
|
2485
|
+
/**
|
|
2486
|
+
* Upload hook: File entry
|
|
2487
|
+
*
|
|
2488
|
+
* @callback OnUploadFileEntryHook
|
|
2489
|
+
* @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
|
|
2490
|
+
* @param {File} file - File upload
|
|
2491
|
+
*/
|
|
2492
|
+
|
|
2493
|
+
/**
|
|
2494
|
+
* Upload hook: File exit
|
|
2495
|
+
*
|
|
2496
|
+
* @callback OnUploadFileExitHook
|
|
2497
|
+
* @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
|
|
2498
|
+
* @param {File} file - File upload
|
|
2499
|
+
* @param {XMLHttpRequest} xhr - XMLHttpRequest
|
|
2500
|
+
* @param {string} textStatus - Text status
|
|
2501
|
+
*/
|
|
2502
|
+
|
|
2503
|
+
/**
|
|
2504
|
+
* Upload hook: File error
|
|
2505
|
+
*
|
|
2506
|
+
* @callback OnUploadFileErrorHook
|
|
2507
|
+
* @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
|
|
2508
|
+
* @param {File} file - File upload
|
|
2509
|
+
* @param {XMLHttpRequest} xhr - XMLHttpRequest
|
|
2510
|
+
* @param {string} textStatus - Text status
|
|
2511
|
+
* @param {Error} errorThrown - Error thrown
|
|
2512
|
+
*/
|
|
2513
|
+
|
|
2514
|
+
/**
|
|
2515
|
+
* Upload hook: File delete
|
|
2516
|
+
*
|
|
2517
|
+
* @callback OnUploadFileDeleteHook
|
|
2518
|
+
* @param {InstanceType<typeof MultiFileUpload>} upload - Multi file upload
|
|
2519
|
+
* @param {File} [file] - File upload
|
|
2520
|
+
* @param {XMLHttpRequest} xhr - XMLHttpRequest
|
|
2521
|
+
* @param {string} textStatus - Text status
|
|
2522
|
+
*/
|
|
2523
|
+
|
|
2524
|
+
/**
|
|
2525
|
+
* @typedef {object} UploadResponseSuccess
|
|
2526
|
+
* @property {{ messageText: string, messageHtml: string }} success - Response success
|
|
2527
|
+
* @property {UploadResponseFile} file - Response file
|
|
2528
|
+
*/
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* @typedef {object} UploadResponseError
|
|
2532
|
+
* @property {{ message: string }} error - Response error
|
|
2533
|
+
* @property {UploadResponseFile} file - Response file
|
|
2534
|
+
*/
|
|
2535
|
+
|
|
2536
|
+
/**
|
|
2537
|
+
* @typedef {object} UploadResponseFile
|
|
2538
|
+
* @property {string} filename - File name
|
|
2539
|
+
* @property {string} originalname - Original file name
|
|
2540
|
+
*/
|
|
2541
|
+
|
|
2542
|
+
/**
|
|
2543
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
2544
|
+
*/
|
|
2545
|
+
MultiFileUpload.moduleName = 'moj-multi-file-upload';
|
|
2546
|
+
/**
|
|
2547
|
+
* Multi file upload default config
|
|
2548
|
+
*
|
|
2549
|
+
* @type {MultiFileUploadConfig}
|
|
2550
|
+
*/
|
|
2551
|
+
MultiFileUpload.defaults = Object.freeze({
|
|
2552
|
+
uploadStatusText: 'Uploading files, please wait',
|
|
2553
|
+
dropzoneHintText: 'Drag and drop files here or',
|
|
2554
|
+
dropzoneButtonText: 'Choose files',
|
|
2555
|
+
feedbackContainer: {
|
|
2556
|
+
selector: '.moj-multi-file__uploaded-files'
|
|
2557
|
+
},
|
|
2558
|
+
hooks: {
|
|
2559
|
+
entryHook: () => {},
|
|
2560
|
+
exitHook: () => {},
|
|
2561
|
+
errorHook: () => {},
|
|
2562
|
+
deleteHook: () => {}
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
/**
|
|
2566
|
+
* Multi file upload config schema
|
|
2567
|
+
*
|
|
2568
|
+
* @satisfies {Schema<MultiFileUploadConfig>}
|
|
2569
|
+
*/
|
|
2570
|
+
MultiFileUpload.schema = Object.freeze(/** @type {const} */{
|
|
2571
|
+
properties: {
|
|
2572
|
+
uploadUrl: {
|
|
2573
|
+
type: 'string'
|
|
2574
|
+
},
|
|
2575
|
+
deleteUrl: {
|
|
2576
|
+
type: 'string'
|
|
2577
|
+
},
|
|
2578
|
+
uploadStatusText: {
|
|
2579
|
+
type: 'string'
|
|
2580
|
+
},
|
|
2581
|
+
dropzoneHintText: {
|
|
2582
|
+
type: 'string'
|
|
2583
|
+
},
|
|
2584
|
+
dropzoneButtonText: {
|
|
2585
|
+
type: 'string'
|
|
2586
|
+
},
|
|
2587
|
+
feedbackContainer: {
|
|
2588
|
+
type: 'object'
|
|
2589
|
+
},
|
|
2590
|
+
hooks: {
|
|
2591
|
+
type: 'object'
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* @augments {ConfigurableComponent<MultiSelectConfig>}
|
|
2598
|
+
*/
|
|
2599
|
+
class MultiSelect extends ConfigurableComponent {
|
|
2600
|
+
/**
|
|
2601
|
+
* @param {Element | null} $root - HTML element to use for multi select
|
|
2602
|
+
* @param {MultiSelectConfig} [config] - Multi select config
|
|
2603
|
+
*/
|
|
2604
|
+
constructor($root, config = {}) {
|
|
2605
|
+
var _this$config$checkbox;
|
|
2606
|
+
super($root, config);
|
|
2607
|
+
const $container = this.$root.querySelector(`#${this.config.idPrefix}select-all`);
|
|
2608
|
+
const $checkboxes = /** @type {NodeListOf<HTMLInputElement>} */(_this$config$checkbox = this.config.checkboxes.items) != null ? _this$config$checkbox : this.$root.querySelectorAll(this.config.checkboxes.selector);
|
|
2609
|
+
if (!$container || !($container instanceof HTMLElement) || !$checkboxes.length) {
|
|
1992
2610
|
return this;
|
|
1993
2611
|
}
|
|
1994
|
-
this.
|
|
1995
|
-
|
|
1996
|
-
this.
|
|
1997
|
-
this
|
|
1998
|
-
this.
|
|
1999
|
-
this.
|
|
2000
|
-
this.
|
|
2001
|
-
this.
|
|
2002
|
-
this.checked = options.checked || false;
|
|
2612
|
+
this.setupToggle(this.config.idPrefix);
|
|
2613
|
+
this.$toggleButton = this.$toggle.querySelector('input');
|
|
2614
|
+
this.$toggleButton.addEventListener('click', this.onButtonClick.bind(this));
|
|
2615
|
+
this.$container = $container;
|
|
2616
|
+
this.$container.append(this.$toggle);
|
|
2617
|
+
this.$checkboxes = Array.from($checkboxes);
|
|
2618
|
+
this.$checkboxes.forEach($input => $input.addEventListener('click', this.onCheckboxClick.bind(this)));
|
|
2619
|
+
this.checked = config.checked || false;
|
|
2003
2620
|
}
|
|
2004
2621
|
setupToggle(idPrefix = '') {
|
|
2005
2622
|
const id = `${idPrefix}checkboxes-all`;
|
|
2006
|
-
const toggle = document.createElement('div');
|
|
2007
|
-
const label = document.createElement('label');
|
|
2008
|
-
const input = document.createElement('input');
|
|
2009
|
-
const span = document.createElement('span');
|
|
2010
|
-
toggle.classList.add('govuk-checkboxes__item', 'govuk-checkboxes--small', 'moj-multi-select__checkbox');
|
|
2011
|
-
input.id = id;
|
|
2012
|
-
input.type = 'checkbox';
|
|
2013
|
-
input.classList.add('govuk-checkboxes__input');
|
|
2014
|
-
label.setAttribute('for', id);
|
|
2015
|
-
label.classList.add('govuk-label', 'govuk-checkboxes__label', 'moj-multi-select__toggle-label');
|
|
2016
|
-
span.classList.add('govuk-visually-hidden');
|
|
2017
|
-
span.textContent = 'Select all';
|
|
2018
|
-
label.append(span);
|
|
2019
|
-
toggle.append(input, label);
|
|
2020
|
-
this
|
|
2623
|
+
const $toggle = document.createElement('div');
|
|
2624
|
+
const $label = document.createElement('label');
|
|
2625
|
+
const $input = document.createElement('input');
|
|
2626
|
+
const $span = document.createElement('span');
|
|
2627
|
+
$toggle.classList.add('govuk-checkboxes__item', 'govuk-checkboxes--small', 'moj-multi-select__checkbox');
|
|
2628
|
+
$input.id = id;
|
|
2629
|
+
$input.type = 'checkbox';
|
|
2630
|
+
$input.classList.add('govuk-checkboxes__input');
|
|
2631
|
+
$label.setAttribute('for', id);
|
|
2632
|
+
$label.classList.add('govuk-label', 'govuk-checkboxes__label', 'moj-multi-select__toggle-label');
|
|
2633
|
+
$span.classList.add('govuk-visually-hidden');
|
|
2634
|
+
$span.textContent = 'Select all';
|
|
2635
|
+
$label.append($span);
|
|
2636
|
+
$toggle.append($input, $label);
|
|
2637
|
+
this.$toggle = $toggle;
|
|
2021
2638
|
}
|
|
2022
2639
|
onButtonClick() {
|
|
2023
2640
|
if (this.checked) {
|
|
2024
2641
|
this.uncheckAll();
|
|
2025
|
-
this
|
|
2642
|
+
this.$toggleButton.checked = false;
|
|
2026
2643
|
} else {
|
|
2027
2644
|
this.checkAll();
|
|
2028
|
-
this
|
|
2645
|
+
this.$toggleButton.checked = true;
|
|
2029
2646
|
}
|
|
2030
2647
|
}
|
|
2031
2648
|
checkAll() {
|
|
2032
|
-
this
|
|
2033
|
-
|
|
2649
|
+
this.$checkboxes.forEach($input => {
|
|
2650
|
+
$input.checked = true;
|
|
2034
2651
|
});
|
|
2035
2652
|
this.checked = true;
|
|
2036
2653
|
}
|
|
2037
2654
|
uncheckAll() {
|
|
2038
|
-
this
|
|
2039
|
-
|
|
2655
|
+
this.$checkboxes.forEach($input => {
|
|
2656
|
+
$input.checked = false;
|
|
2040
2657
|
});
|
|
2041
2658
|
this.checked = false;
|
|
2042
2659
|
}
|
|
2660
|
+
|
|
2661
|
+
/**
|
|
2662
|
+
* @param {MouseEvent} event - Click event
|
|
2663
|
+
*/
|
|
2043
2664
|
onCheckboxClick(event) {
|
|
2665
|
+
if (!(event.target instanceof HTMLInputElement)) {
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2044
2668
|
if (!event.target.checked) {
|
|
2045
|
-
this
|
|
2669
|
+
this.$toggleButton.checked = false;
|
|
2046
2670
|
this.checked = false;
|
|
2047
2671
|
} else {
|
|
2048
|
-
if (this
|
|
2049
|
-
this
|
|
2672
|
+
if (this.$checkboxes.filter($input => $input.checked).length === this.$checkboxes.length) {
|
|
2673
|
+
this.$toggleButton.checked = true;
|
|
2050
2674
|
this.checked = true;
|
|
2051
2675
|
}
|
|
2052
2676
|
}
|
|
2053
2677
|
}
|
|
2054
|
-
}
|
|
2055
2678
|
|
|
2056
|
-
class PasswordReveal {
|
|
2057
2679
|
/**
|
|
2058
|
-
*
|
|
2680
|
+
* Name for the component used when initialising using data-module attributes.
|
|
2059
2681
|
*/
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
/**
|
|
2685
|
+
* Multi select config
|
|
2686
|
+
*
|
|
2687
|
+
* @typedef {object} MultiSelectConfig
|
|
2688
|
+
* @property {string} [idPrefix] - Prefix for the Select all" checkbox `id` attribute
|
|
2689
|
+
* @property {boolean} [checked] - Whether the "Select all" checkbox is checked
|
|
2690
|
+
* @property {object} [checkboxes] - Checkboxes config
|
|
2691
|
+
* @property {string} [checkboxes.selector] - Checkboxes query selector
|
|
2692
|
+
* @property {NodeListOf<HTMLInputElement>} [checkboxes.items] - Checkboxes query selector results
|
|
2693
|
+
*/
|
|
2694
|
+
|
|
2695
|
+
/**
|
|
2696
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
2697
|
+
*/
|
|
2698
|
+
MultiSelect.moduleName = 'moj-multi-select';
|
|
2699
|
+
/**
|
|
2700
|
+
* Multi select config
|
|
2701
|
+
*
|
|
2702
|
+
* @type {MultiSelectConfig}
|
|
2703
|
+
*/
|
|
2704
|
+
MultiSelect.defaults = Object.freeze({
|
|
2705
|
+
idPrefix: '',
|
|
2706
|
+
checkboxes: {
|
|
2707
|
+
selector: 'tbody input.govuk-checkboxes__input'
|
|
2708
|
+
}
|
|
2709
|
+
});
|
|
2710
|
+
/**
|
|
2711
|
+
* Multi select config schema
|
|
2712
|
+
*
|
|
2713
|
+
* @satisfies {Schema<MultiSelectConfig>}
|
|
2714
|
+
*/
|
|
2715
|
+
MultiSelect.schema = Object.freeze(/** @type {const} */{
|
|
2716
|
+
properties: {
|
|
2717
|
+
idPrefix: {
|
|
2718
|
+
type: 'string'
|
|
2719
|
+
},
|
|
2720
|
+
checked: {
|
|
2721
|
+
type: 'boolean'
|
|
2722
|
+
},
|
|
2723
|
+
checkboxes: {
|
|
2724
|
+
type: 'object'
|
|
2063
2725
|
}
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2726
|
+
}
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2729
|
+
class PasswordReveal extends Component {
|
|
2730
|
+
/**
|
|
2731
|
+
* @param {Element | null} $root - HTML element to use for password reveal
|
|
2732
|
+
*/
|
|
2733
|
+
constructor($root) {
|
|
2734
|
+
super($root);
|
|
2735
|
+
const $input = this.$root.querySelector('.govuk-input');
|
|
2736
|
+
if (!$input || !($input instanceof HTMLInputElement)) {
|
|
2067
2737
|
return this;
|
|
2068
2738
|
}
|
|
2069
|
-
this
|
|
2070
|
-
this.
|
|
2739
|
+
this.$input = $input;
|
|
2740
|
+
this.$input.setAttribute('spellcheck', 'false');
|
|
2071
2741
|
this.createButton();
|
|
2072
2742
|
}
|
|
2073
2743
|
createButton() {
|
|
2074
|
-
this
|
|
2075
|
-
this
|
|
2076
|
-
this
|
|
2077
|
-
this.
|
|
2078
|
-
this.
|
|
2079
|
-
this.button
|
|
2080
|
-
this
|
|
2081
|
-
this.
|
|
2082
|
-
this.
|
|
2744
|
+
this.$group = document.createElement('div');
|
|
2745
|
+
this.$button = document.createElement('button');
|
|
2746
|
+
this.$button.setAttribute('type', 'button');
|
|
2747
|
+
this.$root.classList.add('moj-password-reveal');
|
|
2748
|
+
this.$group.classList.add('moj-password-reveal__wrapper');
|
|
2749
|
+
this.$button.classList.add('govuk-button', 'govuk-button--secondary', 'moj-password-reveal__button');
|
|
2750
|
+
this.$button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
|
|
2751
|
+
this.$button.addEventListener('click', this.onButtonClick.bind(this));
|
|
2752
|
+
this.$group.append(this.$input, this.$button);
|
|
2753
|
+
this.$root.append(this.$group);
|
|
2083
2754
|
}
|
|
2084
2755
|
onButtonClick() {
|
|
2085
|
-
if (this.
|
|
2086
|
-
this.
|
|
2087
|
-
this
|
|
2756
|
+
if (this.$input.type === 'password') {
|
|
2757
|
+
this.$input.type = 'text';
|
|
2758
|
+
this.$button.innerHTML = 'Hide <span class="govuk-visually-hidden">password</span>';
|
|
2088
2759
|
} else {
|
|
2089
|
-
this.
|
|
2090
|
-
this
|
|
2760
|
+
this.$input.type = 'password';
|
|
2761
|
+
this.$button.innerHTML = 'Show <span class="govuk-visually-hidden">password</span>';
|
|
2091
2762
|
}
|
|
2092
2763
|
}
|
|
2764
|
+
|
|
2765
|
+
/**
|
|
2766
|
+
* Name for the component used when initialising using data-module attributes.
|
|
2767
|
+
*/
|
|
2093
2768
|
}
|
|
2769
|
+
PasswordReveal.moduleName = 'moj-password-reveal';
|
|
2094
2770
|
|
|
2095
|
-
|
|
2771
|
+
/**
|
|
2772
|
+
* @augments {ConfigurableComponent<RichTextEditorConfig>}
|
|
2773
|
+
*/
|
|
2774
|
+
class RichTextEditor extends ConfigurableComponent {
|
|
2096
2775
|
/**
|
|
2097
|
-
* @param {
|
|
2776
|
+
* @param {Element | null} $root - HTML element to use for rich text editor
|
|
2777
|
+
* @param {RichTextEditorConfig} config
|
|
2098
2778
|
*/
|
|
2099
|
-
constructor(
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
} = options;
|
|
2103
|
-
if (!textarea || !textarea.parentElement || !(textarea instanceof HTMLTextAreaElement) || !('contentEditable' in document.documentElement)) {
|
|
2779
|
+
constructor($root, config = {}) {
|
|
2780
|
+
super($root, config);
|
|
2781
|
+
if (!RichTextEditor.isSupported()) {
|
|
2104
2782
|
return this;
|
|
2105
2783
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
italic: false,
|
|
2109
|
-
underline: false,
|
|
2110
|
-
bullets: true,
|
|
2111
|
-
numbers: true
|
|
2112
|
-
};
|
|
2113
|
-
this.textarea = textarea;
|
|
2114
|
-
this.container = this.textarea.parentElement;
|
|
2115
|
-
this.options = options;
|
|
2116
|
-
if (this.container.hasAttribute('data-rich-text-editor-init')) {
|
|
2784
|
+
const $textarea = this.$root.querySelector('.govuk-textarea');
|
|
2785
|
+
if (!$textarea || !($textarea instanceof HTMLTextAreaElement)) {
|
|
2117
2786
|
return this;
|
|
2118
2787
|
}
|
|
2119
|
-
this
|
|
2788
|
+
this.$textarea = $textarea;
|
|
2120
2789
|
this.createToolbar();
|
|
2121
2790
|
this.hideDefault();
|
|
2122
2791
|
this.configureToolbar();
|
|
@@ -2126,34 +2795,42 @@ class RichTextEditor {
|
|
|
2126
2795
|
up: 38,
|
|
2127
2796
|
down: 40
|
|
2128
2797
|
};
|
|
2129
|
-
this
|
|
2130
|
-
this.
|
|
2131
|
-
this
|
|
2798
|
+
this.$content.addEventListener('input', this.onEditorInput.bind(this));
|
|
2799
|
+
this.$root.querySelector('label').addEventListener('click', this.onLabelClick.bind(this));
|
|
2800
|
+
this.$toolbar.addEventListener('keydown', this.onToolbarKeydown.bind(this));
|
|
2132
2801
|
}
|
|
2802
|
+
|
|
2803
|
+
/**
|
|
2804
|
+
* @param {KeyboardEvent} event - Click event
|
|
2805
|
+
*/
|
|
2133
2806
|
onToolbarKeydown(event) {
|
|
2134
|
-
let focusableButton;
|
|
2807
|
+
let $focusableButton;
|
|
2135
2808
|
switch (event.keyCode) {
|
|
2136
2809
|
case this.keys.right:
|
|
2137
2810
|
case this.keys.down:
|
|
2138
2811
|
{
|
|
2139
|
-
focusableButton = this
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
nextButton
|
|
2143
|
-
|
|
2144
|
-
|
|
2812
|
+
$focusableButton = this.$buttons.find(button => button.getAttribute('tabindex') === '0');
|
|
2813
|
+
if ($focusableButton) {
|
|
2814
|
+
const $nextButton = $focusableButton.nextElementSibling;
|
|
2815
|
+
if ($nextButton && $nextButton instanceof HTMLButtonElement) {
|
|
2816
|
+
$nextButton.focus();
|
|
2817
|
+
$focusableButton.setAttribute('tabindex', '-1');
|
|
2818
|
+
$nextButton.setAttribute('tabindex', '0');
|
|
2819
|
+
}
|
|
2145
2820
|
}
|
|
2146
2821
|
break;
|
|
2147
2822
|
}
|
|
2148
2823
|
case this.keys.left:
|
|
2149
2824
|
case this.keys.up:
|
|
2150
2825
|
{
|
|
2151
|
-
focusableButton = this
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
previousButton
|
|
2155
|
-
|
|
2156
|
-
|
|
2826
|
+
$focusableButton = this.$buttons.find(button => button.getAttribute('tabindex') === '0');
|
|
2827
|
+
if ($focusableButton) {
|
|
2828
|
+
const $previousButton = $focusableButton.previousElementSibling;
|
|
2829
|
+
if ($previousButton && $previousButton instanceof HTMLButtonElement) {
|
|
2830
|
+
$previousButton.focus();
|
|
2831
|
+
$focusableButton.setAttribute('tabindex', '-1');
|
|
2832
|
+
$previousButton.setAttribute('tabindex', '0');
|
|
2833
|
+
}
|
|
2157
2834
|
}
|
|
2158
2835
|
break;
|
|
2159
2836
|
}
|
|
@@ -2162,19 +2839,19 @@ class RichTextEditor {
|
|
|
2162
2839
|
getToolbarHtml() {
|
|
2163
2840
|
let html = '';
|
|
2164
2841
|
html += '<div class="moj-rich-text-editor__toolbar" role="toolbar">';
|
|
2165
|
-
if (this.
|
|
2842
|
+
if (this.config.toolbar.bold) {
|
|
2166
2843
|
html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--bold" type="button" data-command="bold"><span class="govuk-visually-hidden">Bold</span></button>';
|
|
2167
2844
|
}
|
|
2168
|
-
if (this.
|
|
2845
|
+
if (this.config.toolbar.italic) {
|
|
2169
2846
|
html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--italic" type="button" data-command="italic"><span class="govuk-visually-hidden">Italic</span></button>';
|
|
2170
2847
|
}
|
|
2171
|
-
if (this.
|
|
2848
|
+
if (this.config.toolbar.underline) {
|
|
2172
2849
|
html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--underline" type="button" data-command="underline"><span class="govuk-visually-hidden">Underline</span></button>';
|
|
2173
2850
|
}
|
|
2174
|
-
if (this.
|
|
2851
|
+
if (this.config.toolbar.bullets) {
|
|
2175
2852
|
html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--unordered-list" type="button" data-command="insertUnorderedList"><span class="govuk-visually-hidden">Unordered list</span></button>';
|
|
2176
2853
|
}
|
|
2177
|
-
if (this.
|
|
2854
|
+
if (this.config.toolbar.numbers) {
|
|
2178
2855
|
html += '<button class="moj-rich-text-editor__toolbar-button moj-rich-text-editor__toolbar-button--ordered-list" type="button" data-command="insertOrderedList"><span class="govuk-visually-hidden">Ordered list</span></button>';
|
|
2179
2856
|
}
|
|
2180
2857
|
html += '</div>';
|
|
@@ -2184,27 +2861,31 @@ class RichTextEditor {
|
|
|
2184
2861
|
return `${this.getToolbarHtml()}<div class="govuk-textarea moj-rich-text-editor__content" contenteditable="true" spellcheck="false"></div>`;
|
|
2185
2862
|
}
|
|
2186
2863
|
hideDefault() {
|
|
2187
|
-
this
|
|
2188
|
-
this
|
|
2189
|
-
this
|
|
2864
|
+
this.$textarea.classList.add('govuk-visually-hidden');
|
|
2865
|
+
this.$textarea.setAttribute('aria-hidden', 'true');
|
|
2866
|
+
this.$textarea.setAttribute('tabindex', '-1');
|
|
2190
2867
|
}
|
|
2191
2868
|
createToolbar() {
|
|
2192
|
-
this
|
|
2193
|
-
this
|
|
2194
|
-
this
|
|
2195
|
-
this.
|
|
2196
|
-
this
|
|
2197
|
-
this.
|
|
2198
|
-
this
|
|
2869
|
+
this.$toolbar = document.createElement('div');
|
|
2870
|
+
this.$toolbar.className = 'moj-rich-text-editor';
|
|
2871
|
+
this.$toolbar.innerHTML = this.getEnhancedHtml();
|
|
2872
|
+
this.$root.append(this.$toolbar);
|
|
2873
|
+
this.$content = /** @type {HTMLElement} */
|
|
2874
|
+
this.$root.querySelector('.moj-rich-text-editor__content');
|
|
2875
|
+
this.$content.innerHTML = this.$textarea.value;
|
|
2199
2876
|
}
|
|
2200
2877
|
configureToolbar() {
|
|
2201
|
-
this
|
|
2202
|
-
this.
|
|
2203
|
-
this
|
|
2204
|
-
button.setAttribute('tabindex', !index ? '0' : '-1');
|
|
2205
|
-
button.addEventListener('click', this.onButtonClick.bind(this));
|
|
2878
|
+
this.$buttons = Array.from(/** @type {NodeListOf<HTMLButtonElement>} */
|
|
2879
|
+
this.$root.querySelectorAll('.moj-rich-text-editor__toolbar-button'));
|
|
2880
|
+
this.$buttons.forEach(($button, index) => {
|
|
2881
|
+
$button.setAttribute('tabindex', !index ? '0' : '-1');
|
|
2882
|
+
$button.addEventListener('click', this.onButtonClick.bind(this));
|
|
2206
2883
|
});
|
|
2207
2884
|
}
|
|
2885
|
+
|
|
2886
|
+
/**
|
|
2887
|
+
* @param {MouseEvent} event - Click event
|
|
2888
|
+
*/
|
|
2208
2889
|
onButtonClick(event) {
|
|
2209
2890
|
if (!(event.currentTarget instanceof HTMLElement)) {
|
|
2210
2891
|
return;
|
|
@@ -2212,290 +2893,400 @@ class RichTextEditor {
|
|
|
2212
2893
|
document.execCommand(event.currentTarget.getAttribute('data-command'), false, undefined);
|
|
2213
2894
|
}
|
|
2214
2895
|
getContent() {
|
|
2215
|
-
return this
|
|
2896
|
+
return this.$content.innerHTML;
|
|
2216
2897
|
}
|
|
2217
2898
|
onEditorInput() {
|
|
2218
2899
|
this.updateTextarea();
|
|
2219
2900
|
}
|
|
2220
2901
|
updateTextarea() {
|
|
2221
2902
|
document.execCommand('defaultParagraphSeparator', false, 'p');
|
|
2222
|
-
this
|
|
2903
|
+
this.$textarea.value = this.getContent();
|
|
2223
2904
|
}
|
|
2905
|
+
|
|
2906
|
+
/**
|
|
2907
|
+
* @param {MouseEvent} event - Click event
|
|
2908
|
+
*/
|
|
2224
2909
|
onLabelClick(event) {
|
|
2225
2910
|
event.preventDefault();
|
|
2226
|
-
this
|
|
2911
|
+
this.$content.focus();
|
|
2227
2912
|
}
|
|
2913
|
+
static isSupported() {
|
|
2914
|
+
return 'contentEditable' in document.documentElement;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
/**
|
|
2918
|
+
* Name for the component used when initialising using data-module attributes.
|
|
2919
|
+
*/
|
|
2228
2920
|
}
|
|
2229
2921
|
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2922
|
+
/**
|
|
2923
|
+
* Rich text editor config
|
|
2924
|
+
*
|
|
2925
|
+
* @typedef {object} RichTextEditorConfig
|
|
2926
|
+
* @property {RichTextEditorToolbar} [toolbar] - Toolbar options
|
|
2927
|
+
*/
|
|
2928
|
+
|
|
2929
|
+
/**
|
|
2930
|
+
* Rich text editor toolbar options
|
|
2931
|
+
*
|
|
2932
|
+
* @typedef {object} RichTextEditorToolbar
|
|
2933
|
+
* @property {boolean} [bold] - Show the bold button
|
|
2934
|
+
* @property {boolean} [italic] - Show the italic button
|
|
2935
|
+
* @property {boolean} [underline] - Show the underline button
|
|
2936
|
+
* @property {boolean} [bullets] - Show the bullets button
|
|
2937
|
+
* @property {boolean} [numbers] - Show the numbers button
|
|
2938
|
+
*/
|
|
2939
|
+
|
|
2940
|
+
/**
|
|
2941
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
2942
|
+
*/
|
|
2943
|
+
RichTextEditor.moduleName = 'moj-rich-text-editor';
|
|
2944
|
+
/**
|
|
2945
|
+
* Rich text editor config
|
|
2946
|
+
*
|
|
2947
|
+
* @type {RichTextEditorConfig}
|
|
2948
|
+
*/
|
|
2949
|
+
RichTextEditor.defaults = Object.freeze({
|
|
2950
|
+
toolbar: {
|
|
2951
|
+
bold: false,
|
|
2952
|
+
italic: false,
|
|
2953
|
+
underline: false,
|
|
2954
|
+
bullets: true,
|
|
2955
|
+
numbers: true
|
|
2956
|
+
}
|
|
2957
|
+
});
|
|
2958
|
+
/**
|
|
2959
|
+
* Rich text editor config schema
|
|
2960
|
+
*
|
|
2961
|
+
* @satisfies {Schema<RichTextEditorConfig>}
|
|
2962
|
+
*/
|
|
2963
|
+
RichTextEditor.schema = Object.freeze(/** @type {const} */{
|
|
2964
|
+
properties: {
|
|
2965
|
+
toolbar: {
|
|
2966
|
+
type: 'object'
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
/**
|
|
2972
|
+
* @augments {ConfigurableComponent<SearchToggleConfig>}
|
|
2973
|
+
*/
|
|
2974
|
+
class SearchToggle extends ConfigurableComponent {
|
|
2975
|
+
/**
|
|
2976
|
+
* @param {Element | null} $root - HTML element to use for search toggle
|
|
2977
|
+
* @param {SearchToggleConfig} [config] - Search toggle config
|
|
2978
|
+
*/
|
|
2979
|
+
constructor($root, config = {}) {
|
|
2980
|
+
var _this$config$searchCo, _this$config$toggleBu;
|
|
2981
|
+
super($root, config);
|
|
2982
|
+
const $searchContainer = (_this$config$searchCo = this.config.searchContainer.element) != null ? _this$config$searchCo : this.$root.querySelector(this.config.searchContainer.selector);
|
|
2983
|
+
const $toggleButtonContainer = (_this$config$toggleBu = this.config.toggleButtonContainer.element) != null ? _this$config$toggleBu : this.$root.querySelector(this.config.toggleButtonContainer.selector);
|
|
2984
|
+
if (!$searchContainer || !$toggleButtonContainer || !($searchContainer instanceof HTMLElement) || !($toggleButtonContainer instanceof HTMLElement)) {
|
|
2236
2985
|
return this;
|
|
2237
2986
|
}
|
|
2238
|
-
this
|
|
2987
|
+
this.$searchContainer = $searchContainer;
|
|
2988
|
+
this.$toggleButtonContainer = $toggleButtonContainer;
|
|
2239
2989
|
const svg = '<svg viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="moj-search-toggle__button__icon"><path d="M7.433,12.5790048 C6.06762625,12.5808611 4.75763941,12.0392925 3.79217348,11.0738265 C2.82670755,10.1083606 2.28513891,8.79837375 2.28699522,7.433 C2.28513891,6.06762625 2.82670755,4.75763941 3.79217348,3.79217348 C4.75763941,2.82670755 6.06762625,2.28513891 7.433,2.28699522 C8.79837375,2.28513891 10.1083606,2.82670755 11.0738265,3.79217348 C12.0392925,4.75763941 12.5808611,6.06762625 12.5790048,7.433 C12.5808611,8.79837375 12.0392925,10.1083606 11.0738265,11.0738265 C10.1083606,12.0392925 8.79837375,12.5808611 7.433,12.5790048 L7.433,12.5790048 Z M14.293,12.579 L13.391,12.579 L13.071,12.269 C14.2300759,10.9245158 14.8671539,9.20813198 14.866,7.433 C14.866,3.32786745 11.5381325,-1.65045755e-15 7.433,-1.65045755e-15 C3.32786745,-1.65045755e-15 -1.65045755e-15,3.32786745 -1.65045755e-15,7.433 C-1.65045755e-15,11.5381325 3.32786745,14.866 7.433,14.866 C9.208604,14.8671159 10.9253982,14.2296624 12.27,13.07 L12.579,13.39 L12.579,14.294 L18.296,20 L20,18.296 L14.294,12.579 L14.293,12.579 Z"></path></svg>';
|
|
2240
|
-
this
|
|
2241
|
-
this
|
|
2242
|
-
this
|
|
2243
|
-
this
|
|
2244
|
-
this
|
|
2245
|
-
this
|
|
2246
|
-
this
|
|
2247
|
-
this
|
|
2990
|
+
this.$toggleButton = document.createElement('button');
|
|
2991
|
+
this.$toggleButton.setAttribute('class', 'moj-search-toggle__button');
|
|
2992
|
+
this.$toggleButton.setAttribute('type', 'button');
|
|
2993
|
+
this.$toggleButton.setAttribute('aria-haspopup', 'true');
|
|
2994
|
+
this.$toggleButton.setAttribute('aria-expanded', 'false');
|
|
2995
|
+
this.$toggleButton.innerHTML = `${this.config.toggleButton.text} ${svg}`;
|
|
2996
|
+
this.$toggleButton.addEventListener('click', this.onToggleButtonClick.bind(this));
|
|
2997
|
+
this.$toggleButtonContainer.append(this.$toggleButton);
|
|
2248
2998
|
document.addEventListener('click', this.onDocumentClick.bind(this));
|
|
2249
2999
|
document.addEventListener('focusin', this.onDocumentClick.bind(this));
|
|
2250
3000
|
}
|
|
2251
3001
|
showMenu() {
|
|
2252
|
-
this
|
|
2253
|
-
this.
|
|
2254
|
-
this.
|
|
3002
|
+
this.$toggleButton.setAttribute('aria-expanded', 'true');
|
|
3003
|
+
this.$searchContainer.classList.remove('moj-js-hidden');
|
|
3004
|
+
this.$searchContainer.querySelector('input').focus();
|
|
2255
3005
|
}
|
|
2256
3006
|
hideMenu() {
|
|
2257
|
-
this.
|
|
2258
|
-
this
|
|
3007
|
+
this.$searchContainer.classList.add('moj-js-hidden');
|
|
3008
|
+
this.$toggleButton.setAttribute('aria-expanded', 'false');
|
|
2259
3009
|
}
|
|
2260
3010
|
onToggleButtonClick() {
|
|
2261
|
-
if (this
|
|
3011
|
+
if (this.$toggleButton.getAttribute('aria-expanded') === 'false') {
|
|
2262
3012
|
this.showMenu();
|
|
2263
3013
|
} else {
|
|
2264
3014
|
this.hideMenu();
|
|
2265
3015
|
}
|
|
2266
3016
|
}
|
|
3017
|
+
|
|
3018
|
+
/**
|
|
3019
|
+
* @param {MouseEvent | FocusEvent} event
|
|
3020
|
+
*/
|
|
2267
3021
|
onDocumentClick(event) {
|
|
2268
|
-
if (!this
|
|
3022
|
+
if (event.target instanceof Node && !this.$toggleButtonContainer.contains(event.target) && !this.$searchContainer.contains(event.target)) {
|
|
2269
3023
|
this.hideMenu();
|
|
2270
3024
|
}
|
|
2271
3025
|
}
|
|
3026
|
+
|
|
3027
|
+
/**
|
|
3028
|
+
* Name for the component used when initialising using data-module attributes.
|
|
3029
|
+
*/
|
|
2272
3030
|
}
|
|
2273
3031
|
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
3032
|
+
/**
|
|
3033
|
+
* @typedef {object} SearchToggleConfig
|
|
3034
|
+
* @property {object} [searchContainer] - Search config
|
|
3035
|
+
* @property {string} [searchContainer.selector] - Selector for search container
|
|
3036
|
+
* @property {Element | null} [searchContainer.element] - HTML element for search container
|
|
3037
|
+
* @property {object} [toggleButton] - Toggle button config
|
|
3038
|
+
* @property {string} [toggleButton.text] - Text for toggle button
|
|
3039
|
+
* @property {object} [toggleButtonContainer] - Toggle button container config
|
|
3040
|
+
* @property {string} [toggleButtonContainer.selector] - Selector for toggle button container
|
|
3041
|
+
* @property {Element | null} [toggleButtonContainer.element] - HTML element for toggle button container
|
|
3042
|
+
*/
|
|
3043
|
+
|
|
3044
|
+
/**
|
|
3045
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
3046
|
+
*/
|
|
3047
|
+
SearchToggle.moduleName = 'moj-search-toggle';
|
|
3048
|
+
/**
|
|
3049
|
+
* Search toggle config
|
|
3050
|
+
*
|
|
3051
|
+
* @type {SearchToggleConfig}
|
|
3052
|
+
*/
|
|
3053
|
+
SearchToggle.defaults = Object.freeze({
|
|
3054
|
+
searchContainer: {
|
|
3055
|
+
selector: '.moj-search'
|
|
3056
|
+
},
|
|
3057
|
+
toggleButton: {
|
|
3058
|
+
text: 'Search'
|
|
3059
|
+
},
|
|
3060
|
+
toggleButtonContainer: {
|
|
3061
|
+
selector: '.moj-search-toggle__toggle'
|
|
3062
|
+
}
|
|
3063
|
+
});
|
|
3064
|
+
/**
|
|
3065
|
+
* Search toggle config schema
|
|
3066
|
+
*
|
|
3067
|
+
* @satisfies {Schema<SearchToggleConfig>}
|
|
3068
|
+
*/
|
|
3069
|
+
SearchToggle.schema = Object.freeze(/** @type {const} */{
|
|
3070
|
+
properties: {
|
|
3071
|
+
searchContainer: {
|
|
3072
|
+
type: 'object'
|
|
3073
|
+
},
|
|
3074
|
+
toggleButton: {
|
|
3075
|
+
type: 'object'
|
|
3076
|
+
},
|
|
3077
|
+
toggleButtonContainer: {
|
|
3078
|
+
type: 'object'
|
|
2281
3079
|
}
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
3080
|
+
}
|
|
3081
|
+
});
|
|
3082
|
+
|
|
3083
|
+
/**
|
|
3084
|
+
* @augments {ConfigurableComponent<SortableTableConfig>}
|
|
3085
|
+
*/
|
|
3086
|
+
class SortableTable extends ConfigurableComponent {
|
|
3087
|
+
/**
|
|
3088
|
+
* @param {Element | null} $root - HTML element to use for sortable table
|
|
3089
|
+
* @param {SortableTableConfig} [config] - Sortable table config
|
|
3090
|
+
*/
|
|
3091
|
+
constructor($root, config = {}) {
|
|
3092
|
+
super($root, config);
|
|
3093
|
+
const $head = $root == null ? void 0 : $root.querySelector('thead');
|
|
3094
|
+
const $body = $root == null ? void 0 : $root.querySelector('tbody');
|
|
3095
|
+
if (!$head || !$body) {
|
|
2286
3096
|
return this;
|
|
2287
3097
|
}
|
|
2288
|
-
this
|
|
2289
|
-
this
|
|
2290
|
-
this.
|
|
3098
|
+
this.$head = $head;
|
|
3099
|
+
this.$body = $body;
|
|
3100
|
+
this.$headings = this.$head ? Array.from(this.$head.querySelectorAll('th')) : [];
|
|
2291
3101
|
this.createHeadingButtons();
|
|
2292
3102
|
this.createStatusBox();
|
|
2293
3103
|
this.initialiseSortedColumn();
|
|
2294
|
-
this
|
|
2295
|
-
}
|
|
2296
|
-
setupOptions(params) {
|
|
2297
|
-
params = params || {};
|
|
2298
|
-
this.statusMessage = params.statusMessage || 'Sort by %heading% (%direction%)';
|
|
2299
|
-
this.ascendingText = params.ascendingText || 'ascending';
|
|
2300
|
-
this.descendingText = params.descendingText || 'descending';
|
|
3104
|
+
this.$head.addEventListener('click', this.onSortButtonClick.bind(this));
|
|
2301
3105
|
}
|
|
2302
3106
|
createHeadingButtons() {
|
|
2303
|
-
for (const heading of this
|
|
2304
|
-
if (heading.hasAttribute('aria-sort')) {
|
|
2305
|
-
this.createHeadingButton(heading);
|
|
3107
|
+
for (const $heading of this.$headings) {
|
|
3108
|
+
if ($heading.hasAttribute('aria-sort')) {
|
|
3109
|
+
this.createHeadingButton($heading);
|
|
2306
3110
|
}
|
|
2307
3111
|
}
|
|
2308
3112
|
}
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
3113
|
+
|
|
3114
|
+
/**
|
|
3115
|
+
* @param {HTMLTableCellElement} $heading
|
|
3116
|
+
*/
|
|
3117
|
+
createHeadingButton($heading) {
|
|
3118
|
+
const index = this.$headings.indexOf($heading);
|
|
3119
|
+
const $button = document.createElement('button');
|
|
3120
|
+
$button.setAttribute('type', 'button');
|
|
3121
|
+
$button.setAttribute('data-index', `${index}`);
|
|
3122
|
+
$button.textContent = $heading.textContent;
|
|
3123
|
+
$heading.textContent = '';
|
|
3124
|
+
$heading.appendChild($button);
|
|
2317
3125
|
}
|
|
2318
3126
|
createStatusBox() {
|
|
2319
|
-
this
|
|
2320
|
-
this
|
|
2321
|
-
this
|
|
2322
|
-
this
|
|
2323
|
-
this
|
|
2324
|
-
this.
|
|
3127
|
+
this.$status = document.createElement('div');
|
|
3128
|
+
this.$status.setAttribute('aria-atomic', 'true');
|
|
3129
|
+
this.$status.setAttribute('aria-live', 'polite');
|
|
3130
|
+
this.$status.setAttribute('class', 'govuk-visually-hidden');
|
|
3131
|
+
this.$status.setAttribute('role', 'status');
|
|
3132
|
+
this.$root.insertAdjacentElement('afterend', this.$status);
|
|
2325
3133
|
}
|
|
2326
3134
|
initialiseSortedColumn() {
|
|
2327
|
-
var
|
|
2328
|
-
const rows = this.getTableRowsArray();
|
|
2329
|
-
const heading = this.
|
|
2330
|
-
const sortButton = heading == null ? void 0 : heading.querySelector('button');
|
|
2331
|
-
const sortDirection = heading == null ? void 0 : heading.getAttribute('aria-sort');
|
|
2332
|
-
const columnNumber = Number.parseInt((
|
|
2333
|
-
if (
|
|
3135
|
+
var _$sortButton$getAttri;
|
|
3136
|
+
const $rows = this.getTableRowsArray();
|
|
3137
|
+
const $heading = this.$root.querySelector('th[aria-sort]');
|
|
3138
|
+
const $sortButton = $heading == null ? void 0 : $heading.querySelector('button');
|
|
3139
|
+
const sortDirection = $heading == null ? void 0 : $heading.getAttribute('aria-sort');
|
|
3140
|
+
const columnNumber = Number.parseInt((_$sortButton$getAttri = $sortButton == null ? void 0 : $sortButton.getAttribute('data-index')) != null ? _$sortButton$getAttri : '0', 10);
|
|
3141
|
+
if (!$heading || !$sortButton || !(sortDirection === 'ascending' || sortDirection === 'descending')) {
|
|
2334
3142
|
return;
|
|
2335
3143
|
}
|
|
2336
|
-
const sortedRows = this.sort(rows, columnNumber, sortDirection);
|
|
2337
|
-
this.addRows(sortedRows);
|
|
3144
|
+
const $sortedRows = this.sort($rows, columnNumber, sortDirection);
|
|
3145
|
+
this.addRows($sortedRows);
|
|
2338
3146
|
}
|
|
3147
|
+
|
|
3148
|
+
/**
|
|
3149
|
+
* @param {MouseEvent} event - Click event
|
|
3150
|
+
*/
|
|
2339
3151
|
onSortButtonClick(event) {
|
|
2340
|
-
var
|
|
2341
|
-
const button = event.target;
|
|
2342
|
-
if (
|
|
3152
|
+
var _$button$getAttribute;
|
|
3153
|
+
const $button = event.target;
|
|
3154
|
+
if (!$button || !($button instanceof HTMLButtonElement) || !$button.parentElement) {
|
|
2343
3155
|
return;
|
|
2344
3156
|
}
|
|
2345
|
-
const heading = button.parentElement;
|
|
2346
|
-
const sortDirection = heading.getAttribute('aria-sort');
|
|
2347
|
-
const columnNumber = Number.parseInt((
|
|
3157
|
+
const $heading = $button.parentElement;
|
|
3158
|
+
const sortDirection = $heading.getAttribute('aria-sort');
|
|
3159
|
+
const columnNumber = Number.parseInt((_$button$getAttribute = $button == null ? void 0 : $button.getAttribute('data-index')) != null ? _$button$getAttribute : '0', 10);
|
|
2348
3160
|
const newSortDirection = sortDirection === 'none' || sortDirection === 'descending' ? 'ascending' : 'descending';
|
|
2349
|
-
const rows = this.getTableRowsArray();
|
|
2350
|
-
const sortedRows = this.sort(rows, columnNumber, newSortDirection);
|
|
2351
|
-
this.addRows(sortedRows);
|
|
3161
|
+
const $rows = this.getTableRowsArray();
|
|
3162
|
+
const $sortedRows = this.sort($rows, columnNumber, newSortDirection);
|
|
3163
|
+
this.addRows($sortedRows);
|
|
2352
3164
|
this.removeButtonStates();
|
|
2353
|
-
this.updateButtonState(button, newSortDirection);
|
|
3165
|
+
this.updateButtonState($button, newSortDirection);
|
|
2354
3166
|
}
|
|
2355
|
-
|
|
3167
|
+
|
|
3168
|
+
/**
|
|
3169
|
+
* @param {HTMLButtonElement} $button
|
|
3170
|
+
* @param {string} direction
|
|
3171
|
+
*/
|
|
3172
|
+
updateButtonState($button, direction) {
|
|
2356
3173
|
if (!(direction === 'ascending' || direction === 'descending')) {
|
|
2357
3174
|
return;
|
|
2358
3175
|
}
|
|
2359
|
-
button.parentElement.setAttribute('aria-sort', direction);
|
|
2360
|
-
let message = this.statusMessage;
|
|
2361
|
-
message = message.replace(/%heading%/, button.textContent);
|
|
2362
|
-
message = message.replace(/%direction%/, this[`${direction}Text`]);
|
|
2363
|
-
this
|
|
3176
|
+
$button.parentElement.setAttribute('aria-sort', direction);
|
|
3177
|
+
let message = this.config.statusMessage;
|
|
3178
|
+
message = message.replace(/%heading%/, $button.textContent);
|
|
3179
|
+
message = message.replace(/%direction%/, this.config[`${direction}Text`]);
|
|
3180
|
+
this.$status.textContent = message;
|
|
2364
3181
|
}
|
|
2365
3182
|
removeButtonStates() {
|
|
2366
|
-
for (const heading of this
|
|
2367
|
-
heading.setAttribute('aria-sort', 'none');
|
|
3183
|
+
for (const $heading of this.$headings) {
|
|
3184
|
+
$heading.setAttribute('aria-sort', 'none');
|
|
2368
3185
|
}
|
|
2369
3186
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
3187
|
+
|
|
3188
|
+
/**
|
|
3189
|
+
* @param {HTMLTableRowElement[]} $rows
|
|
3190
|
+
*/
|
|
3191
|
+
addRows($rows) {
|
|
3192
|
+
for (const $row of $rows) {
|
|
3193
|
+
this.$body.append($row);
|
|
2373
3194
|
}
|
|
2374
3195
|
}
|
|
2375
3196
|
getTableRowsArray() {
|
|
2376
|
-
return Array.from(this
|
|
3197
|
+
return Array.from(this.$body.querySelectorAll('tr'));
|
|
2377
3198
|
}
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
3199
|
+
|
|
3200
|
+
/**
|
|
3201
|
+
* @param {HTMLTableRowElement[]} $rows
|
|
3202
|
+
* @param {number} columnNumber
|
|
3203
|
+
* @param {string} sortDirection
|
|
3204
|
+
*/
|
|
3205
|
+
sort($rows, columnNumber, sortDirection) {
|
|
3206
|
+
return $rows.sort(($rowA, $rowB) => {
|
|
3207
|
+
const $tdA = $rowA.querySelectorAll('td, th')[columnNumber];
|
|
3208
|
+
const $tdB = $rowB.querySelectorAll('td, th')[columnNumber];
|
|
3209
|
+
if (!$tdA || !$tdB || !($tdA instanceof HTMLElement) || !($tdB instanceof HTMLElement)) {
|
|
2383
3210
|
return 0;
|
|
2384
3211
|
}
|
|
2385
|
-
const valueA = sortDirection === 'ascending' ? this.getCellValue(tdA) : this.getCellValue(tdB);
|
|
2386
|
-
const valueB = sortDirection === 'ascending' ? this.getCellValue(tdB) : this.getCellValue(tdA);
|
|
3212
|
+
const valueA = sortDirection === 'ascending' ? this.getCellValue($tdA) : this.getCellValue($tdB);
|
|
3213
|
+
const valueB = sortDirection === 'ascending' ? this.getCellValue($tdB) : this.getCellValue($tdA);
|
|
2387
3214
|
return !(typeof valueA === 'number' && typeof valueB === 'number') ? valueA.toString().localeCompare(valueB.toString()) : valueA - valueB;
|
|
2388
3215
|
});
|
|
2389
3216
|
}
|
|
2390
|
-
|
|
2391
|
-
|
|
3217
|
+
|
|
3218
|
+
/**
|
|
3219
|
+
* @param {HTMLElement} $cell
|
|
3220
|
+
*/
|
|
3221
|
+
getCellValue($cell) {
|
|
3222
|
+
const val = $cell.getAttribute('data-sort-value') || $cell.innerHTML;
|
|
2392
3223
|
const valAsNumber = Number(val);
|
|
2393
3224
|
return Number.isFinite(valAsNumber) ? valAsNumber // Exclude invalid numbers, infinity etc
|
|
2394
3225
|
: val;
|
|
2395
3226
|
}
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
const version = '0.0.0-development';
|
|
2399
3227
|
|
|
2400
|
-
|
|
3228
|
+
/**
|
|
3229
|
+
* Name for the component used when initialising using data-module attributes.
|
|
3230
|
+
*/
|
|
3231
|
+
}
|
|
2401
3232
|
|
|
3233
|
+
/**
|
|
3234
|
+
* Sortable table config
|
|
3235
|
+
*
|
|
3236
|
+
* @typedef {object} SortableTableConfig
|
|
3237
|
+
* @property {string} [statusMessage] - Status message
|
|
3238
|
+
* @property {string} [ascendingText] - Ascending text
|
|
3239
|
+
* @property {string} [descendingText] - Descending text
|
|
3240
|
+
*/
|
|
2402
3241
|
|
|
2403
3242
|
/**
|
|
2404
|
-
* @
|
|
3243
|
+
* @import { Schema } from 'govuk-frontend/dist/govuk/common/configuration.mjs'
|
|
2405
3244
|
*/
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
});
|
|
2433
|
-
const $richTextEditors = scope.querySelectorAll('[data-module="moj-rich-text-editor"]');
|
|
2434
|
-
$richTextEditors.forEach($richTextEditor => {
|
|
2435
|
-
const options = {
|
|
2436
|
-
textarea: $richTextEditor
|
|
2437
|
-
};
|
|
2438
|
-
const toolbarAttr = $richTextEditor.getAttribute('data-moj-rich-text-editor-toolbar');
|
|
2439
|
-
if (toolbarAttr) {
|
|
2440
|
-
const toolbar = toolbarAttr.split(',');
|
|
2441
|
-
options.toolbar = {};
|
|
2442
|
-
for (const option of toolbar) {
|
|
2443
|
-
if (option === 'bold' || option === 'italic' || option === 'underline' || option === 'bullets' || option === 'numbers') {
|
|
2444
|
-
options.toolbar[option] = true;
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
3245
|
+
SortableTable.moduleName = 'moj-sortable-table';
|
|
3246
|
+
/**
|
|
3247
|
+
* Sortable table config
|
|
3248
|
+
*
|
|
3249
|
+
* @type {SortableTableConfig}
|
|
3250
|
+
*/
|
|
3251
|
+
SortableTable.defaults = Object.freeze({
|
|
3252
|
+
statusMessage: 'Sort by %heading% (%direction%)',
|
|
3253
|
+
ascendingText: 'ascending',
|
|
3254
|
+
descendingText: 'descending'
|
|
3255
|
+
});
|
|
3256
|
+
/**
|
|
3257
|
+
* Sortable table config schema
|
|
3258
|
+
*
|
|
3259
|
+
* @satisfies {Schema<SortableTableConfig>}
|
|
3260
|
+
*/
|
|
3261
|
+
SortableTable.schema = Object.freeze(/** @type {const} */{
|
|
3262
|
+
properties: {
|
|
3263
|
+
statusMessage: {
|
|
3264
|
+
type: 'string'
|
|
3265
|
+
},
|
|
3266
|
+
ascendingText: {
|
|
3267
|
+
type: 'string'
|
|
3268
|
+
},
|
|
3269
|
+
descendingText: {
|
|
3270
|
+
type: 'string'
|
|
2447
3271
|
}
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
const $searchToggles = scope.querySelectorAll('[data-module="moj-search-toggle"]');
|
|
2451
|
-
$searchToggles.forEach($searchToggle => {
|
|
2452
|
-
new SearchToggle({
|
|
2453
|
-
toggleButton: {
|
|
2454
|
-
container: $searchToggle.querySelector('.moj-search-toggle__toggle'),
|
|
2455
|
-
text: $searchToggle.getAttribute('data-moj-search-toggle-text')
|
|
2456
|
-
},
|
|
2457
|
-
search: {
|
|
2458
|
-
container: $searchToggle.querySelector('.moj-search')
|
|
2459
|
-
}
|
|
2460
|
-
});
|
|
2461
|
-
});
|
|
2462
|
-
const $sortableTables = scope.querySelectorAll('[data-module="moj-sortable-table"]');
|
|
2463
|
-
$sortableTables.forEach($table => {
|
|
2464
|
-
new SortableTable({
|
|
2465
|
-
table: $table
|
|
2466
|
-
});
|
|
2467
|
-
});
|
|
2468
|
-
const $datePickers = scope.querySelectorAll('[data-module="moj-date-picker"]');
|
|
2469
|
-
$datePickers.forEach($datePicker => {
|
|
2470
|
-
new DatePicker($datePicker);
|
|
2471
|
-
});
|
|
2472
|
-
const $buttonMenus = scope.querySelectorAll('[data-module="moj-button-menu"]');
|
|
2473
|
-
$buttonMenus.forEach($buttonmenu => {
|
|
2474
|
-
new ButtonMenu($buttonmenu);
|
|
2475
|
-
});
|
|
2476
|
-
const $alerts = scope.querySelectorAll('[data-module="moj-alert"]');
|
|
2477
|
-
$alerts.forEach($alert => {
|
|
2478
|
-
new Alert($alert);
|
|
2479
|
-
});
|
|
2480
|
-
}
|
|
3272
|
+
}
|
|
3273
|
+
});
|
|
2481
3274
|
|
|
2482
3275
|
/**
|
|
2483
|
-
* @
|
|
2484
|
-
* @property {Element} [scope=document] - Scope to query for components
|
|
3276
|
+
* @param {Config} [config]
|
|
2485
3277
|
*/
|
|
3278
|
+
function initAll(config) {
|
|
3279
|
+
for (const Component of [AddAnother, Alert, ButtonMenu, DatePicker, MultiSelect, PasswordReveal, RichTextEditor, SearchToggle, SortableTable]) {
|
|
3280
|
+
createAll(Component, undefined, config);
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
2486
3283
|
|
|
2487
3284
|
/**
|
|
2488
|
-
*
|
|
2489
|
-
*
|
|
2490
|
-
* @typedef {object} Schema
|
|
2491
|
-
* @property {{ [field: string]: SchemaProperty | undefined }} properties - Schema properties
|
|
3285
|
+
* @typedef {Parameters<typeof GOVUKFrontend.initAll>[0]} Config
|
|
2492
3286
|
*/
|
|
2493
3287
|
|
|
2494
3288
|
/**
|
|
2495
|
-
*
|
|
2496
|
-
*
|
|
2497
|
-
* @typedef {object} SchemaProperty
|
|
2498
|
-
* @property {'string' | 'boolean' | 'number' | 'object'} type - Property type
|
|
3289
|
+
* @import * as GOVUKFrontend from 'govuk-frontend'
|
|
2499
3290
|
*/
|
|
2500
3291
|
|
|
2501
3292
|
export { AddAnother, Alert, ButtonMenu, DatePicker, FilterToggleButton, FormValidator, MultiFileUpload, MultiSelect, PasswordReveal, RichTextEditor, SearchToggle, SortableTable, initAll, version };
|