{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "address-playground",
  "title": "Address Playground",
  "description": "A search page with no map: autocomplete in English and French, typed coordinates, every name a result carries with its language (Inuktitut syllabics beside the name where the record has them), its type, precision, match and source, and the nearest places of six categories around it. Calls /geocode/autocomplete (and /geocode/reverse for typed coordinates) and /geocode/nearby.",
  "dependencies": [
    "@unmap/geocoding"
  ],
  "registryDependencies": [
    "https://registry.unmap.dev/r/map-context.json",
    "https://registry.unmap.dev/r/geocoder.json",
    "card",
    "toggle-group",
    "badge",
    "button",
    "alert",
    "skeleton"
  ],
  "files": [
    {
      "path": "registry/blocks/address-playground/page.tsx",
      "content": "/**\n * /address-playground: the address playground block as a Next.js App Router page, full-bleed (the\n * block keeps its own gutter). There is no map on it, only search. The key comes from\n * NEXT_PUBLIC_UNMAP_KEY in .env.local; installing the block added the empty line, paste your key.\n */\nimport { AddressPlayground } from \"@/components/ui/address-playground\";\n\nexport const metadata = { title: \"Look up any place in Canada\" };\n\nexport default function AddressPlaygroundPage() {\n  return (\n    <main className=\"h-dvh\">\n      <AddressPlayground apiKey={process.env.NEXT_PUBLIC_UNMAP_KEY ?? \"\"} className=\"h-full\" />\n    </main>\n  );\n}\n",
      "type": "registry:page",
      "target": "app/address-playground/page.tsx"
    },
    {
      "path": "registry/blocks/address-playground/address-playground.tsx",
      "content": "\"use client\";\n\n/**\n * <AddressPlayground> - unmap search with no map: a search page in one component. Type an address,\n * a place or a pair of coordinates, choose a result, and see everything the record carries: every\n * name it has, each marked with its language (a name in syllabics set beside the name itself,\n * where the record has one), its type, precision, match and source as the API returns them, and\n * the nearest places of six categories around it. A nearby place opens the same way.\n *\n * The search box is the registry's geocoder in a map store with no map (`MapProvider` with\n * `container={null}`), so no MapLibre map is ever created and choosing a result flies nothing.\n * Each action is one request to the unmap API on your key: each pause in typing calls\n * /geocode/autocomplete (/geocode/reverse for typed coordinates), each example under the box calls\n * /geocode/autocomplete once, and each category calls /geocode/nearby. Nothing is requested until\n * the reader acts. Results are OpenStreetMap and Canadian open data, credited on the page.\n *\n * Installed by `npx shadcn add` with its page at app/address-playground/page.tsx. Edit freely.\n */\n\nimport { Fragment, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Geocoder as GeocoderClient, type GeocodeResult } from \"@unmap/geocoding\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { Geocoder } from \"@/components/ui/geocoder\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\";\nimport { MapProvider } from \"@/lib/map-context\";\n\n// --- address-playground helpers (kept identical across both lanes) ---\nexport type PlaygroundLang = \"en\" | \"fr\";\n\n/** Why a request failed, as the copy names it; the text is read in the language shown, not the one asked in. */\nexport type PlaygroundError = \"outside\" | \"busy\" | \"keyRefused\" | \"unavailable\" | \"failed\";\n\n/** The fields of a geocode result the page reads; `GeocodeResult` from @unmap/geocoding fits it. */\nexport interface PlaygroundPlace {\n  id?: string;\n  name?: string;\n  layer?: string;\n  lng?: number;\n  lat?: number;\n  source?: string;\n  source_id?: string;\n  match_type?: string;\n  precision?: string;\n  category?: string;\n  distance?: number;\n  names?: Record<string, string>;\n  address?: { housenumber?: string; street?: string; city?: string; region?: string; postcode?: string; unit?: string };\n}\n\n/** One language's name for a place, with the record's other names in that language. */\nexport interface NameRow {\n  /** The language tag, as the record keys it and as the page marks the text: `en`, `iu`, `iu-Latn`. */\n  code: string;\n  /** The language's name in the interface language. */\n  label: string;\n  text: string;\n  also: string[];\n}\n\n/** Where a reader gets an unmap key: the no-key state's first action. */\nexport const KEY_URL = \"https://unmap.dev/dashboard\";\n\n/** Where the search data is credited: OpenStreetMap's terms, and unmap's full list of sources. */\nexport const PLAYGROUND_CREDITS = {\n  osm: \"https://www.openstreetmap.org/copyright\",\n  attribution: { en: \"https://unmap.dev/attribution\", fr: \"https://unmap.dev/fr/attribution\" },\n};\n\n/** How many suggestions the search box asks for. */\nexport const SEARCH_LIMIT = 8;\n/** The nearby list: the nearest five within 2 km of the result. */\nexport const NEARBY_RADIUS = 2000;\nexport const NEARBY_LIMIT = 5;\n\n/** The two interface languages, each labelled in its own language. */\nexport const PLAYGROUND_LANGS: ReadonlyArray<{ code: PlaygroundLang; label: string }> = [\n  { code: \"en\", label: \"English\" },\n  { code: \"fr\", label: \"Français\" },\n];\n\n/** Categories the nearby list offers: canonical taxonomy ids the gateway accepts, with labels. */\nexport const PLAYGROUND_CATEGORIES = [\n  { id: \"health.pharmacy\", en: \"Pharmacy\", fr: \"Pharmacie\" },\n  { id: \"food.cafe\", en: \"Café\", fr: \"Café\" },\n  { id: \"shop.supermarket\", en: \"Grocery\", fr: \"Épicerie\" },\n  { id: \"transport.fuel\", en: \"Gas station\", fr: \"Station-service\" },\n  { id: \"education.school\", en: \"School\", fr: \"École\" },\n  { id: \"culture.library\", en: \"Library\", fr: \"Bibliothèque\" },\n] as const;\n\n/** Searches to try from the empty state, one autocomplete request each; `lang` marks the query's own language. */\nexport const PLAYGROUND_EXAMPLES: ReadonlyArray<{ query: string; lang?: string }> = [\n  { query: \"ᐃᖃᓗᐃᑦ\", lang: \"iu\" },\n  { query: \"Vieux-Montréal\", lang: \"fr\" },\n  { query: \"111 Wellington St Ottawa\", lang: \"en\" },\n];\n\nexport const PLAYGROUND_COPY = {\n  en: {\n    title: \"Look up any place in Canada\",\n    intro: \"Search an address, a place or a pair of coordinates, and see every name the record carries, in every language it has.\",\n    language: \"Language\",\n    placeholder: \"Search an address or a place\",\n    try: \"Try\",\n    coordinates: \"Coordinates work too, like 51.05,\\u00a0-114.07.\",\n    emptyTitle: \"Choose a result to see everything it carries.\",\n    emptyBody: \"Its names in each language the record has, Inuktitut syllabics included where present.\",\n    looking: (query: string) => `Looking up ${query}...`,\n    noMatch: (query: string) => `Nothing found for ${query}. Try another spelling, or type it in the search box.`,\n    names: \"Names\",\n    namesNone: \"This record has no names by language, only the one above.\",\n    also: \"Also known as\",\n    details: \"Details\",\n    fields: {\n      layer: \"Type\",\n      category: \"Category\",\n      precision: \"Precision\",\n      matchType: \"Match\",\n      source: \"Source\",\n      sourceId: \"Source ID\",\n      coordinates: \"Coordinates\",\n      id: \"ID\",\n    },\n    layers: {\n      address: \"Address\",\n      street: \"Street\",\n      locality: \"Locality\",\n      region: \"Region\",\n      poi: \"Place\",\n      legal_land: \"Legal land\",\n      well: \"Well\",\n      facility: \"Facility\",\n      parcel: \"Parcel\",\n    } as Record<string, string>,\n    nearby: \"Nearby\",\n    nearbyHint: \"The nearest five within 2 km.\",\n    category: \"Category\",\n    nearbyIdle: \"Pick a category to list what is near this place.\",\n    nearbyEmpty: \"Nothing of this kind within 2 km.\",\n    nearbyLoading: \"Searching nearby...\",\n    retry: \"Try again\",\n    failedTitle: \"That search did not work.\",\n    outside: \"That point is outside Canada. Search covers Canada.\",\n    busy: \"Too many requests. Try again in a moment.\",\n    keyRefused: \"The API key was refused. Check it and try again.\",\n    unavailable: \"The service didn't answer. Try again in a moment.\",\n    failed: \"The search failed. Try again.\",\n    announceNames: (languages: string) => ` Names in ${languages}.`,\n    announceNearby: (category: string, count: number, nearest: string, distance: string) =>\n      `${category}: ${count} within 2 km. Nearest: ${nearest}, ${distance}.`,\n    announceNearbyNone: (category: string) => `${category}: nothing within 2 km.`,\n    creditLead: \"Search data:\",\n    osm: \"OpenStreetMap contributors\",\n    creditMiddle: \"(ODbL) and Canadian open data. Each result names its source;\",\n    creditLink: \"full credits\",\n    noKeyTitle: \"Add your unmap key to start searching.\",\n    getKey: \"Get a key\",\n    noKey:\n      'No unmap key. Pass apiKey. On Next.js, set NEXT_PUBLIC_UNMAP_KEY in .env.local. On Nuxt, set NUXT_PUBLIC_UNMAP_KEY in .env. If you installed with unmap add, nuxt.config already declares runtimeConfig: { public: { unmapKey: \"\" } }; otherwise add it there. Then restart the dev server.',\n  },\n  fr: {\n    title: \"Trouvez n'importe quel lieu au Canada\",\n    intro: \"Cherchez une adresse, un lieu ou une paire de coordonnées, et voyez tous les noms de la fiche, dans chaque langue qu'elle contient.\",\n    language: \"Langue\",\n    placeholder: \"Chercher une adresse ou un lieu\",\n    try: \"Essayez\",\n    coordinates: \"Les coordonnées fonctionnent aussi, par exemple 51,05;\\u00a0-114,07.\",\n    emptyTitle: \"Choisissez un résultat pour voir tout ce qu'il contient.\",\n    emptyBody: \"Ses noms dans chaque langue de la fiche, y compris le syllabique inuktitut quand il y en a.\",\n    looking: (query: string) => `Recherche de ${query}...`,\n    noMatch: (query: string) => `Aucun résultat pour ${query}. Essayez une autre orthographe, ou tapez-le dans la recherche.`,\n    names: \"Noms\",\n    namesNone: \"Cette fiche n'a pas de noms par langue, seulement celui ci-dessus.\",\n    also: \"Aussi appelé\",\n    details: \"Détails\",\n    fields: {\n      layer: \"Type\",\n      category: \"Catégorie\",\n      precision: \"Précision\",\n      matchType: \"Correspondance\",\n      source: \"Source\",\n      sourceId: \"ID à la source\",\n      coordinates: \"Coordonnées\",\n      id: \"ID\",\n    },\n    layers: {\n      address: \"Adresse\",\n      street: \"Rue\",\n      locality: \"Localité\",\n      region: \"Région\",\n      poi: \"Lieu\",\n      legal_land: \"Terre légale\",\n      well: \"Puits\",\n      facility: \"Installation\",\n      parcel: \"Parcelle\",\n    } as Record<string, string>,\n    nearby: \"À proximité\",\n    nearbyHint: \"Les cinq plus proches à moins de 2 km.\",\n    category: \"Catégorie\",\n    nearbyIdle: \"Choisissez une catégorie pour voir ce qui se trouve près de ce lieu.\",\n    nearbyEmpty: \"Rien de ce type à moins de 2 km.\",\n    nearbyLoading: \"Recherche à proximité...\",\n    retry: \"Réessayer\",\n    failedTitle: \"Cette recherche n'a pas fonctionné.\",\n    outside: \"Ce point est hors du Canada. La recherche couvre le Canada.\",\n    busy: \"Trop de requêtes. Réessayez dans un instant.\",\n    keyRefused: \"La clé API a été refusée. Vérifiez-la et réessayez.\",\n    unavailable: \"Le service n'a pas répondu. Réessayez dans un instant.\",\n    failed: \"La recherche a échoué. Réessayez.\",\n    announceNames: (languages: string) => ` Noms en ${languages}.`,\n    announceNearby: (category: string, count: number, nearest: string, distance: string) =>\n      `${category} : ${count} à moins de 2 km. Le plus proche : ${nearest}, ${distance}.`,\n    announceNearbyNone: (category: string) => `${category} : rien à moins de 2 km.`,\n    creditLead: \"Données de recherche :\",\n    osm: \"contributeurs d'OpenStreetMap\",\n    creditMiddle: \"(ODbL) et données ouvertes canadiennes. Chaque résultat nomme sa source\\u00a0;\",\n    creditLink: \"toutes les sources\",\n    noKeyTitle: \"Ajoutez votre clé unmap pour commencer à chercher.\",\n    getKey: \"Obtenir une clé\",\n    noKey:\n      'Aucune clé unmap. Passez apiKey. Avec Next.js, définissez NEXT_PUBLIC_UNMAP_KEY dans .env.local. Avec Nuxt, définissez NUXT_PUBLIC_UNMAP_KEY dans .env. Si vous avez installé le bloc avec unmap add, nuxt.config déclare déjà runtimeConfig: { public: { unmapKey: \"\" } } ; sinon, ajoutez-le. Redémarrez ensuite le serveur de développement.',\n  },\n};\n\nexport function isLang(value: unknown): value is PlaygroundLang {\n  return value === \"en\" || value === \"fr\";\n}\n\n/** True for a key that is itself a language tag the page can mark text with: `en`, `iu`, `iu-Latn`; not `en-alt1`. */\nexport function isNameTag(key: string): boolean {\n  return /^[a-z]{2,3}(-[A-Z][a-z]{3})?$/.test(key);\n}\n\n/**\n * Language names the page sets itself, in both interface languages: the official two and the\n * Indigenous languages the record most often carries (CGNDB folds Inuktitut, Cree and Ojibwe\n * dialects to `iu`, `cr` and `oj`). Browsers ship names for few of these, so the page does not\n * rely on them; any other tag is named by `Intl.DisplayNames` when the browser can, else shown as\n * the tag itself.\n */\nexport const LANGUAGE_NAMES: Record<string, { en: string; fr: string }> = {\n  en: { en: \"English\", fr: \"anglais\" },\n  fr: { en: \"French\", fr: \"français\" },\n  iu: { en: \"Inuktitut\", fr: \"inuktitut\" },\n  cr: { en: \"Cree\", fr: \"cri\" },\n  oj: { en: \"Ojibwe\", fr: \"ojibwé\" },\n  moe: { en: \"Innu\", fr: \"innu\" },\n  alq: { en: \"Algonquin\", fr: \"algonquin\" },\n  atj: { en: \"Atikamekw\", fr: \"atikamekw\" },\n  mic: { en: \"Mi'kmaq\", fr: \"mi'kmaq\" },\n  moh: { en: \"Mohawk\", fr: \"mohawk\" },\n  nsk: { en: \"Naskapi\", fr: \"naskapi\" },\n  bla: { en: \"Blackfoot\", fr: \"pied-noir\" },\n  crg: { en: \"Michif\", fr: \"mitchif\" },\n  gwi: { en: \"Gwich'in\", fr: \"gwich'in\" },\n  hai: { en: \"Haida\", fr: \"haïda\" },\n  tli: { en: \"Tlingit\", fr: \"tlingit\" },\n};\n\n/** Script names for a tag's script subtag: `iu-Latn` is Inuktitut written in the Latin alphabet. */\nexport const SCRIPT_NAMES: Record<string, { en: string; fr: string }> = {\n  Latn: { en: \"Latin\", fr: \"latin\" },\n  Cans: { en: \"syllabics\", fr: \"syllabique\" },\n};\n\nfunction displayName(code: string, lang: PlaygroundLang, type: \"language\" | \"script\"): string | undefined {\n  try {\n    return new Intl.DisplayNames([lang], { type, fallback: \"none\" }).of(code) ?? undefined;\n  } catch {\n    return undefined;\n  }\n}\n\n/** A language tag's name in the interface language: \"Inuktitut (Latin)\" for `iu-Latn`, or the tag when nothing names it. */\nexport function languageName(code: string, lang: PlaygroundLang): string {\n  const [base = \"\", script] = code.split(\"-\");\n  const language = LANGUAGE_NAMES[base]?.[lang] ?? displayName(base, lang, \"language\");\n  if (!language) return code;\n  if (!script) return language;\n  return `${language} (${SCRIPT_NAMES[script]?.[lang] ?? displayName(script, lang, \"script\") ?? script})`;\n}\n\n/**\n * A record's names, one row per language: the interface language first, then the other official\n * language, then the rest by tag. Keys the record adds to a language (`en-alt1`, `fr-alt2`,\n * `en-iata`) are listed under it as `also`, a repeat in another case once; a language with only\n * those takes the first as its name. Blank names, and keys that name no language, are left out.\n */\nexport function nameRows(names: Record<string, string> | undefined, lang: PlaygroundLang): NameRow[] {\n  const rows: Record<string, { text: string; also: string[] }> = {};\n  const entries = Object.entries(names ?? {})\n    .map(([key, text]) => [key, typeof text === \"string\" ? text.trim() : \"\"] as const)\n    .filter(([, text]) => text !== \"\");\n  for (const [key, text] of entries) {\n    if (isNameTag(key)) rows[key] = { text, also: [] };\n  }\n  for (const [key, text] of entries) {\n    const base = key.split(\"-\")[0] ?? \"\";\n    if (isNameTag(key) || !isNameTag(base)) continue;\n    const row = rows[base] ?? { text: \"\", also: [] };\n    rows[base] = row;\n    if (![row.text, ...row.also].some((seen) => seen.toLowerCase() === text.toLowerCase())) row.also.push(text);\n  }\n  const other = lang === \"en\" ? \"fr\" : \"en\";\n  const order = (code: string) => (code === lang ? 0 : code === other ? 1 : 2);\n  return Object.entries(rows)\n    .sort(([a], [b]) => order(a) - order(b) || (a < b ? -1 : a > b ? 1 : 0))\n    .map(([code, row]) => {\n      const [text, ...also] = row.text ? [row.text, ...row.also] : row.also;\n      return { code, label: languageName(code, lang), text: text ?? \"\", also };\n    });\n}\n\n/** The record's name in a script other than Latin (syllabics beside \"Iqaluit\"), found by its script, not its tag. */\nexport function otherScriptName(result: { name?: string; names?: Record<string, string> }): { code: string; text: string } | undefined {\n  for (const [code, raw] of Object.entries(result.names ?? {})) {\n    const text = typeof raw === \"string\" ? raw.trim() : \"\";\n    if (isNameTag(code) && text !== \"\" && text !== result.name && /\\p{L}/u.test(text) && !/\\p{Script=Latin}/u.test(text)) {\n      return { code, text };\n    }\n  }\n  return undefined;\n}\n\n/**\n * The tag of the language the record's own name is in: the first language, interface language\n * first, whose name is that name. Undefined when none is, so the text keeps the page's language.\n */\nexport function nameTag(result: { name?: string; names?: Record<string, string> }, lang: PlaygroundLang): string | undefined {\n  const name = (result.name ?? \"\").trim();\n  return name === \"\" ? undefined : nameRows(result.names, lang).find((row) => row.text === name)?.code;\n}\n\n/** The address as a Canadian writes it: unit-number street, city, province postal code. Absent parts are left out. */\nexport function addressLine(result: PlaygroundPlace): string {\n  const address = result.address ?? {};\n  const street = [address.housenumber, address.street].filter(Boolean).join(\" \");\n  const line = address.unit && street ? `${address.unit}-${street}` : street;\n  const region = [address.region, address.postcode].filter(Boolean).join(\" \");\n  return [line, address.city, region].filter(Boolean).join(\", \");\n}\n\n/** The result's type in words, or the API's own value for a type the page has no words for. */\nexport function layerLabel(layer: string | undefined, lang: PlaygroundLang): string {\n  return layer ? (PLAYGROUND_COPY[lang].layers[layer] ?? layer) : \"\";\n}\n\n/** A category id in words when it is one of the page's six, else the id as the API returns it. */\nexport function categoryLabel(id: string, lang: PlaygroundLang): string {\n  return PLAYGROUND_CATEGORIES.find((entry) => entry.id === id)?.[lang] ?? id;\n}\n\n/** The fields a result carries, labelled, in a fixed order and as the API returns them; absent fields are left out. */\nexport function detailFields(result: PlaygroundPlace, lang: PlaygroundLang): Array<[string, string]> {\n  const labels = PLAYGROUND_COPY[lang].fields;\n  const point = typeof result.lat === \"number\" && typeof result.lng === \"number\" ? `${result.lat.toFixed(5)}, ${result.lng.toFixed(5)}` : undefined;\n  const fields: Array<[string, string | undefined]> = [\n    [labels.layer, result.layer],\n    [labels.category, result.category],\n    [labels.precision, result.precision],\n    [labels.matchType, result.match_type],\n    [labels.source, result.source],\n    [labels.sourceId, result.source_id],\n    [labels.coordinates, point],\n    [labels.id, result.id],\n  ];\n  return fields.filter((field): field is [string, string] => typeof field[1] === \"string\" && field[1] !== \"\");\n}\n\n/** Metres for people: metres under a kilometre, else kilometres to one decimal. */\nexport function formatDistance(metres: number, lang: PlaygroundLang): string {\n  const locale = lang === \"fr\" ? \"fr-CA\" : \"en-CA\";\n  return metres < 1000\n    ? `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(metres)} m`\n    : `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(metres / 1000)} km`;\n}\n\n/** A list in the interface language's own way: \"A, B, and C\", \"A, B et C\". */\nexport function listOf(items: string[], lang: PlaygroundLang): string {\n  try {\n    return new Intl.ListFormat([lang], { type: \"conjunction\" }).format(items);\n  } catch {\n    return items.join(\", \");\n  }\n}\n\n/**\n * The chosen result in one sentence for the live region: its name, where it is, and its\n * languages. A place whose address starts with its own name is said once: \"Iqaluit, NU\".\n */\nexport function describeResult(result: PlaygroundPlace, lang: PlaygroundLang): string {\n  const copy = PLAYGROUND_COPY[lang];\n  const labels = nameRows(result.names, lang).map((row) => row.label);\n  const name = result.name ?? \"\";\n  const place = addressLine(result);\n  const head = `${place === \"\" ? name : place === name || place.startsWith(`${name}, `) ? place : `${name}, ${place}`}.`;\n  return labels.length > 0 ? `${head}${copy.announceNames(listOf(labels, lang))}` : head;\n}\n\n/** A nearby answer in one sentence for the live region: how many, and the nearest. */\nexport function describeNearby(results: PlaygroundPlace[], category: string, lang: PlaygroundLang): string {\n  const copy = PLAYGROUND_COPY[lang];\n  const label = categoryLabel(category, lang);\n  const nearest = results[0];\n  if (!nearest) return copy.announceNearbyNone(label);\n  const distance = typeof nearest.distance === \"number\" ? formatDistance(nearest.distance, lang) : \"\";\n  return copy.announceNearby(label, results.length, nearest.name ?? \"\", distance);\n}\n\n/**\n * Why a request failed: the place, the key or the rate when the gateway says so; a service that\n * did not answer for a 5xx or a request that never got an HTTP answer.\n */\nexport function errorKind(caught: unknown): PlaygroundError {\n  const failure = typeof caught === \"object\" && caught !== null ? (caught as { code?: unknown; status?: unknown }) : {};\n  const status = typeof failure.status === \"number\" ? failure.status : null;\n  if (failure.code === \"outside_coverage\") return \"outside\";\n  if (status === 429) return \"busy\";\n  if (status === 401 || status === 403) return \"keyRefused\";\n  if (status === null || status >= 500) return \"unavailable\";\n  return \"failed\";\n}\n\n/** What to tell the reader about a failed request, in `lang`. */\nexport function requestError(caught: unknown, lang: PlaygroundLang): string {\n  return PLAYGROUND_COPY[lang][errorKind(caught)];\n}\n\nexport function isAbort(caught: unknown): boolean {\n  return typeof caught === \"object\" && caught !== null && (caught as { name?: unknown }).name === \"AbortError\";\n}\n// --- end address-playground helpers ---\n\nexport interface AddressPlaygroundProps {\n  /** unmap API key. The page passes NEXT_PUBLIC_UNMAP_KEY. With none, the block asks for one and loads nothing. */\n  apiKey: string;\n  /** Gateway base URL. Defaults to the unmap production gateway. */\n  gateway?: string;\n  /** The language it starts in; the reader can switch. Defaults to \"en\". */\n  lang?: PlaygroundLang;\n  className?: string;\n}\n\n// Failures and announcements keep what happened, not finished sentences: the text is read at\n// render, so switching language says them again in the new one.\ntype Lookup =\n  | { kind: \"idle\" }\n  | { kind: \"loading\"; query: string }\n  | { kind: \"error\"; query: string; error: PlaygroundError | \"noMatch\" }\n  | { kind: \"result\"; result: GeocodeResult };\n\ntype Nearby = { kind: \"idle\" } | { kind: \"loading\" } | { kind: \"error\"; error: PlaygroundError } | { kind: \"result\"; results: GeocodeResult[] };\n\ntype Said = { kind: \"result\"; result: GeocodeResult } | { kind: \"nearby\"; results: GeocodeResult[]; category: string } | null;\n\nexport function AddressPlayground({ apiKey, gateway, lang = \"en\", className }: AddressPlaygroundProps) {\n  if (!apiKey.trim()) {\n    const copy = PLAYGROUND_COPY[lang];\n    // Where the page would be: the block's gutter, the Alert at a readable width, the first action first.\n    return (\n      <div\n        data-unmap-block=\"address-playground\"\n        data-state=\"no-key\"\n        lang={lang}\n        className={[\"flex h-full min-h-[480px] items-start justify-center bg-muted/40 p-4 sm:items-center sm:p-6\", className].filter(Boolean).join(\" \")}\n      >\n        <Alert role=\"alert\" className=\"w-full max-w-md motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n          <AlertTitle className=\"line-clamp-none\">{copy.noKeyTitle}</AlertTitle>\n          <AlertDescription className=\"flex flex-col items-start gap-3\">\n            <a className=\"font-medium text-primary underline underline-offset-4\" href={KEY_URL} target=\"_blank\" rel=\"noreferrer\">\n              {copy.getKey}\n            </a>\n            <p>{copy.noKey}</p>\n          </AlertDescription>\n        </Alert>\n      </div>\n    );\n  }\n  return (\n    // A map store with no map: the geocoder reads one, and with no container none is ever made.\n    <MapProvider apiKey={apiKey} gateway={gateway} container={null}>\n      <Playground apiKey={apiKey} gateway={gateway} startLang={lang} className={className} />\n    </MapProvider>\n  );\n}\n\n/** A value and its unit, the unit quieter: \"850\" and \"m\". */\nfunction Distance({ metres, lang }: { metres: number; lang: PlaygroundLang }) {\n  const text = formatDistance(metres, lang);\n  const split = text.lastIndexOf(\" \");\n  return (\n    <span className=\"shrink-0 text-sm tabular-nums\">\n      {text.slice(0, split)}\n      <span className=\"text-xs text-muted-foreground\">{` ${text.slice(split + 1)}`}</span>\n    </span>\n  );\n}\n\nfunction Playground({ apiKey, gateway, startLang, className }: { apiKey: string; gateway?: string; startLang: PlaygroundLang; className?: string }) {\n  const [lang, setLang] = useState<PlaygroundLang>(startLang);\n  const [lookup, setLookup] = useState<Lookup>({ kind: \"idle\" });\n  const [category, setCategory] = useState<string | null>(null);\n  const [nearby, setNearby] = useState<Nearby>({ kind: \"idle\" });\n  // Bumped by \"Try again\" on a failed nearby request, to ask again with nothing else changed.\n  const [attempt, setAttempt] = useState(0);\n  // What the live region says: the chosen result once, then each nearby answer. Nothing until the reader acts.\n  const [said, setSaid] = useState<Said>(null);\n  const client = useMemo(() => new GeocoderClient({ key: apiKey, gateway }), [apiKey, gateway]);\n  const lookupRef = useRef<AbortController | null>(null);\n  const titleRef = useRef<HTMLDivElement | null>(null);\n  // Set when a nearby place opens: the button that opened it goes with the old card.\n  const focusTitleRef = useRef(false);\n  const namesId = useId();\n  const detailsId = useId();\n  const nearbyId = useId();\n  const copy = PLAYGROUND_COPY[lang];\n  const selected = lookup.kind === \"result\" ? lookup.result : null;\n\n  // One nearby request per result, category and language; a newer one aborts it.\n  useEffect(() => {\n    if (!selected || !category) {\n      setNearby({ kind: \"idle\" });\n      return;\n    }\n    const controller = new AbortController();\n    setNearby({ kind: \"loading\" });\n    client\n      .nearby(category, { near: [selected.lng, selected.lat], radius: NEARBY_RADIUS, limit: NEARBY_LIMIT, lang, signal: controller.signal })\n      .then((results) => {\n        if (controller.signal.aborted) return;\n        setNearby({ kind: \"result\", results });\n        setSaid({ kind: \"nearby\", results, category });\n      })\n      .catch((caught: unknown) => {\n        if (!controller.signal.aborted && !isAbort(caught)) setNearby({ kind: \"error\", error: errorKind(caught) });\n      });\n    return () => controller.abort();\n  }, [client, selected, category, lang, attempt]);\n\n  useEffect(() => () => lookupRef.current?.abort(), []);\n\n  // A nearby place opened from the list: its card replaces the list, so focus goes to its name.\n  useEffect(() => {\n    if (!selected || !focusTitleRef.current) return;\n    focusTitleRef.current = false;\n    titleRef.current?.focus();\n  }, [selected]);\n\n  function show(result: GeocodeResult) {\n    lookupRef.current?.abort();\n    setLookup({ kind: \"result\", result });\n    setCategory(null);\n    setSaid({ kind: \"result\", result });\n  }\n\n  function openNearby(result: GeocodeResult) {\n    focusTitleRef.current = true;\n    show(result);\n  }\n\n  function tryExample(query: string) {\n    lookupRef.current?.abort();\n    const controller = new AbortController();\n    lookupRef.current = controller;\n    setLookup({ kind: \"loading\", query });\n    setCategory(null);\n    client\n      .autocomplete(query, { limit: 1, lang, signal: controller.signal })\n      .then(([first]) => {\n        if (controller.signal.aborted) return;\n        if (first) show(first);\n        else setLookup({ kind: \"error\", query, error: \"noMatch\" });\n      })\n      .catch((caught: unknown) => {\n        if (!controller.signal.aborted && !isAbort(caught)) setLookup({ kind: \"error\", query, error: errorKind(caught) });\n      });\n  }\n\n  function chooseLang(next: unknown) {\n    // Pressing the pressed item empties a single-select group: the language stays.\n    if (isLang(next)) setLang(next);\n  }\n\n  const other = selected ? otherScriptName(selected) : undefined;\n  const place = selected ? addressLine(selected) : \"\";\n  const rows = selected ? nameRows(selected.names, lang) : [];\n  const saying = said === null ? \"\" : said.kind === \"result\" ? describeResult(said.result, lang) : describeNearby(said.results, said.category, lang);\n\n  return (\n    <div data-unmap-block=\"address-playground\" lang={lang} className={[\"h-full min-h-[480px] overflow-y-auto p-4 sm:p-6\", className].filter(Boolean).join(\" \")}>\n      <div className=\"mx-auto flex w-full max-w-3xl flex-col gap-6\">\n        <header className=\"flex flex-wrap items-start justify-between gap-x-6 gap-y-3\">\n          <div className=\"flex min-w-0 flex-1 basis-64 flex-col gap-1.5\">\n            <h2 className=\"text-xl font-semibold tracking-tight text-balance\">{copy.title}</h2>\n            <p className=\"text-sm text-pretty text-muted-foreground\">{copy.intro}</p>\n          </div>\n          <ToggleGroup variant=\"outline\" size=\"sm\" aria-label={copy.language} value={[lang]} onValueChange={(next: unknown[]) => chooseLang(next[0])}>\n            {PLAYGROUND_LANGS.map((entry) => (\n              <ToggleGroupItem key={entry.code} value={entry.code} lang={entry.code} className=\"px-3 aria-pressed:font-semibold\">\n                {entry.label}\n              </ToggleGroupItem>\n            ))}\n          </ToggleGroup>\n        </header>\n\n        <div className=\"flex flex-col gap-3\">\n          <Geocoder\n            apiKey={apiKey}\n            gateway={gateway}\n            lang={lang}\n            limit={SEARCH_LIMIT}\n            placeholder={copy.placeholder}\n            errorText={(caught) => requestError(caught, lang)}\n            onSelect={show}\n            renderResult={(result) => <ResultOption result={result} lang={lang} />}\n          />\n          <div className=\"flex flex-wrap items-center gap-2\">\n            <span className=\"text-xs text-muted-foreground\">{copy.try}</span>\n            {PLAYGROUND_EXAMPLES.map((example) => (\n              <Button key={example.query} type=\"button\" variant=\"outline\" size=\"sm\" lang={example.lang} onClick={() => tryExample(example.query)}>\n                {example.query}\n              </Button>\n            ))}\n          </div>\n          <p className=\"text-xs text-muted-foreground\">{copy.coordinates}</p>\n        </div>\n\n        <p data-testid=\"address-playground-announce\" aria-live=\"polite\" className=\"sr-only\">\n          {saying}\n        </p>\n\n        {/* Where the answer lands, always this tall, so nothing below it jumps when one arrives. */}\n        <div data-testid=\"address-playground-answer\" aria-busy={lookup.kind === \"loading\"} className=\"min-h-72 sm:min-h-[26rem]\">\n          {lookup.kind === \"idle\" ? (\n            <div className=\"flex h-full min-h-72 flex-col items-center justify-center gap-2 rounded-xl sm:min-h-[26rem] border border-dashed p-6 text-center motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300\">\n              <p className=\"text-base font-medium text-balance\">{copy.emptyTitle}</p>\n              <p className=\"max-w-md text-sm text-pretty text-muted-foreground\">{copy.emptyBody}</p>\n            </div>\n          ) : lookup.kind === \"loading\" ? (\n            <ResultSkeleton label={copy.looking(lookup.query)} />\n          ) : lookup.kind === \"error\" ? (\n            <Alert variant=\"destructive\" role=\"alert\" className=\"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200\">\n              <AlertTitle className=\"line-clamp-none\">{copy.failedTitle}</AlertTitle>\n              <AlertDescription className=\"flex flex-col items-start gap-3\">\n                <p>{lookup.error === \"noMatch\" ? copy.noMatch(lookup.query) : copy[lookup.error]}</p>\n                {lookup.error !== \"noMatch\" ? (\n                  <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => tryExample(lookup.query)}>\n                    {copy.retry}\n                  </Button>\n                ) : null}\n              </AlertDescription>\n            </Alert>\n          ) : selected ? (\n            <Card key={selected.id} className=\"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-200\">\n              <CardHeader className=\"gap-2\">\n                <div className=\"flex flex-wrap gap-1.5\">\n                  <Badge variant=\"secondary\">{layerLabel(selected.layer, lang)}</Badge>\n                  {selected.category ? <Badge variant=\"outline\">{categoryLabel(selected.category, lang)}</Badge> : null}\n                </div>\n                <CardTitle\n                  ref={titleRef}\n                  role=\"heading\"\n                  aria-level={3}\n                  tabIndex={-1}\n                  lang={nameTag(selected, lang)}\n                  className=\"rounded-sm text-2xl leading-tight font-semibold tracking-tight break-words outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:text-3xl\"\n                >\n                  {selected.name}\n                </CardTitle>\n                {other ? (\n                  // The name in its own script, set as large as the name: the record carries it, so the page shows it.\n                  <p data-testid=\"address-playground-script\" className=\"flex flex-wrap items-baseline gap-x-3 gap-y-1\">\n                    <span lang={other.code} className=\"text-2xl leading-tight font-semibold break-words text-primary sm:text-3xl\">\n                      {other.text}\n                    </span>\n                    <span className=\"text-sm text-muted-foreground\">{languageName(other.code, lang)}</span>\n                  </p>\n                ) : null}\n                {place ? <CardDescription>{place}</CardDescription> : null}\n              </CardHeader>\n              <CardContent className=\"flex flex-col gap-6\">\n                <section aria-labelledby={namesId} className=\"flex flex-col gap-2\">\n                  <h4 id={namesId} className=\"text-sm font-medium\">\n                    {copy.names}\n                  </h4>\n                  {rows.length > 0 ? (\n                    <dl className=\"grid gap-2 sm:grid-cols-2\">\n                      {rows.map((row) => (\n                        <div key={row.code} className=\"flex min-w-0 flex-col gap-1 rounded-lg border p-3\">\n                          <dt className=\"text-xs text-muted-foreground first-letter:uppercase\">{row.label}</dt>\n                          <dd lang={row.code} className=\"text-lg leading-snug font-medium break-words\">\n                            {row.text}\n                          </dd>\n                          {row.also.length > 0 ? (\n                            <dd className=\"text-xs text-muted-foreground\">\n                              {`${copy.also} `}\n                              <span lang={row.code}>{row.also.join(\", \")}</span>\n                            </dd>\n                          ) : null}\n                        </div>\n                      ))}\n                    </dl>\n                  ) : (\n                    <p className=\"text-sm text-muted-foreground\">{copy.namesNone}</p>\n                  )}\n                </section>\n                <div className=\"grid gap-6 md:grid-cols-2\">\n                  <section aria-labelledby={detailsId} className=\"flex min-w-0 flex-col gap-2\">\n                    <h4 id={detailsId} className=\"text-sm font-medium\">\n                      {copy.details}\n                    </h4>\n                    <dl className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-sm\">\n                      {detailFields(selected, lang).map(([label, value]) => (\n                        <Fragment key={label}>\n                          <dt className=\"text-muted-foreground\">{label}</dt>\n                          <dd className=\"font-mono text-xs leading-5 break-all tabular-nums\">{value}</dd>\n                        </Fragment>\n                      ))}\n                    </dl>\n                  </section>\n                  <section aria-labelledby={nearbyId} className=\"flex min-w-0 flex-col gap-3\">\n                    <div className=\"flex flex-wrap items-baseline justify-between gap-x-3\">\n                      <h4 id={nearbyId} className=\"text-sm font-medium\">\n                        {copy.nearby}\n                      </h4>\n                      <p className=\"text-xs text-muted-foreground\">{copy.nearbyHint}</p>\n                    </div>\n                    <ToggleGroup\n                      variant=\"outline\"\n                      size=\"sm\"\n                      aria-label={copy.category}\n                      value={category ? [category] : []}\n                      onValueChange={(next: unknown[]) => setCategory(typeof next[0] === \"string\" ? next[0] : null)}\n                      className=\"w-full flex-wrap\"\n                    >\n                      {PLAYGROUND_CATEGORIES.map((entry) => (\n                        <ToggleGroupItem key={entry.id} value={entry.id} className=\"aria-pressed:font-semibold\">\n                          {entry[lang]}\n                        </ToggleGroupItem>\n                      ))}\n                    </ToggleGroup>\n                    {/* Five rows tall whatever it holds, so an answer never moves the page. */}\n                    <div data-testid=\"address-playground-nearby\" aria-busy={nearby.kind === \"loading\"} className=\"min-h-[14.75rem]\">\n                      {nearby.kind === \"idle\" ? (\n                        <p className=\"text-sm text-muted-foreground\">{copy.nearbyIdle}</p>\n                      ) : nearby.kind === \"loading\" ? (\n                        <>\n                          <p role=\"status\" className=\"sr-only\">\n                            {copy.nearbyLoading}\n                          </p>\n                          <ul aria-hidden=\"true\" className=\"flex flex-col gap-1\">\n                            {Array.from({ length: NEARBY_LIMIT }, (_, index) => (\n                              <li key={index} data-skeleton-row=\"\" className=\"flex h-11 items-center justify-between gap-3 px-2\">\n                                <span className=\"flex flex-col gap-1.5\">\n                                  <Skeleton className=\"h-3.5 w-36\" />\n                                  <Skeleton className=\"h-3 w-24\" />\n                                </span>\n                                <Skeleton className=\"h-3.5 w-12\" />\n                              </li>\n                            ))}\n                          </ul>\n                        </>\n                      ) : nearby.kind === \"error\" ? (\n                        <Alert variant=\"destructive\" role=\"alert\">\n                          <AlertDescription className=\"flex flex-col items-start gap-2\">\n                            <p>{copy[nearby.error]}</p>\n                            <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => setAttempt((count) => count + 1)}>\n                              {copy.retry}\n                            </Button>\n                          </AlertDescription>\n                        </Alert>\n                      ) : nearby.results.length === 0 ? (\n                        <p className=\"text-sm text-muted-foreground\">{copy.nearbyEmpty}</p>\n                      ) : (\n                        <ol aria-label={copy.nearby} className=\"flex flex-col gap-1\">\n                          {nearby.results.map((found, index) => (\n                            <li\n                              key={found.id}\n                              className=\"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:fill-mode-backwards motion-safe:duration-200\"\n                              style={{ animationDelay: `${index * 40}ms` }}\n                            >\n                              <Button\n                                type=\"button\"\n                                variant=\"ghost\"\n                                className=\"h-11 w-full justify-between gap-3 px-2 text-left font-normal\"\n                                onClick={() => openNearby(found)}\n                              >\n                                <span className=\"flex min-w-0 flex-col\">\n                                  <span className=\"truncate text-sm font-medium\">{found.name}</span>\n                                  <span className=\"truncate text-xs text-muted-foreground\">\n                                    {[found.address?.housenumber, found.address?.street].filter(Boolean).join(\" \") || found.address?.city}\n                                  </span>\n                                </span>\n                                {typeof found.distance === \"number\" ? <Distance metres={found.distance} lang={lang} /> : null}\n                              </Button>\n                            </li>\n                          ))}\n                        </ol>\n                      )}\n                    </div>\n                  </section>\n                </div>\n              </CardContent>\n            </Card>\n          ) : null}\n        </div>\n\n        <p data-testid=\"address-playground-credits\" className=\"text-xs text-pretty text-muted-foreground\">\n          {`${copy.creditLead} `}\n          <a className=\"underline underline-offset-2\" href={PLAYGROUND_CREDITS.osm} target=\"_blank\" rel=\"noreferrer\">\n            {copy.osm}\n          </a>\n          {` ${copy.creditMiddle} `}\n          <a className=\"underline underline-offset-2\" href={PLAYGROUND_CREDITS.attribution[lang]} target=\"_blank\" rel=\"noreferrer\">\n            {copy.creditLink}\n          </a>\n          .\n        </p>\n      </div>\n    </div>\n  );\n}\n\n/** An option in the search list: the name, a name in another script under it, then where and what it is. */\nfunction ResultOption({ result, lang }: { result: GeocodeResult; lang: PlaygroundLang }) {\n  const other = otherScriptName(result);\n  const meta = [addressLine(result), layerLabel(result.layer, lang)].filter(Boolean).join(\" · \");\n  return (\n    <span className=\"flex min-w-0 flex-col\">\n      <span className=\"truncate\" lang={nameTag(result, lang)}>\n        {result.name}\n      </span>\n      {other ? (\n        <span className=\"truncate text-xs\" lang={other.code}>\n          {other.text}\n        </span>\n      ) : null}\n      {meta ? <span className=\"truncate text-xs text-muted-foreground\">{meta}</span> : null}\n    </span>\n  );\n}\n\n/** The result card's shape while an example is looked up: badge, name, place, two name tiles, details. */\nfunction ResultSkeleton({ label }: { label: string }) {\n  return (\n    <Card>\n      <p role=\"status\" className=\"sr-only\">\n        {label}\n      </p>\n      <CardHeader className=\"gap-2\" aria-hidden=\"true\">\n        <Skeleton className=\"h-5 w-16\" />\n        <Skeleton className=\"h-8 w-2/3\" />\n        <Skeleton className=\"h-4 w-1/3\" />\n      </CardHeader>\n      <CardContent className=\"flex flex-col gap-6\" aria-hidden=\"true\">\n        <div className=\"flex flex-col gap-2\">\n          <Skeleton className=\"h-4 w-16\" />\n          <div className=\"grid gap-2 sm:grid-cols-2\">\n            <Skeleton className=\"h-[4.5rem] w-full\" />\n            <Skeleton className=\"h-[4.5rem] w-full\" />\n          </div>\n        </div>\n        <div className=\"grid gap-6 md:grid-cols-2\">\n          <div className=\"flex flex-col gap-2\">\n            {Array.from({ length: 5 }, (_, index) => (\n              <Skeleton key={index} className=\"h-4 w-full\" />\n            ))}\n          </div>\n          <div className=\"flex flex-col gap-3\">\n            <Skeleton className=\"h-4 w-20\" />\n            <div className=\"flex flex-wrap gap-2\">\n              {Array.from({ length: PLAYGROUND_CATEGORIES.length }, (_, index) => (\n                <Skeleton key={index} className=\"h-7 w-20\" />\n              ))}\n            </div>\n            <div className=\"min-h-[14.75rem]\" />\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "envVars": {
    "NEXT_PUBLIC_UNMAP_KEY": ""
  },
  "docs": "Paste your unmap key into NEXT_PUBLIC_UNMAP_KEY in .env.local, then open /address-playground. Guide: https://unmap.dev/docs/frontend/blocks#look-up-any-place-in-canada",
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}