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