brut-js 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.projections.json +10 -0
- package/CODE_OF_CONDUCT.txt +99 -0
- package/LICENSE.txt +370 -0
- package/README.md +118 -0
- package/package.json +30 -0
- package/specs/AjaxSubmit.spec.js +241 -0
- package/specs/Autosubmit.spec.js +127 -0
- package/specs/ConfirmSubmit.spec.js +193 -0
- package/specs/ConstraintViolationMessage.spec.js +33 -0
- package/specs/ConstraintViolationMessages.spec.js +27 -0
- package/specs/Form.spec.js +136 -0
- package/specs/I18nTranslation.spec.js +19 -0
- package/specs/LocaleDetection.spec.js +22 -0
- package/specs/Message.spec.js +15 -0
- package/specs/Tabs.spec.js +41 -0
- package/specs/config/asset_metadata.json +7 -0
- package/specs/public/js/bundle.js +1284 -0
- package/specs/public/js/bundle.js.map +7 -0
- package/src/AjaxSubmit.js +364 -0
- package/src/Autosubmit.js +61 -0
- package/src/BaseCustomElement.js +261 -0
- package/src/ConfirmSubmit.js +114 -0
- package/src/ConfirmationDialog.js +141 -0
- package/src/ConstraintViolationMessage.js +101 -0
- package/src/ConstraintViolationMessages.js +90 -0
- package/src/Form.js +117 -0
- package/src/I18nTranslation.js +59 -0
- package/src/LocaleDetection.js +93 -0
- package/src/Logger.js +90 -0
- package/src/Message.js +55 -0
- package/src/RichString.js +113 -0
- package/src/Tabs.js +167 -0
- package/src/appForTestingOnly.js +15 -0
- package/src/index.js +119 -0
- package/src/testing/index.js +348 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
// src/Logger.js
|
|
3
|
+
var Logger = class {
|
|
4
|
+
/** Create a logger for the given prefix.
|
|
5
|
+
*
|
|
6
|
+
* @param {string|false} stringOrFalse - if false,returns a {@link BufferedLogger}. Otherwise, returns a {@link PrefixedLogger} using the param's value as the prefix.
|
|
7
|
+
*
|
|
8
|
+
* @returns {Logger}
|
|
9
|
+
*/
|
|
10
|
+
static forPrefix(stringOrFalse) {
|
|
11
|
+
if (!stringOrFalse) {
|
|
12
|
+
return new BufferedLogger();
|
|
13
|
+
} else {
|
|
14
|
+
return new PrefixedLogger(stringOrFalse);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Subclasses must implement this.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} level - 'info' or 'warn' to indicate the logging level
|
|
20
|
+
* @param {...*} args - args to pass directly to console.log
|
|
21
|
+
*/
|
|
22
|
+
log() {
|
|
23
|
+
throw `Subclass must implement`;
|
|
24
|
+
}
|
|
25
|
+
/** Log an informational bit of information */
|
|
26
|
+
info(...args) {
|
|
27
|
+
this.log("info", ...args);
|
|
28
|
+
}
|
|
29
|
+
/** Log a warning */
|
|
30
|
+
warn(...args) {
|
|
31
|
+
this.log("warn", ...args);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var BufferedLogger = class extends Logger {
|
|
35
|
+
constructor() {
|
|
36
|
+
super();
|
|
37
|
+
this.messages = [];
|
|
38
|
+
}
|
|
39
|
+
log(...args) {
|
|
40
|
+
this.messages.push(args);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var PrefixedLogger = class extends Logger {
|
|
44
|
+
/** Create a PrefixedLogger.
|
|
45
|
+
*
|
|
46
|
+
* @param {string|true} prefixOrTrue - if true, uses the prefix `"debug"`, otherwise uses the param as the prefix to all
|
|
47
|
+
* messages output.
|
|
48
|
+
*/
|
|
49
|
+
constructor(prefixOrTrue) {
|
|
50
|
+
super();
|
|
51
|
+
this.prefix = prefixOrTrue === true ? "debug" : prefixOrTrue;
|
|
52
|
+
}
|
|
53
|
+
/** Dumps hte contents of a {@link BufferedLogger} to this logger's output.
|
|
54
|
+
*
|
|
55
|
+
* @param {BufferedLogger} bufferedLogger - a logger with pent-up messages, waiting to be logged.
|
|
56
|
+
*/
|
|
57
|
+
dump(bufferedLogger) {
|
|
58
|
+
if (bufferedLogger instanceof BufferedLogger) {
|
|
59
|
+
bufferedLogger.messages.forEach((args) => {
|
|
60
|
+
this.log(...args);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
log(level, ...args) {
|
|
65
|
+
if (typeof args[0] === "string") {
|
|
66
|
+
const message = `[prefix:${this.prefix}]:${args[0]}`;
|
|
67
|
+
console[level](message, ...args.slice(1));
|
|
68
|
+
} else {
|
|
69
|
+
console[level](this.prefix, ...args);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var Logger_default = Logger;
|
|
74
|
+
|
|
75
|
+
// src/RichString.js
|
|
76
|
+
var RichString = class _RichString {
|
|
77
|
+
/** Prefer this over the constructor, as this will
|
|
78
|
+
* wrap `possiblyDefinedStringOrRichString` only if necessary
|
|
79
|
+
* as well as handle `null`.
|
|
80
|
+
*
|
|
81
|
+
* @param {null|undefined|String|RichString} possiblyDefinedStringOrRichString - if `null`, `undefined`, or otherwise falsey, this method returns `null`. If a String, returns a new `RichString` wrapping it. If a `RichString`, returns the `RichString` unchanged.
|
|
82
|
+
*/
|
|
83
|
+
static fromString(possiblyDefinedStringOrRichString) {
|
|
84
|
+
if (possiblyDefinedStringOrRichString instanceof _RichString) {
|
|
85
|
+
return possiblyDefinedStringOrRichString;
|
|
86
|
+
}
|
|
87
|
+
if (!possiblyDefinedStringOrRichString) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return new _RichString(String(possiblyDefinedStringOrRichString));
|
|
91
|
+
}
|
|
92
|
+
/** Prefer `fromString` */
|
|
93
|
+
constructor(string) {
|
|
94
|
+
if (typeof string !== "string") {
|
|
95
|
+
throw `You may only construct a RichString with a String, not a ${typeof string}`;
|
|
96
|
+
}
|
|
97
|
+
this.string = string;
|
|
98
|
+
}
|
|
99
|
+
/** Returns a `RichString` with the string capitalized. */
|
|
100
|
+
capitalize() {
|
|
101
|
+
return new _RichString(this.string.charAt(0).toUpperCase() + this.string.slice(1));
|
|
102
|
+
}
|
|
103
|
+
/** Returns a `RichString` with the string un-capitalized. */
|
|
104
|
+
decapitalize() {
|
|
105
|
+
return new _RichString(this.string.charAt(0).toLowerCase() + this.string.slice(1));
|
|
106
|
+
}
|
|
107
|
+
/** Returns a `RichString` with the string converted from snake or kebab case into camel case. */
|
|
108
|
+
camelize() {
|
|
109
|
+
return _RichString.fromString(this.string.replace(/[_.-](\w|$)/g, function(_, x) {
|
|
110
|
+
return x.toUpperCase();
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
/** Returns a 'humanized' `RichString`, which is basically a de-camelized version with the first letter
|
|
114
|
+
* capitalized.
|
|
115
|
+
*/
|
|
116
|
+
humanize() {
|
|
117
|
+
return this.decamlize({ spacer: " " }).capitalize();
|
|
118
|
+
}
|
|
119
|
+
/** Returns a `RichString` with the string converted from camel case to snake or kebab case.
|
|
120
|
+
*
|
|
121
|
+
* @param {Object} parameters
|
|
122
|
+
* @param {string} parameters.spacer ["_"] - a string to use when joining words together.
|
|
123
|
+
*
|
|
124
|
+
*/
|
|
125
|
+
decamlize({ spacer = "_" } = {}) {
|
|
126
|
+
if (this.string.length < 2) {
|
|
127
|
+
return new _RichString(this.string.toLowerCase());
|
|
128
|
+
}
|
|
129
|
+
const replacement = `$1${spacer}$2`;
|
|
130
|
+
const decamelized = this.string.replace(
|
|
131
|
+
/([\p{Lowercase_Letter}\d])(\p{Uppercase_Letter})/gu,
|
|
132
|
+
replacement
|
|
133
|
+
);
|
|
134
|
+
return new _RichString(
|
|
135
|
+
decamelized.replace(
|
|
136
|
+
/(\p{Uppercase_Letter})(\p{Uppercase_Letter}\p{Lowercase_Letter}+)/gu,
|
|
137
|
+
replacement
|
|
138
|
+
).toLowerCase()
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
/** Return the underlying String value */
|
|
142
|
+
toString() {
|
|
143
|
+
return this.string;
|
|
144
|
+
}
|
|
145
|
+
/** Return the underlying String value or null if the string is blank */
|
|
146
|
+
toStringOrNull() {
|
|
147
|
+
if (this.isBlank()) {
|
|
148
|
+
return null;
|
|
149
|
+
} else {
|
|
150
|
+
return this.string;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/* Returns true if this string has only whitespace in it */
|
|
154
|
+
isBlank() {
|
|
155
|
+
return this.string.trim() == "";
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
var RichString_default = RichString;
|
|
159
|
+
|
|
160
|
+
// src/BaseCustomElement.js
|
|
161
|
+
var BaseCustomElement = class extends HTMLElement {
|
|
162
|
+
/** A {@link Logger} you can use to write warning messages. By default, these
|
|
163
|
+
* messages are not shown in the console. If you put `show-warnings` as an attribute on your
|
|
164
|
+
* element, warnings sent to this logger *are* shown.
|
|
165
|
+
*/
|
|
166
|
+
logger = Logger_default.forPrefix(null);
|
|
167
|
+
#_connectedCallbackCalled = false;
|
|
168
|
+
#_disconnectedCallbackCalled = false;
|
|
169
|
+
constructor() {
|
|
170
|
+
super();
|
|
171
|
+
this.logger = Logger_default.forPrefix(null);
|
|
172
|
+
}
|
|
173
|
+
/** You must call this to define the custom element. This is bascially
|
|
174
|
+
* a wrapper around `customElements.define`. It is recommended that you call
|
|
175
|
+
* this inside a `DOMContentLoaded` event, or after the page's HTML has been processed.
|
|
176
|
+
*
|
|
177
|
+
* @see external:CustomElementRegistry
|
|
178
|
+
*/
|
|
179
|
+
static define() {
|
|
180
|
+
if (!this.tagName) {
|
|
181
|
+
throw `To use BaseCustomElement, you must define the static member tagName to return your custom tag's name`;
|
|
182
|
+
}
|
|
183
|
+
customElements.define(this.tagName, this);
|
|
184
|
+
}
|
|
185
|
+
showWarningsChangedCallback({ oldValue, newValue }) {
|
|
186
|
+
let oldLogger;
|
|
187
|
+
if (!oldValue && newValue) {
|
|
188
|
+
oldLogger = this.logger;
|
|
189
|
+
}
|
|
190
|
+
let prefix = newValue == "" ? this.id : newValue;
|
|
191
|
+
if (!prefix) {
|
|
192
|
+
prefix = "UNKNOWN COMPONENT";
|
|
193
|
+
}
|
|
194
|
+
this.logger = Logger_default.forPrefix(prefix);
|
|
195
|
+
if (oldLogger) {
|
|
196
|
+
this.logger.dump(oldLogger);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Overrides the standard callback to allow subclasses to have a slightly easier API when responding
|
|
201
|
+
* to attribute changes. You can override this to use the custom element callback directly. Note that if
|
|
202
|
+
* you do, `show-warnings` will not have any affect and you probably don't need to bother using
|
|
203
|
+
* this class as your base class.
|
|
204
|
+
*
|
|
205
|
+
* This method will locate a per-attribute method and call that.
|
|
206
|
+
* Attribute names are assumed to be in kebab-case and are translated to camelCase to create a method name.
|
|
207
|
+
* That method is `«attributeInCamelCase»ChangedCallback`, so if your attribute is `hex-code`,
|
|
208
|
+
* a method named `hexCodeChangedCallback` in invoked. If no such method is defined, a
|
|
209
|
+
* warning is logged in the console, regardless of the `show-warnings` attribute.
|
|
210
|
+
*
|
|
211
|
+
* The method is invoked with `{oldValue,newValue,newValueAsBoolean}` - i.e. an object and not positional parameters. This
|
|
212
|
+
* means your implementation can omit any parameters it doesn't care about. `newValueAsBoolean` is not part of
|
|
213
|
+
* the custom element spec, but is provided as an unambiguous way to know if a boolean attribute was set or not. This is
|
|
214
|
+
* because if the value is set, it is likely to be the empty string, which is considered false by JavaScript. Cool.
|
|
215
|
+
*
|
|
216
|
+
* The return value of the method is ignored.
|
|
217
|
+
*
|
|
218
|
+
* After your method is called, if there is a method named `update`, it is called with no arguments.
|
|
219
|
+
*
|
|
220
|
+
* What this allows you to do is separate how you manage your element's attributes from how your logic
|
|
221
|
+
* is managed. For complex elements that take a lot of attributes, this can simplify your element's code without straying too far from the spec.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
*
|
|
225
|
+
* // If your element accepts the attribute `warning-message` that will be trimmed of whitespace
|
|
226
|
+
* // then placed into all `H1` tags inside the element, you can manage that like so:
|
|
227
|
+
* class MyElement extends BaseCustomElement {
|
|
228
|
+
* static tagName = "my-element"
|
|
229
|
+
* static observedAttributes = [
|
|
230
|
+
* "warning-message",
|
|
231
|
+
* ]
|
|
232
|
+
*
|
|
233
|
+
* // called by attributeChangedCallback when warning-message's value changes
|
|
234
|
+
* warningMessageChangedCallback({newValue}) {
|
|
235
|
+
* this.warningMessage = (newValue || "").trim()
|
|
236
|
+
* }
|
|
237
|
+
*
|
|
238
|
+
* // called after attributeChangedCallback calls warningMessageChangedCallback
|
|
239
|
+
* update() {
|
|
240
|
+
* this.querySelectorAll("h1").forEach( (e) => e.textContent = this.warningMessage )
|
|
241
|
+
* }
|
|
242
|
+
* }
|
|
243
|
+
*
|
|
244
|
+
*
|
|
245
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements}
|
|
246
|
+
*
|
|
247
|
+
*/
|
|
248
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
249
|
+
const callbackName = `${new RichString_default(name).camelize()}ChangedCallback`;
|
|
250
|
+
if (this[callbackName]) {
|
|
251
|
+
const newValueAsBoolean = newValue !== null;
|
|
252
|
+
this[callbackName]({ oldValue, newValue, newValueAsBoolean });
|
|
253
|
+
} else if (this.constructor.observedAttributes.indexOf(name) != -1) {
|
|
254
|
+
console.warn("Observing %s but no method named %s was found to handle it", name, callbackName);
|
|
255
|
+
}
|
|
256
|
+
this.__update();
|
|
257
|
+
}
|
|
258
|
+
/** Overrides the custom element callback to set internal flags allowing you to know if your
|
|
259
|
+
* element has been disconnected. When an element is disconnected, `update` is not called.
|
|
260
|
+
*
|
|
261
|
+
* If you want to add your own logic during disconnection, override {@link onDisconnected}.
|
|
262
|
+
*
|
|
263
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements}
|
|
264
|
+
*/
|
|
265
|
+
disconnectedCallback() {
|
|
266
|
+
this.#_disconnectedCallbackCalled = true;
|
|
267
|
+
this.onDisconnected();
|
|
268
|
+
}
|
|
269
|
+
/** Override this to add logic when `disconnectedCallback` is called by the browser. This will
|
|
270
|
+
* not be called if you overrode `disconnectedCallback`.
|
|
271
|
+
*/
|
|
272
|
+
onDisconnected() {
|
|
273
|
+
}
|
|
274
|
+
/** Overrides the custom element callback to set internal flags allowing you to know if your
|
|
275
|
+
* element has been connected. `update` is still called for elements that have not yet connected, however
|
|
276
|
+
* in practice your element will be connected before any codepath that calls `update` is called.
|
|
277
|
+
*
|
|
278
|
+
* To add logic when your element is connected, override {@link onConnected}
|
|
279
|
+
*
|
|
280
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements}
|
|
281
|
+
* @see BaseCustomElement#connectedCallbackCalled
|
|
282
|
+
*/
|
|
283
|
+
connectedCallback() {
|
|
284
|
+
this.#_connectedCallbackCalled = true;
|
|
285
|
+
this.onConnected();
|
|
286
|
+
this.__update();
|
|
287
|
+
}
|
|
288
|
+
/** Override this to add logic when `connectedCallback` is called by the browser. This will
|
|
289
|
+
* not be called if you overrode `connectedCallback`
|
|
290
|
+
*/
|
|
291
|
+
onConnected() {
|
|
292
|
+
}
|
|
293
|
+
/** Returns true if this element is connected and the connected callback has been called.
|
|
294
|
+
* This is different from `Node#isConnected`, which can return true before `connectedCallback` is called.
|
|
295
|
+
*/
|
|
296
|
+
get connectedCallbackCalled() {
|
|
297
|
+
return !!this.#_connectedCallbackCalled;
|
|
298
|
+
}
|
|
299
|
+
/** Override this to perform whatever logic your element must perform.
|
|
300
|
+
* Because changes to your element's attributes can happen at any time and in any order,
|
|
301
|
+
* you will want to consolidate all logic into one method—this one. You will also
|
|
302
|
+
* want to make sure that this method is idempotent and fault-tolerant.
|
|
303
|
+
*
|
|
304
|
+
* It is called by {@link BaseCustomElement#attributeChangedCallback|attributeChangedCallback} and {@link BaseCustomElement#connectedCallback|connectedCallback}, however
|
|
305
|
+
* it will *not* be called after the elment has been disconnected.
|
|
306
|
+
*
|
|
307
|
+
* That means that any event listeners, rendering, content manipulation, or other behavior should happen hear
|
|
308
|
+
* and it *must* be idempotent. In particular, any event listeners you attach must be done with care. Using
|
|
309
|
+
* anonymous functions could result in duplicate listeners.
|
|
310
|
+
*/
|
|
311
|
+
update() {
|
|
312
|
+
}
|
|
313
|
+
__update() {
|
|
314
|
+
if (this.#_disconnectedCallbackCalled) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.update();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
var BaseCustomElement_default = BaseCustomElement;
|
|
321
|
+
|
|
322
|
+
// src/I18nTranslation.js
|
|
323
|
+
var I18nTranslation = class extends BaseCustomElement_default {
|
|
324
|
+
static tagName = "brut-i18n-translation";
|
|
325
|
+
static observedAttributes = [
|
|
326
|
+
"show-warnings",
|
|
327
|
+
"key",
|
|
328
|
+
"value"
|
|
329
|
+
];
|
|
330
|
+
#key = null;
|
|
331
|
+
#value = "";
|
|
332
|
+
keyChangedCallback({ newValue }) {
|
|
333
|
+
this.#key = newValue;
|
|
334
|
+
}
|
|
335
|
+
valueChangedCallback({ newValue }) {
|
|
336
|
+
this.#value = newValue ? String(newValue) : "";
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Called by other JavaScript to get the translated string.
|
|
340
|
+
* @param {Object} interpolatedValues - Object where the keys are placeholders in the string for interpolation and the values are
|
|
341
|
+
* the values to replace. Placeholders not in the translated value are ignored. Missing placeholders won't cause an error, but the
|
|
342
|
+
* placeholder will be present verbatim in the translated string.
|
|
343
|
+
*
|
|
344
|
+
* @example
|
|
345
|
+
* const element = document.querySeletor("brut-i18n-translation[key='greeting']")
|
|
346
|
+
* if (element) {
|
|
347
|
+
* const translation = element.translation({ username: "Pat" })
|
|
348
|
+
* alert(translation) // Shows 'Hello Pat'
|
|
349
|
+
* }
|
|
350
|
+
*/
|
|
351
|
+
translation(interpolatedValues) {
|
|
352
|
+
return this.#value.replaceAll(/%\{([^}%]+)\}/g, (match, key) => {
|
|
353
|
+
if (interpolatedValues[key]) {
|
|
354
|
+
return interpolatedValues[key];
|
|
355
|
+
}
|
|
356
|
+
return match;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
var I18nTranslation_default = I18nTranslation;
|
|
361
|
+
|
|
362
|
+
// src/ConstraintViolationMessage.js
|
|
363
|
+
var ConstraintViolationMessage = class _ConstraintViolationMessage extends BaseCustomElement_default {
|
|
364
|
+
static tagName = "brut-constraint-violation-message";
|
|
365
|
+
static observedAttributes = [
|
|
366
|
+
"show-warnings",
|
|
367
|
+
"key",
|
|
368
|
+
"input-name"
|
|
369
|
+
];
|
|
370
|
+
static createElement(document2, attributes) {
|
|
371
|
+
const element = document2.createElement(_ConstraintViolationMessage.tagName);
|
|
372
|
+
element.setAttribute("key", this.i18nKey("fe", attributes.key));
|
|
373
|
+
element.setAttribute("input-name", attributes["input-name"]);
|
|
374
|
+
if (Object.hasOwn(attributes, "show-warnings")) {
|
|
375
|
+
element.setAttribute("show-warnings", attributes["show-warnings"]);
|
|
376
|
+
}
|
|
377
|
+
return element;
|
|
378
|
+
}
|
|
379
|
+
/** Returns the I18N key used for front-end constraint violations. This is useful
|
|
380
|
+
* if you need to construct a key and want to follow Brut's conventions on how they
|
|
381
|
+
* are managed.
|
|
382
|
+
*
|
|
383
|
+
* @param {...String} keyPath - parts of the path of the key after the namespace that Brut manages.
|
|
384
|
+
*/
|
|
385
|
+
static i18nKey(...keyPath) {
|
|
386
|
+
const path = ["general", "cv"];
|
|
387
|
+
return path.concat(keyPath).join(".");
|
|
388
|
+
}
|
|
389
|
+
#key = null;
|
|
390
|
+
#inputNameKey = null;
|
|
391
|
+
#thisFieldKey = this.#i18nKey("this_field");
|
|
392
|
+
keyChangedCallback({ newValue }) {
|
|
393
|
+
this.#key = newValue;
|
|
394
|
+
}
|
|
395
|
+
inputNameChangedCallback({ newValue }) {
|
|
396
|
+
this.#inputNameKey = this.#i18nKey("fe", "fieldNames", newValue);
|
|
397
|
+
}
|
|
398
|
+
update() {
|
|
399
|
+
if (!this.#key) {
|
|
400
|
+
this.logger.info("No key attribute, so can't do anything");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const selector = `${I18nTranslation_default.tagName}[key='${this.#key}']`;
|
|
404
|
+
const translation = document.querySelector(selector);
|
|
405
|
+
if (!translation) {
|
|
406
|
+
this.logger.info("Could not find translation based on selector '%s'", selector);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const fieldNameSelector = `${I18nTranslation_default.tagName}[key='${this.#inputNameKey}']`;
|
|
410
|
+
const thisFieldSelector = `${I18nTranslation_default.tagName}[key='${this.#thisFieldKey}']`;
|
|
411
|
+
let fieldNameTranslation = document.querySelector(fieldNameSelector);
|
|
412
|
+
if (!fieldNameTranslation) {
|
|
413
|
+
this.logger.info("Could not find translation for input/field name based on selector '%s'. Will try 'this field' fallback", fieldNameSelector);
|
|
414
|
+
fieldNameTranslation = document.querySelector(thisFieldSelector);
|
|
415
|
+
if (!fieldNameTranslation) {
|
|
416
|
+
this.logger.info("Could not find translation for 'this field' fallback key, based on selector '%s'", thisFieldSelector);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const fieldName = fieldNameTranslation ? fieldNameTranslation.translation() : "this field";
|
|
420
|
+
this.textContent = RichString_default.fromString(translation.translation({ field: fieldName })).capitalize().toString();
|
|
421
|
+
}
|
|
422
|
+
/** Helper that calls the static version */
|
|
423
|
+
#i18nKey(...keyPath) {
|
|
424
|
+
return this.constructor.i18nKey(...keyPath);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
var ConstraintViolationMessage_default = ConstraintViolationMessage;
|
|
428
|
+
|
|
429
|
+
// src/ConstraintViolationMessages.js
|
|
430
|
+
var ConstraintViolationMessages = class extends BaseCustomElement_default {
|
|
431
|
+
static tagName = "brut-constraint-violation-messages";
|
|
432
|
+
static observedAttributes = [
|
|
433
|
+
"show-warnings",
|
|
434
|
+
"server-side",
|
|
435
|
+
"input-name"
|
|
436
|
+
];
|
|
437
|
+
serverSideChangedCallback({ newValueAsBoolean }) {
|
|
438
|
+
}
|
|
439
|
+
inputNameChangedCallback({ newValue }) {
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Creates error messages based on the passed `ValidityState` and input name.
|
|
443
|
+
*
|
|
444
|
+
* This should be called as part of a Form validation event to provide a customized UX for
|
|
445
|
+
* the error messages, beyond what the browser would do by default. The keys used are the same
|
|
446
|
+
* as the attributes of a `ValidityState`, so for example, a range underflow would mean that `validity.rangeUnderflow` would return
|
|
447
|
+
* true. Thus, a `<brut-constraint-violation-message>` would be created with `key="general.cv.fe.rangeUnderflow"`.
|
|
448
|
+
*
|
|
449
|
+
* The `cv.fe` is hard-coded to be consistent with Brut's server-side translation management.
|
|
450
|
+
*
|
|
451
|
+
* @param {ValidityState} validityState - the return from an element's `validity` when it's found to have constraint violations.
|
|
452
|
+
* @param {String} inputName - the element's `name`.
|
|
453
|
+
*/
|
|
454
|
+
createMessages({ validityState, inputName }) {
|
|
455
|
+
const errors = this.#VALIDITY_STATE_ATTRIBUTES.filter((attribute) => validityState[attribute]);
|
|
456
|
+
this.clearMessages();
|
|
457
|
+
errors.forEach((key) => {
|
|
458
|
+
const options = {
|
|
459
|
+
key,
|
|
460
|
+
"input-name": inputName
|
|
461
|
+
};
|
|
462
|
+
const showWarnings = this.getAttribute("show-warnings");
|
|
463
|
+
if (showWarnings != null) {
|
|
464
|
+
options["show-warnings"] = showWarnings;
|
|
465
|
+
}
|
|
466
|
+
const element = ConstraintViolationMessage_default.createElement(document, options);
|
|
467
|
+
this.appendChild(element);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Clear all messages. Useful for when an input has become valid during a session.
|
|
472
|
+
*/
|
|
473
|
+
clearMessages() {
|
|
474
|
+
this.textContent = "";
|
|
475
|
+
}
|
|
476
|
+
#VALIDITY_STATE_ATTRIBUTES = [
|
|
477
|
+
"badInput",
|
|
478
|
+
"customError",
|
|
479
|
+
"patternMismatch",
|
|
480
|
+
"rangeOverflow",
|
|
481
|
+
"rangeUnderflow",
|
|
482
|
+
"stepMismatch",
|
|
483
|
+
"tooLong",
|
|
484
|
+
"tooShort",
|
|
485
|
+
"typeMismatch",
|
|
486
|
+
"valueMissing"
|
|
487
|
+
];
|
|
488
|
+
};
|
|
489
|
+
var ConstraintViolationMessages_default = ConstraintViolationMessages;
|
|
490
|
+
|
|
491
|
+
// src/AjaxSubmit.js
|
|
492
|
+
var AjaxSubmit = class extends BaseCustomElement_default {
|
|
493
|
+
static tagName = "brut-ajax-submit";
|
|
494
|
+
static observedAttributes = [
|
|
495
|
+
"show-warnings",
|
|
496
|
+
"requesting",
|
|
497
|
+
"submitted",
|
|
498
|
+
"submitted-lifetime",
|
|
499
|
+
"request-timeout",
|
|
500
|
+
"max-retry-attempts",
|
|
501
|
+
"log-request-errors"
|
|
502
|
+
];
|
|
503
|
+
#requestErrorLogger = () => {
|
|
504
|
+
};
|
|
505
|
+
#formSubmitDelay = 0;
|
|
506
|
+
#submittedLifetime = 2e3;
|
|
507
|
+
#requestTimeout = 5e3;
|
|
508
|
+
#maxRetryAttempts = 25;
|
|
509
|
+
submittedLifetimeChangedCallback({ newValue }) {
|
|
510
|
+
const newValueAsInt = parseInt(newValue);
|
|
511
|
+
if (isNaN(newValueAsInt)) {
|
|
512
|
+
throw `submitted-lifetime must be a number, not '${newValue}'`;
|
|
513
|
+
}
|
|
514
|
+
this.#submittedLifetime = newValueAsInt;
|
|
515
|
+
}
|
|
516
|
+
maxRetryAttemptsChangedCallback({ newValue }) {
|
|
517
|
+
const num = parseInt(newValue);
|
|
518
|
+
if (isNaN(num)) {
|
|
519
|
+
this.logger.warn(`max-retry-attempts '${newValue}' is not a number. Using 1 as a fallback`);
|
|
520
|
+
this.#maxRetryAttempts = 1;
|
|
521
|
+
} else {
|
|
522
|
+
this.#maxRetryAttempts = num;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
requestTimeoutChangedCallback({ newValue }) {
|
|
526
|
+
const newValueAsInt = parseInt(newValue);
|
|
527
|
+
if (isNaN(newValueAsInt)) {
|
|
528
|
+
throw `request-timeout must be a number, not '${newValue}'`;
|
|
529
|
+
}
|
|
530
|
+
this.#requestTimeout = newValueAsInt;
|
|
531
|
+
}
|
|
532
|
+
submittedChangedCallback({ newValueAsBoolean }) {
|
|
533
|
+
}
|
|
534
|
+
requestingChangedCallback({ newValueAsBoolean }) {
|
|
535
|
+
if (this.#button()) {
|
|
536
|
+
if (newValueAsBoolean) {
|
|
537
|
+
this.#button().setAttribute("disabled", true);
|
|
538
|
+
} else {
|
|
539
|
+
this.#button().removeAttribute("disabled", true);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
logRequestErrorsChangedCallback({ newValueAsBoolean }) {
|
|
544
|
+
if (newValueAsBoolean) {
|
|
545
|
+
this.#requestErrorLogger = console.warn;
|
|
546
|
+
this.#formSubmitDelay = 2e3;
|
|
547
|
+
} else {
|
|
548
|
+
this.#requestErrorLogger = () => {
|
|
549
|
+
};
|
|
550
|
+
this.#formSubmitDelay = 0;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
update() {
|
|
554
|
+
const button = this.#button();
|
|
555
|
+
if (!button) {
|
|
556
|
+
this.logger.info("Could not find a <button> to attach behavior to");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const form2 = button.form;
|
|
560
|
+
if (!form2) {
|
|
561
|
+
this.logger.info("%o did not have a form associated with it - cannot attach behavior", button);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
button.form.addEventListener("submit", this.#formSubmitted);
|
|
565
|
+
}
|
|
566
|
+
#formSubmitted = (event) => {
|
|
567
|
+
const submitter = event.submitter;
|
|
568
|
+
if (submitter == this.#button()) {
|
|
569
|
+
event.preventDefault();
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
this.#submitForm(event.target, now, 0);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
#submitForm(form2, firstSubmittedAt, numAttempts) {
|
|
575
|
+
const headers = new Headers();
|
|
576
|
+
headers.append("X-Requested-With", "XMLHttpRequest");
|
|
577
|
+
headers.append("Content-Type", "application/x-www-form-urlencoded");
|
|
578
|
+
const formData = new FormData(form2);
|
|
579
|
+
const urlSearchParams = new URLSearchParams(formData);
|
|
580
|
+
const timeoutSignal = AbortSignal.timeout(this.#requestTimeout);
|
|
581
|
+
const request = new Request(
|
|
582
|
+
form2.action,
|
|
583
|
+
{
|
|
584
|
+
headers,
|
|
585
|
+
method: form2.method,
|
|
586
|
+
body: urlSearchParams,
|
|
587
|
+
signal: timeoutSignal
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
if (numAttempts > this.#maxRetryAttempts) {
|
|
591
|
+
this.#requestErrorLogger("%d attempts. Giving up", numAttempts);
|
|
592
|
+
this.#submitFormThroughBrowser(form2);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
this.setAttribute("requesting", true);
|
|
596
|
+
fetch(request).then((response) => {
|
|
597
|
+
if (response.ok) {
|
|
598
|
+
this.removeAttribute("requesting");
|
|
599
|
+
this.setAttribute("submitted", true);
|
|
600
|
+
setTimeout(() => this.removeAttribute("submitted"), this.#submittedLifetime);
|
|
601
|
+
this.dispatchEvent(new CustomEvent("brut:submitok"));
|
|
602
|
+
} else {
|
|
603
|
+
let retry = false;
|
|
604
|
+
let resubmit2 = false;
|
|
605
|
+
if (Date.now() - firstSubmittedAt > this.#requestTimeout) {
|
|
606
|
+
this.#requestErrorLogger("Since initial button press %d, it's taken more than %d ms to get a response.", firstSubmittedAt, this.#requestTimeout);
|
|
607
|
+
retry = false;
|
|
608
|
+
resubmit2 = true;
|
|
609
|
+
} else {
|
|
610
|
+
const status = parseInt(response.status);
|
|
611
|
+
if (isNaN(status)) {
|
|
612
|
+
this.#requestErrorLogger("Got unparseable status: %d", response.status);
|
|
613
|
+
retry = false;
|
|
614
|
+
} else if (status >= 500) {
|
|
615
|
+
this.#requestErrorLogger("Got a %d, maybe retry will fix", status);
|
|
616
|
+
retry = true;
|
|
617
|
+
} else {
|
|
618
|
+
retry = false;
|
|
619
|
+
if (status == 422) {
|
|
620
|
+
this.#handleConstraintViolations(response);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (retry) {
|
|
625
|
+
this.#requestErrorLogger("Trying again (attempt %d)", numAttempts + 1);
|
|
626
|
+
setTimeout(() => this.#submitForm(form2, firstSubmittedAt, numAttempts + 1), numAttempts * 10);
|
|
627
|
+
} else if (resubmit2) {
|
|
628
|
+
this.#requestErrorLogger("'retry' was marked false, but resubmit is 'true', so submitting through browser");
|
|
629
|
+
this.#submitFormThroughBrowser(form2);
|
|
630
|
+
this.removeAttribute("requesting");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}).catch((error) => {
|
|
634
|
+
this.#requestErrorLogger("Got %o, which cannot be retried", error);
|
|
635
|
+
this.#submitFormThroughBrowser(form2);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
#button = () => {
|
|
639
|
+
return this.querySelector("button");
|
|
640
|
+
};
|
|
641
|
+
#submitFormThroughBrowser(form2) {
|
|
642
|
+
form2.removeEventListener("submit", this.#formSubmitted);
|
|
643
|
+
if (this.#formSubmitDelay > 0) {
|
|
644
|
+
console.log("Form submission has been delayed by %d ms in order to allow examining the log", this.#formSubmitDelay);
|
|
645
|
+
setTimeout(() => form2.requestSubmit(this.#button()), this.#formSubmitDelay);
|
|
646
|
+
} else {
|
|
647
|
+
form2.requestSubmit(this.#button());
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
#handleConstraintViolations(response) {
|
|
651
|
+
response.text().then((text) => {
|
|
652
|
+
try {
|
|
653
|
+
const inputsToMessages = ErrorMessagesForInput.mapInputsToErrorMessages(
|
|
654
|
+
this.#errorMessagesFromServer(text),
|
|
655
|
+
this.#requestErrorLogger
|
|
656
|
+
);
|
|
657
|
+
let inputToScrollToAfterReportingValidity;
|
|
658
|
+
for (const [inputName, { input, messagesElement, errorMessages }] of Object.entries(inputsToMessages)) {
|
|
659
|
+
if (!inputToScrollToAfterReportingValidity) {
|
|
660
|
+
inputToScrollToAfterReportingValidity = input;
|
|
661
|
+
}
|
|
662
|
+
messagesElement.clearMessages();
|
|
663
|
+
errorMessages.forEach((element) => messagesElement.appendChild(element));
|
|
664
|
+
this.#setCustomValidityThatClearsOnChange(input, errorMessages);
|
|
665
|
+
}
|
|
666
|
+
if (inputToScrollToAfterReportingValidity) {
|
|
667
|
+
inputToScrollToAfterReportingValidity.scrollIntoView();
|
|
668
|
+
}
|
|
669
|
+
resubmit = false;
|
|
670
|
+
this.removeAttribute("requesting");
|
|
671
|
+
this.dispatchEvent(new CustomEvent("brut:submitinvalid"));
|
|
672
|
+
} catch (e) {
|
|
673
|
+
this.#requestErrorLogger("While parsing %s, got %s", text, e);
|
|
674
|
+
resubmit = true;
|
|
675
|
+
}
|
|
676
|
+
if (resubmit) {
|
|
677
|
+
this.#submitFormThroughBrowser(form);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
#errorMessagesFromServer(text) {
|
|
682
|
+
const parser = new DOMParser();
|
|
683
|
+
const fragment = parser.parseFromString(text, "text/html");
|
|
684
|
+
return fragment.querySelectorAll(ConstraintViolationMessage_default.tagName);
|
|
685
|
+
}
|
|
686
|
+
#setCustomValidityThatClearsOnChange(input, errorMessages) {
|
|
687
|
+
input.setCustomValidity(errorMessages[0].textContent);
|
|
688
|
+
input.reportValidity();
|
|
689
|
+
input.addEventListener("change", () => input.setCustomValidity(""));
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
var ErrorMessagesForInput = class _ErrorMessagesForInput {
|
|
693
|
+
static mapInputsToErrorMessages(errorMessages, requestErrorLogger) {
|
|
694
|
+
const inputsToMessages = {};
|
|
695
|
+
Array.from(errorMessages).map((element) => {
|
|
696
|
+
return new _ErrorMessagesForInput({
|
|
697
|
+
element,
|
|
698
|
+
inputName: element.getAttribute("input-name"),
|
|
699
|
+
document
|
|
700
|
+
});
|
|
701
|
+
}).forEach((errorMessagesForInput) => {
|
|
702
|
+
if (errorMessagesForInput.allElementsFound()) {
|
|
703
|
+
if (!inputsToMessages[errorMessagesForInput.inputName]) {
|
|
704
|
+
inputsToMessages[errorMessagesForInput.inputName] = {
|
|
705
|
+
input: errorMessagesForInput.input,
|
|
706
|
+
messagesElement: errorMessagesForInput.messagesElement,
|
|
707
|
+
errorMessages: []
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
inputsToMessages[errorMessagesForInput.inputName].errorMessages.push(errorMessagesForInput.element);
|
|
711
|
+
} else {
|
|
712
|
+
requestErrorLogger("Server message %o could not be shown to the user: %s", errorMessagesForInput.element, errorMessagesForInput.reasonNotAllElementsFound());
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
return inputsToMessages;
|
|
716
|
+
}
|
|
717
|
+
constructor({ element, inputName, document: document2 }) {
|
|
718
|
+
this.element = element;
|
|
719
|
+
this.inputName = inputName;
|
|
720
|
+
if (this.inputName) {
|
|
721
|
+
const selector = `${ConstraintViolationMessages_default.tagName}[server-side][input-name='${this.inputName}']`;
|
|
722
|
+
this.messagesElement = document2.querySelector(selector);
|
|
723
|
+
if (this.messagesElement) {
|
|
724
|
+
this.closestForm = this.messagesElement.closest("form");
|
|
725
|
+
}
|
|
726
|
+
if (this.inputName && this.closestForm) {
|
|
727
|
+
this.input = this.closestForm.elements.namedItem(this.inputName);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
allElementsFound() {
|
|
732
|
+
return !!this.input;
|
|
733
|
+
}
|
|
734
|
+
reasonNotAllElementsFound() {
|
|
735
|
+
let reason;
|
|
736
|
+
if (this.inputName) {
|
|
737
|
+
if (this.messagesElement) {
|
|
738
|
+
if (this.closestForm) {
|
|
739
|
+
reason = `Form did not contain an input named ${this.inputName}`;
|
|
740
|
+
} else {
|
|
741
|
+
reason = `Could not find a form that contained the ${ConstraintViolationMessages_default.tagName} element`;
|
|
742
|
+
}
|
|
743
|
+
} else {
|
|
744
|
+
reason = `Could not find a ${ConstraintViolationMessages_default.tagName} element for ${this.inputName}`;
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
reason = "server message was missing an input-name";
|
|
748
|
+
}
|
|
749
|
+
return reason;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
var AjaxSubmit_default = AjaxSubmit;
|
|
753
|
+
|
|
754
|
+
// src/ConfirmationDialog.js
|
|
755
|
+
var ConfirmationDialog = class extends BaseCustomElement_default {
|
|
756
|
+
static tagName = "brut-confirmation-dialog";
|
|
757
|
+
static observedAttributes = [
|
|
758
|
+
"message",
|
|
759
|
+
"confirm-label",
|
|
760
|
+
"show-warnings"
|
|
761
|
+
];
|
|
762
|
+
#onClose = () => {
|
|
763
|
+
};
|
|
764
|
+
#message = new RichString_default("");
|
|
765
|
+
#confirmLabel = new RichString_default("OK");
|
|
766
|
+
constructor() {
|
|
767
|
+
super();
|
|
768
|
+
this.okListener = (event) => {
|
|
769
|
+
this.#closeDialog();
|
|
770
|
+
this.#onClose(true);
|
|
771
|
+
};
|
|
772
|
+
this.cancelListener = (event) => {
|
|
773
|
+
this.#closeDialog();
|
|
774
|
+
this.#onClose(false);
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
messageChangedCallback({ newValue }) {
|
|
778
|
+
this.#message = RichString_default.fromString(newValue);
|
|
779
|
+
}
|
|
780
|
+
confirmLabelChangedCallback({ newValue }) {
|
|
781
|
+
this.#confirmLabel = RichString_default.fromString(newValue);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Call this to show the dialog. When the dialog is closed, `onClose` is called with the result.
|
|
785
|
+
*
|
|
786
|
+
* @param {function} onClose - a function called with either `true` or `false`, if the dialog was confirmed or
|
|
787
|
+
* denied, respectively.
|
|
788
|
+
*
|
|
789
|
+
* @example
|
|
790
|
+
* dialog.showModal( (confirmed) => {
|
|
791
|
+
* if (confirmed) {
|
|
792
|
+
* form.submit()
|
|
793
|
+
* }
|
|
794
|
+
* else {
|
|
795
|
+
* // do nothing
|
|
796
|
+
* }
|
|
797
|
+
* })
|
|
798
|
+
*/
|
|
799
|
+
showModal(onClose) {
|
|
800
|
+
const dialog = this.#dialog;
|
|
801
|
+
if (dialog) {
|
|
802
|
+
this.#onClose = onClose || (() => {
|
|
803
|
+
});
|
|
804
|
+
dialog.showModal();
|
|
805
|
+
} else {
|
|
806
|
+
this.logger.warn("No <dialog> found to show");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
get #dialog() {
|
|
810
|
+
return this.querySelector("dialog");
|
|
811
|
+
}
|
|
812
|
+
#closeDialog() {
|
|
813
|
+
const dialog = this.#dialog;
|
|
814
|
+
if (dialog) {
|
|
815
|
+
dialog.close();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
update() {
|
|
819
|
+
const dialog = this.#dialog;
|
|
820
|
+
if (!dialog) {
|
|
821
|
+
this.logger.warn("Could not find a <dialog> - this custom element won't do anything");
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
this.#setMessage(dialog);
|
|
825
|
+
this.#setupButtons();
|
|
826
|
+
}
|
|
827
|
+
#setMessage(dialog) {
|
|
828
|
+
const h1 = dialog.querySelector("h1");
|
|
829
|
+
if (h1) {
|
|
830
|
+
if (this.#message.isBlank()) {
|
|
831
|
+
h1.textContent = null;
|
|
832
|
+
} else {
|
|
833
|
+
h1.textContent = this.#message.toString();
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
this.logger.warn("Dialog had no <h1>, so nowhere to put the message");
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
#setupButtons() {
|
|
840
|
+
const okButton = this.querySelector("button[value='ok']");
|
|
841
|
+
const cancelButton = this.querySelector("button[value='cancel']");
|
|
842
|
+
if (!okButton || !cancelButton) {
|
|
843
|
+
if (!okButton) {
|
|
844
|
+
this.logger.warn("no <button value='ok'> which is required for this dialog to work");
|
|
845
|
+
}
|
|
846
|
+
if (!cancelButton) {
|
|
847
|
+
this.logger.warn("no <button value='cancel'> which is required for this dialog to work");
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
okButton.textContent = this.#confirmLabel;
|
|
852
|
+
okButton.addEventListener("click", this.okListener);
|
|
853
|
+
cancelButton.addEventListener("click", this.cancelListener);
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
var ConfirmationDialog_default = ConfirmationDialog;
|
|
857
|
+
|
|
858
|
+
// src/ConfirmSubmit.js
|
|
859
|
+
var ConfirmSubmit = class extends BaseCustomElement_default {
|
|
860
|
+
static tagName = "brut-confirm-submit";
|
|
861
|
+
static observedAttributes = [
|
|
862
|
+
"message",
|
|
863
|
+
"dialog",
|
|
864
|
+
"show-warnings"
|
|
865
|
+
];
|
|
866
|
+
#message = new RichString_default("");
|
|
867
|
+
#confirming = false;
|
|
868
|
+
#dialogId = null;
|
|
869
|
+
messageChangedCallback({ newValue }) {
|
|
870
|
+
this.#message = new RichString_default(newValue || "");
|
|
871
|
+
}
|
|
872
|
+
dialogChangedCallback({ newValue }) {
|
|
873
|
+
this.#dialogId = RichString_default.fromString(newValue);
|
|
874
|
+
}
|
|
875
|
+
constructor() {
|
|
876
|
+
super();
|
|
877
|
+
this.onClick = (event) => {
|
|
878
|
+
if (this.#confirming) {
|
|
879
|
+
this.#confirming = false;
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (this.#message.isBlank()) {
|
|
883
|
+
this.logger.warn("No message provided, so cannot confirm");
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const dialog = this.#findDialog();
|
|
887
|
+
if (dialog) {
|
|
888
|
+
event.preventDefault();
|
|
889
|
+
dialog.setAttribute("message", this.#message.toString());
|
|
890
|
+
const buttonLabel = event.target.getAttribute("aria-label") || event.target.textContent;
|
|
891
|
+
dialog.setAttribute("confirm-label", buttonLabel);
|
|
892
|
+
this.#confirming = true;
|
|
893
|
+
dialog.showModal((confirm) => {
|
|
894
|
+
if (confirm) {
|
|
895
|
+
event.target.click();
|
|
896
|
+
} else {
|
|
897
|
+
this.#confirming = false;
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
} else {
|
|
901
|
+
const result = window.confirm(this.#message);
|
|
902
|
+
if (!result) {
|
|
903
|
+
event.preventDefault();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
#findDialog() {
|
|
909
|
+
if (this.#dialogId) {
|
|
910
|
+
const dialog = document.getElementById(this.#dialogId);
|
|
911
|
+
if (dialog) {
|
|
912
|
+
if (dialog.tagName.toLowerCase() != ConfirmationDialog_default.tagName) {
|
|
913
|
+
throw `${this.#dialogId} is the id of a '${dialog.tagName}', not '${ConfirmationDialog_default.tagName}'`;
|
|
914
|
+
}
|
|
915
|
+
return dialog;
|
|
916
|
+
}
|
|
917
|
+
this.logger.warn(`No dialog with id ${this.#dialogId} - using window.confirm as a fallback`);
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
const dialogs = document.querySelectorAll(ConfirmationDialog_default.tagName);
|
|
921
|
+
if (dialogs.length == 1) {
|
|
922
|
+
return dialogs[0];
|
|
923
|
+
}
|
|
924
|
+
if (dialogs.length == 0) {
|
|
925
|
+
this.logger.warn(`No '${ConfirmationDialog_default.tagName}' found in document - using window.confirm as a fallback`);
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
throw `Found ${dialogs.length} '${ConfirmationDialog_default.tagName}' elements. Not sure which to use. Remove all but one or specify the 'dialog' attribute on this element to specify which one to use`;
|
|
929
|
+
}
|
|
930
|
+
update() {
|
|
931
|
+
this.querySelectorAll("button").forEach((button) => button.addEventListener("click", this.onClick));
|
|
932
|
+
this.querySelectorAll("input[type=submit]").forEach((button) => button.addEventListener("click", this.onClick));
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
var ConfirmSubmit_default = ConfirmSubmit;
|
|
936
|
+
|
|
937
|
+
// src/Form.js
|
|
938
|
+
var Form = class extends BaseCustomElement_default {
|
|
939
|
+
static tagName = "brut-form";
|
|
940
|
+
static observedAttributes = [
|
|
941
|
+
"show-warnings"
|
|
942
|
+
];
|
|
943
|
+
#markFormSubmitted = (event) => {
|
|
944
|
+
const form2 = event.target.form;
|
|
945
|
+
if (!form2) {
|
|
946
|
+
this.logger.warn("%o had no form", event.target);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
form2.dataset["submitted"] = true;
|
|
950
|
+
};
|
|
951
|
+
#updateValidity = (event) => {
|
|
952
|
+
this.#updateErrorMessages(event);
|
|
953
|
+
};
|
|
954
|
+
#sendValid = () => {
|
|
955
|
+
this.dispatchEvent(new CustomEvent("brut:valid"));
|
|
956
|
+
};
|
|
957
|
+
#sendInvalid = () => {
|
|
958
|
+
this.dispatchEvent(new CustomEvent("brut:invalid"));
|
|
959
|
+
};
|
|
960
|
+
update() {
|
|
961
|
+
const forms = this.querySelectorAll("form");
|
|
962
|
+
if (forms.length == 0) {
|
|
963
|
+
this.logger.warn("Didn't find any forms. Ignoring");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
forms.forEach((form2) => {
|
|
967
|
+
Array.from(form2.elements).forEach((formElement) => {
|
|
968
|
+
formElement.addEventListener("invalid", this.#updateValidity);
|
|
969
|
+
formElement.addEventListener("invalid", this.#markFormSubmitted);
|
|
970
|
+
formElement.addEventListener("input", this.#updateValidity);
|
|
971
|
+
});
|
|
972
|
+
form2.querySelectorAll(AjaxSubmit_default.tagName).forEach((ajaxSubmits) => {
|
|
973
|
+
ajaxSubmits.addEventListener("brut:submitok", this.#sendValid);
|
|
974
|
+
ajaxSubmits.addEventListener("brut:submitinvalid", this.#sendInvalid);
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
#updateErrorMessages(event) {
|
|
979
|
+
const element = event.target;
|
|
980
|
+
const selector = `${ConstraintViolationMessages_default.tagName}:not([server-side])`;
|
|
981
|
+
const errorLabels = element.parentNode.querySelectorAll(selector);
|
|
982
|
+
if (errorLabels.length == 0) {
|
|
983
|
+
this.logger.warn(`Did not find any elements matching ${selector}, so no error messages will be shown`);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
let anyErrors = false;
|
|
987
|
+
errorLabels.forEach((errorLabel) => {
|
|
988
|
+
if (element.validity.valid) {
|
|
989
|
+
errorLabel.clearMessages();
|
|
990
|
+
} else {
|
|
991
|
+
anyErrors = true;
|
|
992
|
+
errorLabel.createMessages({
|
|
993
|
+
validityState: element.validity,
|
|
994
|
+
inputName: element.name
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
if (anyErrors) {
|
|
999
|
+
this.#sendInvalid();
|
|
1000
|
+
} else {
|
|
1001
|
+
this.#sendValid();
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
1005
|
+
var Form_default = Form;
|
|
1006
|
+
|
|
1007
|
+
// src/Message.js
|
|
1008
|
+
var Message = class _Message extends BaseCustomElement_default {
|
|
1009
|
+
static tagName = "brut-message";
|
|
1010
|
+
static observedAttributes = [
|
|
1011
|
+
"show-warnings",
|
|
1012
|
+
"key"
|
|
1013
|
+
];
|
|
1014
|
+
static createElement(document2, attributes) {
|
|
1015
|
+
const element = document2.createElement(_Message.tagName);
|
|
1016
|
+
element.setAttribute("key", attributes.key);
|
|
1017
|
+
element.setAttribute("show-warnings", attributes["show-warnings"]);
|
|
1018
|
+
return element;
|
|
1019
|
+
}
|
|
1020
|
+
#key = null;
|
|
1021
|
+
keyChangedCallback({ newValue }) {
|
|
1022
|
+
this.#key = newValue;
|
|
1023
|
+
}
|
|
1024
|
+
update() {
|
|
1025
|
+
if (!this.#key) {
|
|
1026
|
+
this.logger.info("No key attribute, so can't do anything");
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const selector = `${I18nTranslation_default.tagName}[key='${this.#key}']`;
|
|
1030
|
+
const translation = document.querySelector(selector);
|
|
1031
|
+
if (!translation) {
|
|
1032
|
+
this.logger.info("Could not find translation based on selector '%s'", selector);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
this.textContent = RichString_default.fromString(translation.translation()).capitalize().toString();
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
var Message_default = Message;
|
|
1039
|
+
|
|
1040
|
+
// src/Tabs.js
|
|
1041
|
+
var Tabs = class extends BaseCustomElement_default {
|
|
1042
|
+
static tagName = "brut-tabs";
|
|
1043
|
+
static observedAttributes = [
|
|
1044
|
+
"tab-selection-pushes-and-restores-state",
|
|
1045
|
+
"show-warnings"
|
|
1046
|
+
];
|
|
1047
|
+
tabSelectionPushesAndRestoresStateChangedCallback({ newValue, oldValue }) {
|
|
1048
|
+
this.#pushAndRestoreTabState = newValue != null;
|
|
1049
|
+
}
|
|
1050
|
+
update() {
|
|
1051
|
+
this.#tabs().forEach((tab) => {
|
|
1052
|
+
tab.addEventListener("click", this.#tabClicked);
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
#pushAndRestoreTabState = false;
|
|
1056
|
+
#tabClicked = (event) => {
|
|
1057
|
+
event.preventDefault();
|
|
1058
|
+
this.#setTabAsSelected(event.target);
|
|
1059
|
+
event.preventDefault();
|
|
1060
|
+
};
|
|
1061
|
+
#reloadTab = (event) => {
|
|
1062
|
+
const tab = document.getElementById(event.state.tabId);
|
|
1063
|
+
if (tab) {
|
|
1064
|
+
this.#setTabAsSelected(tab, { skipPushState: true });
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
#setTabAsSelected(selectedTab, { skipPushState = false } = {}) {
|
|
1068
|
+
this.#tabs().forEach((tab) => {
|
|
1069
|
+
const tabPanels = [];
|
|
1070
|
+
const ariaControls = tab.getAttribute("aria-controls");
|
|
1071
|
+
if (ariaControls) {
|
|
1072
|
+
ariaControls.split(/\s+/).forEach((id) => {
|
|
1073
|
+
const panel = document.getElementById(id);
|
|
1074
|
+
if (panel) {
|
|
1075
|
+
tabPanels.push(panel);
|
|
1076
|
+
} else {
|
|
1077
|
+
this.logger.warn("Tab %o references panel with id %s, but no such element exists with that id", tab, id);
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
if (tab == selectedTab) {
|
|
1082
|
+
tab.setAttribute("aria-selected", true);
|
|
1083
|
+
tab.setAttribute("tabindex", "0");
|
|
1084
|
+
tabPanels.forEach((panel) => panel.removeAttribute("hidden"));
|
|
1085
|
+
if (this.#pushAndRestoreTabState && !skipPushState) {
|
|
1086
|
+
let href = tab.getAttribute("href") || "";
|
|
1087
|
+
if (href.startsWith("?")) {
|
|
1088
|
+
let hrefQueryString = href.slice(1);
|
|
1089
|
+
const anchorIndex = hrefQueryString.indexOf("#");
|
|
1090
|
+
if (anchorIndex != -1) {
|
|
1091
|
+
hrefQueryString = hrefQueryString.slice(-1 * (hrefQueryString.length - anchorIndex - 1));
|
|
1092
|
+
}
|
|
1093
|
+
const currentQuery = new URLSearchParams(window.location.search);
|
|
1094
|
+
const hrefQuery = new URLSearchParams(hrefQueryString);
|
|
1095
|
+
hrefQuery.forEach((value, key) => {
|
|
1096
|
+
currentQuery.set(key, value);
|
|
1097
|
+
});
|
|
1098
|
+
href = "?" + currentQuery.toString() + (anchorIndex == -1 ? "" : hrefQueryString.slice(anchorIndex));
|
|
1099
|
+
}
|
|
1100
|
+
window.history.pushState({ tabId: tab.id }, "", href);
|
|
1101
|
+
window.addEventListener("popstate", this.#reloadTab);
|
|
1102
|
+
}
|
|
1103
|
+
this.dispatchEvent(new CustomEvent("brut:tabselected", { tabId: tab.id }));
|
|
1104
|
+
} else {
|
|
1105
|
+
tab.setAttribute("aria-selected", false);
|
|
1106
|
+
tab.setAttribute("tabindex", "-1");
|
|
1107
|
+
tabPanels.forEach((panel) => panel.setAttribute("hidden", true));
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
#tabs() {
|
|
1112
|
+
const tabs = [];
|
|
1113
|
+
this.querySelectorAll("[role=tab]").forEach((tab) => {
|
|
1114
|
+
if (tab.tagName.toLowerCase() == "a" || tab.tagName.toLowerCase() == "button") {
|
|
1115
|
+
tabs.push(tab);
|
|
1116
|
+
} else {
|
|
1117
|
+
this.logger.warn("An element with tag %s was assigned role=tab, and %s doesn't work that way. Use an <a> or a <button>", tab.tagName, this.constructor.name);
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
return tabs;
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
var Tabs_default = Tabs;
|
|
1124
|
+
|
|
1125
|
+
// src/LocaleDetection.js
|
|
1126
|
+
var LocaleDetection = class extends BaseCustomElement_default {
|
|
1127
|
+
static tagName = "brut-locale-detection";
|
|
1128
|
+
static observedAttributes = [
|
|
1129
|
+
"locale-from-server",
|
|
1130
|
+
"timezone-from-server",
|
|
1131
|
+
"url",
|
|
1132
|
+
"timeout-before-ping-ms",
|
|
1133
|
+
"show-warnings"
|
|
1134
|
+
];
|
|
1135
|
+
#localeFromServer = null;
|
|
1136
|
+
#timezoneFromServer = null;
|
|
1137
|
+
#reportingURL = null;
|
|
1138
|
+
#timeoutBeforePing = 1e3;
|
|
1139
|
+
#serverContacted = false;
|
|
1140
|
+
localeFromServerChangedCallback({ newValue }) {
|
|
1141
|
+
this.#localeFromServer = newValue;
|
|
1142
|
+
}
|
|
1143
|
+
timezoneFromServerChangedCallback({ newValue }) {
|
|
1144
|
+
this.#timezoneFromServer = newValue;
|
|
1145
|
+
}
|
|
1146
|
+
urlChangedCallback({ newValue }) {
|
|
1147
|
+
if (this.#serverContacted) {
|
|
1148
|
+
this.#serverContacted = false;
|
|
1149
|
+
}
|
|
1150
|
+
this.#reportingURL = newValue;
|
|
1151
|
+
}
|
|
1152
|
+
timeoutBeforePingMsChangedCallback({ newValue }) {
|
|
1153
|
+
this.#timeoutBeforePing = newValue;
|
|
1154
|
+
}
|
|
1155
|
+
update() {
|
|
1156
|
+
if (this.#timeoutBeforePing == 0) {
|
|
1157
|
+
this.#pingServerWithLocaleInfo();
|
|
1158
|
+
} else {
|
|
1159
|
+
setTimeout(this.#pingServerWithLocaleInfo.bind(this), this.#timeoutBeforePing);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
#pingServerWithLocaleInfo() {
|
|
1163
|
+
if (!this.#reportingURL) {
|
|
1164
|
+
this.logger.info("no url= set, so nowhere to report to");
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (this.#localeFromServer && this.#timezoneFromServer) {
|
|
1168
|
+
this.logger.info("locale and timezone both set, not contacting server");
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (this.#serverContacted) {
|
|
1172
|
+
this.logger.info("server has already been contacted at the given url, not doing it again");
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
this.#serverContacted = true;
|
|
1176
|
+
const formatOptions = Intl.DateTimeFormat().resolvedOptions();
|
|
1177
|
+
const request = new Request(
|
|
1178
|
+
this.#reportingURL,
|
|
1179
|
+
{
|
|
1180
|
+
headers: {
|
|
1181
|
+
"Content-Type": "application/json"
|
|
1182
|
+
},
|
|
1183
|
+
method: "POST",
|
|
1184
|
+
body: JSON.stringify({
|
|
1185
|
+
locale: formatOptions.locale,
|
|
1186
|
+
timeZone: formatOptions.timeZone
|
|
1187
|
+
})
|
|
1188
|
+
}
|
|
1189
|
+
);
|
|
1190
|
+
window.fetch(request).then((response) => {
|
|
1191
|
+
if (response.ok) {
|
|
1192
|
+
this.logger.info("Server gave us the OK");
|
|
1193
|
+
} else {
|
|
1194
|
+
console.warn(response);
|
|
1195
|
+
}
|
|
1196
|
+
}).catch((e) => {
|
|
1197
|
+
console.warn(e);
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
var LocaleDetection_default = LocaleDetection;
|
|
1202
|
+
|
|
1203
|
+
// src/Autosubmit.js
|
|
1204
|
+
var Autosubmit = class extends BaseCustomElement_default {
|
|
1205
|
+
static tagName = "brut-autosubmit";
|
|
1206
|
+
static observedAttributes = [
|
|
1207
|
+
"show-warnings"
|
|
1208
|
+
];
|
|
1209
|
+
#submitForm = (event) => {
|
|
1210
|
+
const form2 = this.closest("form");
|
|
1211
|
+
if (!form2) {
|
|
1212
|
+
this.logger.info("No longer a form containing this element");
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
if (event.target.form != form2) {
|
|
1216
|
+
this.logger.info("Event target %o's form is not the form that contains this element", event.target);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
form2.requestSubmit();
|
|
1220
|
+
};
|
|
1221
|
+
update() {
|
|
1222
|
+
const form2 = this.closest("form");
|
|
1223
|
+
if (!form2) {
|
|
1224
|
+
this.logger.info("No form containing this element - nothing to autosubmit");
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const inputs = Array.from(this.querySelectorAll("input, textarea, select")).filter((element) => {
|
|
1228
|
+
return element.form == form2;
|
|
1229
|
+
});
|
|
1230
|
+
if (inputs.length == 0) {
|
|
1231
|
+
this.logger.info("No input, textarea, or select inside this element belongs to the form containing this element");
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
inputs.forEach((input) => {
|
|
1235
|
+
input.addEventListener("change", this.#submitForm);
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
var Autosubmit_default = Autosubmit;
|
|
1240
|
+
|
|
1241
|
+
// src/index.js
|
|
1242
|
+
var BrutCustomElements = class {
|
|
1243
|
+
static elementClasses = [];
|
|
1244
|
+
static define() {
|
|
1245
|
+
this.elementClasses.forEach((e) => {
|
|
1246
|
+
e.define();
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
static addElementClasses(...classes) {
|
|
1250
|
+
this.elementClasses.push(...classes);
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
BrutCustomElements.addElementClasses(
|
|
1254
|
+
// Ordering is important here - TBD how to make sure these are created in order
|
|
1255
|
+
I18nTranslation_default,
|
|
1256
|
+
Message_default,
|
|
1257
|
+
ConfirmSubmit_default,
|
|
1258
|
+
ConfirmationDialog_default,
|
|
1259
|
+
ConstraintViolationMessages_default,
|
|
1260
|
+
Form_default,
|
|
1261
|
+
AjaxSubmit_default,
|
|
1262
|
+
ConstraintViolationMessage_default,
|
|
1263
|
+
Tabs_default,
|
|
1264
|
+
LocaleDetection_default,
|
|
1265
|
+
Autosubmit_default
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
// src/appForTestingOnly.js
|
|
1269
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
1270
|
+
BrutCustomElements.define();
|
|
1271
|
+
if (!HTMLDialogElement.prototype.showModal) {
|
|
1272
|
+
HTMLDialogElement.prototype.showModal = function() {
|
|
1273
|
+
this.open = true;
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
if (!HTMLDialogElement.prototype.close) {
|
|
1277
|
+
HTMLDialogElement.prototype.close = function(returnValue) {
|
|
1278
|
+
this.open = false;
|
|
1279
|
+
this.returnValue = returnValue;
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
})();
|
|
1284
|
+
//# sourceMappingURL=bundle.js.map
|