@weni/unnnic-system 3.14.0 → 3.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weni/unnnic-system",
3
- "version": "3.14.0",
3
+ "version": "3.15.0",
4
4
  "type": "commonjs",
5
5
  "files": [
6
6
  "dist",
@@ -399,6 +399,72 @@ describe('UnnnicSelect.vue', () => {
399
399
  });
400
400
  });
401
401
 
402
+ describe('infinite scroll functionality', () => {
403
+ test('infinite scroll is disabled by default', () => {
404
+ expect(wrapper.vm.infiniteScroll).toBe(false);
405
+ });
406
+
407
+ test('applies infinite scroll props correctly', async () => {
408
+ await wrapper.setProps({
409
+ infiniteScroll: true,
410
+ infiniteScrollDistance: 20,
411
+ infiniteScrollCanLoadMore: () => false,
412
+ });
413
+
414
+ expect(wrapper.vm.infiniteScroll).toBe(true);
415
+ expect(wrapper.vm.infiniteScrollDistance).toBe(20);
416
+ expect(wrapper.vm.infiniteScrollCanLoadMore()).toBe(false);
417
+ });
418
+
419
+ test('does not render loading when infiniteScrollLoading is false', async () => {
420
+ await wrapper.setProps({ infiniteScroll: true });
421
+ wrapper.vm.openPopover = true;
422
+ await wrapper.vm.$nextTick();
423
+
424
+ const loading = wrapper.find('.unnnic-select__infinite-loading');
425
+ expect(loading.exists()).toBe(false);
426
+ });
427
+
428
+ test('sets infiniteScrollLoading to true and verifies state', async () => {
429
+ await wrapper.setProps({
430
+ infiniteScroll: true,
431
+ options: [
432
+ { label: 'Option 1', value: 'option1' },
433
+ { label: 'Option 2', value: 'option2' },
434
+ ],
435
+ });
436
+
437
+ wrapper.vm.openPopover = true;
438
+ await wrapper.vm.$nextTick();
439
+
440
+ expect(wrapper.vm.infiniteScrollLoading).toBe(false);
441
+
442
+ wrapper.vm.infiniteScrollLoading = true;
443
+ await wrapper.vm.$nextTick();
444
+
445
+ expect(wrapper.vm.infiniteScrollLoading).toBe(true);
446
+ expect(wrapper.vm.infiniteScroll).toBe(true);
447
+ });
448
+
449
+ test('finishInfiniteScroll sets loading to false', async () => {
450
+ await wrapper.setProps({ infiniteScroll: true });
451
+ wrapper.vm.infiniteScrollLoading = true;
452
+ expect(wrapper.vm.infiniteScrollLoading).toBe(true);
453
+
454
+ wrapper.vm.finishInfiniteScroll();
455
+ expect(wrapper.vm.infiniteScrollLoading).toBe(false);
456
+ });
457
+
458
+ test('resetInfiniteScroll sets loading to false', async () => {
459
+ await wrapper.setProps({ infiniteScroll: true });
460
+ wrapper.vm.infiniteScrollLoading = true;
461
+ expect(wrapper.vm.infiniteScrollLoading).toBe(true);
462
+
463
+ wrapper.vm.resetInfiniteScroll();
464
+ expect(wrapper.vm.infiniteScrollLoading).toBe(false);
465
+ });
466
+ });
467
+
402
468
  describe('snapshot testing', () => {
403
469
  test('matches snapshot with default props', () => {
404
470
  expect(wrapper.html()).toMatchSnapshot();
@@ -418,5 +484,13 @@ describe('UnnnicSelect.vue', () => {
418
484
  await wrapper.setProps({ disabled: true });
419
485
  expect(wrapper.html()).toMatchSnapshot();
420
486
  });
487
+
488
+ test('matches snapshot with infinite scroll enabled', async () => {
489
+ await wrapper.setProps({ infiniteScroll: true });
490
+ wrapper.vm.openPopover = true;
491
+ wrapper.vm.infiniteScrollLoading = true;
492
+ await wrapper.vm.$nextTick();
493
+ expect(wrapper.html()).toMatchSnapshot();
494
+ });
421
495
  });
422
496
  });
@@ -36,6 +36,24 @@ exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with disabled st
36
36
  </div>"
37
37
  `;
38
38
 
39
+ exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with infinite scroll enabled 1`] = `
40
+ "<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="true" aria-controls="reka-popover-content-v-1" data-state="open">
41
+ <section data-v-9f8d6c86="" data-v-d890ad85="" data-v-6077efb7="" class="unnnic-form-element unnnic-form md unnnic-select__input" data-testid="form-element">
42
+ <!--v-if-->
43
+ <div data-v-a0d36167="" data-v-d890ad85="" class="text-input size--md unnnic-select__input unnnic-form-input" hascloudycolor="false" mask=""><input data-v-86533b41="" data-v-a0d36167="" class="unnnic-select__input unnnic-form-input input-itself input size-md normal input--has-icon-right focus use-focus-prop unnnic-select__input unnnic-form-input input-itself" hascloudycolor="false" placeholder="" iconleft="" iconright="keyboard_arrow_up" iconleftclickable="false" iconrightclickable="false" showclear="false" type="text" readonly="" value="">
44
+ <!--v-if-->
45
+ <section data-v-a0d36167="" class="icon-right-container">
46
+ <!--v-if--><span data-v-26446d8e="" data-v-a0d36167="" class="unnnic-icon material-symbols-rounded unnnic-icon-scheme--fg-base unnnic-icon-size--ant unnnic-icon__size--ant icon-right" data-testid="material-icon" translate="no">keyboard_arrow_up</span>
47
+ </section>
48
+ </div>
49
+ <!--v-if-->
50
+ </section>
51
+ </button>
52
+ <!--teleport start-->
53
+ <!--teleport end-->
54
+ </div>"
55
+ `;
56
+
39
57
  exports[`UnnnicSelect.vue > snapshot testing > matches snapshot with search enabled 1`] = `
40
58
  "<div data-v-6077efb7="" class="unnnic-select"><button data-v-9d52eef8="" data-v-6077efb7="" class="unnnic-popover-trigger w-full" id="reka-popover-trigger-v-0" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="" data-state="closed">
41
59
  <section data-v-9f8d6c86="" data-v-d890ad85="" data-v-6077efb7="" class="unnnic-form-element unnnic-form md unnnic-select__input" data-testid="form-element">
@@ -32,7 +32,10 @@
32
32
  :style="popoverContentCustomStyles"
33
33
  :width="inputWidthString"
34
34
  >
35
- <div class="unnnic-select__content">
35
+ <div
36
+ ref="contentRef"
37
+ class="unnnic-select__content"
38
+ >
36
39
  <UnnnicInput
37
40
  v-if="props.enableSearch"
38
41
  class="unnnic-select__input-search"
@@ -47,20 +50,30 @@
47
50
  >
48
51
  {{ $t('without_results') }}
49
52
  </p>
50
- <PopoverOption
51
- v-for="(option, index) in filteredOptions"
52
- v-else
53
- :key="option[props.itemValue]"
54
- :data-option-index="index"
55
- data-testid="select-option"
56
- :label="option[props.itemLabel]"
57
- :active="
58
- option[props.itemValue] === selectedItem?.[props.itemValue]
59
- "
60
- :focused="focusedOptionIndex === index"
61
- :disabled="option.disabled"
62
- @click="handleSelectOption(option)"
63
- />
53
+ <template v-else>
54
+ <PopoverOption
55
+ v-for="(option, index) in filteredOptions"
56
+ :key="option[props.itemValue]"
57
+ :data-option-index="index"
58
+ data-testid="select-option"
59
+ :label="option[props.itemLabel]"
60
+ :active="
61
+ option[props.itemValue] === selectedItem?.[props.itemValue]
62
+ "
63
+ :focused="focusedOptionIndex === index"
64
+ :disabled="option.disabled"
65
+ @click="handleSelectOption(option)"
66
+ />
67
+ <div
68
+ v-if="props.infiniteScroll && infiniteScrollLoading"
69
+ class="unnnic-select__infinite-loading"
70
+ >
71
+ <UnnnicIconLoading
72
+ scheme="neutral-dark"
73
+ size="sm"
74
+ />
75
+ </div>
76
+ </template>
64
77
  </div>
65
78
  </PopoverContent>
66
79
  </Popover>
@@ -68,10 +81,11 @@
68
81
  </template>
69
82
 
70
83
  <script setup lang="ts">
71
- import { computed, ref, watch, nextTick } from 'vue';
72
- import { useElementSize } from '@vueuse/core';
84
+ import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
85
+ import { useElementSize, useInfiniteScroll } from '@vueuse/core';
73
86
 
74
87
  import UnnnicInput from '../Input/Input.vue';
88
+ import UnnnicIconLoading from '../IconLoading/IconLoading.vue';
75
89
 
76
90
  import {
77
91
  Popover,
@@ -106,6 +120,9 @@ interface SelectProps {
106
120
  search?: string;
107
121
  locale?: string;
108
122
  disabled?: boolean;
123
+ infiniteScroll?: boolean;
124
+ infiniteScrollDistance?: number;
125
+ infiniteScrollCanLoadMore?: () => boolean;
109
126
  }
110
127
 
111
128
  const props = withDefaults(defineProps<SelectProps>(), {
@@ -123,17 +140,24 @@ const props = withDefaults(defineProps<SelectProps>(), {
123
140
  errors: '',
124
141
  message: '',
125
142
  search: '',
143
+ infiniteScroll: false,
144
+ infiniteScrollDistance: 10,
145
+ infiniteScrollCanLoadMore: () => true,
126
146
  });
127
147
 
128
148
  const emit = defineEmits<{
129
149
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
150
  'update:modelValue': [value: any];
131
151
  'update:search': [value: string];
152
+ 'scroll-end': [];
132
153
  }>();
133
154
 
134
155
  const openPopover = ref(false);
135
156
  const selectInputRef = ref<HTMLInputElement | null>(null);
136
157
  const { width: inputWidth } = useElementSize(selectInputRef);
158
+ const contentRef = ref<HTMLDivElement | null>(null);
159
+ const infiniteScrollReset = ref<(() => void) | null>(null);
160
+ const infiniteScrollLoading = ref(false);
137
161
 
138
162
  const inputWidthString = computed(() => {
139
163
  return `${inputWidth.value}px`;
@@ -153,6 +177,12 @@ watch(openPopover, () => {
153
177
  );
154
178
  scrollToOption(selectedOptionIndex, 'instant', 'center');
155
179
  }
180
+
181
+ if (openPopover.value && props.infiniteScroll) {
182
+ nextTick(() => {
183
+ setupInfiniteScroll();
184
+ });
185
+ }
156
186
  });
157
187
 
158
188
  const handleKeyDown = (event) => {
@@ -263,6 +293,79 @@ const filteredOptions = computed(() => {
263
293
  .includes(props.search?.toLowerCase()),
264
294
  );
265
295
  });
296
+
297
+ const setupInfiniteScroll = () => {
298
+ if (!props.infiniteScroll) {
299
+ return;
300
+ }
301
+
302
+ if (infiniteScrollReset.value) {
303
+ infiniteScrollReset.value();
304
+ infiniteScrollReset.value = null;
305
+ }
306
+
307
+ nextTick(() => {
308
+ const scrollElement = contentRef.value;
309
+
310
+ if (!scrollElement) {
311
+ return;
312
+ }
313
+
314
+ const { reset } = useInfiniteScroll(
315
+ scrollElement,
316
+ () => {
317
+ if (!infiniteScrollLoading.value) {
318
+ infiniteScrollLoading.value = true;
319
+ emit('scroll-end');
320
+ }
321
+ },
322
+ {
323
+ distance: props.infiniteScrollDistance,
324
+ canLoadMore: () => {
325
+ return (
326
+ props.infiniteScrollCanLoadMore() && !infiniteScrollLoading.value
327
+ );
328
+ },
329
+ },
330
+ );
331
+
332
+ infiniteScrollReset.value = reset;
333
+ });
334
+ };
335
+
336
+ const finishInfiniteScroll = () => {
337
+ infiniteScrollLoading.value = false;
338
+
339
+ if (openPopover.value && props.infiniteScroll) {
340
+ nextTick(() => {
341
+ setupInfiniteScroll();
342
+ });
343
+ }
344
+ };
345
+
346
+ const resetInfiniteScroll = () => {
347
+ infiniteScrollLoading.value = false;
348
+ if (infiniteScrollReset.value) {
349
+ infiniteScrollReset.value();
350
+ }
351
+
352
+ if (openPopover.value && props.infiniteScroll) {
353
+ nextTick(() => {
354
+ setupInfiniteScroll();
355
+ });
356
+ }
357
+ };
358
+
359
+ onBeforeUnmount(() => {
360
+ if (infiniteScrollReset.value) {
361
+ infiniteScrollReset.value();
362
+ }
363
+ });
364
+
365
+ defineExpose({
366
+ finishInfiniteScroll,
367
+ resetInfiniteScroll,
368
+ });
266
369
  </script>
267
370
 
268
371
  <style lang="scss" scoped>
@@ -270,16 +373,14 @@ const filteredOptions = computed(() => {
270
373
 
271
374
  :deep(.unnnic-select__input) {
272
375
  cursor: pointer;
273
- }
274
376
 
275
- :deep(.unnnic-select__input-search) {
276
- > .icon-left {
377
+ > .icon-right {
277
378
  color: $unnnic-color-fg-base;
278
379
  }
279
380
  }
280
381
 
281
- :deep(.unnnic-select__input) {
282
- > .icon-right {
382
+ :deep(.unnnic-select__input-search) {
383
+ > .icon-left {
283
384
  color: $unnnic-color-fg-base;
284
385
  }
285
386
  }
@@ -304,5 +405,13 @@ const filteredOptions = computed(() => {
304
405
  color: $unnnic-color-fg-muted;
305
406
  }
306
407
  }
408
+
409
+ &__infinite-loading {
410
+ display: flex;
411
+ justify-content: center;
412
+ align-items: center;
413
+ padding: $unnnic-space-2 0;
414
+ min-height: 40px;
415
+ }
307
416
  }
308
417
  </style>
@@ -82,6 +82,18 @@ export default {
82
82
  disabled: {
83
83
  description: 'Disable the select.',
84
84
  },
85
+ infiniteScroll: {
86
+ description:
87
+ 'Enable infinite scroll functionality. When enabled, the component will emit a `scroll-end` event when the user scrolls near the bottom of the options list.',
88
+ },
89
+ infiniteScrollDistance: {
90
+ description:
91
+ 'Distance in pixels from the bottom of the scroll area to trigger the `scroll-end` event. Default is 10.',
92
+ },
93
+ infiniteScrollCanLoadMore: {
94
+ description:
95
+ 'Function that returns a boolean indicating whether more items can be loaded. Used to prevent unnecessary scroll-end events.',
96
+ },
85
97
  },
86
98
  render: (args) => ({
87
99
  components: { UnnnicSelect },
@@ -159,3 +171,77 @@ export const WithSearch = {
159
171
  search: '',
160
172
  },
161
173
  };
174
+
175
+ export const WithInfiniteScroll = {
176
+ render: () => ({
177
+ components: { UnnnicSelect },
178
+ data() {
179
+ return {
180
+ selectedValue: null,
181
+ loadedOptions: [],
182
+ currentPage: 1,
183
+ totalPages: 10,
184
+ isLoading: false,
185
+ };
186
+ },
187
+ mounted() {
188
+ this.loadInitialOptions();
189
+ },
190
+ methods: {
191
+ loadInitialOptions() {
192
+ this.loadedOptions = this.generateOptions(1);
193
+ },
194
+ generateOptions(page) {
195
+ const startIndex = (page - 1) * 10 + 1;
196
+ return Array.from({ length: 10 }, (_, i) => ({
197
+ label: `Option ${startIndex + i}`,
198
+ value: `option${startIndex + i}`,
199
+ }));
200
+ },
201
+ async handleScrollEnd() {
202
+ if (this.currentPage >= this.totalPages || this.isLoading) {
203
+ return;
204
+ }
205
+
206
+ this.isLoading = true;
207
+
208
+ await new Promise((resolve) => setTimeout(resolve, 1000));
209
+
210
+ this.currentPage++;
211
+ const newOptions = this.generateOptions(this.currentPage);
212
+ this.loadedOptions = [...this.loadedOptions, ...newOptions];
213
+
214
+ this.isLoading = false;
215
+
216
+ this.$refs.selectRef.finishInfiniteScroll();
217
+ },
218
+ canLoadMore() {
219
+ return this.currentPage < this.totalPages && !this.isLoading;
220
+ },
221
+ },
222
+ template: `
223
+ <div style="width: 300px;">
224
+ <h3>Infinite Scroll Example</h3>
225
+ <p style="color: #666; font-size: 14px;">
226
+ Scroll down in the options list to load more items.
227
+ <br />
228
+ Page: {{ currentPage }} / {{ totalPages }}
229
+ <br />
230
+ Total options: {{ loadedOptions.length }}
231
+ </p>
232
+ <p>Selected: {{ selectedValue }}</p>
233
+ <unnnic-select
234
+ ref="selectRef"
235
+ v-model="selectedValue"
236
+ :options="loadedOptions"
237
+ placeholder="Select an option"
238
+ label="Infinite Scroll Select"
239
+ :infinite-scroll="true"
240
+ :infinite-scroll-distance="10"
241
+ :infinite-scroll-can-load-more="canLoadMore"
242
+ @scroll-end="handleScrollEnd"
243
+ />
244
+ </div>
245
+ `,
246
+ }),
247
+ };