@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/CHANGELOG.md +11 -0
- package/dist/{es-91ae9eed.mjs → es-b51fb49d.mjs} +1 -1
- package/dist/{index-3b503557.mjs → index-88ec0352.mjs} +5313 -5262
- package/dist/{pt-br-9553a558.mjs → pt-br-cd9f0dbc.mjs} +1 -1
- package/dist/style.css +1 -1
- package/dist/unnnic.mjs +1 -1
- package/dist/unnnic.umd.js +38 -38
- package/package.json +1 -1
- package/src/components/Select/__tests__/Select.spec.js +74 -0
- package/src/components/Select/__tests__/__snapshots__/Select.spec.js.snap +18 -0
- package/src/components/Select/index.vue +131 -22
- package/src/stories/Select.stories.js +86 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
+
};
|