{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sidenotes",
  "type": "registry:ui",
  "title": "Sidenotes",
  "description": "Numbered references whose notes sit in the margin beside their line, and fold into inline disclosures on narrow containers.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/sidenotes.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type SidenotesSide = \"right\" | \"left\";\nexport type SidenotesNumbering = \"decimal\" | \"lower-alpha\" | \"lower-roman\";\ntype Layout = \"margin\" | \"inline\";\n\nexport interface SidenotesProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Container width (px) from which notes move into the margin. Narrower containers show inline disclosures. */\n  breakpoint?: number;\n  /** Width of the margin column in px. */\n  gutter?: number;\n  /** Space between the text column and the margin column in px. */\n  gap?: number;\n  /** Which margin holds the notes. */\n  side?: SidenotesSide;\n  /** Counter style shared by references and notes. */\n  numbering?: SidenotesNumbering;\n  /** Minimum vertical space between stacked notes in px. */\n  spacing?: number;\n  /** Highlight and focus colour (any CSS colour). */\n  accent?: string;\n}\n\nexport interface SidenoteProps extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /** The note itself. Phrasing content (text, links, <em>…) — it is rendered inside the paragraph. */\n  note: React.ReactNode;\n  /** The referenced words in the body text. */\n  children: React.ReactNode;\n  /** Start expanded when the container is narrow. */\n  defaultOpen?: boolean;\n}\n\ntype Entry = { anchor: HTMLElement; note: HTMLElement };\n\n/**\n * Mutable registry of mounted sidenotes. Positions are written straight to the DOM — never React state.\n * It also numbers the notes in document order (the same order the CSS counter uses) so each reference\n * button gets an accessible name like \"Note 3\".\n */\nclass NoteRegistry {\n  private entries = new Map<string, Entry>();\n  private numbers = new Map<string, number>();\n  private subscribers = new Set<() => void>();\n  private layoutListener: () => void = () => {};\n  add(id: string, entry: Entry) {\n    this.entries.set(id, entry);\n    this.renumber();\n  }\n  remove(id: string) {\n    this.entries.delete(id);\n    this.renumber();\n  }\n  list() {\n    return Array.from(this.entries.values());\n  }\n  numberOf(id: string) {\n    return this.numbers.get(id) ?? 0;\n  }\n  /** For useSyncExternalStore: notified whenever the numbering may have changed. */\n  subscribe = (callback: () => void) => {\n    this.subscribers.add(callback);\n    return () => {\n      this.subscribers.delete(callback);\n    };\n  };\n  /** The provider's layout pass, run whenever a note mounts or unmounts. */\n  listen(listener: () => void) {\n    this.layoutListener = listener;\n    return () => {\n      this.layoutListener = () => {};\n    };\n  }\n  private renumber() {\n    const ordered = Array.from(this.entries).sort(([, a], [, b]) =>\n      a.anchor.compareDocumentPosition(b.anchor) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,\n    );\n    this.numbers = new Map(ordered.map(([id], i) => [id, i + 1]));\n    this.subscribers.forEach((callback) => callback());\n    this.layoutListener();\n  }\n}\n\nconst noopSubscribe = () => () => {};\n\ninterface SidenotesContextValue {\n  layout: Layout;\n  side: SidenotesSide;\n  activeId: string | null;\n  setActiveId: (id: string | null) => void;\n  registry: NoteRegistry | null;\n}\n\nconst SidenotesContext = React.createContext<SidenotesContextValue>({\n  layout: \"inline\",\n  side: \"right\",\n  activeId: null,\n  setActiveId: () => {},\n  registry: null,\n});\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/**\n * Provider for editorial sidenotes. On wide containers each note sits in the margin, aligned with the\n * line of its reference; notes that would overlap are pushed down so they stack. On narrow containers\n * (measured with ResizeObserver, so it responds to the container, not the viewport) every reference\n * becomes a disclosure button that expands its note inline. Numbering uses a CSS counter, so the\n * reference marks and the notes always agree — on the server too.\n */\nexport function Sidenotes({\n  breakpoint = 720,\n  gutter = 200,\n  gap = 40,\n  side = \"right\",\n  numbering = \"decimal\",\n  spacing = 12,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  children,\n  ...props\n}: SidenotesProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const [registry] = React.useState(() => new NoteRegistry());\n  const [layout, setLayout] = React.useState<Layout>(\"inline\");\n  const [activeId, setActiveId] = React.useState<string | null>(null);\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n\n    const desiredLayout = (): Layout => (root.getBoundingClientRect().width >= breakpoint ? \"margin\" : \"inline\");\n\n    /** Aligns every note with its reference line, then pushes overlapping notes down. */\n    const position = () => {\n      const entries = registry.list();\n      if (layout !== \"margin\") {\n        for (const { note } of entries) {\n          note.style.top = \"\";\n          note.style.left = \"\";\n          note.style.width = \"\";\n        }\n        root.style.minHeight = \"\";\n        return;\n      }\n      const rootRect = root.getBoundingClientRect();\n      // Rects include CSS transforms (e.g. a scaled preview); offsets don't. Convert back to layout px.\n      const scale = root.offsetWidth ? rootRect.width / root.offsetWidth : 1;\n      const items = entries\n        .map((entry) => {\n          const lines = entry.anchor.getClientRects();\n          const line = lines[lines.length - 1] ?? entry.anchor.getBoundingClientRect();\n          return { entry, desired: (line.top - rootRect.top) / scale };\n        })\n        .sort((a, b) => a.desired - b.desired);\n\n      let cursor = -Infinity;\n      let bottom = 0;\n      for (const { entry, desired } of items) {\n        const { note } = entry;\n        const top = Math.max(desired, cursor);\n        const parent = (note.offsetParent as HTMLElement | null) ?? root;\n        const parentRect = parent.getBoundingClientRect();\n        // Offsets of the note's containing block relative to the provider, in layout px.\n        const dx = (parentRect.left - rootRect.left) / scale + parent.clientLeft;\n        const dy = (parentRect.top - rootRect.top) / scale + parent.clientTop;\n        const x = side === \"right\" ? root.offsetWidth - gutter : 0;\n        note.style.width = `${gutter}px`;\n        note.style.left = `${round(x - dx)}px`;\n        note.style.top = `${round(top - dy)}px`;\n        const height = note.offsetHeight;\n        cursor = top + height + spacing;\n        bottom = Math.max(bottom, top + height);\n      }\n      // Keep a long final note from spilling out of the article.\n      const minHeight = `${Math.ceil(bottom)}px`;\n      if (root.style.minHeight !== minHeight) root.style.minHeight = minHeight;\n    };\n\n    const initial = desiredLayout();\n    if (initial !== layout) {\n      // Layout depends on the measured container width, which only exists in the browser.\n      setLayout(initial);\n      return;\n    }\n    position();\n\n    let frame = 0;\n    const schedule = () => {\n      if (frame) return;\n      frame = requestAnimationFrame(() => {\n        frame = 0;\n        const next = desiredLayout();\n        if (next !== layout) setLayout(next);\n        else position();\n      });\n    };\n\n    const stopListening = registry.listen(() => {\n      schedule();\n    });\n    const ro = new ResizeObserver(schedule);\n    ro.observe(root);\n    for (const { note } of registry.list()) ro.observe(note);\n    let disposed = false;\n    document.fonts?.ready.then(() => {\n      if (!disposed) schedule();\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(frame);\n      ro.disconnect();\n      stopListening();\n    };\n  }, [registry, layout, breakpoint, gutter, side, spacing, children]);\n\n  const margin = layout === \"margin\";\n  const value: SidenotesContextValue = { layout, side, activeId, setActiveId, registry };\n\n  return (\n    <SidenotesContext.Provider value={value}>\n      <div\n        ref={rootRef}\n        data-layout={layout}\n        {...props}\n        className={`relative [counter-reset:sidenote] ${className}`}\n        style={{\n          [\"--sn-accent\" as string]: accent,\n          [\"--sn-numbering\" as string]: numbering,\n          paddingRight: margin && side === \"right\" ? gutter + gap : undefined,\n          paddingLeft: margin && side === \"left\" ? gutter + gap : undefined,\n          ...style,\n        }}\n      >\n        {children}\n      </div>\n    </SidenotesContext.Provider>\n  );\n}\n\nconst ring =\n  \"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sn-accent)]\";\n\n/**\n * A reference in the body text plus its note. Use inside <Sidenotes>. In the margin layout the\n * numbered mark is a button described by its note (aria-describedby); in the inline layout it is a\n * disclosure button (aria-expanded + aria-controls) that opens the note below the line.\n */\nexport function Sidenote({\n  note,\n  children,\n  defaultOpen = false,\n  className = \"\",\n  onPointerEnter,\n  onPointerLeave,\n  ...props\n}: SidenoteProps) {\n  const { layout, side, activeId, setActiveId, registry } = React.useContext(SidenotesContext);\n  const id = React.useId();\n  const noteId = `${id}-note`;\n  const [open, setOpen] = React.useState(defaultOpen);\n  // Document-order number for the accessible name; the visible digit comes from the CSS counter.\n  const number = React.useSyncExternalStore(\n    registry ? registry.subscribe : noopSubscribe,\n    () => registry?.numberOf(id) ?? 0,\n    () => 0,\n  );\n  const anchorRef = React.useRef<HTMLSpanElement>(null);\n  const noteRef = React.useRef<HTMLSpanElement>(null);\n\n  React.useLayoutEffect(() => {\n    const anchor = anchorRef.current;\n    const noteEl = noteRef.current;\n    if (!registry || !anchor || !noteEl) return;\n    registry.add(id, { anchor, note: noteEl });\n    return () => registry.remove(id);\n  }, [registry, id]);\n\n  const margin = layout === \"margin\";\n  const active = activeId === id;\n  const text = typeof children === \"string\" ? children.trimEnd() : null;\n  const cut = text ? text.lastIndexOf(\" \") : -1;\n  const expanded = margin || open;\n  const activate = () => setActiveId(id);\n  const deactivate = () => {\n    if (activeId === id) setActiveId(null);\n  };\n\n  const mark = (\n    <button\n      type=\"button\"\n      aria-label={number ? `Note ${number}` : undefined}\n      aria-describedby={margin ? noteId : undefined}\n      aria-expanded={margin ? undefined : open}\n      aria-controls={margin ? undefined : noteId}\n      onFocus={activate}\n      onBlur={deactivate}\n      onClick={() => {\n        if (margin) activate();\n        else setOpen((value) => !value);\n      }}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\" && !margin && open) {\n          event.preventDefault();\n          setOpen(false);\n        }\n      }}\n      className={`relative -top-[0.5em] ml-[0.06em] inline-flex min-w-[1.2em] cursor-pointer items-center justify-center rounded-[4px] px-[0.2em] align-baseline font-mono text-[0.62em] font-medium leading-[1.4] text-[var(--sn-accent)] no-underline transition-colors duration-200 motion-reduce:transition-none ${\n        !margin && open ? \"bg-[var(--sn-accent)] text-white dark:text-zinc-950\" : \"hover:bg-[color-mix(in_oklab,var(--sn-accent)_14%,transparent)]\"\n      } ${ring}`}\n    >\n      <span className=\"sr-only\">Note </span>\n      <span className=\"[counter-increment:sidenote] after:content-[counter(sidenote,var(--sn-numbering))]\" />\n    </button>\n  );\n\n  return (\n    <>\n      <span\n        ref={anchorRef}\n        {...props}\n        data-active={active || undefined}\n        className={`rounded-[3px] underline decoration-1 underline-offset-[0.22em] transition-[background-color,text-decoration-color] duration-200 [box-decoration-break:clone] [-webkit-box-decoration-break:clone] motion-reduce:transition-none ${\n          active\n            ? \"bg-[color-mix(in_oklab,var(--sn-accent)_14%,transparent)] decoration-[var(--sn-accent)]\"\n            : \"decoration-zinc-900/25 dark:decoration-white/25\"\n        } ${className}`}\n        onPointerEnter={(event) => {\n          onPointerEnter?.(event);\n          if (event.pointerType !== \"touch\") activate();\n        }}\n        onPointerLeave={(event) => {\n          onPointerLeave?.(event);\n          deactivate();\n        }}\n      >\n        {text !== null ? (\n          <>\n            {text.slice(0, cut + 1)}\n            {/* The last word and the mark never wrap apart. */}\n            <span className=\"whitespace-nowrap\">\n              {text.slice(cut + 1)}\n              {mark}\n            </span>\n          </>\n        ) : (\n          <>\n            {children}\n            {mark}\n          </>\n        )}\n      </span>\n      <span\n        ref={noteRef}\n        id={noteId}\n        role=\"note\"\n        data-active={active || undefined}\n        onPointerEnter={(event) => {\n          if (event.pointerType !== \"touch\") activate();\n        }}\n        onPointerLeave={deactivate}\n        className={\n          margin\n            ? `absolute top-0 z-[1] block text-[max(11px,0.78em)] leading-[1.55] font-normal tracking-normal transition-[color,border-color] duration-200 motion-reduce:transition-none ${\n                side === \"right\" ? \"border-l pl-3 text-left\" : \"border-r pr-3 text-right\"\n              } ${\n                active\n                  ? \"border-[var(--sn-accent)] text-zinc-900 dark:text-zinc-100\"\n                  : \"border-zinc-900/12 text-zinc-600 dark:border-white/12 dark:text-zinc-400\"\n              }`\n            : // Collapsed notes are display:none so they never break the line. Opening animates the rows\n              // from 0fr via @starting-style; closing keeps the box (discrete display) until 0fr.\n              `transition-[grid-template-rows,display] transition-discrete duration-300 ease-[cubic-bezier(0.2,0.7,0.2,1)] motion-reduce:transition-none ${\n                expanded ? \"grid grid-rows-[1fr] starting:grid-rows-[0fr]\" : \"hidden grid-rows-[0fr]\"\n              }`\n        }\n      >\n        <span className={margin ? \"block font-normal\" : \"block min-h-0 overflow-hidden\"}>\n          <span\n            className={\n              margin\n                ? \"block\"\n                : `my-2.5 block rounded-r-md border-l-2 border-[var(--sn-accent)] bg-zinc-900/[0.035] py-2 pl-3 pr-3 text-[0.82em] leading-[1.55] font-normal tracking-normal text-zinc-700 dark:bg-white/[0.04] dark:text-zinc-300`\n            }\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"mr-1.5 font-mono text-[0.85em] font-medium text-[var(--sn-accent)] before:content-[counter(sidenote,var(--sn-numbering))]\"\n            />\n            {note}\n          </span>\n        </span>\n      </span>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/sidenotes"
}
