diff --git a/CLAUDE.md b/CLAUDE.md index 6279db9..4054471 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Longer-form design docs live in [`docs/`](./docs/README.md) — one file per major +decision (the `WithProps` mixin, the prop model, the registration protocol, +event forwarding, per-component special cases, load order, tooling). This file +stays a terse working cheat-sheet; `docs/` is the "why". + ## Commands ```bash diff --git a/README.md b/README.md index e3bbc82..814ee74 100644 --- a/README.md +++ b/README.md @@ -714,3 +714,5 @@ npm run test # vitest run npm run test:watch # vitest, watch mode # serve the project root with any static-file server and open index.html ``` + +Design docs — how the library is built and why — are in [`docs/`](./docs/README.md). diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 0000000..2dbfb63 --- /dev/null +++ b/docs/01-architecture.md @@ -0,0 +1,114 @@ +# 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. A component +file is small (often 30–60 lines): a table of property descriptors, a +`createLeafletObject()` that calls one Leaflet constructor, a couple of +`declare` lines for types, and `customElements.define()`. Everything else — +attributes, option building, two-way sync, event forwarding, tree membership +— comes from the `WithProps` mixin. + +## 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. A component does: + +```ts +export class LeafletMarker 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](./02-props-and-attributes.md)). 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`; 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. `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/components/leaflet-map.ts`. It still extends `WithProps(PROPS)` with +`attach: 'none'`, but additionally: + +- builds its own **Shadow DOM** in `connectedCallback` (a `
` container + for Leaflet, a `