@optionfactory/ful 0.24.0 → 0.26.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/dist/ful.mjs CHANGED
@@ -70,8 +70,9 @@ class Hex {
70
70
  }
71
71
  }
72
72
 
73
- class Observable {
74
- constructor() {
73
+ const Observable = (SuperClass) => class extends SuperClass {
74
+ constructor(...args) {
75
+ super(...args);
75
76
  this.listeners = {};
76
77
  }
77
78
  fireSync(event, data, initialAcc) {
@@ -98,16 +99,8 @@ class Observable {
98
99
  const listeners = this.listeners[event] || [];
99
100
  const idx = listeners.indexOf(listener);
100
101
  return idx === -1 ? [] : listeners.splice(idx, 1);
101
- }
102
- static mixin(self) {
103
- self.listeners = {};
104
- self.fireSync = Observable.prototype.fireSync;
105
- self.fire = Observable.prototype.fire;
106
- self.on = Observable.prototype.on;
107
- self.un = Observable.prototype.un;
108
- }
109
-
110
- }
102
+ }
103
+ };
111
104
 
112
105
  class ContextInterceptor {
113
106
  constructor() {
@@ -276,775 +269,813 @@ function jsonPatch(body, headers){
276
269
  return jsonRequest('PATCH', body, headers);
277
270
  }
278
271
 
279
- /* global Infinity, CSS */
280
-
281
- class CustomElements {
282
- static id = 0;
283
- static uid(prefix) {
284
- return `${prefix}-${++CustomElements.id}`;
285
- }
286
- static forwardAttributes(from, to, except) {
287
- from.getAttributeNames().filter(a => except.indexOf(a) === -1)
288
- .filter(a => a[0] === '@')
289
- .forEach(a => {
290
- if (a === '@class') {
291
- to.classList.add(...from.getAttribute("@class").split(" ").filter(a => a.length));
292
- return;
293
- }
294
- to.setAttribute(a.substring(1), from.getAttribute(a));
295
- });
296
- }
297
- static extractSlots(el) {
298
- const slotted = Object.fromEntries([...el.querySelectorAll("[slot]")].map(el => {
299
- el.parentElement.removeChild(el);
300
- const slot = el.getAttribute("slot");
301
- el.removeAttribute("slot");
302
- return [slot, el];
303
- }));
304
- slotted.default = new DocumentFragment();
305
- slotted.default.append(...el.childNodes);
306
- return slotted;
272
+ class Storage {
273
+ constructor(prefix, storage) {
274
+ this.prefix = prefix;
275
+ this.storage = storage;
307
276
  }
308
- static labelAndInputGroup(id, name, isFloating, slotted) {
309
- if (isFloating) {
310
- /**
311
- * <div class="input-group has-validation">
312
- * <span data-tpl-if="slotted.before" class="input-group-text">{{{{ slotted.before }}}}</span>
313
- * <div class="form-floating">
314
- * {{{{ slotted.input }}}}
315
- * <label data-tpl-for="name" class="form-label">{{{{ slotted.default }}}}</label>
316
- * </div>
317
- * <span data-tpl-if="slotted.after" class="input-group-text">{{{{ slotted.after }}}}</span>
318
- * <ful-field-error data-tpl-field="name"></ful-field-error>
319
- * </div>
320
- */
321
- const label = document.createElement("label");
322
- label.setAttribute("for", id);
323
- label.classList.add('form-label');
324
- label.append(slotted.default);
325
-
326
- const ff = document.createElement('div');
327
- ff.classList.add("form-floating");
328
- ff.append(slotted.input, label);
329
-
330
- const ffe = document.createElement('ful-field-error');
331
- ffe.setAttribute("field", name);
332
-
333
- const ig = document.createElement("div");
334
- ig.classList.add('input-group', 'has-validtion');
335
-
336
- if (slotted.before) {
337
- ig.append(slotted.before);
338
- } else if (slotted.ibefore) {
339
- const igt = document.createElement('div');
340
- igt.classList.add('input-group-text');
341
- igt.append(slotted.ibefore);
342
- ig.append(igt);
343
- }
344
- ig.append(ff);
345
- if (slotted.after) {
346
- ig.append(slotted.after);
347
- } else if (slotted.iafter) {
348
- const igt = document.createElement('div');
349
- igt.classList.add('input-group-text');
350
- igt.append(slotted.iafter);
351
- ig.append(igt);
352
- }
353
- ig.append(ffe);
354
- return ig;
355
- }
356
- /**
357
- <label data-tpl-for="name" class="form-label">{{{{ slotted.default }}}}</label>
358
- <div class="input-group has-validation">
359
- <span data-tpl-if="slotted.before" class="input-group-text">{{{{ slotted.before }}}}</span>
360
- {{{{ slotted.input }}}}
361
- <span data-tpl-if="slotted.after" class="input-group-text">{{{{ slotted.after }}}}</span>
362
- <ful-field-error data-tpl-field="name"></ful-field-error>
363
- </div>
364
- */
365
-
366
- const label = document.createElement("label");
367
- label.setAttribute("for", name);
368
- label.classList.add('form-label');
369
- label.append(slotted.default);
370
-
371
- const ffe = document.createElement('ful-field-error');
372
- ffe.setAttribute("field", name);
373
-
374
- const ig = document.createElement("div");
375
- ig.classList.add('input-group', 'has-validation');
376
-
377
- if (slotted.before) {
378
- ig.append(slotted.before);
379
- } else if (slotted.ibefore) {
380
- const igt = document.createElement('div');
381
- igt.classList.add('input-group-text');
382
- igt.append(slotted.ibefore);
383
- ig.append(igt);
384
- }
385
- ig.append(slotted.input);
386
- if (slotted.after) {
387
- ig.append(slotted.after);
388
- } else if (slotted.iafter) {
389
- const igt = document.createElement('div');
390
- igt.classList.add('input-group-text');
391
- igt.append(slotted.iafter);
392
- ig.append(igt);
393
- }
394
- ig.append(ffe);
395
-
396
- const fragment = new DocumentFragment();
397
- fragment.append(label, ig);
398
- return fragment;
277
+ save(k, v) {
278
+ this.storage.setItem(`${this.prefix}-${k}`, JSON.stringify(v));
399
279
  }
400
-
401
- }
402
-
403
-
404
- class FieldError extends HTMLElement {
405
- constructor() {
406
- super();
280
+ load(k) {
281
+ const got = this.storage.getItem(`${this.prefix}-${k}`);
282
+ return got === undefined ? undefined : JSON.parse(got);
407
283
  }
408
- connectedCallback() {
409
- this.classList.add('invalid-feedback');
284
+ remove(k) {
285
+ this.storage.removeItem(`${this.prefix}-${k}`);
410
286
  }
411
- static configure() {
412
- customElements.define('ful-field-error', FieldError);
287
+ pop(k) {
288
+ const decoded = this.load(k);
289
+ this.remove(k);
290
+ return decoded;
413
291
  }
414
292
  }
415
293
 
416
- class Errors extends HTMLElement {
417
- constructor() {
418
- super();
419
- }
420
- connectedCallback() {
421
- this.classList.add('alert', 'alert-danger', 'd-none');
422
- }
423
- static configure() {
424
- customElements.define('ful-errors', Errors);
294
+ class LocalStorage extends Storage {
295
+ constructor(prefix) {
296
+ super(prefix, localStorage);
425
297
  }
426
-
427
298
  }
428
299
 
429
- class Spinner extends HTMLElement {
430
- constructor() {
431
- super();
432
- }
433
- connectedCallback() {
434
- this.classList.add('spinner-border', 'spinner-border-sm', 'd-none');
435
- this.setAttribute("aria-hidden", "true");
436
- }
437
- show() {
438
- this.classList.remove("d-none");
439
- }
440
- hide() {
441
- this.classList.add("d-none");
442
- }
443
- static configure() {
444
- customElements.define('ful-spinner', Spinner);
300
+ class SessionStorage extends Storage {
301
+ constructor(prefix) {
302
+ super(prefix, sessionStorage);
445
303
  }
446
304
  }
447
305
 
448
-
449
-
450
- class Input extends HTMLElement {
451
- constructor() {
452
- super();
453
- const id = CustomElements.uid('ful-input');
454
- const name = this.getAttribute('@name');
455
- const floating = this.hasAttribute('@floating');
456
- const slotted = CustomElements.extractSlots(this);
457
- slotted.input = slotted.input || (() => {
458
- const el = document.createElement("input");
459
- el.classList.add("form-control");
460
- return el;
461
- })();
462
- CustomElements.forwardAttributes(this, slotted.input, ['@floating']);
463
- const attrIfMissing = (el, k, v) => !el.hasAttribute(k) && el.setAttribute(k, v);
464
- attrIfMissing(slotted.input, "name", id);
465
- attrIfMissing(slotted.input, "id", id);
466
- attrIfMissing(slotted.input, "type", "text");
467
- attrIfMissing(slotted.input, "placeholder", " ");
468
- this.innerHTML = '';
469
- this.append(CustomElements.labelAndInputGroup(id, name || id, floating, slotted));
306
+ class VersionedStorage {
307
+ constructor(storage, key, dataSupplier){
308
+ this.storage = storage;
309
+ this.key = key;
310
+ this.dataSupplier = dataSupplier;
311
+ this.cache = null;
312
+
470
313
  }
471
- static configure() {
472
- customElements.define('ful-input', Input);
314
+ async load(revision){
315
+ const saved = this.storage.load(this.key);
316
+ if (!!saved && saved.revision === revision) {
317
+ this.cache = saved.value;
318
+ return;
319
+ }
320
+ const freshData = await this.dataSupplier(revision, this.key);
321
+ this.storage.save(this.key, {
322
+ revision: revision,
323
+ value: freshData
324
+ });
325
+ this.cache = freshData;
326
+ }
327
+ data(){
328
+ return this.cache;
473
329
  }
474
330
  }
475
331
 
476
-
477
-
478
- /**
479
- * <script src="tom-select.complete.js"></script>
480
- * <link href="tom-select.bootstrap5.css" rel="stylesheet" />
481
- */
482
- class Select extends HTMLElement {
483
- constructor(tsConfig) {
484
- super();
485
- Observable.mixin(this);
486
- const id = CustomElements.uid('ful-select');
487
- const name = this.getAttribute('@name');
488
- const floating = this.hasAttribute('@floating');
489
- const remote = this.hasAttribute('@remote');
490
- const slotted = CustomElements.extractSlots(this);
491
- slotted.input = slotted.input || (() => {
492
- return document.createElement("select");
493
- })();
494
- CustomElements.forwardAttributes(this, slotted.input, ['@floating', '@remote']);
495
- const attrIfMissing = (el, k, v) => !el.hasAttribute(k) && el.setAttribute(k, v);
496
- attrIfMissing(slotted.input, "name", id);
497
- attrIfMissing(slotted.input, "id", id);
498
- attrIfMissing(slotted.input, "placeholder", " ");
499
- this.innerHTML = '';
500
- this.append(CustomElements.labelAndInputGroup(id, name || id, floating, slotted));
501
- this.loaded = !remote;
502
- this.ts = new TomSelect(slotted.input, Object.assign(remote ? {
503
- preload: 'focus',
504
- load: async (query, callback) => {
505
- if (this.loaded) {
506
- callback();
507
- return;
508
- }
509
- const data = await this.fire('load', query, []);
510
- this.loaded = true;
511
- callback(data);
512
- }
513
- } : {}, tsConfig));
514
- slotted.input.setValue = this.setValue.bind(this);
515
- slotted.input.getValue = this.getValue.bind(this);
516
- }
517
- async setValue(v){
518
- if(!this.loaded){
519
- await this.ts.load();
520
- }
521
- this.ts.setValue(v);
332
+ class AuthorizationCodeFlow {
333
+ static forKeycloak(clientId, realmBaseUrl, redirectUri){
334
+ const scope = "openid profile";
335
+ return new AuthorizationCodeFlow(clientId, scope, {
336
+ auth: new URL("protocol/openid-connect/auth", realmBaseUrl),
337
+ token: new URL("protocol/openid-connect/token", realmBaseUrl),
338
+ logout: new URL("protocol/openid-connect/logout", realmBaseUrl),
339
+ registration: new URL("protocol/openid-connect/registrations", realmBaseUrl),
340
+ redirect: redirectUri
341
+ });
522
342
  }
523
- getValue(){
524
- const v = this.ts.getValue();
525
- return v === '' ? null : v;
343
+ constructor(clientId, scope, {auth, token, registration, logout, redirect}) {
344
+ this.storage = new SessionStorage(clientId);
345
+ this.clientId = clientId;
346
+ this.scope = scope;
347
+ this.uri = {auth, token, registration, logout, redirect};
526
348
  }
527
- static custom(tagName, configuration) {
528
- customElements.define(tagName, class extends Select {
529
- constructor() {
530
- super(configuration);
531
- }
349
+ async action(uri, additionalParams){
350
+ const pkceVerifier = Base64.encode(crypto.getRandomValues(new Uint8Array(32)).buffer);
351
+ const pkceChallenge = Base64.encode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pkceVerifier)));
352
+ const state = this.clientId + Base64.encode(crypto.getRandomValues(new Uint8Array(16)).buffer);
353
+ this.storage.save(AuthorizationCodeFlow.PKCE_AND_STATE_KEY, {
354
+ state: state,
355
+ verifier: pkceVerifier
356
+ });
357
+ const url = new URL(uri);
358
+ url.searchParams.set("client_id", this.clientId);
359
+ url.searchParams.set("redirect_uri", this.uri.redirect);
360
+ url.searchParams.set("response_type", 'code');
361
+ url.searchParams.set("scope", this.scope);
362
+ url.searchParams.set("state", state);
363
+ url.searchParams.set("code_challenge", pkceChallenge);
364
+ url.searchParams.set("code_challenge_method", 'S256');
365
+ Object.entries(additionalParams || {}).forEach(kv => {
366
+ url.searchParams.set(kv[0], kv[1]);
532
367
  });
368
+ window.location = url;
533
369
  }
534
- static configure() {
535
- return Select.custom('ful-select');
370
+ async registration(additionalParams){
371
+ await this.action(this.uri.registration, additionalParams);
536
372
  }
537
-
538
- }
539
-
540
- class Form extends HTMLElement {
541
- constructor({ mutators, extractors, valueHoldersSelector, ignoredChildrenSelector }) {
542
- super();
543
- Observable.mixin(this);
544
- this.mutators = mutators || {};
545
- this.extractors = extractors || {};
546
- this.valueHoldersSelector = valueHoldersSelector || '[name]';
547
- this.ignoredChildrenSelector = ignoredChildrenSelector || '.d-none';
548
-
549
- const form = document.createElement('form');
550
- form.append(...this.childNodes);
551
- this.appendChild(form);
552
-
553
- form.addEventListener('submit', async (e) => {
554
- e.preventDefault();
555
- this.spinner(true);
556
- try {
557
- await this.fire('submit', this.getValues(), this);
558
- } catch (e) {
559
- if (e instanceof Failure) {
560
- this.setErrors(e.problems);
561
- return;
562
- }
563
- throw e;
564
- } finally {
565
- this.spinner(false);
566
- }
373
+ async applicationInitiatedAction(kcAction){
374
+ await this.action(this.uri.auth, {
375
+ kc_action: kcAction
567
376
  });
568
377
  }
569
- spinner(spin) {
570
- this.querySelectorAll('ful-spinner').forEach(el => {
571
- el[spin ? 'show' : 'hide']();
572
- });
573
- this.querySelectorAll('[type=submit],[type=reset]').forEach(el => {
574
- el.disabled = spin;
378
+ async _tokenExchange(code, state) {
379
+ window.history.replaceState('', "", this.uri.redirect);
380
+ const stateAndVerifier = this.storage.pop(AuthorizationCodeFlow.PKCE_AND_STATE_KEY);
381
+ if (stateAndVerifier.state !== state) {
382
+ throw new Error("State mismatch");
383
+ }
384
+ const response = await fetch(this.uri.token, {
385
+ method: "POST",
386
+ headers: {
387
+ "Content-Type": 'application/x-www-form-urlencoded'
388
+ },
389
+ body: new URLSearchParams([
390
+ ["client_id", this.clientId],
391
+ ["code", code],
392
+ ["grant_type", "authorization_code"],
393
+ ["code_verifier", stateAndVerifier.verifier],
394
+ ["state", stateAndVerifier.state],
395
+ ["redirect_uri", this.uri.redirect]
396
+ ])
575
397
  });
398
+ if (!response.ok) {
399
+ const text = await response.text();
400
+ throw new Error("Error:" + response.status + ": " + text);
401
+ }
402
+ const token = await response.json();
403
+ return new AuthorizationCodeFlowSession(this.clientId, token, this.uri);
576
404
  }
577
- setValues(values) {
578
- for (let k in values) {
579
- if (!values.hasOwnProperty(k)) {
580
- continue;
581
- }
582
- Array.from(this.querySelectorAll(`[name='${CSS.escape(k)}']`)).forEach((el) => {
583
- Form.mutate(this.mutators, el, values[k], k, values);
584
- });
405
+ async ensureLoggedIn() {
406
+ const url = new URL(window.location.href);
407
+ const code = url.searchParams.get("code");
408
+ if (code && this.storage.load(AuthorizationCodeFlow.PKCE_AND_STATE_KEY)) {
409
+ //if callback from keycloak and we have our state still stored
410
+ const state = url.searchParams.get("state");
411
+ return await this._tokenExchange(code, state);
585
412
  }
413
+ //if not authorized
414
+ await this.action(this.uri.auth, {});
415
+ return null;
586
416
  }
587
- getValues() {
588
- return Array.from(this.querySelectorAll(this.valueHoldersSelector))
589
- .filter((el) => {
590
- if (el.dataset['fulBindInclude'] === 'never') {
591
- return false;
592
- }
593
- return el.dataset['fulBindInclude'] === 'always' || el.closest(this.ignoredChildrenSelector) === null;
594
- })
595
- .reduce((result, el) => {
596
- return Form.providePath(result, el.getAttribute('name'), Form.extract(this.extractors, el));
597
- }, {});
598
- }
599
- setErrors(errors, scroll) {
600
- this.clearErrors();
601
- errors
602
- .filter((e) => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT')
603
- .forEach((e) => {
604
- const name = e.context.replace("[", ".").replace("].", ".");
605
- this.querySelectorAll(`[name='${CSS.escape(name)}']`)
606
- .forEach(input => {
607
- input.classList.add('is-invalid');
608
- if (input.parentElement.classList.contains("form-floating")) {
609
- input.parentElement.classList.add('is-invalid');
610
- }
611
- });
612
- this.querySelectorAll(`ful-field-error[field='${CSS.escape(name)}']`)
613
- .forEach(el => el.innerText = e.reason);
614
- });
615
- this.querySelectorAll("ful-errors")
616
- .forEach(el => {
617
- const globalErrors = errors.filter((e) => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT');
618
- el.innerHTML = globalErrors.map(e => e.reason).join("\n");
619
- if (globalErrors.length !== 0) {
620
- el.classList.remove('d-none');
621
- }
622
- });
417
+ }
418
+ AuthorizationCodeFlow.PKCE_AND_STATE_KEY = "state-and-verifier";
623
419
 
624
- if (!scroll) {
625
- return;
626
- }
627
- const ys = Array.from(this.querySelectorAll('ful-field-error:not(.d-none)'))
628
- .map(el => el.getBoundingClientRect().y + window.scrollY);
629
- const miny = Math.min(...ys);
630
- if (miny !== Infinity) {
631
- window.scroll(window.scrollX, miny > 100 ? miny - 100 : 0);
632
- }
420
+ class AuthorizationCodeFlowSession {
421
+ static parseToken(token) {
422
+ const [rawHeader, rawPayload, signature] = token.split(".");
423
+ const ut8decoder = new TextDecoder("utf-8");
424
+ return {
425
+ header: JSON.parse(ut8decoder.decode(Base64.decode(rawHeader, Base64.STANDARD))),
426
+ payload: JSON.parse(ut8decoder.decode(Base64.decode(rawPayload, Base64.STANDARD))),
427
+ signature: signature
428
+ };
429
+ }
430
+ constructor(clientId, t, {token, logout, redirect}) {
431
+ this.clientId = clientId;
432
+ this.token = t;
433
+ this.accessToken = AuthorizationCodeFlowSession.parseToken(t.access_token);
434
+ this.refreshToken = AuthorizationCodeFlowSession.parseToken(t.refresh_token);
435
+ this.uri = { token, logout, redirect };
436
+ this.refreshCallback = null;
633
437
  }
634
- clearErrors() {
635
- this.querySelectorAll('[name].is-invalid, .form-floating.is-invalid')
636
- .forEach(el => el.classList.remove('is-invalid'));
637
- this.querySelectorAll("ful-errors")
638
- .forEach(el => {
639
- el.innerHTML = '';
640
- el.classList.add('d-none');
641
- });
438
+ onRefresh(callback) {
439
+ this.refreshCallback = callback;
642
440
  }
643
- static extract(extractors, el) {
644
- const maybeExtractor = extractors[el.dataset['fulBindExtractor']] || extractors[el.dataset['fulBindProvide']];
645
- if (maybeExtractor) {
646
- return maybeExtractor(el);
647
- }
648
- if (el.getAttribute('type') === 'radio') {
649
- if (!el.checked) {
650
- return undefined;
651
- }
652
- return el.dataset['fulBindType'] === 'boolean' ? el.value === 'true' : el.value;
653
- }
654
- if (el.getAttribute('type') === 'checkbox') {
655
- return el.checked;
656
- }
657
- if (el.dataset['fulBindType'] === 'boolean') {
658
- return !el.value ? null : el.value === 'true';
441
+ async refresh() {
442
+ const response = await fetch(this.uri.token, {
443
+ method: "POST",
444
+ headers: {
445
+ "Content-Type": 'application/x-www-form-urlencoded'
446
+ },
447
+ body: new URLSearchParams([
448
+ ["client_id", this.clientId],
449
+ ["grant_type", "refresh_token"],
450
+ ["refresh_token", this.token.refresh_token]
451
+ ])
452
+ });
453
+ if (!response.ok) {
454
+ throw new Error("Error:" + response.status + ": " + response.text());
659
455
  }
660
- if (el.getValue) {
661
- return el.getValue();
456
+ const token = await response.json();
457
+ this.token = token;
458
+ this.accessToken = AuthorizationCodeFlowSession.parseToken(token.access_token);
459
+ this.refreshToken = AuthorizationCodeFlowSession.parseToken(token.refresh_token);
460
+ if (this.refreshCallback) {
461
+ this.refreshCallback(this.token, this.accessToken, this.refreshToken);
662
462
  }
663
- return el.value || null;
664
463
  }
665
- static mutate(mutators, el, raw, key, values) {
666
- const maybeMutator = mutators[el.dataset['fulBindMutator']] || mutators[el.dataset['fulBindProvide']];
667
- if (maybeMutator) {
668
- maybeMutator(el, raw, key, values);
669
- return;
670
- }
671
- if (el.getAttribute('type') === 'radio') {
672
- el.checked = el.getAttribute('value') === raw;
673
- return;
674
- }
675
- if (el.getAttribute('type') === 'checkbox') {
676
- el.checked = raw;
677
- return;
678
- }
679
- if (el.setValue) {
680
- el.setValue(raw);
464
+ shouldBeRefreshed(gracePeriod) {
465
+ const now = new Date().getTime();
466
+ const refreshTokenExpiresAt = this.refreshToken.payload.exp * 1000;
467
+ const expired = now > refreshTokenExpiresAt;
468
+ const shouldRefresh = now - gracePeriod > refreshTokenExpiresAt;
469
+ return !expired && shouldRefresh;
470
+ }
471
+ async refreshIf(gracePeriod) {
472
+ if (!this.shouldBeRefreshed(gracePeriod)) {
681
473
  return;
682
474
  }
683
- el.value = raw;
475
+ await this.refresh();
476
+ }
477
+ logout() {
478
+ const url = new URL(this.uri.logout);
479
+ url.searchParams.set("post_logout_redirect_uri", this.uri.redirect);
480
+ url.searchParams.set("id_token_hint", this.token.id_token);
481
+ window.location = url;
684
482
  }
685
483
 
686
- static providePath(result, path, value) {
687
- const keys = path.split(".").map((k) => k.match(/^[0-9]+$/) ? +k : k);
688
- let current = result;
689
- let previous = null;
690
- for (let i = 0; ; ++i) {
691
- const ckey = keys[i];
692
- const pkey = keys[i - 1];
693
- if (Number.isInteger(ckey) && !Array.isArray(current)) {
694
- if (previous !== null) {
695
- previous[pkey] = current = [];
696
- } else {
697
- result = current = [];
484
+ bearerToken() {
485
+ return `Bearer ${this.token.access_token}`;
486
+ }
487
+
488
+ interceptor(gracePeriodBefore, gracePeriodAfter){
489
+ return new AuthorizationCodeFlowInterceptor(this, gracePeriodBefore, gracePeriodAfter);
490
+ }
491
+ }
492
+
493
+ class AuthorizationCodeFlowInterceptor {
494
+ constructor(session, gracePeriodBefore, gracePeriodAfter) {
495
+ this.session = session;
496
+ this.gracePeriodBefore = gracePeriodBefore || 2000;
497
+ this.gracePeriodAfter = gracePeriodAfter || 30000;
498
+ }
499
+ async intercept(request, chain) {
500
+ await this.session.refreshIf(this.gracePeriodBefore);
501
+ const headers = new Headers(request.options.headers);
502
+ headers.set("Authorization", this.session.bearerToken());
503
+ request.options.headers = headers;
504
+ const response = await chain.proceed(request);
505
+ await this.session.refreshIf(this.gracePeriodAfter);
506
+ return response;
507
+ }
508
+ }
509
+
510
+ const timing = {
511
+ sleep(ms) {
512
+ return new Promise(resolve => setTimeout(resolve, ms));
513
+ },
514
+ DEBOUNCE_DEFAULT: 0,
515
+ DEBOUNCE_IMMEDIATE: 1,
516
+ debounce(timeoutMs, func, options) {
517
+ let tid = null;
518
+ let args = [];
519
+ let previousTimestamp = 0;
520
+ let opts = options || timing.DEBOUNCE_DEFAULT;
521
+
522
+ const later = () => {
523
+ const elapsed = new Date().getTime() - previousTimestamp;
524
+ if (timeoutMs > elapsed) {
525
+ tid = setTimeout(later, timeoutMs - elapsed);
526
+ return;
527
+ }
528
+ tid = null;
529
+ if (opts !== timing.DEBOUNCE_IMMEDIATE) {
530
+ func(...args);
531
+ }
532
+ // This check is needed because `func` can recursively invoke `debounced`.
533
+ if (tid === null) {
534
+ args = [];
535
+ }
536
+ };
537
+
538
+ return function () {
539
+ args = arguments;
540
+ previousTimestamp = new Date().getTime();
541
+ if (tid === null) {
542
+ tid = setTimeout(later, timeoutMs);
543
+ if (opts === timing.DEBOUNCE_IMMEDIATE) {
544
+ func(...args);
698
545
  }
699
546
  }
700
- if (i === keys.length - 1) {
701
- //when value is undefined we only want to define the property if it's not defined
702
- current[ckey] = value !== undefined ? value : (ckey in current ? current[ckey] : null);
703
- return result;
547
+ };
548
+ },
549
+ THROTTLE_DEFAULT: 0,
550
+ THROTTLE_NO_LEADING: 1,
551
+ THROTTLE_NO_TRAILING: 2,
552
+ throttle(timeoutMs, func, options) {
553
+ let tid = null;
554
+ let args = [];
555
+ let previousTimestamp = 0;
556
+ let opts = options || timing.THROTTLE_DEFAULT;
557
+
558
+ const later = () => {
559
+ previousTimestamp = (opts & timing.THROTTLE_NO_LEADING) ? 0 : new Date().getTime();
560
+ tid = null;
561
+ func(...args);
562
+ if (tid === null) {
563
+ args = [];
704
564
  }
705
- if (current[ckey] === undefined) {
706
- current[ckey] = {};
565
+ };
566
+
567
+ return function () {
568
+ const now = new Date().getTime();
569
+ if (!previousTimestamp && (opts & timing.THROTTLE_NO_LEADING)) {
570
+ previousTimestamp = now;
707
571
  }
708
- previous = current;
709
- current = current[ckey];
710
- }
711
- }
712
- static custom(tagName, configuration) {
713
- customElements.define(tagName, class extends Form {
714
- constructor() {
715
- super(configuration);
572
+ const remaining = timeoutMs - (now - previousTimestamp);
573
+ args = arguments;
574
+ if (remaining <= 0 || remaining > timeoutMs) {
575
+ if (tid !== null) {
576
+ clearTimeout(tid);
577
+ tid = null;
578
+ }
579
+ previousTimestamp = now;
580
+ func(...args);
581
+ if (tid === null) {
582
+ args = [];
583
+ }
584
+ } else if (tid === null && !(opts & timing.THROTTLE_NO_TRAILING)) {
585
+ tid = setTimeout(later, remaining);
716
586
  }
587
+ };
588
+
589
+ }
590
+ };
591
+
592
+ class Fragments {
593
+ static fromHtml(...html) {
594
+ const el = document.createElement('div');
595
+ el.innerHTML = html.join("");
596
+ const fragment = new DocumentFragment();
597
+ Array.from(el.childNodes).forEach(node => {
598
+ fragment.appendChild(node);
717
599
  });
600
+ return fragment;
601
+ }
602
+ static toHtml(fragment) {
603
+ var r = document.createElement("root");
604
+ r.appendChild(fragment);
605
+ return r.innerHTML;
606
+ }
607
+ static from(...nodes) {
608
+ const fragment = new DocumentFragment();
609
+ for (let i = 0; i !== nodes.length; ++i) {
610
+ fragment.appendChild(nodes[i]);
611
+ }
612
+ return fragment;
718
613
  }
719
- static configure(configuration) {
720
- FieldError.configure();
721
- Errors.configure();
722
- Spinner.configure();
723
- Input.configure();
724
- Select.configure();
725
- Form.custom('ful-form', configuration || {});
614
+ static fromChildNodes(el) {
615
+ const nodes = Array.from(el.childNodes);
616
+ const fragment = new DocumentFragment();
617
+ for (let i = 0; i !== nodes.length; ++i) {
618
+ fragment.appendChild(nodes[i]);
619
+ }
620
+ return fragment;
726
621
  }
727
622
  }
728
623
 
729
- class Storage {
730
- constructor(prefix, storage) {
731
- this.prefix = prefix;
732
- this.storage = storage;
733
- }
734
- save(k, v) {
735
- this.storage.setItem(`${this.prefix}-${k}`, JSON.stringify(v));
624
+ class Attributes {
625
+ static id = 0;
626
+ static uid(prefix) {
627
+ return `${prefix}-${++Attributes.id}`;
736
628
  }
737
- load(k) {
738
- const got = this.storage.getItem(`${this.prefix}-${k}`);
739
- return got === undefined ? undefined : JSON.parse(got);
629
+ static asBoolean(value) {
630
+ return value !== null && value !== undefined && value !== false;
740
631
  }
741
- remove(k) {
742
- this.storage.removeItem(`${this.prefix}-${k}`);
632
+ static defaultValue(el, k, v) {
633
+ if (!el.hasAttribute(k)) {
634
+ el.setAttribute(k, v);
635
+ }
636
+ return el.getAttribute(k);
743
637
  }
744
- pop(k) {
745
- const decoded = this.load(k);
746
- this.remove(k);
747
- return decoded;
638
+ static forward(prefix, from, to) {
639
+ from.getAttributeNames()
640
+ .filter(a => a.startsWith(prefix))
641
+ .forEach(a => {
642
+ const target = a.substring(prefix.length);
643
+ if (target === 'class') {
644
+ to.classList.add(...from.getAttribute(prefix + "class").split(" ").filter(a => a.length));
645
+ return;
646
+ }
647
+ to.setAttribute(target, from.getAttribute(a));
648
+ });
748
649
  }
749
650
  }
750
651
 
751
- class LocalStorage extends Storage {
752
- constructor(prefix) {
753
- super(prefix, localStorage);
652
+ class Slots {
653
+ static from(el) {
654
+ const slotted = Object.fromEntries(Array.from(el.querySelectorAll("[slot]")).map(el => {
655
+ el.parentElement.removeChild(el);
656
+ const slot = el.getAttribute("slot");
657
+ el.removeAttribute("slot");
658
+ return [slot, el];
659
+ }));
660
+ slotted.default = new DocumentFragment();
661
+ slotted.default.append(...el.childNodes);
662
+ return slotted;
754
663
  }
755
- }
756
664
 
757
- class SessionStorage extends Storage {
758
- constructor(prefix) {
759
- super(prefix, sessionStorage);
760
- }
761
665
  }
762
666
 
763
- class VersionedStorage {
764
- constructor(storage, key, dataSupplier){
765
- this.storage = storage;
766
- this.key = key;
767
- this.dataSupplier = dataSupplier;
768
- this.cache = null;
769
-
770
- }
771
- async load(revision){
772
- const saved = this.storage.load(this.key);
773
- if (!!saved && saved.revision === revision) {
774
- this.cache = saved.value;
775
- return;
667
+ const Templated = (SuperClass, template) => {
668
+ return class extends SuperClass {
669
+ #rendered;
670
+ async connectedCallback() {
671
+ if (this.#rendered) {
672
+ return;
673
+ }
674
+ const slotted = Slots.from(this);
675
+ const fragment = await Promise.resolve(this.render(slotted, template));
676
+ this.innerHTML = '';
677
+ if (fragment) {
678
+ this.appendChild(fragment);
679
+ }
680
+ this.#rendered = true;
776
681
  }
777
- const freshData = await this.dataSupplier(revision, this.key);
778
- this.storage.save(this.key, {
779
- revision: revision,
780
- value: freshData
781
- });
782
- this.cache = freshData;
682
+ };
683
+ };
684
+
685
+ const Stateful = (SuperClass, flags, others) => {
686
+
687
+ const all = [].concat(flags).concat(others || []);
688
+
689
+ return class extends SuperClass {
690
+ static get observedAttributes() {
691
+ return all;
692
+ }
693
+ constructor(...args) {
694
+ super(...args);
695
+ this.internals_ = this.internals_ || this.attachInternals();
696
+ for (const flag of flags) {
697
+ Object.defineProperty(this, flag, {
698
+ get() {
699
+ return this.hasAttribute(flag);
700
+ },
701
+ set(value) {
702
+ if (Attributes.asBoolean(value)) {
703
+ this.internals_.states.add(`--${flag}`);
704
+ this.setAttribute(flag, '');
705
+ return;
706
+ }
707
+ this.internals_.states.delete(`--${flag}`);
708
+ this.removeAttribute(flag);
709
+ }
710
+ });
711
+ }
712
+ }
713
+ attributeChangedCallback(name, oldValue, newValue) {
714
+ if (oldValue === newValue) {
715
+ return;
716
+ }
717
+ this[name] = newValue;
718
+ const method = this[`on${name.charAt(0).toUpperCase()}${name.substr(1).toLowerCase()}Changed`];
719
+ method?.call(this, newValue, oldValue);
720
+ }
721
+ };
722
+ };
723
+
724
+ class FieldError extends Templated(HTMLElement) {
725
+ render(slotted, template) {
726
+ this.classList.add('invalid-feedback');
783
727
  }
784
- data(){
785
- return this.cache;
728
+ static configure() {
729
+ customElements.define('ful-field-error', FieldError);
786
730
  }
787
731
  }
788
732
 
789
- class AuthorizationCodeFlow {
790
- static forKeycloak(clientId, realmBaseUrl, redirectUri){
791
- const scope = "openid profile";
792
- return new AuthorizationCodeFlow(clientId, scope, {
793
- auth: new URL("protocol/openid-connect/auth", realmBaseUrl),
794
- token: new URL("protocol/openid-connect/token", realmBaseUrl),
795
- logout: new URL("protocol/openid-connect/logout", realmBaseUrl),
796
- registration: new URL("protocol/openid-connect/registrations", realmBaseUrl),
797
- redirect: redirectUri
798
- });
799
- }
800
- constructor(clientId, scope, {auth, token, registration, logout, redirect}) {
801
- this.storage = new SessionStorage(clientId);
802
- this.clientId = clientId;
803
- this.scope = scope;
804
- this.uri = {auth, token, registration, logout, redirect};
733
+ class Errors extends Templated(HTMLElement) {
734
+ render(slotted, template) {
735
+ this.classList.add('alert', 'alert-danger', 'd-none');
805
736
  }
806
- async action(uri, additionalParams){
807
- const pkceVerifier = Base64.encode(crypto.getRandomValues(new Uint8Array(32)).buffer);
808
- const pkceChallenge = Base64.encode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pkceVerifier)));
809
- const state = this.clientId + Base64.encode(crypto.getRandomValues(new Uint8Array(16)).buffer);
810
- this.storage.save(AuthorizationCodeFlow.PKCE_AND_STATE_KEY, {
811
- state: state,
812
- verifier: pkceVerifier
813
- });
814
- const url = new URL(uri);
815
- url.searchParams.set("client_id", this.clientId);
816
- url.searchParams.set("redirect_uri", this.uri.redirect);
817
- url.searchParams.set("response_type", 'code');
818
- url.searchParams.set("scope", this.scope);
819
- url.searchParams.set("state", state);
820
- url.searchParams.set("code_challenge", pkceChallenge);
821
- url.searchParams.set("code_challenge_method", 'S256');
822
- Object.entries(additionalParams || {}).forEach(kv => {
823
- url.searchParams.set(kv[0], kv[1]);
824
- });
825
- window.location = url;
737
+ static configure() {
738
+ customElements.define('ful-errors', Errors);
826
739
  }
827
- async registration(additionalParams){
828
- await this.action(this.uri.registration, additionalParams);
740
+
741
+ }
742
+
743
+ /* global Infinity, CSS */
744
+
745
+ class Form extends Templated(Observable(HTMLElement)) {
746
+ constructor({ mutators, extractors, valueHoldersSelector, ignoredChildrenSelector }) {
747
+ super();
748
+ this.mutators = mutators || {};
749
+ this.extractors = extractors || {};
750
+ this.valueHoldersSelector = valueHoldersSelector || '[name]';
751
+ this.ignoredChildrenSelector = ignoredChildrenSelector || '.d-none';
829
752
  }
830
- async applicationInitiatedAction(kcAction){
831
- await this.action(this.uri.auth, {
832
- kc_action: kcAction
753
+ render(slotted, template) {
754
+ const form = document.createElement('form');
755
+ form.append(slotted.default);
756
+ form.addEventListener('submit', async (e) => {
757
+ e.preventDefault();
758
+ this.spinner(true);
759
+ try {
760
+ await this.fire('submit', this.getValues(), this);
761
+ } catch (e) {
762
+ if (e instanceof Failure) {
763
+ this.setErrors(e.problems);
764
+ return;
765
+ }
766
+ throw e;
767
+ } finally {
768
+ this.spinner(false);
769
+ }
833
770
  });
771
+ return form;
834
772
  }
835
- async _tokenExchange(code, state) {
836
- window.history.replaceState('', "", this.uri.redirect);
837
- const stateAndVerifier = this.storage.pop(AuthorizationCodeFlow.PKCE_AND_STATE_KEY);
838
- if (stateAndVerifier.state !== state) {
839
- throw new Error("State mismatch");
840
- }
841
- const response = await fetch(this.uri.token, {
842
- method: "POST",
843
- headers: {
844
- "Content-Type": 'application/x-www-form-urlencoded'
845
- },
846
- body: new URLSearchParams([
847
- ["client_id", this.clientId],
848
- ["code", code],
849
- ["grant_type", "authorization_code"],
850
- ["code_verifier", stateAndVerifier.verifier],
851
- ["state", stateAndVerifier.state],
852
- ["redirect_uri", this.uri.redirect]
853
- ])
773
+ spinner(spin) {
774
+ this.querySelectorAll('ful-spinner').forEach(el => {
775
+ el.hidden = !spin;
776
+ });
777
+ this.querySelectorAll('[type=submit],[type=reset]').forEach(el => {
778
+ el.disabled = spin;
854
779
  });
855
- if (!response.ok) {
856
- const text = await response.text();
857
- throw new Error("Error:" + response.status + ": " + text);
858
- }
859
- const token = await response.json();
860
- return new AuthorizationCodeFlowSession(this.clientId, token, this.uri);
861
780
  }
862
- async ensureLoggedIn() {
863
- const url = new URL(window.location.href);
864
- const code = url.searchParams.get("code");
865
- if (code && this.storage.load(AuthorizationCodeFlow.PKCE_AND_STATE_KEY)) {
866
- //if callback from keycloak and we have our state still stored
867
- const state = url.searchParams.get("state");
868
- return await this._tokenExchange(code, state);
781
+ setValues(values) {
782
+ for (let k in values) {
783
+ if (!values.hasOwnProperty(k)) {
784
+ continue;
785
+ }
786
+ Array.from(this.querySelectorAll(`[name='${CSS.escape(k)}']`)).forEach((el) => {
787
+ Form.mutate(this.mutators, el, values[k], k, values);
788
+ });
869
789
  }
870
- //if not authorized
871
- await this.action(this.uri.auth, {});
872
- return null;
873
790
  }
874
- }
875
- AuthorizationCodeFlow.PKCE_AND_STATE_KEY = "state-and-verifier";
791
+ getValues() {
792
+ return Array.from(this.querySelectorAll(this.valueHoldersSelector))
793
+ .filter((el) => {
794
+ if (el.dataset['fulBindInclude'] === 'never') {
795
+ return false;
796
+ }
797
+ return el.dataset['fulBindInclude'] === 'always' || el.closest(this.ignoredChildrenSelector) === null;
798
+ })
799
+ .reduce((result, el) => {
800
+ return Form.providePath(result, el.getAttribute('name'), Form.extract(this.extractors, el));
801
+ }, {});
802
+ }
803
+ setErrors(errors, scroll) {
804
+ this.clearErrors();
805
+ errors
806
+ .filter((e) => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT')
807
+ .forEach((e) => {
808
+ const name = e.context.replace("[", ".").replace("].", ".");
809
+ this.querySelectorAll(`[name='${CSS.escape(name)}']`)
810
+ .forEach(input => {
811
+ input.classList.add('is-invalid');
812
+ if (input.parentElement.classList.contains("form-floating")) {
813
+ input.parentElement.classList.add('is-invalid');
814
+ }
815
+ });
816
+ this.querySelectorAll(`ful-field-error[field='${CSS.escape(name)}']`)
817
+ .forEach(el => el.innerText = e.reason);
818
+ });
819
+ this.querySelectorAll("ful-errors")
820
+ .forEach(el => {
821
+ const globalErrors = errors.filter((e) => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT');
822
+ el.innerHTML = globalErrors.map(e => e.reason).join("\n");
823
+ if (globalErrors.length !== 0) {
824
+ el.classList.remove('d-none');
825
+ }
826
+ });
876
827
 
877
- class AuthorizationCodeFlowSession {
878
- static parseToken(token) {
879
- const [rawHeader, rawPayload, signature] = token.split(".");
880
- const ut8decoder = new TextDecoder("utf-8");
881
- return {
882
- header: JSON.parse(ut8decoder.decode(Base64.decode(rawHeader, Base64.STANDARD))),
883
- payload: JSON.parse(ut8decoder.decode(Base64.decode(rawPayload, Base64.STANDARD))),
884
- signature: signature
885
- };
886
- }
887
- constructor(clientId, t, {token, logout, redirect}) {
888
- this.clientId = clientId;
889
- this.token = t;
890
- this.accessToken = AuthorizationCodeFlowSession.parseToken(t.access_token);
891
- this.refreshToken = AuthorizationCodeFlowSession.parseToken(t.refresh_token);
892
- this.uri = { token, logout, redirect };
893
- this.refreshCallback = null;
828
+ if (!scroll) {
829
+ return;
830
+ }
831
+ const ys = Array.from(this.querySelectorAll('ful-field-error:not(.d-none)'))
832
+ .map(el => el.getBoundingClientRect().y + window.scrollY);
833
+ const miny = Math.min(...ys);
834
+ if (miny !== Infinity) {
835
+ window.scroll(window.scrollX, miny > 100 ? miny - 100 : 0);
836
+ }
894
837
  }
895
- onRefresh(callback) {
896
- this.refreshCallback = callback;
838
+ clearErrors() {
839
+ this.querySelectorAll('[name].is-invalid, .form-floating.is-invalid')
840
+ .forEach(el => el.classList.remove('is-invalid'));
841
+ this.querySelectorAll("ful-errors")
842
+ .forEach(el => {
843
+ el.innerHTML = '';
844
+ el.classList.add('d-none');
845
+ });
897
846
  }
898
- async refresh() {
899
- const response = await fetch(this.uri.token, {
900
- method: "POST",
901
- headers: {
902
- "Content-Type": 'application/x-www-form-urlencoded'
903
- },
904
- body: new URLSearchParams([
905
- ["client_id", this.clientId],
906
- ["grant_type", "refresh_token"],
907
- ["refresh_token", this.token.refresh_token]
908
- ])
909
- });
910
- if (!response.ok) {
911
- throw new Error("Error:" + response.status + ": " + response.text());
847
+ static extract(extractors, el) {
848
+ const maybeExtractor = extractors[el.dataset['fulBindExtractor']] || extractors[el.dataset['fulBindProvide']];
849
+ if (maybeExtractor) {
850
+ return maybeExtractor(el);
912
851
  }
913
- const token = await response.json();
914
- this.token = token;
915
- this.accessToken = this._parseToken(token.access_token);
916
- this.refreshToken = this._parseToken(token.refresh_token);
917
- if (this.refreshCallback) {
918
- this.refreshCallback(this.token, this.accessToken, this.refreshToken);
852
+ if (el.getAttribute('type') === 'radio') {
853
+ if (!el.checked) {
854
+ return undefined;
855
+ }
856
+ return el.dataset['fulBindType'] === 'boolean' ? el.value === 'true' : el.value;
919
857
  }
858
+ if (el.getAttribute('type') === 'checkbox') {
859
+ return el.checked;
860
+ }
861
+ if (el.dataset['fulBindType'] === 'boolean') {
862
+ return !el.value ? null : el.value === 'true';
863
+ }
864
+ if (el.getValue) {
865
+ return el.getValue();
866
+ }
867
+ return el.value || null;
920
868
  }
921
- shouldBeRefreshed(gracePeriod) {
922
- const now = new Date().getTime();
923
- const refreshTokenExpiresAt = this.refreshToken.payload.exp * 1000;
924
- const expired = now > refreshTokenExpiresAt;
925
- const shouldRefresh = now - gracePeriod > refreshTokenExpiresAt;
926
- return !expired && shouldRefresh;
927
- }
928
- async refreshIf(gracePeriod) {
929
- if (!this.shouldBeRefreshed(gracePeriod)) {
869
+ static mutate(mutators, el, raw, key, values) {
870
+ const maybeMutator = mutators[el.dataset['fulBindMutator']] || mutators[el.dataset['fulBindProvide']];
871
+ if (maybeMutator) {
872
+ maybeMutator(el, raw, key, values);
930
873
  return;
931
874
  }
932
- await this.refresh();
933
- }
934
- logout() {
935
- const url = new URL(this.uri.logout);
936
- url.searchParams.set("post_logout_redirect_uri", this.uri.redirect);
937
- url.searchParams.set("id_token_hint", this.token.id_token);
938
- window.location = url;
875
+ if (el.getAttribute('type') === 'radio') {
876
+ el.checked = el.getAttribute('value') === raw;
877
+ return;
878
+ }
879
+ if (el.getAttribute('type') === 'checkbox') {
880
+ el.checked = raw;
881
+ return;
882
+ }
883
+ if (el.setValue) {
884
+ el.setValue(raw);
885
+ return;
886
+ }
887
+ el.value = raw;
939
888
  }
940
889
 
941
- bearerToken() {
942
- return `Bearer ${this.token.access_token}`;
890
+ static providePath(result, path, value) {
891
+ const keys = path.split(".").map((k) => k.match(/^[0-9]+$/) ? +k : k);
892
+ let current = result;
893
+ let previous = null;
894
+ for (let i = 0; ; ++i) {
895
+ const ckey = keys[i];
896
+ const pkey = keys[i - 1];
897
+ if (Number.isInteger(ckey) && !Array.isArray(current)) {
898
+ if (previous !== null) {
899
+ previous[pkey] = current = [];
900
+ } else {
901
+ result = current = [];
902
+ }
903
+ }
904
+ if (i === keys.length - 1) {
905
+ //when value is undefined we only want to define the property if it's not defined
906
+ current[ckey] = value !== undefined ? value : (ckey in current ? current[ckey] : null);
907
+ return result;
908
+ }
909
+ if (current[ckey] === undefined) {
910
+ current[ckey] = {};
911
+ }
912
+ previous = current;
913
+ current = current[ckey];
914
+ }
943
915
  }
944
-
945
- interceptor(gracePeriodBefore, gracePeriodAfter){
946
- return new AuthorizationCodeFlowInterceptor(this, gracePeriodBefore, gracePeriodAfter);
916
+ static custom(tagName, configuration) {
917
+ customElements.define(tagName, class extends Form {
918
+ constructor() {
919
+ super(configuration);
920
+ }
921
+ });
947
922
  }
948
923
  }
949
924
 
950
- class AuthorizationCodeFlowInterceptor {
951
- constructor(session, gracePeriodBefore, gracePeriodAfter) {
952
- this.session = session;
953
- this.gracePeriodBefore = gracePeriodBefore || 2000;
954
- this.gracePeriodAfter = gracePeriodAfter || 30000;
925
+ const ful_input_ec = globalThis.ec || ftl.EvaluationContext.configure({});
926
+
927
+ const ful_input_template = globalThis.template || ftl.Template.fromHtml(`
928
+ <div data-tpl-if="floating" class="input-group has-validation">
929
+ <span data-tpl-if="slotted.ibefore" class="input-group-text">{{{{ slotted.ibefore }}}}</span>
930
+ <div data-tpl-if="slotted.before" data-tpl-remove="tag">{{{{ slotted.before }}}}</div>
931
+ <div class="form-floating">
932
+ {{{{ slotted.input }}}}
933
+ <label data-tpl-for="name" class="form-label">{{{{ slotted.default }}}}</label>
934
+ </div>
935
+ <div data-tpl-if="slotted.after" data-tpl-remove="tag">{{{{ slotted.after }}}}</div>
936
+ <span data-tpl-if="slotted.iafter" class="input-group-text">{{{{ slotted.iafter }}}}</span>
937
+ <ful-field-error data-tpl-if="name" data-tpl-field="name"></ful-field-error>
938
+ </div>
939
+ <div data-tpl-if="!floating" data-tpl-remove="tag">
940
+ <label data-tpl-for="id" class="form-label">{{{{ slotted.default }}}}</label>
941
+ <div class="input-group has-validation">
942
+ <span data-tpl-if="slotted.ibefore" class="input-group-text">{{{{ slotted.ibefore }}}}</span>
943
+ <div data-tpl-if="slotted.before" data-tpl-remove="tag">{{{{ slotted.before }}}}</div>
944
+ {{{{ slotted.input }}}}
945
+ <div data-tpl-if="slotted.after" data-tpl-remove="tag">{{{{ slotted.after }}}}</div>
946
+ <span data-tpl-if="slotted.iafter" class="input-group-text">{{{{ slotted.iafter }}}}</span>
947
+ <ful-field-error data-tpl-if="name" data-tpl-field="name"></ful-field-error>
948
+ </div>
949
+ </div>
950
+ `, ful_input_ec);
951
+
952
+
953
+
954
+ class Input extends Templated(HTMLElement, ful_input_template) {
955
+ render(slotted, template) {
956
+ const floating = this.hasAttribute('floating');
957
+ const input = slotted.input = slotted.input || (() => {
958
+ const el = document.createElement("input");
959
+ el.classList.add("form-control");
960
+ return el;
961
+ })();
962
+ const id = input.getAttribute('id') || this.getAttribute('input-id') || Attributes.uid('ful-input');
963
+ Attributes.forward('input-', this, slotted.input);
964
+ Attributes.defaultValue(slotted.input, "id", id);
965
+ Attributes.defaultValue(slotted.input, "type", "text");
966
+ Attributes.defaultValue(slotted.input, "placeholder", " ");
967
+ const name = input.getAttribute('name');
968
+ return template.render({ id, name, floating, slotted });
955
969
  }
956
- async intercept(request, chain) {
957
- await this.session.refreshIf(this.gracePeriodBefore);
958
- const headers = new Headers(request.options.headers);
959
- headers.set("Authorization", this.session.bearerToken());
960
- request.options.headers = headers;
961
- const response = await chain.proceed(request);
962
- await this.session.refreshIf(this.gracePeriodAfter);
963
- return response;
970
+ static configure() {
971
+ customElements.define('ful-input', Input);
964
972
  }
965
973
  }
966
974
 
967
- const timing = {
968
- sleep(ms) {
969
- return new Promise(resolve => setTimeout(resolve, ms));
970
- },
971
- DEBOUNCE_DEFAULT: 0,
972
- DEBOUNCE_IMMEDIATE: 1,
973
- debounce(timeoutMs, func, options) {
974
- let tid = null;
975
- let args = [];
976
- let previousTimestamp = 0;
977
- let opts = options || timing.DEBOUNCE_DEFAULT;
978
-
979
- const later = () => {
980
- const elapsed = new Date().getTime() - previousTimestamp;
981
- if (timeoutMs > elapsed) {
982
- tid = setTimeout(later, timeoutMs - elapsed);
983
- return;
984
- }
985
- tid = null;
986
- if (opts !== timing.DEBOUNCE_IMMEDIATE) {
987
- func(...args);
988
- }
989
- // This check is needed because `func` can recursively invoke `debounced`.
990
- if (tid === null) {
991
- args = [];
992
- }
993
- };
975
+ /**
976
+ * <script src="tom-select.complete.js"></script>
977
+ * <link href="tom-select.bootstrap5.css" rel="stylesheet" />
978
+ */
979
+ const ful_select_ec = globalThis.ec || ftl.EvaluationContext.configure({});
980
+
981
+ const ful_select_template = globalThis.template || ftl.Template.fromHtml(`
982
+ <div data-tpl-if="floating" class="input-group has-validation">
983
+ <span data-tpl-if="slotted.ibefore" class="input-group-text">{{{{ slotted.ibefore }}}}</span>
984
+ <div data-tpl-if="slotted.before" data-tpl-remove="tag">{{{{ slotted.before }}}}</div>
985
+ <div class="form-floating">
986
+ {{{{ slotted.input }}}}
987
+ <label data-tpl-for="name" class="form-label">{{{{ slotted.default }}}}</label>
988
+ </div>
989
+ <div data-tpl-if="slotted.after" data-tpl-remove="tag">{{{{ slotted.after }}}}</div>
990
+ <span data-tpl-if="slotted.iafter" class="input-group-text">{{{{ slotted.iafter }}}}</span>
991
+ <ful-field-error data-tpl-if="name" data-tpl-field="name"></ful-field-error>
992
+ </div>
993
+ <div data-tpl-if="!floating" data-tpl-remove="tag">
994
+ <label data-tpl-for="id" class="form-label">{{{{ slotted.default }}}}</label>
995
+ <div class="input-group has-validation">
996
+ <span data-tpl-if="slotted.ibefore" class="input-group-text">{{{{ slotted.ibefore }}}}</span>
997
+ <div data-tpl-if="slotted.before" data-tpl-remove="tag">{{{{ slotted.before }}}}</div>
998
+ {{{{ slotted.input }}}}
999
+ <div data-tpl-if="slotted.after" data-tpl-remove="tag">{{{{ slotted.after }}}}</div>
1000
+ <span data-tpl-if="slotted.iafter" class="input-group-text">{{{{ slotted.iafter }}}}</span>
1001
+ <ful-field-error data-tpl-if="name" data-tpl-field="name"></ful-field-error>
1002
+ </div>
1003
+ </div>
1004
+ `, ful_select_ec);
1005
+
1006
+
1007
+ class Select extends Templated(Observable(HTMLElement), ful_select_template) {
1008
+ constructor(tsConfig) {
1009
+ super();
1010
+ this.tsConfig = tsConfig;
1011
+ }
1012
+ render(slotted, template) {
1013
+ const floating = this.hasAttribute('floating');
1014
+ const remote = this.hasAttribute('remote');
1015
+ const input = slotted.input = slotted.input || (() => {
1016
+ return document.createElement("select");
1017
+ })();
1018
+ const id = input.getAttribute('id') || this.getAttribute('input-id') || Attributes.uid('ful-select');
1019
+ Attributes.forward('input-', this, input);
1020
+ Attributes.defaultValue(input, "id", id);
1021
+ Attributes.defaultValue(input, "placeholder", " ");
1022
+ const name = input.getAttribute('name');
1023
+ input.setValue = this.setValue.bind(this);
1024
+ input.getValue = this.getValue.bind(this);
1025
+
1026
+ //tomselect needs the input to have a parent.
1027
+ //se we move the input to a fragment
1028
+ slotted.input = Fragments.from(input);
994
1029
 
995
- return function () {
996
- args = arguments;
997
- previousTimestamp = new Date().getTime();
998
- if (tid === null) {
999
- tid = setTimeout(later, timeoutMs);
1000
- if (opts === timing.DEBOUNCE_IMMEDIATE) {
1001
- func(...args);
1030
+ this.loaded = !remote;
1031
+ this.ts = new TomSelect(input, Object.assign(remote ? {
1032
+ preload: 'focus',
1033
+ load: async (query, callback) => {
1034
+ if (this.loaded) {
1035
+ callback();
1036
+ return;
1002
1037
  }
1038
+ const data = await this.fire('load', query, []);
1039
+ this.loaded = true;
1040
+ callback(data);
1003
1041
  }
1004
- };
1005
- },
1006
- THROTTLE_DEFAULT: 0,
1007
- THROTTLE_NO_LEADING: 1,
1008
- THROTTLE_NO_TRAILING: 2,
1009
- throttle(timeoutMs, func, options) {
1010
- let tid = null;
1011
- let args = [];
1012
- let previousTimestamp = 0;
1013
- let opts = options || timing.THROTTLE_DEFAULT;
1042
+ } : {}, this.tsConfig));
1014
1043
 
1015
- const later = () => {
1016
- previousTimestamp = (opts & timing.THROTTLE_NO_LEADING) ? 0 : new Date().getTime();
1017
- tid = null;
1018
- func(...args);
1019
- if (tid === null) {
1020
- args = [];
1044
+ return template.render({ id, name, floating, slotted });
1045
+ }
1046
+ async setValue(v) {
1047
+ if (!this.loaded) {
1048
+ await this.ts.load();
1049
+ }
1050
+ this.ts.setValue(v);
1051
+ }
1052
+ getValue() {
1053
+ const v = this.ts.getValue();
1054
+ return v === '' ? null : v;
1055
+ }
1056
+ static custom(tagName, configuration) {
1057
+ customElements.define(tagName, class extends Select {
1058
+ constructor() {
1059
+ super(configuration);
1021
1060
  }
1022
- };
1061
+ });
1062
+ }
1063
+ static configure() {
1064
+ return Select.custom('ful-select');
1065
+ }
1023
1066
 
1024
- return function () {
1025
- const now = new Date().getTime();
1026
- if (!previousTimestamp && (opts & timing.THROTTLE_NO_LEADING)) {
1027
- previousTimestamp = now;
1028
- }
1029
- const remaining = timeoutMs - (now - previousTimestamp);
1030
- args = arguments;
1031
- if (remaining <= 0 || remaining > timeoutMs) {
1032
- if (tid !== null) {
1033
- clearTimeout(tid);
1034
- tid = null;
1035
- }
1036
- previousTimestamp = now;
1037
- func(...args);
1038
- if (tid === null) {
1039
- args = [];
1040
- }
1041
- } else if (tid === null && !(opts & timing.THROTTLE_NO_TRAILING)) {
1042
- tid = setTimeout(later, remaining);
1043
- }
1044
- };
1067
+ }
1045
1068
 
1069
+ class Spinner extends Templated(HTMLElement) {
1070
+ render(slotted, template) {
1071
+ return Fragments.fromHtml(`
1072
+ <div class="spinner-border spinner-border-sm" aria-hidden="true"></div>
1073
+ `);
1046
1074
  }
1047
- };
1075
+ static configure() {
1076
+ customElements.define('ful-spinner', Spinner);
1077
+ }
1078
+ }
1048
1079
 
1049
1080
  class Wizard extends HTMLElement {
1050
1081
  constructor() {
@@ -1151,5 +1182,5 @@ class App {
1151
1182
  }
1152
1183
  }
1153
1184
 
1154
- export { App, AuthorizationCodeFlow, AuthorizationCodeFlowInterceptor, AuthorizationCodeFlowSession, Base64, CustomElements, Errors, Failure, FieldError, Form, Hex, HttpClient, Input, LocalStorage, Observable, Select, SessionStorage, Spinner, VersionedStorage, Wizard, jsonPatch, jsonPost, jsonPut, jsonRequest, timing };
1185
+ export { App, Attributes, AuthorizationCodeFlow, AuthorizationCodeFlowInterceptor, AuthorizationCodeFlowSession, Base64, Errors, Failure, FieldError, Form, Fragments, Hex, HttpClient, Input, LocalStorage, Observable, Select, SessionStorage, Slots, Spinner, Stateful, Templated, VersionedStorage, Wizard, jsonPatch, jsonPost, jsonPut, jsonRequest, timing };
1155
1186
  //# sourceMappingURL=ful.mjs.map