@weni/unnnic-system 3.34.0 → 3.34.2
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/style.css +1 -1
- package/dist/unnnic.mjs +3643 -3589
- package/dist/unnnic.umd.js +32 -32
- package/package.json +1 -1
- package/src/components/DatePicker/DatePicker.vue +4 -1
- package/src/components/DatePicker/__tests__/DatePicker.spec.js +22 -0
- package/src/components/DatePicker/translations.ts +9 -0
- package/src/components/MultiSelect/__tests__/MultiSelect.spec.js +213 -1
- package/src/components/MultiSelect/__tests__/__snapshots__/MultiSelect.spec.js.snap +18 -0
- package/src/components/MultiSelect/index.vue +102 -21
- package/src/stories/MultiSelect.stories.js +86 -0
package/package.json
CHANGED
|
@@ -879,7 +879,10 @@ function getStartAndEndDateByPeriod(period: string) {
|
|
|
879
879
|
const daysMatch = period.match(/^last-(\d+)-days$/);
|
|
880
880
|
const monthsMatch = period.match(/^last-(\d+)-months$/);
|
|
881
881
|
|
|
882
|
-
if (
|
|
882
|
+
if (period === 'today') {
|
|
883
|
+
periodStartDate = dateToString(todayClone);
|
|
884
|
+
periodEndDate = dateToString(todayClone);
|
|
885
|
+
} else if (daysMatch) {
|
|
883
886
|
const howMuch = Number(daysMatch[1]);
|
|
884
887
|
|
|
885
888
|
periodEndDate = dateToString(todayClone);
|
|
@@ -95,6 +95,28 @@ describe('DatePicker.vue', () => {
|
|
|
95
95
|
expect(updateEquivalent[0][0]).toBe('Last 7 days');
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
it('submits with today period and emits equivalent option name', async () => {
|
|
99
|
+
wrapper = factory({
|
|
100
|
+
options: [
|
|
101
|
+
{ name: 'Today', id: 'today' },
|
|
102
|
+
{ name: 'Custom', id: 'custom' },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await wrapper.vm.autoSelect('today');
|
|
107
|
+
await wrapper.find('[data-testid="date-picker-submit"]').trigger('click');
|
|
108
|
+
|
|
109
|
+
const submit = wrapper.emitted('submit');
|
|
110
|
+
const updateEquivalent = wrapper.emitted('update:equivalentOption');
|
|
111
|
+
|
|
112
|
+
expect(submit).toBeTruthy();
|
|
113
|
+
expect(submit[0][0]).toHaveProperty('startDate');
|
|
114
|
+
expect(submit[0][0]).toHaveProperty('endDate');
|
|
115
|
+
expect(submit[0][0].startDate).toBe(submit[0][0].endDate);
|
|
116
|
+
|
|
117
|
+
expect(updateEquivalent[0][0]).toBe('Today');
|
|
118
|
+
});
|
|
119
|
+
|
|
98
120
|
it('submits with custom selection and clears equivalent option', async () => {
|
|
99
121
|
wrapper.vm.optionSelected = 'custom';
|
|
100
122
|
await wrapper.find('[data-testid="date-picker-submit"]').trigger('click');
|
|
@@ -28,6 +28,7 @@ const englishMonths = [
|
|
|
28
28
|
const englishDays = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
|
29
29
|
|
|
30
30
|
const englishPeriods: PeriodOption[] = [
|
|
31
|
+
{ name: 'Today', id: 'today' },
|
|
31
32
|
{ name: 'Last 7 days', id: 'last-7-days' },
|
|
32
33
|
{ name: 'Last 14 days', id: 'last-14-days' },
|
|
33
34
|
{ name: 'Last 30 days', id: 'last-30-days' },
|
|
@@ -96,6 +97,10 @@ buttons.en = buttons['en-us'];
|
|
|
96
97
|
|
|
97
98
|
export const periods: Record<string, PeriodOption[]> = {
|
|
98
99
|
'pt-br': [
|
|
100
|
+
{
|
|
101
|
+
name: 'Hoje',
|
|
102
|
+
id: 'today',
|
|
103
|
+
},
|
|
99
104
|
{
|
|
100
105
|
name: 'Últimos 7 dias',
|
|
101
106
|
id: 'last-7-days',
|
|
@@ -128,6 +133,10 @@ export const periods: Record<string, PeriodOption[]> = {
|
|
|
128
133
|
en: englishPeriods,
|
|
129
134
|
'en-us': englishPeriods,
|
|
130
135
|
es: [
|
|
136
|
+
{
|
|
137
|
+
name: 'Hoy',
|
|
138
|
+
id: 'today',
|
|
139
|
+
},
|
|
131
140
|
{
|
|
132
141
|
name: 'Últimos 7 días',
|
|
133
142
|
id: 'last-7-days',
|
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
import { mount, flushPromises } from '@vue/test-utils';
|
|
2
|
-
import { beforeEach, describe, expect, afterEach, test } from 'vitest';
|
|
2
|
+
import { beforeEach, describe, expect, afterEach, test, vi } from 'vitest';
|
|
3
3
|
import UnnnicMultiSelect from '../index.vue';
|
|
4
4
|
import i18n from '@/utils/plugins/i18n';
|
|
5
5
|
|
|
6
|
+
const { infiniteScrollResetMock, useInfiniteScrollMock } = vi.hoisted(() => {
|
|
7
|
+
const infiniteScrollResetMock = vi.fn();
|
|
8
|
+
const useInfiniteScrollMock = vi.fn((_element, _onLoadMore, _options) => ({
|
|
9
|
+
reset: infiniteScrollResetMock,
|
|
10
|
+
isLoading: { value: false },
|
|
11
|
+
}));
|
|
12
|
+
return { infiniteScrollResetMock, useInfiniteScrollMock };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
vi.mock('@vueuse/core', async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal();
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
useInfiniteScroll: (...args) => useInfiniteScrollMock(...args),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function getInfiniteScrollCallbacks() {
|
|
24
|
+
const lastCall = useInfiniteScrollMock.mock.calls.at(-1);
|
|
25
|
+
return {
|
|
26
|
+
onLoadMore: lastCall?.[1],
|
|
27
|
+
canLoadMore: lastCall?.[2]?.canLoadMore,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const visibleScrollEl = { clientHeight: 200 };
|
|
32
|
+
const overflowingScrollEl = { clientHeight: 200, scrollHeight: 800 };
|
|
33
|
+
|
|
6
34
|
describe('UnnnicMultiSelect.vue', () => {
|
|
7
35
|
let wrapper;
|
|
8
36
|
|
|
@@ -32,6 +60,8 @@ describe('UnnnicMultiSelect.vue', () => {
|
|
|
32
60
|
};
|
|
33
61
|
|
|
34
62
|
beforeEach(() => {
|
|
63
|
+
infiniteScrollResetMock.mockClear();
|
|
64
|
+
useInfiniteScrollMock.mockClear();
|
|
35
65
|
wrapper = mountWrapper();
|
|
36
66
|
});
|
|
37
67
|
|
|
@@ -627,6 +657,176 @@ describe('UnnnicMultiSelect.vue', () => {
|
|
|
627
657
|
});
|
|
628
658
|
});
|
|
629
659
|
|
|
660
|
+
describe('infinite scroll functionality', () => {
|
|
661
|
+
test('infinite scroll is disabled by default', () => {
|
|
662
|
+
expect(wrapper.vm.infiniteScroll).toBe(false);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('applies infinite scroll props correctly', async () => {
|
|
666
|
+
await wrapper.setProps({
|
|
667
|
+
infiniteScroll: true,
|
|
668
|
+
infiniteScrollDistance: 20,
|
|
669
|
+
infiniteScrollCanLoadMore: () => false,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
expect(wrapper.vm.infiniteScroll).toBe(true);
|
|
673
|
+
expect(wrapper.vm.infiniteScrollDistance).toBe(20);
|
|
674
|
+
expect(wrapper.vm.infiniteScrollCanLoadMore()).toBe(false);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('does not render loading when infiniteScrollLoading is false', async () => {
|
|
678
|
+
await wrapper.setProps({ infiniteScroll: true });
|
|
679
|
+
wrapper.vm.setOpenPopover(true);
|
|
680
|
+
await wrapper.vm.$nextTick();
|
|
681
|
+
|
|
682
|
+
const loading = wrapper.find('.unnnic-multi-select__infinite-loading');
|
|
683
|
+
expect(loading.exists()).toBe(false);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test('sets infiniteScrollLoading to true and verifies state', async () => {
|
|
687
|
+
await wrapper.setProps({
|
|
688
|
+
infiniteScroll: true,
|
|
689
|
+
options: [
|
|
690
|
+
{ label: 'Option 1', value: 'option1' },
|
|
691
|
+
{ label: 'Option 2', value: 'option2' },
|
|
692
|
+
],
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
wrapper.vm.setOpenPopover(true);
|
|
696
|
+
await wrapper.vm.$nextTick();
|
|
697
|
+
|
|
698
|
+
expect(wrapper.vm.infiniteScrollLoading).toBe(false);
|
|
699
|
+
|
|
700
|
+
wrapper.vm.infiniteScrollLoading = true;
|
|
701
|
+
await wrapper.vm.$nextTick();
|
|
702
|
+
|
|
703
|
+
expect(wrapper.vm.infiniteScrollLoading).toBe(true);
|
|
704
|
+
expect(wrapper.vm.infiniteScroll).toBe(true);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('finishInfiniteScroll sets loading to false', async () => {
|
|
708
|
+
await wrapper.setProps({ infiniteScroll: true });
|
|
709
|
+
wrapper.vm.infiniteScrollLoading = true;
|
|
710
|
+
expect(wrapper.vm.infiniteScrollLoading).toBe(true);
|
|
711
|
+
|
|
712
|
+
wrapper.vm.finishInfiniteScroll();
|
|
713
|
+
expect(wrapper.vm.infiniteScrollLoading).toBe(false);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
test('resetInfiniteScroll sets loading to false', async () => {
|
|
717
|
+
await wrapper.setProps({ infiniteScroll: true });
|
|
718
|
+
wrapper.vm.infiniteScrollLoading = true;
|
|
719
|
+
expect(wrapper.vm.infiniteScrollLoading).toBe(true);
|
|
720
|
+
|
|
721
|
+
wrapper.vm.resetInfiniteScroll();
|
|
722
|
+
expect(wrapper.vm.infiniteScrollLoading).toBe(false);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('calls useInfiniteScroll once and does not recreate it on finish', async () => {
|
|
726
|
+
wrapper.unmount();
|
|
727
|
+
useInfiniteScrollMock.mockClear();
|
|
728
|
+
infiniteScrollResetMock.mockClear();
|
|
729
|
+
|
|
730
|
+
const scrollWrapper = mountWrapper({ infiniteScroll: true });
|
|
731
|
+
expect(useInfiniteScrollMock).toHaveBeenCalledTimes(1);
|
|
732
|
+
|
|
733
|
+
scrollWrapper.vm.setOpenPopover(true);
|
|
734
|
+
await scrollWrapper.vm.$nextTick();
|
|
735
|
+
scrollWrapper.vm.infiniteScrollLoading = true;
|
|
736
|
+
scrollWrapper.vm.finishInfiniteScroll();
|
|
737
|
+
await scrollWrapper.vm.$nextTick();
|
|
738
|
+
|
|
739
|
+
expect(useInfiniteScrollMock).toHaveBeenCalledTimes(1);
|
|
740
|
+
expect(infiniteScrollResetMock).toHaveBeenCalled();
|
|
741
|
+
|
|
742
|
+
scrollWrapper.unmount();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test('emits scroll-end once when the list does not fill the popover', async () => {
|
|
746
|
+
const fewOptions = [
|
|
747
|
+
{ label: 'Option 1', value: 'option1' },
|
|
748
|
+
{ label: 'Option 2', value: 'option2' },
|
|
749
|
+
];
|
|
750
|
+
const scrollWrapper = mountWrapper({
|
|
751
|
+
infiniteScroll: true,
|
|
752
|
+
options: fewOptions,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
scrollWrapper.vm.setOpenPopover(true);
|
|
756
|
+
await scrollWrapper.vm.$nextTick();
|
|
757
|
+
|
|
758
|
+
const { onLoadMore, canLoadMore } = getInfiniteScrollCallbacks();
|
|
759
|
+
expect(canLoadMore(visibleScrollEl)).toBe(true);
|
|
760
|
+
expect(scrollWrapper.emitted('scroll-end')).toBeFalsy();
|
|
761
|
+
|
|
762
|
+
onLoadMore();
|
|
763
|
+
expect(scrollWrapper.emitted('scroll-end')).toHaveLength(1);
|
|
764
|
+
expect(scrollWrapper.vm.infiniteScrollLoading).toBe(true);
|
|
765
|
+
expect(canLoadMore(visibleScrollEl)).toBe(false);
|
|
766
|
+
|
|
767
|
+
onLoadMore();
|
|
768
|
+
expect(scrollWrapper.emitted('scroll-end')).toHaveLength(1);
|
|
769
|
+
|
|
770
|
+
scrollWrapper.unmount();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test('does not emit scroll-end on open when the list already overflows', async () => {
|
|
774
|
+
const manyOptions = Array.from({ length: 20 }, (_, i) => ({
|
|
775
|
+
label: `Option ${i + 1}`,
|
|
776
|
+
value: `option${i + 1}`,
|
|
777
|
+
}));
|
|
778
|
+
const scrollWrapper = mountWrapper({
|
|
779
|
+
infiniteScroll: true,
|
|
780
|
+
options: manyOptions,
|
|
781
|
+
optionsLines: 5,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
scrollWrapper.vm.setOpenPopover(true);
|
|
785
|
+
await scrollWrapper.vm.$nextTick();
|
|
786
|
+
|
|
787
|
+
expect(scrollWrapper.emitted('scroll-end')).toBeFalsy();
|
|
788
|
+
|
|
789
|
+
const { canLoadMore } = getInfiniteScrollCallbacks();
|
|
790
|
+
expect(canLoadMore(overflowingScrollEl)).toBe(true);
|
|
791
|
+
expect(canLoadMore({ clientHeight: 0 })).toBe(false);
|
|
792
|
+
|
|
793
|
+
scrollWrapper.unmount();
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test('canLoadMore is false when infinite scroll is disabled or the popover is closed', () => {
|
|
797
|
+
const { canLoadMore } = getInfiniteScrollCallbacks();
|
|
798
|
+
expect(canLoadMore(visibleScrollEl)).toBe(false);
|
|
799
|
+
|
|
800
|
+
wrapper.vm.setOpenPopover(true);
|
|
801
|
+
expect(canLoadMore(visibleScrollEl)).toBe(false);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test('displays loading indicator when infiniteScrollLoading is true', async () => {
|
|
805
|
+
await wrapper.setProps({ infiniteScroll: true });
|
|
806
|
+
wrapper.vm.setOpenPopover(true);
|
|
807
|
+
await wrapper.vm.$nextTick();
|
|
808
|
+
|
|
809
|
+
const { onLoadMore } = getInfiniteScrollCallbacks();
|
|
810
|
+
onLoadMore();
|
|
811
|
+
await wrapper.vm.$nextTick();
|
|
812
|
+
|
|
813
|
+
expect(wrapper.vm.infiniteScrollLoading).toBe(true);
|
|
814
|
+
expect(
|
|
815
|
+
document.querySelector('.unnnic-multi-select__infinite-loading'),
|
|
816
|
+
).not.toBeNull();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('does not display infinite scroll loading when infiniteScroll is false', async () => {
|
|
820
|
+
await wrapper.setProps({ infiniteScroll: false });
|
|
821
|
+
wrapper.vm.setOpenPopover(true);
|
|
822
|
+
await wrapper.vm.$nextTick();
|
|
823
|
+
|
|
824
|
+
expect(
|
|
825
|
+
document.querySelector('.unnnic-multi-select__infinite-loading'),
|
|
826
|
+
).toBeNull();
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
630
830
|
describe('snapshot testing', () => {
|
|
631
831
|
test('matches snapshot with default props', () => {
|
|
632
832
|
expect(wrapper.html()).toMatchSnapshot();
|
|
@@ -651,5 +851,17 @@ describe('UnnnicMultiSelect.vue', () => {
|
|
|
651
851
|
await wrapper.setProps({ disabled: true });
|
|
652
852
|
expect(wrapper.html()).toMatchSnapshot();
|
|
653
853
|
});
|
|
854
|
+
|
|
855
|
+
test('matches snapshot with infinite scroll enabled', async () => {
|
|
856
|
+
await wrapper.setProps({ infiniteScroll: true });
|
|
857
|
+
wrapper.vm.setOpenPopover(true);
|
|
858
|
+
await wrapper.vm.$nextTick();
|
|
859
|
+
|
|
860
|
+
const { onLoadMore } = getInfiniteScrollCallbacks();
|
|
861
|
+
onLoadMore();
|
|
862
|
+
await wrapper.vm.$nextTick();
|
|
863
|
+
|
|
864
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
865
|
+
});
|
|
654
866
|
});
|
|
655
867
|
});
|
|
@@ -36,6 +36,24 @@ exports[`UnnnicMultiSelect.vue > snapshot testing > matches snapshot with disabl
|
|
|
36
36
|
</div>"
|
|
37
37
|
`;
|
|
38
38
|
|
|
39
|
+
exports[`UnnnicMultiSelect.vue > snapshot testing > matches snapshot with infinite scroll enabled 1`] = `
|
|
40
|
+
"<div data-v-03c7fb50="" class="unnnic-multi-select"><button data-v-9d52eef8="" data-v-03c7fb50="" 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-03c7fb50="" class="unnnic-form-element unnnic-form md unnnic-multi-select__input" data-testid="form-element">
|
|
42
|
+
<!--v-if-->
|
|
43
|
+
<div data-v-a0d36167="" data-v-d890ad85="" class="text-input size--md unnnic-multi-select__input unnnic-form-input" hascloudycolor="false" mask=""><input data-v-86533b41="" data-v-a0d36167="" class="unnnic-multi-select__input unnnic-form-input input-itself input size-md normal input--has-icon-right focus use-focus-prop unnnic-multi-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[`UnnnicMultiSelect.vue > snapshot testing > matches snapshot with multiple selected values 1`] = `
|
|
40
58
|
"<div data-v-03c7fb50="" class="unnnic-multi-select"><button data-v-9d52eef8="" data-v-03c7fb50="" 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-03c7fb50="" class="unnnic-form-element unnnic-form md unnnic-multi-select__input" data-testid="form-element">
|
|
@@ -47,21 +47,29 @@
|
|
|
47
47
|
>
|
|
48
48
|
{{ $t('without_results') }}
|
|
49
49
|
</p>
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
50
|
+
<template v-else>
|
|
51
|
+
<div class="unnnic-multi-select__options">
|
|
52
|
+
<UnnnicMultiSelectOption
|
|
53
|
+
v-for="(option, index) in filteredOptions"
|
|
54
|
+
:key="String(option[props.itemValue])"
|
|
55
|
+
:data-option-index="index"
|
|
56
|
+
:label="String(option[props.itemLabel] ?? '')"
|
|
57
|
+
:active="getActivatedOptionStatus(option)"
|
|
58
|
+
:focused="focusedOptionIndex === index"
|
|
59
|
+
:disabled="option.disabled"
|
|
60
|
+
@update:model-value="handleSelectOption(option, $event)"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
<div
|
|
64
|
+
v-if="props.infiniteScroll && infiniteScrollLoading"
|
|
65
|
+
class="unnnic-multi-select__infinite-loading"
|
|
66
|
+
>
|
|
67
|
+
<UnnnicIconLoading
|
|
68
|
+
scheme="fg-base"
|
|
69
|
+
size="sm"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
</template>
|
|
65
73
|
</div>
|
|
66
74
|
</PopoverContent>
|
|
67
75
|
</Popover>
|
|
@@ -69,10 +77,13 @@
|
|
|
69
77
|
</template>
|
|
70
78
|
|
|
71
79
|
<script setup lang="ts">
|
|
72
|
-
import { computed, watch, useTemplateRef } from 'vue';
|
|
80
|
+
import { computed, ref, watch, nextTick, useTemplateRef } from 'vue';
|
|
81
|
+
|
|
82
|
+
import { useInfiniteScroll } from '@vueuse/core';
|
|
73
83
|
|
|
74
84
|
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover';
|
|
75
85
|
import UnnnicInput from '../Input/Input.vue';
|
|
86
|
+
import UnnnicIconLoading from '../IconLoading/IconLoading.vue';
|
|
76
87
|
import UnnnicMultiSelectOption from './MultiSelectOption.vue';
|
|
77
88
|
|
|
78
89
|
import { useSelectBase } from '../../composables/useSelectBase';
|
|
@@ -85,6 +96,9 @@ defineOptions({
|
|
|
85
96
|
|
|
86
97
|
interface MultiSelectProps extends SelectBaseProps {
|
|
87
98
|
modelValue: (SelectOption | unknown)[];
|
|
99
|
+
infiniteScroll?: boolean;
|
|
100
|
+
infiniteScrollDistance?: number;
|
|
101
|
+
infiniteScrollCanLoadMore?: () => boolean;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
const props = withDefaults(defineProps<MultiSelectProps>(), {
|
|
@@ -103,15 +117,19 @@ const props = withDefaults(defineProps<MultiSelectProps>(), {
|
|
|
103
117
|
message: '',
|
|
104
118
|
search: '',
|
|
105
119
|
clearable: false,
|
|
120
|
+
infiniteScroll: false,
|
|
121
|
+
infiniteScrollDistance: 10,
|
|
122
|
+
infiniteScrollCanLoadMore: () => true,
|
|
106
123
|
});
|
|
107
124
|
|
|
108
125
|
const emit = defineEmits<{
|
|
109
126
|
'update:modelValue': [value: (SelectOption | unknown)[]];
|
|
110
127
|
'update:search': [value: string];
|
|
128
|
+
'scroll-end': [];
|
|
111
129
|
}>();
|
|
112
130
|
|
|
113
131
|
const multiSelectInputRef = useTemplateRef<HTMLElement>('multiSelectInputRef');
|
|
114
|
-
const contentRef = useTemplateRef<
|
|
132
|
+
const contentRef = useTemplateRef<HTMLDivElement>('contentRef');
|
|
115
133
|
|
|
116
134
|
const base = useSelectBase(props, 'multi', multiSelectInputRef, contentRef);
|
|
117
135
|
|
|
@@ -184,14 +202,66 @@ keyboard.setupKeydownBinding();
|
|
|
184
202
|
|
|
185
203
|
const focusedOptionIndex = keyboard.focusedOptionIndex;
|
|
186
204
|
|
|
187
|
-
|
|
188
|
-
|
|
205
|
+
const infiniteScrollLoading = ref(false);
|
|
206
|
+
|
|
207
|
+
function canLoadMoreInfiniteScroll(el?: unknown) {
|
|
208
|
+
if (
|
|
209
|
+
!props.infiniteScroll ||
|
|
210
|
+
!base.openPopover.value ||
|
|
211
|
+
infiniteScrollLoading.value ||
|
|
212
|
+
!props.infiniteScrollCanLoadMore()
|
|
213
|
+
) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!el || typeof el !== 'object' || !('clientHeight' in el)) return false;
|
|
218
|
+
return (el as HTMLElement).clientHeight > 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const scrollElement = computed(
|
|
222
|
+
() =>
|
|
223
|
+
(contentRef.value?.closest('.unnnic-popover') as HTMLElement | null) ??
|
|
224
|
+
contentRef.value,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const { reset: resetInfiniteScrollObserver } = useInfiniteScroll(
|
|
228
|
+
scrollElement,
|
|
229
|
+
() => {
|
|
230
|
+
if (infiniteScrollLoading.value) return;
|
|
231
|
+
infiniteScrollLoading.value = true;
|
|
232
|
+
emit('scroll-end');
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
distance: props.infiniteScrollDistance,
|
|
236
|
+
canLoadMore: canLoadMoreInfiniteScroll,
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
watch(base.openPopover, (isOpen) => {
|
|
241
|
+
if (!isOpen) {
|
|
189
242
|
handleSearch('');
|
|
190
|
-
|
|
191
|
-
|
|
243
|
+
infiniteScrollLoading.value = false;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
keyboard.focusedOptionIndex.value = -1;
|
|
248
|
+
if (props.infiniteScroll) {
|
|
249
|
+
nextTick(() => resetInfiniteScrollObserver());
|
|
192
250
|
}
|
|
193
251
|
});
|
|
194
252
|
|
|
253
|
+
function finishInfiniteScroll() {
|
|
254
|
+
infiniteScrollLoading.value = false;
|
|
255
|
+
if (base.openPopover.value && props.infiniteScroll) {
|
|
256
|
+
nextTick(() => resetInfiniteScrollObserver());
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resetInfiniteScroll() {
|
|
261
|
+
infiniteScrollLoading.value = false;
|
|
262
|
+
nextTick(() => resetInfiniteScrollObserver());
|
|
263
|
+
}
|
|
264
|
+
|
|
195
265
|
defineExpose({
|
|
196
266
|
openPopover,
|
|
197
267
|
setOpenPopover,
|
|
@@ -199,6 +269,9 @@ defineExpose({
|
|
|
199
269
|
calculatedPopoverHeight: base.calculatedPopoverHeight,
|
|
200
270
|
selectedItems,
|
|
201
271
|
inputValue,
|
|
272
|
+
infiniteScrollLoading,
|
|
273
|
+
finishInfiniteScroll,
|
|
274
|
+
resetInfiniteScroll,
|
|
202
275
|
});
|
|
203
276
|
</script>
|
|
204
277
|
|
|
@@ -236,5 +309,13 @@ defineExpose({
|
|
|
236
309
|
flex-direction: column;
|
|
237
310
|
gap: $unnnic-space-6;
|
|
238
311
|
}
|
|
312
|
+
|
|
313
|
+
&__infinite-loading {
|
|
314
|
+
display: flex;
|
|
315
|
+
justify-content: center;
|
|
316
|
+
align-items: center;
|
|
317
|
+
padding: $unnnic-space-2 0;
|
|
318
|
+
min-height: $unnnic-space-10;
|
|
319
|
+
}
|
|
239
320
|
}
|
|
240
321
|
</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: { UnnnicMultiSelect },
|
|
@@ -167,3 +179,77 @@ export const WithSearch = {
|
|
|
167
179
|
search: '',
|
|
168
180
|
},
|
|
169
181
|
};
|
|
182
|
+
|
|
183
|
+
export const WithInfiniteScroll = {
|
|
184
|
+
render: () => ({
|
|
185
|
+
components: { UnnnicMultiSelect },
|
|
186
|
+
data() {
|
|
187
|
+
return {
|
|
188
|
+
selectedValue: [],
|
|
189
|
+
loadedOptions: [],
|
|
190
|
+
currentPage: 1,
|
|
191
|
+
totalPages: 10,
|
|
192
|
+
isLoading: false,
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
mounted() {
|
|
196
|
+
this.loadInitialOptions();
|
|
197
|
+
},
|
|
198
|
+
methods: {
|
|
199
|
+
loadInitialOptions() {
|
|
200
|
+
this.loadedOptions = this.generateOptions(1);
|
|
201
|
+
},
|
|
202
|
+
generateOptions(page) {
|
|
203
|
+
const startIndex = (page - 1) * 10 + 1;
|
|
204
|
+
return Array.from({ length: 10 }, (_, i) => ({
|
|
205
|
+
label: `Option ${startIndex + i}`,
|
|
206
|
+
value: `option${startIndex + i}`,
|
|
207
|
+
}));
|
|
208
|
+
},
|
|
209
|
+
async handleScrollEnd() {
|
|
210
|
+
if (this.currentPage >= this.totalPages || this.isLoading) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.isLoading = true;
|
|
215
|
+
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
217
|
+
|
|
218
|
+
this.currentPage++;
|
|
219
|
+
const newOptions = this.generateOptions(this.currentPage);
|
|
220
|
+
this.loadedOptions = [...this.loadedOptions, ...newOptions];
|
|
221
|
+
|
|
222
|
+
this.isLoading = false;
|
|
223
|
+
|
|
224
|
+
this.$refs.multiSelectRef.finishInfiniteScroll();
|
|
225
|
+
},
|
|
226
|
+
canLoadMore() {
|
|
227
|
+
return this.currentPage < this.totalPages && !this.isLoading;
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
template: `
|
|
231
|
+
<div style="width: 300px;">
|
|
232
|
+
<h3>Infinite Scroll Example</h3>
|
|
233
|
+
<p style="color: #666; font-size: 14px;">
|
|
234
|
+
Scroll down in the options list to load more items.
|
|
235
|
+
<br />
|
|
236
|
+
Page: {{ currentPage }} / {{ totalPages }}
|
|
237
|
+
<br />
|
|
238
|
+
Total options: {{ loadedOptions.length }}
|
|
239
|
+
</p>
|
|
240
|
+
<p>Selected: {{ selectedValue }}</p>
|
|
241
|
+
<unnnic-multi-select
|
|
242
|
+
ref="multiSelectRef"
|
|
243
|
+
v-model="selectedValue"
|
|
244
|
+
:options="loadedOptions"
|
|
245
|
+
placeholder="Select options"
|
|
246
|
+
label="Infinite Scroll MultiSelect"
|
|
247
|
+
:infinite-scroll="true"
|
|
248
|
+
:infinite-scroll-distance="10"
|
|
249
|
+
:infinite-scroll-can-load-more="canLoadMore"
|
|
250
|
+
@scroll-end="handleScrollEnd"
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
`,
|
|
254
|
+
}),
|
|
255
|
+
};
|