# leaflet-web-components [Leaflet.js](https://leafletjs.com/) as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object with reactive attribute binding. ## Installation ```bash npm install leaflet-web-components leaflet ``` Also published to JSR as [`@buddy/leaflet-components`](https://jsr.io/@buddy/leaflet-components) (`deno add jsr:@buddy/leaflet-components`). `leaflet` is always a peer dependency you install yourself; there is no CommonJS or UMD build. The examples below use the npm name — substitute `@buddy/leaflet-components` if you pull from JSR. ## Import options The package is ESM-only — `tsc` emits `dist/` as individual modules, one per component, with no bundling step. Deep imports work for registering only what you use. ```js // Registers all components import 'leaflet-web-components'; // Register only one component (defines the tag) import 'leaflet-web-components/components/leaflet-marker.js'; ``` Each component is two modules with the same basename: `components/leaflet-marker.js` is the side-effecting one that calls `customElements.define()`, and `elements/leaflet-marker.js` is just the class (`default` export, **no** `define`). Import from `elements/` when you want to subclass a component or register it under a different tag name: ```js // The class only — nothing is registered import LeafletMarkerElement from 'leaflet-web-components/elements/leaflet-marker.js'; // …or the whole set, by name import { LeafletMarkerElement, LeafletCircleElement } from 'leaflet-web-components/elements'; ``` On npm the package root resolves to a build that also installs the ambient TypeScript augmentations (`HTMLElementTagNameMap` so `document.querySelector('leaflet-map')` is typed, and the custom-event map). The JSR build omits those — JSR's type rules disallow global augmentation — so from JSR you add your own if you want them. ## Usage Import once to register all custom elements, then use them declaratively in HTML. ```html Hello! Hover me I am a circle ``` ## `leaflet-map` The root component. All other components must be children of ``. ```html ``` ### View state These attributes stay in sync with the map as the user interacts with it — panning updates `lat`/`lng`, zooming updates `zoom`. | Attribute | Default | Description | | ---------- | ------- | --------------------------------------------------------------------------- | | `lat` | `0` | Center latitude | | `lng` | `0` | Center longitude | | `zoom` | `2` | Zoom level | | `min-zoom` | `0` | Minimum zoom level | | `max-zoom` | — | Maximum zoom level. When unset, Leaflet uses the tile layer's own max zoom. | ### Interaction Boolean options that default to `true` are controlled by the presence of a `disable-*` attribute. Handler-based options can be toggled at any time; the rest only apply at construction. | Attribute | Controls | Live? | | ------------------------------- | -------------------------------- | ----- | | `disable-dragging` | Mouse/touch panning | ✓ | | `disable-scroll-wheel-zoom` | Scroll-wheel zoom | ✓ | | `disable-double-click-zoom` | Double-click zoom | ✓ | | `disable-touch-zoom` | Pinch-to-zoom | ✓ | | `disable-box-zoom` | Shift-drag zoom box | ✓ | | `disable-keyboard` | Keyboard pan/zoom | ✓ | | `disable-zoom-control` | Built-in zoom control | — | | `disable-attribution-control` | Built-in attribution | — | | `disable-close-popup-on-click` | Close popup on map click | — | | `disable-track-resize` | Auto-resize on window resize | — | | `disable-bounce-at-zoom-limits` | Bounce animation at min/max zoom | — | | `disable-tap-hold` | Long-press context menu (mobile) | — | Boolean options that default to `false` are enabled by adding the attribute: | Attribute | Description | | ----------------- | ------------------------------------------------------------- | | `prefer-canvas` | Render vector layers on Canvas instead of SVG | | `world-copy-jump` | Pan to the original world copy when crossing the antimeridian | ### Animation | Attribute | Default | Description | | ------------------------------- | ------- | -------------------------------- | | `disable-zoom-animation` | — | Disable CSS zoom animation | | `disable-fade-animation` | — | Disable tile fade-in | | `disable-marker-zoom-animation` | — | Disable marker zoom animation | | `zoom-animation-threshold` | `4` | Max zoom delta for animated zoom | ### Inertia & panning | Attribute | Default | Description | | ---------------------- | ---------- | ---------------------------- | | `disable-inertia` | — | Disable inertial panning | | `inertia-deceleration` | `3000` | Deceleration rate (px/s²) | | `inertia-max-speed` | `Infinity` | Maximum inertia speed (px/s) | | `ease-linearity` | `0.2` | Pan easing linearity | ### Zoom behaviour | Attribute | Default | Description | | ---------------------- | ------- | ------------------------------------------------------ | | `zoom-snap` | `1` | Zoom snapping interval; `0` for continuous zoom | | `zoom-delta` | `1` | Zoom step per keyboard/button press | | `max-bounds-viscosity` | `0` | How much the bounds resist panning past them (`0`–`1`) | ### Scroll wheel | Attribute | Default | Description | | ------------------------- | ------- | ------------------------------------ | | `wheel-debounce-time` | `40` | Debounce delay for wheel events (ms) | | `wheel-px-per-zoom-level` | `60` | Pixels of scroll per zoom level | ### Keyboard & touch | Attribute | Default | Description | | -------------------- | ------- | ---------------------------------------- | | `keyboard-pan-delta` | `80` | Pan distance per key press (px) | | `tap-tolerance` | `15` | Max touch movement to trigger a tap (px) | ### Rendering | Attribute | Default | Description | | -------------------- | --------- | ---------------------------------------------------------- | | `transform-3d-limit` | `8388608` | Max CSS `translate3d` component value before a layer reset | ### Accessing the underlying map ```js const el = document.querySelector('leaflet-map'); el.leafletObject; // L.Map instance (undefined before connected) el.zoom; // current zoom (reads live from map, falls back to attribute) el.scrollWheelZoom = false; // disable at runtime ``` All `leaflet-map` properties are two-way: reading returns the live map value; writing updates both the attribute and the map. ## Escape hatch Every component exposes a `leafletObject` getter that returns the underlying Leaflet instance. This is useful when you need to call Leaflet API methods directly: ```js const marker = document.querySelector('leaflet-marker'); marker.leafletObject?.setLatLng([51.5, -0.09]); const map = document.querySelector('leaflet-map'); map.leafletObject?.flyTo([48.86, 2.35], 13); ``` Returns `undefined` while the element isn't connected to the DOM. ### CSS Leaflet's stylesheet is loaded from a CDN via a `` in the shadow DOM. Marker icon paths are derived from the CSS URL automatically. | Attribute | Default | Description | | ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `css-url` | `https://unpkg.com/leaflet@1.9.4/dist/leaflet.css` | URL for Leaflet CSS | | `css-integrity` | `sha256-...` | SRI hash (required with default URL; when `css-url` is custom, no default integrity is used unless explicitly set) | | `css-crossorigin` | _(auto)_ | `"anonymous"` when integrity is active, otherwise absent. Set explicitly to override. | ### Resize A `ResizeObserver` on the host element automatically calls `map.invalidateSize()` whenever the element's dimensions change — from CSS classes, inline styles, attribute changes, or parent layout. ## Events Every event the underlying Leaflet object fires is re-emitted on the element as a `leaflet:` DOM event — `zoomend` becomes `leaflet:zoomend`, `popupopen` becomes `leaflet:popupopen`, and so on. This is generic and automatic for every component; nothing needs to be configured per event. ```js const map = document.querySelector('leaflet-map'); map.addEventListener('leaflet:moveend', (e) => { console.log(e.detail.target.getCenter()); }); const marker = document.querySelector('leaflet-marker'); marker.addEventListener('leaflet:dragend', (e) => { console.log('dragged', e.detail.distance, 'px'); }); ``` `event.detail` is the same object a native Leaflet `.on()` listener receives — it always carries `type`, `target`, and `sourceTarget` in addition to whatever fields that particular event adds (`latlng` for mouse events, `popup` for `popupopen`/`popupclose`, `distance` for `dragend`, etc.). These events don't bubble: Leaflet already propagates layer events up to the map internally, so a bubbling DOM event would make `` see each one twice. Which events a component can fire depends on the Leaflet class it wraps — see [Leaflet's own event reference](https://leafletjs.com/reference.html) for the full list per class (Map, Layer, Marker, Path, Popup, TileLayer, etc.). ## TypeScript `leaflet-*` elements are typed. `document.createElement`/`querySelector` infer the right component class for every tag: ```ts const map = document.querySelector('leaflet-map'); // LeafletMap | null ``` `addEventListener` is typed per component against the real Leaflet event payload for events that component actually fires — ordinary DOM events (`click`, etc.) still work normally, and an event name the component doesn't fire is a compile error: ```ts marker.addEventListener('leaflet:dragend', (e) => { e.detail.distance; // number — DragEndEvent }); // Type error: baselayerchange is only fired by , not . marker.addEventListener('leaflet:baselayerchange', () => {}); ``` There's no generic fallback overload for `leaflet:*` names — that's deliberate, so a typo or a wrong event name doesn't silently type-check. A genuinely dynamic (non-literal) event name string needs a cast. --- ## Components ### `` | Attribute | Default | Description | | --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------- | | `url` | `''` | Tile URL template (`https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`) | | `attribution` | `''` | Attribution text | | `min-zoom` | `0` | Minimum zoom level | | `max-zoom` | `18` | Maximum zoom level | | `opacity` | `1.0` | Tile layer opacity | | `z-index` | `1` | Z-index | | `subdomains` | `'abc'` | Subdomains for `{s}` in the URL template | | `tms` | — | Use TMS tile coordinate scheme | | `zoom-offset` | `0` | Offset added to the zoom level when requesting tiles | | `zoom-reverse` | — | Reverse the zoom level when requesting tiles | | `detect-retina` | — | Request higher-resolution tiles on retina displays | | `cross-origin` | `''` | CORS setting for tile images | | `referrer-policy` | — | `referrerPolicy` for tile image requests | | `error-tile-url` | `''` | Fallback tile image on load error | | `tile-size` | `256` | Tile size in pixels | | `no-wrap` | — | Don't wrap tiles horizontally across the antimeridian | | `bounds` | `''` | Restrict tile loading to this bounding box, as JSON: `[[south,west],[north,east]]` | | `class-name` | `''` | CSS class for tile images | | `min-native-zoom` | — | Lowest zoom level the tile source natively supports | | `max-native-zoom` | — | Highest zoom level the tile source natively supports | | `keep-buffer` | `2` | Extra rows/columns of tiles to keep loaded outside the viewport | | `update-when-idle` | — | Update tiles only when the map stops moving | | `update-when-zooming` | `true` (toggle with `update-when-zooming="false"`) | Update tiles continuously while zooming | | `update-interval` | `200` | Throttle between tile updates while panning (ms) | | `pane` | `'tilePane'` | Map pane name | Most of these apply only at construction — Leaflet exposes no setter for them, so changing the attribute afterward has no effect. `min-zoom`, `max-zoom`, `opacity`, and `z-index` are live. ### `` All `` attributes above apply (a WMS layer is a tile layer), plus: | Attribute | Default | Description | | ------------- | -------------- | -------------------------------------------------------------------------------------------- | | `url` | `''` | WMS service URL | | `layers` | `''` | Comma-separated layer names | | `styles` | `''` | Comma-separated style names | | `format` | `'image/jpeg'` | Image format | | `transparent` | — | Request transparent tiles | | `version` | `'1.1.1'` | WMS version | | `uppercase` | — | Use uppercase WMS parameter names | | `crs` | — | Coordinate reference system by name: `'EPSG3857'`, `'EPSG4326'`, `'EPSG3395'`, or `'Simple'` | For a CRS Leaflet doesn't ship (a custom projection from a plugin, say), nest a CRS-providing element instead of using the `crs` attribute. It's just a custom element that fires a `leaflet-crs-changed` event on itself, bubbling, carrying the actual `L.CRS` value — no base class required: ```js class MyCRSProvider extends HTMLElement { connectedCallback() { this.dispatchEvent( new CustomEvent('leaflet-crs-changed', { bubbles: true, detail: { crs: myPluginCRS } }), ); } } customElements.define('my-crs-provider', MyCRSProvider); ``` ```html ``` A nested provider takes priority over the `crs` attribute when both are present. `crs` is construction-only (Leaflet has no live setter for it), so picking one up — from either source — rebuilds the whole layer; place the provider so it connects before you need the correct projection to take effect, since there's nothing to revert to partway through a request. ### `` | Attribute | Default | Description | | ---------------- | ------- | ----------------------------- | | `lat` | `0` | Latitude | | `lng` | `0` | Longitude | | `title` | `''` | Tooltip text on hover | | `alt` | `''` | Alt text for the marker image | | `draggable` | — | Allow dragging the marker | | `opacity` | `1.0` | Marker opacity | | `z-index-offset` | `0` | Z-index offset | A `` or `` child sets the marker's icon (see below); without one, Leaflet's default icon is used. ### `` Sets the marker icon to an image. Not rendered itself — swapped into a parent `` via `setIcon()` whenever an attribute changes. Produces no icon (Leaflet's default is used instead) until `icon-url` is set. | Attribute | Default | Description | | ------------------- | ------- | --------------------------------------------------------------------- | | `icon-url` | `''` | Icon image URL (required) | | `icon-retina-url` | `''` | Higher-resolution icon image URL | | `icon-size` | — | Icon size as JSON: `[width, height]` | | `icon-anchor` | — | Point of the icon aligned to the marker's location, as JSON: `[x, y]` | | `popup-anchor` | — | Popup anchor point relative to `icon-anchor`, as JSON: `[x, y]` | | `tooltip-anchor` | — | Tooltip anchor point relative to `icon-anchor`, as JSON: `[x, y]` | | `shadow-url` | `''` | Shadow image URL | | `shadow-retina-url` | `''` | Higher-resolution shadow image URL | | `shadow-size` | — | Shadow size as JSON: `[width, height]` | | `shadow-anchor` | — | Shadow anchor point, as JSON: `[x, y]` | | `class-name` | `''` | CSS class for the icon image | ### `` Sets the marker icon to an HTML element instead of an image. Content comes from `innerHTML` (or the `html` attribute if no markup is given) — either way it's rebuilt on every change, the same as ``. | Attribute | Default | Description | | ---------------- | -------------------- | --------------------------------------------------------------------- | | `icon-size` | — | Icon size as JSON: `[width, height]` | | `icon-anchor` | — | Point of the icon aligned to the marker's location, as JSON: `[x, y]` | | `popup-anchor` | — | Popup anchor point relative to `icon-anchor`, as JSON: `[x, y]` | | `tooltip-anchor` | — | Tooltip anchor point relative to `icon-anchor`, as JSON: `[x, y]` | | `class-name` | `'leaflet-div-icon'` | CSS class for the icon element | | `html` | `''` | HTML content, if not using `innerHTML` | | `bg-pos` | — | Background position offset, as JSON: `[x, y]` | ### `` All [path style](#path-style) attributes apply. | Attribute | Default | Description | | --------- | ------- | ---------------- | | `lat` | `0` | Center latitude | | `lng` | `0` | Center longitude | | `radius` | `1000` | Radius in meters | ### `` All [path style](#path-style) attributes apply. | Attribute | Default | Description | | --------- | ------- | ---------------- | | `lat` | `0` | Center latitude | | `lng` | `0` | Center longitude | | `radius` | `10` | Radius in pixels | ### `` Coordinates are taken from child `` elements, not from attributes. All [path style](#path-style) attributes apply, except `fill` defaults to unfilled (a polyline isn't a closed shape, but it can still be filled explicitly). | Attribute | Default | Description | | --------------- | ------- | ------------------------------------------------------------------------------- | | `smooth-factor` | `1.0` | Simplification factor applied while panning/zooming — higher is more simplified | | `no-clip` | — | Disable polyline clipping | ### `` Coordinates are taken from child `` elements, not from attributes. All [path style](#path-style) attributes apply. ### `` All [path style](#path-style) attributes apply. | Attribute | Default | Description | | --------- | ------- | --------------------------------------------------- | | `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | ### `` Vertex helper — not rendered directly. Used as a child of `` or ``. | Attribute | Default | Description | | --------- | ------- | ----------- | | `lat` | `0` | Latitude | | `lng` | `0` | Longitude | ### `` | Attribute | Default | Description | | ------------------- | ------- | --------------------------------------------------- | | `url` | `''` | Image URL | | `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | | `opacity` | `1.0` | Overlay opacity | | `alt` | `''` | Alt text | | `interactive` | — | Receive mouse/touch events | | `cross-origin` | `''` | CORS setting for the image | | `error-overlay-url` | `''` | Fallback image on load error | | `z-index` | `0` | Z-index | | `class-name` | `''` | CSS class for the image element | ### `` | Attribute | Default | Description | | ------------------- | ------------------------------------------------ | --------------------------------------------------- | | `url` | `''` | Video URL | | `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | | `opacity` | `1.0` | Overlay opacity | | `alt` | `''` | Alt text | | `interactive` | — | Receive mouse/touch events | | `cross-origin` | `''` | CORS setting for the video | | `loop` | — | Loop playback | | `autoplay` | — | Start playing automatically | | `muted` | — | Mute audio | | `playsinline` | — | Play inline (mobile) | | `z-index` | `0` | Z-index | | `class-name` | `''` | CSS class for the video element | | `keep-aspect-ratio` | `true` (toggle with `keep-aspect-ratio="false"`) | Preserve the video's aspect ratio within its bounds | | `error-overlay-url` | `''` | Fallback image on load error | ### `` Content comes from an inline `` child element. No `url` attribute. | Attribute | Default | Description | | -------------- | ------- | --------------------------------------------------- | | `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | | `opacity` | `1.0` | Overlay opacity | | `interactive` | — | Receive mouse/touch events | | `cross-origin` | `''` | CORS setting | | `z-index` | `0` | Z-index | | `class-name` | `''` | CSS class for the SVG element | ### `` Passthrough container. No attributes. Accepts layers, popups, and tooltips as children. ### `` Passthrough container with Leaflet's `FeatureGroup` (supports `getBounds()`, style propagation, etc.). No attributes. Accepts layers, popups, and tooltips as children. ### `` All [path style](#path-style) attributes apply — each feature GeoJSON builds gets them as its style. | Attribute | Default | Description | | --------- | ------- | -------------- | | `data` | `''` | GeoJSON string | ### `` Content comes from `innerHTML`, not an attribute. Mutations to innerHTML sync automatically via a `MutationObserver`. | Attribute | Default | Description | | -------------- | ------- | ------------------------------------------------------------- | | `lat` | `0` | Latitude (omit to auto-attach to the parent layer's position) | | `lng` | `0` | Longitude | | `max-width` | `300` | Maximum width (px) | | `min-width` | `50` | Minimum width (px) | | `max-height` | `0` | Maximum height (px; `0` = unlimited) | | `auto-pan` | — | Automatically pan the map to keep the popup visible | | `close-button` | — | Show a close button | | `auto-close` | — | Close the popup when another popup is opened | ### `` Content comes from `innerHTML`, not an attribute. | Attribute | Default | Description | | ----------- | -------- | ------------------------------------------------------------------------- | | `lat` | `0` | Latitude (omit to auto-attach to the parent layer's position) | | `lng` | `0` | Longitude | | `pane` | — | Map pane name | | `offset` | — | Pixel offset as JSON: `[x, y]` | | `direction` | `'auto'` | Direction: `'right'`, `'left'`, `'top'`, `'bottom'`, `'center'`, `'auto'` | | `permanent` | — | Always visible (no hover required) | | `sticky` | — | Follow the mouse | | `opacity` | `1.0` | Tooltip opacity | ### `` | Attribute | Default | Description | | ---------------- | ------------ | ----------------------- | | `position` | `'topleft'` | Corner position | | `zoom-in-text` | `'+'` | Zoom-in button label | | `zoom-in-title` | `'Zoom in'` | Zoom-in button tooltip | | `zoom-out-text` | `'-'` | Zoom-out button label | | `zoom-out-title` | `'Zoom out'` | Zoom-out button tooltip | ### `` | Attribute | Default | Description | | ---------- | --------------- | --------------------------- | | `position` | `'bottomright'` | Corner position | | `prefix` | `''` | Text before the attribution | ### `` | Attribute | Default | Description | | ------------------ | -------------- | ------------------------------------- | | `position` | `'bottomleft'` | Corner position | | `max-width` | `100` | Maximum width of the scale (px) | | `metric` | — | Show metric scale (m/km) | | `imperial` | — | Show imperial scale (mi/ft) | | `update-when-idle` | — | Update only when the map stops moving | ### `` The built-in Leaflet layer switcher. Children with `type="base"` appear as radio buttons; children with `type="overlay"` (or no `type`) appear as checkboxes. ```html ``` | Attribute | Default | Description | | ------------------ | ---------------------------------------- | ------------------------------------------------------------ | | `position` | `'topright'` | Corner position | | `collapsed` | `true` (toggle with `collapsed="false"`) | Collapse into an icon until hovered | | `auto-z-index` | `true` | Assign increasing z-indexes to layers | | `hide-single-base` | — | Hide the base layers section when only one base layer exists | | `sort-layers` | — | Sort layers alphabetically | Each child layer inside `` may carry: | Attribute | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type="base"` | Show as a radio button (base layer). Omit or set `type="overlay"` for a checkbox. | | `name` | Human-readable label displayed in the control. | | `active` | Add the layer to the map immediately so it starts visible. Internally, the control dispatches a `leaflet-add-layer` event that the map handles. Layers without `active` are removed from the map before the control renders, so they correctly appear unchecked. | The control intercepts child `leaflet-register` events and stops propagation — layers inside `` are managed by the layers control, not added directly to the map. ## Path style The following attributes apply to ``, ``, ``, ``, ``, and ``. | Attribute | Default | Description | | ----------------------- | ---------------------------------------------------- | ------------------------------------------------- | | `stroke` | `true` (toggle with `stroke="false"`) | Draw the stroke | | `color` | `'#3388ff'` | Stroke color | | `weight` | `3` | Stroke width (px) | | `opacity` | `1.0` | Stroke opacity | | `line-cap` | `'round'` | Line cap: `'butt'`, `'round'`, `'square'` | | `line-join` | `'round'` | Line join: `'miter'`, `'round'`, `'bevel'` | | `dash-array` | `''` | Dash pattern, e.g. `'5, 10'` | | `dash-offset` | `''` | Dash offset | | `fill` | `true` (toggle with `fill="false"`) | Enable fill | | `fill-color` | `'#3388ff'` | Fill color | | `fill-opacity` | `0.2` | Fill opacity | | `fill-rule` | `'evenodd'` | Fill rule: `'nonzero'`, `'evenodd'` | | `interactive` | `true` (toggle with `interactive="false"`) | Receive mouse/touch events | | `bubbling-mouse-events` | `true` (toggle with `bubbling-mouse-events="false"`) | Let mouse events on this layer also reach the map | | `class-name` | `''` | CSS class for the path element | | `pane` | `'overlay'` | Map pane name | `stroke`, `color`, `weight`, `opacity`, `line-cap`, `line-join`, `dash-array`, `dash-offset`, `fill`, `fill-color`, `fill-opacity`, and `fill-rule` are live — Leaflet has a setter for them (`setStyle`). `interactive`, `bubbling-mouse-events`, `class-name`, and `pane` only apply at construction. ## Nesting rules - **Popup / tooltip as child of a layer** → bound via `bindPopup` / `bindTooltip`. - **Layer as child of a group** → added via `addLayer`. - **Any layer as child of `leaflet-map`** → added to the map directly. - **`leaflet-polygon` / `leaflet-polyline`** take their coordinates from `` children, not from attributes. - **`` / `` as child of ``** → swapped in via `setIcon()`. - **Layer as child of ``** → intercepted by the control and registered as a base or overlay entry. The control stops propagation so the layer doesn't reach the map directly. Use `active` to start the layer visible. ## Building your own components This library's own components are built from a small toolkit — a mixin, some prop-codec builders, and a few reusable fragments — and that whole toolkit is exported from the package root. A component wrapping a Leaflet plugin (a custom layer, control, or icon) is built exactly the same way `` or `` are, whether or not it ever ends up in this package. ### The pattern 1. Describe your attributes as a `const PROPS` table, build a base class with `const Base = WithProps(PROPS, options?)`, then `extends Base`. 2. Implement `createLeafletObject(options)`, returning whatever Leaflet object your plugin provides. 3. `declare readonly leafletObject?: TheType;` — `WithProps`'s own inference of the object type from the `PROPS` table alone isn't reliable enough to skip this (every component in this package does it). 4. Call `customElements.define('my-plugin-layer', MyPluginLayer)`. (If you want to let _your_ consumers subclass or re-register, mirror this package's split: put the class in its own module as a `default` export and keep the `define()` call in a separate side-effecting module.) ```ts import { MyClusterGroup, type MyClusterGroupOptions } from 'some-leaflet-plugin'; import { WithProps, bool, num, type PropDef, type LeafletElementConstructor, } from 'leaflet-web-components'; const PROPS: { radius: PropDef; disableClusteringAtZoom: PropDef; spiderfy: PropDef; } = { radius: num(80), disableClusteringAtZoom: num(18), spiderfy: bool(true), }; const Base: LeafletElementConstructor = WithProps(PROPS); class MyClusterLayer extends Base { declare readonly leafletObject?: MyClusterGroup; createLeafletObject(options: MyClusterGroupOptions): MyClusterGroup { return new MyClusterGroup(options); } } customElements.define('my-cluster-layer', MyClusterLayer); ``` The two `const`s with explicit type annotations — rather than `class X extends WithProps({ ... })` inline — are what lets this package publish to JSR, whose type checker rejects a call expression as a superclass and any public type it can't resolve without full inference. If you're only consuming the package in an app (not publishing your component to JSR), the inline `extends WithProps({ ... })` form works too. That's it — no other integration point is needed. `WithProps` handles `observedAttributes`, attribute↔property sync, building the options object from attributes on connect, dispatching attribute changes to the matching Leaflet setter (or a `set` you provide per prop, for anything a setter can't handle), and re-emitting every event your object fires as `leaflet:`. ### Attribute builders (`PROPS` table entries) | Builder | Attribute holds | Notes | | ------------------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `str(default?, opts?)` | a string | | | `num(default?, opts?)` | a number | | | `bool(default?, opts?)` | a boolean | present/absent by default; use `bool(true, ...)` for an option Leaflet defaults to `true`, so `attr="false"` can opt out | | `choice<'a' \| 'b'>(default, opts?)` | a string literal union | not runtime-validated, just typed | | `json(default, opts?)` | JSON | decodes/encodes via `JSON.parse`/`JSON.stringify` — for points, bounds, arbitrary objects | | `disabled(opts?)` | a boolean | the inverse of `bool(true)`: attribute is auto-named `disable-`, presence means `false` | | `positional(def)` | — | wraps any of the above; marks a prop your Leaflet constructor takes as an argument rather than an option, so it's excluded from the options object `createLeafletObject` receives | Each builder's `opts` can include `set(obj, value, el)` (called on attribute change instead of the default `setXyz()` lookup — needed when there's no matching setter, or the update needs more than one value, like a lat/lng pair), `get(obj)` (backs the live property getter, and — paired with `event` — keeps the attribute in sync whenever your object fires that event), and `attribute` (override the default kebab-cased name). ### Reusing the built-in fragments Common attribute groups are already factored out and exported: `pathProps` (Leaflet `Path` styling — color, weight, dashes, fill, etc.), `latLngProps` (a synced lat/lng pair, for anything positioned on the map), `tileLayerProps` (the base `GridLayer`/`TileLayer` options), and `urlProp`. Each is exported with an explicit object type, so a `PROPS` annotation can pull it in with `typeof`. A custom vector layer plugin, for instance, can just spread `pathProps` in instead of redeclaring styling attributes: ```ts import { WithProps, pathProps, positional, json, type Positional, type LeafletElementConstructor, } from 'leaflet-web-components'; const PROPS: typeof pathProps & { data: Positional; } = { ...pathProps, data: positional(json(null)), }; const Base: LeafletElementConstructor = WithProps(PROPS); class MyShapeLayer extends Base { /* ... */ } ``` `Positional` is the exported alias for a `positional()` prop's type (`PropDef & { option: false }`). ### Nesting into the tree By default (`attach: 'children'`, the default when you omit `options`), your component registers itself with its nearest ancestor and accepts registering descendants as child layers/popups/tooltips. Use `attach: 'self'` for something that has a parent but manages no children of its own (a popup, a tooltip, a control); use `attach: 'none'` for something that's neither (an icon). The registration check on the _receiving_ side keys off `instanceof Layer` / `instanceof Popup` / `instanceof Tooltip` — Leaflet's own base classes, not this package's. Since real Leaflet plugins are conventionally built by extending those same classes, a plugin layer or control nests correctly under ``, ``, ``, or `` with no special-casing on either side. ### Typing events `LeafletAddEventListener`/`LeafletRemoveEventListener` narrow `addEventListener`/`removeEventListener` for `leaflet:` events, the same way every component in this package does: ```ts import { WithProps, type LeafletAddEventListener, type LeafletRemoveEventListener, type PathEvents, } from 'leaflet-web-components'; // Base = WithProps(PROPS), as in the previous example class MyShapeLayer extends Base { declare addEventListener: LeafletAddEventListener; declare removeEventListener: LeafletRemoveEventListener; /* ... */ } ``` `event-types.ts`'s fragments (`MouseEvents`, `PopupBindEvents`, `DragEvents`, `TileEvents`, ...) and their compositions (`PathEvents`, `MarkerEvents`, `TileLayerEvents`, `GroupEvents`, `MapEvents`, ...) are all exported for reuse; compose your own if your plugin fires events none of them cover. If you also want `document.createElement('my-cluster-layer')` to infer your class, augment `HTMLElementTagNameMap`: ```ts declare global { interface HTMLElementTagNameMap { 'my-cluster-layer': MyClusterLayer; } } ``` This package keeps its own version of that augmentation in a dedicated module (`src/core/globals.ts`) that its npm entry imports and its JSR entry doesn't — JSR disallows `declare global`. If you publish your plugin to JSR, do the same: keep the augmentation out of your JSR entry's module graph. ### Custom marker icons `` listens for a generic `icon-changed` event and calls `setIcon()` with whatever `detail.icon` holds — it doesn't check which component fired it. A component wrapping a plugin that provides its own icon (e.g. a themed marker icon set) just needs to call `emitIconChanged`: ```ts import { emitIconChanged } from 'leaflet-web-components'; // after building or updating your icon: emitIconChanged(this, myPluginIcon); // and on disconnect: emitIconChanged(this, null); ``` and it plugs into any `` immediately, the same as this package's own ``/``. ### One ordering pitfall, if your plugin has multiple custom elements `customElements.define()` upgrades every matching element already parsed into the page immediately and synchronously. If one of your custom elements listens for a bubbling announcement fired by another of your own custom elements on connect (the way `` listens for ``), the listening one must be registered (`customElements.define`d) first — otherwise, on a real static-HTML page, every instance of the "announcer" tag upgrades and fires its one-shot announcement before the "listener" tag even exists, and it's lost for good. This only matters for relationships _within_ your own plugin; it doesn't affect nesting into this package's components, which is handled by the bubbling registration protocol above, not a one-shot announcement. ## Development ```bash npm run build # tsc emits individual ESM modules to dist/ (no bundling) npm run typecheck # tsc --noEmit, then tsc -p tsconfig.test.json for test/ npm run lint # oxlint over src/ and test/ npm run format # oxfmt npm run test # vitest run npm run test:watch # vitest, watch mode # serve the project root with any static-file server and open index.html ``` Design docs — how the library is built and why — are in [`docs/`](./docs/README.md).