@e-llm-studio/federated-map-view
v0.0.3
Published
Reusable, backend-agnostic Google Map component with modular map features (search, layers, markers, polygon drawing).
Downloads
85
Maintainers
Keywords
Readme
🗺️ @e-llm-studio/federated-map-view
A reusable, backend-agnostic Google Map component for React. It renders the map and all map-only UI (search, layer switching, markers, info windows, predefined boundaries, custom-boundary drawing, and a filter drawer) and emits events — it never fetches data or persists anything. Your app owns the data and reacts to callbacks.
Table of Contents
- Overview
- Features
- Installation
- Quick Start
FederatedMapViewProps- Feature Guides
- Pass-through DOM Attributes (
elementAttrs) - TypeScript & Generics
- Advanced:
useBoundaryDrawing - Exported Utilities & Constants
- Local Development
- Design Principles
- License
Overview
FederatedMapView is the host component: it owns the GoogleMap instance
and all map-only UI state, then exposes a small, declarative props API. Every
piece of data (markers, boundaries, filter categories) comes in through props,
and every user action goes back out through callbacks — so the component drops
into any backend/state setup without change.
Map features are organized as self-contained modules under src/features/
(e.g. polygon-drawing), which keeps each feature's UI, styles, and types
together and easy to extend.
Features
- 🔍 Places search — Google Places autocomplete that pans the map to the picked place
- 🗂️ Layer switching — roadmap / satellite / terrain / hybrid + custom dark & light styles
- 📍 Markers + info windows — labelled pins with async-resolvable HTML info windows, a "View details" hook, and a highlight ring for a located pin
- 🧲 Marker collision — an optional Advanced-Markers mode that thins out overlapping pins at low zoom and reveals them on zoom-in (needs a vector Map ID)
- 🎯 Click-to-select — a controlled "selected point" marker for pick-a-location flows
- 🟩 Predefined boundaries — render read-only polygons or a GeoJSON URL the component fetches itself (with optional
fitBounds), plus a secondary highlight layer - ✏️ Custom boundary drawing — draw polygons with a live rubber-band, undo/redo, vertex/midpoint reshape handles, whole-shape drag, explicit save, batch save, containment + self-intersection guards
- 🗃️ Manage Custom Boundaries — a slide-out sidebar to search, edit, and delete saved boundaries
- 🧰 "Search By" filter drawer — accordion of consumer-driven categories with server-side pagination, debounced search, skeleton loading, and a "Show on Map" mode picker
- 🔀 "View Permits" toggle — show/hide permit markers from a switch over the map (controlled or uncontrolled)
- 📋 Permits list panel — an exported
PermitsListPanelthe host opens from the summary pill's "View" button (the map stays UI-agnostic — it just reports the click) - 📡 Viewport events —
onBoundsChangedon map idle so you can fetch data for the visible area - 🏷️ Pass-through DOM attributes — inject
id/data-*onto specific controls viaelementAttrs(handy for E2E tests or UI-driving agents) - 🧩 Fully generic — data-carrying shapes are generic over your record type
T; no app-specific type leaks into the library - 📦 Zero bundled assets — inline SVG / icon-font glyphs; image icons come in via
*IconUrlprops
Installation
npm install @e-llm-studio/federated-map-view
# or
yarn add @e-llm-studio/federated-map-viewPeer Dependencies
The consuming app must install and provide these:
| Package | Used for |
| --- | --- |
| react, react-dom | React runtime (>=16.8, <19) |
| @react-google-maps/api | Google Maps JS loader + map primitives |
| lucide-react | Toolbar / popup glyphs |
| primereact + primeicons | Filter drawer + Manage Custom Boundaries controls |
npm install @react-google-maps/api lucide-react primereact primeiconsPrimeReact Setup (required)
The filter drawer and the Manage Custom Boundaries panel are built on
PrimeReact, so your app must initialize PrimeReact once at its root — wrap
the tree in PrimeReactProvider and import a theme. Skipping this makes
PrimeReact's overlay components throw
Cannot read properties of undefined (reading 'hideOverlaysOnDocumentScrolling')
on scroll.
import { PrimeReactProvider } from "primereact/api";
import "primereact/resources/themes/lara-light-blue/theme.css"; // any theme
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
export default function Root() {
return (
<PrimeReactProvider>
<App /> {/* renders <FederatedMapView /> somewhere inside */}
</PrimeReactProvider>
);
}You also need a Google Maps JavaScript API key with the Maps JavaScript API and Places API enabled, and the key's referrer restrictions must allow your app's origin.
Quick Start
import { useMemo, useState } from "react";
import {
FederatedMapView,
type MapMarker,
type MapBoundary,
type BoundaryDraft,
} from "@e-llm-studio/federated-map-view";
// Your own record type — carried through the map and echoed back in callbacks.
type Permit = { permitNumber: string; status: string };
const markers: MapMarker<Permit>[] = [
{
id: 1,
position: { lat: 40.7484, lng: -73.9857 },
label: "Empire State",
data: { permitNumber: "P-1001", status: "Approved" },
},
];
export default function MapScreen() {
const [boundaries, setBoundaries] = useState<MapBoundary<Permit>[]>([]);
const drawing = useMemo(
() => ({
enabled: true,
boundaries,
// A new boundary was saved — assign an id and persist it (return the id).
onBoundarySave: (draft: BoundaryDraft) => {
const id = `b-${Date.now()}`;
setBoundaries((prev) => [...prev, { id, ...draft }]);
return id;
},
onBoundaryDelete: (id: string | number) =>
setBoundaries((prev) => prev.filter((b) => b.id !== id)),
}),
[boundaries]
);
return (
// Give the map a real height — it fills its container.
<div style={{ height: "100vh", width: "100%" }}>
<FederatedMapView<Permit>
mapConfig={{
googleMapsApiKey: GOOGLE_MAPS_API_KEY, // from your own env/config
center: { lat: 40.7549, lng: -73.984 },
zoom: 13,
}}
markers={markers}
drawing={drawing}
infoWindow={{
enabled: true,
resolveContent: (m) =>
`<strong>${m.label}</strong><br/>Permit: ${m.data?.permitNumber}`,
}}
actions={{
onMarkerClick: (m) => console.log("marker", m.id),
onBoundsChanged: (b) => console.log("viewport", b),
}}
/>
</div>
);
}Height matters —
FederatedMapViewfills its parent. A parent with0height renders an invisible map.
FederatedMapView Props
The component is generic over T (your record type). Only mapConfig is
required.
| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| mapConfig | MapConfig | ✅ | API key, center/zoom, region, libraries, and API version. See Map Configuration. |
| search | SearchConfig | – | Places search box (on by default). |
| layer | LayerConfig | – | Base-map layer switcher (on by default). |
| markers | MapMarker<T>[] | – | Pins to render (with labels + payloads). |
| selectedPoint | SelectedPoint | – | A single controlled marker for click-to-select flows. |
| highlightedMarkerId | string \| number \| null | – | Emphasize one marker (raised + highlight ring). Clear with undefined/null. |
| predefinedBoundaries | PredefinedBoundaries | – | Read-only polygons or a GeoJSON URL to fetch & draw. |
| highlightedBoundaries | MapPolygon<T>[] | – | Secondary polygon layer drawn above predefinedBoundaries (blue by default). |
| infoWindow | InfoWindowConfig<T> | – | Marker info-window content resolver. |
| drawing | BoundaryDrawingConfig<T> | – | Custom-boundary drawing + Manage panel. |
| filters | FiltersConfig | – | The "Search By" filter drawer. |
| permitSummary | PermitSummaryConfig | – | Bottom-center summary pill; "View" reports a click (host renders the list). |
| permitsToggle | PermitsToggleConfig | – | "View Permits" switch that shows/hides the markers. |
| actions | MapActions<T> | – | Map/marker event callbacks. |
| config | MapViewConfig | – | Map controls, markerMode (collision), container className. |
| loadingFallback | ReactNode | – | Shown while the Maps script loads (default Loading...). |
Feature Guides
1. Map Configuration (mapConfig)
| Field | Type | Description |
| --- | --- | --- |
| googleMapsApiKey | string | Required. Your Maps JS API key. |
| center | LatLng | Initial center. Defaults to the library's defaultCenter. |
| zoom | number | Initial zoom. Defaults to DEFAULT_ZOOM. |
| region | string | Localization/biasing country code — does not move the map. |
| libraries | ("places" \| "drawing" \| "geometry" \| "visualization" \| "marker")[] | Extra Maps libraries. Defaults to ["places", "marker"]. |
| mapId | string | Vector Map ID. Required for marker collision (see Marker Collision). Makes the map a vector map — Google then ignores the JS styles, so dark/light styling must come from cloud styling on the Map ID. |
| version | string | Maps API version ("weekly", "quarterly", or e.g. "3.58"). |
The Maps loader is a global singleton. If your app mounts more than one
useJsApiLoader(e.g. a secondFederatedMapView), every call must pass the samemapConfig— libraries andmapIdincluded — or Google throws "Loader must not be called again with different options".
2. Search (search)
search={{
enabled: true,
placeholder: "Search a location",
onPlaceSelected: (place) => console.log(place.formatted_address),
}}The component pans to the selected place; you decide what else to do. Requires
the places library (loaded by default).
3. Layers (layer)
layer={{
enabled: true,
selected: "roadmap", // roadmap | satellite | terrain | hybrid | darkMode | lightMode
onChange: (id) => console.log("layer", id),
}}darkMode / lightMode apply the library's bundled map styles; the rest map to
native Google map types. Override the switcher entries with layer.options.
4. Markers & Info Windows (markers + infoWindow)
markers={[
{
id: 1,
position: { lat: 40.75, lng: -73.98 },
label: "Empire State", // pill label above the pin
iconUrls: ["/pin.png"], // optional; omit for the default Google pin
data: myRecord, // echoed back in marker callbacks
},
]}
infoWindow={{
enabled: true,
// Return an HTML string (may be async, e.g. reverse-geocode).
resolveContent: (marker) => `
<div>
<strong>${marker.label}</strong>
<a href="#" data-fmv-view-details>View details</a>
</div>`,
}}Any element carrying the data-fmv-view-details attribute inside the
returned HTML becomes a click target that fires actions.onMarkerViewDetails.
To emphasize one pin (e.g. the permit the user just located in a list), pass its id — the marker is raised above the others and given a highlight ring:
<FederatedMapView highlightedMarkerId={locatedPermitId /* or null to clear */} />Marker Collision
For viewports that hold far more markers than fit on screen (e.g. a state-wide
view of thousands of permits), set config.markerMode to "collision". The map
then uses Advanced Markers and draws only the pins that fit at the current
zoom, revealing the rest as the user zooms in. MapMarker.priority (higher wins)
picks which marker survives a collision.
<FederatedMapView
mapConfig={{ googleMapsApiKey: KEY, mapId: YOUR_VECTOR_MAP_ID }}
markers={[{ id: 1, position, label: "P-1", priority: 10 /* … */ }]}
config={{ markerMode: "collision" }} // default: "default" (draw every pin)
/>Collision requires a vector map, so mapConfig.mapId must be set — without
it the mode silently falls back to "default". The mode changes only what is
drawn; your own counts (e.g. the "Permits Found" pill) still reflect the full
data set you passed in.
5. Selected Point (selectedPoint)
A single controlled marker + optional info window for "pick a location" flows.
Set it in response to onMapClick, clear it on close:
const [point, setPoint] = useState<SelectedPoint>();
<FederatedMapView
selectedPoint={point}
actions={{
onMapClick: (latLng) =>
setPoint({
position: latLng,
render: () => <div>Selected: {latLng.lat.toFixed(4)}</div>,
onClose: () => setPoint(undefined),
}),
}}
/>6. Predefined & Highlighted Boundaries
predefinedBoundaries — read-only geometry drawn on load, either explicit
polygons or a GeoJSON URL the component fetches itself:
// Explicit polygons
predefinedBoundaries={[{ id: "z1", paths: [{ lat, lng }, ...] }]}
// …or a GeoJSON URL (component fetches + draws; fitBounds zooms to it)
predefinedBoundaries={{
geoJsonUrl: "https://example.com/georgia.geojson",
fitBounds: true,
}}highlightedBoundaries — an optional second polygon layer drawn above
the predefined one, for one-off highlights such as the regions currently
selected in the filter drawer. It defaults to a blue theme so it reads as
distinct from the (green) predefined outline; each polygon may still override
its own style:
highlightedBoundaries={selectedRegions /* MapPolygon<T>[] */}7. Custom Boundary Drawing + Manage Panel (drawing)
Enables the draw/edit toolbar, the save popup, and the Manage Custom Boundaries sidebar. The library only renders and edits boundaries — your app owns persistence via the callbacks.
drawing={{
enabled: true,
boundaries, // saved boundaries you control
minPoints: 3,
// Optional geometry guards.
isPointAllowed: (pt) => insideState(pt), // containment
onRestricted: (ctx) => toast.error(`Cannot ${ctx} outside the state`),
onSelfIntersect: (ctx) => toast.error("Boundary can't cross itself"),
onBoundaryCreate: (draft) => toast(`${draft.name} created`), // details captured
onBoundarySave: (draft) => persist(draft), // return assigned id
onBoundariesSave: async (drafts) => persistBatch(drafts), // batch save; may be async (see below)
onDuplicateName: (name) => toast.error(`"${name}" already exists`),
onBoundaryUpdate: (update) => persistUpdate(update),
onBoundaryDelete: (id) => remove(id),
onBoundarySelect: (b) => console.log("selected", b?.id),
onEditSessionChange: (editing) => setEditing(editing), // { name } while editing, null otherwise
onUnsavedChange: (hasUnsaved) => setDirty(hasUnsaved), // warn before navigating away
onManageOpenChange: (open) => setManageOpen(open), // mirror panel state
}}Async batch save & failure recovery. onBoundariesSave may return a
Promise<boolean | void>. Resolving to false (or rejecting) tells the library
the save failed, so it keeps the creation session alive — the drawn
boundaries stay on the map and the user can fix the problem (e.g. a name the
backend rejected as duplicate) and click Save again without redrawing. Resolving
to anything else (or a non-Promise return) is treated as success and ends the
session.
onBoundariesSave: async (drafts) => {
try {
await api.saveBoundaries(drafts);
return true; // success → session ends
} catch {
toast.error("Save failed — your boundaries are still here.");
return false; // failure → session stays open, nothing lost
}
}Drawing UX: click to add points (a live line trails the cursor), click the
first point to close the loop → a save form captures name/description. The
toolbar's Save commits via onBoundarySave (or onBoundariesSave for a
batch). Undo/redo step one vertex/edit at a time. Saved boundaries can be
re-entered from the Manage sidebar → Edit (drag the shape and its
vertex/midpoint handles) → Update Boundary persists via onBoundaryUpdate.
| Key drawing field | Type | Description |
| --- | --- | --- |
| enabled | boolean | Turn the feature on. |
| boundaries | MapBoundary<T>[] | Saved boundaries (consumer-owned). |
| boundaryStyle / activeStyle | PolygonStyle | Styles for saved vs. in-progress polygons. |
| minPoints | number | Min vertices to close a polygon (default 3). |
| isPointAllowed | (pt: LatLng) => boolean | Reject points outside an allowed area. |
| onRestricted | (ctx: "draw" \| "edit") => void | A gesture was blocked by isPointAllowed. |
| onSelfIntersect | (ctx: "draw" \| "edit") => void | A gesture was blocked because the outline would cross itself. |
| onBoundaryCreate | (draft) => void | Details first captured (pre-save) — for a "created" toast. |
| onBoundarySave | (draft) => void \| id | New boundary saved — return the assigned id. |
| onBoundariesSave | (drafts) => void \| id[] \| Promise<boolean \| void> | Batch save of a session; async resolve false = failed (keeps session). |
| onDuplicateName | (name: string) => void | A name already used by another drawn/saved boundary was rejected. |
| onBoundaryUpdate | (update) => void \| id | Existing boundary's shape/details changed. |
| onBoundaryDelete | (id) => void | Boundary deleted. |
| onBoundarySelect | (b \| null) => void | Boundary selected / deselected. |
| onEditSessionChange | ({ name } \| null) => void | An edit session on an existing boundary started ({ name }) or ended (null). |
| onUnsavedChange | (hasUnsaved: boolean) => void | There is (or is no longer) unsaved boundary work — warn before exit. |
| onManageOpenChange | (open: boolean) => void | The Manage panel opened/closed (mirror it in your state). |
| texts | BoundaryDrawingTexts | i18n / white-label string overrides. |
| elementAttrs | BoundaryElementAttrs<T> | Pass-through DOM attributes (see elementAttrs). |
8. Filters — "Search By" drawer (filters)
An accordion of consumer-driven categories with server-side pagination and debounced search. The library renders; your app supplies and paginates the data.
filters={{
enabled: true,
categories, // FilterCategory[] you fetch & paginate
isLoading, // shows a skeleton while true
selectedByCategory: { districts: ["d1"] },
search: persistedTerm, // seeds the input each time the drawer opens
emptyText: "No results found", // shown in a category with no items
onSearchChange: (q) => refetchCategories(q), // debounced
onLoadMore: (categoryId) => fetchNextPage(categoryId),
onSearch: ({ predefined, custom }) => applyFilters(predefined, custom),
onOpenChange: (open) => setDrawerOpen(open), // mirror the drawer's state
custom: { items: customBoundaryItems, onItemMenuClick: openMenu },
// "Show on Map" popup beside the Search By trigger.
boundaryDisplayMode, // "common" | "all"
onBoundaryDisplayModeChange: (m) => setDisplayMode(m),
}}A FilterCategory is { id, label, items, total?, hasMore?, isLoading? }. Set
hasMore when more items exist server-side and append the next page to items
in your onLoadMore handler.
The drawer is display-only — it never filters the map itself.
onSearchhands you the selections (predefinedkeyed by category id,customa flat list of boundary ids) and your app decides what to render.
"Show on Map" (BoundaryDisplayMode) lets the user choose how selected
boundaries are drawn — "common" (only the overlapping area) or "all" (every
selected boundary in full). It's controlled: you own the value and redraw
accordingly.
Only one side panel is open at a time. The filter drawer and the Manage Custom Boundaries sidebar are full-height overlays on the same edge, so opening one closes the other. Both report every transition via
filters.onOpenChange/drawing.onManageOpenChange— including a panel closed implicitly — so mirrored state can't drift.
9. View Permits Toggle (permitsToggle)
A switch rendered over the map (top-right). When on, permit markers are
drawn; when off, they're hidden. Works uncontrolled (seeded from
defaultOn, default true) or controlled (pass value + onChange):
// Uncontrolled — the map owns the state
permitsToggle={{ enabled: true, label: "View Permits", defaultOn: true }}
// Controlled — you own it
permitsToggle={{
enabled: true,
label: "View Permits",
value: viewPermits,
onChange: setViewPermits,
}}10. Permit Summary + Permits List Panel
permitSummary is a presentational bottom-center pill (count + optional
"View" action). It holds no list of its own — the map stays UI-agnostic and
just reports the View click via onView; the host decides what to open.
permitSummary={{ visible: true, count: 12, label: "Permits Found", onView: () => setOpen(true) }}
visiblemust betrue— an emptypermitSummary={{}}renders nothing. The pill is also hidden automatically while a boundary is being drawn/edited.
The library exports a styled PermitsListPanel so you don't have to rebuild
one — but you render it, owning its open state and rows:
import { FederatedMapView, PermitsListPanel } from "@e-llm-studio/federated-map-view";
import type { PermitListItem } from "@e-llm-studio/federated-map-view";
const [open, setOpen] = useState(false);
const rows: PermitListItem[] = permits.map((p) => ({
id: p.id, title: p.number, description: p.address, status: p.status, meta: p.issuedOn,
}));
<div style={{ position: "relative", height: "100vh" }}>
<FederatedMapView
permitSummary={{ visible: true, count: rows.length, onView: () => setOpen((o) => !o) }}
/* … */
/>
{open && (
<PermitsListPanel title="Permits Found" items={rows} onClose={() => setOpen(false)} />
)}
</div>PermitsListPanel is absolutely positioned (bottom-left, above the pill), so
render it as a sibling of the map inside a position: relative box. A row's
status string is auto-mapped to a colour badge (approved/pending/rejected…).
11. Map Events (actions)
| Callback | Fired when |
| --- | --- |
| onLoad(map) | The google.maps.Map instance is ready. |
| onMapClick(latLng, event) | The user clicks empty map (ignored while drawing). |
| onBoundsChanged(bounds) | Map goes idle — use it to fetch data for the viewport. |
| onMarkerClick(marker) | A marker is clicked. |
| onMarkerViewDetails(marker) | A [data-fmv-view-details] element in an info window is clicked. |
| onMarkerInfoWindowClose(marker) | The user dismisses a marker's info window. |
| onStreetViewToggle(isStreetView) | Street View is entered/exited. |
Pass-through DOM Attributes (elementAttrs)
Every major control accepts consumer-supplied DOM attributes (id, data-*, …)
that the library spreads verbatim onto the matching node. The library stays
agnostic about their meaning — it's a hook for E2E test selectors or a
UI-driving agent, without the library needing to know such a thing exists.
<FederatedMapView
search={{ elementAttrs: { "data-testid": "map-search" } }}
drawing={{
enabled: true,
elementAttrs: {
createTrigger: { "data-testid": "draw-boundary" },
saveDrawing: { "data-testid": "save-boundary" },
infoEditDetails: { "data-testid": "boundary-edit" }, // info popup's pencil
infoDelete: { "data-testid": "boundary-delete" }, // info popup's trash
// per-row attrs in the Manage panel are functions of the boundary
boundaryEdit: (b) => ({ "data-testid": `edit-${b.id}` }),
boundaryDelete: (b) => ({ "data-testid": `delete-${b.id}` }),
},
}}
filters={{
enabled: true,
elementAttrs: {
trigger: { "data-testid": "search-by" },
applyButton: { "data-testid": "apply-filters" },
item: (categoryId, item, selected) => ({
"data-testid": `${categoryId}-${item.id}`,
"data-selected": selected,
}),
},
}}
permitsToggle={{
enabled: true,
elementAttrs: { show: { "data-testid": "permits-on" }, hide: { "data-testid": "permits-off" } },
}}
/>Types: ElementAttrs (the base record), BoundaryElementAttrs<T>, and
FilterElementAttrs.
Two behaviours worth knowing — both exist because some controls can't be driven with a single click:
permitsToggle.elementAttrs— a switch can't express "turn it ON", so supplying these also renders two always-mounted, screen-reader-only buttons that set the state explicitly.filters.elementAttrs.displayOption— the "Show on Map" options live in a popup that only mounts while open, so supplying this also renders screen-reader-only twins that pick the mode in one step.
The Places suggestion dropdown is rendered by Google's own widget in a separate container, so it can't be annotated — a programmatic consumer can set the search input's text, but only a real user can pick a suggestion.
TypeScript & Generics
Every data-carrying prop is generic over your record type T. Passing the type
parameter once flows it end-to-end — what you attach to a marker's data comes
back correctly typed in the callbacks:
<FederatedMapView<Permit>
markers={permitMarkers}
actions={{ onMarkerClick: (m) => m.data?.permitNumber /* typed as string */ }}
/>Advanced: useBoundaryDrawing
For consumers building a fully custom toolbar/popup around the same draw/edit/move/undo engine, the imperative hook is exported:
import { useBoundaryDrawing } from "@e-llm-studio/federated-map-view";
import type { BoundaryDrawingApi } from "@e-llm-studio/federated-map-view";
const boundary = useBoundaryDrawing(map, drawingConfig);
// boundary.startCreating(), boundary.setTool("draw"), boundary.undo(),
// boundary.saveActive(), boundary.hasUnsavedChanges, boundary.mode, …Most apps should use the built-in toolbar via the drawing prop instead.
Exported Utilities & Constants
import {
FederatedMapView, // the map component
PermitsListPanel, // host-rendered permits list (see Permit Summary)
useBoundaryDrawing, // the imperative drawing engine (advanced)
containerStyle, // default map container style
defaultCenter, // default map center
DEFAULT_ZOOM, // default zoom
darkModeStyle, // dark map style array
lightModeStyle, // light map style array
DEFAULT_LAYER_OPTIONS,
mapUtils, // marker/overlay/street-view helpers
} from "@e-llm-studio/federated-map-view";Local Development
This package ships a small demo harness (not part of the published build):
npm install
npm start # http://localhost:4000On first load, paste a Google Maps API key into the prompt (kept in localStorage). The demo wires up markers, info windows, a seed boundary, and the drawing/manage flows so you can exercise every callback.
npm run build # produces the publishable dist/ (ESM + CJS + type declarations)src/App.tsx / src/index.tsx are demo-only and excluded from the build.
Design Principles
- No data fetching, store access, or business logic. The consumer provides data via props and reacts via callbacks; the host app owns persistence.
- Generic payloads. Data-carrying shapes are generic over
T, so no app-specific type leaks into the library. - No bundled assets. Icons are inline SVG or icon-font packages; image icons
come in via
*IconUrlprops. - Self-contained styles. Plain,
fmv--prefixed CSS imported as a side effect — no CSS-module or Tailwind setup required in the host app.
License
MIT © e-LLM Studio
