{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "lazy-image",
  "title": "Lazy Image",
  "type": "registry:ui",
  "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"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/lazy-image/lazy-image.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\n\nexport type ImagePlaceholder = 'skeleton' | 'none'\n\nconst trueByDefault = {\n  fromAttribute: (v: string | null) => v !== 'false' && v !== null,\n  toAttribute: (v: boolean) => (v ? '' : 'false'),\n}\n\n/**\n * <uip-lazy-image> — progressive image loader with skeleton placeholder and fallback.\n */\nexport class UipLazyImage extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        overflow: hidden;\n        position: relative;\n      }\n    `,\n  ]\n\n  static properties = {\n    src: { reflect: true },\n    srcSet: { attribute: 'srcset', reflect: true },\n    sizes: { reflect: true },\n    alt: { reflect: true },\n    aspectRatio: { attribute: 'aspect-ratio', reflect: true },\n    width: { reflect: true },\n    height: { reflect: true },\n    placeholder: { reflect: true },\n    cover: { converter: trueByDefault, reflect: true },\n    eager: { type: Boolean, reflect: true },\n    fallback: { reflect: true },\n    transition: { converter: trueByDefault, reflect: true },\n    state: { state: true },\n    visible: { state: true },\n  }\n\n  src = ''\n  srcSet?: string\n  sizes?: string\n  alt = ''\n  aspectRatio?: string | number\n  width?: string | number\n  height?: string | number\n  placeholder: ImagePlaceholder = 'skeleton'\n  cover = true\n  eager = false\n  fallback?: string\n  transition = true\n\n  private state: 'idle' | 'loading' | 'loaded' | 'error' = 'idle'\n  private visible = false\n  private observer?: IntersectionObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'lazy-image')\n    this.setupObserver()\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    if (this.observer) {\n      this.observer.disconnect()\n      this.observer = undefined\n    }\n  }\n\n  protected updated(changedProps: Map<string, unknown>) {\n    super.updated(changedProps)\n    if (changedProps.has('src') || changedProps.has('eager')) {\n      this.state = 'idle'\n      this.setupObserver()\n    }\n    if (this.visible && this.state === 'idle') {\n      this.state = 'loading'\n    }\n  }\n\n  private setupObserver() {\n    if (isServer) {\n      this.visible = true\n      return\n    }\n\n    if (this.eager) {\n      this.visible = true\n      return\n    }\n\n    this.visible = false\n\n    if (typeof IntersectionObserver === 'undefined') {\n      this.visible = true\n      return\n    }\n\n    if (this.observer) {\n      this.observer.disconnect()\n    }\n\n    this.observer = new IntersectionObserver(\n      (entries) => {\n        for (const entry of entries) {\n          if (entry.isIntersecting) {\n            this.visible = true\n            this.observer?.disconnect()\n            this.observer = undefined\n            break\n          }\n        }\n      },\n      { rootMargin: '200px' },\n    )\n    this.observer.observe(this)\n  }\n\n  private onImgLoad(e: Event) {\n    this.state = 'loaded'\n    this.dispatchEvent(new CustomEvent('load', { bubbles: true, composed: true }))\n  }\n\n  private onImgError(e: Event) {\n    this.state = 'error'\n    this.dispatchEvent(new CustomEvent('error', { bubbles: true, composed: true }))\n  }\n\n  render() {\n    const styleParts: string[] = []\n    if (this.aspectRatio !== undefined) {\n      const ar = typeof this.aspectRatio === 'number' ? String(this.aspectRatio) : this.aspectRatio\n      styleParts.push(`aspect-ratio: ${ar};`)\n    }\n    if (this.width !== undefined) {\n      const w = typeof this.width === 'number' ? `${this.width}px` : this.width\n      styleParts.push(`width: ${w};`)\n    }\n    if (this.height !== undefined) {\n      const h = typeof this.height === 'number' ? `${this.height}px` : this.height\n      styleParts.push(`height: ${h};`)\n    }\n    const containerStyle = styleParts.join(' ')\n\n    return html`\n      <div\n        part=\"base\"\n        data-slot=\"lazy-image\"\n        class=\"bg-muted relative size-full overflow-hidden\"\n        style=${containerStyle || nothing}\n      >\n        ${this.placeholder === 'skeleton' && this.state !== 'loaded' && this.state !== 'error'\n          ? html`<uip-skeleton class=\"absolute inset-0 size-full rounded-none\"></uip-skeleton>`\n          : nothing}\n        ${this.visible && this.state !== 'error'\n          ? html`\n              <img\n                src=${this.src}\n                srcset=${this.srcSet ?? nothing}\n                sizes=${this.sizes ?? nothing}\n                alt=${this.alt}\n                loading=${this.eager ? 'eager' : 'lazy'}\n                decoding=${this.eager ? 'sync' : 'async'}\n                class=${cn(\n                  'block size-full',\n                  this.cover ? 'object-cover' : 'object-contain',\n                  this.transition && 'transition-opacity duration-300',\n                  this.state === 'loaded' ? 'opacity-100' : 'opacity-0',\n                )}\n                @load=${this.onImgLoad}\n                @error=${this.onImgError}\n              />\n            `\n          : nothing}\n        ${this.state === 'error'\n          ? html`\n              <slot name=\"fallback\">\n                ${this.fallback\n                  ? html`\n                      <img\n                        src=${this.fallback}\n                        alt=${this.alt}\n                        class=${cn('block size-full', this.cover ? 'object-cover' : 'object-contain')}\n                      />\n                    `\n                  : html`\n                      <div\n                        role=\"img\"\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 aria-hidden=\"true\">Image unavailable</span>\n                      </div>\n                    `}\n              </slot>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-lazy-image') || customElements.define('uip-lazy-image', UipLazyImage)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-lazy-image': UipLazyImage\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/lazy-image/lazy-image.ts"
    }
  ],
  "dependencies": [
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
