Skip to content

Commit

Permalink
feat(VList): add lazy mode to VListGroup
Browse files Browse the repository at this point in the history
Add a `lazy` props to skip the rendering of children if the group has never
been opened

This allow to render instantly a list with ~1k elements (see example in docs)
  • Loading branch information
tinou98 authored and johnleider committed Jul 17, 2023
1 parent 5290bfc commit ba81e21
Show file tree
Hide file tree
Showing 4 changed files with 168 additions and 72 deletions.
68 changes: 68 additions & 0 deletions packages/docs/src/examples/v-list/misc-lazy-rendering.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<template>
<v-card>
<v-card-title>List with {{ size }} elements</v-card-title>

<v-card-text v-if="data">
<v-list>
<recursive-list :entry="data"></recursive-list>
</v-list>
</v-card-text>
<v-card-actions v-else class="justify-center">
<v-btn @click="data = MakeEntries()">Prepare</v-btn>
</v-card-actions>
</v-card>
</template>

<script lang="ts" setup>
import { computed, h, ref, VNode } from 'vue'
import { VListGroup, VListItem } from 'vuetify/components'
export type Entry = {
name: string,
children?: Entry[],
}
/*
<VListGroup v-if="entry.children" lazy>
<template v-slot:activator="{ props }">
<VListItem v-bind="props">
{{ p.val.name }}
</VListItem>
</template>
<RecursiveList :val="e" v-for="e in p.val.children" />
</VListGroup>
<VListItem v-else>
{{ p.val.name }}
</VListItem>
*/
const RecursiveList = ({ entry }: { entry: Entry }): VNode =>
entry.children
? h(VListGroup, { lazy: true }, {
default: () => entry.children?.map(entry => RecursiveList({ entry })),
activator: ({ props }: any) => h(VListItem, props, { default: () => entry.name }),
})
: h(VListItem, null, { default: () => entry.name })
function MakeEntries (prefix = 'Prop', depth = 5): Entry {
let children
if (depth > 0) {
children = []
for (let i = 0; i < 5; ++i) {
children.push(MakeEntries(prefix + '.' + i, depth - 1))
}
}
return { name: prefix, children }
}
function GetSize (v: Entry): number {
return v.children?.map(GetSize).reduce((acc, v) => acc + v, 1) ?? 1
}
const data = ref<Entry>()
const size = computed(() => data.value ? GetSize(data.value) : 0)
</script>
26 changes: 9 additions & 17 deletions packages/docs/src/pages/en/components/lists.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,23 @@ meta:
keywords: lists, vuetify list component, vue list component
related:
- /components/item-groups/
- /components/avatars/
- /components/sheets/
---

# Lists

The `v-list` component is used to display information. It can contain an avatar, content, actions, subheaders and much more. Lists present content in a way that makes it easy to identify a specific item in a collection. They provide a consistent styling for organizing groups of text and images.

<entry />

## Usage

Lists come in three main variations. **single-line** (default), **two-line** and **three-line**. The line declaration specifies the minimum height of the item and can also be controlled from `v-list` with the same prop.

<usage name="v-list" />

<entry />

## API

| Component | Description |
| - | - |
| [v-list](/api/v-list/) | Primary Component |
| [v-list-group](/api/v-list-group/) | Sub-component used to display or hide groups of items |
| [v-list-subheader](/api/v-list-subheader/) | Sub-component used to separate groups of items |
| [v-list-item](/api/v-list-item/) | Sub-component used to display a single item or modify the `v-list` state |
| [v-list-item-title](/api/v-list-item-title/) | Sub-component used to display the title of a list item. Wraps the `#title` slot |
| [v-list-item-subtitle](/api/v-list-item-subtitle/) | Sub-component used to display the subtitle of a list item. Wraps the `#subtitle` slot |
| [v-list-item-action](/api/v-list-item-action/) | Sub-component used to display [v-checkbox](/components/checkboxes/) or [v-switch](/components/switches/) |
| [v-list-img](/api/v-list-img/) | Sub-component that is used to wrap a the [v-img](/components/images/) component |
| [v-list-item-media](/api/v-list-item-media/) | Sub-component that is used to wrap a the [v-img](/components/images/) component |

<api-inline hide-links />
<api-inline />

## Examples

Expand Down Expand Up @@ -125,3 +111,9 @@ Lists can contain subheaders, dividers, and can contain 1 or more lines. The sub
A **three-line** list with actions. Utilizing **v-list-group**, easily connect actions to your tiles.

<example file="v-list/misc-action-and-item-groups" />

#### Lazy rendering

A big list rendered instantly using *lazy* option.

<example file="v-list/misc-lazy-rendering" />
88 changes: 52 additions & 36 deletions packages/vuetify/src/components/VList/VListGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,26 @@ import { VDefaultsProvider } from '@/components/VDefaultsProvider'
import { VExpandTransition } from '@/components/transitions'

// Composables
import { useList } from './list'
import { IconValue } from '@/composables/icons'
import { makeComponentProps } from '@/composables/component'
import { makeLazyProps, useLazy } from '@/composables/lazy'
import { makeTagProps } from '@/composables/tag'
import { MaybeTransition } from '@/composables/transition'
import { useList } from './list'
import { useNestedGroupActivator, useNestedItem } from '@/composables/nested/nested'
import { useSsrBoot } from '@/composables/ssrBoot'

// Utilities
import { computed, toRef } from 'vue'
import { defineComponent, genericComponent, propsFactory, useRender } from '@/util'

export type VListGroupSlots = {
default: []
activator: [{ isOpen: boolean, props: Record<string, unknown> }]
import { defineComponent, genericComponent, pick, propsFactory, useRender } from '@/util'

// Types
import type { InternalListItem } from './VList'
import type { SlotsToProps } from '@/util'
import type { ExtractPropTypes, Ref } from 'vue'

export type ListGroupActivatorSlot = {
props: {
onClick: (e: Event) => void
class: string
}
}

const VListGroupActivator = defineComponent({
Expand All @@ -31,9 +36,7 @@ const VListGroupActivator = defineComponent({
})

export const makeVListGroupProps = propsFactory({
/* @deprecated */
activeColor: String,
baseColor: String,
color: String,
collapseIcon: {
type: IconValue,
Expand All @@ -47,47 +50,46 @@ export const makeVListGroupProps = propsFactory({
appendIcon: IconValue,
fluid: Boolean,
subgroup: Boolean,
title: String,
value: null,

...makeComponentProps(),
...makeLazyProps(),
...makeTagProps(),
}, 'v-list-group')

export const VListGroup = genericComponent<VListGroupSlots>()({
export const VListGroup = genericComponent<new <T extends InternalListItem>() => {
$props: {
items?: T[]
} & SlotsToProps<{
activator: [ListGroupActivatorSlot]
default: []
}>
}>()({
name: 'VListGroup',

props: makeVListGroupProps(),
props: {
title: String,

...makeVListGroupProps(),
},

setup (props, { slots }) {
const { isOpen, open, id: _id } = useNestedItem(toRef(props, 'value'), true)
const id = computed(() => `v-list-group--id-${String(_id.value)}`)
const list = useList()
const { isBooted } = useSsrBoot()

const { hasContent, onAfterLeave } = useLazy(props, isOpen)

function onClick (e: Event) {
open(!isOpen.value, e)
}

const activatorProps = computed(() => ({
const activatorProps: Ref<ListGroupActivatorSlot['props']> = computed(() => ({
onClick,
class: 'v-list-group__header',
id: id.value,
}))

const toggleIcon = computed(() => isOpen.value ? props.collapseIcon : props.expandIcon)
const activatorDefaults = computed(() => ({
VListItem: {
active: isOpen.value,
activeColor: props.activeColor,
baseColor: props.baseColor,
color: props.color,
prependIcon: props.prependIcon || (props.subgroup && toggleIcon.value),
appendIcon: props.appendIcon || (!props.subgroup && toggleIcon.value),
title: props.title,
value: props.value,
},
}))

useRender(() => (
<props.tag
Expand All @@ -99,23 +101,33 @@ export const VListGroup = genericComponent<VListGroupSlots>()({
'v-list-group--subgroup': props.subgroup,
'v-list-group--open': isOpen.value,
},
props.class,
]}
style={ props.style }
>
{ slots.activator && (
<VDefaultsProvider defaults={ activatorDefaults.value }>
<VDefaultsProvider
defaults={{
VListItem: {
active: isOpen.value,
activeColor: props.activeColor,
color: props.color,
prependIcon: props.prependIcon || (props.subgroup && toggleIcon.value),
appendIcon: props.appendIcon || (!props.subgroup && toggleIcon.value),
title: props.title,
value: props.value,
},
}}
>
<VListGroupActivator>
{ slots.activator({ props: activatorProps.value, isOpen: isOpen.value }) }
{ slots.activator({ props: activatorProps.value, isOpen }) }
</VListGroupActivator>
</VDefaultsProvider>
)}

<MaybeTransition transition={{ component: VExpandTransition }} disabled={ !isBooted.value }>
<VExpandTransition onAfterLeave={ onAfterLeave }>
<div class="v-list-group__items" role="group" aria-labelledby={ id.value } v-show={ isOpen.value }>
{ slots.default?.() }
{ hasContent.value && slots.default?.() }
</div>
</MaybeTransition>
</VExpandTransition>
</props.tag>
))

Expand All @@ -124,3 +136,7 @@ export const VListGroup = genericComponent<VListGroupSlots>()({
})

export type VListGroup = InstanceType<typeof VListGroup>

export function filterListGroupProps (props: ExtractPropTypes<ReturnType<typeof makeVListGroupProps>>) {
return pick(props, Object.keys(VListGroup.props) as any)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ import { VListGroup } from '../VListGroup'
import { VListItem } from '../VListItem'
import { VList } from '../VList'

function ListGroup (lazy: boolean, open: boolean): JSX.Element {
return (
<CenteredGrid width="200px">
<h2 class="mt-8">ListGroup</h2>

<VList opened={ open ? ['group'] : [] }>
<VListGroup value="group" lazy={ lazy }>
{{
activator: ({ props }) => <VListItem { ...props } title="Group" />,
default: () => (
<>
<VListItem title="Child 1" />
<VListItem title="Child 2" />
</>
),
}}
</VListGroup>
</VList>
</CenteredGrid>
)
}

describe('VListGroup', () => {
function mountFunction (content: JSX.Element) {
return cy.mount(() => content)
Expand All @@ -27,26 +49,24 @@ describe('VListGroup', () => {
})

it('supports children', () => {
const wrapper = mountFunction((
<CenteredGrid width="200px">
<h2 class="mt-8">ListGroup</h2>
const wrapper = mountFunction(ListGroup(false, true))
wrapper.get('.v-list-item-title').contains('Group')
})

<VList opened={['group']}>
<VListGroup value="group">
{{
activator: props => <VListItem { ...props } title="Group" />,
default: () => (
<>
<VListItem title="Child 1" />
<VListItem title="Child 2" />
</>
),
}}
</VListGroup>
</VList>
</CenteredGrid>
))
describe('lazy', () => {
it('open on click', () => {
const wrapper = mountFunction(ListGroup(true, false))

wrapper.get('.v-list-item-title').contains('Group')
const group = wrapper.get('.v-list-group')
group.get('.v-list-group__items .v-list-item').should('have.length', 0)
group.get('.v-list-item').click()
group.get('.v-list-group__items .v-list-item').should('have.length', 2)
})

it('already opened children', () => {
const wrapper = mountFunction(ListGroup(true, true))

wrapper.get('.v-list-group__items .v-list-item').should('have.length', 2)
})
})
})

0 comments on commit ba81e21

Please sign in to comment.