integrate withLoadMore HOC into List

This commit is contained in:
Henry Jameson 2026-06-08 04:13:17 +03:00
commit 503309890f
16 changed files with 449 additions and 434 deletions

View file

@ -0,0 +1,58 @@
.List {
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
display: flex;
flex-direction: column;
.list {
flex: 1;
}
.list-item {
display: flex;
align-items: center;
&:not(:last-child) {
border-bottom: 1px dotted var(--border);
}
}
.header {
display: flex;
align-items: center;
padding: var(--__vertical-gap) var(--__horizontal-gap);
border-bottom: 1px solid;
border-bottom-color: var(--border);
.actions {
flex: 1;
}
}
.footer {
padding: 0.9em;
text-align: center;
a {
cursor: pointer;
}
}
.checkbox-wrapper {
padding-right: var(--__horizontal-gap);
flex: none;
}
&.-scrollable {
overflow-y: hidden;
display: flex;
flex-direction: column;
.list {
overflow-y: auto;
flex: 1 1 auto;
}
}
}

152
src/components/list/list.js Normal file
View file

@ -0,0 +1,152 @@
import { isEmpty } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const List = {
props: {
boxOnly: {
type: Boolean,
default: false,
},
items: {
type: Array,
default: () => [],
},
fetchFunction: {
type: Function,
default: null,
},
itemsFunction: {
type: Function,
default: null,
},
getKey: {
type: Function,
default: (item) => item.id,
},
getClass: {
type: Function,
default: () => '',
},
nonInteractive: {
type: Boolean,
default: false,
},
scrollable: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
},
components: {
Checkbox,
},
data() {
return {
loading: false,
bottomedOut: false,
error: false,
dynamicItems: this.itemsFunction ? [] : null,
selected: [],
}
},
computed: {
allKeys() {
return this.actualItems.map(this.getKey)
},
filteredSelected() {
return this.allKeys.filter((key) => this.selected.indexOf(key) !== -1)
},
allSelected() {
return this.filteredSelected.length === this.actualItems.length
},
noneSelected() {
return this.filteredSelected.length === 0
},
someSelected() {
return !this.allSelected && !this.noneSelected
},
actualItems() {
return this.dynamicItems || this.actualItems
},
},
created() {
if (this.fetchFunction) {
window.addEventListener('scroll', this.scrollLoad)
if (this.dynamicItems.length === 0) {
this.fetchEntries()
}
}
},
unmounted() {
window.removeEventListener('scroll', this.scrollLoad)
},
methods: {
// Entries is not a computed because computed can't track the dynamic
// selector for changes and won't trigger after fetch.
updateEntries(newEntries) {
this.dynamicItems = this.itemsFunction(newEntries)
},
fetchEntries() {
if (!this.loading) {
this.loading = true
this.error = false
this.fetchFunction()
.then((newEntries) => {
this.loading = false
this.bottomedOut = isEmpty(newEntries)
return newEntries
})
.catch((error) => {
this.loading = false
this.error = error
})
.finally((newEntries) => {
this.updateEntries(newEntries)
})
}
},
scrollLoad(e) {
if (this.fetchFunction) {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.loading === false &&
this.bottomedOut === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchEntries()
}
}
},
isSelected(item) {
return this.filteredSelected.indexOf(this.getKey(item)) !== -1
},
toggle(checked, item) {
console.log('TOGGLE', checked, item)
const key = this.getKey(item)
const oldChecked = this.isSelected(key)
if (checked !== oldChecked) {
if (checked) {
this.selected.push(key)
} else {
this.selected.splice(this.selected.indexOf(key), 1)
}
}
},
toggleAll(value) {
if (value) {
this.selected = this.allKeys.slice(0)
} else {
this.selected = []
}
},
},
}
export default List

View file

@ -1,49 +1,91 @@
<template>
<div
class="list"
role="list"
class="List"
:class="{ '-scrollable': scrollable }"
>
<div
v-for="item in items"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
role="listitem"
v-if="selectable"
class="header"
>
<slot
name="item"
:item="item"
/>
<div class="checkbox-wrapper">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
@update:model-value="toggleAll"
>
{{ $t('selectable_list.select_all') }}
</Checkbox>
</div>
<div class="actions">
<slot
name="header"
:selected="filteredSelected"
/>
</div>
</div>
<div
v-if="items.length === 0 && !!$slots.empty"
class="list-empty-content faint"
class="list"
role="list"
>
<slot name="empty" />
<slot name="load" />
<div
v-for="item in actualItems"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
role="listitem"
>
<div
v-if="selectable"
class="checkbox-wrapper"
>
<Checkbox
:model-value="isSelected(item)"
@update:model-value="checked => toggle(checked, item)"
@click.stop
/>
</div>
<slot
name="item"
:item="item"
/>
</div>
<div
v-if="actualItems.length === 0 && !!$slots.empty"
class="list-empty-content faint"
>
<slot name="empty" />
<slot name="load" />
</div>
<div class="footer">
<button
v-if="error"
class="button-unstyled -link -fullwidth alert error"
@click="fetchEntries"
>
{{ $t('general.generic_error') }}
{{ error }}
</button>
<FAIcon
v-else-if="loading"
spin
icon="circle-notch"
/>
<a
v-else-if="!bottomedOut"
@click="fetchEntries"
role="button"
tabindex="0"
>
{{ $t('general.more') }}
</a>
<span v-else>
{{ $t('general.no_more') }}
</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => [],
},
getKey: {
type: Function,
default: (item) => item.id,
},
getClass: {
type: Function,
default: () => '',
},
nonInteractive: {
type: Boolean,
default: false,
},
},
}
</script>
<script src="./list.js"></script>
<style src="./list.css"></style>