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;