{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "lazy-image",
  "title": "Lazy Image",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/lazy-image/Img.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\nimport { Skeleton } from '@/components/ui/skeleton'\n\ntype Placeholder = 'skeleton' | 'none'\n\nconst props = withDefaults(\n  defineProps<{\n    src: string\n    srcset?: string\n    sizes?: string\n    alt: string\n    aspectRatio?: string | number\n    width?: string | number\n    height?: string | number\n    placeholder?: Placeholder\n    cover?: boolean\n    eager?: boolean\n    fallback?: string\n    transition?: boolean\n    class?: HTMLAttributes['class']\n    imgClass?: HTMLAttributes['class']\n  }>(),\n  {\n    placeholder: 'skeleton',\n    cover: true,\n    eager: false,\n    transition: true,\n  },\n)\n\nconst emits = defineEmits<{\n  (e: 'load', event: Event): void\n  (e: 'error', event: Event): void\n}>()\n\nconst root = ref<HTMLElement | null>(null)\nconst state = ref<'idle' | 'loading' | 'loaded' | 'error'>('idle')\nconst visible = ref(props.eager)\n\nconst aspectStyle = computed((): Record<string, string> => {\n  const r = props.aspectRatio\n  if (r === undefined) return {}\n  if (typeof r === 'number') return { aspectRatio: String(r) }\n  return { aspectRatio: r }\n})\n\nconst sizeStyle = computed((): Record<string, string> => {\n  const out: Record<string, string> = {}\n  if (props.width !== undefined) out.width = typeof props.width === 'number' ? `${props.width}px` : props.width\n  if (props.height !== undefined) out.height = typeof props.height === 'number' ? `${props.height}px` : props.height\n  return out\n})\n\nconst containerStyle = computed(() => ({ ...aspectStyle.value, ...sizeStyle.value }))\n\nlet observer: IntersectionObserver | null = null\n\nfunction setupObserver() {\n  if (props.eager || typeof IntersectionObserver === 'undefined' || !root.value) {\n    visible.value = true\n    return\n  }\n  observer = new IntersectionObserver(\n    (entries) => {\n      for (const entry of entries) {\n        if (entry.isIntersecting) {\n          visible.value = true\n          observer?.disconnect()\n          observer = null\n          return\n        }\n      }\n    },\n    { rootMargin: '200px' },\n  )\n  observer.observe(root.value)\n}\n\nonMounted(() => {\n  setupObserver()\n})\n\nonBeforeUnmount(() => {\n  observer?.disconnect()\n  observer = null\n})\n\nwatch(\n  () => props.src,\n  () => {\n    state.value = 'idle'\n    if (!props.eager) {\n      visible.value = false\n      setupObserver()\n    }\n  },\n)\n\nwatch(visible, (v) => {\n  if (v && state.value === 'idle') state.value = 'loading'\n})\n\nfunction onLoad(e: Event) {\n  state.value = 'loaded'\n  emits('load', e)\n}\n\nfunction onError(e: Event) {\n  state.value = 'error'\n  emits('error', e)\n}\n</script>\n\n<template>\n  <div\n    ref=\"root\"\n    data-uipkge\n    data-slot=\"lazy-image\"\n    :class=\"cn('bg-muted relative overflow-hidden', props.class)\"\n    :style=\"containerStyle\"\n  >\n    <Skeleton\n      v-if=\"placeholder === 'skeleton' && state !== 'loaded' && state !== 'error'\"\n      class=\"absolute inset-0 size-full rounded-none\"\n    />\n\n    <img\n      v-if=\"visible && state !== 'error'\"\n      :src=\"src\"\n      :srcset=\"srcset\"\n      :sizes=\"sizes\"\n      :alt=\"alt\"\n      :loading=\"eager ? 'eager' : 'lazy'\"\n      :decoding=\"eager ? 'sync' : 'async'\"\n      :class=\"\n        cn(\n          'block size-full',\n          cover ? 'object-cover' : 'object-contain',\n          transition && 'transition-opacity duration-300',\n          state === 'loaded' ? 'opacity-100' : 'opacity-0',\n          imgClass,\n        )\n      \"\n      @load=\"onLoad\"\n      @error=\"onError\"\n    />\n\n    <template v-if=\"state === 'error'\">\n      <slot name=\"fallback\">\n        <img\n          v-if=\"fallback\"\n          :src=\"fallback\"\n          :alt=\"alt\"\n          :class=\"cn('block size-full', cover ? 'object-cover' : 'object-contain', imgClass)\"\n        />\n        <div\n          v-else\n          class=\"text-muted-foreground absolute inset-0 flex items-center justify-center text-xs\"\n          aria-label=\"Image failed to load\"\n        >\n          <span>Image unavailable</span>\n        </div>\n      </slot>\n    </template>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/lazy-image/Img.vue"
    },
    {
      "path": "packages/registry-vue/components/lazy-image/index.ts",
      "content": "export { default as Img } from './Img.vue'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/lazy-image/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/skeleton.json"
  ],
  "description": "Lazy-loaded image with aspect-ratio reservation, skeleton placeholder, fade-in transition, and error fallback. Composes loading=\"lazy\" with IntersectionObserver for off-viewport hold and pairs with the skeleton primitive for the placeholder state.",
  "categories": [
    "data-display"
  ]
}