Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/docs/src/examples/components/lazy/basic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { Lazy } from '@vuetify/v0'
</script>

<template>
<div class="flex flex-col gap-4">
<p class="text-sm text-secondary">
Scroll down to see the lazy-loaded content appear.
</p>

<div class="h-48 overflow-y-auto border border-outline rounded-lg p-4">
<div class="h-32" />

<Lazy.Root class="min-h-24">
<Lazy.Placeholder>
<div
aria-label="Loading content"
class="h-24 rounded-lg bg-surface-tint motion-safe:animate-pulse flex items-center justify-center"
role="status"
>
<span aria-hidden="true" class="text-secondary text-sm">Loading...</span>
</div>
</Lazy.Placeholder>

<Lazy.Content>
<div class="h-24 rounded-lg bg-primary/10 flex items-center justify-center">
<span class="text-primary font-medium">Content loaded!</span>
</div>
</Lazy.Content>
</Lazy.Root>

<div class="h-32" />
</div>
</div>
</template>
60 changes: 60 additions & 0 deletions packages/0/src/components/Lazy/LazyContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @module LazyContent
*
* @remarks
* Content component shown after the element intersects the viewport.
* Consumes the Lazy context and displays when hasContent is true.
*/

<script lang="ts">
// Types
import type { AtomProps } from '#v0/components/Atom'

export interface LazyContentProps extends AtomProps {
/** Namespace for retrieving lazy context */
namespace?: string
}

export interface LazyContentSlotProps {
/** Whether this content is currently visible */
hasContent: boolean
}
</script>

<script lang="ts" setup>
// Components
import { Atom } from '#v0/components/Atom'
// Composables
import { useLazyRoot } from './LazyRoot.vue'

// Utilities
import { toRef } from 'vue'

defineOptions({ name: 'LazyContent' })

defineSlots<{
default: (props: LazyContentSlotProps) => any
}>()

const {
as = 'div',
renderless,
namespace = 'v0:lazy',
} = defineProps<LazyContentProps>()

const context = useLazyRoot(namespace)

const slotProps = toRef((): LazyContentSlotProps => ({
hasContent: context.hasContent.value,
}))
</script>

<template>
<Atom
v-if="context.hasContent.value"
:as
:renderless
>
<slot v-bind="slotProps" />
</Atom>
</template>
60 changes: 60 additions & 0 deletions packages/0/src/components/Lazy/LazyPlaceholder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @module LazyPlaceholder
*
* @remarks
* Placeholder component shown before content intersects the viewport.
* Consumes the Lazy context and displays when hasContent is false.
*/

<script lang="ts">
// Types
import type { AtomProps } from '#v0/components/Atom'

export interface LazyPlaceholderProps extends AtomProps {
/** Namespace for retrieving lazy context */
namespace?: string
}

export interface LazyPlaceholderSlotProps {
/** Whether content is ready (placeholder hides when true) */
hasContent: boolean
}
</script>

<script lang="ts" setup>
// Components
import { Atom } from '#v0/components/Atom'
// Composables
import { useLazyRoot } from './LazyRoot.vue'

// Utilities
import { toRef } from 'vue'

defineOptions({ name: 'LazyPlaceholder' })

defineSlots<{
default: (props: LazyPlaceholderSlotProps) => any
}>()

const {
as = 'div',
renderless,
namespace = 'v0:lazy',
} = defineProps<LazyPlaceholderProps>()

const context = useLazyRoot(namespace)

const slotProps = toRef((): LazyPlaceholderSlotProps => ({
hasContent: context.hasContent.value,
}))
</script>

<template>
<Atom
v-if="!context.hasContent.value"
:as
:renderless
>
<slot v-bind="slotProps" />
</Atom>
</template>
107 changes: 107 additions & 0 deletions packages/0/src/components/Lazy/LazyRoot.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @module LazyRoot
*
* @remarks
* Root component for deferred content rendering. Uses IntersectionObserver
* to detect when the element enters the viewport and switches from
* placeholder to content. Leverages the useLazy composable for SSR-safe
* booted state management.
*
* Supports integration with Vue Transition via the `onAfterLeave` slot prop,
* which resets the booted state when the leave transition completes (unless
* eager mode is enabled).
*/

<script lang="ts">
// Foundational
import { createContext } from '#v0/composables/createContext'

// Types
import type { AtomProps } from '#v0/components/Atom'
import type { LazyContext } from '#v0/composables/useLazy'

export interface LazyRootProps extends AtomProps {
/** Namespace for dependency injection */
namespace?: string
/** When true, content renders immediately without waiting for intersection */
eager?: boolean
/** IntersectionObserver root margin */
rootMargin?: string
/** IntersectionObserver threshold */
threshold?: number | number[]
}

export interface LazyRootSlotProps {
/** Whether the lazy content has been activated (intersected or eager) */
isBooted: boolean
/** Whether content is ready to be displayed */
hasContent: boolean
/** Reset booted state. Call on leave transition if not eager. */
reset: () => void
/** Transition callback for after-leave. Resets if not eager. */
onAfterLeave: () => void
}

export type LazyRootContext = LazyContext

export const [useLazyRoot, provideLazyRoot] = createContext<LazyRootContext>()
</script>

<script lang="ts" setup>
// Components
import { Atom } from '#v0/components/Atom'

// Composables
import { useIntersectionObserver } from '#v0/composables/useIntersectionObserver'
import { useLazy } from '#v0/composables/useLazy'

// Utilities
import { shallowRef, toRef } from 'vue'

defineOptions({ name: 'LazyRoot' })

defineSlots<{
default: (props: LazyRootSlotProps) => any
}>()

const {
as = 'div',
renderless,
namespace = 'v0:lazy',
eager = false,
rootMargin = '0px',
threshold = 0,
} = defineProps<LazyRootProps>()

const rootEl = shallowRef<HTMLElement>()
const isIntersected = shallowRef(false)

// Use useLazy with intersection as the active signal
const context = useLazy(isIntersected, { eager })

useIntersectionObserver(
rootEl,
entries => {
const entry = entries.at(-1)
if (entry?.isIntersecting) {
isIntersected.value = true
}
},
{ once: true, rootMargin, threshold },
)

provideLazyRoot(namespace, context)

const slotProps = toRef((): LazyRootSlotProps => ({
isBooted: context.isBooted.value,
hasContent: context.hasContent.value,
reset: context.reset,
onAfterLeave: context.onAfterLeave,
}))
</script>

<template>
<Atom ref="rootEl" :as :renderless>
<slot v-bind="slotProps" />
</Atom>
</template>
Loading
Loading