"use client"; import { useEffect, useMemo, useState } from "react"; import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example"; import type { CustomJsxAuthoringCatalog, CustomJsxComponentApi, CustomJsxPropDescriptor, } from "@homarr/custom-widgets/catalog"; import { CustomWidgetCodeExample } from "./custom-widget-code"; import styles from "./custom-jsx-component-reference.module.css"; type ComponentCatalog = CustomJsxAuthoringCatalog; type CatalogComponent = CustomJsxComponentApi; type CatalogProp = CustomJsxPropDescriptor; const PAGE_SIZE = 40; export function CustomJsxComponentReference() { return ; } function ComponentReferenceFallback() { return (
Loading the Custom JSX component catalog…
); } function CustomJsxComponentReferenceClient() { const catalogUrl = "/custom-widgets/component-catalog-v1.json"; const [catalog, setCatalog] = useState(null); const [loadError, setLoadError] = useState(null); const [query, setQuery] = useState(""); const [packageFilter, setPackageFilter] = useState("all"); const [categoryFilter, setCategoryFilter] = useState("all"); const [safetyFilter, setSafetyFilter] = useState("available"); const [bindableOnly, setBindableOnly] = useState(false); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [expanded, setExpanded] = useState>(() => new Set()); const [showGlobalProps, setShowGlobalProps] = useState(false); useEffect(() => { const controller = new AbortController(); setLoadError(null); void fetch(catalogUrl, { signal: controller.signal }) .then(async (response) => { if (!response.ok) throw new Error(`The catalog request returned HTTP ${response.status}.`); return (await response.json()) as ComponentCatalog; }) .then((value) => { if (!Array.isArray(value.components) || !Array.isArray(value.types) || !Array.isArray(value.globalProps)) { throw new Error("The catalog response has an unsupported shape."); } setCatalog(value); }) .catch((error: unknown) => { if (controller.signal.aborted) return; setLoadError(error instanceof Error ? error.message : "The component catalog could not be loaded."); }); return () => controller.abort(); }, [catalogUrl]); const categories = useMemo( () => [...new Set(catalog?.components.map((component) => component.category) ?? [])].sort(), [catalog], ); const packages = useMemo( () => [...new Set(catalog?.components.map((component) => component.package) ?? [])].sort(), [catalog], ); const filteredComponents = useMemo(() => { if (!catalog) return []; const normalizedQuery = query.trim().toLocaleLowerCase(); return catalog.components.filter((component) => { if (packageFilter !== "all" && component.package !== packageFilter) return false; if (categoryFilter !== "all" && component.category !== categoryFilter) return false; if (safetyFilter === "available" && component.safety === "denied") return false; if (safetyFilter !== "all" && safetyFilter !== "available" && component.safety !== safetyFilter) return false; if (bindableOnly && !component.bind) return false; if (!normalizedQuery) return true; return [ component.name, component.package, component.category, component.description, component.deniedReason, ...component.subcomponents, ...component.props.map((prop) => prop.name), ].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery)); }); }, [bindableOnly, catalog, categoryFilter, packageFilter, query, safetyFilter]); const visibleComponents = filteredComponents.slice(0, visibleCount); useEffect(() => { setVisibleCount(PAGE_SIZE); }, [bindableOnly, categoryFilter, packageFilter, query, safetyFilter]); useEffect(() => { if (!catalog) return; const revealHashTarget = () => { const hash = decodeLocationHash(window.location.hash); const component = catalog.components.find((entry) => componentAnchor(entry.name) === hash); if (!component) return; if (!document.getElementById(hash)) { setQuery(component.name); setPackageFilter("all"); setCategoryFilter("all"); setSafetyFilter(component.safety === "denied" ? "all" : "available"); setBindableOnly(false); setVisibleCount(PAGE_SIZE); } setExpanded((current) => new Set([...current, component.name])); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => document.getElementById(hash)?.scrollIntoView({ block: "start" })); }); }; revealHashTarget(); window.addEventListener("hashchange", revealHashTarget); return () => window.removeEventListener("hashchange", revealHashTarget); }, [catalog]); if (loadError) { return (
Component catalog unavailable. {loadError} Open the raw catalog to inspect the generated data directly.
); } if (!catalog) return ; return (

Release-matched static catalog

Explore {catalog.components.length} Custom JSX components

This browser reads the same versioned JSON artifact used by offline authoring. It never imports or executes the listed component objects.

Open raw JSON
Catalog schema
v{catalog.schemaVersion}
Custom Widget
{catalog.customWidgetVersion}
Mantine
{catalog.mantineVersion}
Blocked capabilities
{catalog.blockedCapabilities.length}

Shared serializable props

Every available component also receives these {catalog.globalProps.length} generated global, ARIA, data, and safe style props. Component cards below list only their component-specific additions.

{showGlobalProps && (
)}
{filteredComponents.length} matching component{filteredComponents.length === 1 ? "" : "s"} {(query || packageFilter !== "all" || categoryFilter !== "all" || safetyFilter !== "available" || bindableOnly) && ( )}
{filteredComponents.length === 0 ? (
No component matches these filters.
) : (
{visibleComponents.map((component) => { const isExpanded = expanded.has(component.name); return (

{component.name}

#
{component.package} {humanize(component.category)} {component.safety} {component.bind && bind: {component.bind.type}}
{(component.description || component.deniedReason) && (

{component.deniedReason ?? component.description}

)} {isExpanded && ( )}
); })}
)} {visibleCount < filteredComponents.length && ( )}
); } function decodeLocationHash(hash: string) { const value = hash.slice(1); try { return decodeURIComponent(value); } catch { return value; } } function ComponentDetails({ id, component, types }: { id: string; component: CatalogComponent; types: string[] }) { return (
Component contract
{component.bind ? (

bind stores a temporary {component.bind.type} input. Initialize it with{" "} {component.bind.initialProp}.

) : (

This component does not expose a temporary-input adapter.

)} {component.subcomponents.length > 0 && (

Subcomponents:{" "} {component.subcomponents.map((name, index) => ( {index > 0 && ", "} {name} ))}

)} {component.blockedProps.length > 0 && (
    {component.blockedProps.map((prop) => (
  • {prop.name} is blocked: {prop.reason}
  • ))}
)} {component.accessibilityRequirements.length > 0 && (
    {component.accessibilityRequirements.map((requirement) => (
  • {requirement}
  • ))}
)} Open upstream documentation
Component props ({component.props.length})
{component.props.length === 0 ? (

No component-specific props are exposed.

) : ( )}
Raw component JSON
); } function PropTable({ props, types }: { props: CatalogProp[]; types: string[] }) { return (
{props.map((prop) => ( ))}
Prop Type Required Known values Description
{prop.name} {types[prop.typeRef] ?? "unknown"} {prop.required ? Yes : "No"} {prop.literalValues?.length ? ( {prop.literalValues.map(formatLiteral).join(" | ")} ) : ( — )} {prop.description ?? —}
); } function componentAnchor(name: string) { return `custom-jsx-component-${name .replaceAll(".", "-") .replace(/([a-z0-9])([A-Z])/g, "$1-$2") .toLowerCase()}`; } function humanize(value: string) { return value.replaceAll("-", " ").replace(/\b\w/g, (character) => character.toUpperCase()); } function formatLiteral(value: string | number | boolean | null) { return typeof value === "string" ? JSON.stringify(value) : String(value); }