{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-compare",
  "title": "Image Compare",
  "description": "Astro before-and-after pictures with a pointer and keyboard divider.",
  "dependencies": [
    "cn@0.4.0"
  ],
  "registryDependencies": [
    "@gnostic/button",
    "@gnostic/icon"
  ],
  "files": [
    {
      "path": "packages/ui/src/components/ui/image-compare/image-compare-handle.astro",
      "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"cn\"\nimport { Button } from \"../button/index.ts\"\nimport { Icon } from \"../icon/index.ts\"\n\n/**\n * Decorative divider between the two pictures.\n * Div attributes such as `class` pass through.\n * The default slot replaces the arrows icon.\n * Keyboard input stays on the range control in `ImageCompare`.\n */\ntype Props = HTMLAttributes<\"div\">\n\nconst { class: className, ...props } = Astro.props\n---\n\n<div\n  data-slot=\"image-compare-handle\"\n  aria-hidden=\"true\"\n  inert\n  class={cn(\n    \"image-compare-divider pointer-events-none absolute inset-y-0 z-30 w-0.5 -translate-x-1/2 bg-background/72 shadow-md\",\n    className,\n  )}\n  {...props}\n>\n  <Button\n    variant=\"glass\"\n    size=\"icon-xl\"\n    tabindex=\"-1\"\n    class=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full\"\n  >\n    <slot>\n      <Icon\n        name=\"lucide:MoveHorizontal\"\n        class=\"text-white/32 group-hover/image-compare:text-white/64\"\n      />\n    </slot>\n  </Button>\n</div>\n\n<style>\n  /* Follow the same position variable as the clipped picture. */\n  .image-compare-divider {\n    left: var(--image-compare-position, 50%);\n  }\n</style>\n",
      "type": "registry:file",
      "target": "~/src/components/ui/image-compare/image-compare-handle.astro"
    },
    {
      "path": "packages/ui/src/components/ui/image-compare/image-compare-image.astro",
      "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"cn\"\nimport type { ImageCompareImageProps } from \"./types.ts\"\n\n/** Image attributes pass through. `src`, `alt`, and `clip` come from `ImageCompareImageProps`. */\ntype Props = Omit<HTMLAttributes<\"img\">, \"src\" | \"alt\"> & ImageCompareImageProps\n\nconst {\n  class: className,\n  src,\n  alt,\n  width,\n  height,\n  clip = false,\n  draggable = false,\n  ...props\n} = Astro.props\n\n/**\n * An imported picture carries its own pixel size.\n * A plain address uses the 16:9 fallback unless the caller sets a size.\n * `draggable` is the string \"true\" or \"false\". A boolean false would omit the attribute.\n */\nconst resolvedSrc = typeof src === \"string\" ? src : src.src\nconst resolvedWidth = width ?? (typeof src === \"string\" ? 1200 : src.width)\nconst resolvedHeight = height ?? (typeof src === \"string\" ? 675 : src.height)\nconst imageDraggable =\n  draggable === true || draggable === \"true\" ? \"true\" : \"false\"\n---\n\n<img\n  data-slot=\"image-compare-image\"\n  data-clip={clip ? \"true\" : undefined}\n  src={resolvedSrc}\n  alt={alt}\n  width={resolvedWidth}\n  height={resolvedHeight}\n  draggable={imageDraggable}\n  class={cn(\n    \"pointer-events-none absolute inset-0 size-full object-cover\",\n    clip ? \"image-compare-clip z-10\" : \"z-0\",\n    className,\n  )}\n  {...props}\n/>\n\n<style>\n  /* Reveal the left portion. The root sets --image-compare-position. */\n  .image-compare-clip {\n    clip-path: inset(0 calc(100% - var(--image-compare-position, 50%)) 0 0);\n  }\n</style>\n",
      "type": "registry:file",
      "target": "~/src/components/ui/image-compare/image-compare-image.astro"
    },
    {
      "path": "packages/ui/src/components/ui/image-compare/image-compare.astro",
      "content": "---\nimport type { HTMLAttributes } from \"astro/types\"\n\nimport { cn } from \"cn\"\nimport type { ImageCompareProps } from \"./types.ts\"\n\n/**\n * Comparison frame.\n * Place two `ImageCompareImage` parts and one `ImageCompareHandle` in the slot.\n * Div attributes pass through. `defaultValue` and `label` come from `ImageCompareProps`.\n */\ntype Props = HTMLAttributes<\"div\"> & ImageCompareProps\n\nconst {\n  class: className,\n  defaultValue = 50,\n  label = \"Amount of the clipped image shown\",\n  style,\n  ...props\n} = Astro.props\n\n/**\n * Keep the divider inside the frame.\n * A non-numeric value falls back to the center.\n */\nconst position = Number.isFinite(defaultValue)\n  ? Math.min(100, Math.max(0, defaultValue))\n  : 50\n\n/** String form so a value of 0 stays on the range control. */\nconst positionValue = String(position)\nconst positionStyle = `--image-compare-position:${positionValue}%`\nconst mergedStyle =\n  typeof style === \"string\"\n    ? `${style};${positionStyle}`\n    : { ...style, \"--image-compare-position\": `${positionValue}%` }\n---\n\n<div\n  data-slot=\"image-compare\"\n  style={mergedStyle}\n  class={cn(\n    \"group/image-compare relative block aspect-video w-full cursor-ew-resize touch-pan-y overflow-hidden select-none\",\n    className,\n  )}\n  {...props}\n>\n  <slot />\n  <input\n    data-slot=\"image-compare-input\"\n    type=\"range\"\n    min=\"0\"\n    max=\"100\"\n    step=\"1\"\n    value={positionValue}\n    aria-label={label}\n    class=\"pointer-events-none sr-only\"\n  />\n</div>\n\n<style>\n  /* The range control is hidden. Show its focus on the visible handle. */\n  :global(\n    [data-slot=\"image-compare\"]:has(\n        [data-slot=\"image-compare-input\"]:focus-visible\n      )\n      [data-slot=\"image-compare-handle\"]\n      [data-slot=\"button\"]\n  ) {\n    outline: 3px solid var(--ring);\n    outline-offset: 3px;\n  }\n</style>\n\n<script>\n  /** Roots already bound on this page. A later page load skips them. */\n  const bound = new WeakSet<HTMLElement>()\n\n  /**\n   * Drag on the frame, or arrow keys on the range control, moves the divider.\n   * The pictures read `--image-compare-position` from this root.\n   */\n  function bindImageCompare(root: HTMLElement) {\n    if (bound.has(root)) return\n    const input = root.querySelector<HTMLInputElement>(\n      \"[data-slot='image-compare-input']\",\n    )\n    if (!input) return\n    bound.add(root)\n\n    const updatePosition = () => {\n      const value = Math.min(100, Math.max(0, Number(input.value)))\n      root.style.setProperty(\"--image-compare-position\", `${value}%`)\n    }\n    input.addEventListener(\"input\", updatePosition)\n    updatePosition()\n\n    /** Map a pointer x position to a percent of the frame width. */\n    const updateFromPointer = (clientX: number) => {\n      const bounds = root.getBoundingClientRect()\n      if (bounds.width === 0) return\n      const ratio = (clientX - bounds.left) / bounds.width\n      const value = Math.round(Math.min(1, Math.max(0, ratio)) * 100)\n      input.value = String(value)\n      input.dispatchEvent(new Event(\"input\", { bubbles: true }))\n    }\n\n    root.addEventListener(\"pointerdown\", (event) => {\n      if (event.pointerType === \"mouse\" && event.button !== 0) return\n      const target = event.target\n      if (target instanceof Element) {\n        const interactive = target.closest(\n          \"a, button, input, textarea, select, label\",\n        )\n        if (interactive && interactive !== input) return\n      }\n      if (event.pointerType === \"mouse\") event.preventDefault()\n      input.focus({ preventScroll: true })\n      root.setPointerCapture(event.pointerId)\n      updateFromPointer(event.clientX)\n    })\n\n    root.addEventListener(\"pointermove\", (event) => {\n      if (!root.hasPointerCapture(event.pointerId)) return\n      updateFromPointer(event.clientX)\n    })\n  }\n\n  const initialize = () => {\n    for (const root of document.querySelectorAll<HTMLElement>(\n      \"[data-slot='image-compare']\",\n    ))\n      bindImageCompare(root)\n  }\n\n  initialize()\n  document.addEventListener(\"astro:page-load\", initialize)\n</script>\n",
      "type": "registry:file",
      "target": "~/src/components/ui/image-compare/image-compare.astro"
    },
    {
      "path": "packages/ui/src/components/ui/image-compare/index.ts",
      "content": "export { default as ImageCompareHandle } from './image-compare-handle.astro'\nexport { default as ImageCompareImage } from './image-compare-image.astro'\nexport { default as ImageCompare } from './image-compare.astro'\n\nexport type { ImageCompareImageProps, ImageCompareProps } from './types.ts'\n",
      "type": "registry:file",
      "target": "~/src/components/ui/image-compare/index.ts"
    },
    {
      "path": "packages/ui/src/components/ui/image-compare/types.ts",
      "content": "import type { ImageMetadata } from 'astro'\n\n/**\n * Props for the comparison frame.\n * Div attributes such as `class` and `id` pass through.\n * Put both pictures and the handle in the default slot.\n */\nexport interface ImageCompareProps {\n    /**\n     * Divider position at first render, from 0 to 100.\n     * `0` hides the clipped picture. `100` covers the frame with it.\n     * @default 50\n     */\n    defaultValue?: number\n    /**\n     * Accessible name for the range control.\n     * @default \"Amount of the clipped image shown\"\n     */\n    label?: string\n}\n\n/**\n * Props for one picture in a comparison.\n * Image attributes such as `class`, `width`, `height`, and `loading` pass through.\n */\nexport interface ImageCompareImageProps {\n    /**\n     * Picture file or address.\n     */\n    src: ImageMetadata | string\n    /**\n     * Text alternative for this picture.\n     */\n    alt: string\n    /**\n     * Show only the part of this picture to the left of the divider.\n     * Set this on one picture. Leave it unset on the picture that fills the frame.\n     * @default false\n     */\n    clip?: boolean\n}\n",
      "type": "registry:file",
      "target": "~/src/components/ui/image-compare/types.ts"
    }
  ],
  "meta": {
    "framework": "astro",
    "source": "@gnostic/ui"
  },
  "docs": "https://ui.gnosticai.com/components/image-compare",
  "categories": [
    "ui"
  ],
  "type": "registry:ui"
}
