classcard-ui 0.2.1474 → 0.2.1476

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "classcard-ui",
3
- "version": "0.2.1474",
3
+ "version": "0.2.1476",
4
4
  "main": "dist/classcard-ui.umd.min.js",
5
5
  "scripts": {
6
6
  "serve": "vue-cli-service serve",
@@ -14,7 +14,10 @@
14
14
  <div class="relative">
15
15
  <v-select
16
16
  ref="vselect"
17
- class="disabled:custom-disabled-state absolute inline-block h-full w-full text-sm"
17
+ :class="[
18
+ 'disabled:custom-disabled-state absolute inline-block h-full w-full text-sm',
19
+ isMultiple ? 'c-multiselect--capped-pills' : '',
20
+ ]"
18
21
  :placeholder="placeholder"
19
22
  :multiple="isMultiple"
20
23
  :taggable="isTaggable"
@@ -62,48 +65,76 @@
62
65
  />
63
66
  </span>
64
67
  </template>
65
- <template #selected-option="option">
66
- <div v-if="!option.header" :class="['flex items-center gap-2', isDisabled ? 'text-gray-500' : '']">
67
- <c-icon
68
- v-if="option.icon"
69
- :name="option.icon.name"
70
- :type="option.icon.type"
71
- :class="[
72
- 'h-4 w-4 flex-shrink-0 text-gray-400',
73
- option.icon.optionIconClass,
74
- ]"
75
- />
76
- <c-avatar
77
- v-if="option.image && displaySelectedOptionAvatar"
78
- :size="
79
- option.description && showAdditionalText
80
- ? 'extrasmall'
81
- : 'extraextrasmall'
82
- "
83
- :image="option.image"
84
- :rounded="true"
85
- :description="option.description"
86
- class="mr-2"
87
- ></c-avatar>
88
- <c-avatar
89
- v-else-if="option.initials && displaySelectedOptionAvatar"
90
- size="extraextrasmall"
91
- :nameInitials="option.initials"
92
- :description="option.description"
93
- :rounded="true"
94
- :isDynamicallyColored="true"
95
- class="mr-2"
96
- ></c-avatar>
97
- <span>{{
98
- selectedOptionLabel && option[selectedOptionLabel]
99
- ? option[selectedOptionLabel]
100
- : optionLabelSecondary && option[optionLabelSecondary]
101
- ? option[optionLabelSecondary]
102
- : optionLabel && option[optionLabel]
103
- ? option[optionLabel]
104
- : ""
105
- }}</span>
106
- </div>
68
+ <template
69
+ #selected-option-container="{ option, deselect, multiple, disabled }"
70
+ >
71
+ <template v-for="row in [getPillRow(option)]">
72
+ <div
73
+ v-if="row && row.showPill"
74
+ :key="row.slotKey"
75
+ class="vs__selected vs__selected--capped inline-flex items-center gap-1.5"
76
+ >
77
+ <p
78
+ v-if="!option.header"
79
+ :class="[
80
+ 'vs__selected-inner inline-flex min-w-0 max-w-[120px] items-center gap-1.5 rounded bg-gray-100 px-2 py-1 text-gray-800',
81
+ isDisabled ? 'text-gray-500' : '',
82
+ ]"
83
+ >
84
+ <c-icon
85
+ v-if="option.icon"
86
+ :name="option.icon.name"
87
+ :type="option.icon.type"
88
+ :class="[
89
+ 'h-4 w-4 flex-shrink-0 text-gray-400',
90
+ option.icon.optionIconClass,
91
+ ]"
92
+ />
93
+ <c-avatar
94
+ v-if="option.image && displaySelectedOptionAvatar"
95
+ :size="
96
+ option.description && showAdditionalText
97
+ ? 'extrasmall'
98
+ : 'extraextrasmall'
99
+ "
100
+ :image="option.image"
101
+ :rounded="true"
102
+ :description="option.description"
103
+ class="flex-shrink-0"
104
+ ></c-avatar>
105
+ <c-avatar
106
+ v-else-if="option.initials && displaySelectedOptionAvatar"
107
+ size="extraextrasmall"
108
+ :nameInitials="option.initials"
109
+ :description="option.description"
110
+ :rounded="true"
111
+ :isDynamicallyColored="true"
112
+ class="flex-shrink-0"
113
+ ></c-avatar>
114
+ <span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
115
+ <button
116
+ v-if="multiple"
117
+ type="button"
118
+ class="flex h-2 w-2 flex-shrink-0 items-center justify-center rounded text-gray-400 focus:outline-none"
119
+ :disabled="disabled"
120
+ :title="`Deselect ${row.label}`"
121
+ :aria-label="`Deselect ${row.label}`"
122
+ @click.stop.prevent="deselect(option)"
123
+ >
124
+ <span class="text-base leading-none" aria-hidden="true"
125
+ >×</span
126
+ >
127
+ </button>
128
+ </p>
129
+ <span
130
+ v-if="row.showOverflowBadge"
131
+ class="vs__selected-overflow inline-flex w-max rounded bg-gray-100 px-2 py-1 text-gray-800"
132
+ aria-live="polite"
133
+ >
134
+ +{{ selectedOverflowCount }}
135
+ </span>
136
+ </div>
137
+ </template>
107
138
  </template>
108
139
  <!-- eslint-disable-next-line vue/no-unused-vars -->
109
140
  <template #no-options="{ search, searching, loading }">
@@ -145,6 +176,12 @@
145
176
  <div v-if="!isGrouped">
146
177
  <div v-if="option.hasNoData" class="dropdown-menu-bordered"></div>
147
178
  <div v-else class="mt-1 flex h-full w-full items-center">
179
+ <div @click.stop v-if="addCheckBox" class="flex-shrink-0">
180
+ <c-checkbox
181
+ :value="isChecked(option)"
182
+ @onChange="handleSingleSelect(option)"
183
+ />
184
+ </div>
148
185
  <p v-if="showOptionImage" class="flex-shrink-0">
149
186
  <c-avatar
150
187
  v-if="option[imageLabel]"
@@ -210,12 +247,6 @@
210
247
  </span>
211
248
  </div>
212
249
  </div>
213
- <div @click.stop v-if="addCheckBox">
214
- <c-checkbox
215
- :value="isChecked(option)"
216
- @onChange="handleSingleSelect(option)"
217
- />
218
- </div>
219
250
  </div>
220
251
  <p
221
252
  class="text-xs"
@@ -315,9 +346,14 @@
315
346
  <li ref="load" class="loader" v-show="hasNextPage">
316
347
  Loading more options...
317
348
  </li>
349
+
350
+ <!-- Footer buttons when footer buttons are visible -->
318
351
  <div
319
- class="group sticky bottom-0 mt-1 bg-white"
320
- :class="showFooterButton && showFooterButton2 ? 'space-y-2' : ''"
352
+ v-if="showFooterButton || showFooterButton2"
353
+ :class="[
354
+ 'group sticky bottom-0 mt-1 bg-white',
355
+ showFooterButton && showFooterButton2 ? 'space-y-2' : '',
356
+ ]"
321
357
  >
322
358
  <li
323
359
  v-if="showFooterButton"
@@ -372,6 +408,8 @@
372
408
  </div>
373
409
  </li>
374
410
  </div>
411
+
412
+ <!-- Create option button when showCreateOption is true and there is a search term -->
375
413
  <div
376
414
  v-if="
377
415
  showCreateOption &&
@@ -379,7 +417,9 @@
379
417
  search &&
380
418
  search.trim()
381
419
  "
382
- class="sticky bottom-0 z-10 flex min-h-[44px] items-start gap-3 rounded-b-md bg-white px-3 py-2 shadow-lg ring-1 ring-gray-900 ring-opacity-5"
420
+ ref="createOption"
421
+ class="sticky z-10 flex min-h-[44px] items-start gap-3 bg-white px-3 py-2 shadow-lg ring-1 ring-gray-900 ring-opacity-5"
422
+ :style="footerStickyStyles.create"
383
423
  >
384
424
  <span class="word-break flex-1 text-sm text-gray-700">{{
385
425
  search
@@ -400,9 +440,13 @@
400
440
  Create
401
441
  </button>
402
442
  </div>
443
+
444
+ <!-- Other option checkbox when otherOption is true -->
403
445
  <div
404
446
  v-if="otherOption"
405
- class="sticky bottom-0 z-10 border-t border-gray-100 bg-gray-50 px-3 py-2"
447
+ ref="otherOption"
448
+ class="sticky z-10 border-t border-gray-100 bg-gray-50 px-3 py-2"
449
+ :style="footerStickyStyles.other"
406
450
  >
407
451
  <div class="flex" @mousedown.prevent.stop>
408
452
  <c-checkbox
@@ -419,6 +463,20 @@
419
463
  </div>
420
464
  </div>
421
465
  </div>
466
+
467
+ <!-- Clear all button when multiple is true and there are selected values -->
468
+ <button
469
+ v-if="isMultiple && hasSelectedValues"
470
+ ref="clearAllButton"
471
+ type="button"
472
+ class="sticky z-10 w-full border-t border-gray-200 bg-gray-50 px-4 py-3 text-left text-sm text-gray-500 hover:text-gray-900 focus:outline-none"
473
+ :style="footerStickyStyles.clear"
474
+ :id="id + '_clear_all_button'"
475
+ aria-label="Clear all selections"
476
+ @mousedown.prevent.stop="handleClearAll"
477
+ >
478
+ Clear all
479
+ </button>
422
480
  </template>
423
481
  </v-select>
424
482
  <button
@@ -436,7 +494,10 @@
436
494
  </button>
437
495
  </div>
438
496
  <p v-if="subLabel" class="mt-2 text-sm text-gray-500">{{ subLabel }}</p>
439
- <p v-if="!isValidate && errorMessage" class="mt-2 text-sm text-red-600 text-left">
497
+ <p
498
+ v-if="!isValidate && errorMessage"
499
+ class="mt-2 text-left text-sm text-red-600"
500
+ >
440
501
  {{ errorMessage }}
441
502
  </p>
442
503
  <p v-if="helpText && isValidate == true" class="mt-2 text-sm text-gray-500">
@@ -450,7 +511,6 @@ import CAvatar from "../CAvatar/CAvatar.vue";
450
511
  import CIcon from "../CIcon/CIcon.vue";
451
512
  import CCheckbox from "../CCheckbox/CCheckbox.vue";
452
513
  import { debounce } from "lodash-es";
453
- // import Fuse from "fuse.js";
454
514
  import "vue-select/dist/vue-select.css";
455
515
  import { getActionID } from "../../helper";
456
516
  import CTag from "../CTag/CTag.vue";
@@ -523,6 +583,11 @@ export default {
523
583
  selectedOptionLabel: {
524
584
  type: String,
525
585
  },
586
+ // the key to compare the options when addCheckBox is true
587
+ primaryComparisonKey: {
588
+ type: String,
589
+ default: "id",
590
+ },
526
591
  // action to trigger after selecting option from dropdown
527
592
  onSelectOptions: {
528
593
  type: Function,
@@ -681,6 +746,67 @@ export default {
681
746
 
682
747
  return !hasExactMatch;
683
748
  },
749
+ /*
750
+ This function is used to get the remaining count of options that are not shown in the dropdown.
751
+ */
752
+ selectedOverflowCount() {
753
+ if (!this.isMultiple) return 0;
754
+ const totalSelectedOptions = Array.isArray(this.value)
755
+ ? this.value.length
756
+ : 0;
757
+ return totalSelectedOptions > 2 ? totalSelectedOptions - 2 : 0;
758
+ },
759
+ hasSelectedValues() {
760
+ return Array.isArray(this.value) && this.value.length > 0;
761
+ },
762
+ /**
763
+ * Pre-built pill rows keyed by `value[i]` and by vue-select option key.
764
+ * Recomputes only when selection / multi / overflow-relevant state changes — not on dropdown hover.
765
+ * Index comes from `value` order (same order vue-select uses for selected-option-container).
766
+ */
767
+ pillRowsBySlotLookup() {
768
+ if (!Array.isArray(this.value)) {
769
+ return { byRef: new Map(), byKey: new Map() };
770
+ }
771
+ const byRef = new Map();
772
+ const byKey = new Map();
773
+ const overflow = this.selectedOverflowCount;
774
+
775
+ this.value.forEach((item, index) => {
776
+ const label = this.getDisplayLabelForOption(item);
777
+ const key = this.getVueSelectOptionKey(item);
778
+ const slotKey = `slot-${index}-${String(key)}`;
779
+ const meta = { index, key, label, slotKey };
780
+ const showPill = this.isMultiple;
781
+ const showOverflowBadge =
782
+ this.isMultiple && index === 1 && overflow > 0;
783
+ const row = {
784
+ meta,
785
+ showPill,
786
+ showOverflowBadge,
787
+ label,
788
+ slotKey,
789
+ };
790
+ byRef.set(item, row);
791
+ if (!byKey.has(key)) {
792
+ byKey.set(key, row);
793
+ }
794
+ });
795
+ return { byRef, byKey };
796
+ },
797
+ /**
798
+ * Sticky `bottom` offsets so create → other → clear stack without overlap.
799
+ * DOM order is create (top of sticky group), other, clear (anchored to dropdown bottom).
800
+ */
801
+ footerStickyStyles() {
802
+ const clearH = Number(this.footerMetaData.clearAllHeight) || 0;
803
+ const otherH = Number(this.footerMetaData.otherOptionHeight) || 0;
804
+ return {
805
+ create: { bottom: `${clearH + otherH}px` },
806
+ other: { bottom: `${clearH}px` },
807
+ clear: { bottom: "0px" },
808
+ };
809
+ },
684
810
  },
685
811
  data() {
686
812
  return {
@@ -690,6 +816,11 @@ export default {
690
816
  ? this.optionsSelected
691
817
  : [],
692
818
  observer: null,
819
+ footerMetaData: {
820
+ clearAllHeight: 0,
821
+ otherOptionHeight: 0,
822
+ createOptionHeight: 0,
823
+ },
693
824
  };
694
825
  },
695
826
  methods: {
@@ -702,6 +833,8 @@ export default {
702
833
  },
703
834
  fetchOptions(search, loaderSearching) {
704
835
  this.emitGetOptions(search, loaderSearching);
836
+
837
+ this._updateFooterHeights();
705
838
  },
706
839
  emitGetOptions: debounce(function (search, loaderSearching) {
707
840
  this.$emit("getOptions", search.trim(), loaderSearching);
@@ -724,23 +857,93 @@ export default {
724
857
  this.emitGetOptions("", true);
725
858
  },
726
859
  onClose() {
727
- this.observer.disconnect();
860
+ if (this.observer) {
861
+ this.observer.disconnect();
862
+ }
728
863
  },
729
864
  async infiniteScroll([{ isIntersecting }]) {
730
865
  if (isIntersecting) {
731
866
  this.emitLoadNextPage();
732
867
  }
733
868
  },
734
- getIndex(option) {
735
- return this.value.findIndex((item) => item.id == option.id);
869
+ /*
870
+ This function is used to create a unique key for the option.
871
+ */
872
+ createUniqueKeyForOption(sortable) {
873
+ const ordered = {};
874
+ Object.keys(sortable)
875
+ .sort()
876
+ .forEach((key) => {
877
+ ordered[key] = sortable[key];
878
+ });
879
+ return JSON.stringify(ordered);
880
+ },
881
+ /*
882
+ This function is used to get the key of the option for the selected pills.
883
+ Aligns with vue-select default getOptionKey.
884
+ Reference: https://vue-select.org/api/props.html#getoptionkey
885
+ */
886
+ getVueSelectOptionKey(option) {
887
+ if (typeof option !== "object" || option === null) {
888
+ return option;
889
+ }
890
+ try {
891
+ return Object.prototype.hasOwnProperty.call(option, "id")
892
+ ? option.id
893
+ : this.createUniqueKeyForOption(option);
894
+ } catch (e) {
895
+ return String(option);
896
+ }
897
+ },
898
+ getDisplayLabelForOption(option) {
899
+ if (!option) return "";
900
+ if (this.selectedOptionLabel && option[this.selectedOptionLabel]) {
901
+ return option[this.selectedOptionLabel];
902
+ }
903
+ if (this.optionLabelSecondary && option[this.optionLabelSecondary]) {
904
+ return option[this.optionLabelSecondary];
905
+ }
906
+ if (this.optionLabel && option[this.optionLabel]) {
907
+ return option[this.optionLabel];
908
+ }
909
+ return "";
910
+ },
911
+ /**
912
+ * Easy lookup for each selected-option slot.
913
+ */
914
+ getPillRow(option) {
915
+ const { byRef, byKey } = this.pillRowsBySlotLookup;
916
+ if (byRef.has(option)) {
917
+ return byRef.get(option);
918
+ }
919
+ const k = this.getVueSelectOptionKey(option);
920
+ return byKey.get(k) || null;
921
+ },
922
+ handleClearAll() {
923
+ this.value = [];
924
+ this.$emit("onSelectOptions", []);
925
+ this.$nextTick(() => {
926
+ if (this.$refs.vselect) {
927
+ this.$refs.vselect.search = "";
928
+ }
929
+ });
736
930
  },
737
931
  isChecked(option) {
738
- return this.value.findIndex((item) => item.id == option.id) == -1 ? 0 : 1;
932
+ return this.value.some(
933
+ (item) =>
934
+ item[this.primaryComparisonKey] === option[this.primaryComparisonKey]
935
+ )
936
+ ? 1
937
+ : 0;
739
938
  },
740
939
  handleSingleSelect(option) {
741
940
  if (this.addCheckBox) {
742
- if (this.isChecked(option) == 1) {
743
- this.value = this.value.filter((item) => item.id != option.id);
941
+ if (this.isChecked(option) === 1) {
942
+ this.value = this.value.filter(
943
+ (item) =>
944
+ item[this.primaryComparisonKey] !==
945
+ option[this.primaryComparisonKey]
946
+ );
744
947
  } else {
745
948
  this.value = [...this.value, option];
746
949
  }
@@ -751,10 +954,12 @@ export default {
751
954
  }
752
955
  },
753
956
  handleOtherOptionChange() {
754
- const isSelected = this.isChecked(this.otherOption) == 1;
957
+ const isSelected = this.isChecked(this.otherOption) === 1;
755
958
  if (isSelected) {
756
959
  this.value = this.value.filter(
757
- (item) => item.id != this.otherOption.id
960
+ (item) =>
961
+ item[this.primaryComparisonKey] !==
962
+ this.otherOption[this.primaryComparisonKey]
758
963
  );
759
964
  } else {
760
965
  this.value = [...this.value, this.otherOption];
@@ -822,6 +1027,19 @@ export default {
822
1027
  }
823
1028
  }
824
1029
  },
1030
+ _updateFooterHeights() {
1031
+ this.$nextTick(() => {
1032
+ const createEl = this.$refs.createOption;
1033
+ const otherEl = this.$refs.otherOption;
1034
+ const clearEl = this.$refs.clearAllButton;
1035
+
1036
+ this.footerMetaData = {
1037
+ createOptionHeight: createEl ? createEl.offsetHeight : 0,
1038
+ otherOptionHeight: otherEl ? otherEl.offsetHeight : 0,
1039
+ clearAllHeight: clearEl ? clearEl.offsetHeight : 0,
1040
+ };
1041
+ });
1042
+ },
825
1043
  },
826
1044
  watch: {
827
1045
  optionsSelected: {
@@ -830,41 +1048,52 @@ export default {
830
1048
  },
831
1049
  deep: true,
832
1050
  },
1051
+ value: {
1052
+ handler() {
1053
+ this._updateFooterHeights();
1054
+ },
1055
+ },
833
1056
  },
834
1057
  mounted() {
835
1058
  this.observer = new IntersectionObserver(this.infiniteScroll);
836
1059
  },
1060
+ beforeDestroy() {
1061
+ if (this.observer) {
1062
+ this.observer.disconnect();
1063
+ this.observer = null;
1064
+ }
1065
+ },
837
1066
  };
838
1067
  </script>
839
1068
  <style>
840
- .disabled {
841
- pointer-events: none;
842
- color: #bfcbd9;
843
- cursor: not-allowed;
844
- background-image: none;
845
- background-color: #eef1f6;
846
- border-color: #d1dbe5;
847
- }
848
1069
  .v-select {
849
1070
  @apply cursor-pointer;
850
1071
  }
1072
+
851
1073
  .vs__dropdown-toggle {
852
- @apply w-full min-h-[36px] rounded-md border border-gray-300 bg-white px-3 py-2 text-left shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm;
1074
+ @apply min-h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-left shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm;
853
1075
  }
1076
+
854
1077
  .vs__selected-options {
855
- flex-wrap: wrap !important;
856
- height: auto;
857
- max-height: 60px;
858
- overflow: hidden;
859
- overflow-y: auto;
1078
+ @apply flex items-center gap-1.5 p-0;
860
1079
  }
1080
+
1081
+ .c-multiselect--capped-pills .vs__selected-options {
1082
+ @apply min-w-0 flex-nowrap overflow-hidden;
1083
+ }
1084
+
1085
+ /* Hide 3rd+ chips if they still mount (key mismatch); keeps one row */
1086
+ .c-multiselect--capped-pills
1087
+ .vs__selected-options
1088
+ > .vs__selected:nth-child(n + 3) {
1089
+ display: none !important;
1090
+ }
1091
+
861
1092
  .vs--open .vs__dropdown-toggle {
862
1093
  border-bottom-color: rgba(212, 212, 216, var(--tw-border-opacity));
863
1094
  @apply rounded-b-md;
864
1095
  }
865
- .vs__selected {
866
- @apply m-0 border-none text-gray-700;
867
- }
1096
+
868
1097
  .extra:hover {
869
1098
  color: white;
870
1099
  }
@@ -912,7 +1141,7 @@ export default {
912
1141
  }
913
1142
 
914
1143
  .vs__dropdown-option--disabled {
915
- @apply !text-gray-500 !bg-gray-50 !border-gray-200 shadow-none opacity-100 cursor-not-allowed;
1144
+ @apply cursor-not-allowed !border-gray-200 !bg-gray-50 !text-gray-500 opacity-100 shadow-none;
916
1145
  }
917
1146
 
918
1147
  .vs__dropdown-option--disabled * {
@@ -927,11 +1156,15 @@ export default {
927
1156
  @apply pointer-events-none opacity-100;
928
1157
  }
929
1158
 
930
- .vs__dropdown-toggle {
931
- height: 100%;
932
- }
933
-
934
1159
  .vs__no-options {
935
1160
  @apply px-4 py-3 text-sm text-gray-500;
936
1161
  }
1162
+
1163
+ .vs__selected {
1164
+ @apply m-0 border-none !bg-transparent !p-0;
1165
+ }
1166
+
1167
+ .vs__deselect {
1168
+ @apply m-0;
1169
+ }
937
1170
  </style>