{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "font-picker",
  "title": "Font Picker",
  "author": "Matthew Blode",
  "description": "A searchable picker for Google Fonts with live previews.",
  "dependencies": ["blode-icons-react"],
  "registryDependencies": [
    "@blode/button",
    "@blode/combobox",
    "@blode/spinner",
    "@blode/google-fonts"
  ],
  "files": [
    {
      "path": "ui/font-picker.tsx",
      "content": "\"use client\";\n\nimport { CircleAlertIcon } from \"blode-icons-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  fetchGoogleFonts,\n  GoogleFontsError,\n  loadGoogleFontPreview,\n} from \"@/lib/google-fonts\";\nimport type {\n  GoogleFont,\n  GoogleFontCategory,\n  GoogleFontsErrorCode,\n  GoogleFontSort,\n} from \"@/lib/google-fonts\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  Combobox,\n  ComboboxContent,\n  ComboboxEmpty,\n  ComboboxInput,\n  ComboboxItem,\n  ComboboxList,\n} from \"@/components/ui/combobox\";\nimport { Spinner } from \"@/components/ui/spinner\";\n\n/**\n * How far beyond the visible rows to start loading previews, so a row is already\n * in its own typeface by the time a scroll brings it into view.\n *\n * Previews are viewport-driven rather than capped at the top of the list. The\n * list is not virtualised — the full catalogue is ~1950 rows in the DOM against\n * roughly nine visible at a time — so a fixed \"first N\" cap leaves everything\n * the user actually scrolls to rendered in the interface font. An observer\n * instead ties the cost to what is on screen, which is a small constant no\n * matter how large the catalogue or how far down the user goes.\n */\nconst PREVIEW_ROOT_MARGIN = \"240px\";\n\ninterface FontPickerBaseProps extends Omit<React.ComponentProps<\"div\">, \"onChange\"> {\n  /** Keeps only these categories. Defaults to every category. */\n  categories?: GoogleFontCategory[];\n  /** Disables the picker. */\n  disabled?: boolean;\n  /** Id for the input, so an external label can point at it. */\n  id?: string;\n  /** Caps how many families are offered. */\n  limit?: number;\n  /** Called with the newly selected family name. */\n  onValueChange: (family: string) => void;\n  /** Order to request from the API. Defaults to `\"popularity\"`. */\n  sort?: GoogleFontSort;\n  /** Keeps only families covering every one of these subsets. */\n  subsets?: string[];\n  /** The selected family name, or an empty string for none. */\n  value: string;\n  /** Keeps only families offering every one of these variants. */\n  variants?: string[];\n}\n\n/**\n * Where the catalogue comes from. Exactly one of the three, so a picker that\n * can never populate does not type-check.\n */\ntype FontPickerSourceProps =\n  | {\n      /**\n       * A Google Fonts Developer API key, called straight from the browser.\n       * Public by design — it travels in the query string of a browser request —\n       * so restrict it by HTTP referrer in the Google Cloud Console. Prefer\n       * `endpoint` if you have a server to hide it behind.\n       */\n      apiKey: string;\n      endpoint?: never;\n      fonts?: never;\n    }\n  | {\n      apiKey?: never;\n      /**\n       * A URL on your own origin that proxies the Google Fonts Developer API.\n       * The key stays on your server and the response can be cached once for\n       * every visitor. It must answer with `{ items }` whose rows carry\n       * `category`, `family`, `subsets`, and `variants`.\n       */\n      endpoint: string;\n      fonts?: never;\n    }\n  | {\n      apiKey?: never;\n      endpoint?: never;\n      /**\n       * Supplies the catalogue directly and skips the network entirely. For\n       * tests, offline previews, and seeding a known list on the server.\n       */\n      fonts: GoogleFont[];\n    };\n\ntype FontPickerProps = FontPickerBaseProps & FontPickerSourceProps;\n\nconst ERROR_MESSAGES: Record<GoogleFontsErrorCode, string> = {\n  \"missing-key\": \"Add a Google Fonts API key or endpoint to load the font list.\",\n  network: \"Could not reach Google Fonts. Check your connection and try again.\",\n  \"rate-limited\": \"Google Fonts is rate limiting this key. Try again in a moment.\",\n  \"request-failed\": \"Google Fonts could not return the font list.\",\n};\n\nconst FontPicker = ({\n  apiKey,\n  categories,\n  className,\n  disabled,\n  endpoint,\n  fonts: fontsOverride,\n  id,\n  limit,\n  onValueChange,\n  sort = \"popularity\",\n  subsets,\n  value,\n  variants,\n  ...props\n}: FontPickerProps) => {\n  const [fetched, setFetched] = React.useState<GoogleFont[]>([]);\n  const [fetchError, setFetchError] = React.useState<GoogleFontsErrorCode | null>(null);\n  const [fetching, setFetching] = React.useState(true);\n  const [attempt, setAttempt] = React.useState(0);\n\n  // A supplied catalogue wins outright, so the fetch state is derived rather\n  // than mirrored — nothing can drift out of sync with the `fonts` prop.\n  const usingOverride = fontsOverride !== undefined;\n  const fonts = fontsOverride ?? fetched;\n  const loading = !usingOverride && fetching;\n  const errorCode = usingOverride ? null : fetchError;\n\n  // Array props are new objects on every render, so the effect keys off their\n  // contents rather than their identity.\n  const filterKey = JSON.stringify({ categories, limit, sort, subsets, variants });\n\n  React.useEffect(() => {\n    if (usingOverride) {\n      return;\n    }\n\n    const controller = new AbortController();\n    const options = JSON.parse(filterKey) as {\n      categories?: GoogleFontCategory[];\n      limit?: number;\n      sort: GoogleFontSort;\n      subsets?: string[];\n      variants?: string[];\n    };\n\n    const load = async () => {\n      setFetching(true);\n      setFetchError(null);\n      try {\n        const result = await fetchGoogleFonts({\n          ...options,\n          apiKey,\n          endpoint,\n          signal: controller.signal,\n        });\n        setFetched(result);\n        setFetching(false);\n      } catch (error) {\n        if (controller.signal.aborted) {\n          return;\n        }\n        setFetchError(error instanceof GoogleFontsError ? error.code : \"request-failed\");\n        setFetched([]);\n        setFetching(false);\n      }\n    };\n\n    void load();\n\n    return () => controller.abort();\n  }, [apiKey, attempt, endpoint, filterKey, usingOverride]);\n\n  const families = React.useMemo(() => fonts.map((font) => font.family), [fonts]);\n\n  // Which rows are on screen. Populated by the observer below rather than by\n  // slicing the list, so scrolling to row 900 previews row 900.\n  const [onScreen, setOnScreen] = React.useState<ReadonlySet<string>>(() => new Set());\n  const observerRef = React.useRef<IntersectionObserver | null>(null);\n  const familyOf = React.useRef(new WeakMap<Element, string>());\n\n  React.useEffect(() => {\n    const observer = new IntersectionObserver(\n      (entries) => {\n        setOnScreen((current) => {\n          const next = new Set(current);\n          let changed = false;\n          for (const entry of entries) {\n            const family = familyOf.current.get(entry.target);\n            if (!family) {\n              continue;\n            }\n            if (entry.isIntersecting) {\n              if (!next.has(family)) {\n                next.add(family);\n                changed = true;\n              }\n            } else if (next.delete(family)) {\n              changed = true;\n            }\n          }\n          return changed ? next : current;\n        });\n      },\n      { rootMargin: PREVIEW_ROOT_MARGIN },\n    );\n    observerRef.current = observer;\n    return () => {\n      observer.disconnect();\n      observerRef.current = null;\n    };\n  }, []);\n\n  // One stable callback per family, cached. A fresh closure per render would be\n  // a new ref identity, so React would detach and reattach every row on every\n  // render — and since detaching unobserves and reattaching observes, the\n  // observer would fire, set state, and render again, forever.\n  const itemRefs = React.useRef(new Map<string, React.RefCallback<HTMLElement>>());\n\n  const observeItem = React.useCallback((family: string) => {\n    const cache = itemRefs.current;\n    const cached = cache.get(family);\n    if (cached) {\n      return cached;\n    }\n    const ref: React.RefCallback<HTMLElement> = (node) => {\n      const observer = observerRef.current;\n      if (!(node && observer)) {\n        return;\n      }\n      familyOf.current.set(node, family);\n      observer.observe(node);\n      return () => {\n        // Unobserving cancels any pending notification, so a row that unmounts\n        // while still intersecting would otherwise stay in `onScreen` — and its\n        // stylesheet in `<head>` — for the life of the picker.\n        observer.unobserve(node);\n        setOnScreen((current) => {\n          if (!current.has(family)) {\n            return current;\n          }\n          const next = new Set(current);\n          next.delete(family);\n          return next;\n        });\n      };\n    };\n    cache.set(family, ref);\n    return ref;\n  }, []);\n\n  // The selected family is previewed whether or not its row is on screen: it is\n  // what the trigger and any sample text the consumer renders are set in, and\n  // the popup is closed most of the time.\n  const previewFamilies = React.useMemo(() => {\n    const next = new Set(onScreen);\n    if (value) {\n      next.add(value);\n    }\n    return next;\n  }, [onScreen, value]);\n\n  // Diffed rather than torn down and rebuilt, so a family that survives a scroll\n  // keeps its stylesheet instead of flashing back to the fallback.\n  const previewsRef = React.useRef(new Map<string, () => void>());\n\n  React.useEffect(() => {\n    const active = previewsRef.current;\n    for (const [family, release] of active) {\n      if (!previewFamilies.has(family)) {\n        release();\n        active.delete(family);\n      }\n    }\n    for (const family of previewFamilies) {\n      if (!active.has(family)) {\n        active.set(family, loadGoogleFontPreview(family));\n      }\n    }\n  }, [previewFamilies]);\n\n  React.useEffect(() => {\n    const active = previewsRef.current;\n    return () => {\n      for (const release of active.values()) {\n        release();\n      }\n      active.clear();\n    };\n  }, []);\n\n  if (errorCode) {\n    return (\n      <div\n        className={cn(\n          \"flex items-center gap-2 rounded-[var(--field-radius)] border border-destructive/40 bg-card px-3 py-2\",\n          className,\n        )}\n        data-slot=\"font-picker\"\n        role=\"alert\"\n        {...props}\n      >\n        <CircleAlertIcon className=\"size-4 shrink-0 text-destructive\" />\n        <span className=\"min-w-0 flex-1 text-sm\">{ERROR_MESSAGES[errorCode]}</span>\n        {errorCode !== \"missing-key\" && (\n          <Button\n            onClick={() => setAttempt((current) => current + 1)}\n            size=\"sm\"\n            type=\"button\"\n            variant=\"outline\"\n          >\n            Try again\n          </Button>\n        )}\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(\"flex flex-col gap-1.5\", className)} data-slot=\"font-picker\" {...props}>\n      <Combobox\n        disabled={disabled || loading}\n        items={families}\n        onValueChange={(next) => onValueChange(next ?? \"\")}\n        value={value || null}\n      >\n        <ComboboxInput\n          disabled={disabled || loading}\n          id={id}\n          placeholder={loading ? \"Loading fonts…\" : \"Search fonts\"}\n          // The selection reads in its own typeface too, not just the rows. Its\n          // stylesheet is always loaded — `previewFamilies` adds `value`\n          // unconditionally — so this holds with the popup closed.\n          style={\n            value\n              ? { fontFamily: `\"${value}\", var(--font-sans, ui-sans-serif), sans-serif` }\n              : undefined\n          }\n        />\n        <ComboboxContent>\n          <ComboboxEmpty>No fonts match that search.</ComboboxEmpty>\n          <ComboboxList>\n            {(family: string) => (\n              <ComboboxItem\n                key={family}\n                ref={observeItem(family)}\n                style={\n                  previewFamilies.has(family)\n                    ? { fontFamily: `\"${family}\", var(--font-sans, ui-sans-serif), sans-serif` }\n                    : undefined\n                }\n                value={family}\n              >\n                {family}\n              </ComboboxItem>\n            )}\n          </ComboboxList>\n        </ComboboxContent>\n      </Combobox>\n\n      {loading && (\n        <output className=\"flex items-center gap-2 text-muted-foreground text-sm\">\n          <Spinner size={14} />\n          Loading fonts…\n        </output>\n      )}\n\n      {!loading && families.length === 0 && (\n        <output className=\"text-muted-foreground text-sm\">No fonts match these filters.</output>\n      )}\n    </div>\n  );\n};\n\nexport { FontPicker };\nexport type { FontPickerProps };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
