8.3 KiB
01 — Architecture
Element per Leaflet object
Every leaflet-* custom element maps 1:1 to a single Leaflet object — a
Map, a Marker, a TileLayer, a Popup, a Control, an Icon. The
element owns that object for its connected lifetime, holds the only reference
to it, and exposes it as element.leafletObject.
Nothing about the wrapping is per-object hand-written plumbing. An element
file is small: an explicitly-typed const PROPS table of property
descriptors, a const Base = WithProps(PROPS), then a class with a
createLeafletObject() that calls one Leaflet constructor and a couple of
declare lines for types. Everything else — attributes, option building,
two-way sync, event forwarding, tree membership — comes from the WithProps
mixin.
elements/ and components/
Each component is two files sharing a basename:
src/elements/leaflet-foo.tsexportsdefault class LeafletFooElement extends Base(whereconst Base = WithProps(PROPS)) — the class alone, no side effects. Import it (or thesrc/elements/index.tsbarrel, which re-exports every class by name) to get the constructor without registering a tag; useful for subclassing or defining it under a different name.src/components/leaflet-foo.tsis three lines: import the class,customElements.define('leaflet-foo', LeafletFooElement), re-export it. Importing this module (orsrc/index.ts/src/index.npm.ts, which import all of them in a load-bearing order — see 06) is what registers the tag.
package.json maps leaflet-web-components/elements,
.../elements/leaflet-foo.js and .../components/leaflet-foo.js onto the
matching dist/ files.
Two package entry points
src/index.ts is the shared entry (all classes + the load-bearing
./components/* side effects, no declare global) and is what jsr.json
publishes. src/index.npm.ts is the npm entry: it re-exports ./index.ts
and imports src/core/globals.ts, which carries the two ambient
declare global blocks. globals.ts is imported only by the npm entry (and
test/setup.ts) and is excluded from the JSR tarball, because JSR's "no slow
types" check rejects declare global anywhere in its module graph. See
07.
The WithProps mixin
src/core/with-props.ts. WithProps(PROPS, options?) is a mixin factory:
it always extends HTMLElement internally (there is no base-class parameter)
and returns a constructor typed LeafletElementConstructor<TObj, TProps>. An
element file does:
const PROPS: typeof latLngProps & {
title: PropDef<string, Marker>;
/* ...one line per prop... */
} = { ...latLngProps, title: str('', { set: /* ... */ }), /* ... */ };
const Base: LeafletElementConstructor<Marker, typeof PROPS> = WithProps(PROPS);
export default class LeafletMarkerElement extends Base {
declare readonly leafletObject?: Marker;
createLeafletObject(options: MarkerOptions): Marker {
return new Marker([this.lat, this.lng], options);
}
}
PROPS is a const record mapping property names to PropDef descriptors
(see 02). From that table alone the mixin
derives everything below.
Why const PROPS / const Base, both explicitly typed
class Foo extends WithProps({ ... }) is not allowed: JSR's fast-check
rejects a call expression as a superclass, and rejects any leaked type it
can't resolve without full type inference. So each file hoists the table to
const PROPS with an explicit type annotation, then
const Base: LeafletElementConstructor<TheLeafletClass, typeof PROPS> = WithProps(PROPS), then extends Base. The shared fragments in
shared-props.ts (pathProps, latLngProps, tileLayerProps, urlProp)
carry their own explicit types so a PROPS annotation can reference them via
typeof; Positional<T, Obj> (in props.ts) is the alias for a
positional() prop's type. as const with no annotation works only when
every table value is a bare identifier reference. This is a JSR constraint,
not a TypeScript one — see 07.
What the generated class does
-
static observedAttributes— the kebab-cased attribute name of every prop in the table (fillOpacity→fill-opacity; aPropDefcan override the derived name, e.g.disabled()producesdisable-*). -
Property accessors — one
Object.definePropertyper prop on the prototype. The getter reads the live Leaflet value when thePropDefdefines aget(falling back to the attribute, then the declared default); the setter encodes the value onto the attribute and letsattributeChangedCallbackpropagate it. -
On connect (
connectedCallback):#buildOptions()assembles a Leaflet options object from the currently-present attributes plus nothing else (absent attribute ⇒ absent key ⇒ Leaflet's own default applies), skipping any prop markedpositional(). ThencreateLeafletObject(options)runs. Then, unlessattach: 'none', the element registers with its parent (see 03). ThenleafletObjectCreated()— a no-op hook components can override. -
On attribute change (
attributeChangedCallback): dispatch to the prop's ownset(obj, value, el)if it has one; otherwise call the matching Leaflet setter by naming convention (opacity→obj.setOpacity) if the object has one; otherwise silently do nothing — many Leaflet options are constructor-only and this is expected. If the mixin was created withoptions.recreate(icons), an attribute change instead throws the object away and rebuilds it. -
Two-way sync:
#watchObject()subscribes one Leaflet listener per distinctevent:named in the table; when it fires, every prop naming that event has its live value read viagetand written back to the attribute. Dragging a marker firesmove, which writes bothlatandlng. A#syncingflag set during that write makes the resultingattributeChangedCallbacka no-op — this is the only place an update cycle could form, and it's the only guard needed. -
Event forwarding:
#forwardEvents()wraps the object's ownfire()method so every Leaflet event becomes a non-bubblingleaflet:<type>CustomEventon the element. Generic and automatic — no per-component or per-event registration. See 04. -
On disconnect: unbind children, remove the object from its parent, call
obj.off()for the sync listeners andobj.remove().
Why a factory and not a base class
The prop table drives code generation (observedAttributes, the accessor
descriptors) that has to exist on the class before any instance. A factory
that closes over the resolved table and defines accessors on
Class.prototype is the natural shape for that. An empty table
(const PROPS: Record<never, never> = {}) is still a useful base:
leaflet-layer-group and leaflet-feature-group use it to get lifecycle,
child registration and event forwarding with no options of their own.
Duck typing, deliberately
Layer, Control and Icon share no common Leaflet interface, and which
setters exist varies by class. The mixin's method(obj, name) helper looks a
method up by string and binds it, or returns undefined. This is why the
attribute-change path can "try the setter, else no-op" without knowing
anything about the concrete class.
leaflet-map is special
src/elements/leaflet-map.ts. LeafletMapElement still extends a
Base = WithProps(PROPS, { attach: 'none' }) like every other element, but
additionally:
- builds its own Shadow DOM in
connectedCallback(a<div>container for Leaflet, a<style>for:host, and a<link>to Leaflet's CSS), - is the root of the component tree: it listens for the bubbling
leaflet-registerevent and terminates it withlayer.addTo(this.map)(plusleaflet-add-layer/leaflet-remove-layer→map.addLayer/removeLayer, which onlyleaflet-control-layersdispatches), - runs a
ResizeObserveron the host to callmap.invalidateSize(), - treats its
css-*attributes as describing the shadow-root stylesheet, not the map — changing one just re-links the<link>and re-derivesIcon.Default.imagePathfrom the same URL.
See 05 for the CSS/escape-hatch details.