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