@optionfactory/ful 0.104.0 → 1.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/dist/ful.iife.js CHANGED
@@ -1,4 +1,4 @@
1
- var ful = (function (exports, ftl, TomSelect) {
1
+ var ful = (function (exports, ftl) {
2
2
  'use strict';
3
3
 
4
4
  class Base64 {
@@ -66,10 +66,10 @@ var ful = (function (exports, ftl, TomSelect) {
66
66
  }
67
67
  static encode(bytes, upper) {
68
68
  return Array.from(bytes)
69
- .map(b => b.toString(16))
70
- .map(b => upper ? b.toUpperCase() : b)
71
- .map(o => o.padStart(2, 0))
72
- .join('');
69
+ .map(b => b.toString(16))
70
+ .map(b => upper ? b.toUpperCase() : b)
71
+ .map(o => o.padStart(2, 0))
72
+ .join('');
73
73
  }
74
74
  }
75
75
 
@@ -121,7 +121,6 @@ var ful = (function (exports, ftl, TomSelect) {
121
121
  }
122
122
  }
123
123
 
124
-
125
124
  /**
126
125
  * @typedef {Int8Array| Uint8Array| Uint8ClampedArray| Int16Array| Uint16Array| Int32Array| Uint32Array| Float32Array| Float64Array| BigInt64Array| BigUint64Array} TypedArray
127
126
  */
@@ -390,7 +389,6 @@ var ful = (function (exports, ftl, TomSelect) {
390
389
  }
391
390
  };
392
391
 
393
-
394
392
  class HttpRequestBuilder {
395
393
  #client;
396
394
  #method;
@@ -451,7 +449,7 @@ var ful = (function (exports, ftl, TomSelect) {
451
449
  this.#headers.delete(k);
452
450
  } else {
453
451
  this.#headers.set(k, v);
454
- }
452
+ }
455
453
  }
456
454
  return this;
457
455
  }
@@ -485,16 +483,17 @@ var ful = (function (exports, ftl, TomSelect) {
485
483
  return this;
486
484
  }
487
485
  /**
488
- * Adds a query parameter to the request, overriding it if it already exists. Null and undefined values cause the key to be removed.
486
+ * Adds a query parameter to the request, overriding it if it already exists. Empty vs, or a single null or undefined value cause the key to be removed.
489
487
  * @param {string} k
490
- * @param {string} v
488
+ * @param {...string} vs
491
489
  * @returns {HttpRequestBuilder} this builder
492
490
  */
493
- param(k, v) {
494
- if (v === null || v === undefined) {
491
+ param(k, ...vs) {
492
+ if (vs.length === 0 || vs[0] === null || vs[0] === undefined) {
495
493
  this.#params.delete(k);
496
- } else {
497
- this.#params.set(k, v);
494
+ }
495
+ for (const v of vs) {
496
+ this.#params.append(k, v);
498
497
  }
499
498
  return this;
500
499
  }
@@ -539,7 +538,6 @@ var ful = (function (exports, ftl, TomSelect) {
539
538
  */
540
539
  options(kvs) {
541
540
  for (const [k, v] of Object.entries(kvs)) {
542
- // @ts-ignore
543
541
  this.#options[k] = v;
544
542
  }
545
543
  return this;
@@ -647,7 +645,6 @@ var ful = (function (exports, ftl, TomSelect) {
647
645
  }
648
646
  }
649
647
 
650
-
651
648
  class HttpMultipartRequestCustomizer {
652
649
  #formData;
653
650
  /**
@@ -744,14 +741,14 @@ var ful = (function (exports, ftl, TomSelect) {
744
741
  }
745
742
 
746
743
  class VersionedStorage {
747
- constructor(storage, key, dataSupplier){
744
+ constructor(storage, key, dataSupplier) {
748
745
  this.storage = storage;
749
746
  this.key = key;
750
747
  this.dataSupplier = dataSupplier;
751
748
  this.cache = null;
752
-
749
+
753
750
  }
754
- async load(revision){
751
+ async load(revision) {
755
752
  const saved = this.storage.load(this.key);
756
753
  if (!!saved && saved.revision === revision) {
757
754
  this.cache = saved.value;
@@ -764,13 +761,13 @@ var ful = (function (exports, ftl, TomSelect) {
764
761
  });
765
762
  this.cache = freshData;
766
763
  }
767
- data(){
764
+ data() {
768
765
  return this.cache;
769
766
  }
770
767
  }
771
768
 
772
769
  class AuthorizationCodeFlow {
773
- static forKeycloak(clientId, realmBaseUrl, redirectUri){
770
+ static forKeycloak(clientId, realmBaseUrl, redirectUri) {
774
771
  const scope = "openid profile";
775
772
  return new AuthorizationCodeFlow(clientId, scope, {
776
773
  auth: new URL("protocol/openid-connect/auth", realmBaseUrl),
@@ -778,15 +775,15 @@ var ful = (function (exports, ftl, TomSelect) {
778
775
  logout: new URL("protocol/openid-connect/logout", realmBaseUrl),
779
776
  registration: new URL("protocol/openid-connect/registrations", realmBaseUrl),
780
777
  redirect: redirectUri
781
- });
778
+ });
782
779
  }
783
- constructor(clientId, scope, {auth, token, registration, logout, redirect}) {
780
+ constructor(clientId, scope, { auth, token, registration, logout, redirect }) {
784
781
  this.storage = new SessionStorage(clientId);
785
782
  this.clientId = clientId;
786
783
  this.scope = scope;
787
- this.uri = {auth, token, registration, logout, redirect};
784
+ this.uri = { auth, token, registration, logout, redirect };
788
785
  }
789
- async action(uri, additionalParams){
786
+ async action(uri, additionalParams) {
790
787
  const pkceVerifier = Base64.encode(crypto.getRandomValues(new Uint8Array(32)).buffer);
791
788
  const pkceChallenge = Base64.encode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pkceVerifier)));
792
789
  const state = this.clientId + Base64.encode(crypto.getRandomValues(new Uint8Array(16)).buffer);
@@ -807,10 +804,10 @@ var ful = (function (exports, ftl, TomSelect) {
807
804
  });
808
805
  window.location.href = url.toString();
809
806
  }
810
- async registration(additionalParams){
807
+ async registration(additionalParams) {
811
808
  await this.action(this.uri.registration, additionalParams);
812
809
  }
813
- async applicationInitiatedAction(kcAction, additionalParams){
810
+ async applicationInitiatedAction(kcAction, additionalParams) {
814
811
  await this.action(this.uri.auth, {
815
812
  ...additionalParams,
816
813
  kc_action: kcAction,
@@ -867,8 +864,8 @@ var ful = (function (exports, ftl, TomSelect) {
867
864
  payload: JSON.parse(utf8decoder.decode(Base64.decode(rawPayload, Base64.STANDARD))),
868
865
  signature: signature
869
866
  };
870
- }
871
- constructor(clientId, t, {token, logout, redirect}) {
867
+ }
868
+ constructor(clientId, t, { token, logout, redirect }) {
872
869
  this.clientId = clientId;
873
870
  this.token = t;
874
871
  this.idToken = AuthorizationCodeFlowSession.parseToken(t.id_token);
@@ -928,9 +925,9 @@ var ful = (function (exports, ftl, TomSelect) {
928
925
  bearerToken() {
929
926
  return `Bearer ${this.token.access_token}`;
930
927
  }
931
-
932
- interceptor(gracePeriodBefore, gracePeriodAfter){
933
- return new AuthorizationCodeFlowInterceptor(this, gracePeriodBefore, gracePeriodAfter);
928
+
929
+ interceptor(gracePeriodBefore, gracePeriodAfter) {
930
+ return new AuthorizationCodeFlowInterceptor(this, gracePeriodBefore, gracePeriodAfter);
934
931
  }
935
932
  }
936
933
 
@@ -958,11 +955,21 @@ var ful = (function (exports, ftl, TomSelect) {
958
955
  },
959
956
  DEBOUNCE_DEFAULT: 0,
960
957
  DEBOUNCE_IMMEDIATE: 1,
958
+ /**
959
+ * Executes only after a period of inactivity (pause in events).
960
+ * Delays execution until events stop for a set duration.
961
+ * Consolidates multiple rapid events into a single execution.
962
+ * Respond to the "end" of a series of events.
963
+ * @param {*} timeoutMs
964
+ * @param {*} func
965
+ * @param {*} [options]
966
+ * @returns {[function, function]}
967
+ */
961
968
  debounce(timeoutMs, func, options) {
969
+ const opts = options ?? timing.DEBOUNCE_DEFAULT;
962
970
  let tid = null;
963
971
  let args = [];
964
972
  let previousTimestamp = 0;
965
- let opts = options || timing.DEBOUNCE_DEFAULT;
966
973
 
967
974
  const later = () => {
968
975
  const elapsed = new Date().getTime() - previousTimestamp;
@@ -980,7 +987,7 @@ var ful = (function (exports, ftl, TomSelect) {
980
987
  }
981
988
  };
982
989
 
983
- return function () {
990
+ const debounced = function () {
984
991
  args = [...arguments];
985
992
  previousTimestamp = new Date().getTime();
986
993
  if (tid === null) {
@@ -990,15 +997,23 @@ var ful = (function (exports, ftl, TomSelect) {
990
997
  }
991
998
  }
992
999
  };
1000
+ const abort = () => clearTimeout(tid);
1001
+ return [debounced, abort];
993
1002
  },
994
1003
  THROTTLE_DEFAULT: 0,
995
1004
  THROTTLE_NO_LEADING: 1,
996
1005
  THROTTLE_NO_TRAILING: 2,
1006
+ /**
1007
+ * Executes at most once per specified time interval, regardless of ongoing events.
1008
+ * Executes regularly as long as events are firing, but at a controlled rate.
1009
+ * Allows execution periodically during a burst of events.
1010
+ * Ensure a function doesn't fire too frequently during continuous events.
1011
+ */
997
1012
  throttle(timeoutMs, func, options) {
1013
+ const opts = options ?? timing.THROTTLE_DEFAULT;
998
1014
  let tid = null;
999
1015
  let args = [];
1000
1016
  let previousTimestamp = 0;
1001
- let opts = options || timing.THROTTLE_DEFAULT;
1002
1017
 
1003
1018
  const later = () => {
1004
1019
  previousTimestamp = (opts & timing.THROTTLE_NO_LEADING) ? 0 : new Date().getTime();
@@ -1008,8 +1023,7 @@ var ful = (function (exports, ftl, TomSelect) {
1008
1023
  args = [];
1009
1024
  }
1010
1025
  };
1011
-
1012
- return function () {
1026
+ const throttled = function () {
1013
1027
  const now = new Date().getTime();
1014
1028
  if (!previousTimestamp && (opts & timing.THROTTLE_NO_LEADING)) {
1015
1029
  previousTimestamp = now;
@@ -1030,10 +1044,27 @@ var ful = (function (exports, ftl, TomSelect) {
1030
1044
  tid = setTimeout(later, remaining);
1031
1045
  }
1032
1046
  };
1033
-
1047
+ const abort = () => clearTimeout(tid);
1048
+ return [throttled, abort];
1034
1049
  }
1035
1050
  };
1036
1051
 
1052
+ class Loaders {
1053
+ static fromAttributes(el, defaultLoader, options) {
1054
+ const http = ftl.registry.component("http-client");
1055
+ const requestMapper = el.hasAttribute("request-mapper") ? ftl.registry.component(el.getAttribute("request-mapper")) : v => v;
1056
+ const responseMapper = el.hasAttribute("response-mapper") ? ftl.registry.component(el.getAttribute("response-mapper")) : v => v;
1057
+ const loaderClass = ftl.registry.component(el.getAttribute("loader") ?? defaultLoader);
1058
+ return loaderClass.create({
1059
+ el,
1060
+ http,
1061
+ requestMapper,
1062
+ responseMapper,
1063
+ options: options ?? {}
1064
+ });
1065
+ }
1066
+ }
1067
+
1037
1068
  class Bindings {
1038
1069
 
1039
1070
  /**
@@ -1108,13 +1139,15 @@ var ful = (function (exports, ftl, TomSelect) {
1108
1139
  return el.value;
1109
1140
  }
1110
1141
 
1111
- static extractFrom(root, ignoredChildrenSelector){
1142
+ /**
1143
+ *
1144
+ * @param {HTMLFormElement} form
1145
+ * @returns
1146
+ */
1147
+ static extractFrom(form){
1112
1148
  let result = {};
1113
- for(const el of /** @type {NodeListOf<HTMLElement>} */(root.querySelectorAll('[name]'))){
1114
- if (el.dataset['fulBindInclude'] === 'never') {
1115
- continue;
1116
- }
1117
- if(ignoredChildrenSelector && el.dataset['fulBindInclude'] !== 'always' && el.closest(ignoredChildrenSelector) !== null){
1149
+ for(const el of form.elements){
1150
+ if(!el.hasAttribute("name") || el.matches(":disabled")){
1118
1151
  continue;
1119
1152
  }
1120
1153
  result = Bindings.providePath(result, /** @type {string} */(el.getAttribute('name')), Bindings.extract(el));
@@ -1139,70 +1172,140 @@ var ful = (function (exports, ftl, TomSelect) {
1139
1172
  el.value = raw;
1140
1173
  }
1141
1174
 
1142
- static mutateIn(root, values){
1175
+ static mutateIn(form, values){
1143
1176
  for (const [flattenedKey, value] of Object.entries(Bindings.flatten(values, ''))) {
1144
- for(const el of root.querySelectorAll(`[name='${CSS.escape(flattenedKey)}']`)){
1177
+ for(const el of form.querySelectorAll(`[name='${CSS.escape(flattenedKey)}']`)){
1145
1178
  Bindings.mutate(el, value);
1146
1179
  }
1147
1180
  }
1148
1181
  }
1149
1182
 
1150
1183
 
1151
- static errors(root, es, invalidClass){
1184
+ static errors(form, es, scrollOnError){
1152
1185
  const fieldErrors = es.filter(e => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT');
1153
1186
  const globalErrors = es.filter(e => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT');
1154
- root.querySelectorAll(`.${CSS.escape(invalidClass)}`).forEach(el => el.classList.remove(invalidClass));
1155
- root.querySelectorAll("ful-errors").forEach(el => {
1187
+ form.querySelectorAll(`[name]`).forEach(el => el.setCustomValidity?.(""));
1188
+ form.querySelectorAll("ful-errors").forEach(el => {
1156
1189
  el.replaceChildren();
1157
1190
  el.setAttribute('hidden', '');
1158
1191
  });
1159
1192
  fieldErrors.forEach(e => {
1160
- const name = e.context.replace("[", ".").replace("].", ".");
1161
- const validationTargetsSelector = `[name='${CSS.escape(name)}'] [ful-validation-target],[name='${CSS.escape(name)}']:not(:has([ful-validation-target]))`;
1162
- root.querySelectorAll(validationTargetsSelector).forEach(input => input.classList.add(invalidClass));
1163
- const fieldErrorsSelector = `ful-field-error[field='${CSS.escape(name)}']`;
1164
- root.querySelectorAll(fieldErrorsSelector).forEach(el => {
1165
- const hel = /** @type HTMLElement} */ (el);
1166
- hel.innerText = e.reason;
1167
- });
1193
+ const name = e.context.replace("[", ".").replace("].", ".").replace("]", "");
1194
+ form.querySelectorAll(`[name='${CSS.escape(name)}']`).forEach(input => input.setCustomValidity?.(e.reason));
1168
1195
  });
1169
- root.querySelectorAll("ful-errors").forEach(el => {
1196
+ form.querySelectorAll("ful-errors").forEach(el => {
1170
1197
  const hel = /** @type HTMLElement} */ (el);
1171
1198
  hel.innerText = globalErrors.map(e => e.reason).join("\n");
1172
1199
  if (globalErrors.length !== 0) {
1173
1200
  el.removeAttribute('hidden');
1174
1201
  }
1175
1202
  });
1203
+ if (es.length == 0 || !scrollOnError) {
1204
+ return;
1205
+ }
1206
+ Array.from(form.querySelectorAll(`:invalid`)).sort((a,b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y)[0]?.focus();
1207
+ }
1208
+ }
1176
1209
 
1210
+ class RemoteJsonFormLoader {
1211
+ #http;
1212
+ #url;
1213
+ #method;
1214
+ #requestMapper;
1215
+ #responseMapper;
1216
+ constructor(http, url, method, requestMapper, responseMapper) {
1217
+ this.#http = http;
1218
+ this.#url = url;
1219
+ this.#method = method;
1220
+ this.#requestMapper = requestMapper;
1221
+ this.#responseMapper = responseMapper;
1222
+ }
1223
+ prepare(values, form) {
1224
+ return this.#requestMapper(values, form);
1225
+ }
1226
+ async submit(values, form) {
1227
+ return await this.#http.request(this.#method, this.#url)
1228
+ .json(values)
1229
+ .fetch()
1230
+ }
1231
+ transform(response, form) {
1232
+ return this.#responseMapper(response, form);
1233
+ }
1234
+ }
1177
1235
 
1236
+ class LocalFormLoader {
1237
+ #requestMapper;
1238
+ #responseMapper;
1239
+ constructor(requestMapper, responseMapper) {
1240
+ this.#requestMapper = requestMapper;
1241
+ this.#responseMapper = responseMapper;
1242
+ }
1243
+ async prepare(values, form) {
1244
+ return await this.#requestMapper(values, form);
1245
+ }
1246
+ async submit(values, form) {
1247
+ return values;
1248
+ }
1249
+ async transform(response, form) {
1250
+ return await this.#responseMapper(response, form);
1178
1251
  }
1179
1252
  }
1180
1253
 
1181
- class Form extends ftl.ParsedElement() {
1182
- static IGNORED_CHILDREN_SELECTOR = '.d-none, [hidden]';
1183
- static SCROLL_OFFSET = 50;
1184
- static INVALID_CLASS = 'is-invalid';
1185
- submitter;
1254
+ class FormLoader {
1255
+ static create({ el, http, requestMapper, responseMapper }) {
1256
+ const url = el.getAttribute("action");
1257
+ if (!url) {
1258
+ return new LocalFormLoader(requestMapper, responseMapper);
1259
+ }
1260
+ const method = el.getAttribute("method") ?? 'POST';
1261
+ return new RemoteJsonFormLoader(http, url, method, requestMapper, responseMapper);
1262
+ }
1263
+ }
1264
+
1265
+ class Form extends ftl.ParsedElement {
1266
+ form;
1186
1267
  render() {
1187
- const form = document.createElement('form');
1268
+ const form = this.form = document.createElement('form');
1269
+ form.setAttribute("novalidate", "");
1188
1270
  ftl.Attributes.forward('form-', this, form);
1189
1271
  form.replaceChildren(...this.childNodes);
1190
1272
  form.addEventListener('submit', async (e) => {
1191
1273
  e.preventDefault();
1192
- this.spinning(async () => {
1193
- await this.submitter?.(this.values, this);
1194
- });
1274
+ e.stopPropagation();
1275
+ await this.submit();
1195
1276
  });
1196
1277
  if (this.hasAttribute("clear-invalid-on-change")) {
1197
- this.addEventListener('change', evt => {
1198
- const target = /** @type HTMLElement */ (evt.target);
1199
- target?.querySelectorAll(`.${CSS.escape(Form.INVALID_CLASS)}`).forEach(el => {
1200
- el.classList.remove(Form.INVALID_CLASS);
1201
- });
1278
+ this.addEventListener('change', (/** @type any */evt) => {
1279
+ evt.target.setCustomValidity?.("");
1202
1280
  });
1203
1281
  }
1204
1282
  this.replaceChildren(form);
1205
1283
  }
1284
+ async submit() {
1285
+ this.spinner(true);
1286
+ try {
1287
+ const loader = Loaders.fromAttributes(this, 'loaders:form');
1288
+ const values = this.values;
1289
+ const request = await loader.prepare(values, this);
1290
+ const se = new CustomEvent('submit', { bubbles: true, cancelable: true, detail: { values, request } });
1291
+ if (!this.dispatchEvent(se)) {
1292
+ return;
1293
+ }
1294
+ try {
1295
+ const response = await loader.submit(se.detail.request, this);
1296
+ const mapped = await loader.transform(response, this);
1297
+ this.dispatchEvent(new CustomEvent('submit:success', { bubbles: true, cancelable: false, detail: { values, request, response: mapped } }));
1298
+ } catch (e) {
1299
+ this.dispatchEvent(new CustomEvent('submit:failure', { bubbles: true, cancelable: false, detail: { values, request, exception: e } }));
1300
+ if (e instanceof Failure) {
1301
+ this.errors = e.problems;
1302
+ }
1303
+ throw e;
1304
+ }
1305
+ } finally {
1306
+ this.spinner(false);
1307
+ }
1308
+ }
1206
1309
  spinner(spin) {
1207
1310
  this.querySelectorAll('ful-spinner').forEach(el => {
1208
1311
  const hel = /** @type HTMLElement */ (el);
@@ -1213,238 +1316,452 @@ var ful = (function (exports, ftl, TomSelect) {
1213
1316
  hel.disabled = spin;
1214
1317
  });
1215
1318
  }
1216
- async remoting(fn) {
1217
- try {
1218
- await fn();
1219
- } catch (e) {
1220
- if (e instanceof Failure) {
1221
- this.errors = e.problems;
1222
- }
1223
- throw e;
1224
- }
1225
- }
1226
- async spinningUntilError(fn) {
1227
- this.spinner(true);
1228
- try {
1229
- await this.remoting(fn);
1230
- } catch (e) {
1231
- this.spinner(false);
1232
- throw e;
1233
- }
1234
- }
1235
- async spinning(fn) {
1236
- this.spinner(true);
1237
- try {
1238
- await this.remoting(fn);
1239
- } finally {
1240
- this.spinner(false);
1241
- }
1242
- }
1243
1319
  set values(vs) {
1244
- Bindings.mutateIn(this, vs);
1320
+ Bindings.mutateIn(this.form, vs);
1245
1321
  }
1246
1322
  get values() {
1247
- return Bindings.extractFrom(this, Form.IGNORED_CHILDREN_SELECTOR);
1323
+ return Bindings.extractFrom(this.form);
1248
1324
  }
1249
1325
  set errors(es) {
1250
- Bindings.errors(this, es, Form.INVALID_CLASS);
1251
- if (es.length == 0 || !this.hasAttribute('scroll-on-error')) {
1252
- return;
1253
- }
1254
- const ys = Array.from(this.querySelectorAll(`ful-errors:not([hidden]), [ful-validated-field]:has(.${Form.INVALID_CLASS}) ful-field-error`))
1255
- .map(el => el.parentElement ? el.parentElement : el)
1256
- .map(el => el.getBoundingClientRect().y + window.scrollY);
1257
- const miny = Math.min(...ys);
1258
- if (miny !== Infinity) {
1259
- window.scroll(window.scrollX, miny > Form.SCROLL_OFFSET ? miny - Form.SCROLL_OFFSET : 0);
1260
- }
1326
+ Bindings.errors(this.form, es, this.hasAttribute('scroll-on-error'));
1261
1327
  }
1262
1328
  }
1263
1329
 
1264
- const INPUT_TEMPLATE = `
1265
- <div ful-validated-field>
1266
- <label data-tpl-for="id" class="form-label">{{{{ slots.default }}}}</label>
1267
- <div class="input-group">
1268
- <span data-tpl-if="slots.ibefore" class="input-group-text">{{{{ slots.ibefore }}}}</span>
1269
- <div data-tpl-if="slots.before" data-tpl-remove="tag">{{{{ slots.before }}}}</div>
1270
- {{{{ slots.input }}}}
1271
- <div data-tpl-if="slots.after" data-tpl-remove="tag">{{{{ slots.after }}}}</div>
1272
- <span data-tpl-if="slots.iafter" class="input-group-text">{{{{ slots.iafter }}}}</span>
1273
- </div>
1274
- <ful-field-error data-tpl-if="name" data-tpl-field="name"></ful-field-error>
1275
- </div>
1276
- `;
1277
-
1278
- const makeInputFragment = (el, template, slots) => {
1279
- const input = el.input = slots.input = slots.input?.firstElementChild ?? (() => {
1280
- const el = document.createElement("input");
1281
- el.classList.add("form-control");
1282
- return el;
1283
- })();
1284
- input.setAttribute('ful-validation-target', '');
1285
- input.addEventListener('change', (evt) => {
1286
- evt.stopPropagation();
1287
- el.dispatchEvent(new CustomEvent('change', {
1288
- bubbles: true,
1289
- cancelable: false,
1290
- detail: {
1291
- value: el.value
1292
- }
1293
- }));
1294
- });
1295
- const id = input.getAttribute('id') ?? el.getAttribute('input-id') ?? ftl.Attributes.uid('ful-input');
1296
- ftl.Attributes.forward('input-', el, slots.input);
1297
- ftl.Attributes.defaultValue(slots.input, "id", id);
1298
- ftl.Attributes.defaultValue(slots.input, "type", "text");
1299
- ftl.Attributes.defaultValue(slots.input, "placeholder", " ");
1300
- const name = el.getAttribute('name');
1301
- return template.withOverlay(el, { id, name, slots }).render();
1302
- };
1303
-
1304
- class Input extends ftl.ParsedElement({
1305
- observed: ['value'],
1306
- slots: true,
1307
- template: INPUT_TEMPLATE
1308
- }){
1309
- input;
1310
- render({slots}) {
1311
- const fragment = makeInputFragment(this, this.template(), slots);
1330
+ class Input extends ftl.ParsedElement {
1331
+ static observed = ['value'];
1332
+ static slots = true;
1333
+ static template = `
1334
+ <label data-tpl-for="id" class="form-label">{{{{ slots.default }}}}</label>
1335
+ <div class="input-group">
1336
+ <span data-tpl-if="slots.ibefore" class="input-group-text">{{{{ slots.ibefore }}}}</span>
1337
+ {{{{ slots.before }}}}
1338
+ <input class="form-control" data-tpl-id="id" type="text" placeholder=" " data-tpl-aria-describedby="fieldErrorId" form="">
1339
+ {{{{ slots.after }}}}
1340
+ <span data-tpl-if="slots.iafter" class="input-group-text">{{{{ slots.iafter }}}}</span>
1341
+ </div>
1342
+ <ful-field-error data-tpl-id="fieldErrorId"></ful-field-error>
1343
+ `;
1344
+ static formAssociated = true;
1345
+ #input;
1346
+ #fieldError;
1347
+ constructor() {
1348
+ super();
1349
+ this.internals = this.attachInternals();
1350
+ }
1351
+ render({ slots }) {
1352
+ const id = ftl.Attributes.uid('ful-input');
1353
+ const fieldErrorId = `${id}-error`;
1354
+
1355
+ const fragment = this.template().withOverlay({ id, fieldErrorId, slots }).render();
1356
+ this.#input = fragment.querySelector("input");
1357
+ ftl.Attributes.forward('input-', this, this.#input);
1358
+ this.#input.addEventListener('change', (evt) => {
1359
+ evt.stopPropagation();
1360
+ this.dispatchEvent(new CustomEvent('change', {
1361
+ bubbles: true,
1362
+ cancelable: false,
1363
+ detail: {
1364
+ value: this.value
1365
+ }
1366
+ }));
1367
+ });
1368
+ this.#fieldError = fragment.querySelector('ful-field-error');
1312
1369
  this.replaceChildren(fragment);
1313
1370
  }
1314
1371
  get value() {
1315
- return this.input.value;
1372
+ return this.#input.value;
1316
1373
  }
1317
1374
  set value(value) {
1318
- this.input.value = value;
1375
+ this.#input.value = value;
1376
+ }
1377
+ focus(options) {
1378
+ this.#input.focus(options);
1379
+ }
1380
+ setCustomValidity(error) {
1381
+ if (!error) {
1382
+ this.internals.setValidity({});
1383
+ this.#fieldError.innerText = "";
1384
+ return;
1385
+ }
1386
+ this.internals.setValidity({ customError: true }, " ");
1387
+ this.#fieldError.innerText = error;
1319
1388
  }
1320
1389
  }
1321
1390
 
1322
- /**
1323
- * <script src="tom-select.complete.js"></script>
1324
- * <link href="tom-select.bootstrap5.css" rel="stylesheet" />
1325
- */
1391
+ class CompleteSelectLoader {
1392
+ #http;
1393
+ #url;
1394
+ #method;
1395
+ #responseMapper;
1396
+ #prefetch;
1397
+ #data;
1398
+ constructor(http, url, method, responseMapper, prefetch) {
1399
+ this.#http = http;
1400
+ this.#url = url;
1401
+ this.#method = method;
1402
+ this.#responseMapper = responseMapper;
1403
+ this.#prefetch = prefetch;
1404
+ this.#data = null;
1405
+ }
1406
+ async prefetch() {
1407
+ if (!this.#prefetch) {
1408
+ return;
1409
+ }
1410
+ await this.#ensureFetched();
1411
+ }
1412
+ async exact(...keys) {
1413
+ await this.#ensureFetched();
1414
+ return this.#data.filter(([k, v]) => keys.includes(k));
1415
+ }
1416
+ async load(needle) {
1417
+ await this.#ensureFetched();
1418
+ return this.#data.filter(([k, v]) => v.includes(needle?.toLowerCase()));
1419
+ }
1420
+ async #ensureFetched() {
1421
+ if (this.#data !== null) {
1422
+ return
1423
+ }
1424
+ const data = await this.#http.request(this.#method, this.#url)
1425
+ .fetchJson();
1426
+ this.#data = this.#responseMapper(data);
1427
+ }
1428
+ static create({ el, http, responseMapper }) {
1429
+ return new CompleteSelectLoader(
1430
+ http,
1431
+ el.getAttribute("src"),
1432
+ el.getAttribute("method") ?? 'POST',
1433
+ responseMapper,
1434
+ el.hasAttribute("preload")
1435
+ );
1436
+ }
1437
+ }
1326
1438
 
1327
- class Select extends ftl.ParsedElement({
1328
- observed: ["value"],
1329
- slots: true,
1330
- template: `
1331
- <div ful-validated-field>
1332
- <label data-tpl-for="tsId" class="form-label">{{{{ slots.default }}}}</label>
1333
- {{{{ input }}}}
1334
- <div class="input-group">
1335
- <span data-tpl-if="slots.ibefore" class="input-group-text">{{{{ slots.ibefore }}}}</span>
1336
- <div data-tpl-if="slots.before" data-tpl-remove="tag">{{{{ slots.before }}}}</div>
1337
- {{{{ slots.input }}}}
1338
- <div data-tpl-if="slots.after" data-tpl-remove="tag">{{{{ slots.after }}}}</div>
1339
- <span data-tpl-if="slots.iafter" class="input-group-text">{{{{ slots.iafter }}}}</span>
1340
- </div>
1341
- <ful-field-error data-tpl-if="name" data-tpl-field="name"></ful-field-error>
1342
- </div>
1343
- `
1344
- }) {
1345
- shouldLoad;
1346
- _unwrappedRemoteLoad;
1347
- ts;
1348
- constructor(tsConfig) {
1349
- super();
1350
- this.tsConfig = tsConfig;
1351
- }
1352
- render({slots}) {
1353
- const type = this.getAttribute("type") ?? 'local';
1354
- const remote = type != 'local';
1355
- const loadOnce = this.getAttribute('load') != 'always';
1356
- const name = this.getAttribute('name');
1357
- const input = slots.input = slots.input?.firstElementChild ?? (() => {
1358
- return document.createElement("select");
1359
- })();
1360
- input.setAttribute('ful-validation-target', '');
1439
+ class ChunkedSelectLoader {
1440
+ #http;
1441
+ #url;
1442
+ #method;
1443
+ #responseMapper;
1444
+ constructor(http, url, method, responseMapper) {
1445
+ this.#http = http;
1446
+ this.#url = url;
1447
+ this.#method = method;
1448
+ this.#responseMapper = responseMapper;
1449
+ }
1450
+ async exact(...keys) {
1451
+ const data = await this.#http.request(this.#method, this.#url)
1452
+ .param("k", ...keys)
1453
+ .fetchJson();
1454
+ return this.#responseMapper(data);
1455
+ }
1456
+ async load(needle) {
1457
+ const data = await this.#http.request(this.#method, this.#url)
1458
+ .param("s", needle)
1459
+ .fetchJson();
1460
+ return this.#responseMapper(data);
1461
+ }
1462
+ static create({ el, http, responseMapper }) {
1463
+ return new ChunkedSelectLoader(
1464
+ http,
1465
+ el.getAttribute("src"),
1466
+ el.getAttribute("method") ?? 'POST',
1467
+ responseMapper
1468
+ );
1469
+ }
1470
+ }
1361
1471
 
1362
- const id = input.getAttribute('id') ?? this.getAttribute('input-id') ?? ftl.Attributes.uid('ful-select');
1363
- const tsId = `${id}-ts-control`;
1364
- ftl.Attributes.forward('input-', this, input);
1365
- ftl.Attributes.defaultValue(input, "id", id);
1366
- ftl.Attributes.defaultValue(input, "placeholder", " ");
1472
+ class OptionsSlotSelectLoader {
1473
+ #data
1474
+ constructor(data) {
1475
+ this.#data = data;
1476
+ }
1477
+ async exact(...keys) {
1478
+ await timing.sleep(500);
1479
+ return this.#data.filter(([k, v]) => keys.includes(k));
1480
+ }
1481
+ async load(needle) {
1482
+ await timing.sleep(500);
1483
+ return this.#data.filter(([k, v]) => v.includes(needle?.toLowerCase()));
1484
+ }
1485
+ }
1367
1486
 
1368
- //tomselect needs the input to have a parent.
1369
- //se we move the input to a fragment
1370
- slots.input = ftl.Fragments.from(input);
1371
1487
 
1372
- this.loaded = !remote;
1488
+ class SelectLoader {
1489
+ static create(conf) {
1490
+ if (!conf.el.hasAttribute("src")) {
1491
+ const els = Array.from(conf.options.options?.querySelectorAll('option') ?? []);
1492
+ const data = els.map(e => {
1493
+ return [e.getAttribute("value") ?? e.innerText.trim(), e.innerText.trim()];
1494
+ });
1495
+ return new OptionsSlotSelectLoader(data);
1496
+ }
1497
+ const chunked = "chunked" == conf.el.getAttribute("mode");
1498
+ return chunked ? ChunkedSelectLoader.create(conf) : CompleteSelectLoader.create(conf);
1499
+ }
1500
+ }
1373
1501
 
1374
- const tsDefaultConfig = {
1375
- render: {
1376
- loading: () => '<ful-spinner class="centered p-2"></ful-spinner>'
1502
+ class Dropdown extends ftl.ParsedElement {
1503
+ static slots = true
1504
+ static template = `
1505
+ <ful-spinner class="centered" hidden></ful-spinner>
1506
+ <menu tabindex="-1" hidden></menu>
1507
+ `;
1508
+ #spinner
1509
+ #menu
1510
+ render({ slots }) {
1511
+ const fragment = this.template().render();
1512
+ this.#spinner = fragment.querySelector("ful-spinner");
1513
+ this.#menu = fragment.querySelector("menu");
1514
+ this.#menu.addEventListener('click', evt => {
1515
+ evt.stopPropagation();
1516
+ if (!evt.target.matches('li')) {
1517
+ this.hide();
1518
+ return;
1377
1519
  }
1378
- };
1520
+ this.#change(evt.target);
1521
+ });
1522
+ this.replaceChildren(fragment);
1523
+ }
1524
+ acceptSelection() {
1525
+ const selected = this.#menu.querySelector('[selected]') ?? this.#menu.firstElementChild;
1526
+ this.#change(selected);
1527
+ }
1528
+ update(values) {
1529
+ if (values === undefined) {
1530
+ throw new Error("null data");
1531
+ }
1532
+ if (values.length === 0) {
1533
+ const el = document.createElement('div');
1534
+ el.classList.add('text-center', 'py-2', 'bi', 'bi-database-slash');
1535
+ this.#menu.replaceChildren(el);
1536
+ return;
1537
+ }
1538
+ this.#menu.replaceChildren(...values.map(([k, v], i) => {
1539
+ const el = document.createElement('li');
1540
+ if (i === 0) {
1541
+ el.setAttribute("selected", '');
1542
+ }
1543
+ el.setAttribute("value", k);
1544
+ el.innerText = v;
1545
+ return el;
1546
+ }));
1547
+ }
1548
+ #change(target) {
1549
+ const value = target.getAttribute('value');
1550
+ const label = target.innerText;
1551
+ this.hide();
1552
+ this.dispatchEvent(new CustomEvent('change', {
1553
+ bubbles: true,
1554
+ cancelable: false,
1555
+ detail: { label, value }
1556
+ }));
1557
+ }
1558
+ hide() {
1559
+ this.setAttribute('hidden', '');
1560
+ }
1561
+ get shown() {
1562
+ return !this.hasAttribute('hidden');
1563
+ }
1564
+ async show(loader) {
1565
+ this.removeAttribute('hidden');
1566
+ this.#menu.setAttribute('hidden', '');
1567
+ this.#spinner.removeAttribute('hidden');
1568
+ try {
1569
+ const data = await loader();
1570
+ this.update(data);
1571
+ } finally {
1572
+ this.#spinner.setAttribute('hidden', '');
1573
+ this.#menu.removeAttribute('hidden');
1574
+ }
1575
+ }
1576
+ async moveOrShow(forward, loader) {
1577
+ if (!this.hasAttribute("hidden")) {
1578
+ const selected = this.#menu.querySelector('[selected]') ?? this.#menu.firstElementChild;
1579
+ const candidate = selected[`${forward ? 'next' : 'previous'}ElementSibling`];
1580
+ if (candidate) {
1581
+ selected.removeAttribute('selected');
1582
+ candidate.setAttribute("selected", "");
1583
+ }
1584
+ return;
1585
+ }
1586
+ await this.show(loader);
1587
+ }
1588
+ }
1379
1589
 
1380
- this._remote = remote;
1381
- // we need to await this load in setValue when remote is configured and the option
1382
- // is not loaded yet.
1383
- // tomselect settings.load does not retun a promise as it wraps the configured load function
1384
- // with a debouncer
1385
- this._unwrappedRemoteLoad = async (query, callback) => {
1590
+ class Select extends ftl.ParsedElement {
1591
+ static observed = ['value:csvm']
1592
+ static slots = true
1593
+ static template = `
1594
+ <label data-tpl-for="id" class="form-label">{{{{ slots.default }}}}</label>
1595
+ <div class="input-group flex-nowrap" tabindex="-1">
1596
+ <span data-tpl-if="slots.ibefore" class="input-group-text">{{{{ slots.ibefore }}}}</span>
1597
+ {{{{ slots.before }}}}
1598
+ <div class="ful-select-input">
1599
+ <badges></badges>
1600
+ <input data-tpl-id="id" data-tpl-ariadesribed-by="fieldErrorId" type="text" form="">
1601
+ </div>
1602
+ {{{{ slots.after }}}}
1603
+ <span data-tpl-if="slots.iafter" class="input-group-text">{{{{ slots.iafter }}}}</span>
1604
+ </div>
1605
+ <ful-dropdown hidden></ful-dropdown>
1606
+ <ful-field-error data-tpl-id="fieldErrorId"></ful-field-error>
1607
+ `;
1608
+ static mappers = {
1609
+ "csvm": (v, name, el) => {
1610
+ if (el.hasAttribute("multiple")) {
1611
+ return v === null ? [] : v.split(",").map(e => e.trim()).filter(e => e)
1612
+ }
1613
+ return v === null || v === '' ? null : v
1614
+ }
1615
+ };
1616
+ static formAssociated = true
1617
+ internals
1618
+ #loader
1619
+ #badges
1620
+ #ddmenu
1621
+ #input
1622
+ #multiple
1623
+ #fieldError
1624
+ #values = new Map()
1625
+ constructor() {
1626
+ super();
1627
+ this.internals = this.attachInternals();
1628
+ }
1629
+ async render({ slots, observed }) {
1630
+ const name = this.getAttribute("name");
1631
+ const id = ftl.Attributes.uid('ful-select');
1632
+ const fieldErrorId = id + "-error";
1633
+ this.#loader = Loaders.fromAttributes(this, 'loaders:select', { options: slots.options });
1634
+ await this.#loader.prefetch?.();
1635
+ const fragment = this.template().withOverlay({ slots, name, id, fieldErrorId }).render();
1636
+ this.#input = fragment.querySelector('input');
1637
+ this.#badges = fragment.querySelector('badges');
1638
+ this.#ddmenu = fragment.querySelector('ful-dropdown');
1639
+ this.#multiple = this.hasAttribute("multiple");
1640
+ this.#fieldError = fragment.querySelector('ful-field-error');
1386
1641
 
1387
- if (!remote || remote && loadOnce && this.loaded) {
1388
- callback();
1642
+ const self = this;
1643
+ const [dload, abortdload] = timing.debounce(400, () => self.#ddmenu.show(() => self.#loader.load(self.#input.value)));
1644
+ this.addEventListener('click', (/** @type any */e) => {
1645
+ e.stopPropagation();
1646
+ if (e.target.matches('input')) {
1647
+ return;
1648
+ }
1649
+ if (this.#ddmenu.shown) {
1650
+ this.#ddmenu.hide();
1389
1651
  return;
1390
1652
  }
1391
- const type = query && query.hasOwnProperty('byId') ? 'id' : 'query';
1392
- const qvalue = type === 'id' ? query.byId : query;
1393
- const data = await (this.#loader ? this.#loader(qvalue, type) : []);
1394
- if (type !== 'id') {
1395
- this.loaded = true;
1653
+ this.#input.focus();
1654
+ dload();
1655
+ });
1656
+ this.#badges.addEventListener('click', (e) => {
1657
+ e.stopPropagation();
1658
+ const idx = [...this.#badges.children].indexOf(e.target);
1659
+ if (idx === -1) {
1660
+ return;
1396
1661
  }
1397
- callback(data);
1398
- };
1399
- this.ts = new TomSelect(input, Object.assign(remote ? {
1400
- preload: 'focus',
1401
- load: this._unwrappedRemoteLoad,
1402
- shouldLoad: (query) => this.shouldLoad ? this.shouldLoad(query) : true
1403
- } : {}, tsDefaultConfig, this.tsConfig));
1404
- this.ts.on('change', value => {
1405
- this.dispatchEvent(new CustomEvent('change', {
1406
- bubbles: true,
1407
- cancelable: false,
1408
- detail: {
1409
- value: this.value
1662
+ this.#values.delete(Array.from(this.#values.keys()).pop());
1663
+ this.#syncBadges();
1664
+ });
1665
+
1666
+ this.#input.addEventListener('blur', e => {
1667
+ if (e.relatedTarget && this.contains(e.relatedTarget)) {
1668
+ return;
1669
+ }
1670
+ abortdload();
1671
+ this.#ddmenu.hide();
1672
+ this.#input.value = '';
1673
+ });
1674
+ this.#input.addEventListener('keydown', e => {
1675
+ switch (e.code) {
1676
+ case 'ArrowUp': {
1677
+ this.#ddmenu.moveOrShow(false, () => self.#loader.load(self.#input.value));
1678
+ break;
1410
1679
  }
1411
- }));
1680
+ case 'ArrowDown': {
1681
+ this.#ddmenu.moveOrShow(true, () => self.#loader.load(self.#input.value));
1682
+ break;
1683
+ }
1684
+ case 'Escape': {
1685
+ this.#ddmenu.hide();
1686
+ break;
1687
+ }
1688
+ case 'Enter': {
1689
+ this.#ddmenu.acceptSelection();
1690
+ this.#input.value = '';
1691
+ break;
1692
+ }
1693
+ case 'Backspace': {
1694
+ //remove last if caret a position 0
1695
+ if (this.#values.size && this.#input.selectionStart === 0 && this.#input.selectionEnd === 0) {
1696
+ this.#values.delete(Array.from(this.#values.keys()).pop());
1697
+ this.#syncBadges();
1698
+ }
1699
+ break;
1700
+ }
1701
+ case 'Tab': {
1702
+ this.#ddmenu.hide();
1703
+ abortdload();
1704
+ break;
1705
+ }
1706
+ }
1412
1707
  });
1413
- //we remove the input to move it
1414
- input.addEventListener('change', (evt) => {
1415
- evt.stopPropagation();
1708
+ this.#input.addEventListener('input', e => {
1709
+ dload();
1416
1710
  });
1417
- input.remove();
1418
- this.template().withOverlay({ id, tsId, name, input, slots }).renderTo(this);
1419
- }
1420
- #loader;
1421
- set loader(l) {
1422
- this.#loader = l;
1423
- // loader can be configured later so we load now
1424
- if (this.hasAttribute('value')) {
1425
- this.value = this.getAttribute("value");
1426
- }
1711
+ this.#ddmenu.addEventListener('change', (e) => {
1712
+ if (!this.#multiple) {
1713
+ this.#values.clear();
1714
+ }
1715
+ this.#values.set(e.detail.value, e.detail.label);
1716
+ this.#syncBadges();
1717
+ this.#input.focus();
1718
+ this.#ddmenu.hide();
1719
+ });
1720
+ this.replaceChildren(fragment);
1427
1721
  }
1428
- get value() {
1429
- const v = this.ts.getValue();
1430
- return v === '' ? null : v;
1722
+ #syncBadges() {
1723
+ const badges = Array.from(this.#values.entries()).map(([k, v]) => {
1724
+ const b = document.createElement('badge');
1725
+ b.setAttribute("role", "button");
1726
+ b.setAttribute("value", k);
1727
+ b.innerText = v;
1728
+ return b;
1729
+ });
1730
+ this.#badges.innerHTML = '';
1731
+ this.#badges.append(...badges);
1431
1732
  }
1432
1733
  set value(value) {
1433
1734
  (async () => {
1434
- if (this._remote) {
1435
- await this._unwrappedRemoteLoad({ byId: value }, this.ts.loadCallback.bind(this.ts));
1436
- }
1437
- const silent = true;
1438
- this.ts.setValue(value, silent);
1735
+ const entries = await (this.#multiple ? this.#loader.exact(...value) : this.#loader.exact(value));
1736
+ this.#values = new Map(entries);
1737
+ this.#syncBadges();
1439
1738
  })();
1440
1739
  }
1740
+ get value() {
1741
+ if (this.#multiple) {
1742
+ return [...this.#values.keys()];
1743
+ }
1744
+ return [...this.#values.keys()][0] ?? null;
1745
+ }
1746
+ focus(options) {
1747
+ this.#input.focus(options);
1748
+ }
1749
+ setCustomValidity(error) {
1750
+ if (!error) {
1751
+ this.internals.setValidity({});
1752
+ this.#fieldError.innerText = "";
1753
+ return;
1754
+ }
1755
+ this.internals.setValidity({ customError: true }, " ");
1756
+ this.#fieldError.innerText = error;
1757
+ }
1441
1758
  }
1442
1759
 
1443
- class RadioGroup extends ftl.ParsedElement({
1444
- observed: ['value', 'disabled:presence'],
1445
- slots: true,
1446
- template: `
1447
- <fieldset ful-validated-field>
1760
+ class RadioGroup extends ftl.ParsedElement {
1761
+ static observed = ['value'];
1762
+ static slots = true;
1763
+ static template = `
1764
+ <fieldset data-tpl-aria-describedby="fieldErrorId">
1448
1765
  <legend class="form-label">
1449
1766
  {{{{ slots.default }}}}
1450
1767
  </legend>
@@ -1455,18 +1772,24 @@ var ful = (function (exports, ftl, TomSelect) {
1455
1772
  <div class="label-wrapper" data-tpl-each="inputsAndLabels" data-tpl-var="ial">
1456
1773
  <label>
1457
1774
  {{{{ ial[0] }}}}
1458
- {{{{ ial[1] }}}}
1775
+ <div>{{{{ ial[1] }}}}</div>
1459
1776
  </label>
1460
1777
  </div>
1461
1778
  </section>
1462
- <ful-field-error data-tpl-if="name" data-tpl-field="name"></ful-field-error>
1779
+ <ful-field-error data-tpl-id="fieldErrorId"></ful-field-error>
1463
1780
  <footer data-tpl-if="slots.footer">
1464
1781
  {{{{ slots.footer }}}}
1465
1782
  </footer>
1466
1783
  </fieldset>
1467
- `
1468
- }) {
1469
- render({slots}) {
1784
+ `;
1785
+ static formAssociated = true;
1786
+ #fieldError;
1787
+ #firstRadio;
1788
+ constructor() {
1789
+ super();
1790
+ this.internals = this.attachInternals();
1791
+ }
1792
+ render({ slots }) {
1470
1793
  const name = this.getAttribute('name') ?? ftl.Attributes.uid('ful-radiogroup');
1471
1794
  const radioEls = Array.from(slots.default.querySelectorAll('ful-radio'));
1472
1795
  const inputsAndLabels = radioEls.map(el => {
@@ -1475,8 +1798,7 @@ var ful = (function (exports, ftl, TomSelect) {
1475
1798
  ftl.Attributes.forward('input-', this, input);
1476
1799
  ftl.Attributes.forward('', el, input);
1477
1800
  input.setAttribute('name', `${name}-ignore`);
1478
- input.setAttribute('ful-validation-target', '');
1479
- input.dataset['fulBindInclude'] = 'never';
1801
+ input.setAttribute('form', ``);
1480
1802
  input.addEventListener('change', evt => {
1481
1803
  evt.stopPropagation();
1482
1804
  //change is not cancelable
@@ -1493,14 +1815,11 @@ var ful = (function (exports, ftl, TomSelect) {
1493
1815
  });
1494
1816
 
1495
1817
  radioEls.forEach(el => el.remove());
1496
- this.template().withOverlay({ name, slots, inputsAndLabels }).renderTo(this);
1497
- }
1498
- get disabled() {
1499
- return this.hasAttribute('disabled');
1818
+ const fieldErrorId = ftl.Attributes.uid("ful-error");
1819
+ this.template().withOverlay({ name, fieldErrorId, slots, inputsAndLabels }).renderTo(this);
1820
+ this.#fieldError = this.querySelector('ful-field-error');
1821
+ this.#firstRadio = this.querySelector('input[type=radio]');
1500
1822
  }
1501
- set disabled(value) {
1502
- this.reflect(() => ftl.Attributes.toggle(this, 'disabled', value));
1503
- }
1504
1823
  get value() {
1505
1824
  /** @type {HTMLInputElement|null} */
1506
1825
  const checked = this.querySelector('input[type=radio]:checked');
@@ -1519,45 +1838,780 @@ var ful = (function (exports, ftl, TomSelect) {
1519
1838
  el.checked = true;
1520
1839
  }
1521
1840
  }
1841
+ focus(options) {
1842
+ this.#firstRadio.focus(options);
1843
+ }
1844
+ setCustomValidity(error) {
1845
+ if (!error) {
1846
+ this.internals.setValidity({});
1847
+ this.#fieldError.innerText = "";
1848
+ return;
1849
+ }
1850
+ this.internals.setValidity({ customError: true }, " ");
1851
+ this.#fieldError.innerText = error;
1852
+ }
1853
+ }
1854
+
1855
+ class Checkbox extends ftl.ParsedElement {
1856
+ static observed = ['value:bool'];
1857
+ static slots = true;
1858
+ static template = `
1859
+ <div data-tpl-class="klass">
1860
+ <input dat-tpl-id="id" class="form-check-input" type="checkbox" role="switch" form="" placeholder=" " data-tpl-aria-describedby="fieldErrorId">
1861
+ <label data-tpl-for="id" class="form-check-label">{{{{ slots.default }}}}</label>
1862
+ </div>
1863
+ <ful-field-error data-tpl-if="fieldErrorId"></ful-field-error>
1864
+ `;
1865
+ #input;
1866
+ #fieldError;
1867
+ static formAssociated = true;
1868
+ constructor() {
1869
+ super();
1870
+ this.internals = this.attachInternals();
1871
+ }
1872
+ render({ slots }) {
1873
+ const id = ftl.Attributes.uid("ful-checkbox");
1874
+ const fieldErrorId = id + "-error";
1875
+ const klass = this.getAttribute('type') == 'switch' ? "form-check form-switch" : "form-check";
1876
+ const fragment = this.template().withOverlay({ slots, klass, id, fieldErrorId }).render();
1877
+ this.#input = fragment.querySelector("input");
1878
+ ftl.Attributes.forward('input-', this, this.#input);
1879
+ this.#fieldError = fragment.querySelector('ful-field-error');
1880
+ this.#input.addEventListener('change', (evt) => {
1881
+ evt.stopPropagation();
1882
+ this.dispatchEvent(new CustomEvent('change', {
1883
+ bubbles: true,
1884
+ cancelable: false,
1885
+ detail: {
1886
+ value: this.value
1887
+ }
1888
+ }));
1889
+ });
1890
+ this.replaceChildren(fragment);
1891
+ }
1892
+ get value() {
1893
+ return this.#input.checked;
1894
+ }
1895
+ set value(value) {
1896
+ this.#input.checked = value;
1897
+ }
1898
+ focus(options) {
1899
+ this.#input.focus(options);
1900
+ }
1901
+ setCustomValidity(error) {
1902
+ if (!error) {
1903
+ this.internals.setValidity({});
1904
+ this.#fieldError.innerText = "";
1905
+ return;
1906
+ }
1907
+ this.internals.setValidity({ customError: true }, " ");
1908
+ this.#fieldError.innerText = error;
1909
+ }
1522
1910
  }
1523
1911
 
1524
- class Spinner extends ftl.ParsedElement({
1525
- slots: true,
1526
- template: `
1912
+ class Spinner extends ftl.ParsedElement {
1913
+ static slots = true;
1914
+ static template = `
1527
1915
  <div class="ful-spinner-wrapper">
1528
1916
  <div class="ful-spinner-text">{{{{ slots.default }}}}</div>
1529
1917
  <div class="ful-spinner-icon"></div>
1530
1918
  </div>
1531
- `
1532
- }) {
1533
- render({slots}) {
1919
+ `;
1920
+ render({ slots }) {
1534
1921
  this.template().withOverlay({ slots }).renderTo(this);
1535
1922
  }
1536
1923
  }
1537
1924
 
1925
+ class SortButton extends ftl.ParsedElement {
1926
+ static observed = ["order"];
1927
+ #order;
1928
+ render() {
1929
+ const sorter = this.getAttribute("sorter");
1930
+ const orders = ["asc", "desc", null];
1931
+ this.addEventListener('click', () => {
1932
+ const nextOrder = orders[(orders.indexOf(this.order) + 1) % 3];
1933
+ this.dispatchEvent(new CustomEvent('sort-requested', {
1934
+ bubbles: true,
1935
+ cancelable: true,
1936
+ detail: {
1937
+ value: { sorter, order: nextOrder }
1938
+ }
1939
+ }));
1940
+ });
1941
+ }
1942
+
1943
+ get order() {
1944
+ return this.#order || null;
1945
+ }
1946
+
1947
+ set order(value) {
1948
+ this.#order = value || null;
1949
+ this.reflect(() => {
1950
+ if (this.#order) {
1951
+ this.setAttribute("order", value);
1952
+ } else {
1953
+ this.removeAttribute("order");
1954
+ }
1955
+ });
1956
+ }
1957
+ }
1958
+
1959
+ class Pagination extends ftl.ParsedElement {
1960
+ static observed = ["total:number", "current:number"];
1961
+ static template = `
1962
+ <nav aria-label="Page navigation" class="user-select-none">
1963
+ <ul class="pagination">
1964
+ <li class="page-item ms-auto me-2" data-tpl-if="paginationLabel"> Showing page {{ curr.label }} of {{ total }}</li>
1965
+ <li class="page-item ms-auto me-2" data-tpl-if="!paginationLabel"></li>
1966
+ <li class="page-item">
1967
+ <a data-tpl-class="prev.enabled?'page-link':'page-link disabled'" aria-label="Previous" role="button" data-tpl-data-page="prev.index">
1968
+ <span aria-hidden="true">&laquo;</span>
1969
+ </a>
1970
+ </li>
1971
+ <li class="page-item" data-tpl-each="pages" data-tpl-var="page">
1972
+ <a data-tpl-class="curr.index != page.index ? 'page-link': 'page-link disabled'" role="button" data-tpl-data-page="page.index" >
1973
+ {{ page.label }}
1974
+ </a>
1975
+ </li>
1976
+ <li class="page-item">
1977
+ <a data-tpl-class="next.enabled?'page-link':'page-link disabled'" aria-label="Next" role="button" data-tpl-data-page="next.index">
1978
+ <span aria-hidden="true">&raquo;</span>
1979
+ </a>
1980
+ </li>
1981
+ </ul>
1982
+ </nav>
1983
+ `;
1984
+ #paginationLabel;
1985
+ #total = 0;
1986
+ #current = 0;
1987
+ render({ observed }) {
1988
+ this.#paginationLabel = this.hasAttribute('pagination-label');
1989
+ this.update(observed.current ?? 0, observed.total ?? 0);
1990
+ this.addEventListener('click', (/** @type any */evt) => {
1991
+ const el = evt.target.closest('a');
1992
+ if (!el) {
1993
+ return;
1994
+ }
1995
+ this.dispatchEvent(new CustomEvent('page-requested', {
1996
+ bubbles: true,
1997
+ cancelable: true,
1998
+ detail: {
1999
+ value: Number(el.dataset.page)
2000
+ }
2001
+ }));
2002
+
2003
+ });
2004
+ }
2005
+ update(current, total) {
2006
+ const maxRender = Number(this.getAttribute('pages') ?? "5");
2007
+ const prev = { index: Math.max(0, current - 1), enabled: current > 0 };
2008
+ const curr = { index: current, label: current + 1 };
2009
+ const next = { index: Math.min(total, current + 1), enabled: current + 1 < total };
2010
+ const pages = [{
2011
+ index: current,
2012
+ label: current + 1
2013
+ }];
2014
+ for (let mid = current, offset = 1; offset !== maxRender && pages.length != maxRender; ++offset) {
2015
+ const p = mid - offset;
2016
+ if (p >= 0) {
2017
+ pages.unshift({ index: p, label: p + 1 });
2018
+ }
2019
+ const n = mid + offset;
2020
+ if (n < total) {
2021
+ pages.push({ index: n, label: n + 1 });
2022
+ }
2023
+ }
2024
+ const paginationLabel = this.#paginationLabel;
2025
+ this.template().withOverlay({ total, prev, curr, next, pages, paginationLabel }).renderTo(this);
2026
+ }
2027
+ get total() {
2028
+ return this.#total;
2029
+ }
2030
+ set total(value) {
2031
+ this.#total = value;
2032
+ this.reflect(() => {
2033
+ this.setAttribute('total', String(value));
2034
+ this.update(this.#current ?? 0, this.#total);
2035
+ });
2036
+ }
2037
+ get current() {
2038
+ return this.#current;
2039
+ }
2040
+ set current(value) {
2041
+ this.#current = value;
2042
+ this.reflect(() => {
2043
+ this.setAttribute('current', String(value));
2044
+ this.update(this.#current, this.#total ?? 0);
2045
+ });
2046
+ }
2047
+ }
2048
+
2049
+ class TableSchemaParser {
2050
+ static parse(nodeOrFragment, template) {
2051
+ const schema = ftl.Nodes.queryChildren(nodeOrFragment, "schema");
2052
+ if (!schema) {
2053
+ throw new Error(`missing expected <schema> in ${nodeOrFragment}`);
2054
+ }
2055
+ return ftl.Nodes.queryChildrenAll(schema, "column")
2056
+ .map(el => {
2057
+ return {
2058
+ sorter: el.getAttribute("sorter"),
2059
+ order: el.getAttribute("order"),
2060
+ title: TableSchemaParser.#parseTitle(el, template),
2061
+ content: TableSchemaParser.#parseContent(el, template)
2062
+ }
2063
+ });
2064
+ }
2065
+
2066
+ static #parseTitle(el, template) {
2067
+ const maybeTitleTag = ftl.Nodes.queryChildren(el, 'title');
2068
+ if (maybeTitleTag) {
2069
+ maybeTitleTag.remove();
2070
+ }
2071
+ const fragment = maybeTitleTag ? template.withFragment(ftl.Fragments.fromChildNodes(maybeTitleTag)).render() : document.createTextNode(el.getAttribute("title") ?? '');
2072
+ return {
2073
+ classes: el.getAttribute("th-class"),
2074
+ fragment
2075
+ };
2076
+ }
2077
+
2078
+ static #parseContent(el, template) {
2079
+ return {
2080
+ classes: el.getAttribute("td-class"),
2081
+ template: template.withFragment(ftl.Fragments.fromChildNodes(el))
2082
+ }
2083
+ }
2084
+ }
2085
+
2086
+ class RemoteTableLoader{
2087
+ #http;
2088
+ #url;
2089
+ #method;
2090
+ constructor(http, url, method){
2091
+ this.#http = http;
2092
+ this.#url = url;
2093
+ this.#method = method;
2094
+ }
2095
+ async load(pageRequest, sortRequest, filterRequest){
2096
+ const filters = Object.entries(filterRequest).filter(([k, v]) => v);
2097
+ return await this.#http.request(this.#method, this.#url)
2098
+ .param("page", pageRequest.page)
2099
+ .param("size", pageRequest.size)
2100
+ .param("sort", sortRequest.order ? `${sortRequest.sorter},${sortRequest.order}` : null)
2101
+ .param("filters", filters.length > 0 ? JSON.stringify(Object.fromEntries(filters)) : null)
2102
+ .fetchJson();
2103
+ }
2104
+ }
2105
+
2106
+
2107
+ class TableLoader{
2108
+ static create({el, http}){
2109
+ const url = el.getAttribute("src");
2110
+ const method = el.getAttribute("method") ?? 'GET';
2111
+ return new RemoteTableLoader(http, url, method);
2112
+ }
2113
+ }
2114
+
2115
+ class Table extends ftl.ParsedElement {
2116
+ static slots = true;
2117
+ static template = `
2118
+ <ful-form data-tpl-if="slots.filters">
2119
+ {{{{ slots.filters }}}}
2120
+ </ful-form>
2121
+ <table class="table">
2122
+ <caption data-tpl-if="slots.caption">{{{{ slots.caption }}}}</caption>
2123
+ <thead>
2124
+ <tr>
2125
+ <th data-tpl-each="schema" scope="col" data-tpl-class="title.classes">
2126
+ {{{{ title.fragment }}}}
2127
+ <ful-sorter data-tpl-if="sorter || order" data-tpl-sorter="sorter" data-tpl-order="order"></ful-sorter>
2128
+ </th>
2129
+ </tr>
2130
+ </thead>
2131
+ <tbody></tbody>
2132
+ <tbody data-ref="no-autoload">
2133
+ <tr>
2134
+ <td data-tpl-colspan="schema.length" class="text-center align-middle p-4">
2135
+ <i class="bi bi-search" style="font-size: 40px; color: #BDC3CA"></i>
2136
+ <p class="mt-3 mb-0" style="color: #BDC3CA">
2137
+ Avvia la ricerca per visualizzare i risultati...
2138
+ </p>
2139
+ </td>
2140
+ </tr>
2141
+ </tbody>
2142
+ <tbody data-ref="loading" hidden>
2143
+ <tr>
2144
+ <td data-tpl-colspan="schema.length" class="text-center align-middle p-4">
2145
+ <ful-spinner class="big"></ful-spinner>
2146
+ </td>
2147
+ </tr>
2148
+ </tbody>
2149
+ <tbody data-ref="feedback" hidden>
2150
+ <tr>
2151
+ <td data-tpl-colspan="schema.length" class="text-center align-middle p-4">
2152
+ <div class="alert alert-danger">
2153
+ <p>Errore nel caricamento della tabella:</p>
2154
+ <p class="mb-0" data-ref="feedback-error"></p>
2155
+ </div>
2156
+ </td>
2157
+ </tr>
2158
+ </tbody>
2159
+ <tfoot data-tpl-if="slots.footer">
2160
+ {{{{ slots.footer }}}}
2161
+ </tfoot>
2162
+ </table>
2163
+ <ful-pagination current="0" total="1"></ful-pagination>
2164
+ `;
2165
+ static templates = {
2166
+ row: `
2167
+ <tr data-tpl-if="pageResponse.data.length == 0">
2168
+ <td data-tpl-colspan="schema.length" class="text-center align-middle p-4">
2169
+ Nessun elemento trovato.
2170
+ </td>
2171
+ </tr>
2172
+ <tr data-tpl-each="pageResponse.data" data-tpl-var="row">
2173
+ <td data-tpl-each="schema" data-tpl-class="content.classes">
2174
+ {{{{ content.template.withOverlay(row).render() }}}}
2175
+ </td>
2176
+ </tr>
2177
+ `
2178
+ };
2179
+ #schema;
2180
+ #body;
2181
+ #loading;
2182
+ #noAutoload;
2183
+ #feedback;
2184
+ #paginator;
2185
+ #sorters;
2186
+ #latestRequest;
2187
+ async render({ slots, observed }) {
2188
+ const template = this.template();
2189
+ const schema = TableSchemaParser.parse(slots.default, template);
2190
+ const fragment = template.withOverlay({ slots, schema }).render();
2191
+ const table = /** @type HTMLTableElement */ (ftl.Nodes.queryChildren(fragment, 'table'));
2192
+ ftl.Attributes.forward('table-', this, table);
2193
+ this.#schema = schema;
2194
+ this.#body = table.querySelector(':scope > tbody');
2195
+ this.#loading = table.querySelector(":scope > tbody[data-ref=loading]");
2196
+ this.#noAutoload = table.querySelector(":scope > tbody[data-ref=no-autoload]");
2197
+ this.#feedback = table.querySelector(":scope > tbody[data-ref=feedback]");
2198
+ this.#paginator = ftl.Nodes.queryChildren(fragment, 'ful-pagination');
2199
+ this.#sorters = table.querySelectorAll(':scope > thead ful-sorter') ?? [];
2200
+ this.replaceChildren(fragment);
2201
+ await ftl.Nodes.waitForUpgrades();
2202
+ const orderFromSchema = schema.find(v => v.order);
2203
+
2204
+ const maybeForm = /** @type any */(ftl.Nodes.queryChildren(this, 'ful-form'));
2205
+ this.#latestRequest = {
2206
+ pageRequest: {
2207
+ page: 0,
2208
+ size: this.getAttribute("page-size") ? Number(this.getAttribute("page-size")) : 10
2209
+ },
2210
+ sortRequest: { order: orderFromSchema?.order, sorter: orderFromSchema?.sorter },
2211
+ filterRequest: maybeForm?.values ?? {}
2212
+ };
2213
+ maybeForm?.addEventListener('submit:success', async (evt) => {
2214
+ await this.load({
2215
+ page: 0,
2216
+ size: this.#latestRequest.pageRequest.size
2217
+ }, this.#latestRequest.sortRequest, evt.detail.request);
2218
+ });
2219
+ if (maybeForm) {
2220
+ maybeForm.submitter = async (filterRequest, form) => {
2221
+ };
2222
+ }
2223
+ this.addEventListener('page-requested', async (/** @type any */e) => {
2224
+ await this.load({
2225
+ page: e.detail.value,
2226
+ size: this.#latestRequest.pageRequest.size
2227
+ }, this.#latestRequest.sortRequest, this.#latestRequest.filterRequest);
2228
+ });
2229
+ this.addEventListener('sort-requested', async (/** @type any */e) => {
2230
+ await this.load(this.#latestRequest.pageRequest, e.detail.value, this.#latestRequest.filterRequest);
2231
+ this.#sorters.forEach(s => s.order = null);
2232
+ e.target.order = e.detail.value.order;
2233
+ });
2234
+ if (this.hasAttribute('autoload')) {
2235
+ await this.reload();
2236
+ }
2237
+ }
2238
+
2239
+ async reload() {
2240
+ return await this.load(this.#latestRequest.pageRequest, this.#latestRequest.sortRequest, this.#latestRequest.filterRequest);
2241
+ }
2242
+ async load(pageRequest, sortRequest, filterRequest) {
2243
+ this.#body.innerHTML = "";
2244
+ this.#loading.removeAttribute("hidden", "");
2245
+ this.#feedback.setAttribute("hidden", "");
2246
+ this.#noAutoload.setAttribute("hidden", "");
2247
+ try {
2248
+ const loader = Loaders.fromAttributes(this, 'loaders:table');
2249
+ const pageResponse =await loader.load(pageRequest, sortRequest, filterRequest);
2250
+ this.#latestRequest = { pageRequest, sortRequest, filterRequest };
2251
+ this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
2252
+ } catch (/** @type any */error) {
2253
+ this.#loading.setAttribute("hidden", "");
2254
+ this.#feedback.removeAttribute("hidden", "");
2255
+ if (!error.problems) {
2256
+ this.#feedback.querySelector('[data-ref=feedback-error]').textContent = error;
2257
+ } else {
2258
+ this.#feedback.querySelector('[data-ref=feedback-error]').textContent = error.problems.map(p => `${p.reason}`);
2259
+ }
2260
+ throw error;
2261
+ }
2262
+ }
2263
+
2264
+ async resetWithFilter(filterRequest) {
2265
+ return await this.load({
2266
+ page: 0,
2267
+ size: this.#latestRequest.pageRequest.size
2268
+ }, this.#latestRequest.sortRequest, filterRequest);
2269
+ }
2270
+
2271
+ #update(pageRequest, sortRequest, filterRequest, pageResponse) {
2272
+ this.#loading.setAttribute("hidden", "");
2273
+ this.#body.replaceChildren(this.template('row').withOverlay({
2274
+ schema: this.#schema,
2275
+ pageRequest,
2276
+ filterRequest,
2277
+ pageResponse
2278
+ }).render());
2279
+ this.#paginator.current = pageRequest.page;
2280
+ this.#paginator.total = Math.ceil(pageResponse.size / pageRequest.size);
2281
+ }
2282
+ }
2283
+
2284
+ class InstantFilter extends ftl.ParsedElement {
2285
+ static observed = ["value:json"];
2286
+ static slots = true;
2287
+ static template = `
2288
+ <label data-tpl-for="id" class="form-label" data-tpl-if="label">{{{{ label }}}}</label>
2289
+ <div class="input-group">
2290
+ <button data-ref="operator" class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" value="LTE" form="">&PrecedesSlantEqual;</button>
2291
+ <ul class="dropdown-menu">
2292
+ <li><a class="dropdown-item" role="button" value="EQ">=</a></li>
2293
+ <li><a class="dropdown-item" role="button" value="NEQ">&ne;</a></li>
2294
+ <li><a class="dropdown-item" role="button" value="LT">&prec;</a></li>
2295
+ <li><a class="dropdown-item" role="button" value="GT">&succ;</a></li>
2296
+ <li><a class="dropdown-item" role="button" value="LTE">&PrecedesSlantEqual;</a></li>
2297
+ <li><a class="dropdown-item" role="button" value="GTE">&SucceedsSlantEqual;</a></li>
2298
+ <li><a class="dropdown-item" role="button" value="BETWEEN">&LeftRightArrow;</a></li>
2299
+ </ul>
2300
+ <input data-tpl-id="id" data-ref="value1" type="datetime-local" class="form-control" form="">
2301
+ <input data-ref="value2" type="datetime-local" class="form-control" form="" hidden>
2302
+ <span class="input-group-text"><i class="bi bi-search"></i></span>
2303
+ </div>
2304
+ <ful-field-error></ful-field-error>
2305
+ `;
2306
+ static formAssociated = true;
2307
+ #operator;
2308
+ #value1;
2309
+ #value2;
2310
+ #fieldError;
2311
+ constructor() {
2312
+ super();
2313
+ this.internals = this.attachInternals();
2314
+ }
2315
+ render({ slots }) {
2316
+ const id = ftl.Attributes.uid('instant-filter');
2317
+ const label = ftl.Fragments.toHtml(slots.default.cloneNode(true)).trim().length === 0 ? null : slots.default;
2318
+ const name = this.getAttribute("name");
2319
+ const fragment = this.template().withOverlay({ id, label, name }).render(this);
2320
+ this.#operator = fragment.querySelector('[data-ref=operator]');
2321
+ this.#value1 = fragment.querySelector('[data-ref=value1]');
2322
+ this.#value2 = fragment.querySelector('[data-ref=value2]');
2323
+ this.replaceChildren(fragment);
2324
+ this.#fieldError = this.querySelector('ful-field-error');
2325
+ this.addEventListener('click', (evt) => {
2326
+ const target = /** @type HTMLElement */ (evt.target);
2327
+ if (!target.matches('ul > li > a')) {
2328
+ return;
2329
+ }
2330
+ const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
2331
+ const value = /** @type String */ (target.getAttribute("value"));
2332
+ ftl.Attributes.toggle(this.#value2, 'hidden', value !== 'BETWEEN');
2333
+ btn.setAttribute('value', value);
2334
+ btn.innerHTML = target.innerHTML;
2335
+ });
2336
+ }
2337
+
2338
+ get value() {
2339
+ const operator = this.#operator.getAttribute('value');
2340
+ const values = operator === 'BETWEEN' ? [this.#value1.value, this.#value2.value] : [this.#value1.value];
2341
+ return values.some(v => v === '') ? undefined : [operator, ...values.map(v => new Date(v).toISOString())];
2342
+ }
2343
+ set value(v) {
2344
+ if (v === null || v === undefined) {
2345
+ this.#value1.value = '';
2346
+ this.#value2.value = '';
2347
+ this.reflect(() => {
2348
+ this.removeAttribute('value');
2349
+ });
2350
+ return;
2351
+ }
2352
+ const [operator, ...values] = v;
2353
+ this.#operator.setAttribute('value', operator);
2354
+ this.#value1.value = values[0] ? InstantFilter.isoToLocal(values[0]) : values[0];
2355
+ this.#value2.value = values[1] ? InstantFilter.isoToLocal(values[1]) : values[1];
2356
+ this.reflect(() => {
2357
+ this.setAttribute('value', JSON.stringify(v));
2358
+ });
2359
+ }
2360
+
2361
+ static isoToLocal(iso) {
2362
+ //this is so sad
2363
+ const d = new Date(iso);
2364
+ const pad = (n, v) => String(v).padStart(n, '0');
2365
+ const date = `${d.getFullYear()}-${pad(2, d.getMonth() + 1)}-${pad(2, d.getDate())}`;
2366
+ const time = `${pad(2, d.getHours())}:${pad(2, d.getMinutes())}:${pad(2, d.getSeconds())}.${pad(3, d.getMilliseconds())}`;
2367
+ return `${date}T${time}`
2368
+ }
2369
+ focus(options) {
2370
+ this.#value1.focus(options);
2371
+ }
2372
+ setCustomValidity(error) {
2373
+ if (!error) {
2374
+ this.internals.setValidity({});
2375
+ this.#fieldError.innerText = "";
2376
+ return;
2377
+ }
2378
+ this.internals.setValidity({ customError: true }, " ");
2379
+ this.#fieldError.innerText = error;
2380
+ }
2381
+ }
2382
+
2383
+ class LocalDateFilter extends ftl.ParsedElement {
2384
+ static observed = ["value:json"];
2385
+ static slots = true;
2386
+ static template = `
2387
+ <label data-tpl-for="id" class="form-label" data-tpl-if="label">{{{{ label }}}}</label>
2388
+ <div class="input-group">
2389
+ <button data-ref="operator" class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" value="EQ" form="">=</button>
2390
+ <ul class="dropdown-menu">
2391
+ <li><a class="dropdown-item" role="button" value="EQ">=</a></li>
2392
+ <li><a class="dropdown-item" role="button" value="NEQ">&ne;</a></li>
2393
+ <li><a class="dropdown-item" role="button" value="LT">&prec;</a></li>
2394
+ <li><a class="dropdown-item" role="button" value="GT">&succ;</a></li>
2395
+ <li><a class="dropdown-item" role="button" value="LTE">&PrecedesSlantEqual;</a></li>
2396
+ <li><a class="dropdown-item" role="button" value="GTE">&SucceedsSlantEqual;</a></li>
2397
+ <li><a class="dropdown-item" role="button" value="BETWEEN">&LeftRightArrow;</a></li>
2398
+ </ul>
2399
+ <input data-tpl-id="id" data-ref="value1" type="date" class="form-control" form="">
2400
+ <input data-ref="value2" type="date" class="form-control" form="" hidden>
2401
+ <span class="input-group-text"><i class="bi bi-search"></i></span>
2402
+
2403
+ </div>
2404
+ <ful-field-error></ful-field-error>
2405
+ `;
2406
+ static formAssociated = true;
2407
+ #operator;
2408
+ #value1;
2409
+ #value2;
2410
+ #fieldError;
2411
+ constructor() {
2412
+ super();
2413
+ this.internals = this.attachInternals();
2414
+ }
2415
+ render({ slots }) {
2416
+ const id = ftl.Attributes.uid('instant-filter');
2417
+ const label = ftl.Fragments.toHtml(slots.default.cloneNode(true)).trim().length === 0 ? null : slots.default;
2418
+ const name = this.getAttribute("name");
2419
+ const fragment = this.template().withOverlay({ id, label, name }).render(this);
2420
+ this.#operator = fragment.querySelector('[data-ref=operator]');
2421
+ this.#value1 = fragment.querySelector('[data-ref=value1]');
2422
+ this.#value2 = fragment.querySelector('[data-ref=value2]');
2423
+ this.replaceChildren(fragment);
2424
+ this.#fieldError = this.querySelector('ful-field-error');
2425
+ this.addEventListener('click', (evt) => {
2426
+ const target = /** @type HTMLElement */(evt.target);
2427
+ if (!target.matches('ul > li > a')) {
2428
+ return;
2429
+ }
2430
+ const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
2431
+ const value = /** @type String */ (target.getAttribute("value"));
2432
+ ftl.Attributes.toggle(this.#value2, 'hidden', value !== 'BETWEEN');
2433
+ btn.setAttribute('value', value);
2434
+ btn.innerHTML = target.innerHTML;
2435
+ });
2436
+ }
2437
+ get value() {
2438
+ const operator = this.#operator.getAttribute('value');
2439
+ const values = operator == 'BETWEEN' ? [this.#value1.value, this.#value2.value] : [this.#value1.value];
2440
+ return values.some(v => v === '') ? undefined : [operator, "ISO_8601", ...values];
2441
+ }
2442
+ set value(v) {
2443
+ if (v === null || v === undefined) {
2444
+ this.#value1.value = '';
2445
+ this.#value2.value = '';
2446
+ this.reflect(() => {
2447
+ this.removeAttribute('value');
2448
+ });
2449
+ return;
2450
+ }
2451
+ const [operator, ...values] = v;
2452
+ this.#operator.setAttibute('value', operator);
2453
+ this.#value1.value = values[0];
2454
+ this.#value2.value = values[1];
2455
+ this.reflect(() => {
2456
+ this.setAttribute('value', JSON.stringify(v));
2457
+ });
2458
+ }
2459
+ focus(options) {
2460
+ this.#value1.focus(options);
2461
+ }
2462
+ setCustomValidity(error) {
2463
+ if (!error) {
2464
+ this.internals.setValidity({});
2465
+ this.#fieldError.innerText = "";
2466
+ return;
2467
+ }
2468
+ this.internals.setValidity({ customError: true }, " ");
2469
+ this.#fieldError.innerText = error;
2470
+ }
2471
+ }
2472
+
2473
+ class TextFilter extends ftl.ParsedElement {
2474
+ static observed = ["value:json"];
2475
+ static slots = true;
2476
+ static template = `
2477
+ <label data-tpl-for="id" class="form-label" data-tpl-if="label">{{{{ label }}}}</label>
2478
+ <div class="input-group">
2479
+ <button data-ref="operator" class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" value="CONTAINS" form="">&mldr;a&mldr;</button>
2480
+ <ul class="dropdown-menu">
2481
+ <li><a class="dropdown-item" role="button" value="CONTAINS">&mldr;a&mldr;</a></li>
2482
+ <li><a class="dropdown-item" role="button" value="STARTS_WITH">a&mldr;</a></li>
2483
+ <li><a class="dropdown-item" role="button" value="ENDS_WITH">&mldr;a</a></li>
2484
+ <li><a class="dropdown-item" role="button" value="EQ">=</a></li>
2485
+ </ul>
2486
+ <input data-tpl-id="id" data-ref="value" type="text" class="form-control" form="">
2487
+ <span class="input-group-text"><i class="bi bi-search"></i></span>
2488
+ </div>
2489
+ <ful-field-error></ful-field-error>
2490
+ `;
2491
+ static formAssociated = true;
2492
+ #operator;
2493
+ #value;
2494
+ #fieldError;
2495
+ constructor() {
2496
+ super();
2497
+ this.internals = this.attachInternals();
2498
+ }
2499
+ render({ slots }) {
2500
+ const id = ftl.Attributes.uid('string-filter');
2501
+ const label = ftl.Fragments.toHtml(slots.default.cloneNode(true)).trim().length === 0 ? null : slots.default;
2502
+ const name = this.getAttribute("name");
2503
+ const fragment = this.template().withOverlay({ id, label, name }).render(this);
2504
+ this.#operator = fragment.querySelector('[data-ref=operator]');
2505
+ this.#value = fragment.querySelector('[data-ref=value]');
2506
+ this.replaceChildren(fragment);
2507
+ this.#fieldError = this.querySelector('ful-field-error');
2508
+ this.addEventListener('click', (evt) => {
2509
+ const target = /** @type HTMLElement */(evt.target);
2510
+ if (!target.matches('ul > li > a')) {
2511
+ return;
2512
+ }
2513
+ const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
2514
+ const value = /** @type String */ (target.getAttribute("value"));
2515
+ btn.setAttribute('value', value);
2516
+ btn.innerHTML = target.innerHTML;
2517
+ });
2518
+ }
2519
+
2520
+ get value() {
2521
+ const operator = this.#operator.getAttribute('value');
2522
+ return this.#value.value === '' ? undefined : [operator, 'IGNORE_CASE', this.#value.value];
2523
+ }
2524
+
2525
+ set value(v) {
2526
+ if (v === null || v === undefined) {
2527
+ this.#value.value = '';
2528
+ this.reflect(() => {
2529
+ this.removeAttribute('value');
2530
+ });
2531
+ return;
2532
+ }
2533
+ const [operator, sensitivity, value] = v;
2534
+ this.#operator.setAttribute('value', operator);
2535
+ this.#value.value = value;
2536
+ this.reflect(() => {
2537
+ this.setAttribute('value', JSON.stringify(v));
2538
+ });
2539
+ }
2540
+ focus(options) {
2541
+ this.#value.focus(options);
2542
+ }
2543
+ setCustomValidity(error) {
2544
+ if (!error) {
2545
+ this.internals.setValidity({});
2546
+ this.#fieldError.innerText = "";
2547
+ return;
2548
+ }
2549
+ this.internals.setValidity({ customError: true }, " ");
2550
+ this.#fieldError.innerText = error;
2551
+ }
2552
+ }
2553
+
2554
+ class Plugin {
2555
+ configure(registry) {
2556
+ const httpClient = HttpClient.builder()
2557
+ .withCsrfToken()
2558
+ .withRedirectOnUnauthorized("/")
2559
+ .build();
2560
+ registry
2561
+ .defineComponent('http-client', httpClient)
2562
+ .defineElement('ful-spinner', Spinner)
2563
+ .defineElement('ful-form', Form)
2564
+ .defineElement('ful-checkbox', Checkbox)
2565
+ .defineElement('ful-input', Input)
2566
+ .defineElement('ful-radio-group', RadioGroup)
2567
+ .defineElement('ful-table', Table)
2568
+ .defineElement('ful-pagination', Pagination)
2569
+ .defineElement('ful-sorter', SortButton)
2570
+ .defineElement('ful-filter-instant', InstantFilter)
2571
+ .defineElement('ful-filter-local-date', LocalDateFilter)
2572
+ .defineElement('ful-filter-text', TextFilter)
2573
+ .defineElement('ful-select', Select)
2574
+ .defineElement('ful-dropdown', Dropdown)
2575
+ .defineComponent("loaders:select", SelectLoader)
2576
+ .defineComponent("loaders:form", FormLoader)
2577
+ .defineComponent("loaders:table", TableLoader);
2578
+ }
2579
+ }
2580
+
1538
2581
  exports.AuthorizationCodeFlow = AuthorizationCodeFlow;
1539
2582
  exports.AuthorizationCodeFlowInterceptor = AuthorizationCodeFlowInterceptor;
1540
2583
  exports.AuthorizationCodeFlowSession = AuthorizationCodeFlowSession;
1541
2584
  exports.Base64 = Base64;
1542
2585
  exports.Bindings = Bindings;
2586
+ exports.Checkbox = Checkbox;
2587
+ exports.Dropdown = Dropdown;
1543
2588
  exports.Failure = Failure;
1544
2589
  exports.Form = Form;
2590
+ exports.FormLoader = FormLoader;
1545
2591
  exports.Hex = Hex;
1546
2592
  exports.HttpClient = HttpClient;
1547
2593
  exports.HttpClientError = HttpClientError;
1548
- exports.INPUT_TEMPLATE = INPUT_TEMPLATE;
1549
2594
  exports.Input = Input;
2595
+ exports.InstantFilter = InstantFilter;
2596
+ exports.Loaders = Loaders;
2597
+ exports.LocalDateFilter = LocalDateFilter;
1550
2598
  exports.LocalStorage = LocalStorage;
1551
2599
  exports.MediaType = MediaType;
2600
+ exports.Pagination = Pagination;
2601
+ exports.Plugin = Plugin;
1552
2602
  exports.RadioGroup = RadioGroup;
1553
2603
  exports.Select = Select;
2604
+ exports.SelectLoader = SelectLoader;
1554
2605
  exports.SessionStorage = SessionStorage;
2606
+ exports.SortButton = SortButton;
1555
2607
  exports.Spinner = Spinner;
2608
+ exports.Table = Table;
2609
+ exports.TableSchemaParser = TableSchemaParser;
2610
+ exports.TextFilter = TextFilter;
1556
2611
  exports.VersionedStorage = VersionedStorage;
1557
- exports.makeInputFragment = makeInputFragment;
1558
2612
  exports.timing = timing;
1559
2613
 
1560
2614
  return exports;
1561
2615
 
1562
- })({}, ftl, window?.TomSelect || {});
2616
+ })({}, ftl);
1563
2617
  //# sourceMappingURL=ful.iife.js.map