Skip to content

Commit

Permalink
feat: inifite scroll #76 #165 #198
Browse files Browse the repository at this point in the history
  • Loading branch information
adamberecz committed May 26, 2022
1 parent ac19de3 commit 5b7ef06
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 8 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/Multiselect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -81,6 +82,7 @@ declare class Multiselect extends Vue {
option: VNode[];
groupLabel: VNode[];
tag: VNode[];
infinite: VNode[];
};
}

Expand Down
20 changes: 20 additions & 0 deletions src/Multiselect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@
<div :class="classList.noResults" v-html="noResultsText"></div>
</slot>

<div v-if="infinite && hasMore" :class="classList.inifinite" ref="infiniteLoader">
<slot name="infinite">
<span :class="classList.inifiniteSpinner"></span>
</slot>
</div>

<slot name="afterlist" :options="fo"></slot>
</div>

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -492,6 +499,11 @@
required: false,
default: false,
},
infinite: {
type: Boolean,
required: false,
default: false,
},
},
setup(props, context)
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -572,6 +591,7 @@
...pointerAction,
...keyboard,
...classes,
...scroll,
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/composables/useClasses.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 : []),
Expand Down
21 changes: 15 additions & 6 deletions src/composables/useOptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -717,6 +724,7 @@ export default function useOptions (props, context, dep)
watch(label, refreshLabels)

return {
pfo,
fo,
filteredOptions: fo,
hasSelected,
Expand All @@ -729,6 +737,7 @@ export default function useOptions (props, context, dep)
noResults,
resolving,
busy,
offset,
select,
deselect,
remove,
Expand Down
86 changes: 86 additions & 0 deletions src/composables/useScroll.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
21 changes: 21 additions & 0 deletions tests/unit/composables/useOptions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
95 changes: 95 additions & 0 deletions tests/unit/composables/useScroll.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading

0 comments on commit 5b7ef06

Please sign in to comment.