{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spoiler",
  "title": "Spoiler",
  "description": "Hides text or a block until someone presses to see it, so the reader chooses the moment. Screen readers do not get ahead of everyone else.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/spoiler.tsx",
      "content": "import type * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\nimport { BlockSpoiler, BlurSpoiler, InlineSpoiler, MoreSpoiler } from \"@/registry/miseui/ui/spoiler-parts\"\nimport { useReveal } from \"@/registry/miseui/ui/spoiler-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype SpoilerVariant = \"blur\" | \"block\" | \"inline\" | \"more\"\ntype SpoilerEffect = \"blur\"\n\nconst DEFAULT_COLLAPSED_PX = 96\n\ntype SpoilerProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Stays out of the accessibility tree and the tab order until revealed. */\n  children: React.ReactNode\n  /** `blur` softens the content in place, `block` covers it with an opaque panel, `inline` redacts words in a sentence, `more` shows the first lines with a fade. */\n  variant?: SpoilerVariant\n  /** `blur` clears the content in a spring and eases the height of `more`. Instant under reduced motion. */\n  effect?: SpoilerEffect\n  revealed?: boolean\n  defaultRevealed?: boolean\n  onRevealedChange?: (revealed: boolean) => void\n  /** What the control says. Defaults to \"Show spoiler\", or \"Show more\" for `more`. */\n  label?: string\n  /** The way back in `more`. */\n  hideLabel?: string\n  /** Height of `more` while collapsed, in px. */\n  collapsedHeight?: number\n  /** What a screen reader hears before `more` opens. A plain string child gets its first sentence automatically. */\n  preview?: React.ReactNode\n}\n\nfunction Spoiler({\n  children,\n  variant = \"blur\",\n  effect,\n  revealed,\n  defaultRevealed,\n  onRevealedChange,\n  label,\n  hideLabel = \"Show less\",\n  collapsedHeight = DEFAULT_COLLAPSED_PX,\n  preview,\n  className,\n  ...props\n}: SpoilerProps) {\n  const [isRevealed, setRevealed] = useReveal({ revealed, defaultRevealed, onRevealedChange })\n  const reduceMotion = useReducedMotion() ?? false\n  const animated = effect === \"blur\" && !reduceMotion\n  const shared = { revealed: isRevealed, animated, label: label ?? (variant === \"more\" ? \"Show more\" : \"Show spoiler\"), onReveal: () => setRevealed(true) }\n  const attributes = { \"data-slot\": \"spoiler\", \"data-variant\": variant, \"data-effect\": effect, \"data-revealed\": isRevealed ? \"true\" : \"false\" }\n  if (variant === \"inline\") {\n    return (\n      <span {...attributes} className={className}>\n        <InlineSpoiler {...shared}>{children}</InlineSpoiler>\n      </span>\n    )\n  }\n  return (\n    <div {...attributes} className={cn(\"min-w-0\", className)} {...props}>\n      {variant === \"blur\" ? <BlurSpoiler {...shared}>{children}</BlurSpoiler> : null}\n      {variant === \"block\" ? <BlockSpoiler {...shared}>{children}</BlockSpoiler> : null}\n      {variant === \"more\" ? (\n        <MoreSpoiler {...shared} hideLabel={hideLabel} collapsedHeight={collapsedHeight} preview={preview} onToggle={() => setRevealed(!isRevealed)}>\n          {children}\n        </MoreSpoiler>\n      ) : null}\n    </div>\n  )\n}\n\nexport { Spoiler, type SpoilerProps, type SpoilerVariant, type SpoilerEffect }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/spoiler-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { useState } from \"react\"\nimport { AnimatePresence, motion } from \"motion/react\"\nimport { ChevronDown, Eye } from \"lucide-react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { previewOfText, useFocusOnReveal, useOverflowing } from \"@/registry/miseui/ui/spoiler-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst INSTANT = { duration: 0 } as const\nconst BLUR_PX = 8\nconst COLLAPSED_FADE = \"linear-gradient(to bottom, black calc(100% - 48px), transparent)\"\n\nconst COVER_FOCUS_CLASS = \"outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\"\n\ninterface PanelProps {\n  revealed: boolean\n  onReveal: () => void\n  label: string\n  animated: boolean\n  children: React.ReactNode\n}\n\n/** Hidden content is real DOM that sets the size, but aria-hidden and inert keep it from being read or tabbed into. */\nfunction hiddenProps(revealed: boolean) {\n  return { \"aria-hidden\": revealed ? undefined : true, inert: !revealed } as const\n}\n\nfunction BlurSpoiler({ revealed, onReveal, label, animated, children }: PanelProps) {\n  const { target, markPressed } = useFocusOnReveal(revealed)\n  const blur = `blur(${revealed ? 0 : BLUR_PX}px)`\n  return (\n    <div className=\"relative rounded-md\">\n      <motion.div\n        ref={target as React.RefObject<HTMLDivElement>}\n        tabIndex={revealed ? -1 : undefined}\n        data-slot=\"spoiler-content\"\n        {...hiddenProps(revealed)}\n        className={cn(\"rounded-md outline-none\", !revealed && \"select-none\")}\n        // The first paint is already blurred, so nothing can flash in the clear before the style lands.\n        initial={{ filter: blur }}\n        animate={{ filter: blur }}\n        transition={animated ? SPRING_DEFAULT : INSTANT}\n      >\n        {children}\n      </motion.div>\n      {revealed ? null : (\n        <button\n          type=\"button\"\n          aria-expanded={false}\n          data-slot=\"spoiler-cover\"\n          className={cn(\"absolute inset-0 grid cursor-pointer place-items-center rounded-md\", COVER_FOCUS_CLASS)}\n          onClick={() => {\n            markPressed()\n            onReveal()\n          }}\n        >\n          <span className=\"inline-flex h-8 items-center gap-2 rounded-full border bg-background px-3 text-[13px] font-medium shadow-xs pointer-coarse:h-10\">\n            <Eye aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n            {label}\n          </span>\n        </button>\n      )}\n    </div>\n  )\n}\n\nfunction BlockSpoiler({ revealed, onReveal, label, animated, children }: PanelProps) {\n  const { target, markPressed } = useFocusOnReveal(revealed)\n  return (\n    <div className=\"relative min-h-10 overflow-hidden rounded-md\">\n      {/* Keeps its room behind the opaque cover, so opening it moves nothing else on the page. */}\n      <div ref={target as React.RefObject<HTMLDivElement>} tabIndex={revealed ? -1 : undefined} data-slot=\"spoiler-content\" {...hiddenProps(revealed)} className={cn(\"outline-none\", !revealed && \"invisible select-none\")}>\n        {children}\n      </div>\n      <AnimatePresence initial={false}>\n        {revealed ? null : (\n          <motion.button\n            type=\"button\"\n            aria-expanded={false}\n            data-slot=\"spoiler-cover\"\n            className={cn(\"absolute inset-0 flex min-h-10 cursor-pointer items-center justify-center gap-2 rounded-md border bg-muted px-3 text-[13px] font-medium\", COVER_FOCUS_CLASS)}\n            exit={{ opacity: 0 }}\n            transition={animated ? SPRING_DEFAULT : INSTANT}\n            onClick={() => {\n              markPressed()\n              onReveal()\n            }}\n          >\n            <Eye aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n            {label}\n          </motion.button>\n        )}\n      </AnimatePresence>\n    </div>\n  )\n}\n\n/**\n * A span, not a button: browsers treat a button as one box, and a redaction bar has to wrap with the sentence around it.\n * The same element before and after, so the colours can transition and focus simply stays where it is.\n */\nfunction InlineSpoiler({ revealed, onReveal, label, animated, children }: PanelProps) {\n  const cover = revealed\n    ? { tabIndex: -1 }\n    : {\n        role: \"button\",\n        tabIndex: 0,\n        \"aria-label\": label,\n        onClick: onReveal,\n        onKeyDown: (event: React.KeyboardEvent) => {\n          if (event.key !== \"Enter\" && event.key !== \" \") return\n          event.preventDefault()\n          onReveal()\n        },\n      }\n  return (\n    <span\n      {...cover}\n      data-slot={revealed ? \"spoiler-content\" : \"spoiler-cover\"}\n      className={cn(\n        \"rounded-sm box-decoration-clone outline-none\",\n        !revealed && \"cursor-pointer bg-foreground text-transparent select-none hover:bg-foreground/80 focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        animated && \"transition-[color,background-color] duration-(--duration-base) ease-(--ease-mise) motion-reduce:transition-none\",\n      )}\n    >\n      <span aria-hidden={revealed ? undefined : true}>{children}</span>\n    </span>\n  )\n}\n\ninterface MoreSpoilerProps extends Omit<PanelProps, \"onReveal\"> {\n  onToggle: () => void\n  hideLabel: string\n  collapsedHeight: number\n  preview?: React.ReactNode\n}\n\nfunction MoreSpoiler({ revealed, onToggle, label, hideLabel, animated, collapsedHeight, preview, children }: MoreSpoilerProps) {\n  const [clip, setClip] = useState<HTMLDivElement | null>(null)\n  const overflowing = useOverflowing(clip, collapsedHeight)\n  const open = revealed || !overflowing\n  const spoken = preview ?? (typeof children === \"string\" ? previewOfText(children) : null)\n  return (\n    <div className=\"grid min-w-0 justify-items-start gap-2\">\n      <motion.div\n        ref={setClip}\n        data-slot=\"spoiler-content\"\n        className=\"w-full overflow-hidden\"\n        {...hiddenProps(open)}\n        initial={false}\n        animate={{ height: open ? \"auto\" : collapsedHeight }}\n        transition={animated ? SPRING_DEFAULT : INSTANT}\n        style={open ? undefined : { maskImage: COLLAPSED_FADE, WebkitMaskImage: COLLAPSED_FADE }}\n      >\n        <div>{children}</div>\n      </motion.div>\n      {/* The sighted reader sees the first lines through the fade, so the screen reader is given the same start. */}\n      {open || !spoken ? null : <p className=\"sr-only\">{spoken}</p>}\n      {overflowing ? (\n        <button\n          type=\"button\"\n          aria-expanded={revealed}\n          data-slot=\"spoiler-toggle\"\n          className=\"inline-flex h-8 items-center gap-1 rounded-md px-2 -mx-2 text-[13px] font-medium text-foreground outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:h-10\"\n          onClick={onToggle}\n        >\n          {revealed ? hideLabel : label}\n          <ChevronDown aria-hidden=\"true\" className={cn(\"size-4 text-muted-foreground\", revealed && \"rotate-180\")} />\n        </button>\n      ) : null}\n    </div>\n  )\n}\n\nexport { BlockSpoiler, BlurSpoiler, InlineSpoiler, MoreSpoiler }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/spoiler-state.ts",
      "content": "import { useEffect, useRef, useState } from \"react\"\n\n/** First sentence of a plain string, or a cut at a word boundary: what a screen reader hears before `more` is opened. */\nfunction previewOfText(text: string, maxLength = 120) {\n  const trimmed = text.trim()\n  if (trimmed.length <= maxLength) return trimmed\n  const sentenceEnd = trimmed.search(/[.!?](\\s|$)/)\n  if (sentenceEnd >= 0 && sentenceEnd < maxLength) return trimmed.slice(0, sentenceEnd + 1)\n  const cut = trimmed.lastIndexOf(\" \", maxLength)\n  return `${trimmed.slice(0, cut > 0 ? cut : maxLength)}...`\n}\n\ninterface RevealOptions {\n  revealed?: boolean\n  defaultRevealed?: boolean\n  onRevealedChange?: (revealed: boolean) => void\n}\n\nfunction useReveal({ revealed, defaultRevealed = false, onRevealedChange }: RevealOptions) {\n  const [uncontrolled, setUncontrolled] = useState(defaultRevealed)\n  const current = revealed ?? uncontrolled\n  const setRevealed = (next: boolean) => {\n    setUncontrolled(next)\n    onRevealedChange?.(next)\n  }\n  return [current, setRevealed] as const\n}\n\n/**\n * The button that reveals is gone once it has been pressed, which would drop keyboard focus to the top of the page.\n * Focus moves to the revealed content instead, but only when it was a press that revealed it.\n */\nfunction useFocusOnReveal(revealed: boolean) {\n  const target = useRef<HTMLElement | null>(null)\n  const pressed = useRef(false)\n  useEffect(() => {\n    if (!revealed || !pressed.current) return\n    pressed.current = false\n    target.current?.focus()\n  }, [revealed])\n  return { target, markPressed: () => (pressed.current = true) }\n}\n\n/** The fade and the button only make sense when something really runs past the collapsed height. */\nfunction useOverflowing(node: HTMLElement | null, collapsedHeight: number) {\n  const [overflowing, setOverflowing] = useState(true)\n  useEffect(() => {\n    if (!node || typeof ResizeObserver === \"undefined\") return\n    const inner = node.firstElementChild\n    const measure = () => setOverflowing((inner?.scrollHeight ?? node.scrollHeight) > collapsedHeight + 1)\n    const observer = new ResizeObserver(measure)\n    observer.observe(inner ?? node)\n    return () => observer.disconnect()\n  }, [node, collapsedHeight])\n  return overflowing\n}\n\nexport { previewOfText, useFocusOnReveal, useOverflowing, useReveal }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}