{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "accordion", "title": "Accordion", "description": "Collapsible sections with rough chevrons and hand-drawn separators.", "dependencies": [ "roughjs" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/use-rough.json", "utils", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "https://www.bydefaulthuman.fun/r/rough-lib.json" ], "files": [ { "path": "registry/new-york/ui/accordion.tsx", "content": "\"use client\";\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n type HTMLAttributes,\n} from \"react\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport { cn } from \"@/lib/utils\";\nimport {\n CrumbleContext,\n randomSeed,\n resolveRoughVars,\n stableSeed,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\n// ─── Context ────────────────────────────────────────────────────────────────\n\ninterface AccordionContextValue {\n animateOnHover: boolean;\n multiple: boolean;\n openItems: Set;\n theme: CrumbleTheme;\n toggle: (value: string) => void;\n}\n\nconst AccordionContext = createContext({\n animateOnHover: true,\n multiple: false,\n openItems: new Set(),\n theme: \"pencil\",\n toggle: () => {},\n});\n\n// ─── Accordion (root) ────────────────────────────────────────────────────────\n\nexport interface AccordionProps\n extends HTMLAttributes, CrumbleColorProps {\n animateOnHover?: boolean;\n defaultValue?: string | string[];\n multiple?: boolean;\n onValueChange?: (value: string | string[]) => void;\n theme?: CrumbleTheme;\n}\n\nexport function Accordion({\n animateOnHover = true,\n children,\n className,\n defaultValue,\n fill,\n multiple = false,\n onValueChange,\n stroke,\n strokeMuted,\n theme: themeProp,\n ...props\n}: AccordionProps) {\n const { theme: contextTheme } = useContext(CrumbleContext);\n const theme = themeProp ?? contextTheme;\n\n const [openItems, setOpenItems] = useState>(() => {\n if (!defaultValue) return new Set();\n return new Set(Array.isArray(defaultValue) ? defaultValue : [defaultValue]);\n });\n\n const toggle = useCallback(\n (value: string) => {\n setOpenItems((prev) => {\n const next = new Set(prev);\n if (next.has(value)) {\n next.delete(value);\n } else {\n if (!multiple) next.clear();\n next.add(value);\n }\n onValueChange?.(\n multiple ? Array.from(next) : (Array.from(next)[0] ?? \"\"),\n );\n return next;\n });\n },\n [multiple, onValueChange],\n );\n\n const roughStyle = resolveRoughVars({ stroke, strokeMuted, fill });\n\n return (\n \n \n {children}\n \n \n );\n}\n\n// ─── AccordionItem ───────────────────────────────────────────────────────────\n//\n// Draws a full rough rectangle border around the entire item (trigger + content).\n// The border re-seeds on hover for the \"re-sketching\" feel.\n// When open, a light hachure fill makes the active item visually pop.\n\nexport interface AccordionItemProps extends HTMLAttributes {\n value: string;\n}\n\nexport function AccordionItem({\n children,\n className,\n value,\n ...props\n}: AccordionItemProps) {\n const { openItems, theme } = useContext(AccordionContext);\n const isOpen = openItems.has(value);\n\n const containerRef = useRef(null);\n const borderSvgRef = useRef(null);\n const { drawRect } = useRough({\n variant: \"interactive\",\n stableId: `accordion-border-${value}-${isOpen ? \"open\" : \"closed\"}`,\n svgRef: borderSvgRef,\n theme,\n });\n\n const drawBorder = useCallback(\n (reseed = false) => {\n const container = containerRef.current;\n const svg = borderSvgRef.current;\n if (!container || !svg) return;\n\n const w = container.offsetWidth;\n const h = container.offsetHeight;\n if (w === 0 || h === 0) return;\n\n svg.replaceChildren();\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"viewBox\", `0 0 ${w} ${h}`);\n\n // Stable seed so the border doesn't jump on every render,\n // but re-seeds on hover for the hand-drawn re-sketch feel.\n const extraSeed = reseed ? { seed: randomSeed() } : {};\n\n // \"interactive\" variant gives the most aggressive roughness.\n const opts = {\n stroke: \"var(--cr-stroke, currentColor)\",\n strokeWidth: theme === \"crayon\" ? 2.5 : theme === \"ink\" ? 1.8 : 1.5,\n roughness: theme === \"crayon\" ? 3.2 : theme === \"ink\" ? 1.0 : 2.0,\n bowing: theme === \"crayon\" ? 2.5 : theme === \"ink\" ? 0.8 : 1.6,\n // Hachure fill on the open item so it reads as \"active\".\n fill: isOpen ? \"var(--cr-fill, currentColor)\" : \"none\",\n fillStyle: \"hachure\",\n fillWeight: theme === \"crayon\" ? 1.0 : 0.5,\n hachureGap: theme === \"pencil\" ? 9 : theme === \"crayon\" ? 7 : 11,\n hachureAngle: -41,\n ...extraSeed,\n };\n\n // Inset so the wobbly stroke doesn't clip at the container edge.\n const pad = theme === \"crayon\" ? 4 : 3;\n const rect = drawRect(pad, pad, w - pad * 2, h - pad * 2, opts);\n if (!rect) return;\n // `fillOpacity` is not in rough's Options type — patch it as an SVG\n // presentation attribute on the generated fill paths after drawing.\n if (isOpen) {\n rect.querySelectorAll(\"path\").forEach((p) => {\n if (p.getAttribute(\"fill\") && p.getAttribute(\"fill\") !== \"none\") {\n p.setAttribute(\"fill-opacity\", \"0.06\");\n }\n });\n }\n svg.appendChild(rect);\n },\n [drawRect, isOpen, theme],\n );\n\n useEffect(() => {\n const id = requestAnimationFrame(() => drawBorder());\n return () => cancelAnimationFrame(id);\n }, [drawBorder]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n const ro = new ResizeObserver(() => drawBorder());\n ro.observe(container);\n return () => ro.disconnect();\n }, [drawBorder]);\n\n return (\n drawBorder(true)}\n onMouseLeave={() => drawBorder(false)}\n {...props}\n >\n {/* Rough border — sits behind all children */}\n \n {children}\n \n );\n}\n\n// ─── AccordionTrigger ────────────────────────────────────────────────────────\n//\n// Larger chevron (24×24) with \"interactive\" roughness.\n// A small left-side tick mark gives a hand-annotated margin feel.\n\nexport interface AccordionTriggerProps extends HTMLAttributes {\n value: string;\n}\n\nexport function AccordionTrigger({\n children,\n className,\n value,\n ...props\n}: AccordionTriggerProps) {\n const { animateOnHover, openItems, theme, toggle } =\n useContext(AccordionContext);\n const isOpen = openItems.has(value);\n\n const chevronRef = useRef(null);\n const tickRef = useRef(null);\n const { drawLine: drawChevronLine } = useRough({\n variant: \"interactive\",\n stableId: `chev-${value}`,\n svgRef: chevronRef,\n theme,\n });\n const { drawLine: drawTickLine } = useRough({\n variant: \"border\",\n stableId: `tick-${value}`,\n svgRef: tickRef,\n theme,\n });\n\n // ── Chevron ──────────────────────────────────────────────────────────────\n\n const drawChevron = useCallback(\n (reseed = false) => {\n const svg = chevronRef.current;\n if (!svg) return;\n\n svg.replaceChildren();\n svg.setAttribute(\"width\", \"24\");\n svg.setAttribute(\"height\", \"24\");\n svg.setAttribute(\"viewBox\", \"0 0 24 24\");\n\n const mkSeed = (suffix: string) =>\n reseed ? randomSeed() : stableSeed(`chev-${suffix}-${value}`);\n\n const baseOpts = {\n stroke: \"currentColor\",\n strokeWidth: theme === \"crayon\" ? 2.4 : theme === \"ink\" ? 2.0 : 1.6,\n roughness: theme === \"crayon\" ? 3.0 : theme === \"ink\" ? 0.9 : 2.2,\n bowing: theme === \"crayon\" ? 2.8 : theme === \"ink\" ? 0.7 : 1.8,\n };\n\n if (isOpen) {\n const ul = drawChevronLine(3, 16, 12, 7, {\n ...baseOpts,\n seed: mkSeed(\"ul\"),\n });\n const ur = drawChevronLine(12, 7, 21, 16, {\n ...baseOpts,\n seed: mkSeed(\"ur\"),\n strokeWidth: (baseOpts.strokeWidth ?? 1.6) * 0.9,\n });\n if (ul) svg.appendChild(ul);\n if (ur) svg.appendChild(ur);\n } else {\n const dl = drawChevronLine(3, 8, 12, 17, {\n ...baseOpts,\n seed: mkSeed(\"dl\"),\n });\n const dr = drawChevronLine(12, 17, 21, 8, {\n ...baseOpts,\n seed: mkSeed(\"dr\"),\n strokeWidth: (baseOpts.strokeWidth ?? 1.6) * 0.9,\n });\n if (dl) svg.appendChild(dl);\n if (dr) svg.appendChild(dr);\n }\n },\n [drawChevronLine, isOpen, theme, value],\n );\n\n // ── Left tick mark ────────────────────────────────────────────────────────\n // A rough vertical bar on the left edge — like a pencil annotation in a\n // notebook margin. Brighter when the item is open.\n\n const drawTick = useCallback(\n (reseed = false) => {\n const svg = tickRef.current;\n if (!svg) return;\n\n svg.replaceChildren();\n svg.setAttribute(\"width\", \"6\");\n svg.setAttribute(\"height\", \"22\");\n svg.setAttribute(\"viewBox\", \"0 0 6 22\");\n\n const tickEl = drawTickLine(3, 1, 3, 21, {\n seed: reseed ? randomSeed() : stableSeed(`tick-${value}-${isOpen}`),\n stroke: isOpen\n ? \"var(--cr-stroke, currentColor)\"\n : \"var(--cr-stroke-muted, currentColor)\",\n strokeWidth: theme === \"crayon\" ? 2.0 : theme === \"ink\" ? 1.4 : 1.2,\n roughness: theme === \"crayon\" ? 2.8 : theme === \"ink\" ? 0.8 : 2.0,\n });\n if (!tickEl) return;\n // `opacity` is not in rough's Options type — set it on the element directly.\n tickEl.setAttribute(\"opacity\", isOpen ? \"1\" : \"0.4\");\n svg.appendChild(tickEl);\n },\n [drawTickLine, isOpen, theme, value],\n );\n\n useEffect(() => {\n const id = requestAnimationFrame(() => {\n drawChevron();\n drawTick();\n });\n return () => cancelAnimationFrame(id);\n }, [drawChevron, drawTick]);\n\n return (\n toggle(value)}\n onMouseEnter={() => {\n if (animateOnHover) {\n drawChevron(true);\n drawTick(true);\n }\n }}\n onMouseLeave={() => {\n if (animateOnHover) {\n drawChevron(false);\n drawTick(false);\n }\n }}\n {...(props as HTMLAttributes)}\n >\n {/* Margin tick */}\n \n\n {children}\n\n {/* Chevron */}\n \n \n );\n}\n\n// ─── AccordionContent ────────────────────────────────────────────────────────\n//\n// The content area is indented to align with the trigger label (past the tick).\n// A short inset rough underline at the bottom acts as a \"end of section\" stroke.\n\nexport interface AccordionContentProps extends HTMLAttributes {\n value: string;\n}\n\nexport function AccordionContent({\n children,\n className,\n value,\n ...props\n}: AccordionContentProps) {\n const { openItems, theme } = useContext(AccordionContext);\n const isOpen = openItems.has(value);\n\n const containerRef = useRef(null);\n const underlineSvgRef = useRef(null);\n const { drawLine } = useRough({\n variant: \"border\",\n stableId: `accordion-underline-${value}`,\n svgRef: underlineSvgRef,\n theme,\n });\n\n const drawUnderline = useCallback(() => {\n const container = containerRef.current;\n const svg = underlineSvgRef.current;\n if (!container || !svg) return;\n\n const w = container.offsetWidth;\n svg.replaceChildren();\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", \"8\");\n svg.setAttribute(\"viewBox\", `0 0 ${w} 8`);\n\n // Short inset line so it reads as an annotation, not a full-width divider.\n const inset = Math.min(32, w * 0.08);\n const lineEl = drawLine(inset, 4, w - inset, 4, {\n stroke: \"var(--cr-stroke-muted, currentColor)\",\n strokeWidth: theme === \"crayon\" ? 1.8 : theme === \"ink\" ? 1.4 : 1.0,\n roughness: theme === \"crayon\" ? 2.4 : theme === \"ink\" ? 0.6 : 1.6,\n });\n if (!lineEl) return;\n // `opacity` is not in rough's Options type — set it on the element directly.\n lineEl.setAttribute(\"opacity\", \"0.45\");\n svg.appendChild(lineEl);\n }, [drawLine, theme]);\n\n useEffect(() => {\n if (!isOpen) return;\n const id = requestAnimationFrame(() => drawUnderline());\n return () => cancelAnimationFrame(id);\n }, [drawUnderline, isOpen]);\n\n useEffect(() => {\n if (!isOpen) return;\n const container = containerRef.current;\n if (!container) return;\n const ro = new ResizeObserver(() => drawUnderline());\n ro.observe(container);\n return () => ro.disconnect();\n }, [drawUnderline, isOpen]);\n\n if (!isOpen) return null;\n\n return (\n \n {children}\n {/* Rough \"end of section\" underline */}\n \n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/accordion.tsx" } ], "type": "registry:ui" }