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
parent
9b6c7ca598
commit
0419ab781d
@ -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;
|
||||||
|
}
|
||||||
@ -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']>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue