@schukai/monster 4.136.8 → 4.136.11

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/CHANGELOG.md CHANGED
@@ -18,6 +18,8 @@
18
18
  - **message-state-button:** distinguish overlay, prose, and wide message layouts for smart popper sizing and overflow ([#401](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/401))
19
19
  - **popper:** support kebab-case camelCase option attributes such as `data-monster-option-popper-content-overflow` ([#401](https://gitlab.schukai.com/oss/libraries/javascript/monster/issues/401))
20
20
  - **popper/select:** let nested `monster-select` poppers escape parent popper content wrappers without shrinking normal parent content sizing ([#416](https://gitlab.schukai.com/oss/libraries/javascript/monster/-/work_items/416))
21
+ - **select:** skip hidden remote info requests when `showRemoteInfo` is disabled ([#418](https://gitlab.schukai.com/oss/libraries/javascript/monster/-/work_items/418))
22
+ - **select:** defer remote info loading until the dropdown opens and reuse pagination totals from remote option fetches when available ([#418](https://gitlab.schukai.com/oss/libraries/javascript/monster/-/work_items/418))
21
23
 
22
24
  ### Changes
23
25
 
package/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.136.8"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.136.11"}
@@ -306,6 +306,7 @@ const lookupCacheSymbol = Symbol("lookupCache");
306
306
  const lookupInProgressSymbol = Symbol("lookupInProgress");
307
307
  const unresolvedSelectionValuesSymbol = Symbol("unresolvedSelectionValues");
308
308
  const fetchRequestVersionSymbol = Symbol("fetchRequestVersion");
309
+ const remoteInfoRequestSymbol = Symbol("remoteInfoRequest");
309
310
 
310
311
  /**
311
312
  * @private
@@ -458,6 +459,28 @@ class Select extends CustomControl {
458
459
  /**
459
460
  * Defines the default configuration options for the monster-control.
460
461
  * These options can be overridden via the HTML attribute `data-monster-options`.
462
+ * Use JSON in `data-monster-options` when option values must keep their JSON
463
+ * types, for example:
464
+ *
465
+ * ```html
466
+ * <monster-select
467
+ * data-monster-options='{"empty":{"equivalents":[null,"",0]}}'>
468
+ * </monster-select>
469
+ * ```
470
+ *
471
+ * When the value comes from an updater object, bind it through
472
+ * `data-monster-properties`. The bound object value keeps its JavaScript type,
473
+ * so an array such as `[null, "", 0]` keeps the numeric `0` as a number:
474
+ *
475
+ * ```html
476
+ * <monster-select
477
+ * data-monster-properties="option:empty.equivalents path:emptyEquivalents">
478
+ * </monster-select>
479
+ * ```
480
+ *
481
+ * Do not use `data-monster-option-empty-equivalents` for typed primitive
482
+ * values; array option attributes are split as strings and cannot express a
483
+ * numeric `0`.
461
484
  * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
462
485
  *
463
486
  * @property {string[]} toggleEventType - Array of DOM event names (e.g., ["click", "touch"]) that toggle the dropdown.
@@ -528,7 +551,7 @@ class Select extends CustomControl {
528
551
  * @property {Object} empty - Handling of empty or undefined values.
529
552
  * @property {string} empty.defaultValueRadio - Default value for `type="radio"` when no selection exists.
530
553
  * @property {Array} empty.defaultValueCheckbox - Default value (empty array) for `type="checkbox"`.
531
- * @property {Array} empty.equivalents - Values that are considered "empty" (e.g., `undefined`, `null`, `""`) and are normalized to the default value.
554
+ * @property {Array} empty.equivalents - Values that are considered "empty" (e.g., `undefined`, `null`, `""`, `NaN`) and are normalized to the default value. Matching is type-safe: a configured string `"0"` only matches the string `"0"`; configure numeric `0` explicitly, via `data-monster-options` JSON, `data-monster-properties`, or `setOption()`, when the number `0` should be treated as empty.
532
555
  * @property {Object} formatter - Functions for formatting display values.
533
556
  * @property {Function} formatter.selection - Callback `(value, option) => string` to format the display text of selected values.
534
557
  * @property {Object} classes - CSS classes for various elements.
@@ -698,7 +721,6 @@ class Select extends CustomControl {
698
721
  checkOptionState.call(this);
699
722
  calcAndSetOptionsDimension.call(this);
700
723
  updatePopper.call(this);
701
- initTotal.call(this);
702
724
  })
703
725
  .catch((e) => {
704
726
  addErrorAttribute(this, e);
@@ -721,8 +743,6 @@ class Select extends CustomControl {
721
743
  let lazyLoadFlag = self.getOption("features.lazyLoad", false);
722
744
  const remoteFilterFlag = getFilterMode.call(this) === FILTER_MODE_REMOTE;
723
745
 
724
- initTotal.call(self);
725
-
726
746
  if (getFilterMode.call(this) === FILTER_MODE_REMOTE) {
727
747
  self.setOption("features.lazyLoad", false);
728
748
  lazyLoadFlag = false;
@@ -994,7 +1014,7 @@ function processAndApplyPaginationData(data) {
994
1014
  const mappingCurrentPage = this.getOption("mapping.currentPage");
995
1015
  const mappingObjectsPerPage = this.getOption("mapping.objectsPerPage");
996
1016
 
997
- if (!mappingTotal || !mappingCurrentPage || !mappingObjectsPerPage) {
1017
+ if (!mappingTotal) {
998
1018
  this.setOption("total", null);
999
1019
  resetPaginationState.call(this);
1000
1020
  return;
@@ -1003,8 +1023,6 @@ function processAndApplyPaginationData(data) {
1003
1023
  try {
1004
1024
  const pathfinder = new Pathfinder(data);
1005
1025
  const total = pathfinder.getVia(mappingTotal);
1006
- const currentPage = pathfinder.getVia(mappingCurrentPage);
1007
- const objectsPerPage = pathfinder.getVia(mappingObjectsPerPage);
1008
1026
 
1009
1027
  if (!isInteger(total)) {
1010
1028
  addErrorAttribute(this, "total is not an integer");
@@ -1015,8 +1033,16 @@ function processAndApplyPaginationData(data) {
1015
1033
 
1016
1034
  this.setOption("total", total);
1017
1035
 
1036
+ if (!mappingCurrentPage || !mappingObjectsPerPage) {
1037
+ resetPaginationState.call(this, false);
1038
+ return;
1039
+ }
1040
+
1041
+ const currentPage = pathfinder.getVia(mappingCurrentPage);
1042
+ const objectsPerPage = pathfinder.getVia(mappingObjectsPerPage);
1043
+
1018
1044
  if (total === 0) {
1019
- resetPaginationState.call(this);
1045
+ resetPaginationState.call(this, false);
1020
1046
  return;
1021
1047
  }
1022
1048
 
@@ -1887,6 +1913,14 @@ function fetchIt(url, controlOptions) {
1887
1913
  this[fetchRequestVersionSymbol] += 1;
1888
1914
  const requestVersion = this[fetchRequestVersionSymbol];
1889
1915
 
1916
+ if (getFilterMode.call(this) === FILTER_MODE_REMOTE) {
1917
+ let classes = new TokenList(this.getOption("classes.noOptions"));
1918
+ classes.add("d-none");
1919
+ this.setOption("classes.noOptions", classes.toString());
1920
+ this.setOption("messages.emptyOptions", "");
1921
+ this.setOption("messages.total", "");
1922
+ }
1923
+
1890
1924
  new Processing(10, () => {
1891
1925
  fetchData
1892
1926
  .call(this, url)
@@ -2692,7 +2726,17 @@ function setTotalText() {
2692
2726
  return;
2693
2727
  }
2694
2728
 
2729
+ if (this[isLoadingSymbol] === true) {
2730
+ this.setOption("messages.total", "");
2731
+ return;
2732
+ }
2733
+
2695
2734
  const count = this.getOption("options").length;
2735
+ if (count === 0) {
2736
+ this.setOption("messages.total", "");
2737
+ return;
2738
+ }
2739
+
2696
2740
  const total = Number.parseInt(this.getOption("total"));
2697
2741
  if (Number.isNaN(total)) {
2698
2742
  this.setOption("messages.total", "");
@@ -3887,8 +3931,12 @@ function areOptionsAvailableAndInitInternal() {
3887
3931
  setStatusOrRemoveBadges.call(this, "empty");
3888
3932
  if (getFilterMode.call(this) === FILTER_MODE_REMOTE) {
3889
3933
  if (this[isLoadingSymbol] !== true) {
3890
- this.setOption("total", null);
3891
- resetPaginationState.call(this);
3934
+ if (isInteger(this.getOption("total"))) {
3935
+ resetPaginationState.call(this, false);
3936
+ } else {
3937
+ this.setOption("total", null);
3938
+ resetPaginationState.call(this);
3939
+ }
3892
3940
  }
3893
3941
  }
3894
3942
 
@@ -4113,6 +4161,9 @@ function convertSelectionToValue(selection) {
4113
4161
  * @private
4114
4162
  * @param value
4115
4163
  * @returns {boolean}
4164
+ *
4165
+ * Empty-equivalent matching is intentionally type-safe. Consumers that want
4166
+ * numeric `0` to count as empty must configure `0`, not `"0"`.
4116
4167
  */
4117
4168
  function isValueIsEmpty(value) {
4118
4169
  let equivalents = this.getOption("empty.equivalents");
@@ -4123,7 +4174,7 @@ function isValueIsEmpty(value) {
4123
4174
  equivalents = [equivalents];
4124
4175
  }
4125
4176
 
4126
- return equivalents.indexOf(value) !== -1;
4177
+ return equivalents.includes(value);
4127
4178
  }
4128
4179
 
4129
4180
  /**
@@ -4415,8 +4466,10 @@ function show() {
4415
4466
  const shouldLoadRemoteOptions =
4416
4467
  getFilterMode.call(self) === FILTER_MODE_REMOTE &&
4417
4468
  getOptionElements.call(self).length === 0;
4469
+ const shouldLoadDefaultOptions =
4470
+ shouldUseDefaultOptionsUrl.call(self, getCurrentFilterValue.call(self));
4418
4471
 
4419
- if (shouldUseDefaultOptionsUrl.call(self, getCurrentFilterValue.call(self))) {
4472
+ if (shouldLoadDefaultOptions) {
4420
4473
  setTimeout(() => {
4421
4474
  loadDefaultOptionsFromUrl.call(self).catch((e) => {
4422
4475
  addErrorAttribute(self, e);
@@ -4429,6 +4482,8 @@ function show() {
4429
4482
  addErrorAttribute(self, e);
4430
4483
  });
4431
4484
  }, 0);
4485
+ } else {
4486
+ initTotal.call(self);
4432
4487
  }
4433
4488
  calcAndSetOptionsDimension.call(this);
4434
4489
  focusFilter.call(this);
@@ -4500,6 +4555,18 @@ function initTotal() {
4500
4555
  return;
4501
4556
  }
4502
4557
 
4558
+ if (this.getOption("features.showRemoteInfo") !== true) {
4559
+ return;
4560
+ }
4561
+
4562
+ if (isInteger(this.getOption("total"))) {
4563
+ return;
4564
+ }
4565
+
4566
+ if (this[remoteInfoRequestSymbol]) {
4567
+ return;
4568
+ }
4569
+
4503
4570
  const url = this.getOption("remoteInfo.url");
4504
4571
  const mappingTotal = this.getOption("mapping.total");
4505
4572
 
@@ -4508,8 +4575,9 @@ function initTotal() {
4508
4575
  }
4509
4576
 
4510
4577
  const fetchOptions = this.getOption("fetch", {});
4578
+ this.setOption("messages.total", "");
4511
4579
 
4512
- getGlobal()
4580
+ const remoteInfoRequest = getGlobal()
4513
4581
  .fetch(url, fetchOptions)
4514
4582
  .then((response) => {
4515
4583
  if (!response.ok) {
@@ -4533,7 +4601,14 @@ function initTotal() {
4533
4601
  })
4534
4602
  .catch((e) => {
4535
4603
  addErrorAttribute(this, e);
4604
+ })
4605
+ .finally(() => {
4606
+ if (this[remoteInfoRequestSymbol] === remoteInfoRequest) {
4607
+ this[remoteInfoRequestSymbol] = null;
4608
+ }
4536
4609
  });
4610
+
4611
+ this[remoteInfoRequestSymbol] = remoteInfoRequest;
4537
4612
  }
4538
4613
 
4539
4614
  function updatePagination(total, currentPage, objectsPerPage) {
@@ -4546,7 +4621,7 @@ function updatePagination(total, currentPage, objectsPerPage) {
4546
4621
  });
4547
4622
  }
4548
4623
 
4549
- function resetPaginationState() {
4624
+ function resetPaginationState(clearTotalMessage = true) {
4550
4625
  const paginationElement = this[paginationElementSymbol];
4551
4626
  if (!paginationElement) {
4552
4627
  return;
@@ -4556,7 +4631,9 @@ function resetPaginationState() {
4556
4631
  paginationElement.setOption("pages", null);
4557
4632
  paginationElement.setOption("currentPage", null);
4558
4633
  paginationElement.setOption("objectsPerPage", null);
4559
- this.setOption("messages.total", "");
4634
+ if (clearTotalMessage === true) {
4635
+ this.setOption("messages.total", "");
4636
+ }
4560
4637
  }
4561
4638
 
4562
4639
  function clearOptionsOnError() {
@@ -790,6 +790,233 @@ describe('Select', function () {
790
790
  }, 150);
791
791
  });
792
792
 
793
+ it('should not eagerly fetch remote info when showRemoteInfo is disabled', function (done) {
794
+ this.timeout(3000);
795
+
796
+ let mocks = document.getElementById('mocks');
797
+ const requests = [];
798
+ const previousFetch = global['fetch'];
799
+
800
+ global['fetch'] = function (url) {
801
+ requests.push(String(url));
802
+
803
+ return createJsonResponse({
804
+ items: [
805
+ {id: 'alpha', name: 'Alpha'}
806
+ ],
807
+ pagination: {
808
+ total: 3,
809
+ page: 2,
810
+ perPage: 1
811
+ }
812
+ });
813
+ };
814
+
815
+ const select = document.createElement('monster-select');
816
+ select.setOption('url', 'https://example.com/items?filter={filter}&page={page}');
817
+ select.setOption('filter.mode', 'remote');
818
+ select.setOption('mapping.selector', 'items.*');
819
+ select.setOption('mapping.labelTemplate', '${name}');
820
+ select.setOption('mapping.valueTemplate', '${id}');
821
+ select.setOption('mapping.total', 'pagination.total');
822
+ select.setOption('mapping.currentPage', 'pagination.page');
823
+ select.setOption('mapping.objectsPerPage', 'pagination.perPage');
824
+ select.setOption('remoteInfo.url', 'https://example.com/remote-info');
825
+ select.setOption('features.showRemoteInfo', false);
826
+ mocks.appendChild(select);
827
+
828
+ setTimeout(() => {
829
+ try {
830
+ expect(requests).to.deep.equal([]);
831
+ } catch (e) {
832
+ global['fetch'] = previousFetch;
833
+ return done(e);
834
+ }
835
+
836
+ select.fetch('https://example.com/items?filter=*&page=2')
837
+ .then(() => {
838
+ try {
839
+ const pagination = select.shadowRoot.querySelector('[data-monster-role=pagination]');
840
+
841
+ expect(requests).to.deep.equal([
842
+ 'https://example.com/items?filter=*&page=2'
843
+ ]);
844
+ expect(select.getOption('total')).to.equal(3);
845
+ expect(pagination.getOption('currentPage')).to.equal(2);
846
+ expect(pagination.getOption('pages')).to.equal(3);
847
+ expect(pagination.getOption('objectsPerPage')).to.equal(1);
848
+ } catch (e) {
849
+ return done(e);
850
+ } finally {
851
+ global['fetch'] = previousFetch;
852
+ }
853
+
854
+ done();
855
+ })
856
+ .catch((e) => {
857
+ global['fetch'] = previousFetch;
858
+ done(e);
859
+ });
860
+ }, 150);
861
+ });
862
+
863
+ it('should defer remote info fetching until the dropdown is opened', async function () {
864
+ this.timeout(4000);
865
+
866
+ let mocks = document.getElementById('mocks');
867
+ const requests = [];
868
+ const remoteInfoUrl = 'https://example.com/remote-info';
869
+
870
+ global['fetch'] = function (url) {
871
+ requests.push(String(url));
872
+
873
+ return createJsonResponse({
874
+ pagination: {
875
+ total: 5
876
+ }
877
+ });
878
+ };
879
+
880
+ const select = document.createElement('monster-select');
881
+ select.setOption('filter.mode', 'remote');
882
+ select.setOption('filter.position', 'popper');
883
+ select.setOption('mapping.total', 'pagination.total');
884
+ select.setOption('remoteInfo.url', remoteInfoUrl);
885
+ select.setOption('options', [
886
+ {label: 'Alpha', value: 'alpha'}
887
+ ]);
888
+ mocks.appendChild(select);
889
+
890
+ await waitForCondition(() => {
891
+ return select.shadowRoot.querySelector('[data-monster-role=container]') instanceof HTMLElement;
892
+ });
893
+
894
+ expect(requests).to.deep.equal([]);
895
+
896
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
897
+
898
+ container.click();
899
+ await waitForCondition(() => requests.includes(remoteInfoUrl));
900
+ expect(requests.filter((url) => url === remoteInfoUrl)).to.have.length(1);
901
+ });
902
+
903
+ it('should keep total-only remote totals for loaded options', async function () {
904
+ this.timeout(3000);
905
+
906
+ let mocks = document.getElementById('mocks');
907
+
908
+ global['fetch'] = function () {
909
+ return createJsonResponse({
910
+ items: [
911
+ {id: 'alpha', name: 'Alpha'}
912
+ ],
913
+ pagination: {
914
+ total: 1
915
+ }
916
+ });
917
+ };
918
+
919
+ const select = document.createElement('monster-select');
920
+ select.setOption('url', 'https://example.com/items?filter={filter}&page={page}');
921
+ select.setOption('filter.mode', 'remote');
922
+ select.setOption('mapping.selector', 'items.*');
923
+ select.setOption('mapping.labelTemplate', '${name}');
924
+ select.setOption('mapping.valueTemplate', '${id}');
925
+ select.setOption('mapping.total', 'pagination.total');
926
+ mocks.appendChild(select);
927
+
928
+ await waitForCondition(() => {
929
+ return select.shadowRoot.querySelector('[data-monster-role=container]') instanceof HTMLElement;
930
+ });
931
+
932
+ await select.fetch('https://example.com/items?filter=alpha&page=1');
933
+
934
+ expect(select.getOption('total')).to.equal(1);
935
+ expect(select.getOption('messages.total')).to.contain('No additional entries are available');
936
+ });
937
+
938
+ it('should avoid duplicate remote-info badges for empty remote filter results', async function () {
939
+ this.timeout(4000);
940
+
941
+ let mocks = document.getElementById('mocks');
942
+
943
+ global['fetch'] = function () {
944
+ return createJsonResponse({
945
+ items: [],
946
+ pagination: {
947
+ total: 0
948
+ }
949
+ });
950
+ };
951
+
952
+ const select = document.createElement('monster-select');
953
+ select.setOption('url', 'https://example.com/items?filter={filter}&page={page}');
954
+ select.setOption('filter.mode', 'remote');
955
+ select.setOption('filter.position', 'popper');
956
+ select.setOption('mapping.selector', 'items.*');
957
+ select.setOption('mapping.labelTemplate', '${name}');
958
+ select.setOption('mapping.valueTemplate', '${id}');
959
+ select.setOption('mapping.total', 'pagination.total');
960
+ mocks.appendChild(select);
961
+
962
+ await waitForCondition(() => {
963
+ return select.shadowRoot.querySelector('[data-monster-role=filter][name=\"popper-filter\"]') instanceof HTMLInputElement;
964
+ });
965
+
966
+ const container = select.shadowRoot.querySelector('[data-monster-role=container]');
967
+ const filterInput = select.shadowRoot.querySelector('[data-monster-role=filter][name="popper-filter"]');
968
+ filterInput.value = 'alpha';
969
+
970
+ container.click();
971
+
972
+ await waitForCondition(() => {
973
+ return select.getOption('total') === 0;
974
+ });
975
+
976
+ expect(select.getOption('total')).to.equal(0);
977
+ expect(select.getOption('messages.total')).to.equal('');
978
+ expect(select.getOption('messages.emptyOptions')).to.contain('Please consider modifying the filter');
979
+ });
980
+
981
+ it('should keep empty equivalent matching type-safe for numeric zero', function (done) {
982
+ this.timeout(2000);
983
+
984
+ let mocks = document.getElementById('mocks');
985
+ const stringEquivalentSelect = document.createElement('monster-select');
986
+ stringEquivalentSelect.setOption('empty.equivalents', ['0']);
987
+ mocks.appendChild(stringEquivalentSelect);
988
+
989
+ const numericEquivalentSelect = document.createElement('monster-select');
990
+ numericEquivalentSelect.setOption('empty.equivalents', [0]);
991
+ mocks.appendChild(numericEquivalentSelect);
992
+
993
+ setTimeout(() => {
994
+ stringEquivalentSelect.value = 0;
995
+ numericEquivalentSelect.value = 0;
996
+
997
+ setTimeout(() => {
998
+ try {
999
+ expect(stringEquivalentSelect.value).to.equal('0');
1000
+ expect(stringEquivalentSelect.getOption('selection')).to.deep.equal([
1001
+ {
1002
+ label: '0',
1003
+ value: 0,
1004
+ class: 'monster-badge-primary',
1005
+ unresolved: false
1006
+ }
1007
+ ]);
1008
+
1009
+ expect(numericEquivalentSelect.value).to.equal('');
1010
+ expect(numericEquivalentSelect.getOption('selection')).to.deep.equal([]);
1011
+ } catch (e) {
1012
+ return done(e);
1013
+ }
1014
+
1015
+ done();
1016
+ }, 50);
1017
+ }, 50);
1018
+ });
1019
+
793
1020
  it('should not refetch after selecting an already loaded remote option', function (done) {
794
1021
  this.timeout(3000);
795
1022