{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sticky-note",
  "title": "Sticky note",
  "author": "Stickies <https://stickies.live>",
  "description": "A composable sticky note: header to drag, colours or states, words with lists, footer, and a corner to resize.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://stickies.live/r/stickies-format.json",
    "https://stickies.live/r/stickies-theme.json",
    "https://stickies.live/r/use-pointer-drag.json",
    "https://stickies.live/r/use-list-formatting.json"
  ],
  "files": [
    {
      "path": "registry/stickies/ui/sticky-note.tsx",
      "content": "\"use client\"\n\nimport { CircleCheck, X } from \"lucide-react\"\nimport { createContext, useCallback, useContext, useEffect, useRef, useState } from \"react\"\nimport { usePointerDrag } from \"@/registry/stickies/hooks/use-pointer-drag\"\nimport { useListFormatting } from \"@/registry/stickies/hooks/use-list-formatting\"\nimport {\n  changeNote,\n  colorOf,\n  shownColorOf,\n  STICKY_COLOR_NAMES,\n  STICKY_COLORS,\n  STICKY_MIN_SIZE,\n  STICKY_STATE_COLORS,\n  STICKY_STATE_NAMES,\n  STICKY_STATES,\n  type StickyColor,\n  type StickyNoteData,\n  type StickyState,\n  stateOf,\n} from \"@/registry/stickies/lib/stickies-format\"\nimport { cn } from \"@/lib/utils\"\n\ninterface StickyNoteContextValue {\n  note: StickyNoteData\n  /** A change to the words, colour or state: `updatedAt` moves on. */\n  edit: (fields: Partial<StickyNoteData>) => void\n  /** A change to where it is or its size: `updatedAt` stays. */\n  place: (fields: Partial<StickyNoteData>) => void\n  remove?: () => void\n  semantic: boolean\n  positioned: boolean\n  scale: number\n  setLifted: (lifted: boolean) => void\n  confirming: boolean\n  setConfirming: (confirming: boolean) => void\n  /** Whether a footer is there to ask before deleting. */\n  footers: number\n  setFooters: React.Dispatch<React.SetStateAction<number>>\n  rootRef: React.RefObject<HTMLDivElement | null>\n}\n\nconst StickyNoteContext = createContext<StickyNoteContextValue | null>(null)\n\nexport function useStickyNote() {\n  const context = useContext(StickyNoteContext)\n  if (!context) throw new Error(\"StickyNote parts must be inside <StickyNote>.\")\n  return context\n}\n\nexport interface StickyNoteProps extends React.ComponentProps<\"div\"> {\n  note: StickyNoteData\n  /** The note after a change, to store. Geometry changes keep `updatedAt`; others set it to now. */\n  onNoteChange: (next: StickyNoteData) => void\n  /** Without it, `StickyNoteClose` isn't shown. */\n  onDelete?: () => void\n  /** Placed by its `x` and `y` on a canvas. Off, it flows like any block, sized by `className`. */\n  positioned?: boolean\n  /** Colour comes from state: open is yellow, in progress blue, done green. The note keeps its own colour. */\n  semantic?: boolean\n  /** The canvas's zoom, so dragging keeps up with the pointer. */\n  scale?: number\n  /** Picked on a board: shown with a ring. */\n  selected?: boolean\n}\n\n/**\n * A sticky note: paper, a header to hold, words, and a corner to resize, after Murmur's.\n * Compose it from the parts below; leave any out.\n */\nexport function StickyNote({\n  note,\n  onNoteChange,\n  onDelete,\n  positioned = true,\n  semantic = false,\n  scale = 1,\n  selected = false,\n  className,\n  style,\n  children,\n  ...props\n}: StickyNoteProps) {\n  const [lifted, setLifted] = useState(false)\n  const [confirming, setConfirming] = useState(false)\n  const [footers, setFooters] = useState(0)\n  const rootRef = useRef<HTMLDivElement>(null)\n  const latest = useRef(note)\n  latest.current = note\n\n  const edit = useCallback((fields: Partial<StickyNoteData>) => onNoteChange(changeNote(latest.current, fields)), [onNoteChange])\n  const place = useCallback((fields: Partial<StickyNoteData>) => onNoteChange({ ...latest.current, ...fields }), [onNoteChange])\n\n  return (\n    <StickyNoteContext.Provider\n      value={{ note, edit, place, remove: onDelete, semantic, positioned, scale, setLifted, confirming, setConfirming, footers, setFooters, rootRef }}\n    >\n      <div\n        ref={rootRef}\n        data-slot=\"sticky-note\"\n        data-sticky-id={note.id}\n        data-sticky-color={shownColorOf(note, semantic)}\n        data-state={stateOf(note)}\n        data-lifted={lifted || undefined}\n        data-selected={selected || undefined}\n        className={cn(\n          \"group/sticky sticky-shadow flex flex-col overflow-hidden rounded-xl border border-sticky-edge bg-sticky-paper font-sans text-sticky-ink\",\n          positioned ? \"absolute\" : \"relative h-40 w-60\",\n          selected && \"ring-2 ring-ring/70 ring-offset-2 ring-offset-background\",\n          className,\n        )}\n        style={positioned ? { left: note.x, top: note.y, width: note.width, height: note.height, ...style } : style}\n        {...props}\n      >\n        {children}\n      </div>\n    </StickyNoteContext.Provider>\n  )\n}\n\n/** The strip along the top. On a canvas it's the handle: drag it, or focus it and use the arrow keys. */\nexport function StickyNoteHeader({ className, onKeyDown, ...props }: React.ComponentProps<\"div\">) {\n  const { note, place, positioned, scale, setLifted } = useStickyNote()\n  const start = useRef(note)\n  const { handlers } = usePointerDrag({\n    scale,\n    disabled: !positioned,\n    onDragStart: () => {\n      start.current = note\n      setLifted(true)\n    },\n    onDrag: ({ dx, dy }) => place({ x: Math.round(start.current.x + dx), y: Math.round(start.current.y + dy) }),\n    onDragEnd: () => setLifted(false),\n  })\n\n  return (\n    <div\n      data-slot=\"sticky-note-header\"\n      tabIndex={positioned ? 0 : undefined}\n      aria-label={positioned ? \"Move note: drag, or use the arrow keys\" : undefined}\n      onKeyDown={(e) => {\n        onKeyDown?.(e)\n        if (!positioned || e.defaultPrevented || e.target !== e.currentTarget) return\n        const step = e.shiftKey ? 40 : 8\n        const move = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key]\n        if (!move) return\n        e.preventDefault()\n        place({ x: note.x + move[0], y: note.y + move[1] })\n      }}\n      className={cn(\n        \"flex h-7 shrink-0 items-center gap-1.5 bg-sticky-header pr-1.5 pl-3 outline-none focus-visible:ring-2 focus-visible:ring-sticky-meta/50 focus-visible:ring-inset\",\n        positioned && \"cursor-grab touch-none active:cursor-grabbing\",\n        className,\n      )}\n      {...handlers}\n      {...props}\n    />\n  )\n}\n\nfunction Swatch({ color, checked, label, onSelect }: { color: StickyColor; checked: boolean; label: string; onSelect: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={checked}\n      aria-label={label}\n      title={label}\n      data-sticky-color={color}\n      onPointerDown={(e) => e.stopPropagation()}\n      onClick={onSelect}\n      className={cn(\n        \"size-3 rounded-full border border-sticky-edge bg-sticky-paper outline-none focus-visible:ring-2 focus-visible:ring-sticky-meta\",\n        checked && \"ring-1 ring-sticky-meta ring-offset-1 ring-offset-sticky-header\",\n      )}\n    />\n  )\n}\n\n/**\n * The colour picker, shown on hover or focus. With semantic notes on, it picks the state\n * instead, since colour comes from state.\n */\nexport function StickyNoteColors({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { note, edit, semantic } = useStickyNote()\n  const state = stateOf(note)\n  return (\n    <div\n      data-slot=\"sticky-note-colors\"\n      role=\"radiogroup\"\n      aria-label={semantic ? \"State\" : \"Colour\"}\n      className={cn(\n        \"flex items-center gap-1.5 opacity-0 transition-opacity duration-150 group-focus-within/sticky:opacity-100 group-hover/sticky:opacity-100\",\n        className,\n      )}\n      {...props}\n    >\n      {semantic\n        ? STICKY_STATES.map((s) => (\n            <Swatch key={s} color={STICKY_STATE_COLORS[s]} checked={state === s} label={STICKY_STATE_NAMES[s]} onSelect={() => edit({ state: s })} />\n          ))\n        : STICKY_COLORS.map((c) => (\n            <Swatch key={c} color={c} checked={colorOf(note) === c} label={STICKY_COLOR_NAMES[c]} onSelect={() => edit({ colorName: c })} />\n          ))}\n    </div>\n  )\n}\n\n/** A circle half filled: under way. */\nfunction HalfCircle({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} className={className} aria-hidden>\n      <circle cx=\"12\" cy=\"12\" r=\"9\" />\n      <path d=\"M12 3a9 9 0 0 1 0 18Z\" fill=\"currentColor\" stroke=\"none\" />\n    </svg>\n  )\n}\n\nconst STATE_ICONS: Record<Exclude<StickyState, \"open\">, React.ComponentType<{ className?: string }>> = {\n  progress: HalfCircle,\n  done: CircleCheck,\n}\n\n/** A mark for in progress or done, so state never rests on colour alone. Nothing for open. */\nexport function StickyNoteState({ className, ...props }: React.ComponentProps<\"span\">) {\n  const { note } = useStickyNote()\n  const state = stateOf(note)\n  if (state === \"open\") return null\n  const Icon = STATE_ICONS[state]\n  return (\n    <span\n      data-slot=\"sticky-note-state\"\n      role=\"img\"\n      aria-label={STICKY_STATE_NAMES[state]}\n      title={STICKY_STATE_NAMES[state]}\n      className={cn(\"grid size-5 place-items-center text-sticky-meta [&_svg]:size-3.5\", className)}\n      {...props}\n    >\n      <Icon />\n    </span>\n  )\n}\n\n/** Close. A note with words asks first, in its footer, when it has one. */\nexport function StickyNoteClose({ className, onClick, ...props }: React.ComponentProps<\"button\">) {\n  const { note, remove, footers, setConfirming } = useStickyNote()\n  if (!remove) return null\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"sticky-note-close\"\n      aria-label=\"Delete note\"\n      title=\"Delete note\"\n      onPointerDown={(e) => e.stopPropagation()}\n      onClick={(e) => {\n        onClick?.(e)\n        if (e.defaultPrevented) return\n        if (note.text.trim() && footers > 0) setConfirming(true)\n        else remove()\n      }}\n      className={cn(\n        \"grid size-5 place-items-center rounded-sm text-sticky-meta transition-colors duration-150 outline-none hover:text-sticky-ink focus-visible:ring-2 focus-visible:ring-sticky-meta [&_svg]:size-3.5\",\n        className,\n      )}\n      {...props}\n    >\n      <X strokeWidth={2} />\n    </button>\n  )\n}\n\nexport interface StickyNoteTextProps extends Omit<React.ComponentProps<\"textarea\">, \"value\" | \"onChange\"> {\n  /** \"- \" starts a bullet and Return continues a list. On by default. */\n  lists?: boolean\n}\n\n/** The words. Escape stops typing and leaves the note's header focused. */\nexport function StickyNoteText({ className, lists = true, placeholder = \"Write a note\", onKeyDown, ...props }: StickyNoteTextProps) {\n  const { note, edit, rootRef } = useStickyNote()\n  const formatted = useListFormatting(note.text, (text) => edit({ text }))\n  return (\n    <textarea\n      data-slot=\"sticky-note-text\"\n      ref={formatted.ref}\n      value={note.text}\n      placeholder={placeholder}\n      aria-label=\"Note\"\n      onChange={lists ? formatted.onChange : (e) => edit({ text: e.target.value })}\n      onKeyDown={(e) => {\n        onKeyDown?.(e)\n        if (e.defaultPrevented) return\n        if (e.key === \"Escape\") {\n          e.preventDefault()\n          const header = rootRef.current?.querySelector<HTMLElement>('[data-slot=\"sticky-note-header\"][tabindex]')\n          if (header) header.focus()\n          else e.currentTarget.blur()\n          return\n        }\n        if (lists) formatted.onKeyDown(e)\n      }}\n      className={cn(\n        \"min-h-0 flex-1 resize-none bg-transparent px-4 pt-3 text-[15px] leading-6 text-sticky-ink outline-none select-text placeholder:text-sticky-meta\",\n        className,\n      )}\n      {...props}\n    />\n  )\n}\n\n/** The strip along the bottom, for the author or date. It's also where deleting asks first. */\nexport function StickyNoteFooter({ className, children, ...props }: React.ComponentProps<\"div\">) {\n  const { remove, confirming, setConfirming, setFooters } = useStickyNote()\n  useEffect(() => {\n    setFooters((n) => n + 1)\n    return () => setFooters((n) => n - 1)\n  }, [setFooters])\n\n  return (\n    <div\n      data-slot=\"sticky-note-footer\"\n      className={cn(\"flex h-8 shrink-0 items-center justify-end gap-3 px-4 text-xs text-sticky-meta\", className)}\n      {...props}\n    >\n      {confirming && remove ? (\n        <>\n          <span className=\"mr-auto text-sticky-ink\">Delete this note?</span>\n          <button type=\"button\" onClick={() => setConfirming(false)} className=\"hover:text-sticky-ink\">\n            Keep\n          </button>\n          <button type=\"button\" onClick={remove} className=\"text-sticky-ink underline underline-offset-2\">\n            Delete\n          </button>\n        </>\n      ) : (\n        children\n      )}\n    </div>\n  )\n}\n\n/** Who wrote it, from the note's `author`. */\nexport function StickyNoteAuthor({ className, ...props }: React.ComponentProps<\"span\">) {\n  const { note } = useStickyNote()\n  if (!note.author) return null\n  return (\n    <span data-slot=\"sticky-note-author\" className={cn(\"mr-auto min-w-0 truncate text-sticky-ink/70\", className)} {...props}>\n      {note.author}\n    </span>\n  )\n}\n\nconst TIME = new Intl.DateTimeFormat(\"en-GB\", { hour: \"numeric\", minute: \"2-digit\", hour12: true })\nconst DAY = new Intl.DateTimeFormat(\"en-GB\", { day: \"numeric\", month: \"short\" })\n\n/** \"Today, 11:23am\", \"Yesterday, 4:05pm\", or \"12 Oct, 9:10am\". */\nexport function formatStickyDate(at: string | number, now = Date.now()): string {\n  const date = new Date(at)\n  if (Number.isNaN(date.getTime())) return \"\"\n  const time = TIME.format(date).replace(/\\s/g, \"\").toLowerCase()\n  const day = new Date(now)\n  day.setHours(0, 0, 0, 0)\n  const start = day.getTime()\n  if (date.getTime() >= start) return `Today, ${time}`\n  if (date.getTime() >= start - 86_400_000) return `Yesterday, ${time}`\n  return `${DAY.format(date)}, ${time}`\n}\n\n/** When it last changed, from `updatedAt`. Not in the default composition; add it to the footer. */\nexport function StickyNoteDate({ className, ...props }: React.ComponentProps<\"time\">) {\n  const { note } = useStickyNote()\n  const at = note.updatedAt ?? note.createdAt\n  if (!at) return null\n  return (\n    <time data-slot=\"sticky-note-date\" dateTime={at} className={cn(\"shrink-0 tabular-nums\", className)} suppressHydrationWarning {...props}>\n      {formatStickyDate(at)}\n    </time>\n  )\n}\n\n/** The bottom-right corner: drag to resize. */\nexport function StickyNoteResize({ className, ...props }: React.ComponentProps<\"div\">) {\n  const { note, place, positioned, scale, setLifted } = useStickyNote()\n  const start = useRef(note)\n  const { handlers } = usePointerDrag({\n    scale,\n    disabled: !positioned,\n    onDragStart: () => {\n      start.current = note\n      setLifted(true)\n    },\n    onDrag: ({ dx, dy }) =>\n      place({\n        width: Math.round(Math.max(STICKY_MIN_SIZE.width, start.current.width + dx)),\n        height: Math.round(Math.max(STICKY_MIN_SIZE.height, start.current.height + dy)),\n      }),\n    onDragEnd: () => setLifted(false),\n  })\n  if (!positioned) return null\n  return (\n    <div\n      data-slot=\"sticky-note-resize\"\n      aria-hidden\n      className={cn(\"absolute right-0 bottom-0 size-4 cursor-nwse-resize touch-none\", className)}\n      {...handlers}\n      {...props}\n    />\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "See https://stickies.live/docs#sticky-note for usage.",
  "categories": [
    "stickies"
  ],
  "type": "registry:ui"
}