{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "location-selector",
  "title": "Location Selector",
  "description": "A UK location picker: address search plus an optional draggable map pin (Mapbox), and the movement-form flow — a selected-location card field backed by a config-driven picker modal with directory search and a map/address escape hatch.",
  "dependencies": [
    "@untitledui/icons@^0.0.22",
    "mapbox-gl@^3.25.0",
    "react-map-gl@^8.1.1"
  ],
  "registryDependencies": [
    "@nx-ui/button",
    "@nx-ui/dialog",
    "@nx-ui/input",
    "@nx-ui/label",
    "@nx-ui/mapbox",
    "@nx-ui/mapbox-overrides",
    "@nx-ui/os-grid-reference",
    "@nx-ui/spinner",
    "@nx-ui/tabs",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/domain/location-selector/index.ts",
      "content": "export { LocationSelector, type LocationSelectorProps } from \"./location-selector\"\nexport { LocationSearch, type LocationSearchProps } from \"./location-search\"\nexport { LocationSearchBox, type LocationSearchBoxProps } from \"./location-search-box\"\nexport {\n  LocationDetailsPanel,\n} from \"./location-details-panel\"\nexport { LocationMap, type LocationMapProps } from \"./location-map\"\nexport {\n  LocationField,\n  type LocationFieldProps,\n  type LocationFieldTone,\n} from \"./location-field\"\nexport {\n  LocationPickerModal,\n  type LocationPickerModalProps,\n} from \"./location-picker-modal\"\nexport type {\n  GeoPoint,\n  GeocodeFn,\n  GeocodeResult,\n  LocationDetails,\n  LocationOption,\n  LocationValue,\n  LocationValueKind,\n  SelectedLocation,\n} from \"./types\"\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/index.ts"
    },
    {
      "path": "components/blocks/domain/location-selector/location-details-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Copy01 } from \"@untitledui/icons\"\n\nimport { latLonToOsGridReference } from \"@/lib/os-grid-reference\"\nimport { cn } from \"@/lib/utils\"\n\nimport type { SelectedLocation } from \"./types\"\n\n/**\n * LocationDetailsPanel — the read-out for a resolved location: the full address,\n * a display \"Town, County\" line, and a coordinates block (lat/lon to 6 dp and\n * the OS grid reference) with per-row copy buttons.\n */\nexport function LocationDetailsPanel({\n  location,\n  className,\n}: {\n  location: SelectedLocation\n  className?: string\n}) {\n  const { point } = location\n  const gridRef = latLonToOsGridReference(point.lat, point.lon)\n\n  return (\n    <div className={cn(\"flex flex-col gap-4 text-sm\", className)}>\n      <div>\n        <p className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\">\n          Full address\n        </p>\n        <p className=\"mt-0.5 font-medium text-foreground\">{location.label}</p>\n        {location.displayLocation && (\n          <p className=\"text-muted-foreground\">{location.displayLocation}</p>\n        )}\n      </div>\n\n      <div className=\"flex flex-col gap-2\">\n        <p className=\"text-xs font-medium tracking-wide text-muted-foreground uppercase\">\n          Coordinates\n        </p>\n        <CoordRow label=\"Latitude\" value={point.lat.toFixed(6)} />\n        <CoordRow label=\"Longitude\" value={point.lon.toFixed(6)} />\n        <CoordRow label=\"OS grid reference\" value={gridRef ?? \"Outside GB grid\"} copyable={!!gridRef} />\n      </div>\n    </div>\n  )\n}\n\nfunction CoordRow({\n  label,\n  value,\n  copyable = true,\n}: {\n  label: string\n  value: string\n  copyable?: boolean\n}) {\n  const [copied, setCopied] = React.useState(false)\n\n  async function copy() {\n    try {\n      await navigator.clipboard.writeText(value)\n      setCopied(true)\n      setTimeout(() => setCopied(false), 1200)\n    } catch {\n      // clipboard unavailable — ignore\n    }\n  }\n\n  return (\n    <div className=\"flex items-center justify-between gap-3 rounded-md bg-muted/50 px-2.5 py-1.5\">\n      <span className=\"text-xs text-muted-foreground\">{label}</span>\n      <span className=\"flex items-center gap-2\">\n        <span className=\"font-mono text-xs tabular-nums text-foreground\">{value}</span>\n        {copyable && (\n          <button\n            type=\"button\"\n            onClick={copy}\n            aria-label={`Copy ${label}`}\n            className=\"text-muted-foreground transition-colors hover:text-foreground\"\n          >\n            {copied ? (\n              <Check className=\"size-3.5 text-utility-green-600\" />\n            ) : (\n              <Copy01 className=\"size-3.5\" />\n            )}\n          </button>\n        )}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/location-details-panel.tsx"
    },
    {
      "path": "components/blocks/domain/location-selector/location-field.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { MarkerPin01, PencilLine } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport type { LocationValue } from \"./types\"\n\nexport type LocationFieldTone = \"source\" | \"destination\" | \"neutral\"\n\nexport interface LocationFieldProps {\n  /** The picked location, or `null` for the placeholder state. */\n  value: LocationValue | null\n  /** Opens the picker modal — fired from both the placeholder and \"Change\". */\n  onOpen: () => void\n  /**\n   * Accent for the pin square, matching the map-pin colour coding: `source`\n   * is Nexus accent yellow, `destination` success green, `neutral` brand navy.\n   */\n  tone?: LocationFieldTone\n  placeholder?: string\n  invalid?: boolean\n  disabled?: boolean\n  className?: string\n}\n\nconst TONE_CLASS: Record<LocationFieldTone, string> = {\n  source: \"bg-accent-400 text-brand-700\",\n  // Static green-600 ramp, not utility-green-600: the utility alias flips to\n  // green-400 in dark mode, which fails contrast under fixed white text.\n  destination: \"bg-green-600 text-white\",\n  neutral: \"bg-brand-600 text-white dark:bg-brand-500\",\n}\n\n/**\n * LocationField — the selected-location card for movement forms. Empty, it is\n * a slim input-lookalike (\"Pick a location\"); once set it becomes a card with\n * a tone-tinted map-pin square, the location's label and secondary line, and\n * a \"Change\" affordance. The whole control opens the picker modal — pair it\n * with `LocationPickerModal`.\n */\nexport function LocationField({\n  value,\n  onOpen,\n  tone = \"neutral\",\n  placeholder = \"Pick a location\",\n  invalid = false,\n  disabled = false,\n  className,\n}: LocationFieldProps) {\n  if (!value) {\n    return (\n      <button\n        type=\"button\"\n        onClick={onOpen}\n        disabled={disabled}\n        aria-invalid={invalid || undefined}\n        data-slot=\"location-field\"\n        data-state=\"empty\"\n        className={cn(\n          \"flex h-9 w-full items-center gap-2 rounded-lg border bg-background px-3 text-left text-[13px] text-muted-foreground shadow-xs outline-none transition-[border-color,box-shadow] duration-100\",\n          invalid\n            ? \"border-destructive ring-4 ring-destructive/10\"\n            : \"border-input hover:border-ring/50 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className\n        )}\n      >\n        <MarkerPin01 aria-hidden className=\"size-3.5 shrink-0\" />\n        <span className=\"min-w-0 flex-1 truncate\">{placeholder}</span>\n      </button>\n    )\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onOpen}\n      disabled={disabled}\n      data-slot=\"location-field\"\n      data-state=\"selected\"\n      className={cn(\n        \"group flex w-full items-center gap-3 rounded-lg border border-border bg-muted/40 p-2.5 text-left outline-none transition-[border-color,box-shadow] duration-100 hover:border-ring/50 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25\",\n        disabled && \"cursor-not-allowed opacity-50\",\n        className\n      )}\n    >\n      <span\n        aria-hidden\n        className={cn(\n          \"flex size-8 shrink-0 items-center justify-center rounded-md\",\n          TONE_CLASS[tone]\n        )}\n      >\n        <MarkerPin01 className=\"size-4\" />\n      </span>\n      <span className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"flex min-w-0 items-center gap-1.5\">\n          <span className=\"min-w-0 truncate text-[13px] leading-tight font-semibold text-foreground\">\n            {value.label}\n          </span>\n          {value.badge && (\n            <span className=\"shrink-0 rounded-md border border-border bg-background px-1.5 py-px font-mono text-[10px] font-medium text-muted-foreground\">\n              {value.badge}\n            </span>\n          )}\n        </span>\n        {value.secondary && (\n          <span className=\"truncate text-[11.5px] leading-tight text-muted-foreground\">\n            {value.secondary}\n          </span>\n        )}\n      </span>\n      <span className=\"flex shrink-0 items-center gap-1 text-[11.5px] font-semibold text-muted-foreground transition-colors group-hover:text-text-brand-secondary\">\n        <PencilLine aria-hidden className=\"size-3\" />\n        Change\n      </span>\n    </button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/location-field.tsx"
    },
    {
      "path": "components/blocks/domain/location-selector/location-map.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport Map, {\n  type MapRef,\n  Marker,\n  NavigationControl,\n} from \"react-map-gl/mapbox\"\n\nimport \"mapbox-gl/dist/mapbox-gl.css\"\n\n// Hides Mapbox's injected logo/attribution chrome on every map.\n// @registry-dep: mapbox-overrides\nimport \"@/styles/mapbox-overrides.css\"\n\nimport { MAPBOX_STYLES, MAPBOX_TOKEN } from \"@/lib/mapbox\"\nimport { cn } from \"@/lib/utils\"\n\nimport type { GeoPoint } from \"./types\"\n\ntype MapStyleKey = \"streets\" | \"satellite\"\n\nconst STYLE_URL: Record<MapStyleKey, string> = {\n  streets: MAPBOX_STYLES.light,\n  satellite: MAPBOX_STYLES.satellite,\n}\n\nexport interface LocationMapProps {\n  /** The currently-dropped point, or null. */\n  point: GeoPoint | null\n  /** Fired when the user clicks the map or finishes dragging the pin. */\n  onPointChange: (point: GeoPoint) => void\n  /** Mapbox access token; defaults to the shared `MAPBOX_TOKEN`. */\n  token?: string\n  className?: string\n}\n\n/** Great Britain default view when no point is set. */\nconst GB_VIEW = { longitude: -1.8, latitude: 52.5, zoom: 5.2 }\n\n/**\n * LocationMap — a Mapbox map with a draggable brand pin.\n *\n * Click the map to drop a pin, or drag the pin to fine-tune; both fire\n * `onPointChange`. A Map / Satellite toggle switches basemap. The map resizes\n * with its container. Reverse-geocoding of the new point is the parent's job\n * (it owns the `geocode` function) — this component only reports the point.\n */\nexport function LocationMap({\n  point,\n  onPointChange,\n  token = MAPBOX_TOKEN,\n  className,\n}: LocationMapProps) {\n  const [styleKey, setStyleKey] = React.useState<MapStyleKey>(\"satellite\")\n  const mapRef = React.useRef<MapRef>(null)\n\n  // Keep the map sized to its container across layout changes.\n  React.useEffect(() => {\n    const el = mapRef.current?.getContainer()\n    if (!el || typeof ResizeObserver === \"undefined\") return\n    const ro = new ResizeObserver(() => mapRef.current?.resize())\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  // Fly to a newly-set point (e.g. chosen from search).\n  React.useEffect(() => {\n    if (point) {\n      mapRef.current?.flyTo({\n        center: [point.lon, point.lat],\n        zoom: Math.max(mapRef.current.getZoom() ?? 0, 14),\n        speed: 0.8,\n        curve: 1.4,\n      })\n    }\n  }, [point])\n\n  return (\n    <div\n      className={cn(\n        \"relative overflow-hidden rounded-xl border border-border\",\n        className\n      )}\n    >\n      <Map\n        ref={mapRef}\n        mapboxAccessToken={token}\n        initialViewState={\n          point ? { longitude: point.lon, latitude: point.lat, zoom: 14 } : GB_VIEW\n        }\n        mapStyle={STYLE_URL[styleKey]}\n        onClick={(e) => onPointChange({ lon: e.lngLat.lng, lat: e.lngLat.lat })}\n        style={{ width: \"100%\", height: \"100%\" }}\n        attributionControl={false}\n      >\n        <NavigationControl position=\"top-right\" showCompass={false} />\n        {point && (\n          <Marker\n            longitude={point.lon}\n            latitude={point.lat}\n            draggable\n            onDragEnd={(e) => onPointChange({ lon: e.lngLat.lng, lat: e.lngLat.lat })}\n            anchor=\"bottom\"\n          >\n            <BrandPin />\n          </Marker>\n        )}\n      </Map>\n\n      {/* Style toggle */}\n      <div\n        className=\"absolute top-3 left-3 flex overflow-hidden rounded-lg border border-border bg-background shadow-sm\"\n        role=\"group\"\n        aria-label=\"Map style\"\n      >\n        {([\"streets\", \"satellite\"] as const).map((key) => (\n          <button\n            key={key}\n            type=\"button\"\n            aria-pressed={styleKey === key}\n            onClick={() => setStyleKey(key)}\n            className={cn(\n              \"px-3 py-1.5 text-xs font-semibold capitalize transition-colors\",\n              styleKey === key\n                ? \"bg-primary text-primary-foreground\"\n                : \"text-muted-foreground hover:bg-muted\"\n            )}\n          >\n            {key === \"streets\" ? \"Map\" : \"Satellite\"}\n          </button>\n        ))}\n      </div>\n\n      {!point && (\n        <div className=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n          <span className=\"rounded-full bg-foreground/80 px-3 py-1 text-xs font-medium text-background\">\n            Click the map to drop a pin\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n\n/** The brand pin: a pulsing ring around a teardrop, filled Nexus yellow. */\nfunction BrandPin() {\n  return (\n    <span className=\"relative block size-8 cursor-pointer\" aria-hidden>\n      <span className=\"absolute inset-0 animate-ping rounded-full bg-accent-400/40\" />\n      <svg\n        viewBox=\"0 0 24 32\"\n        className=\"absolute -top-1 left-1/2 size-8 -translate-x-1/2 drop-shadow-md\"\n        fill=\"none\"\n      >\n        <path\n          d=\"M12 0C5.373 0 0 5.373 0 12c0 8 12 20 12 20s12-12 12-20C24 5.373 18.627 0 12 0z\"\n          fill=\"var(--color-accent-400, #F7EC33)\"\n          stroke=\"var(--color-brand-700, #121541)\"\n          strokeWidth=\"1.5\"\n        />\n        <circle cx=\"12\" cy=\"12\" r=\"4\" fill=\"#fff\" />\n      </svg>\n    </span>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/location-map.tsx"
    },
    {
      "path": "components/blocks/domain/location-selector/location-picker-modal.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { MarkerPin01, SearchLg } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\"\nimport { Input } from \"@/components/ui/input\"\nimport { Label } from \"@/components/ui/label\"\nimport { Spinner } from \"@/components/ui/spinner\"\nimport { Tabs, TabsList, TabsTrigger } from \"@/components/ui/tabs\"\n\nimport { LocationSelector } from \"./location-selector\"\nimport type {\n  GeocodeFn,\n  LocationOption,\n  LocationValue,\n  LocationValueKind,\n  SelectedLocation,\n} from \"./types\"\n\nexport interface LocationPickerModalProps {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  /** Fires with the picked location (display snapshot included); the modal closes itself. */\n  onSelect: (value: LocationValue) => void\n  title?: string\n  description?: string\n  searchPlaceholder?: string\n  /**\n   * Static directory rows, filtered client-side as the user types. Rows keep\n   * their own `kind`/`badge`/`group`, so one modal serves sites, stockpiles,\n   * facilities and listings alike.\n   */\n  options?: LocationOption[]\n  /**\n   * Async directory search (server typeahead). When provided it replaces the\n   * client-side filtering; `options` (if any) remain the pre-query list.\n   */\n  search?: (query: string) => Promise<LocationOption[]>\n  /** Copy for the no-results state, e.g. \"No stockpiles on this project…\". */\n  emptyState?: { title: string; description?: string }\n  /**\n   * Enables the \"Map & address\" escape hatch — address geocoding plus a\n   * draggable pin, reusing the block's hybrid `LocationSelector`.\n   */\n  geocode?: GeocodeFn\n  /** Mapbox token for the map tab; defaults to the shared `MAPBOX_TOKEN`. */\n  token?: string\n  /** The `kind` stamped onto map/address picks. Defaults to \"freeform\". */\n  freeformKind?: LocationValueKind\n}\n\n/**\n * LocationPickerModal — the set/change dialog behind `LocationField`. One\n * config-driven modal covers every backing directory: pass static `options`\n * (client-filtered), an async `search` (server typeahead), or both; add\n * `geocode` to unlock the \"Map & address\" tab for locations that are not in\n * any directory. Picks emit a `LocationValue` whose display snapshot (label,\n * secondary, badge, point) travels with the id.\n */\nexport function LocationPickerModal({\n  open,\n  onOpenChange,\n  onSelect,\n  title = \"Pick a location\",\n  description = \"Search the directory, or use the map for somewhere new.\",\n  searchPlaceholder = \"Search by name or address…\",\n  options,\n  search,\n  emptyState,\n  geocode,\n  token,\n  freeformKind = \"freeform\",\n}: LocationPickerModalProps) {\n  const [tab, setTab] = React.useState<\"directory\" | \"map\">(\"directory\")\n  const [query, setQuery] = React.useState(\"\")\n  const [results, setResults] = React.useState<LocationOption[] | null>(null)\n  const [loading, setLoading] = React.useState(false)\n  const [activeIndex, setActiveIndex] = React.useState(-1)\n  const [freeform, setFreeform] = React.useState<SelectedLocation | null>(null)\n  const [freeformName, setFreeformName] = React.useState(\"\")\n\n  const searchRef = React.useRef<HTMLInputElement>(null)\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const requestSeq = React.useRef(0)\n  const nameId = React.useId()\n\n  React.useEffect(() => {\n    if (!open) return\n    setTab(\"directory\")\n    setQuery(\"\")\n    setResults(null)\n    setLoading(false)\n    setActiveIndex(-1)\n    setFreeform(null)\n    setFreeformName(\"\")\n  }, [open])\n\n  const trimmed = query.trim()\n\n  // Async directory search (debounced, sequence-guarded).\n  React.useEffect(() => {\n    if (!search || !open) return\n    if (!trimmed) {\n      setResults(null)\n      setLoading(false)\n      return\n    }\n    setLoading(true)\n    const seq = ++requestSeq.current\n    const timer = setTimeout(async () => {\n      try {\n        const next = await search(trimmed)\n        if (seq === requestSeq.current) {\n          setResults(next)\n          setActiveIndex(next.length > 0 ? 0 : -1)\n        }\n      } catch {\n        if (seq === requestSeq.current) setResults([])\n      } finally {\n        if (seq === requestSeq.current) setLoading(false)\n      }\n    }, 250)\n    return () => clearTimeout(timer)\n  }, [search, trimmed, open])\n\n  // The flat visible row list: async results when searching a server\n  // directory, otherwise the static options filtered client-side.\n  const visible = React.useMemo<LocationOption[]>(() => {\n    if (search && trimmed) return results ?? []\n    const pool = options ?? []\n    if (!trimmed) return pool\n    const q = trimmed.toLowerCase()\n    return pool.filter(\n      (option) =>\n        option.label.toLowerCase().includes(q) ||\n        (option.secondary?.toLowerCase().includes(q) ?? false) ||\n        (option.badge?.toLowerCase().includes(q) ?? false) ||\n        (option.group?.toLowerCase().includes(q) ?? false)\n    )\n  }, [search, trimmed, results, options])\n\n  // Group rows under their `group` heading, preserving order.\n  const grouped = React.useMemo(() => {\n    const map = new Map<string, LocationOption[]>()\n    for (const option of visible) {\n      const key = option.group ?? \"\"\n      const list = map.get(key) ?? []\n      list.push(option)\n      map.set(key, list)\n    }\n    return [...map.entries()]\n  }, [visible])\n\n  const commit = React.useCallback(\n    (value: LocationValue) => {\n      onSelect(value)\n      onOpenChange(false)\n    },\n    [onSelect, onOpenChange]\n  )\n\n  const commitOption = (option: LocationOption) => {\n    // Strip the picker-only `group` off the emitted value.\n    const { group: _group, ...value } = option\n    commit(value)\n  }\n\n  const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (visible.length === 0) return\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault()\n      const next =\n        e.key === \"ArrowDown\"\n          ? Math.min(activeIndex + 1, visible.length - 1)\n          : Math.max(activeIndex - 1, 0)\n      setActiveIndex(next)\n      listRef.current\n        ?.querySelector(`[data-index=\"${next}\"]`)\n        ?.scrollIntoView({ block: \"nearest\" })\n    } else if (e.key === \"Enter\") {\n      e.preventDefault()\n      const option = visible[activeIndex] ?? visible[0]\n      if (option) commitOption(option)\n    }\n  }\n\n  const commitFreeform = () => {\n    if (!freeform) return\n    const name = freeformName.trim()\n    commit({\n      kind: freeformKind,\n      label: name || freeform.label,\n      secondary: name ? freeform.label : freeform.displayLocation,\n      point: freeform.point,\n      details: freeform.details,\n    })\n  }\n\n  const showEmpty =\n    !loading &&\n    visible.length === 0 &&\n    (trimmed.length > 0 || (options?.length ?? 0) === 0)\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent\n        className=\"flex max-h-[min(680px,88vh)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl\"\n        initialFocus={searchRef}\n      >\n        <DialogHeader className=\"flex-row items-center gap-3 border-b border-border px-5 py-4 pr-14\">\n          <span\n            aria-hidden\n            className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-utility-brand-50 text-utility-brand-700\"\n          >\n            <MarkerPin01 className=\"size-4.5\" />\n          </span>\n          <div className=\"flex min-w-0 flex-col gap-0.5\">\n            <DialogTitle className=\"text-md\">{title}</DialogTitle>\n            <DialogDescription className=\"truncate text-xs\">\n              {description}\n            </DialogDescription>\n          </div>\n        </DialogHeader>\n\n        {geocode && (\n          <div className=\"border-b border-border px-5 py-2.5\">\n            <Tabs\n              value={tab}\n              onValueChange={(value) => {\n                setTab(value as \"directory\" | \"map\")\n                // The map selector resets on unmount, so drop any half-made\n                // freeform pick rather than letting a stale one linger.\n                if (value === \"directory\") {\n                  setFreeform(null)\n                  setFreeformName(\"\")\n                }\n              }}\n            >\n              <TabsList className=\"w-full\">\n                <TabsTrigger value=\"directory\">Directory</TabsTrigger>\n                <TabsTrigger value=\"map\">Map &amp; address</TabsTrigger>\n              </TabsList>\n            </Tabs>\n          </div>\n        )}\n\n        {tab === \"directory\" ? (\n          <>\n            <div className=\"border-b border-border px-5 py-3\">\n              <div className=\"relative\">\n                <SearchLg\n                  aria-hidden\n                  className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground\"\n                />\n                <input\n                  ref={searchRef}\n                  type=\"text\"\n                  value={query}\n                  onChange={(e) => {\n                    setQuery(e.target.value)\n                    setActiveIndex(-1)\n                  }}\n                  onKeyDown={handleSearchKeyDown}\n                  placeholder={searchPlaceholder}\n                  aria-label={searchPlaceholder}\n                  className=\"h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25\"\n                  data-slot=\"location-picker-search\"\n                />\n              </div>\n            </div>\n\n            <div\n              ref={listRef}\n              className=\"min-h-[280px] flex-1 overflow-y-auto px-4 py-3\"\n            >\n              {loading && (\n                <div className=\"flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground\">\n                  <Spinner className=\"size-4\" />\n                  Searching…\n                </div>\n              )}\n\n              {!loading && search && !trimmed && visible.length === 0 && (\n                <p className=\"px-2 py-12 text-center text-sm text-muted-foreground\">\n                  Start typing to search the directory.\n                </p>\n              )}\n\n              {showEmpty && (\n                <div className=\"flex flex-col items-center justify-center gap-1.5 px-6 py-12 text-center\">\n                  <p className=\"text-sm font-medium text-foreground\">\n                    {emptyState?.title ?? \"No locations found\"}\n                  </p>\n                  <p className=\"max-w-72 text-xs leading-relaxed text-muted-foreground\">\n                    {emptyState?.description ??\n                      (geocode\n                        ? \"Try a different name, or switch to Map & address to set it manually.\"\n                        : \"Try a different name.\")}\n                  </p>\n                </div>\n              )}\n\n              {!loading &&\n                grouped.map(([groupLabel, groupOptions]) => (\n                  <div key={groupLabel || \"__ungrouped\"} className=\"pb-2\">\n                    {groupLabel && (\n                      <p className=\"px-2 pt-1 pb-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground uppercase\">\n                        {groupLabel}\n                      </p>\n                    )}\n                    <div className=\"flex flex-col gap-1\">\n                      {groupOptions.map((option) => {\n                        const index = visible.indexOf(option)\n                        return (\n                          <button\n                            key={option.id}\n                            type=\"button\"\n                            data-index={index}\n                            data-slot=\"location-picker-row\"\n                            onClick={() => commitOption(option)}\n                            onMouseEnter={() => setActiveIndex(index)}\n                            className={cn(\n                              \"flex items-start gap-3 rounded-lg border p-2.5 text-left outline-none transition-colors focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25\",\n                              index === activeIndex\n                                ? \"border-border bg-muted\"\n                                : \"border-transparent hover:border-border hover:bg-muted/60\"\n                            )}\n                          >\n                            <span\n                              aria-hidden\n                              className=\"flex size-9 shrink-0 items-center justify-center rounded-md bg-utility-brand-50 text-utility-brand-700\"\n                            >\n                              <MarkerPin01 className=\"size-4\" />\n                            </span>\n                            <span className=\"flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5\">\n                              <span className=\"flex min-w-0 items-center gap-1.5\">\n                                <span className=\"min-w-0 truncate text-[13px] leading-tight font-semibold text-foreground\">\n                                  {option.label}\n                                </span>\n                                {option.badge && (\n                                  <span className=\"shrink-0 rounded-md border border-border bg-background px-1.5 py-px font-mono text-[10px] font-medium text-muted-foreground\">\n                                    {option.badge}\n                                  </span>\n                                )}\n                              </span>\n                              {option.secondary && (\n                                <span className=\"truncate text-[11.5px] leading-tight text-muted-foreground\">\n                                  {option.secondary}\n                                </span>\n                              )}\n                            </span>\n                          </button>\n                        )\n                      })}\n                    </div>\n                  </div>\n                ))}\n            </div>\n\n            {geocode && (\n              <div className=\"flex items-center justify-between gap-3 border-t border-border bg-muted/30 px-5 py-2.5\">\n                <p className=\"text-[11px] text-muted-foreground\">\n                  Not in the directory? Set it from the map instead.\n                </p>\n                <Button\n                  variant=\"outline\"\n                  size=\"xs\"\n                  onClick={() => setTab(\"map\")}\n                  data-slot=\"location-picker-to-map\"\n                >\n                  Map &amp; address\n                </Button>\n              </div>\n            )}\n          </>\n        ) : (\n          <>\n            <div className=\"min-h-0 flex-1 overflow-y-auto px-5 py-4\">\n              <LocationSelector\n                geocode={geocode!}\n                token={token}\n                onChange={setFreeform}\n                className=\"border-0 p-0\"\n              />\n              <div className=\"mt-4 flex flex-col gap-1.5\">\n                <Label htmlFor={nameId}>\n                  Name this location{\" \"}\n                  <span className=\"font-normal text-muted-foreground\">\n                    (optional)\n                  </span>\n                </Label>\n                <Input\n                  id={nameId}\n                  value={freeformName}\n                  onChange={(e) => setFreeformName(e.target.value)}\n                  placeholder=\"e.g. Gate 3 laydown area\"\n                  className=\"h-9 text-sm\"\n                />\n              </div>\n            </div>\n            <div className=\"flex items-center justify-between gap-3 border-t border-border bg-muted/30 px-5 py-3\">\n              <p className=\"min-w-0 flex-1 truncate text-[11px] text-muted-foreground\">\n                {freeform\n                  ? freeform.label\n                  : \"Search an address or drop a pin to continue.\"}\n              </p>\n              <Button\n                onClick={commitFreeform}\n                disabled={!freeform}\n                data-slot=\"location-picker-use-freeform\"\n              >\n                Use this location\n              </Button>\n            </div>\n          </>\n        )}\n      </DialogContent>\n    </Dialog>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/location-picker-modal.tsx"
    },
    {
      "path": "components/blocks/domain/location-selector/location-search-box.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { MarkerPin01, SearchLg } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Label } from \"@/components/ui/label\"\n\nimport type { GeocodeFn, GeocodeResult } from \"./types\"\n\nexport interface LocationSearchBoxProps {\n  geocode: GeocodeFn\n  onSelect: (result: GeocodeResult) => void\n  label?: string\n  placeholder?: string\n  /** Helper text under the input — defaults to a postcode tip. */\n  hint?: React.ReactNode\n  /** Controlled initial query text (uncontrolled thereafter). */\n  initialQuery?: string\n  disabled?: boolean\n  className?: string\n}\n\n/**\n * LocationSearchBox — a debounced, keyboard-navigable address search.\n *\n * Calls the injected `geocode` function (min 3 chars, 300 ms debounce) and lists\n * the suggestions in a popover. Arrow keys move the active row, Enter selects,\n * Escape closes. Shared by the search-only and map-hybrid location selectors.\n */\nexport function LocationSearchBox({\n  geocode,\n  onSelect,\n  label = \"Search address\",\n  placeholder = \"Start typing an address…\",\n  hint = \"Tip: postcodes work well.\",\n  initialQuery = \"\",\n  disabled = false,\n  className,\n}: LocationSearchBoxProps) {\n  const [query, setQuery] = React.useState(initialQuery)\n  const [results, setResults] = React.useState<GeocodeResult[]>([])\n  const [open, setOpen] = React.useState(false)\n  const [loading, setLoading] = React.useState(false)\n  const [activeIndex, setActiveIndex] = React.useState(-1)\n  const [touched, setTouched] = React.useState(false)\n\n  const inputId = React.useId()\n  const listboxId = React.useId()\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const requestSeq = React.useRef(0)\n\n  // Debounced geocode. A monotonic sequence guards against out-of-order\n  // responses overwriting a newer query's results.\n  React.useEffect(() => {\n    const q = query.trim()\n    if (q.length < 3) {\n      setResults([])\n      setLoading(false)\n      return\n    }\n    setLoading(true)\n    const seq = ++requestSeq.current\n    const timer = setTimeout(async () => {\n      try {\n        const next = await geocode({ query: q })\n        if (seq === requestSeq.current) {\n          setResults(next)\n          setActiveIndex(next.length > 0 ? 0 : -1)\n        }\n      } catch {\n        if (seq === requestSeq.current) setResults([])\n      } finally {\n        if (seq === requestSeq.current) setLoading(false)\n      }\n    }, 300)\n    return () => clearTimeout(timer)\n  }, [query, geocode])\n\n  // Close on outside click.\n  React.useEffect(() => {\n    const handler = (e: MouseEvent) => {\n      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {\n        setOpen(false)\n      }\n    }\n    document.addEventListener(\"mousedown\", handler)\n    return () => document.removeEventListener(\"mousedown\", handler)\n  }, [])\n\n  function choose(result: GeocodeResult) {\n    onSelect(result)\n    setQuery(result.fullAddress)\n    setOpen(false)\n    setActiveIndex(-1)\n  }\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {\n    if (!open && (e.key === \"ArrowDown\" || e.key === \"ArrowUp\")) {\n      setOpen(true)\n      return\n    }\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault()\n      setActiveIndex((i) => Math.min(i + 1, results.length - 1))\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault()\n      setActiveIndex((i) => Math.max(i - 1, 0))\n    } else if (e.key === \"Enter\") {\n      if (activeIndex >= 0 && results[activeIndex]) {\n        e.preventDefault()\n        choose(results[activeIndex])\n      }\n    } else if (e.key === \"Escape\") {\n      setOpen(false)\n    }\n  }\n\n  const showPanel = open && query.trim().length >= 3\n  const showEmpty = showPanel && !loading && touched && results.length === 0\n\n  return (\n    <div className={cn(\"relative flex flex-col gap-1.5\", className)} ref={containerRef}>\n      <Label htmlFor={inputId}>{label}</Label>\n      <div className=\"relative\">\n        <SearchLg className=\"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground\" />\n        <input\n          id={inputId}\n          type=\"text\"\n          role=\"combobox\"\n          aria-expanded={showPanel}\n          aria-controls={listboxId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={\n            activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined\n          }\n          autoComplete=\"off\"\n          disabled={disabled}\n          value={query}\n          placeholder={placeholder}\n          onChange={(e) => {\n            setQuery(e.target.value)\n            setOpen(true)\n            setTouched(true)\n          }}\n          onFocus={() => setOpen(true)}\n          onKeyDown={handleKeyDown}\n          className=\"h-10 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50\"\n        />\n      </div>\n      {hint ? <p className=\"text-xs text-muted-foreground\">{hint}</p> : null}\n\n      {showPanel && (\n        <div\n          id={listboxId}\n          role=\"listbox\"\n          className=\"absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-lg\"\n        >\n          {loading && results.length === 0 ? (\n            <p className=\"px-3 py-3 text-sm text-muted-foreground\">Searching…</p>\n          ) : showEmpty ? (\n            <div className=\"px-3 py-3\">\n              <p className=\"text-sm font-medium text-foreground\">No results</p>\n              <p className=\"text-xs text-muted-foreground\">\n                Try a postcode, town/city, or a shorter query.\n              </p>\n            </div>\n          ) : (\n            <ul className=\"max-h-72 overflow-y-auto py-1\">\n              {results.map((result, index) => (\n                <li key={result.id}>\n                  <button\n                    type=\"button\"\n                    role=\"option\"\n                    id={`${listboxId}-opt-${index}`}\n                    aria-selected={index === activeIndex}\n                    onMouseEnter={() => setActiveIndex(index)}\n                    onClick={() => choose(result)}\n                    className={cn(\n                      \"flex w-full items-start gap-2.5 px-3 py-2 text-left text-sm\",\n                      index === activeIndex\n                        ? \"bg-accent text-accent-foreground\"\n                        : \"hover:bg-muted\"\n                    )}\n                  >\n                    <MarkerPin01 className=\"mt-0.5 size-4 shrink-0 text-muted-foreground\" />\n                    <span className=\"flex min-w-0 flex-col\">\n                      <span className=\"truncate font-medium text-foreground\">\n                        {result.fullAddress}\n                      </span>\n                      {result.displayLocation && (\n                        <span className=\"truncate text-xs text-muted-foreground\">\n                          {result.displayLocation}\n                        </span>\n                      )}\n                    </span>\n                  </button>\n                </li>\n              ))}\n            </ul>\n          )}\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/location-search-box.tsx"
    },
    {
      "path": "components/blocks/domain/location-selector/location-search.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Trash01 } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nimport { LocationDetailsPanel } from \"./location-details-panel\"\nimport { LocationSearchBox } from \"./location-search-box\"\nimport type { GeocodeFn, GeocodeResult, SelectedLocation } from \"./types\"\n\nexport interface LocationSearchProps {\n  geocode: GeocodeFn\n  onChange?: (location: SelectedLocation | null) => void\n  initialQuery?: string\n  label?: string\n  placeholder?: string\n  className?: string\n}\n\n/**\n * LocationSearch — the map-free variant of the location picker. Just the\n * debounced address search plus the resolved-address read-out (coordinates and\n * OS grid reference). Ideal where a map would be overkill, or on a first pass\n * that a fuller picker refines.\n */\nexport function LocationSearch({\n  geocode,\n  onChange,\n  initialQuery,\n  label,\n  placeholder,\n  className,\n}: LocationSearchProps) {\n  const [selected, setSelected] = React.useState<SelectedLocation | null>(null)\n\n  function commit(next: SelectedLocation | null) {\n    setSelected(next)\n    onChange?.(next)\n  }\n\n  function handleSelect(result: GeocodeResult) {\n    commit({\n      point: result.point,\n      label: result.fullAddress,\n      displayLocation: result.displayLocation,\n      details: result.details,\n    })\n  }\n\n  return (\n    <div\n      className={cn(\n        \"flex max-w-md flex-col gap-4 rounded-xl border border-border bg-background p-4\",\n        className\n      )}\n    >\n      <LocationSearchBox\n        geocode={geocode}\n        onSelect={handleSelect}\n        initialQuery={initialQuery}\n        label={label}\n        placeholder={placeholder}\n      />\n      {selected && (\n        <>\n          <LocationDetailsPanel location={selected} />\n          <Button variant=\"outline\" size=\"sm\" className=\"self-start\" onClick={() => commit(null)}>\n            <Trash01 className=\"size-4\" />\n            Clear\n          </Button>\n        </>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/location-search.tsx"
    },
    {
      "path": "components/blocks/domain/location-selector/location-selector.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Trash01 } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\n\nimport { LocationDetailsPanel } from \"./location-details-panel\"\nimport { LocationSearchBox } from \"./location-search-box\"\nimport type {\n  GeocodeFn,\n  GeocodeResult,\n  GeoPoint,\n  SelectedLocation,\n} from \"./types\"\n\n// The map pulls in mapbox-gl (a large, browser-only dependency), so it is\n// loaded lazily — the search-only surfaces never pay for it.\nconst LocationMap = React.lazy(() =>\n  import(\"./location-map\").then((m) => ({ default: m.LocationMap }))\n)\n\nexport interface LocationSelectorProps {\n  geocode: GeocodeFn\n  /** Fired whenever a location is confirmed (search pick, pin drop or drag). */\n  onChange?: (location: SelectedLocation | null) => void\n  /** Mapbox token; defaults to the shared `MAPBOX_TOKEN` from `@/lib/mapbox`. */\n  token?: string\n  initialQuery?: string\n  className?: string\n}\n\nfunction toSelected(result: GeocodeResult): SelectedLocation {\n  return {\n    point: result.point,\n    label: result.fullAddress,\n    displayLocation: result.displayLocation,\n    details: result.details,\n  }\n}\n\n/**\n * LocationSelector — the UK location picker: search an address or drop/drag a\n * map pin, resolving to a `{lon, lat}` point plus a formatted address, a display\n * \"Town, County\" line and an OS grid reference. A two-column layout: search and\n * the resolved-address read-out on the left, the map on the right.\n *\n * `geocode` is injected by the consumer (forward for search, reverse for pin\n * moves), so the block carries no network dependency of its own.\n */\nexport function LocationSelector({\n  geocode,\n  onChange,\n  token,\n  initialQuery,\n  className,\n}: LocationSelectorProps) {\n  const [selected, setSelected] = React.useState<SelectedLocation | null>(null)\n\n  const commit = React.useCallback(\n    (next: SelectedLocation | null) => {\n      setSelected(next)\n      onChange?.(next)\n    },\n    [onChange]\n  )\n\n  // A pin move: reverse-geocode the new point, falling back to raw coordinates.\n  const handlePointChange = React.useCallback(\n    async (point: GeoPoint) => {\n      const coordsLabel = `${point.lat.toFixed(5)}, ${point.lon.toFixed(5)}`\n      // Optimistically show the dropped point immediately.\n      commit({ point, label: \"Dropped pin\" })\n      try {\n        const results = await geocode({ point })\n        commit(results[0] ? toSelected(results[0]) : { point, label: coordsLabel })\n      } catch {\n        commit({ point, label: coordsLabel })\n      }\n    },\n    [commit, geocode]\n  )\n\n  return (\n    <div\n      className={cn(\n        \"grid gap-4 rounded-xl border border-border bg-background p-4 md:grid-cols-5\",\n        className\n      )}\n    >\n      <div className=\"flex flex-col gap-4 md:col-span-2\">\n        <LocationSearchBox\n          geocode={geocode}\n          initialQuery={initialQuery}\n          onSelect={(result) => commit(toSelected(result))}\n        />\n        {selected ? (\n          <>\n            <LocationDetailsPanel location={selected} />\n            <Button variant=\"outline\" size=\"sm\" className=\"self-start\" onClick={() => commit(null)}>\n              <Trash01 className=\"size-4\" />\n              Clear\n            </Button>\n          </>\n        ) : (\n          <p className=\"text-sm text-muted-foreground\">\n            Search an address or place a pin on the map. Drag to fine-tune.\n          </p>\n        )}\n      </div>\n\n      <div className=\"md:col-span-3\">\n        <React.Suspense\n          fallback={\n            <div className=\"flex h-72 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground md:h-full\">\n              Loading map…\n            </div>\n          }\n        >\n          <LocationMap\n            point={selected?.point ?? null}\n            onPointChange={handlePointChange}\n            token={token}\n            className=\"h-72 md:h-full md:min-h-[360px]\"\n          />\n        </React.Suspense>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/location-selector.tsx"
    },
    {
      "path": "components/blocks/domain/location-selector/types.ts",
      "content": "/**\n * Location selector — shared types.\n *\n * A UK-focused location picker: search an address (via an injected `geocode`\n * function) or drop/drag a map pin, resolving to a `{lon, lat}` point plus a\n * formatted address and structured details. Grid-reference conversion is done\n * from the point with `@/lib/os-grid-reference`.\n */\n\n/** A WGS84 point. */\nexport interface GeoPoint {\n  lon: number\n  lat: number\n}\n\n/** Structured address components, best-effort. */\nexport interface LocationDetails {\n  city?: string\n  county?: string\n  postcode?: string\n}\n\n/** A single geocoding suggestion returned by the injected `geocode` function. */\nexport interface GeocodeResult {\n  /** Stable id for keys (e.g. a Mapbox `mapbox_id`, or any unique string). */\n  id: string\n  /** The full, human-readable address, e.g. \"10 Downing Street, London, SW1A 2AA\". */\n  fullAddress: string\n  /** A shorter contextual label, e.g. \"Westminster, Greater London\". */\n  displayLocation?: string\n  point: GeoPoint\n  details?: LocationDetails\n}\n\n/**\n * A forward/reverse geocoder. Forward calls pass `{ query }`; reverse calls\n * (from a dropped/dragged pin) pass `{ point }`. Return an ordered list of\n * results — forward geocoding typically returns several, reverse returns one.\n * Injected by the consumer, so the block ships with no network dependency.\n */\nexport type GeocodeFn = (\n  request: { query: string } | { point: GeoPoint }\n) => Promise<GeocodeResult[]>\n\n/** The value a location selector resolves to. */\nexport interface SelectedLocation {\n  point: GeoPoint\n  label: string\n  displayLocation?: string\n  details?: LocationDetails\n}\n\n// ─── Field + picker-modal flow ───────────────────────────────────────────────\n\n/** What kind of record a picked location refers to. */\nexport type LocationValueKind =\n  | \"site\"\n  | \"stockpile\"\n  | \"supplier\"\n  | \"facility\"\n  | \"listing\"\n  | \"freeform\"\n\n/**\n * The value the location field/modal flow resolves to: a display snapshot\n * (label, secondary line, badge) that travels with the backing id and point,\n * so consuming views never need to re-resolve the record to render it.\n */\nexport interface LocationValue {\n  kind: LocationValueKind\n  /** Backing record id, when the pick came from a directory list. */\n  id?: string\n  label: string\n  /** Secondary display line — an address, \"permit · address\", etc. */\n  secondary?: string\n  /** Small badge next to the label, e.g. a permit number. */\n  badge?: string\n  point?: GeoPoint\n  details?: LocationDetails\n}\n\n/** A pickable row in the location picker modal. */\nexport interface LocationOption extends LocationValue {\n  id: string\n  /** Optional grouping heading, e.g. a project name. */\n  group?: string\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/location-selector/types.ts"
    }
  ],
  "type": "registry:block"
}