diff --git a/README.md b/README.md
index 6bffdd5..8e5dcb4 100644
--- a/README.md
+++ b/README.md
@@ -241,6 +241,7 @@ Join our [Discord channel](https://discord.gg/WhX2nG6GTQ) or [open an issue](htt
| **groupSelect** | `boolean` | `true` | Whether groups can be selected when using `multiple` or `tags` mode. |
| **groupHideEmpty** | `boolean` | `false` | Whether groups that have no `options` by default should be hidden. |
| **required** | `boolean` | `false` | Whether the HTML5 required attribute should be used for multiselect (using an invisible fake input). |
+| **infinite** | `boolean` | `false` | Whether the actual option nodes should only be loaded on scroll. The `limit` option defines how many options are loaded initially and in each new batch. |
| **searchable** | `boolean` | `false` | Whether the options should be searchable. |
| **valueProp** | `string` | `'value'` | If you provide an array of objects as `options` this property should be used as the value of the option. |
| **trackBy** | `string` | `undefined` | The name of the property that should be searched when `searchable` is `true` and an array of objects are provided as `options`. If left `undefined` the `label` prop will be used instead. |
@@ -373,6 +374,7 @@ The `select$` param is each event is Multiselect component's instance.
| **caret** | | Renders a small triangle on the right side of the multiselect. |
| **clear** | `clear` | Renders a remove icon if the multiselect has any value. The `clear` method should be used on `mousedown` event. |
| **spinner** | | Renders a loader icon when async options are being fetched. |
+| **infinite** | | Renders a loader icon when infinite scroll is in progress. |
> Note: we don't use camelCase because they are [normalized back to lowercase](https://github.com/vuejs/vue/issues/9449#issuecomment-461170017) when written in DOM.
@@ -574,6 +576,8 @@ Alternatively you can define class names directly by passing them to the `Multis
clear: 'pr-3.5 relative z-10 opacity-40 transition duration-300 flex-shrink-0 flex-grow-0 flex hover:opacity-80 rtl:pr-0 rtl:pl-3.5',
clearIcon: 'bg-multiselect-remove bg-center bg-no-repeat w-2.5 h-4 py-px box-content inline-block',
spinner: 'bg-multiselect-spinner bg-center bg-no-repeat w-4 h-4 z-10 mr-3.5 animate-spin flex-shrink-0 flex-grow-0 rtl:mr-0 rtl:ml-3.5',
+ inifite: 'flex items-center justify-center w-full',
+ inifiteSpinner: 'bg-multiselect-spinner bg-center bg-no-repeat w-4 h-4 z-10 animate-spin flex-shrink-0 flex-grow-0 m-3.5',
dropdown: 'max-h-60 absolute -left-px -right-px bottom-0 transform translate-y-full border border-gray-300 -mt-px overflow-y-scroll z-50 bg-white flex flex-col rounded-b',
dropdownTop: '-translate-y-full top-px bottom-auto flex-col-reverse rounded-b-none rounded-t',
dropdownHidden: 'hidden',
diff --git a/src/Multiselect.d.ts b/src/Multiselect.d.ts
index 93f2ebd..2e0c94d 100644
--- a/src/Multiselect.d.ts
+++ b/src/Multiselect.d.ts
@@ -56,6 +56,7 @@ declare class Multiselect extends Vue {
reverse?: boolean;
regex?: string|object;
rtl?: boolean;
+ infinite?: boolean;
$emit(eventName: 'change', e: {originalEvent: Event, value: any}): this;
$emit(eventName: 'select', e: {originalEvent: Event, value: any, option: any}): this;
@@ -81,6 +82,7 @@ declare class Multiselect extends Vue {
option: VNode[];
groupLabel: VNode[];
tag: VNode[];
+ infinite: VNode[];
};
}
diff --git a/src/Multiselect.vue b/src/Multiselect.vue
index a7d46af..41606eb 100644
--- a/src/Multiselect.vue
+++ b/src/Multiselect.vue
@@ -180,6 +180,12 @@
+
+
+
+
+
+
@@ -211,6 +217,7 @@
import useMultiselect from './composables/useMultiselect'
import useKeyboard from './composables/useKeyboard'
import useClasses from './composables/useClasses'
+ import useScroll from './composables/useScroll'
export default {
name: 'Multiselect',
@@ -492,6 +499,11 @@
required: false,
default: false,
},
+ infinite: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
setup(props, context)
{
@@ -525,6 +537,13 @@
deactivate: multiselect.deactivate,
})
+ const scroll = useScroll(props, context, {
+ pfo: options.pfo,
+ offset: options.offset,
+ isOpen: dropdown.isOpen,
+ search: search.search,
+ })
+
const pointerAction = usePointerAction(props, context, {
fo: options.fo,
fg: options.fg,
@@ -572,6 +591,7 @@
...pointerAction,
...keyboard,
...classes,
+ ...scroll,
}
}
}
diff --git a/src/composables/useClasses.js b/src/composables/useClasses.js
index abd5369..55f3d28 100644
--- a/src/composables/useClasses.js
+++ b/src/composables/useClasses.js
@@ -40,6 +40,8 @@ export default function useClasses (props, context, dependencies)
clear: 'multiselect-clear',
clearIcon: 'multiselect-clear-icon',
spinner: 'multiselect-spinner',
+ inifinite: 'multiselect-inifite',
+ inifiniteSpinner: 'multiselect-inifite-spinner',
dropdown: 'multiselect-dropdown',
dropdownTop: 'is-top',
dropdownHidden: 'is-hidden',
@@ -101,6 +103,8 @@ export default function useClasses (props, context, dependencies)
clear: c.clear,
clearIcon: c.clearIcon,
spinner: c.spinner,
+ inifinite: c.inifinite,
+ inifiniteSpinner: c.inifiniteSpinner,
dropdown: [c.dropdown]
.concat(openDirection.value === 'top' ? c.dropdownTop : [])
.concat(!isOpen.value || !showOptions.value || !showDropdown.value ? c.dropdownHidden : []),
diff --git a/src/composables/useOptions.js b/src/composables/useOptions.js
index 04b9799..f0bcd2f 100644
--- a/src/composables/useOptions.js
+++ b/src/composables/useOptions.js
@@ -10,7 +10,7 @@ export default function useOptions (props, context, dep)
options, mode, trackBy: trackBy_, limit, hideSelected, createTag, createOption: createOption_, label,
appendNewTag, appendNewOption: appendNewOption_, multipleLabel, object, loading, delay, resolveOnLoad,
minChars, filterResults, clearOnSearch, clearOnSelect, valueProp,
- canDeselect, max, strict, closeOnSelect, groups: groupped, reverse,
+ canDeselect, max, strict, closeOnSelect, groups: groupped, reverse, infinite,
groupOptions, groupHideEmpty, groupSelect, onCreate, disabledProp, searchStart,
} = toRefs(props)
@@ -44,6 +44,8 @@ export default function useOptions (props, context, dep)
// no export
const searchWatcher = ref(null)
+ const offset = ref(infinite.value && limit.value === -1 ? 10 : limit.value)
+
// ============== COMPUTED ==============
// no export
@@ -106,8 +108,8 @@ export default function useOptions (props, context, dep)
}))
})
- // filteredOptions
- const fo = computed(() => {
+ // preFilteredOptions
+ const pfo = computed(() => {
let options = eo.value
if (reverse.value) {
@@ -118,10 +120,15 @@ export default function useOptions (props, context, dep)
options = createdOption.value.concat(options)
}
- options = filterOptions(options)
+ return filterOptions(options)
+ })
+
+ // filteredOptions
+ const fo = computed(() => {
+ let options = pfo.value
- if (limit.value > 0) {
- options = options.slice(0, limit.value)
+ if (offset.value > 0) {
+ options = options.slice(0, offset.value)
}
return options
@@ -717,6 +724,7 @@ export default function useOptions (props, context, dep)
watch(label, refreshLabels)
return {
+ pfo,
fo,
filteredOptions: fo,
hasSelected,
@@ -729,6 +737,7 @@ export default function useOptions (props, context, dep)
noResults,
resolving,
busy,
+ offset,
select,
deselect,
remove,
diff --git a/src/composables/useScroll.js b/src/composables/useScroll.js
new file mode 100644
index 0000000..1461503
--- /dev/null
+++ b/src/composables/useScroll.js
@@ -0,0 +1,86 @@
+import { toRefs, watch, nextTick, onMounted, ref, computed } from 'composition-api'
+
+export default function useScroll (props, context, dep)
+{
+ const {
+ limit, infinite,
+ } = toRefs(props)
+
+ // ============ DEPENDENCIES ============
+
+ const isOpen = dep.isOpen
+ const offset = dep.offset
+ const search = dep.search
+ const pfo = dep.pfo
+
+ // ================ DATA ================
+
+ // no export
+ const observer = ref(null)
+
+ const infiniteLoader = ref(null)
+
+ // ============== COMPUTED ==============
+
+ const hasMore = computed(() => {
+ return offset.value < pfo.value.length
+ })
+
+ // =============== METHODS ==============
+
+ // no export
+ /* istanbul ignore next */
+ const handleIntersectionObserver = async (entries) => {
+ const { isIntersecting, target } = entries[0]
+
+ if (isIntersecting) {
+ const parent = target.offsetParent
+ const scrollTop = parent.scrollTop
+
+ offset.value += limit.value == -1 ? 10 : limit.value
+
+ await nextTick()
+
+ parent.scrollTop = scrollTop
+ }
+ }
+
+ const observe = () => {
+ /* istanbul ignore else */
+ if (isOpen.value && offset.value < pfo.value.length) {
+ observer.value.observe(infiniteLoader.value)
+ } else if (!isOpen.value && observer.value) {
+ observer.value.disconnect()
+ }
+ }
+
+ watch(isOpen, () => {
+ if (!infinite.value) {
+ return
+ }
+
+ observe()
+ })
+
+ watch(search, () => {
+ if (!infinite.value) {
+ return
+ }
+
+ offset.value = limit.value
+
+ observe()
+ }, { flush: 'post' })
+
+ onMounted(() => {
+ /* istanbul ignore else */
+ if (window && window.IntersectionObserver) {
+ observer.value = new IntersectionObserver(handleIntersectionObserver)
+ }
+ })
+
+ return {
+ hasMore,
+ infiniteLoader,
+ }
+}
\ No newline at end of file
diff --git a/tests/unit/composables/useOptions.spec.js b/tests/unit/composables/useOptions.spec.js
index fb0455e..0c8a2dd 100644
--- a/tests/unit/composables/useOptions.spec.js
+++ b/tests/unit/composables/useOptions.spec.js
@@ -8,6 +8,27 @@ expect.extend({toBeVisible})
jest.useFakeTimers()
describe('useOptions', () => {
+ describe('offset', () => {
+ it('should be 10 if infinite=true & limit=udnefined', () => {
+ let select = createSelect({
+ value: null,
+ options: [1,2,3],
+ infinite: true,
+ })
+
+ expect(select.vm.offset).toStrictEqual(10)
+ })
+ it('should be limit if infinite=true & limit=20', () => {
+ let select = createSelect({
+ value: null,
+ options: [1,2,3],
+ infinite: true,
+ limit: 20,
+ })
+
+ expect(select.vm.offset).toStrictEqual(20)
+ })
+ })
describe('fo', () => {
it('should be an empty array of options not defined', () => {
let select = createSelect()
diff --git a/tests/unit/composables/useScroll.spec.js b/tests/unit/composables/useScroll.spec.js
new file mode 100644
index 0000000..fa312ea
--- /dev/null
+++ b/tests/unit/composables/useScroll.spec.js
@@ -0,0 +1,95 @@
+import { nextTick } from 'vue'
+import { createSelect } from 'unit-test-helpers'
+import flushPromises from 'flush-promises'
+
+jest.useFakeTimers()
+
+describe('useScroll', () => {
+ const observe = jest.fn()
+ const disconnect = jest.fn()
+
+ beforeEach(() => {
+ window.IntersectionObserver = jest.fn(() => ({
+ observe,
+ disconnect,
+ }))
+ })
+
+ afterEach(() => {
+ observe.mockRestore()
+ disconnect.mockRestore()
+ })
+
+ describe('observer', () => {
+ it('should be null by default', () => {
+ const mock = jest.fn()
+
+ window.IntersectionObserver = jest.fn(() => {
+ mock()
+ })
+
+ let select = createSelect({
+ value: null,
+ options: [1,2,3]
+ })
+
+ expect(mock).toHaveBeenCalled()
+ })
+ })
+
+ describe('observe', () => {
+ it('should observe if has infinite, opens && options is longer then limit', async () => {
+ let select = createSelect({
+ value: null,
+ options: [1,2,3],
+ infinite: true,
+ limit: 1,
+ })
+
+ select.vm.open()
+
+ await nextTick()
+
+ expect(observe).toHaveBeenCalledTimes(1)
+ })
+
+ it('should disconnect if has infinite && closes', async () => {
+ let select = createSelect({
+ value: null,
+ options: [1,2,3],
+ infinite: true,
+ limit: 1,
+ })
+
+ select.vm.open()
+
+ await nextTick()
+
+ select.vm.close()
+
+ await nextTick()
+
+ expect(disconnect).toHaveBeenCalledTimes(1)
+ })
+
+ it('should observe if has infinite, search changes && options is longer then limit', async () => {
+ let select = createSelect({
+ value: null,
+ options: [1,11,111],
+ infinite: true,
+ limit: 1,
+ searchable: true,
+ })
+
+ select.vm.open()
+ await nextTick()
+
+ expect(observe).toHaveBeenCalledTimes(1)
+
+ select.vm.search = '1'
+ await nextTick()
+
+ expect(observe).toHaveBeenCalledTimes(2)
+ })
+ })
+})
\ No newline at end of file
diff --git a/themes/default.scss b/themes/default.scss
index 9f6fe4a..b726367 100644
--- a/themes/default.scss
+++ b/themes/default.scss
@@ -189,7 +189,16 @@
}
}
-.multiselect-spinner {
+.multiselect-inifite {
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ align-items: center;
+ min-height: calc(2 * var(--ms-border-width, 1px) + var(--ms-font-size, 1rem) * var(--ms-line-height, 1.375) + 2 * var(--ms-py, 0.5rem));
+}
+
+.multiselect-spinner,
+.multiselect-inifite-spinner {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M456.433 371.72l-27.79-16.045c-7.192-4.152-10.052-13.136-6.487-20.636 25.82-54.328 23.566-118.602-6.768-171.03-30.265-52.529-84.802-86.621-144.76-91.424C262.35 71.922 256 64.953 256 56.649V24.56c0-9.31 7.916-16.609 17.204-15.96 81.795 5.717 156.412 51.902 197.611 123.408 41.301 71.385 43.99 159.096 8.042 232.792-4.082 8.369-14.361 11.575-22.424 6.92z'%3E%3C/path%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' fill='currentColor' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M456.433 371.72l-27.79-16.045c-7.192-4.152-10.052-13.136-6.487-20.636 25.82-54.328 23.566-118.602-6.768-171.03-30.265-52.529-84.802-86.621-144.76-91.424C262.35 71.922 256 64.953 256 56.649V24.56c0-9.31 7.916-16.609 17.204-15.96 81.795 5.717 156.412 51.902 197.611 123.408 41.301 71.385 43.99 159.096 8.042 232.792-4.082 8.369-14.361 11.575-22.424 6.92z'%3E%3C/path%3E%3C/svg%3E");
-webkit-mask-position: center;
@@ -202,12 +211,15 @@
width: 1rem;
height: 1rem;
z-index: 10;
- margin: 0 var(--ms-px, 0.875rem) 0 0;
animation: multiselect-spin 1s linear infinite;
flex-shrink: 0;
flex-grow: 0;
}
+.multiselect-spinner {
+ margin: 0 var(--ms-px, 0.875rem) 0 0;
+}
+
.multiselect-clear {
padding: 0 var(--ms-px, 0.875rem) 0 0px;
position: relative;
diff --git a/themes/tailwind.scss b/themes/tailwind.scss
index 2a5e007..2bbde87 100644
--- a/themes/tailwind.scss
+++ b/themes/tailwind.scss
@@ -94,6 +94,14 @@
@apply bg-multiselect-spinner bg-center bg-no-repeat w-4 h-4 z-10 mr-3.5 animate-spin flex-shrink-0 flex-grow-0 rtl:mr-0 rtl:ml-3.5;
}
+.multiselect-inifite {
+ @apply flex items-center justify-center w-full;
+}
+
+.multiselect-inifite-spinner {
+ @apply bg-multiselect-spinner bg-center bg-no-repeat w-4 h-4 z-10 animate-spin flex-shrink-0 flex-grow-0 m-3.5;
+}
+
.multiselect-dropdown {
@apply max-h-60 absolute -left-px -right-px bottom-0 transform translate-y-full border border-gray-300 -mt-px overflow-y-scroll z-50 bg-white flex flex-col rounded-b;