feat: type leaflet-* components and their leaflet: events

Two augmentations, so TypeScript actually knows about the custom elements:

- HTMLElementTagNameMap (src/index.ts): createElement/querySelector now
  infer the exact component class for all 24 tags instead of HTMLElement.
- Per-component addEventListener/removeEventListener overrides, typing
  `leaflet:<name>` events against the real Leaflet event payload
  (PopupEvent, DragEndEvent, LeafletMouseEvent, etc.) while still accepting
  ordinary DOM events normally, and rejecting event names that component
  doesn't fire.

src/core/event-types.ts holds reusable event-name -> payload-type
fragments (mirroring shared-props.ts's fragment reuse), composed per
family: MapEvents, MarkerEvents, PathEvents, TileLayerEvents,
DivOverlayLayerEvents, GroupEvents. LeafletAddEventListener<T>/
LeafletRemoveEventListener<T> in with-props.ts are type-only
intersection-of-overloads helpers applied via `declare addEventListener:
...`, the same pattern every component already uses for `declare readonly
leafletObject?: X` -- zero runtime cost. Deliberately no generic `string`
fallback overload: a fallback would silently accept unrecognized
`leaflet:*` names too, defeating the point.

Fixed a real bug found while building this: #forwardEvents was
dispatching the raw pre-merge `data` Leaflet passes to fire(), missing
type/target/sourceTarget that Leaflet's own fire() merges in before
notifying real .on() listeners. Typing `detail` against Leaflet's actual
event interfaces would have been dishonest otherwise, so the merge now
matches Leaflet's own Evented#fire.

Also added 'line-updated' to the existing internal-event HTMLElementEventMap
augmentation in register.ts (needed once addEventListener got overridden
on polygon/polyline, which use it internally) and cleaned up ~35 now-
redundant `as HTMLElement & {...}` casts across the test suite that the
tag name map makes unnecessary.
main
Buddy 4 weeks ago
parent 9b6c7ca598
commit 0419ab781d

@ -1,7 +1,12 @@
import { CircleMarker, type CircleMarkerOptions } from 'leaflet'; import { CircleMarker, type CircleMarkerOptions } from 'leaflet';
import { num } from '../core/props.ts'; import { num } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts'; import { latLngProps, pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletCircleMarker extends WithProps({ export class LeafletCircleMarker extends WithProps({
...latLngProps, ...latLngProps,
@ -9,6 +14,8 @@ export class LeafletCircleMarker extends WithProps({
...pathProps, ...pathProps,
}) { }) {
declare readonly leafletObject?: CircleMarker; declare readonly leafletObject?: CircleMarker;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: CircleMarkerOptions): CircleMarker { createLeafletObject(options: CircleMarkerOptions): CircleMarker {
return new CircleMarker([this.lat, this.lng], options); return new CircleMarker([this.lat, this.lng], options);

@ -1,7 +1,12 @@
import { Circle, type CircleOptions } from 'leaflet'; import { Circle, type CircleOptions } from 'leaflet';
import { num, positional } from '../core/props.ts'; import { num, positional } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts'; import { latLngProps, pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletCircle extends WithProps({ export class LeafletCircle extends WithProps({
...latLngProps, ...latLngProps,
@ -11,6 +16,8 @@ export class LeafletCircle extends WithProps({
radius: positional(num<Circle>(1000, { get: (obj) => obj.getRadius() })), radius: positional(num<Circle>(1000, { get: (obj) => obj.getRadius() })),
}) { }) {
declare readonly leafletObject?: Circle; declare readonly leafletObject?: Circle;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: CircleOptions): Circle { createLeafletObject(options: CircleOptions): Circle {
return new Circle([this.lat, this.lng], { ...options, radius: this.radius }); return new Circle([this.lat, this.lng], { ...options, radius: this.radius });

@ -1,9 +1,16 @@
import { FeatureGroup } from 'leaflet'; import { FeatureGroup } from 'leaflet';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
// Like leaflet-layer-group, but its children share events and a bounding box. // Like leaflet-layer-group, but its children share events and a bounding box.
export class LeafletFeatureGroup extends WithProps({}) { export class LeafletFeatureGroup extends WithProps({}) {
declare readonly leafletObject?: FeatureGroup; declare readonly leafletObject?: FeatureGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
createLeafletObject(): FeatureGroup { createLeafletObject(): FeatureGroup {
return new FeatureGroup([]); return new FeatureGroup([]);

@ -2,7 +2,12 @@ import { GeoJSON, type PathOptions } from 'leaflet';
import type { GeoJsonObject } from 'geojson'; import type { GeoJsonObject } from 'geojson';
import { json, positional } from '../core/props.ts'; import { json, positional } from '../core/props.ts';
import { pathProps } from '../core/shared-props.ts'; import { pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
export class LeafletGeoJSON extends WithProps({ export class LeafletGeoJSON extends WithProps({
data: positional( data: positional(
@ -16,6 +21,8 @@ export class LeafletGeoJSON extends WithProps({
...pathProps, ...pathProps,
}) { }) {
declare readonly leafletObject?: GeoJSON; declare readonly leafletObject?: GeoJSON;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
// Every prop but `data` is a style option, and GeoJSON takes those nested // Every prop but `data` is a style option, and GeoJSON takes those nested
// under `style` so they apply to each feature it builds. // under `style` so they apply to each feature it builds.

@ -6,7 +6,12 @@ import {
} from 'leaflet'; } from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts'; import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds, urlProp } from '../core/shared-props.ts'; import { getBounds, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletImageOverlay extends WithProps({ export class LeafletImageOverlay extends WithProps({
url: urlProp, url: urlProp,
@ -25,6 +30,8 @@ export class LeafletImageOverlay extends WithProps({
className: str(), className: str(),
}) { }) {
declare readonly leafletObject?: ImageOverlay; declare readonly leafletObject?: ImageOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: ImageOverlayOptions): ImageOverlay { createLeafletObject(options: ImageOverlayOptions): ImageOverlay {
return new ImageOverlay(this.url, this.bounds, options); return new ImageOverlay(this.url, this.bounds, options);

@ -1,10 +1,17 @@
import { LayerGroup } from 'leaflet'; import { LayerGroup } from 'leaflet';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
// A passthrough container: it has no options of its own, and children add // A passthrough container: it has no options of its own, and children add
// themselves to it through the standard registration bubble. // themselves to it through the standard registration bubble.
export class LeafletLayerGroup extends WithProps({}) { export class LeafletLayerGroup extends WithProps({}) {
declare readonly leafletObject?: LayerGroup; declare readonly leafletObject?: LayerGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
createLeafletObject(): LayerGroup { createLeafletObject(): LayerGroup {
return new LayerGroup([]); return new LayerGroup([]);

@ -1,7 +1,12 @@
import { Icon, Map as LMap, type MapOptions } from 'leaflet'; import { Icon, Map as LMap, type MapOptions } from 'leaflet';
import { bool, disabled, num, positional, str } from '../core/props.ts'; import { bool, disabled, num, positional, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts'; import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
import type { MapEvents } from '../core/event-types.ts';
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='; const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
@ -121,6 +126,8 @@ export class LeafletMap extends WithProps(
{ attach: 'none' }, { attach: 'none' },
) { ) {
declare readonly leafletObject?: LMap; declare readonly leafletObject?: LMap;
declare addEventListener: LeafletAddEventListener<MapEvents>;
declare removeEventListener: LeafletRemoveEventListener<MapEvents>;
#container?: HTMLDivElement; #container?: HTMLDivElement;
#cssLink?: HTMLLinkElement; #cssLink?: HTMLLinkElement;

@ -1,8 +1,13 @@
import { Icon, Marker, type MarkerOptions } from 'leaflet'; import { Icon, Marker, type MarkerOptions } from 'leaflet';
import { bool, num, str } from '../core/props.ts'; import { bool, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts'; import { latLngProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletIconChangedEvent } from '../core/register.ts'; import type { LeafletIconChangedEvent } from '../core/register.ts';
import type { MarkerEvents } from '../core/event-types.ts';
const PROPS = { const PROPS = {
...latLngProps, ...latLngProps,
@ -31,6 +36,8 @@ const PROPS = {
export class LeafletMarker extends WithProps(PROPS) { export class LeafletMarker extends WithProps(PROPS) {
declare readonly leafletObject?: Marker; declare readonly leafletObject?: Marker;
declare addEventListener: LeafletAddEventListener<MarkerEvents>;
declare removeEventListener: LeafletRemoveEventListener<MarkerEvents>;
createLeafletObject(options: MarkerOptions): Marker { createLeafletObject(options: MarkerOptions): Marker {
return new Marker([this.lat, this.lng], options); return new Marker([this.lat, this.lng], options);

@ -1,10 +1,17 @@
import { Polygon, type PolylineOptions } from 'leaflet'; import { Polygon, type PolylineOptions } from 'leaflet';
import { pathProps } from '../core/shared-props.ts'; import { pathProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletLine } from './leaflet-line.ts'; import type { LeafletLine } from './leaflet-line.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletPolygon extends WithProps({ ...pathProps }) { export class LeafletPolygon extends WithProps({ ...pathProps }) {
declare readonly leafletObject?: Polygon; declare readonly leafletObject?: Polygon;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#observer?: MutationObserver; #observer?: MutationObserver;

@ -1,8 +1,13 @@
import { Polyline, type PolylineOptions } from 'leaflet'; import { Polyline, type PolylineOptions } from 'leaflet';
import { bool, num } from '../core/props.ts'; import { bool, num } from '../core/props.ts';
import { pathProps, style } from '../core/shared-props.ts'; import { pathProps, style } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletLine } from './leaflet-line.ts'; import type { LeafletLine } from './leaflet-line.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletPolyline extends WithProps({ export class LeafletPolyline extends WithProps({
...pathProps, ...pathProps,
@ -12,6 +17,8 @@ export class LeafletPolyline extends WithProps({
noClip: bool(), noClip: bool(),
}) { }) {
declare readonly leafletObject?: Polyline; declare readonly leafletObject?: Polyline;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#observer?: MutationObserver; #observer?: MutationObserver;

@ -1,7 +1,12 @@
import { Popup, type PopupOptions } from 'leaflet'; import { Popup, type PopupOptions } from 'leaflet';
import { bool, num } from '../core/props.ts'; import { bool, num } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts'; import { latLngProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
export class LeafletPopup extends WithProps( export class LeafletPopup extends WithProps(
{ {
@ -16,6 +21,8 @@ export class LeafletPopup extends WithProps(
{ attach: 'self' }, { attach: 'self' },
) { ) {
declare readonly leafletObject?: Popup; declare readonly leafletObject?: Popup;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
#observer?: MutationObserver; #observer?: MutationObserver;

@ -1,13 +1,20 @@
import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet'; import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet';
import { json, positional } from '../core/props.ts'; import { json, positional } from '../core/props.ts';
import { pathProps, getBounds } from '../core/shared-props.ts'; import { pathProps, getBounds } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletRectangle extends WithProps({ export class LeafletRectangle extends WithProps({
bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })), bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
...pathProps, ...pathProps,
}) { }) {
declare readonly leafletObject?: Rectangle; declare readonly leafletObject?: Rectangle;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: PolylineOptions): Rectangle { createLeafletObject(options: PolylineOptions): Rectangle {
return new Rectangle(this.bounds, options); return new Rectangle(this.bounds, options);

@ -6,7 +6,12 @@ import {
} from 'leaflet'; } from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts'; import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds } from '../core/shared-props.ts'; import { getBounds } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletSVGOverlay extends WithProps({ export class LeafletSVGOverlay extends WithProps({
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })), bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
@ -17,6 +22,8 @@ export class LeafletSVGOverlay extends WithProps({
className: str(), className: str(),
}) { }) {
declare readonly leafletObject?: SVGOverlay; declare readonly leafletObject?: SVGOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: ImageOverlayOptions): SVGOverlay { createLeafletObject(options: ImageOverlayOptions): SVGOverlay {
const svg = const svg =

@ -1,7 +1,12 @@
import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet'; import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet';
import { bool, str, type PropDef } from '../core/props.ts'; import { bool, str, type PropDef } from '../core/props.ts';
import { tileLayerProps, urlProp } from '../core/shared-props.ts'; import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { TileLayerEvents } from '../core/event-types.ts';
// WMS request parameters have no individual setters -- they're merged into the // WMS request parameters have no individual setters -- they're merged into the
// query string through setParams(). // query string through setParams().
@ -43,6 +48,8 @@ export class LeafletTileLayerWMS extends WithProps({
crs: crsProp, crs: crsProp,
}) { }) {
declare readonly leafletObject?: TileLayer.WMS; declare readonly leafletObject?: TileLayer.WMS;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
createLeafletObject(options: WMSOptions): TileLayer.WMS { createLeafletObject(options: WMSOptions): TileLayer.WMS {
return new TileLayer.WMS(this.url, options); return new TileLayer.WMS(this.url, options);

@ -1,12 +1,19 @@
import { TileLayer, type TileLayerOptions } from 'leaflet'; import { TileLayer, type TileLayerOptions } from 'leaflet';
import { tileLayerProps, urlProp } from '../core/shared-props.ts'; import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { TileLayerEvents } from '../core/event-types.ts';
export class LeafletTileLayer extends WithProps({ export class LeafletTileLayer extends WithProps({
url: urlProp, url: urlProp,
...tileLayerProps, ...tileLayerProps,
}) { }) {
declare readonly leafletObject?: TileLayer; declare readonly leafletObject?: TileLayer;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
createLeafletObject(options: TileLayerOptions): TileLayer { createLeafletObject(options: TileLayerOptions): TileLayer {
return new TileLayer(this.url, options); return new TileLayer(this.url, options);

@ -1,7 +1,12 @@
import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet'; import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet';
import { bool, choice, json, num, str } from '../core/props.ts'; import { bool, choice, json, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts'; import { latLngProps } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
export class LeafletTooltip extends WithProps( export class LeafletTooltip extends WithProps(
{ {
@ -16,6 +21,8 @@ export class LeafletTooltip extends WithProps(
{ attach: 'self' }, { attach: 'self' },
) { ) {
declare readonly leafletObject?: Tooltip; declare readonly leafletObject?: Tooltip;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
#observer?: MutationObserver; #observer?: MutationObserver;

@ -6,7 +6,12 @@ import {
} from 'leaflet'; } from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts'; import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds, urlProp } from '../core/shared-props.ts'; import { getBounds, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts'; import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
// Playback options live on the <video> element Leaflet builds, so live updates // Playback options live on the <video> element Leaflet builds, so live updates
// go there rather than through a Leaflet setter. // go there rather than through a Leaflet setter.
@ -36,6 +41,8 @@ export class LeafletVideoOverlay extends WithProps({
errorOverlayUrl: str(), errorOverlayUrl: str(),
}) { }) {
declare readonly leafletObject?: VideoOverlay; declare readonly leafletObject?: VideoOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: VideoOverlayOptions): VideoOverlay { createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
return new VideoOverlay(this.url, this.bounds, options); return new VideoOverlay(this.url, this.bounds, options);

@ -0,0 +1,125 @@
// Reusable event-name -> payload-type fragments, mirroring the shared prop
// fragments in shared-props.ts. Each key names a Leaflet event; WithProps's
// generic fire() forwarding (with-props.ts) re-emits it as a DOM
// `leaflet:<name>` CustomEvent whose `detail` is exactly this payload type
// (see the merge in #forwardEvents, which matches Leaflet's own Evented#fire).
//
// Components compose these into one type per family and apply it via
// `declare addEventListener: LeafletAddEventListener<TheseEvents>` (see
// with-props.ts) -- not through WithProps() itself, since (like
// `leafletObject`) the object type isn't reliably inferred from the PROPS
// table alone and every component already re-declares it manually.
// `ErrorEvent` is aliased below because it's also a global DOM type.
import type {
DragEndEvent,
ErrorEvent as LeafletErrorEvent,
LayerEvent,
LayersControlEvent,
LeafletEvent,
LeafletKeyboardEvent,
LeafletMouseEvent,
LocationEvent,
PopupEvent,
ResizeEvent,
TileErrorEvent,
TileEvent,
TooltipEvent,
ZoomAnimEvent,
} from 'leaflet';
export interface MoveEvents {
movestart: LeafletEvent;
move: LeafletEvent;
moveend: LeafletEvent;
}
export interface LayerAddRemoveEvents {
add: LeafletEvent;
remove: LeafletEvent;
}
export interface MouseEvents {
click: LeafletMouseEvent;
dblclick: LeafletMouseEvent;
mousedown: LeafletMouseEvent;
mouseup: LeafletMouseEvent;
mouseover: LeafletMouseEvent;
mouseout: LeafletMouseEvent;
contextmenu: LeafletMouseEvent;
}
export interface PopupBindEvents {
popupopen: PopupEvent;
popupclose: PopupEvent;
}
export interface TooltipBindEvents {
tooltipopen: TooltipEvent;
tooltipclose: TooltipEvent;
}
export interface DragEvents {
dragstart: LeafletEvent;
drag: LeafletEvent;
dragend: DragEndEvent;
}
export interface TileEvents {
loading: LeafletEvent;
load: LeafletEvent;
tileloadstart: TileEvent;
tileload: TileEvent;
tileunload: TileEvent;
tileerror: TileErrorEvent;
}
export interface DivOverlayEvents {
contentupdate: LeafletEvent;
}
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.
export type BaseLayerEvents = LayerAddRemoveEvents & PopupBindEvents & TooltipBindEvents;
export type PathEvents = BaseLayerEvents & MouseEvents;
export type MarkerEvents = BaseLayerEvents & MouseEvents & MoveEvents & DragEvents;
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.
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.
export type GroupEvents = LayerAddRemoveEvents & LayerGroupEvents;
export interface MapEvents
extends MoveEvents, MouseEvents, PopupBindEvents, TooltipBindEvents, LayerGroupEvents {
zoomstart: LeafletEvent;
zoomend: LeafletEvent;
zoom: LeafletEvent;
zoomlevelschange: LeafletEvent;
viewreset: LeafletEvent;
load: LeafletEvent;
unload: LeafletEvent;
resize: ResizeEvent;
autopanstart: LeafletEvent;
locationerror: LeafletErrorEvent;
locationfound: LocationEvent;
baselayerchange: LayersControlEvent;
overlayadd: LayersControlEvent;
overlayremove: LayersControlEvent;
keypress: LeafletKeyboardEvent;
keydown: LeafletKeyboardEvent;
keyup: LeafletKeyboardEvent;
zoomanim: ZoomAnimEvent;
preclick: LeafletMouseEvent;
}

@ -12,12 +12,17 @@ export type LeafletLayerEvent = CustomEvent<{ layer: Layer }>;
export type LeafletIconChangedEvent = CustomEvent<{ icon: Icon | null }>; export type LeafletIconChangedEvent = CustomEvent<{ icon: Icon | null }>;
// Fired by <leaflet-line> on itself when its lat/lng changes; polygon/polyline
// listen for it (bubbling) to know when to re-read their vertices.
export type LeafletLineUpdatedEvent = CustomEvent<null>;
declare global { declare global {
interface HTMLElementEventMap { interface HTMLElementEventMap {
'leaflet-register': LeafletRegisterEvent; 'leaflet-register': LeafletRegisterEvent;
'leaflet-add-layer': LeafletLayerEvent; 'leaflet-add-layer': LeafletLayerEvent;
'leaflet-remove-layer': LeafletLayerEvent; 'leaflet-remove-layer': LeafletLayerEvent;
'icon-changed': LeafletIconChangedEvent; 'icon-changed': LeafletIconChangedEvent;
'line-updated': LeafletLineUpdatedEvent;
} }
} }

@ -47,6 +47,39 @@ 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
// 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.
export type LeafletAddEventListener<TEvents> = (<K extends keyof TEvents & string>(
type: `leaflet:${K}`,
listener: (ev: CustomEvent<TEvents[K]>) => void,
options?: boolean | AddEventListenerOptions,
) => void) &
(<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
) => void);
export type LeafletRemoveEventListener<TEvents> = (<K extends keyof TEvents & string>(
type: `leaflet:${K}`,
listener: (ev: CustomEvent<TEvents[K]>) => void,
options?: boolean | EventListenerOptions,
) => void) &
(<K extends keyof HTMLElementEventMap>(
type: K,
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
options?: boolean | EventListenerOptions,
) => void);
interface ResolvedProp<TObj extends Class> { interface ResolvedProp<TObj extends Class> {
name: string; name: string;
attribute: string; attribute: string;
@ -242,7 +275,13 @@ export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
if (typeof fire !== 'function') return; if (typeof fire !== 'function') return;
target.fire = (type, data, propagate) => { target.fire = (type, data, propagate) => {
const result = fire.call(obj, type, data, propagate); const result = fire.call(obj, type, data, propagate);
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail: data ?? {} })); // Mirrors Leaflet's own Evented#fire merge (extend({}, data, {type,
// target, sourceTarget})) so `detail` matches what a real `.on()`
// listener receives -- and so Leaflet's typed event interfaces
// (LeafletEvent's type/target/sourceTarget fields) are honest.
const sourceTarget = (data as { sourceTarget?: unknown } | undefined)?.sourceTarget ?? obj;
const detail = { ...data, type, target: obj, sourceTarget };
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail }));
return result; return result;
}; };
} }

@ -24,3 +24,59 @@ export { LeafletPopup } from './components/leaflet-popup.ts';
export { LeafletTooltip } from './components/leaflet-tooltip.ts'; export { LeafletTooltip } from './components/leaflet-tooltip.ts';
export { LeafletIcon } from './components/leaflet-icon.ts'; export { LeafletIcon } from './components/leaflet-icon.ts';
export { LeafletDivIcon } from './components/leaflet-div-icon.ts'; export { LeafletDivIcon } from './components/leaflet-div-icon.ts';
// `export { X } from '...'` re-exports X but doesn't bind it locally, so the
// tag map below needs its own type-only imports of the same classes.
import type { LeafletMap } from './components/leaflet-map.ts';
import type { LeafletMarker } from './components/leaflet-marker.ts';
import type { LeafletCircle } from './components/leaflet-circle.ts';
import type { LeafletCircleMarker } from './components/leaflet-circle-marker.ts';
import type { LeafletLine } from './components/leaflet-line.ts';
import type { LeafletPolygon } from './components/leaflet-polygon.ts';
import type { LeafletPolyline } from './components/leaflet-polyline.ts';
import type { LeafletRectangle } from './components/leaflet-rectangle.ts';
import type { LeafletTileLayer } from './components/leaflet-tile-layer.ts';
import type { LeafletTileLayerWMS } from './components/leaflet-tile-layer-wms.ts';
import type { LeafletImageOverlay } from './components/leaflet-image-overlay.ts';
import type { LeafletVideoOverlay } from './components/leaflet-video-overlay.ts';
import type { LeafletSVGOverlay } from './components/leaflet-svg-overlay.ts';
import type { LeafletLayerGroup } from './components/leaflet-layer-group.ts';
import type { LeafletFeatureGroup } from './components/leaflet-feature-group.ts';
import type { LeafletGeoJSON } from './components/leaflet-geojson.ts';
import type { LeafletControlLayers } from './components/leaflet-control-layers.ts';
import type { LeafletControlZoom } from './components/leaflet-control-zoom.ts';
import type { LeafletControlAttribution } from './components/leaflet-control-attribution.ts';
import type { LeafletControlScale } from './components/leaflet-control-scale.ts';
import type { LeafletPopup } from './components/leaflet-popup.ts';
import type { LeafletTooltip } from './components/leaflet-tooltip.ts';
import type { LeafletIcon } from './components/leaflet-icon.ts';
import type { LeafletDivIcon } from './components/leaflet-div-icon.ts';
declare global {
interface HTMLElementTagNameMap {
'leaflet-map': LeafletMap;
'leaflet-marker': LeafletMarker;
'leaflet-circle': LeafletCircle;
'leaflet-circle-marker': LeafletCircleMarker;
'leaflet-line': LeafletLine;
'leaflet-polygon': LeafletPolygon;
'leaflet-polyline': LeafletPolyline;
'leaflet-rectangle': LeafletRectangle;
'leaflet-tile-layer': LeafletTileLayer;
'leaflet-tile-layer-wms': LeafletTileLayerWMS;
'leaflet-image-overlay': LeafletImageOverlay;
'leaflet-video-overlay': LeafletVideoOverlay;
'leaflet-svg-overlay': LeafletSVGOverlay;
'leaflet-layer-group': LeafletLayerGroup;
'leaflet-feature-group': LeafletFeatureGroup;
'leaflet-geojson': LeafletGeoJSON;
'leaflet-control-layers': LeafletControlLayers;
'leaflet-control-zoom': LeafletControlZoom;
'leaflet-control-attribution': LeafletControlAttribution;
'leaflet-control-scale': LeafletControlScale;
'leaflet-popup': LeafletPopup;
'leaflet-tooltip': LeafletTooltip;
'leaflet-icon': LeafletIcon;
'leaflet-div-icon': LeafletDivIcon;
}
}

@ -11,27 +11,21 @@ import '../../src/components/leaflet-marker.ts';
describe('leaflet-control-zoom / scale / attribution', () => { describe('leaflet-control-zoom / scale / attribution', () => {
it('create their matching Control class with position and options', () => { it('create their matching Control class with position and options', () => {
const zoom = document.createElement('leaflet-control-zoom') as HTMLElement & { const zoom = document.createElement('leaflet-control-zoom');
leafletObject?: Control.Zoom;
};
zoom.setAttribute('position', 'bottomleft'); zoom.setAttribute('position', 'bottomleft');
document.body.append(zoom); document.body.append(zoom);
expect(zoom.leafletObject).toBeInstanceOf(Control.Zoom); expect(zoom.leafletObject).toBeInstanceOf(Control.Zoom);
expect(zoom.leafletObject?.options.position).toBe('bottomleft'); expect(zoom.leafletObject?.options.position).toBe('bottomleft');
zoom.remove(); zoom.remove();
const scale = document.createElement('leaflet-control-scale') as HTMLElement & { const scale = document.createElement('leaflet-control-scale');
leafletObject?: Control.Scale;
};
scale.setAttribute('max-width', '150'); scale.setAttribute('max-width', '150');
document.body.append(scale); document.body.append(scale);
expect(scale.leafletObject).toBeInstanceOf(Control.Scale); expect(scale.leafletObject).toBeInstanceOf(Control.Scale);
expect(scale.leafletObject?.options.maxWidth).toBe(150); expect(scale.leafletObject?.options.maxWidth).toBe(150);
scale.remove(); scale.remove();
const attribution = document.createElement('leaflet-control-attribution') as HTMLElement & { const attribution = document.createElement('leaflet-control-attribution');
leafletObject?: Control.Attribution;
};
attribution.setAttribute('prefix', 'Test'); attribution.setAttribute('prefix', 'Test');
document.body.append(attribution); document.body.append(attribution);
expect(attribution.leafletObject).toBeInstanceOf(Control.Attribution); expect(attribution.leafletObject).toBeInstanceOf(Control.Attribution);
@ -42,9 +36,7 @@ describe('leaflet-control-zoom / scale / attribution', () => {
describe('leaflet-control-layers', () => { describe('leaflet-control-layers', () => {
it('sorts existing children into base layers vs overlays by the type attribute', () => { it('sorts existing children into base layers vs overlays by the type attribute', () => {
const el = document.createElement('leaflet-control-layers') as HTMLElement & { const el = document.createElement('leaflet-control-layers');
leafletObject?: Control.Layers;
};
const base = document.createElement('leaflet-tile-layer'); const base = document.createElement('leaflet-tile-layer');
base.setAttribute('url', 'https://a.example/{z}/{x}/{y}.png'); base.setAttribute('url', 'https://a.example/{z}/{x}/{y}.png');
base.setAttribute('name', 'Base'); base.setAttribute('name', 'Base');
@ -77,9 +69,7 @@ describe('leaflet-control-layers', () => {
describe('leaflet-layer-group / leaflet-feature-group', () => { describe('leaflet-layer-group / leaflet-feature-group', () => {
it('are passthrough WithProps({}) containers that adopt registering children', () => { it('are passthrough WithProps({}) containers that adopt registering children', () => {
const group = document.createElement('leaflet-layer-group') as HTMLElement & { const group = document.createElement('leaflet-layer-group');
leafletObject?: LayerGroup;
};
document.body.append(group); document.body.append(group);
expect(group.leafletObject).toBeInstanceOf(LayerGroup); expect(group.leafletObject).toBeInstanceOf(LayerGroup);
@ -91,9 +81,7 @@ describe('leaflet-layer-group / leaflet-feature-group', () => {
expect(group.leafletObject?.getLayers()).toHaveLength(1); expect(group.leafletObject?.getLayers()).toHaveLength(1);
group.remove(); group.remove();
const feature = document.createElement('leaflet-feature-group') as HTMLElement & { const feature = document.createElement('leaflet-feature-group');
leafletObject?: FeatureGroup;
};
document.body.append(feature); document.body.append(feature);
expect(feature.leafletObject).toBeInstanceOf(FeatureGroup); expect(feature.leafletObject).toBeInstanceOf(FeatureGroup);
feature.remove(); feature.remove();

@ -21,9 +21,7 @@ const FEATURE = {
describe('leaflet-geojson', () => { describe('leaflet-geojson', () => {
it('builds a GeoJSON layer from the data attribute with pathProps nested as style', () => { it('builds a GeoJSON layer from the data attribute with pathProps nested as style', () => {
const el = document.createElement('leaflet-geojson') as HTMLElement & { const el = document.createElement('leaflet-geojson');
leafletObject?: GeoJSON;
};
el.setAttribute('data', JSON.stringify(FEATURE)); el.setAttribute('data', JSON.stringify(FEATURE));
el.setAttribute('color', '#e67e22'); el.setAttribute('color', '#e67e22');
el.setAttribute('fill-opacity', '0.4'); el.setAttribute('fill-opacity', '0.4');
@ -39,9 +37,7 @@ describe('leaflet-geojson', () => {
}); });
it('replaces its layers when the data attribute changes', () => { it('replaces its layers when the data attribute changes', () => {
const el = document.createElement('leaflet-geojson') as HTMLElement & { const el = document.createElement('leaflet-geojson');
leafletObject?: GeoJSON;
};
el.setAttribute('data', JSON.stringify(FEATURE)); el.setAttribute('data', JSON.stringify(FEATURE));
document.body.append(el); document.body.append(el);
expect(el.leafletObject?.getLayers()).toHaveLength(1); expect(el.leafletObject?.getLayers()).toHaveLength(1);

@ -5,9 +5,7 @@ import '../../src/components/leaflet-div-icon.ts';
describe('leaflet-icon', () => { describe('leaflet-icon', () => {
it('creates nothing until icon-url is set, then an Icon once it is', () => { it('creates nothing until icon-url is set, then an Icon once it is', () => {
const el = document.createElement('leaflet-icon') as HTMLElement & { const el = document.createElement('leaflet-icon');
leafletObject?: Icon;
};
document.body.append(el); document.body.append(el);
expect(el.leafletObject).toBeUndefined(); expect(el.leafletObject).toBeUndefined();
@ -18,9 +16,7 @@ describe('leaflet-icon', () => {
}); });
it('rebuilds (not mutates) the Icon on every attribute change and re-emits icon-changed', () => { it('rebuilds (not mutates) the Icon on every attribute change and re-emits icon-changed', () => {
const el = document.createElement('leaflet-icon') as HTMLElement & { const el = document.createElement('leaflet-icon');
leafletObject?: Icon;
};
el.setAttribute('icon-url', 'https://example.com/a.png'); el.setAttribute('icon-url', 'https://example.com/a.png');
const onChanged = vi.fn(); const onChanged = vi.fn();
el.addEventListener('icon-changed', onChanged); el.addEventListener('icon-changed', onChanged);
@ -38,9 +34,7 @@ describe('leaflet-icon', () => {
describe('leaflet-div-icon', () => { describe('leaflet-div-icon', () => {
it('defaults className to Leaflets own default and renders markup as html', () => { it('defaults className to Leaflets own default and renders markup as html', () => {
const el = document.createElement('leaflet-div-icon') as HTMLElement & { const el = document.createElement('leaflet-div-icon');
leafletObject?: DivIcon;
};
el.innerHTML = '<span>*</span>'; el.innerHTML = '<span>*</span>';
document.body.append(el); document.body.append(el);

@ -8,11 +8,7 @@ import '../../src/components/leaflet-icon.ts';
describe('leaflet-marker', () => { describe('leaflet-marker', () => {
it('creates a Marker at lat/lng and keeps lat/lng synced while it moves', () => { it('creates a Marker at lat/lng and keeps lat/lng synced while it moves', () => {
const el = document.createElement('leaflet-marker') as HTMLElement & { const el = document.createElement('leaflet-marker');
leafletObject?: Marker;
lat: number;
lng: number;
};
el.setAttribute('lat', '51.5'); el.setAttribute('lat', '51.5');
el.setAttribute('lng', '-0.09'); el.setAttribute('lng', '-0.09');
document.body.append(el); document.body.append(el);
@ -35,9 +31,7 @@ describe('leaflet-marker', () => {
const map = document.createElement('leaflet-map'); const map = document.createElement('leaflet-map');
document.body.append(map); document.body.append(map);
const el = document.createElement('leaflet-marker') as HTMLElement & { const el = document.createElement('leaflet-marker');
leafletObject?: Marker;
};
el.setAttribute('lat', '0'); el.setAttribute('lat', '0');
el.setAttribute('lng', '0'); el.setAttribute('lng', '0');
el.setAttribute('draggable', ''); el.setAttribute('draggable', '');
@ -50,9 +44,7 @@ describe('leaflet-marker', () => {
}); });
it('swaps in a <leaflet-icon> child via the icon-changed handshake', () => { it('swaps in a <leaflet-icon> child via the icon-changed handshake', () => {
const el = document.createElement('leaflet-marker') as HTMLElement & { const el = document.createElement('leaflet-marker');
leafletObject?: Marker;
};
el.setAttribute('lat', '0'); el.setAttribute('lat', '0');
el.setAttribute('lng', '0'); el.setAttribute('lng', '0');
document.body.append(el); document.body.append(el);
@ -61,18 +53,14 @@ describe('leaflet-marker', () => {
icon.setAttribute('icon-url', 'https://example.com/icon.png'); icon.setAttribute('icon-url', 'https://example.com/icon.png');
el.append(icon); el.append(icon);
expect(el.leafletObject?.options.icon).toBe( expect(el.leafletObject?.options.icon).toBe(icon.leafletObject);
(icon as HTMLElement & { leafletObject?: unknown }).leafletObject,
);
el.remove(); el.remove();
}); });
}); });
describe('leaflet-popup', () => { describe('leaflet-popup', () => {
it('takes its content from innerHTML and stays in sync on mutation', async () => { it('takes its content from innerHTML and stays in sync on mutation', async () => {
const el = document.createElement('leaflet-popup') as HTMLElement & { const el = document.createElement('leaflet-popup');
leafletObject?: Popup;
};
el.innerHTML = '<b>hello</b>'; el.innerHTML = '<b>hello</b>';
document.body.append(el); document.body.append(el);
@ -87,18 +75,14 @@ describe('leaflet-popup', () => {
}); });
it('only sets its own latlng when lat/lng attributes are present', () => { it('only sets its own latlng when lat/lng attributes are present', () => {
const withLatLng = document.createElement('leaflet-popup') as HTMLElement & { const withLatLng = document.createElement('leaflet-popup');
leafletObject?: Popup;
};
withLatLng.setAttribute('lat', '1'); withLatLng.setAttribute('lat', '1');
withLatLng.setAttribute('lng', '2'); withLatLng.setAttribute('lng', '2');
document.body.append(withLatLng); document.body.append(withLatLng);
expect(withLatLng.leafletObject?.getLatLng()).toMatchObject({ lat: 1, lng: 2 }); expect(withLatLng.leafletObject?.getLatLng()).toMatchObject({ lat: 1, lng: 2 });
withLatLng.remove(); withLatLng.remove();
const withoutLatLng = document.createElement('leaflet-popup') as HTMLElement & { const withoutLatLng = document.createElement('leaflet-popup');
leafletObject?: Popup;
};
document.body.append(withoutLatLng); document.body.append(withoutLatLng);
expect(withoutLatLng.leafletObject?.getLatLng()).toBeUndefined(); expect(withoutLatLng.leafletObject?.getLatLng()).toBeUndefined();
withoutLatLng.remove(); withoutLatLng.remove();
@ -107,9 +91,7 @@ describe('leaflet-popup', () => {
describe('leaflet-tooltip', () => { describe('leaflet-tooltip', () => {
it('creates a Tooltip with content and direction/permanent/sticky options', () => { it('creates a Tooltip with content and direction/permanent/sticky options', () => {
const el = document.createElement('leaflet-tooltip') as HTMLElement & { const el = document.createElement('leaflet-tooltip');
leafletObject?: Tooltip;
};
el.innerHTML = 'hover text'; el.innerHTML = 'hover text';
el.setAttribute('direction', 'top'); el.setAttribute('direction', 'top');
el.setAttribute('permanent', ''); el.setAttribute('permanent', '');

@ -12,11 +12,7 @@ const BOUNDS: [[number, number], [number, number]] = [
describe('leaflet-image-overlay', () => { describe('leaflet-image-overlay', () => {
it('creates an ImageOverlay with a live bounds getter (url has no Leaflet getter to read back)', () => { it('creates an ImageOverlay with a live bounds getter (url has no Leaflet getter to read back)', () => {
const el = document.createElement('leaflet-image-overlay') as HTMLElement & { const el = document.createElement('leaflet-image-overlay');
leafletObject?: ImageOverlay;
url: string;
bounds: unknown;
};
el.setAttribute('url', 'https://example.com/a.png'); el.setAttribute('url', 'https://example.com/a.png');
el.setAttribute('bounds', JSON.stringify(BOUNDS)); el.setAttribute('bounds', JSON.stringify(BOUNDS));
document.body.append(el); document.body.append(el);
@ -42,9 +38,7 @@ describe('leaflet-image-overlay', () => {
describe('leaflet-video-overlay', () => { describe('leaflet-video-overlay', () => {
it('creates a VideoOverlay and applies the newly added options', () => { it('creates a VideoOverlay and applies the newly added options', () => {
const el = document.createElement('leaflet-video-overlay') as HTMLElement & { const el = document.createElement('leaflet-video-overlay');
leafletObject?: VideoOverlay;
};
el.setAttribute('url', 'https://example.com/a.mp4'); el.setAttribute('url', 'https://example.com/a.mp4');
el.setAttribute('bounds', JSON.stringify(BOUNDS)); el.setAttribute('bounds', JSON.stringify(BOUNDS));
el.setAttribute('z-index', '5'); el.setAttribute('z-index', '5');
@ -67,10 +61,7 @@ describe('leaflet-video-overlay', () => {
const map = document.createElement('leaflet-map'); const map = document.createElement('leaflet-map');
document.body.append(map); document.body.append(map);
const el = document.createElement('leaflet-video-overlay') as HTMLElement & { const el = document.createElement('leaflet-video-overlay');
leafletObject?: VideoOverlay;
getElement(): HTMLVideoElement | undefined;
};
el.setAttribute('url', 'https://example.com/a.mp4'); el.setAttribute('url', 'https://example.com/a.mp4');
el.setAttribute('bounds', JSON.stringify(BOUNDS)); el.setAttribute('bounds', JSON.stringify(BOUNDS));
el.setAttribute('muted', ''); el.setAttribute('muted', '');
@ -86,10 +77,7 @@ describe('leaflet-video-overlay', () => {
describe('leaflet-svg-overlay', () => { describe('leaflet-svg-overlay', () => {
it('creates an SVGOverlay with a live bounds getter', () => { it('creates an SVGOverlay with a live bounds getter', () => {
const el = document.createElement('leaflet-svg-overlay') as HTMLElement & { const el = document.createElement('leaflet-svg-overlay');
leafletObject?: SVGOverlay;
bounds: unknown;
};
el.setAttribute('bounds', JSON.stringify(BOUNDS)); el.setAttribute('bounds', JSON.stringify(BOUNDS));
document.body.append(el); document.body.append(el);

@ -9,10 +9,7 @@ import '../../src/components/leaflet-line.ts';
describe('leaflet-circle', () => { describe('leaflet-circle', () => {
it('creates a Circle at lat/lng with radius and pathProps applied', () => { it('creates a Circle at lat/lng with radius and pathProps applied', () => {
const el = document.createElement('leaflet-circle') as HTMLElement & { const el = document.createElement('leaflet-circle');
leafletObject?: Circle;
radius: number;
};
el.setAttribute('lat', '51.5'); el.setAttribute('lat', '51.5');
el.setAttribute('lng', '-0.09'); el.setAttribute('lng', '-0.09');
el.setAttribute('radius', '500'); el.setAttribute('radius', '500');
@ -29,10 +26,7 @@ describe('leaflet-circle', () => {
}); });
it('radius property reads the live value and reacts to attribute changes', () => { it('radius property reads the live value and reacts to attribute changes', () => {
const el = document.createElement('leaflet-circle') as HTMLElement & { const el = document.createElement('leaflet-circle');
leafletObject?: Circle;
radius: number;
};
document.body.append(el); document.body.append(el);
// Leaflet's own default, no attribute set // Leaflet's own default, no attribute set
expect(el.radius).toBe(1000); expect(el.radius).toBe(1000);
@ -51,10 +45,7 @@ describe('leaflet-circle', () => {
describe('leaflet-circle-marker', () => { describe('leaflet-circle-marker', () => {
it('creates a CircleMarker with a live radius getter', () => { it('creates a CircleMarker with a live radius getter', () => {
const el = document.createElement('leaflet-circle-marker') as HTMLElement & { const el = document.createElement('leaflet-circle-marker');
leafletObject?: CircleMarker;
radius: number;
};
el.setAttribute('lat', '1'); el.setAttribute('lat', '1');
el.setAttribute('lng', '2'); el.setAttribute('lng', '2');
el.setAttribute('radius', '15'); el.setAttribute('radius', '15');
@ -70,10 +61,7 @@ describe('leaflet-circle-marker', () => {
describe('leaflet-rectangle', () => { describe('leaflet-rectangle', () => {
it('creates a Rectangle from the bounds attribute with a live bounds getter', () => { it('creates a Rectangle from the bounds attribute with a live bounds getter', () => {
const el = document.createElement('leaflet-rectangle') as HTMLElement & { const el = document.createElement('leaflet-rectangle');
leafletObject?: Rectangle;
bounds: [[number, number], [number, number]];
};
el.setAttribute( el.setAttribute(
'bounds', 'bounds',
JSON.stringify([ JSON.stringify([
@ -94,9 +82,7 @@ describe('leaflet-rectangle', () => {
describe('leaflet-polygon', () => { describe('leaflet-polygon', () => {
it('collects vertices from <leaflet-line> children and updates on change', () => { it('collects vertices from <leaflet-line> children and updates on change', () => {
const el = document.createElement('leaflet-polygon') as HTMLElement & { const el = document.createElement('leaflet-polygon');
leafletObject?: Polygon;
};
const line1 = document.createElement('leaflet-line'); const line1 = document.createElement('leaflet-line');
line1.setAttribute('lat', '1'); line1.setAttribute('lat', '1');
line1.setAttribute('lng', '2'); line1.setAttribute('lng', '2');
@ -127,9 +113,7 @@ describe('leaflet-polygon', () => {
describe('leaflet-polyline', () => { describe('leaflet-polyline', () => {
it('applies smoothFactor/noClip and is unfilled by default', () => { it('applies smoothFactor/noClip and is unfilled by default', () => {
const el = document.createElement('leaflet-polyline') as HTMLElement & { const el = document.createElement('leaflet-polyline');
leafletObject?: Polyline;
};
el.setAttribute('smooth-factor', '2.5'); el.setAttribute('smooth-factor', '2.5');
el.setAttribute('no-clip', ''); el.setAttribute('no-clip', '');
const line = document.createElement('leaflet-line'); const line = document.createElement('leaflet-line');

@ -5,9 +5,7 @@ import '../../src/components/leaflet-tile-layer-wms.ts';
describe('leaflet-tile-layer', () => { describe('leaflet-tile-layer', () => {
it('creates a TileLayer from the url attribute with tileLayerProps options', () => { it('creates a TileLayer from the url attribute with tileLayerProps options', () => {
const el = document.createElement('leaflet-tile-layer') as HTMLElement & { const el = document.createElement('leaflet-tile-layer');
leafletObject?: TileLayer;
};
el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png'); el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
el.setAttribute('subdomains', 'abcd'); el.setAttribute('subdomains', 'abcd');
el.setAttribute('tms', ''); el.setAttribute('tms', '');
@ -23,10 +21,7 @@ describe('leaflet-tile-layer', () => {
}); });
it('defaults zIndex to 1, matching Leaflet', () => { it('defaults zIndex to 1, matching Leaflet', () => {
const el = document.createElement('leaflet-tile-layer') as HTMLElement & { const el = document.createElement('leaflet-tile-layer');
leafletObject?: TileLayer;
zIndex: number;
};
el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png'); el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
document.body.append(el); document.body.append(el);
expect(el.leafletObject?.options.zIndex).toBe(1); expect(el.leafletObject?.options.zIndex).toBe(1);
@ -36,9 +31,7 @@ describe('leaflet-tile-layer', () => {
describe('leaflet-tile-layer-wms', () => { describe('leaflet-tile-layer-wms', () => {
it('shares base tileLayerProps with leaflet-tile-layer (previously missing entirely)', () => { it('shares base tileLayerProps with leaflet-tile-layer (previously missing entirely)', () => {
const el = document.createElement('leaflet-tile-layer-wms') as HTMLElement & { const el = document.createElement('leaflet-tile-layer-wms');
leafletObject?: TileLayer.WMS;
};
el.setAttribute('url', 'https://wms.example/service'); el.setAttribute('url', 'https://wms.example/service');
el.setAttribute('layers', 'basic'); el.setAttribute('layers', 'basic');
el.setAttribute('attribution', 'Example'); el.setAttribute('attribution', 'Example');
@ -54,9 +47,7 @@ describe('leaflet-tile-layer-wms', () => {
it('resolves crs by name to the matching Leaflet CRS instance', async () => { it('resolves crs by name to the matching Leaflet CRS instance', async () => {
const { CRS } = await import('leaflet'); const { CRS } = await import('leaflet');
const el = document.createElement('leaflet-tile-layer-wms') as HTMLElement & { const el = document.createElement('leaflet-tile-layer-wms');
leafletObject?: TileLayer.WMS;
};
el.setAttribute('url', 'https://wms.example/service'); el.setAttribute('url', 'https://wms.example/service');
el.setAttribute('layers', 'basic'); el.setAttribute('layers', 'basic');
el.setAttribute('crs', 'EPSG4326'); el.setAttribute('crs', 'EPSG4326');

@ -0,0 +1,53 @@
// Compile-time-only checks for the per-component addEventListener/
// removeEventListener typing and the HTMLElementTagNameMap augmentation.
// expectTypeOf() assertions are checked by `tsc -p tsconfig.test.json`
// (part of `npm run typecheck`), not at runtime -- a mismatched
// `@ts-expect-error` (an expected error that doesn't actually occur) fails
// that typecheck with "Unused '@ts-expect-error' directive".
import type { DragEndEvent, Popup } from 'leaflet';
import { describe, expectTypeOf, it } from 'vitest';
import type { LeafletMap } from '../../src/components/leaflet-map.ts';
import type { LeafletMarker } from '../../src/components/leaflet-marker.ts';
import type { LeafletControlZoom } from '../../src/components/leaflet-control-zoom.ts';
describe('HTMLElementTagNameMap augmentation', () => {
it('createElement/querySelector infer the right component class', () => {
expectTypeOf(document.createElement('leaflet-map')).toEqualTypeOf<LeafletMap>();
expectTypeOf(document.createElement('leaflet-marker')).toEqualTypeOf<LeafletMarker>();
});
});
describe('per-component addEventListener typing', () => {
it('types a known leaflet: event with the right Leaflet event payload', () => {
const map = document.createElement('leaflet-map');
map.addEventListener('leaflet:popupopen', (e) => {
expectTypeOf(e.detail.popup).toEqualTypeOf<Popup>();
});
const marker = document.createElement('leaflet-marker');
marker.addEventListener('leaflet:dragend', (e) => {
expectTypeOf(e.detail.distance).toEqualTypeOf<DragEndEvent['distance']>();
});
});
it('still types ordinary DOM events normally', () => {
const marker = document.createElement('leaflet-marker');
marker.addEventListener('click', (e) => {
expectTypeOf(e).toEqualTypeOf<HTMLElementEventMap['click']>();
});
});
it('rejects a leaflet: event name that component does not fire', () => {
const marker = document.createElement('leaflet-marker');
// @ts-expect-error -- baselayerchange is Map-only, not one of MarkerEvents
marker.addEventListener('leaflet:baselayerchange', () => {});
});
it('components with no custom events keep the default HTMLElementEventMap typing', () => {
const zoom = document.createElement('leaflet-control-zoom');
expectTypeOf(zoom.addEventListener).toEqualTypeOf<LeafletControlZoom['addEventListener']>();
zoom.addEventListener('click', (e) => {
expectTypeOf(e).toEqualTypeOf<HTMLElementEventMap['click']>();
});
});
});

@ -333,7 +333,14 @@ describe('WithProps: generic leaflet: event forwarding', () => {
expect(onNode).toHaveBeenCalledOnce(); expect(onNode).toHaveBeenCalledOnce();
const event = onNode.mock.calls[0][0] as CustomEvent; const event = onNode.mock.calls[0][0] as CustomEvent;
expect(event.detail).toEqual({ foo: 1 }); // Mirrors Leaflet's own fire() merge: the original data plus
// type/target/sourceTarget, matching what a real .on() listener gets.
expect(event.detail).toEqual({
foo: 1,
type: 'zoomend',
target: node.leafletObject,
sourceTarget: node.leafletObject,
});
// Does not bubble. // Does not bubble.
expect(onWrapper).not.toHaveBeenCalled(); expect(onWrapper).not.toHaveBeenCalled();
wrapper.remove(); wrapper.remove();

@ -1,4 +1,3 @@
import { FeatureGroup, Map as LMap, Marker, Popup, TileLayer } from 'leaflet';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import '../src/components/leaflet-map.ts'; import '../src/components/leaflet-map.ts';
import '../src/components/leaflet-tile-layer.ts'; import '../src/components/leaflet-tile-layer.ts';
@ -12,31 +11,23 @@ import '../src/components/leaflet-popup.ts';
// instead of bubbling past it. // instead of bubbling past it.
describe('component tree wiring', () => { describe('component tree wiring', () => {
it('wires a map + tile-layer + feature-group + marker + popup tree together', () => { it('wires a map + tile-layer + feature-group + marker + popup tree together', () => {
const map = document.createElement('leaflet-map') as HTMLElement & { const map = document.createElement('leaflet-map');
leafletObject?: LMap;
};
map.setAttribute('lat', '51.5'); map.setAttribute('lat', '51.5');
map.setAttribute('lng', '-0.09'); map.setAttribute('lng', '-0.09');
map.setAttribute('zoom', '13'); map.setAttribute('zoom', '13');
const tiles = document.createElement('leaflet-tile-layer') as HTMLElement & { map.zoomSnap = 0.5;
leafletObject?: TileLayer;
}; const tiles = document.createElement('leaflet-tile-layer');
tiles.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png'); tiles.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
const group = document.createElement('leaflet-feature-group') as HTMLElement & { const group = document.createElement('leaflet-feature-group');
leafletObject?: FeatureGroup;
};
const marker = document.createElement('leaflet-marker') as HTMLElement & { const marker = document.createElement('leaflet-marker');
leafletObject?: Marker;
};
marker.setAttribute('lat', '51.51'); marker.setAttribute('lat', '51.51');
marker.setAttribute('lng', '-0.1'); marker.setAttribute('lng', '-0.1');
const popup = document.createElement('leaflet-popup') as HTMLElement & { const popup = document.createElement('leaflet-popup');
leafletObject?: Popup;
};
popup.innerHTML = 'hello'; popup.innerHTML = 'hello';
marker.append(popup); marker.append(popup);
@ -59,9 +50,7 @@ describe('component tree wiring', () => {
expect(marker.leafletObject?.getPopup()).toBe(popup.leafletObject); expect(marker.leafletObject?.getPopup()).toBe(popup.leafletObject);
// A marker added later goes through the same path. // A marker added later goes through the same path.
const marker2 = document.createElement('leaflet-marker') as HTMLElement & { const marker2 = document.createElement('leaflet-marker');
leafletObject?: Marker;
};
marker2.setAttribute('lat', '1'); marker2.setAttribute('lat', '1');
marker2.setAttribute('lng', '2'); marker2.setAttribute('lng', '2');
group.append(marker2); group.append(marker2);

Loading…
Cancel
Save