You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/docs/01-architecture.md

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.ts exports default class LeafletFooElement extends Base (where const Base = WithProps(PROPS)) — the class alone, no side effects. Import it (or the src/elements/index.ts barrel, 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.ts is three lines: import the class, customElements.define('leaflet-foo', LeafletFooElement), re-export it. Importing this module (or src/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 (fillOpacityfill-opacity; a PropDef can override the derived name, e.g. disabled() produces disable-*).

  • Property accessors — one Object.defineProperty per prop on the prototype. The getter reads the live Leaflet value when the PropDef defines a get (falling back to the attribute, then the declared default); the setter encodes the value onto the attribute and lets attributeChangedCallback propagate 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 marked positional(). Then createLeafletObject(options) runs. Then, unless attach: 'none', the element registers with its parent (see 03). Then leafletObjectCreated() — a no-op hook components can override.

  • On attribute change (attributeChangedCallback): dispatch to the prop's own set(obj, value, el) if it has one; otherwise call the matching Leaflet setter by naming convention (opacityobj.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 with options.recreate (icons), an attribute change instead throws the object away and rebuilds it.

  • Two-way sync: #watchObject() subscribes one Leaflet listener per distinct event: named in the table; when it fires, every prop naming that event has its live value read via get and written back to the attribute. Dragging a marker fires move, which writes both lat and lng. A #syncing flag set during that write makes the resulting attributeChangedCallback a 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 own fire() method so every Leaflet event becomes a non-bubbling leaflet:<type> CustomEvent on 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 and obj.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-register event and terminates it with layer.addTo(this.map) (plus leaflet-add-layer / leaflet-remove-layermap.addLayer / removeLayer, which only leaflet-control-layers dispatches),
  • runs a ResizeObserver on the host to call map.invalidateSize(),
  • treats its css-* attributes as describing the shadow-root stylesheet, not the map — changing one just re-links the <link> and re-derives Icon.Default.imagePath from the same URL.

See 05 for the CSS/escape-hatch details.