6.4 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
class is small (often 30–60 lines): a table of property descriptors, 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 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, which imports all of them in a load-bearing order — see 06) is what registers the tag.
package.json maps leaflet-components/elements,
leaflet-components/elements/leaflet-foo.js and
leaflet-components/components/leaflet-foo.js onto the matching dist/
files.
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. An element class does:
export default class LeafletMarkerElement extends WithProps(PROPS) {
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.
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. WithProps({}) — an empty
table — 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
WithProps(PROPS) with attach: 'none', 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.