Compare commits

...

7 Commits
v0.1.0 ... main

Author SHA1 Message Date
Buddy 6ff1b75ed3 0.2.0 2 weeks ago
Buddy 9c724ef586 chore: add package description for JSR score
jsr.json had no description field, costing the 'Has a description' point on
the JSR score. Add one and match package.json to it.
2 weeks ago
Buddy 36e1f5fe49 refactor(leaflet-map): rename syncFit -> applyFit
Mirrors the sibling applyCss(): both run on connect and on every relevant
attribute change. The prop set-handler that calls it becomes reapplyFit, the
same relinkCss -> applyCss shape.
2 weeks ago
Buddy cd2c8d0fc5 feat(leaflet-map): fit-to-markers view
Add fit-to-markers / fit-padding / fit-max-zoom attributes to <leaflet-map>.
When fit-to-markers is set the map ignores lat/lng/zoom and fitBounds()es a
box around every locatable layer (getLatLng / getBounds); tile layers and
open popups/tooltips are skipped.

The only reframe trigger is a component registering into the tree -- initial
load and later additions. Panning, zooming, opening a popup and removing a
marker all leave the view untouched. Reframes coalesce onto a microtask so
load produces one fitBounds call, not one per marker.
2 weeks ago
Buddy 664064bf1c 0.1.1 2 weeks ago
Buddy 5ded37a970 docs: JSDoc every public symbol + module docs for JSR score
JSR's score docked "module docs in all entrypoints" and "docs for most
symbols" (0% documented). This adds:

- `@module` doc comments to `src/index.ts` and `src/elements/index.ts`
- JSDoc on every top-level exported symbol reachable from those entrypoints:
  all `props.ts` types + codec factories, the `WithProps` mixin and its
  types, the `register.ts` events and `emit*` helpers, the `shared-props.ts`
  fragments and structural interfaces, every `event-types.ts` family, and all
  25 `Leaflet*Element` classes
- field-level docs on the `PropDef` and `LeafletElement` interfaces

Existing `//` explanations were converted in place; no behaviour change.
`jsr publish --dry-run` still clean; typecheck / lint / test / build pass.
2 weeks ago
Buddy 829f78fd8d chore: update stale package-name reference in test comment 2 weeks ago

@ -25,6 +25,25 @@ prop table alone.
[07](./07-tooling-and-build.md)).
- As tree root it also listens for `leaflet-add-layer` / `leaflet-remove-layer`
(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

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

4
package-lock.json generated

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

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

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

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

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

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

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

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

@ -19,6 +19,7 @@ const PROPS: typeof latLngProps &
};
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 {
declare readonly leafletObject?: CircleMarker;
declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -21,6 +21,7 @@ const PROPS = {
} as const;
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 {
declare readonly leafletObject?: Circle;
declare addEventListener: LeafletAddEventListener<PathEvents>;

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

@ -20,6 +20,11 @@ const Base: LeafletElementConstructor<Control.Layers, typeof PROPS> = WithProps(
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 {
declare readonly leafletObject?: Control.Layers;

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

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

@ -27,6 +27,11 @@ const Base: LeafletElementConstructor<DivIcon, typeof PROPS> = WithProps(PROPS,
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 {
declare readonly leafletObject?: DivIcon;

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

@ -25,6 +25,7 @@ const 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 {
declare readonly leafletObject?: GeoJSON;
declare addEventListener: LeafletAddEventListener<GroupEvents>;

@ -36,6 +36,7 @@ const Base: LeafletElementConstructor<Icon, typeof PROPS> = WithProps(PROPS, {
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 {
declare readonly leafletObject?: Icon;

@ -51,6 +51,7 @@ const 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 {
declare readonly leafletObject?: ImageOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;

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

@ -1,5 +1,11 @@
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 {
static get observedAttributes(): string[] {
return ['lat', 'lng'];

@ -1,4 +1,14 @@
import { Icon, Map as LMap, type MapOptions, version } from 'leaflet';
import {
Icon,
latLngBounds,
Map as LMap,
Popup,
Tooltip,
type LatLng,
type LatLngBounds,
type MapOptions,
version,
} from 'leaflet';
import {
bool,
disabled,
@ -31,6 +41,21 @@ function relinkCss(_map: LMap, _value: string, el: HTMLElement): void {
(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: {
lat: Positional<number, LMap>;
lng: Positional<number, LMap>;
@ -70,6 +95,9 @@ const PROPS: {
cssUrl: Positional<string>;
cssIntegrity: 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
// once it exists -- and written back whenever the user pans or zooms.
@ -168,11 +196,29 @@ const PROPS: {
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { 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' });
// The root of the component tree. Every other component bubbles a
// `leaflet-register` event up to here, which is where it stops.
/**
* `<leaflet-map>` a Leaflet `Map`, and the root of the component tree: every
* 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 {
declare readonly leafletObject?: LMap;
declare addEventListener: LeafletAddEventListener<MapEvents>;
@ -181,6 +227,8 @@ export default class LeafletMapElement extends Base {
#container?: HTMLDivElement;
#cssLink?: HTMLLinkElement;
#resizeObserver?: ResizeObserver;
#fitActive = false;
#fitScheduled = false;
createLeafletObject(options: MapOptions): LMap {
const map = new LMap(this.#container ?? this.#buildShadowRoot(), options);
@ -202,9 +250,12 @@ export default class LeafletMapElement extends Base {
this.addEventListener('leaflet-register', this.#onRegister);
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
this.applyFit();
}
disconnectedCallback(): void {
this.#fitActive = false;
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;
this.removeEventListener('leaflet-register', this.#onRegister);
@ -252,6 +303,48 @@ export default class LeafletMapElement extends Base {
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 {
if (this.#container) return this.#container;
@ -272,7 +365,12 @@ export default class LeafletMapElement extends Base {
#onRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
const map = this.leafletObject;
if (map) e.detail.leafletObject.addTo(map);
if (!map) return;
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) => {

@ -42,6 +42,7 @@ const PROPS: typeof latLngProps & {
};
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 {
declare readonly leafletObject?: Marker;
declare addEventListener: LeafletAddEventListener<MarkerEvents>;

@ -13,6 +13,7 @@ import { VertexTracker } from '../core/vertex-tracker.ts';
const PROPS = { ...pathProps } as const;
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 {
declare readonly leafletObject?: Polygon;
declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -24,6 +24,7 @@ const PROPS: typeof pathProps & {
};
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 {
declare readonly leafletObject?: Polyline;
declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -27,6 +27,11 @@ const PROPS: typeof latLngProps & {
};
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 {
declare readonly leafletObject?: Popup;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;

@ -17,6 +17,7 @@ const 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 {
declare readonly leafletObject?: Rectangle;
declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -40,6 +40,7 @@ const 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 {
declare readonly leafletObject?: SVGOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -63,6 +63,11 @@ const 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 {
declare readonly leafletObject?: TileLayer.WMS;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;

@ -14,6 +14,7 @@ const PROPS = {
} as const;
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 {
declare readonly leafletObject?: TileLayer;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;

@ -27,6 +27,7 @@ const PROPS: typeof latLngProps & {
};
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 {
declare readonly leafletObject?: Tooltip;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;

@ -67,6 +67,7 @@ const 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 {
declare readonly leafletObject?: VideoOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;

@ -1,3 +1,29 @@
/**
* [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,
// not cosmetic: each of those modules calls `customElements.define()`, and
// `customElements.define()` upgrades every matching element already in the

@ -0,0 +1,163 @@
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();
});
});

@ -54,7 +54,7 @@ describe('real page load order (markup before customElements.define)', () => {
expect(map.leafletObject).toBeUndefined();
// Mirrors index.ts's real export order, which is what actually runs when
// a page does `import 'leaflet-components'`.
// a page does `import 'leaflet-web-components'`.
const lc = await import('../src/index.ts');
expect(map).toBeInstanceOf(lc.LeafletMapElement);

Loading…
Cancel
Save