# 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](./06-load-order.md)) 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. The per-file subpaths are npm-only — `jsr.json` exposes just `.` and `./elements` (JSR has no subpath patterns). ### 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](./07-tooling-and-build.md). ## 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`. An element file does: ```ts const PROPS: typeof latLngProps & { title: PropDef; /* ...one line per prop... */ } = { ...latLngProps, title: str('', { set: /* ... */ }), /* ... */ }; const Base: LeafletElementConstructor = 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](./02-props-and-attributes.md)). 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 = 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` (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](./07-tooling-and-build.md). ### What the generated class does - **`static observedAttributes`** — the kebab-cased attribute name of every prop in the table (`fillOpacity` → `fill-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](./03-component-tree.md)). 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 (`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 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:` `CustomEvent` on the element. Generic and automatic — no per-component or per-event registration. See [04](./04-events.md). - **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 = {}`) 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 `
` container for Leaflet, a `