Compare commits

..

1 Commits
main ... v0.1.0

@ -25,25 +25,6 @@ prop table alone.
[07](./07-tooling-and-build.md)). [07](./07-tooling-and-build.md)).
- As tree root it also listens for `leaflet-add-layer` / `leaflet-remove-layer` - As tree root it also listens for `leaflet-add-layer` / `leaflet-remove-layer`
(used by group internals) alongside `leaflet-register`. (used by group internals) alongside `leaflet-register`.
- **`fit-to-markers` / `fit-padding` / `fit-max-zoom`** are not Leaflet options.
With `fit-to-markers` present the map ignores `lat`/`lng`/`zoom` and instead
`fitBounds()`es a box around every layer it can locate — anything with a
`getLatLng()` (markers, circles) or `getBounds()` (rectangles, image/video
overlays); tile layers have neither and are skipped, as are open
popups/tooltips (a map layer with a `getLatLng()`). **The only reframe
trigger is a component registering** — initial page load and any child added
later. Panning, zooming, opening a popup and removing a marker all leave the
view exactly where it is. `#onRegister` calls `#scheduleFit()`, which
coalesces the burst of registrations during page load onto one microtask, so
it's a single `fitBounds` call, not one per marker. `fit-padding` (default
`20`) is the pixel gutter left around the bounds; `fit-max-zoom` (default
none) caps the zoom, which matters when a single marker would otherwise snap
to max zoom. `setView()` still runs once at construction so the map has a
valid view before the first frame.
- Panning / zooming always writes the live centre and zoom back to the
`lat` / `lng` / `zoom` attributes (`moveend` / `zoomend`, the standard `event:`
write-back in the prop table) — including the view `fitBounds()` itself lands
on. That write-back is one-way here: it never re-triggers a fit.
## `leaflet-polygon` / `leaflet-polyline` — vertices from children ## `leaflet-polygon` / `leaflet-polyline` — vertices from children

@ -1,7 +1,6 @@
{ {
"name": "@buddy/leaflet-components", "name": "@buddy/leaflet-components",
"version": "0.2.0", "version": "0.1.0",
"description": "Leaflet.js as native Web Components — one custom element per Leaflet object.",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "leaflet-web-components", "name": "leaflet-web-components",
"version": "0.2.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "leaflet-web-components", "name": "leaflet-web-components",
"version": "0.2.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",

@ -1,7 +1,7 @@
{ {
"name": "leaflet-web-components", "name": "leaflet-web-components",
"version": "0.2.0", "version": "0.1.0",
"description": "Leaflet.js as native Web Components — one custom element per Leaflet object.", "description": "LeafletJS as Web Components",
"main": "dist/index.npm.js", "main": "dist/index.npm.js",
"module": "dist/index.npm.js", "module": "dist/index.npm.js",
"types": "dist/index.npm.d.ts", "types": "dist/index.npm.d.ts",

@ -27,20 +27,17 @@ import type {
ZoomAnimEvent, ZoomAnimEvent,
} from 'leaflet'; } from 'leaflet';
/** `movestart` / `move` / `moveend`. */
export interface MoveEvents { export interface MoveEvents {
movestart: LeafletEvent; movestart: LeafletEvent;
move: LeafletEvent; move: LeafletEvent;
moveend: LeafletEvent; moveend: LeafletEvent;
} }
/** A layer's own `add` / `remove` (added to / removed from a map). */
export interface LayerAddRemoveEvents { export interface LayerAddRemoveEvents {
add: LeafletEvent; add: LeafletEvent;
remove: LeafletEvent; remove: LeafletEvent;
} }
/** `click` / `dblclick` / `mousedown` / `mouseup` / `mouseover` / `mouseout` / `contextmenu`. */
export interface MouseEvents { export interface MouseEvents {
click: LeafletMouseEvent; click: LeafletMouseEvent;
dblclick: LeafletMouseEvent; dblclick: LeafletMouseEvent;
@ -51,26 +48,22 @@ export interface MouseEvents {
contextmenu: LeafletMouseEvent; contextmenu: LeafletMouseEvent;
} }
/** `popupopen` / `popupclose`, fired on the layer a popup is bound to. */
export interface PopupBindEvents { export interface PopupBindEvents {
popupopen: PopupEvent; popupopen: PopupEvent;
popupclose: PopupEvent; popupclose: PopupEvent;
} }
/** `tooltipopen` / `tooltipclose`, fired on the layer a tooltip is bound to. */
export interface TooltipBindEvents { export interface TooltipBindEvents {
tooltipopen: TooltipEvent; tooltipopen: TooltipEvent;
tooltipclose: TooltipEvent; tooltipclose: TooltipEvent;
} }
/** `dragstart` / `drag` / `dragend` (marker dragging). */
export interface DragEvents { export interface DragEvents {
dragstart: LeafletEvent; dragstart: LeafletEvent;
drag: LeafletEvent; drag: LeafletEvent;
dragend: DragEndEvent; dragend: DragEndEvent;
} }
/** Tile-loading lifecycle: `loading` / `load` / `tileloadstart` / `tileload` / `tileunload` / `tileerror`. */
export interface TileEvents { export interface TileEvents {
loading: LeafletEvent; loading: LeafletEvent;
load: LeafletEvent; load: LeafletEvent;
@ -80,40 +73,34 @@ export interface TileEvents {
tileerror: TileErrorEvent; tileerror: TileErrorEvent;
} }
/** `contentupdate`, fired by a popup/tooltip when its content changes. */
export interface DivOverlayEvents { export interface DivOverlayEvents {
contentupdate: LeafletEvent; contentupdate: LeafletEvent;
} }
/** `layeradd` / `layerremove`, fired by a group about its children. */
export interface LayerGroupEvents { export interface LayerGroupEvents {
layeradd: LayerEvent; layeradd: LayerEvent;
layerremove: LayerEvent; layerremove: LayerEvent;
} }
/** Events every non-group layer has: its own `add`/`remove` plus popup/tooltip bind events. */ // Every non-group layer (marker, path, overlay, tile layer...) can have a
// popup/tooltip bound to it regardless of its more specific family.
export type BaseLayerEvents = LayerAddRemoveEvents & PopupBindEvents & TooltipBindEvents; export type BaseLayerEvents = LayerAddRemoveEvents & PopupBindEvents & TooltipBindEvents;
/** Event map for `<leaflet-circle|polygon|polyline|rectangle>`. */
export type PathEvents = BaseLayerEvents & MouseEvents; export type PathEvents = BaseLayerEvents & MouseEvents;
/** Event map for `<leaflet-marker>`. */
export type MarkerEvents = BaseLayerEvents & MouseEvents & MoveEvents & DragEvents; export type MarkerEvents = BaseLayerEvents & MouseEvents & MoveEvents & DragEvents;
/** Event map for `<leaflet-tile-layer>` / `<leaflet-tile-layer-wms>`. */
export type TileLayerEvents = BaseLayerEvents & TileEvents; export type TileLayerEvents = BaseLayerEvents & TileEvents;
/** // Popup/Tooltip themselves don't fire popupopen/tooltipopen about
* Event map for `<leaflet-popup>` / `<leaflet-tooltip>`. Uses // themselves -- that fires on whatever they're bound to -- so this is
* {@link LayerAddRemoveEvents} rather than {@link BaseLayerEvents} a // LayerAddRemoveEvents rather than the fuller BaseLayerEvents.
* popup/tooltip doesn't fire `popupopen` about *itself*.
*/
export type DivOverlayLayerEvents = LayerAddRemoveEvents & MouseEvents & DivOverlayEvents; export type DivOverlayLayerEvents = LayerAddRemoveEvents & MouseEvents & DivOverlayEvents;
/** Event map for `<leaflet-layer-group>` / `<leaflet-feature-group>` / `<leaflet-geojson>` — own add/remove plus children's `layeradd`/`layerremove`. */ // LayerGroup, FeatureGroup and GeoJSON (itself a FeatureGroup) all get both
// their own add/remove and their children's layeradd/layerremove.
export type GroupEvents = LayerAddRemoveEvents & LayerGroupEvents; export type GroupEvents = LayerAddRemoveEvents & LayerGroupEvents;
/** Event map for `<leaflet-map>` — move, mouse, zoom, resize, location, keyboard, layer-control and popup/tooltip events. */
export interface MapEvents export interface MapEvents
extends MoveEvents, MouseEvents, PopupBindEvents, TooltipBindEvents, LayerGroupEvents { extends MoveEvents, MouseEvents, PopupBindEvents, TooltipBindEvents, LayerGroupEvents {
zoomstart: LeafletEvent; zoomstart: LeafletEvent;

@ -1,88 +1,74 @@
/** // Every element property is described by a PropDef: how its value encodes to
* Describes one element property: how its value encodes to and decodes from an // and decodes from an HTML attribute, and how it is pushed into (and read back
* HTML attribute, and how it is pushed into (and read back out of) the Leaflet // out of) the Leaflet object. `WithProps` in with-props.ts is the only consumer
* object. {@link WithProps} is the only consumer a component just declares a // -- components just declare a table of these and never touch the plumbing.
* table of these and never touches the plumbing.
*
* @typeParam T - the decoded property value type
* @typeParam TObj - the Leaflet object type `set`/`get` operate on
*/
export interface PropDef<T = unknown, TObj = unknown> { export interface PropDef<T = unknown, TObj = unknown> {
/** Attribute name; defaults to the kebab-cased property name. A function receives the property name and returns the attribute (see {@link disabled}). */ // Attribute name. Defaults to the kebab-cased property name. A function
// receives the property name and returns the attribute (see `disabled`).
attribute?: string | ((name: string) => string); attribute?: string | ((name: string) => string);
/** The value when the attribute is absent. Keep it equal to Leaflet's own default — an absent attribute is omitted from the options object entirely. */ // The property value when the attribute is absent. Keep this equal to
// Leaflet's own default: an absent attribute is left out of the options
// object entirely, so it is Leaflet's default that actually takes effect.
default: T; default: T;
/** Set (to `false`) by {@link positional} for values the Leaflet constructor takes as an argument rather than an option. */ // Set through `positional()` for values the Leaflet constructor takes as an
// argument (coordinates, urls, bounds) rather than as an option.
option?: false; option?: false;
/** Parse the raw attribute string into the value. */
decode(raw: string): T; decode(raw: string): T;
/** Serialise the value back to an attribute string; return `null` to remove the attribute (restoring Leaflet's default). */ // Returning null removes the attribute, which restores Leaflet's default.
encode(value: T): string | null; encode(value: T): string | null;
/** Push a new value into the live Leaflet object. Defaults to the matching setter (`opacity` → `setOpacity`) when the object has one. */ // Pushes a new value into the Leaflet object. Defaults to calling the
// matching setter when the object has one (`opacity` -> `setOpacity`).
set?(obj: TObj, value: T, el: HTMLElement): void; set?(obj: TObj, value: T, el: HTMLElement): void;
/** Read the live value back out of the object — backs the property getter and the `event` write-back. */ // Reads the live value back out of the Leaflet object. Used by the property
// getter, and by `event` below to write the value back to the attribute.
get?(obj: TObj): T | undefined; get?(obj: TObj): T | undefined;
/** Leaflet event after which `get` is re-read and synced to the attribute (`move` keeps lat/lng current during a drag). */ // Leaflet event after which `get` is re-read and synced to the attribute,
// e.g. `move` keeps lat/lng current while a marker is dragged.
event?: string; event?: string;
} }
/** A record of {@link PropDef}s keyed by property name — an element's `PROPS` table. */
export type PropTable<T> = Record<string, PropDef<unknown, T>>; export type PropTable<T> = Record<string, PropDef<unknown, T>>;
/** // Everything a codec factory doesn't fill in for you. `option` is not here:
* The optional half of a {@link PropDef} everything a codec factory doesn't // it has to come from `positional()` to be visible in PropOptionValues.
* fill in for you. `option` is excluded: it must come from {@link positional}
* to stay visible to {@link PropOptionValues}.
*/
export type PropOptions<TObj, T> = Partial< export type PropOptions<TObj, T> = Partial<
Omit<PropDef<T, TObj>, 'default' | 'decode' | 'encode' | 'option'> Omit<PropDef<T, TObj>, 'default' | 'decode' | 'encode' | 'option'>
>; >;
/** The decoded value type of a single prop, read off its `default`. */ // The value type of a single prop, and of a whole table.
export type PropValue<P> = P extends { default: infer T } ? T : never; export type PropValue<P> = P extends { default: infer T } ? T : never;
/** {@link PropValue} mapped over a whole {@link PropTable} — the element's property shape. */
export type PropValues<T> = { [K in keyof T]: PropValue<T[K]> }; export type PropValues<T> = { [K in keyof T]: PropValue<T[K]> };
/** // The options object handed to `createLeafletObject`. Partial because a prop
* The options object handed to `createLeafletObject`. `Partial` because a prop // only appears when its attribute is present; `option: false` props never do.
* only appears when its attribute is present, and `positional()` props
* (`option: false`) are dropped entirely.
*/
export type PropOptionValues<T> = Partial<{ export type PropOptionValues<T> = Partial<{
[K in keyof T as T[K] extends { option: false } ? never : K]: PropValue<T[K]>; [K in keyof T as T[K] extends { option: false } ? never : K]: PropValue<T[K]>;
}>; }>;
/** // The type `positional()` produces: a PropDef flagged so #buildOptions skips
* The type {@link positional} produces: a {@link PropDef} flagged so // it. Spelled out as an alias so element PROPS tables can be given the explicit
* `#buildOptions` skips it. Spelled out as an alias so element `PROPS` tables // type annotations JSR's "no slow types" check requires without repeating the
* can carry the explicit annotations JSR's "no slow types" check requires // intersection everywhere.
* without repeating the intersection everywhere.
*/
export type Positional<T, TObj = unknown> = PropDef<T, TObj> & { option: false }; export type Positional<T, TObj = unknown> = PropDef<T, TObj> & { option: false };
/** // Marks a prop the Leaflet constructor takes as an argument, so it is left out
* Marks a prop the Leaflet constructor takes as a positional argument // of the options object handed to createLeafletObject().
* (coordinates, url, bounds), so it is left out of the options object handed to
* `createLeafletObject`.
*/
export function positional<T extends PropDef>(def: T): T & { option: false } { export function positional<T extends PropDef>(def: T): T & { option: false } {
return { ...def, option: false }; return { ...def, option: false };
} }
/** `fooBar` → `foo-bar`. The default attribute name for a property. */
export function kebab(name: string): string { export function kebab(name: string): string {
return name.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`); return name.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
} }
/** A numeric attribute: `Number` on the way in, `String` on the way out. */
export function num<TObj = unknown>( export function num<TObj = unknown>(
def = 0, def = 0,
opts?: PropOptions<TObj, number>, opts?: PropOptions<TObj, number>,
@ -90,7 +76,6 @@ export function num<TObj = unknown>(
return { default: def, decode: Number, encode: String, ...opts }; return { default: def, decode: Number, encode: String, ...opts };
} }
/** A string attribute — identity codec both ways. */
export function str<TObj = unknown>( export function str<TObj = unknown>(
def = '', def = '',
opts?: PropOptions<TObj, string>, opts?: PropOptions<TObj, string>,
@ -98,11 +83,9 @@ export function str<TObj = unknown>(
return { default: def, decode: (raw) => raw, encode: (value) => value, ...opts }; return { default: def, decode: (raw) => raw, encode: (value) => value, ...opts };
} }
/** // A string attribute whose values Leaflet types as a union -- ControlPosition,
* A string attribute whose values Leaflet types as a union (`ControlPosition`, // CrossOrigin, tooltip Direction. Nothing is validated at runtime; this is how
* `CrossOrigin`, tooltip `Direction`). Not validated at runtime this just // the options object comes out with the type Leaflet's constructor expects.
* makes the options object come out with the type Leaflet's constructor wants.
*/
export function choice<T extends string, TObj = unknown>( export function choice<T extends string, TObj = unknown>(
def: T, def: T,
opts?: PropOptions<TObj, T>, opts?: PropOptions<TObj, T>,
@ -110,11 +93,9 @@ export function choice<T extends string, TObj = unknown>(
return { default: def, decode: (raw) => raw as T, encode: (value) => value, ...opts }; return { default: def, decode: (raw) => raw as T, encode: (value) => value, ...opts };
} }
/** // A boolean attribute: present is true, `="false"` is false, absent is `def`.
* A boolean attribute: present is `true`, `="false"` is `false`, absent is // Use `bool(true)` for options Leaflet already defaults to true, so that
* `def`. Use `bool(true)` for options Leaflet already defaults on, so // `<leaflet-popup auto-pan="false">` can turn them off.
* `<leaflet-popup auto-pan="false">` can turn them off.
*/
export function bool<TObj = unknown>( export function bool<TObj = unknown>(
def = false, def = false,
opts?: PropOptions<TObj, boolean>, opts?: PropOptions<TObj, boolean>,
@ -127,11 +108,8 @@ export function bool<TObj = unknown>(
}; };
} }
/** // The inverse of `bool(true)`: `<leaflet-map disable-dragging>` reads as
* The inverse of `bool(true)`: `<leaflet-map disable-dragging>` reads as // `dragging === false`. Named `disable-<kebab>` unless `attribute` says else.
* `dragging === false`. Attribute is named `disable-<kebab>` unless `attribute`
* overrides it.
*/
export function disabled<TObj = unknown>( export function disabled<TObj = unknown>(
opts?: PropOptions<TObj, boolean>, opts?: PropOptions<TObj, boolean>,
): PropDef<boolean, TObj> { ): PropDef<boolean, TObj> {
@ -144,7 +122,7 @@ export function disabled<TObj = unknown>(
}; };
} }
/** A JSON attribute (`JSON.parse` / `JSON.stringify`): bounds, icon sizes and anchors, GeoJSON data. */ // For attributes holding JSON: bounds, icon sizes and anchors, GeoJSON data.
export function json<T, TObj = unknown>(def: T, opts?: PropOptions<TObj, T>): PropDef<T, TObj> { export function json<T, TObj = unknown>(def: T, opts?: PropOptions<TObj, T>): PropDef<T, TObj> {
return { return {
default: def, default: def,

@ -1,46 +1,39 @@
import { DivIcon, Icon, Layer, type CRS } from 'leaflet'; import { DivIcon, Icon, Layer, type CRS } from 'leaflet';
/** // Custom event type for the bubbling registration protocol. Carries the
* The bubbling `leaflet-register` event. Carries the Leaflet object and the // Leaflet object and the originating element so the nearest parent can
* originating element so the nearest ancestor component can add it as a child // add it as a child layer, popup, or tooltip.
* layer, bind it as a popup, or bind it as a tooltip.
*/
export type LeafletRegisterEvent = CustomEvent<{ export type LeafletRegisterEvent = CustomEvent<{
leafletObject: Layer; leafletObject: Layer;
element: HTMLElement; element: HTMLElement;
}>; }>;
/** `leaflet-add-layer` / `leaflet-remove-layer` — dispatched by `<leaflet-control-layers>` to toggle a layer on the map. */
export type LeafletLayerEvent = CustomEvent<{ layer: Layer }>; export type LeafletLayerEvent = CustomEvent<{ layer: Layer }>;
/** `icon-changed` — a `<leaflet-icon>` / `<leaflet-div-icon>` child announcing its icon (or `null` on disconnect) to its parent marker. */
export type LeafletIconChangedEvent = CustomEvent<{ icon: Icon | null }>; export type LeafletIconChangedEvent = CustomEvent<{ icon: Icon | null }>;
/** // Fired by <leaflet-line> on itself -- on connect and on every lat/lng
* `leaflet-line-sync` fired by `<leaflet-line>` on itself, on connect and on // change -- carrying its own current position. polygon/polyline listen for
* every lat/lng change, carrying its own position. `<leaflet-polygon>` / // this (bubbling) to track vertices without ever reading a child's state
* `<leaflet-polyline>` listen for it (bubbling) to track vertices without ever // directly.
* reading a child's state.
*/
export type LeafletLineSyncEvent = CustomEvent<{ element: HTMLElement; latlng: [number, number] }>; export type LeafletLineSyncEvent = CustomEvent<{ element: HTMLElement; latlng: [number, number] }>;
/** `leaflet-line-remove` — fired by `<leaflet-line>` on disconnect so a listening parent can drop that vertex. */ // Fired by <leaflet-line> on disconnect, so a listening parent can drop it.
export type LeafletLineRemoveEvent = CustomEvent<{ element: HTMLElement }>; export type LeafletLineRemoveEvent = CustomEvent<{ element: HTMLElement }>;
/** // A CRS is a plain value (methods + a couple of properties, see Leaflet's
* `leaflet-crs-changed` a CRS is a plain value, not a `Layer`, so it doesn't // own `CRS` interface), not a Layer -- it doesn't fit the leaflet-register
* fit the `leaflet-register` protocol. A custom element nested inside a // protocol above. Any custom element nested inside a component that accepts
* component that accepts a `crs` (currently `<leaflet-tile-layer-wms>`) // a `crs` (currently just leaflet-tile-layer-wms) can provide one by firing
* provides one by firing this itself, bubbling, on connect. `crs: null` // this itself, bubbling, on connect -- no base class required, just this
* reverts to the component's own default. // event shape. `crs: null` (e.g. on disconnect) reverts to that component's
*/ // own default, mirroring icon-changed's `icon: null`.
export type LeafletCRSChangedEvent = CustomEvent<{ crs: CRS | null }>; export type LeafletCRSChangedEvent = CustomEvent<{ crs: CRS | null }>;
// The `HTMLElementEventMap` augmentation for these event names lives in // The `HTMLElementEventMap` augmentation for these event names lives in
// `./globals.ts` (npm-only), kept out of this module so JSR -- which rejects // `./globals.ts` (npm-only), kept out of this module so JSR -- which rejects
// `declare global` in its published graph -- can still publish it. // `declare global` in its published graph -- can still publish it.
/** Fire {@link LeafletIconChangedEvent} (`icon-changed`, bubbling) from `el`. `null` clears the icon. */
export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | undefined) { export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | undefined) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('icon-changed', { new CustomEvent('icon-changed', {
@ -50,31 +43,28 @@ export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | u
); );
} }
/** Fire {@link LeafletLineSyncEvent} (`leaflet-line-sync`, bubbling) from `el` with its current position. */
export function emitLineSync(el: HTMLElement, latlng: [number, number]): void { export function emitLineSync(el: HTMLElement, latlng: [number, number]): void {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('leaflet-line-sync', { bubbles: true, detail: { element: el, latlng } }), new CustomEvent('leaflet-line-sync', { bubbles: true, detail: { element: el, latlng } }),
); );
} }
/** // Dispatched on `from`, not `el`: by the time disconnectedCallback runs, `el`
* Fire {@link LeafletLineRemoveEvent} (`leaflet-line-remove`, bubbling). // has already been detached from its parent, so a bubbling dispatch from
* Dispatched on `from`, not `el`: by `disconnectedCallback` time `el` is // `el` itself would have nowhere to bubble to. Callers pass the parent they
* already detached, so a dispatch from it has nowhere to bubble callers pass // cached while still connected.
* the parent they cached while still connected.
*/
export function emitLineRemove(from: EventTarget, el: HTMLElement): void { export function emitLineRemove(from: EventTarget, el: HTMLElement): void {
from.dispatchEvent( from.dispatchEvent(
new CustomEvent('leaflet-line-remove', { bubbles: true, detail: { element: el } }), new CustomEvent('leaflet-line-remove', { bubbles: true, detail: { element: el } }),
); );
} }
/** // Dispatches a custom `leaflet-register` event upward through the DOM
* Fire {@link LeafletRegisterEvent} (`leaflet-register`, bubbling and // tree, carrying a Leaflet object and its host element. Parent components
* `composed`) from `el`. The core wiring mechanism: an ancestor component // (map, circles, groups, etc.) intercept this event and add the layer,
* intercepts it and adds the layer / binds the popup / binds the tooltip, // bind the popup, or bind the tooltip. This is the core wiring mechanism
* replacing the imperative parent-child calls Leaflet normally needs. // that replaces the parent-child relationship that Leaflet normally
*/ // manages via imperative code.
export function registerWithParent(el: HTMLElement, obj: unknown) { export function registerWithParent(el: HTMLElement, obj: unknown) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('leaflet-register', { new CustomEvent('leaflet-register', {

@ -13,33 +13,26 @@ import { bool, choice, json, num, positional, str, type PropDef } from './props.
// Leaflet classes share no common interface, so the prop fragments below // Leaflet classes share no common interface, so the prop fragments below
// describe structurally what they need from the object they update. // describe structurally what they need from the object they update.
/** Structural type for anything a `latLngProps` prop updates — a marker, circle, popup, tooltip. */
export interface Positioned { export interface Positioned {
getLatLng(): LatLng | undefined; getLatLng(): LatLng | undefined;
setLatLng(latlng: [number, number]): unknown; setLatLng(latlng: [number, number]): unknown;
} }
/** Structural type for anything a `pathProps` style prop updates — any Leaflet `Path`. */
export interface Styleable { export interface Styleable {
setStyle(style: PathOptions): unknown; setStyle(style: PathOptions): unknown;
} }
/** Structural type for anything {@link urlProp} updates — `TileLayer`, `ImageOverlay`, `VideoOverlay`. */
export interface Sourced { export interface Sourced {
setUrl(url: string): unknown; setUrl(url: string): unknown;
} }
/** Structural type for anything {@link getBounds} reads — `ImageOverlay`, `Rectangle`, etc. */
export interface Bounded { export interface Bounded {
getBounds(): LatLngBounds; getBounds(): LatLngBounds;
} }
/** // Shared `get` for any `bounds` prop backed by Leaflet's getBounds(). Returns
* Shared `get` for any `bounds` prop, backed by Leaflet's `getBounds()`. // a plain [[south, west], [north, east]] pair rather than the LatLngBounds
* Returns a plain `[[south, west], [north, east]]` pair (not the `LatLngBounds` // instance, matching the JSON-encoded shape the attribute round-trips through.
* instance), matching the JSON shape the attribute round-trips through.
*/
export function getBounds(obj: Bounded): LatLngBoundsExpression { export function getBounds(obj: Bounded): LatLngBoundsExpression {
const b = obj.getBounds(); const b = obj.getBounds();
return [ return [
@ -53,18 +46,16 @@ interface PositionedHost extends HTMLElement {
lng: number; lng: number;
} }
/** Builds a `set` for a style option Leaflet only exposes through `setStyle()`. */ // A `set` for any option Leaflet only exposes through setStyle().
export function style<T>(key: keyof PathOptions): (obj: Styleable, value: T) => void { export function style<T>(key: keyof PathOptions): (obj: Styleable, value: T) => void {
return (obj, value) => { return (obj, value) => {
obj.setStyle({ [key]: value } as PathOptions); obj.setStyle({ [key]: value } as PathOptions);
}; };
} }
/** // The source url. Passed positionally by every Leaflet constructor that takes
* The `url` prop the positional source URL for `TileLayer` / `ImageOverlay` / // one, and ignored when blank so clearing the attribute can't request nothing.
* `VideoOverlay`. Ignored when blank so clearing the attribute can't request an // No `get`: none of TileLayer/ImageOverlay/VideoOverlay expose a getUrl().
* empty URL. No `get` (none of those classes expose `getUrl()`).
*/
export const urlProp: PropDef<string, Sourced> & { option: false } = positional( export const urlProp: PropDef<string, Sourced> & { option: false } = positional(
str<Sourced>('', { str<Sourced>('', {
set(obj, value) { set(obj, value) {
@ -73,13 +64,11 @@ export const urlProp: PropDef<string, Sourced> & { option: false } = positional(
}), }),
); );
/** // lat/lng travel together: both are passed positionally to the Leaflet
* The `lat` / `lng` prop pair. Both are positional (passed to the Leaflet // constructor rather than as options, setting either re-issues setLatLng with
* constructor); setting either re-issues `setLatLng` with the other axis read // the other's current value, and both are written back whenever the object
* off the host element, and both write back on `move` which keeps the // moves -- which is what keeps the attributes current while a marker is
* attributes live while a marker is dragged. Shared by marker, circle, // dragged. Shared by marker, circle, circle-marker, popup and tooltip.
* circle-marker, popup and tooltip.
*/
export const latLngProps: { export const latLngProps: {
lat: PropDef<number, Positioned> & { option: false }; lat: PropDef<number, Positioned> & { option: false };
lng: PropDef<number, Positioned> & { option: false }; lng: PropDef<number, Positioned> & { option: false };
@ -104,12 +93,8 @@ export const latLngProps: {
), ),
}; };
/** // The style options every Path accepts. Defaults match Leaflet's own, so an
* Every SVG style option a Leaflet `Path` accepts. The mutable ones (`color`, // absent attribute and an unset option mean the same thing.
* `weight`, `opacity`, `fill*`, ) route through `setStyle()` via {@link style};
* the constructor-only tail (`className`, `interactive`, `pane`, ) has no
* setter. Defaults match Leaflet's own.
*/
export const pathProps: { export const pathProps: {
stroke: PropDef<boolean, Styleable>; stroke: PropDef<boolean, Styleable>;
color: PropDef<string, Styleable>; color: PropDef<string, Styleable>;
@ -148,13 +133,11 @@ export const pathProps: {
pane: str('overlay'), pane: str('overlay'),
}; };
/** // The GridLayer/TileLayer options every tile source accepts, shared by
* The `GridLayer` / `TileLayer` options common to `<leaflet-tile-layer>` and // leaflet-tile-layer and leaflet-tile-layer-wms (WMSOptions extends
* `<leaflet-tile-layer-wms>` (WMS options extend tile-layer options). Almost // TileLayerOptions). Most are constructor-only -- Leaflet exposes no setter
* all constructor-only. `referrerPolicy` is a hand-written {@link PropDef} // for them -- so changing the attribute after creation has no effect, same as
* rather than {@link choice} because Leaflet's `ReferrerPolicy` type has no // leaflet-map's zoomSnap.
* "unset" member.
*/
export const tileLayerProps: { export const tileLayerProps: {
attribution: PropDef<string>; attribution: PropDef<string>;
minZoom: PropDef<number>; minZoom: PropDef<number>;

@ -8,78 +8,56 @@ import {
} from './props.ts'; } from './props.ts';
import { registerWithParent, type LeafletRegisterEvent } from './register.ts'; import { registerWithParent, type LeafletRegisterEvent } from './register.ts';
/** // How an element joins the component tree:
* How an element joins the component tree: // children -- register with the nearest parent and adopt registering
* // descendants as layers, popups and tooltips (every layer)
* - `children` register with the nearest parent and adopt registering // self -- register with the nearest parent only (popups, tooltips,
* descendants as layers, popups and tooltips (every layer type). // controls: they have a parent but manage no children here)
* - `self` register with the nearest parent only (popups, tooltips, controls: // none -- neither (the map is the root; icons aren't part of the tree)
* they have a parent but manage no children here).
* - `none` neither (the map is the tree root; icons aren't tree members).
*/
export type Attach = 'children' | 'self' | 'none'; export type Attach = 'children' | 'self' | 'none';
/** Second argument to {@link WithProps}. */
export interface ElementOptions { export interface ElementOptions {
/** Tree-membership mode; defaults to `'children'`. See {@link Attach}. */
attach?: Attach; attach?: Attach;
/** // Rebuild the Leaflet object on every attribute change instead of calling
* Rebuild the Leaflet object on every attribute change instead of calling // setters, for objects Leaflet gives us no way to mutate in place (icons).
* setters for objects Leaflet gives no way to mutate in place (icons).
*/
recreate?: boolean; recreate?: boolean;
} }
/** The members {@link WithProps} contributes on top of the generated property accessors. */ // The members WithProps contributes on top of the property accessors.
export interface LeafletElement<TObj, TProps> { export interface LeafletElement<TObj, TProps> {
/** The wrapped Leaflet object, once created (undefined before connect / after disconnect). */
readonly leafletObject?: TObj; readonly leafletObject?: TObj;
/** // The one method every component must implement. `options` holds the decoded
* The one method every component must implement. `options` holds the decoded // value of every prop whose attribute is present, keyed by property name, so
* value of every prop whose attribute is present, keyed by property name, // it can be handed straight to the Leaflet constructor.
* ready to hand to the Leaflet constructor.
*/
createLeafletObject(options: PropOptionValues<TProps>): TObj | undefined; createLeafletObject(options: PropOptionValues<TProps>): TObj | undefined;
/** Hook called after the object is created and after every recreate; override to react. */ // Called after the object is created and after every recreate.
leafletObjectCreated(): void; leafletObjectCreated(): void;
/** Tear the object down and rebuild it from current attributes (used by `recreate` and by WMS CRS children). */
recreateLeafletObject(): void; recreateLeafletObject(): void;
/** Custom-element lifecycle: builds the object and joins the tree. Call `super.connectedCallback()` if you override. */
connectedCallback(): void; connectedCallback(): void;
/** Custom-element lifecycle: leaves the tree and destroys the object. Call `super.disconnectedCallback()` if you override. */
disconnectedCallback(): void; disconnectedCallback(): void;
/** Custom-element lifecycle: pushes an attribute change into the live object. */
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
} }
/**
* The constructor type {@link WithProps} returns: an `HTMLElement` subclass with
* a two-way accessor per prop ({@link PropValues}) plus the
* {@link LeafletElement} lifecycle members. Element files annotate their
* `const Base` with this.
*/
export type LeafletElementConstructor<TObj, TProps> = new () => HTMLElement & export type LeafletElementConstructor<TObj, TProps> = new () => HTMLElement &
PropValues<TProps> & PropValues<TProps> &
LeafletElement<TObj, TProps>; LeafletElement<TObj, TProps>;
/** // Type-only narrowing for addEventListener/removeEventListener so a
* Type-only narrowing for `addEventListener` so a component's `leaflet:<name>` // component's `leaflet:<name>` events (see #forwardEvents below) type-check
* events (see `#forwardEvents`) type-check against the right Leaflet payload. A // against the right Leaflet event payload. Like `declare readonly
* component applies it with a `declare addEventListener: LeafletAddEventListener<TheEvents>` // leafletObject?: Marker`, a component applies this with a `declare` field --
* field the same zero-runtime idiom as `declare readonly leafletObject?: X`. // TObj isn't reliably inferred from the PROPS table alone, so this can't live
* // in WithProps()'s own generics; it costs nothing at runtime either way.
* Deliberately has **no** generic `(type: string, …)` fallback overload (unlike //
* the real DOM API): a fallback would silently accept any misspelled // Deliberately has no generic `(type: string, ...)` fallback overload (unlike
* `leaflet:*` name. The cost is that a genuinely dynamic event-name string // HTMLElement's real addEventListener): a fallback would silently accept any
* needs a cast. // unrecognized `leaflet:*` name too, which defeats the point. The cost is
* // that a genuinely dynamic (non-literal) event name string needs a cast.
* @typeParam TEvents - an event-name payload map from `event-types.ts`
*/
export type LeafletAddEventListener<TEvents> = (<K extends keyof TEvents & string>( export type LeafletAddEventListener<TEvents> = (<K extends keyof TEvents & string>(
type: `leaflet:${K}`, type: `leaflet:${K}`,
listener: (ev: CustomEvent<TEvents[K]>) => void, listener: (ev: CustomEvent<TEvents[K]>) => void,
@ -91,7 +69,6 @@ export type LeafletAddEventListener<TEvents> = (<K extends keyof TEvents & strin
options?: boolean | AddEventListenerOptions, options?: boolean | AddEventListenerOptions,
) => void); ) => void);
/** The `removeEventListener` counterpart of {@link LeafletAddEventListener}. */
export type LeafletRemoveEventListener<TEvents> = (<K extends keyof TEvents & string>( export type LeafletRemoveEventListener<TEvents> = (<K extends keyof TEvents & string>(
type: `leaflet:${K}`, type: `leaflet:${K}`,
listener: (ev: CustomEvent<TEvents[K]>) => void, listener: (ev: CustomEvent<TEvents[K]>) => void,
@ -136,26 +113,13 @@ function resolve<TObj extends Class>(props: PropTable<TObj>): ResolvedProp<TObj>
})); }));
} }
/** // Builds a custom element base class from a table of property definitions.
* Mixin factory: builds a custom-element base class from a table of //
* {@link PropDef}s. Always extends `HTMLElement` internally (there is no // The generated class owns the whole lifecycle: it derives observedAttributes,
* base-class parameter). // defines two-way property accessors, builds the Leaflet options object, wires
* // the object into the component tree, keeps attributes in sync with the object
* The generated class owns the whole lifecycle it derives // (in both directions, without cycles), and re-fires every Leaflet event on the
* `observedAttributes`, defines a two-way property accessor per prop, builds // element as `leaflet:<type>`. Subclasses implement createLeafletObject().
* the Leaflet options object on connect, wires the object into the component
* tree, keeps attributes and the live object in sync (both directions, no
* cycles), and re-fires every Leaflet event on the element as `leaflet:<type>`.
* A subclass only implements `createLeafletObject()`.
*
* Element files must not write `class Foo extends WithProps({…})` JSR's
* type checker rejects a call expression as a superclass. Use a
* `const Base: LeafletElementConstructor<TheClass, typeof PROPS> = WithProps(PROPS)`
* and `extends Base` instead.
*
* @param props - the `PROPS` table (needs an explicit type annotation)
* @param options - tree-attach mode and `recreate` behaviour; see {@link ElementOptions}
*/
export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>( export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
props: TProps, props: TProps,
options: ElementOptions = {}, options: ElementOptions = {},

@ -1,20 +1,11 @@
/** // The element classes on their own -- no `customElements.define()` runs from
* Every `leaflet-*` element class, by name, with **no** `customElements.define()` // anything in this file or the modules it re-exports. Import a `components/*`
* side effect nothing in this module or the modules it re-exports registers a // module (or `src/index.ts`) instead when you want the tags actually
* tag. // registered; reach for these when you want to subclass an element, register
* // it under a different tag name, or otherwise customise before defining.
* Reach for this entrypoint (`leaflet-web-components/elements`, or //
* `jsr:@buddy/leaflet-components/elements`) when you want to subclass an // Every class is the `default` export of its own module, surfaced here under
* element, register it under a different tag name, or otherwise customise // a name. Order is irrelevant -- nothing here has a load-time side effect.
* before defining. To get the tags registered instead, import the package root
* or an individual `components/*` module.
*
* Each class is the `default` export of its own `leaflet-foo.ts` module,
* surfaced here under a `LeafletFooElement` name. Order is irrelevant none of
* these modules has a load-time side effect.
*
* @module
*/
export { default as LeafletMapElement } from './leaflet-map.ts'; export { default as LeafletMapElement } from './leaflet-map.ts';
export { default as LeafletControlLayersElement } from './leaflet-control-layers.ts'; export { default as LeafletControlLayersElement } from './leaflet-control-layers.ts';
export { default as LeafletLayerGroupElement } from './leaflet-layer-group.ts'; export { default as LeafletLayerGroupElement } from './leaflet-layer-group.ts';

@ -19,7 +19,6 @@ const PROPS: typeof latLngProps &
}; };
const Base: LeafletElementConstructor<CircleMarker, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<CircleMarker, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-circle-marker>` — a Leaflet `CircleMarker` (radius in pixels, fixed screen size). */
export default class LeafletCircleMarkerElement extends Base { export default class LeafletCircleMarkerElement extends Base {
declare readonly leafletObject?: CircleMarker; declare readonly leafletObject?: CircleMarker;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -21,7 +21,6 @@ const PROPS = {
} as const; } as const;
const Base: LeafletElementConstructor<Circle, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<Circle, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-circle>` — a Leaflet `Circle` (radius in metres). Takes `pathProps` styling and a `radius`. */
export default class LeafletCircleElement extends Base { export default class LeafletCircleElement extends Base {
declare readonly leafletObject?: Circle; declare readonly leafletObject?: Circle;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -13,7 +13,6 @@ const Base: LeafletElementConstructor<Control.Attribution, typeof PROPS> = WithP
attach: 'self', attach: 'self',
}); });
/** `<leaflet-control-attribution>` — the map's attribution control (`Control.Attribution`). */
export default class LeafletControlAttributionElement extends Base { export default class LeafletControlAttributionElement extends Base {
declare readonly leafletObject?: Control.Attribution; declare readonly leafletObject?: Control.Attribution;

@ -20,11 +20,6 @@ const Base: LeafletElementConstructor<Control.Layers, typeof PROPS> = WithProps(
attach: 'self', attach: 'self',
}); });
/**
* `<leaflet-control-layers>` a layer switcher (`Control.Layers`). Layer
* children register as base layers or overlays (`type="base"`, `active` to
* start visible) instead of being added to the map directly.
*/
export default class LeafletControlLayersElement extends Base { export default class LeafletControlLayersElement extends Base {
declare readonly leafletObject?: Control.Layers; declare readonly leafletObject?: Control.Layers;

@ -19,7 +19,6 @@ const Base: LeafletElementConstructor<Control.Scale, typeof PROPS> = WithProps(P
attach: 'self', attach: 'self',
}); });
/** `<leaflet-control-scale>` — the metric/imperial scale bar (`Control.Scale`). */
export default class LeafletControlScaleElement extends Base { export default class LeafletControlScaleElement extends Base {
declare readonly leafletObject?: Control.Scale; declare readonly leafletObject?: Control.Scale;

@ -21,7 +21,6 @@ const Base: LeafletElementConstructor<Control.Zoom, typeof PROPS> = WithProps(PR
attach: 'self', attach: 'self',
}); });
/** `<leaflet-control-zoom>` — the +/ zoom buttons (`Control.Zoom`). */
export default class LeafletControlZoomElement extends Base { export default class LeafletControlZoomElement extends Base {
declare readonly leafletObject?: Control.Zoom; declare readonly leafletObject?: Control.Zoom;

@ -27,11 +27,6 @@ const Base: LeafletElementConstructor<DivIcon, typeof PROPS> = WithProps(PROPS,
recreate: true, recreate: true,
}); });
/**
* `<leaflet-div-icon>` a Leaflet `DivIcon` (a CSS-styled marker icon). Child
* of `<leaflet-marker>`; renders its own markup when `html` isn't set, and is
* rebuilt on every change.
*/
export default class LeafletDivIconElement extends Base { export default class LeafletDivIconElement extends Base {
declare readonly leafletObject?: DivIcon; declare readonly leafletObject?: DivIcon;

@ -10,7 +10,7 @@ import type { GroupEvents } from '../core/event-types.ts';
const PROPS: Record<never, never> = {}; const PROPS: Record<never, never> = {};
const Base: LeafletElementConstructor<FeatureGroup, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<FeatureGroup, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-feature-group>` — a Leaflet `FeatureGroup`. Like `<leaflet-layer-group>`, but its children also share events and a bounding box. */ // Like leaflet-layer-group, but its children share events and a bounding box.
export default class LeafletFeatureGroupElement extends Base { export default class LeafletFeatureGroupElement extends Base {
declare readonly leafletObject?: FeatureGroup; declare readonly leafletObject?: FeatureGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>; declare addEventListener: LeafletAddEventListener<GroupEvents>;

@ -25,7 +25,6 @@ const PROPS: {
}; };
const Base: LeafletElementConstructor<GeoJSON, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<GeoJSON, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-geojson>` — a Leaflet `GeoJSON` layer. `data` holds the GeoJSON; other attributes style every feature. */
export default class LeafletGeoJSONElement extends Base { export default class LeafletGeoJSONElement extends Base {
declare readonly leafletObject?: GeoJSON; declare readonly leafletObject?: GeoJSON;
declare addEventListener: LeafletAddEventListener<GroupEvents>; declare addEventListener: LeafletAddEventListener<GroupEvents>;

@ -36,7 +36,6 @@ const Base: LeafletElementConstructor<Icon, typeof PROPS> = WithProps(PROPS, {
recreate: true, recreate: true,
}); });
/** `<leaflet-icon>` — a Leaflet `Icon` (an image marker icon). Child of `<leaflet-marker>`; rebuilt on every attribute change and swapped in via `setIcon()`. */
export default class LeafletIconElement extends Base { export default class LeafletIconElement extends Base {
declare readonly leafletObject?: Icon; declare readonly leafletObject?: Icon;

@ -51,7 +51,6 @@ const PROPS: {
}; };
const Base: LeafletElementConstructor<ImageOverlay, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<ImageOverlay, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-image-overlay>` — a Leaflet `ImageOverlay` (an image pinned to geographic `bounds`). */
export default class LeafletImageOverlayElement extends Base { export default class LeafletImageOverlayElement extends Base {
declare readonly leafletObject?: ImageOverlay; declare readonly leafletObject?: ImageOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -10,11 +10,8 @@ import type { GroupEvents } from '../core/event-types.ts';
const PROPS: Record<never, never> = {}; const PROPS: Record<never, never> = {};
const Base: LeafletElementConstructor<LayerGroup, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<LayerGroup, typeof PROPS> = WithProps(PROPS);
/** // A passthrough container: it has no options of its own, and children add
* `<leaflet-layer-group>` a Leaflet `LayerGroup`. A passthrough container // themselves to it through the standard registration bubble.
* with no options of its own; child layers add themselves through the standard
* registration bubble, and groups can nest.
*/
export default class LeafletLayerGroupElement extends Base { export default class LeafletLayerGroupElement extends Base {
declare readonly leafletObject?: LayerGroup; declare readonly leafletObject?: LayerGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>; declare addEventListener: LeafletAddEventListener<GroupEvents>;

@ -1,11 +1,5 @@
import { emitLineRemove, emitLineSync } from '../core/register.ts'; import { emitLineRemove, emitLineSync } from '../core/register.ts';
/**
* `<leaflet-line>` one vertex of a `<leaflet-polygon>` / `<leaflet-polyline>`,
* given by `lat` / `lng` attributes. Not a Leaflet object and not built on
* {@link WithProps}: it just announces its position to its parent (on connect,
* on change, and on disconnect) via the `leaflet-line-sync` / `-remove` events.
*/
export default class LeafletLineElement extends HTMLElement { export default class LeafletLineElement extends HTMLElement {
static get observedAttributes(): string[] { static get observedAttributes(): string[] {
return ['lat', 'lng']; return ['lat', 'lng'];

@ -1,14 +1,4 @@
import { import { Icon, Map as LMap, type MapOptions, version } from 'leaflet';
Icon,
latLngBounds,
Map as LMap,
Popup,
Tooltip,
type LatLng,
type LatLngBounds,
type MapOptions,
version,
} from 'leaflet';
import { import {
bool, bool,
disabled, disabled,
@ -41,21 +31,6 @@ function relinkCss(_map: LMap, _value: string, el: HTMLElement): void {
(el as CssHost).applyCss(); (el as CssHost).applyCss();
} }
interface FitHost extends HTMLElement {
applyFit(): void;
}
// The fit-* attributes aren't Leaflet options either: they ask the element to
// frame every point/bounds layer itself. Any change just re-runs that.
function reapplyFit(_map: LMap, _value: unknown, el: HTMLElement): void {
(el as FitHost).applyFit();
}
// Anything with a position we can fold into a bounding box -- markers,
// circles (getLatLng), rectangles, image/video overlays (getBounds). Tile
// layers have neither and are skipped.
type Locatable = { getLatLng?: () => LatLng; getBounds?: () => LatLngBounds };
const PROPS: { const PROPS: {
lat: Positional<number, LMap>; lat: Positional<number, LMap>;
lng: Positional<number, LMap>; lng: Positional<number, LMap>;
@ -95,9 +70,6 @@ const PROPS: {
cssUrl: Positional<string>; cssUrl: Positional<string>;
cssIntegrity: Positional<string>; cssIntegrity: Positional<string>;
cssCrossorigin: Positional<string>; cssCrossorigin: Positional<string>;
fitToMarkers: Positional<boolean, LMap>;
fitPadding: Positional<number, LMap>;
fitMaxZoom: Positional<number, LMap>;
} = { } = {
// View state. Not constructor options -- the map is positioned with setView // View state. Not constructor options -- the map is positioned with setView
// once it exists -- and written back whenever the user pans or zooms. // once it exists -- and written back whenever the user pans or zooms.
@ -196,29 +168,11 @@ const PROPS: {
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })), cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })), cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
cssCrossorigin: positional(str('', { set: relinkCss })), cssCrossorigin: positional(str('', { set: relinkCss })),
// Not Leaflet options -- see applyFit() below. With `fit-to-markers` present
// the map ignores lat/lng/zoom and frames every point/bounds layer that has
// registered. It reframes only when a new component registers (initial load
// and later additions) -- panning, zooming, opening a popup and removing a
// marker all leave the view untouched. `fit-padding` is the pixel gutter
// kept around the bounds; `fit-max-zoom` caps how far it zooms in (useful
// when a single marker would otherwise snap to max zoom).
fitToMarkers: positional(bool<LMap>(false, { set: reapplyFit })),
fitPadding: positional(num<LMap>(20, { set: reapplyFit })),
fitMaxZoom: positional(num<LMap>(Infinity, { set: reapplyFit })),
}; };
const Base: LeafletElementConstructor<LMap, typeof PROPS> = WithProps(PROPS, { attach: 'none' }); const Base: LeafletElementConstructor<LMap, typeof PROPS> = WithProps(PROPS, { attach: 'none' });
/** // The root of the component tree. Every other component bubbles a
* `<leaflet-map>` a Leaflet `Map`, and the root of the component tree: every // `leaflet-register` event up to here, which is where it stops.
* other component bubbles a `leaflet-register` event up to here, where it stops
* (`layer.addTo(this.map)`). Builds its own shadow root (container + Leaflet
* CSS `<link>`), runs a `ResizeObserver` `invalidateSize()`, and treats its
* `css-*` attributes as describing the shadow stylesheet. With `fit-to-markers`
* set it frames every registered point/bounds layer instead of honouring
* `lat`/`lng`/`zoom` see `applyFit()`.
*/
export default class LeafletMapElement extends Base { export default class LeafletMapElement extends Base {
declare readonly leafletObject?: LMap; declare readonly leafletObject?: LMap;
declare addEventListener: LeafletAddEventListener<MapEvents>; declare addEventListener: LeafletAddEventListener<MapEvents>;
@ -227,8 +181,6 @@ export default class LeafletMapElement extends Base {
#container?: HTMLDivElement; #container?: HTMLDivElement;
#cssLink?: HTMLLinkElement; #cssLink?: HTMLLinkElement;
#resizeObserver?: ResizeObserver; #resizeObserver?: ResizeObserver;
#fitActive = false;
#fitScheduled = false;
createLeafletObject(options: MapOptions): LMap { createLeafletObject(options: MapOptions): LMap {
const map = new LMap(this.#container ?? this.#buildShadowRoot(), options); const map = new LMap(this.#container ?? this.#buildShadowRoot(), options);
@ -250,12 +202,9 @@ export default class LeafletMapElement extends Base {
this.addEventListener('leaflet-register', this.#onRegister); this.addEventListener('leaflet-register', this.#onRegister);
this.addEventListener('leaflet-add-layer', this.#onAddLayer); this.addEventListener('leaflet-add-layer', this.#onAddLayer);
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer); this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
this.applyFit();
} }
disconnectedCallback(): void { disconnectedCallback(): void {
this.#fitActive = false;
this.#resizeObserver?.disconnect(); this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined; this.#resizeObserver = undefined;
this.removeEventListener('leaflet-register', this.#onRegister); this.removeEventListener('leaflet-register', this.#onRegister);
@ -303,48 +252,6 @@ export default class LeafletMapElement extends Base {
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/'); Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
} }
// Called on connect and whenever a fit-* attribute changes -- the sibling of
// applyCss() above. Records whether framing is on (read by #onRegister) and
// requests an immediate (re)frame when it is. No Leaflet event
// subscriptions: the only reframe trigger is a new component registering --
// see #onRegister.
applyFit(): void {
if (!this.leafletObject) return;
this.#fitActive = this.fitToMarkers;
if (this.#fitActive) this.#scheduleFit();
}
// Coalesce the burst of registrations that fires as children connect during
// page load into a single fitBounds on the next microtask.
#scheduleFit = (): void => {
if (this.#fitScheduled) return;
this.#fitScheduled = true;
queueMicrotask(() => {
this.#fitScheduled = false;
this.#fitNow();
});
};
#fitNow(): void {
const map = this.leafletObject;
if (!map || !this.fitToMarkers) return;
const bounds = latLngBounds([]);
map.eachLayer((layer) => {
// An open popup/tooltip is a map layer with a getLatLng(); it shouldn't
// pull on the frame.
if (layer instanceof Popup || layer instanceof Tooltip) return;
const l = layer as Locatable;
if (typeof l.getBounds === 'function') bounds.extend(l.getBounds());
else if (typeof l.getLatLng === 'function') bounds.extend(l.getLatLng());
});
if (!bounds.isValid()) return;
const maxZoom = this.fitMaxZoom;
map.fitBounds(bounds, {
padding: [this.fitPadding, this.fitPadding],
maxZoom: Number.isFinite(maxZoom) ? maxZoom : undefined,
});
}
#buildShadowRoot(): HTMLDivElement { #buildShadowRoot(): HTMLDivElement {
if (this.#container) return this.#container; if (this.#container) return this.#container;
@ -365,12 +272,7 @@ export default class LeafletMapElement extends Base {
#onRegister = (e: LeafletRegisterEvent) => { #onRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation(); e.stopPropagation();
const map = this.leafletObject; const map = this.leafletObject;
if (!map) return; if (map) e.detail.leafletObject.addTo(map);
e.detail.leafletObject.addTo(map);
// A new component joined the tree -- reframe if we're fitting. This is the
// only reframe trigger, so panning, zooming and opening a popup all leave
// the view alone, and removing a marker doesn't pull it back in either.
if (this.#fitActive) this.#scheduleFit();
}; };
#onAddLayer = (e: LeafletLayerEvent) => { #onAddLayer = (e: LeafletLayerEvent) => {

@ -42,7 +42,6 @@ const PROPS: typeof latLngProps & {
}; };
const Base: LeafletElementConstructor<Marker, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<Marker, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-marker>` — a Leaflet `Marker`. Accepts `<leaflet-popup>`, `<leaflet-tooltip>`, and `<leaflet-icon>` / `<leaflet-div-icon>` children. */
export default class LeafletMarkerElement extends Base { export default class LeafletMarkerElement extends Base {
declare readonly leafletObject?: Marker; declare readonly leafletObject?: Marker;
declare addEventListener: LeafletAddEventListener<MarkerEvents>; declare addEventListener: LeafletAddEventListener<MarkerEvents>;

@ -13,7 +13,6 @@ import { VertexTracker } from '../core/vertex-tracker.ts';
const PROPS = { ...pathProps } as const; const PROPS = { ...pathProps } as const;
const Base: LeafletElementConstructor<Polygon, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<Polygon, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-polygon>` — a Leaflet `Polygon`. Vertices come from `<leaflet-line>` children, not an attribute. */
export default class LeafletPolygonElement extends Base { export default class LeafletPolygonElement extends Base {
declare readonly leafletObject?: Polygon; declare readonly leafletObject?: Polygon;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -24,7 +24,6 @@ const PROPS: typeof pathProps & {
}; };
const Base: LeafletElementConstructor<Polyline, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<Polyline, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-polyline>` — a Leaflet `Polyline`. Vertices come from `<leaflet-line>` children, not an attribute. */
export default class LeafletPolylineElement extends Base { export default class LeafletPolylineElement extends Base {
declare readonly leafletObject?: Polyline; declare readonly leafletObject?: Polyline;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -27,11 +27,6 @@ const PROPS: typeof latLngProps & {
}; };
const Base: LeafletElementConstructor<Popup, typeof PROPS> = WithProps(PROPS, { attach: 'self' }); const Base: LeafletElementConstructor<Popup, typeof PROPS> = WithProps(PROPS, { attach: 'self' });
/**
* `<leaflet-popup>` a Leaflet `Popup`. Child of a layer (bound via
* `bindPopup`) or standalone with its own `lat` / `lng`; content is the
* element's markup, kept in sync via a `MutationObserver`.
*/
export default class LeafletPopupElement extends Base { export default class LeafletPopupElement extends Base {
declare readonly leafletObject?: Popup; declare readonly leafletObject?: Popup;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>; declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;

@ -17,7 +17,6 @@ const PROPS: {
}; };
const Base: LeafletElementConstructor<Rectangle, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<Rectangle, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-rectangle>` — a Leaflet `Rectangle` spanning `bounds`, with `pathProps` styling. */
export default class LeafletRectangleElement extends Base { export default class LeafletRectangleElement extends Base {
declare readonly leafletObject?: Rectangle; declare readonly leafletObject?: Rectangle;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -40,7 +40,6 @@ const PROPS: {
}; };
const Base: LeafletElementConstructor<SVGOverlay, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<SVGOverlay, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-svg-overlay>` — a Leaflet `SVGOverlay` (an inline `<svg>` child pinned to geographic `bounds`). */
export default class LeafletSVGOverlayElement extends Base { export default class LeafletSVGOverlayElement extends Base {
declare readonly leafletObject?: SVGOverlay; declare readonly leafletObject?: SVGOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -63,11 +63,6 @@ const PROPS: {
}; };
const Base: LeafletElementConstructor<TileLayer.WMS, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<TileLayer.WMS, typeof PROPS> = WithProps(PROPS);
/**
* `<leaflet-tile-layer-wms>` a Leaflet `TileLayer.WMS`. WMS request params
* (`layers`, `styles`, `format`, ) merge through `setParams()`; `crs` takes a
* named CRS or a nested child that fires `leaflet-crs-changed`.
*/
export default class LeafletTileLayerWMSElement extends Base { export default class LeafletTileLayerWMSElement extends Base {
declare readonly leafletObject?: TileLayer.WMS; declare readonly leafletObject?: TileLayer.WMS;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>; declare addEventListener: LeafletAddEventListener<TileLayerEvents>;

@ -14,7 +14,6 @@ const PROPS = {
} as const; } as const;
const Base: LeafletElementConstructor<TileLayer, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<TileLayer, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-tile-layer>` — a Leaflet `TileLayer` (an XYZ raster tile source), e.g. OpenStreetMap. */
export default class LeafletTileLayerElement extends Base { export default class LeafletTileLayerElement extends Base {
declare readonly leafletObject?: TileLayer; declare readonly leafletObject?: TileLayer;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>; declare addEventListener: LeafletAddEventListener<TileLayerEvents>;

@ -27,7 +27,6 @@ const PROPS: typeof latLngProps & {
}; };
const Base: LeafletElementConstructor<Tooltip, typeof PROPS> = WithProps(PROPS, { attach: 'self' }); const Base: LeafletElementConstructor<Tooltip, typeof PROPS> = WithProps(PROPS, { attach: 'self' });
/** `<leaflet-tooltip>` — a Leaflet `Tooltip`. Child of a layer (bound via `bindTooltip`) or standalone; content is the element's markup. */
export default class LeafletTooltipElement extends Base { export default class LeafletTooltipElement extends Base {
declare readonly leafletObject?: Tooltip; declare readonly leafletObject?: Tooltip;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>; declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;

@ -67,7 +67,6 @@ const PROPS: {
}; };
const Base: LeafletElementConstructor<VideoOverlay, typeof PROPS> = WithProps(PROPS); const Base: LeafletElementConstructor<VideoOverlay, typeof PROPS> = WithProps(PROPS);
/** `<leaflet-video-overlay>` — a Leaflet `VideoOverlay` (a video pinned to geographic `bounds`). `loop` / `autoplay` / `muted` / `playsinline` map to the `<video>`. */
export default class LeafletVideoOverlayElement extends Base { export default class LeafletVideoOverlayElement extends Base {
declare readonly leafletObject?: VideoOverlay; declare readonly leafletObject?: VideoOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -1,29 +1,3 @@
/**
* [Leaflet.js](https://leafletjs.com/) as native Web Components. Importing this
* module registers every `leaflet-*` custom element (`<leaflet-map>`,
* `<leaflet-marker>`, `<leaflet-tile-layer>`, ) and re-exports the toolkit the
* components are built from the {@link WithProps} mixin, the `PropDef` codec
* factories, the shared prop fragments, and the event-map types so a
* third-party component can be built the same way.
*
* ```ts
* import 'leaflet-web-components'; // or: jsr:@buddy/leaflet-components
* ```
*
* ```html
* <leaflet-map lat="51.5" lng="-0.09" zoom="13" style="height:400px">
* <leaflet-tile-layer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"></leaflet-tile-layer>
* <leaflet-marker lat="51.5" lng="-0.09"></leaflet-marker>
* </leaflet-map>
* ```
*
* `leaflet` is a peer dependency. The `HTMLElementTagNameMap` /
* `HTMLElementEventMap` augmentations ship with the npm build only (JSR
* disallows global augmentation); see the README.
*
* @module
*/
// The order of the `./components/*` side-effect imports below is load-bearing, // The order of the `./components/*` side-effect imports below is load-bearing,
// not cosmetic: each of those modules calls `customElements.define()`, and // not cosmetic: each of those modules calls `customElements.define()`, and
// `customElements.define()` upgrades every matching element already in the // `customElements.define()` upgrades every matching element already in the

@ -1,163 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { Popup, type LatLngBounds } from 'leaflet';
import '../../src/components/leaflet-map.ts';
import '../../src/components/leaflet-tile-layer.ts';
import '../../src/components/leaflet-marker.ts';
// The initial (and every re-)frame is deferred to a microtask so the burst of
// `layeradd` events during load collapses into one `fitBounds`. Awaiting a
// freshly-queued microtask flushes the pending one first.
const flush = () =>
new Promise<void>((resolve) => {
queueMicrotask(resolve);
});
function makeMap(attrs: Record<string, string> = {}) {
const map = document.createElement('leaflet-map');
for (const [k, v] of Object.entries(attrs)) map.setAttribute(k, v);
return map;
}
function marker(lat: number, lng: number) {
const m = document.createElement('leaflet-marker');
m.setAttribute('lat', String(lat));
m.setAttribute('lng', String(lng));
return m;
}
describe('<leaflet-map fit-to-markers>', () => {
it('frames every marker once on load instead of honouring lat/lng/zoom', async () => {
const map = makeMap({ 'fit-to-markers': '', lat: '0', lng: '0', zoom: '2' });
map.append(marker(34.0537, -118.2427), marker(33.9416, -118.4085), marker(34.1341, -118.3215));
document.body.append(map);
// Spy after connect but before the microtask flush, so the initial frame is
// still pending and gets captured.
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
await flush();
expect(spy).toHaveBeenCalledTimes(1);
const bounds = spy.mock.calls[0]![0] as LatLngBounds;
expect(bounds.contains([34.0537, -118.2427])).toBe(true);
expect(bounds.contains([33.9416, -118.4085])).toBe(true);
expect(bounds.contains([34.1341, -118.3215])).toBe(true);
expect(spy.mock.calls[0]![1]).toMatchObject({ padding: [20, 20] });
map.remove();
});
it('passes fit-padding and fit-max-zoom through to fitBounds', async () => {
const map = makeMap({ 'fit-to-markers': '', 'fit-padding': '50', 'fit-max-zoom': '12' });
map.append(marker(1, 2), marker(3, 4));
document.body.append(map);
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
await flush();
expect(spy.mock.calls[0]![1]).toMatchObject({ padding: [50, 50], maxZoom: 12 });
map.remove();
});
it('re-frames when a marker is added later', async () => {
const map = makeMap({ 'fit-to-markers': '' });
map.append(marker(10, 10), marker(20, 20));
document.body.append(map);
await flush();
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
map.append(marker(40, -100));
await flush();
expect(spy).toHaveBeenCalledTimes(1);
expect((spy.mock.calls[0]![0] as LatLngBounds).contains([40, -100])).toBe(true);
map.remove();
});
it('activates when the attribute is toggled on after connect', async () => {
const map = makeMap({ lat: '0', lng: '0', zoom: '3' });
map.append(marker(34, -118), marker(35, -119));
document.body.append(map);
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
await flush();
expect(spy).not.toHaveBeenCalled();
map.setAttribute('fit-to-markers', '');
await flush();
expect(spy).toHaveBeenCalledTimes(1);
map.remove();
});
it('does not reframe when the user pans or zooms', async () => {
const map = makeMap({ 'fit-to-markers': '' });
map.append(marker(10, 10), marker(20, 20));
document.body.append(map);
await flush();
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
// Simulate a manual pan + zoom.
map.leafletObject!.setView([0, 0], 6);
map.leafletObject!.fire('moveend');
map.leafletObject!.fire('zoomend');
await flush();
expect(spy).not.toHaveBeenCalled();
map.remove();
});
it('does not reframe when a popup is added to / opened on the map', async () => {
const map = makeMap({ 'fit-to-markers': '' });
map.append(marker(34, -118), marker(35, -119));
document.body.append(map);
await flush();
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
new Popup().setLatLng([34, -118]).setContent('hi').openOn(map.leafletObject!);
await flush();
expect(spy).not.toHaveBeenCalled();
map.remove();
});
it('does nothing without the attribute', async () => {
const map = makeMap({ lat: '10', lng: '20', zoom: '5' });
map.append(marker(1, 2), marker(3, 4));
document.body.append(map);
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
await flush();
expect(spy).not.toHaveBeenCalled();
expect(map.leafletObject!.getZoom()).toBe(5);
map.remove();
});
it('leaves the view alone when no layer has a position', async () => {
const map = makeMap({ 'fit-to-markers': '', lat: '5', lng: '6', zoom: '4' });
const tiles = document.createElement('leaflet-tile-layer');
tiles.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
map.append(tiles);
document.body.append(map);
const spy = vi.spyOn(map.leafletObject!, 'fitBounds');
await flush();
expect(spy).not.toHaveBeenCalled();
expect(map.leafletObject!.getZoom()).toBe(4);
map.remove();
});
it('stops re-framing after disconnect', async () => {
const map = makeMap({ 'fit-to-markers': '' });
map.append(marker(1, 1), marker(2, 2));
document.body.append(map);
await flush();
const mapObj = map.leafletObject!;
const spy = vi.spyOn(mapObj, 'fitBounds');
map.remove();
await flush();
expect(spy).not.toHaveBeenCalled();
});
});
Loading…
Cancel
Save