docs: add design docs breaking down architecture and major decisions

Eight docs under docs/ covering the current design: the WithProps mixin
and lifecycle, the PropDef/attribute model, the leaflet-register bubbling
protocol, generic event forwarding and its typing, per-component special
cases, the load-bearing export order in src/index.ts, and the toolchain
(TS 7, oxlint, oxfmt, Vitest, the no-bundle build, dual npm/JSR publish).

Also wire docs/**/*.md into the format script and point at docs/ from
CLAUDE.md and README.md.
main
Buddy 3 weeks ago
parent 449767335b
commit 2c77784345

@ -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

@ -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).

@ -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 3060 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:<type>`
`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 `<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-layer``map.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](./05-special-cases.md) for the CSS/escape-hatch details.

@ -0,0 +1,82 @@
# 02 — Props & attributes
`src/core/props.ts` defines the model; `src/core/shared-props.ts` bundles the
fragments components reuse. `WithProps` (`src/core/with-props.ts`) is the only
consumer — components declare a table and never touch the plumbing.
## The `PropDef`
Every element property is one `PropDef`:
| Field | Meaning |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `default` | Value when the attribute is absent. **Must equal Leaflet's own default** — an absent attribute is left out of the options object entirely, so it's Leaflet's default that actually takes effect. `default` only feeds the property getter's fallback. |
| `decode(raw)` | attribute string → value |
| `encode(value)` | value → attribute string, or `null` to _remove_ the attribute (which restores Leaflet's default) |
| `attribute?` | Override the derived kebab-case name. A function form receives the property name (used by `disabled()` to prefix `disable-`). |
| `option?: false` | Set only through `positional()`. Marks a value the Leaflet **constructor takes as an argument** (coordinates, url, bounds, GeoJSON data), so it's excluded from the options object and from `PropOptionValues`. |
| `set?(obj, value, el)` | Push a new value into the live object. Omitted ⇒ the mixin calls the naming-convention setter (`radius` → `obj.setRadius`) if it exists, else no-op. |
| `get?(obj)` | Read the live value back out. Used by the property getter and by two-way sync. |
| `event?` | Leaflet event after which `get` is re-read and written to the attribute (`move` keeps `lat`/`lng` current during a drag). Only meaningful together with `get`. |
## Codec factories
Instead of writing `decode`/`encode` by hand, components call a factory:
| Factory | Attribute semantics |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `num(default?, opts?)` | `Number``String` |
| `str(default?, opts?)` | identity both ways |
| `choice<T>(default, opts?)` | like `str`, but typed as a string union (`ControlPosition`, `CrossOrigin`, tooltip `Direction`); **no runtime validation** — the point is that the options object comes out with the type Leaflet's constructor expects |
| `bool(default?, opts?)` | present ⇒ `true`, `="false"``false`, absent ⇒ `default`. `encode` returns `null` when the value equals the default, so the attribute only appears when it's doing something. Use `bool(true)` for options Leaflet defaults on, so `<leaflet-popup auto-pan="false">` can turn them off. |
| `disabled(opts?)` | the inverse of `bool(true)`: attribute named `disable-<kebab>`, `<leaflet-map disable-dragging>` reads as `dragging === false` |
| `json<T>(default, opts?)` | `JSON.parse``JSON.stringify` — bounds, icon sizes/anchors, GeoJSON data |
`positional(def)` wraps any of the above to set `option: false`.
## Shared fragments
`src/core/shared-props.ts` — spread these into a `PROPS` table:
- **`latLngProps`** (`lat`, `lng`) — the coordinate pair, shared by marker,
circle, circle-marker, popup, tooltip. Both are `positional` (passed to the
constructor). Because they travel together, each one's `set` re-issues
`obj.setLatLng([...])` using the _other_ axis read off the host element
(`el.lat` / `el.lng`). Both name `event: 'move'` for write-back, which is
what keeps the attributes live while a marker is dragged.
- **`pathProps`** — every SVG style option a Leaflet `Path` accepts. The
mutable ones (`color`, `weight`, `opacity`, `fill*`, …) use a `style(key)`
helper whose `set` calls `obj.setStyle({ [key]: value })`, because Leaflet
exposes these only through `setStyle`. The constructor-only tail
(`className`, `interactive`, `pane`, …) has no `set` and so no post-create
effect.
- **`tileLayerProps`** — the `GridLayer`/`TileLayer` options common to
`leaflet-tile-layer` and `leaflet-tile-layer-wms` (WMS options extend tile
layer options). Almost all constructor-only. `referrerPolicy` is a
hand-written `PropDef` rather than `choice()` because Leaflet's
`ReferrerPolicy` type has no "unset" member — its absent-attribute fallback
is `undefined`.
- **`urlProp`** — the positional source URL for `TileLayer` / `ImageOverlay`
/ `VideoOverlay`. Its `set` ignores a blank value so clearing the attribute
can't request an empty tile URL. No `get` — none of those classes expose
`getUrl()`.
- **`getBounds(obj)`** — a shared `get` returning `[[s,w],[n,e]]` (matching
the JSON attribute shape) rather than a `LatLngBounds` instance.
## Two-way sync and cycle prevention
1. User sets a property → setter encodes it onto the attribute.
2. `attributeChangedCallback` fires → prop's `set` (or the convention setter)
pushes it into the live object.
3. Leaflet mutates and fires an event (e.g. `move`).
4. The mixin's per-event listener reads `get(obj)` for every prop naming that
event and writes it back to the attribute — but does so with the private
`#syncing` flag set, so the `attributeChangedCallback` this write triggers
returns immediately instead of looping back to step 2.
`#syncing` is the single guard. `attributeChangedCallback` also bails when
`oldValue === newValue`, and when the element isn't connected yet.

@ -0,0 +1,94 @@
# 03 — Component tree & registration
Components never read each other's properties and never query the DOM for
their relatives. They join the tree by **dispatching DOM events that bubble**,
and a parent claims a child by handling the event. This replaces the
imperative parent/child wiring Leaflet normally needs.
## The `leaflet-register` protocol
`src/core/register.ts`. On connect, unless `attach: 'none'`, a component
calls:
```ts
registerWithParent(el, leafletObject);
// dispatches CustomEvent('leaflet-register', {
// detail: { leafletObject, element: el }, bubbles: true, composed: true })
```
The event bubbles up the DOM. The nearest ancestor component that is in
`attach: 'children'` mode has a `leaflet-register` listener; its
`#onChildRegister` handler (`with-props.ts`) inspects
`detail.leafletObject` by `instanceof` and:
- `Popup``obj.bindPopup(child)`, `stopPropagation()`
- `Tooltip``obj.bindTooltip(child)`, `stopPropagation()`
- `Layer` **and** the parent has `addLayer``obj.addLayer(child)`,
`stopPropagation()`
- otherwise: let it keep bubbling
Discrimination is by `instanceof`, not duck typing — which is why the
child-registration tests need real `Popup`/`Tooltip`/`Layer` instances.
If nothing claims it, the event reaches `<leaflet-map>`, whose `#onRegister`
calls `e.detail.leafletObject.addTo(this.map)` and stops it. The map is the
terminus; nothing can register before it exists, which is why it's exported
first (see [06](./06-load-order.md)).
`composed: true` lets the event cross the map's shadow boundary.
### Un-registration
When a child disconnects, the mixin's `#destroyObject()` calls
`obj.remove()`, which detaches it from whatever it was added to. When a
_parent_ disconnects, `#releaseChildren()` walks the entries it adopted and
calls `unbindPopup` / `unbindTooltip` / `removeLayer` for each.
`recreateLeafletObject()` re-runs `registerWithParent` — that call otherwise
happens only once, from `connectedCallback` — so a recreated layer (e.g. a
WMS layer that picked up a child CRS) still gets re-added to its parent.
## `attach` modes
Passed as `WithProps(PROPS, { attach })`. Default `'children'`.
| Mode | Registers with parent | Adopts registering descendants | Used by |
| ------------ | --------------------- | ------------------------------ | -------------------------------------------------- |
| `'children'` | yes | yes (layers, popups, tooltips) | every layer type, groups |
| `'self'` | yes | no | `leaflet-popup`, `leaflet-tooltip`, controls |
| `'none'` | no | no | `leaflet-map` (the root), icons (not tree members) |
## The other announcement events
The `leaflet-register` protocol only carries `Layer` subclasses. Three other
relationships use the same bubble-and-claim shape with their own event types,
so a **plain custom element** (no base class) can participate just by firing
the right event:
| Event | Fired by | Claimed by | Purpose |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------- |
| `icon-changed` (`{ icon: Icon \| null }`) | `leaflet-icon`, `leaflet-div-icon`, on connect / disconnect | `leaflet-marker` | `marker.setIcon(icon ?? new Icon.Default())` |
| `leaflet-line-sync` (`{ element, latlng }`) | `leaflet-line`, on connect and every `lat`/`lng` change | `leaflet-polygon`, `leaflet-polyline` | feed the `VertexTracker` |
| `leaflet-line-remove` (`{ element }`) | `leaflet-line`, on disconnect (from its _cached_ parent — see [05](./05-special-cases.md)) | same | drop the vertex |
| `leaflet-crs-changed` (`{ crs: CRS \| null }`) | any element nested in a CRS-accepting component, on connect | `leaflet-tile-layer-wms` | supply a `CRS` Leaflet doesn't ship by name; `null` reverts to the component's default |
`null` in a payload consistently means "revert to the host component's own
default", mirroring `icon-changed`.
One more internal event rides the same bubble: `leaflet-add-layer` /
`leaflet-remove-layer` (`{ layer }`), dispatched only by
`leaflet-control-layers` for an `active` entry and handled only by
`leaflet-map` (`map.addLayer` / `removeLayer`). See [05](./05-special-cases.md).
## Why events, not lookups
- No load-order coupling between a parent and the DOM query it would
otherwise run (a child may upgrade before or after its parent).
- A child at any nesting depth works without the parent knowing the shape of
the subtree.
- Third-party elements can nest into the tree without importing anything —
just dispatch the documented event.
The one cost is the load-order constraint in [06](./06-load-order.md): a
one-shot connect-time announcement is lost if the listening parent isn't
defined yet.

@ -0,0 +1,87 @@
# 04 — Events
## Forwarding: every Leaflet event → `leaflet:<type>`
`#forwardEvents()` in `src/core/with-props.ts`. Leaflet has no wildcard
listener, so the mixin wraps the instance's own `fire()` method (safe — it's
an object we created and hold alone):
```ts
target.fire = (type, data, propagate) => {
const result = originalFire.call(obj, type, data, propagate);
const sourceTarget = data?.sourceTarget ?? obj;
const detail = { ...data, type, target: obj, sourceTarget };
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail }));
return result;
};
```
Properties of this:
- **Generic** — no per-component or per-event-type registration. Any event
Leaflet fires on the object is re-emitted.
- **`detail` mirrors Leaflet's own `Evented#fire` merge** — original event
data plus `type` / `target` / `sourceTarget` — so a `leaflet:click`
listener's `event.detail` is exactly what a real `map.on('click', …)`
handler would receive. This also keeps Leaflet's typed event interfaces
(`LeafletMouseEvent`, etc.) honest as the `detail` type.
- **Dispatched _after_ Leaflet's own handlers** run, so any attributes the
two-way sync updated in response to the same event are already current when
the DOM event fires.
- **Not bubbling.** Leaflet already propagates layer events up to the map
internally, so a bubbling DOM event would make `<leaflet-map>` see each one
twice.
## Typing: `event-types.ts`
`src/core/event-types.ts` holds small reusable **event-name → payload-type**
fragments, mirroring how `shared-props.ts` shares prop fragments:
```
MouseEvents click/dblclick/mousedown/… → LeafletMouseEvent
MoveEvents movestart/move/moveend → LeafletEvent
DragEvents dragstart/drag → LeafletEvent, dragend → DragEndEvent
PopupBindEvents popupopen/popupclose → PopupEvent
TileEvents loading/load/tileload/tileerror/…
LayerGroupEvents layeradd/layerremove → LayerEvent
```
composed into one type per family:
| Family type | = | Applied by |
| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------ |
| `BaseLayerEvents` | `LayerAddRemoveEvents & PopupBindEvents & TooltipBindEvents` | (building block) |
| `PathEvents` | `BaseLayerEvents & MouseEvents` | circle, polygon, polyline, rectangle |
| `MarkerEvents` | `BaseLayerEvents & MouseEvents & MoveEvents & DragEvents` | marker |
| `TileLayerEvents` | `BaseLayerEvents & TileEvents` | tile-layer, tile-layer-wms |
| `DivOverlayLayerEvents` | `LayerAddRemoveEvents & MouseEvents & DivOverlayEvents` | popup, tooltip |
| `GroupEvents` | `LayerAddRemoveEvents & LayerGroupEvents` | layer-group, feature-group, geojson |
| `MapEvents` | move + mouse + popup/tooltip + zoom + resize + location + keyboard + … | map |
A component narrows its listener types with a `declare` field — the same
zero-runtime idiom as `declare readonly leafletObject?: Marker`:
```ts
class LeafletCircle extends WithProps(PROPS) {
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
}
```
Components that fire nothing meaningful (controls, icons, `leaflet-line`)
skip this and keep the default `HTMLElementEventMap` typing.
### Why no string fallback overload
`LeafletAddEventListener` / `LeafletRemoveEventListener` (defined in
`with-props.ts`) deliberately have **no** generic `(type: string, …)`
overload, unlike the real DOM API. A fallback would silently accept any
misspelled `leaflet:*` name, which defeats the purpose of typing this. The
cost: a genuinely dynamic (non-literal) event-name string needs a cast.
### Keep `detail` honest
If you change what a component fires, keep `#forwardEvents`'s merge shape in
mind — don't type an event's `detail` against a Leaflet interface it wouldn't
actually match at runtime.

@ -0,0 +1,125 @@
# 05 — Per-component special cases
Where a component does something the `WithProps` mixin can't express from a
prop table alone.
## `leaflet-map` — shadow DOM, CSS, resize
`src/components/leaflet-map.ts`.
- **Shadow root** built in `connectedCallback` (not via
`createLeafletObject`): a `<div>` at `100% × 100%` for Leaflet to render
into, a `<style>` that gives `:host` a `block` display and a default
`400px` height, and a `<link>` to Leaflet's stylesheet.
- **`css-url` / `css-integrity` / `css-crossorigin`** are _not_ Leaflet
options — they describe that `<link>`. Their `set` just calls `applyCss()`,
which re-links the stylesheet and re-derives `Icon.Default.imagePath` from
the same URL directory. `applyCss()` reads the _attributes_ directly
(not the properties) because absent and empty mean different things here.
Defaults point at `unpkg.com/leaflet@1.9.4` with a matching SRI hash.
- **`lat` / `lng` / `zoom`** are `positional` but not constructor args in the
usual sense — the map is positioned by `setView()` right after
construction, and they're written back on `moveend` / `zoomend`.
- **`ResizeObserver`** on the host calls `map.invalidateSize()`. jsdom lacks
`ResizeObserver`, so the test setup stubs it (see
[07](./07-tooling-and-build.md)).
- As tree root it also listens for `leaflet-add-layer` / `leaflet-remove-layer`
(used by group internals) alongside `leaflet-register`.
## `leaflet-polygon` / `leaflet-polyline` — vertices from children
`src/components/leaflet-polygon.ts`, `leaflet-polyline.ts`,
`src/core/vertex-tracker.ts`.
Vertices come from `<leaflet-line>` children, **event-driven, never a
lookup**:
- `<leaflet-line>` is a plain `HTMLElement` (no mixin). On connect and on
every `lat`/`lng` change it fires `leaflet-line-sync` on itself, carrying
its own `[lat, lng]`. On disconnect it fires `leaflet-line-remove`.
- The polygon/polyline feeds those events into a shared `VertexTracker`,
which keeps an ordered coordinate list. A newly-registered vertex is
inserted at its **actual document position** via `compareDocumentPosition`
— registration order is not assumed to match DOM order.
- After every sync/remove the component calls
`obj.setLatLngs(tracker.coords())`.
**The `leaflet-line-remove` wrinkle:** `disconnectedCallback` fires _after_
the node is already detached from its parent, so a bubbling dispatch from the
node has nowhere to go. `<leaflet-line>` caches `parentNode` in
`connectedCallback` and dispatches the remove event from that cached
reference instead (`emitLineRemove(from, el)` in `register.ts`).
## `leaflet-popup` / `leaflet-tooltip` — content is markup
`src/components/leaflet-popup.ts`, `leaflet-tooltip.ts`.
- Content is `this.innerHTML`, passed as the `content` option, not an
attribute.
- Both run a `MutationObserver` (`childList` + `characterData` + `subtree`)
and call `setContent(this.innerHTML)` on any change.
- A popup/tooltip only carries a position of its own when it's _not_ bound to
a parent layer — `createLeafletObject` sets `setLatLng` only if both `lat`
and `lng` attributes are present.
- `attach: 'self'` — they register with a parent but adopt no children.
## `leaflet-layer-group` / `leaflet-feature-group` — passthrough
`WithProps({})` — an empty prop table (`new LayerGroup([])` /
`new FeatureGroup([])`). No options of their own, but they still get the full
lifecycle, child registration (`attach: 'children'`), and `leaflet:` event
forwarding. A descendant layer's `leaflet-register` is claimed by the mixin's
own `#onChildRegister` (`Layer` + parent has `addLayer``obj.addLayer`),
stopping there instead of bubbling on to the map. The group itself then
registers with _its_ parent, so a group can nest in a group.
## `leaflet-control-layers` — dual role
`src/components/leaflet-control-layers.ts`. `attach: 'self'` (it's a control —
registers with the map, no children through the standard path), but it _also_
attaches its own `leaflet-register` listener to intercept its child layer
entries (`<… name="OSM" type="base" active>`): `addBaseLayer` when
`type="base"`, else `addOverlay`, keyed by the child's `name` attribute. A
child marked `active` also gets a `leaflet-add-layer` event dispatched
upward, which `<leaflet-map>` handles with `map.addLayer(layer)` so that
layer is shown initially. (The map listens for `leaflet-add-layer` /
`leaflet-remove-layer` for exactly this; `leaflet-control-layers` is their
only dispatcher.)
## `leaflet-geojson` — style nesting
`src/components/leaflet-geojson.ts`. Extends `pathProps`, but GeoJSON takes
style options nested under a `style` key so they apply to each generated
feature: `new GeoJSON(data, { style: options })`. `data` is `positional` +
`json`; its `set` does `clearLayers()` then `addData(value)`.
## `leaflet-tile-layer-wms` — extensible CRS
`src/components/leaflet-tile-layer-wms.ts`.
- The `crs` **attribute** only covers the four CRSes Leaflet ships by name
(`EPSG3857`, `EPSG4326`, `EPSG3395`, `Simple`) via a `NAMED_CRS` lookup.
- A CRS Leaflet doesn't ship comes from a **nested child element** that fires
`leaflet-crs-changed` with a `CRS` instance — no base class, no registry,
just the event shape (see [03](./03-component-tree.md)). It takes priority
over the attribute. `crs: null` reverts to the attribute's named lookup.
- `crs` is constructor-only, so picking up a child CRS means
`recreateLeafletObject()` — a full rebuild of the layer.
- WMS request params (`layers`, `styles`, `format`, `transparent`,
`version`) have no individual setters; each prop's `set` merges through
`obj.setParams({ [key]: value })`.
## `leaflet-icon` / `leaflet-div-icon` — recreate on change
Icons are created with `WithProps(PROPS, { recreate: true })`: Leaflet gives
no way to mutate an `Icon` in place, so any attribute change throws the icon
away and rebuilds it. They're `attach: 'none'` — not tree members — and
announce themselves to a parent `leaflet-marker` via `icon-changed`.
## `leaflet-marker` — live `<img>` attributes
`title` and `alt` end up on the `<img>` Leaflet renders, so their `set`
reaches `obj.getElement()` and assigns the DOM property directly.
`draggable`'s `set` toggles `obj.dragging.enable()/disable()`. Note that
`getElement()` only exists after the marker is added to a real map — tests
touching it append through a `<leaflet-map>`.

@ -0,0 +1,71 @@
# 06 — Load order
## The rule
**Export order in `src/index.ts` is load-bearing.** A component that listens
for a bubbling announcement from another tag must be exported — and therefore
`customElements.define()`d — **before** that other tag.
## Why
`customElements.define(name, Class)` upgrades every matching element already
in the document **immediately and synchronously**, running
`connectedCallback` right then for elements that are already connected.
On a real static-HTML page the markup is fully parsed before the deferred
module script runs, so at `define()` time the elements already exist. The
first `define()` for a given tag wins the race:
- If a **child** tag is defined before its listening **parent** tag, every
instance of that child in the page upgrades and fires its one-shot
connect-time announcement (`leaflet-register`, `icon-changed`,
`leaflet-line-sync`, `leaflet-crs-changed`) into a parent element that has
no listener attached yet — because the parent class isn't defined, so its
`connectedCallback` hasn't run.
- That announcement is **gone for good**: `connectedCallback` does not fire
again for an element that stays connected.
So parents must be defined first.
## The ordering in `src/index.ts`
Root to leaf (this is the comment at the top of the file, kept in sync):
```
LeafletMap ← nothing can register before the root exists
LeafletControlLayers, LeafletLayerGroup,
LeafletFeatureGroup ← listen for leaflet-register from arbitrary layers
every concrete layer type (Marker, Circle,
Polygon, TileLayer, …)
LeafletLine ← child of Polygon/Polyline
LeafletPopup, LeafletTooltip ← child of any layer
LeafletIcon, LeafletDivIcon ← child of Marker
```
Standalone controls (`LeafletControlZoom`, `LeafletControlAttribution`,
`LeafletControlScale`) have no such relationship and can go anywhere.
The core re-exports (`register.ts`, `props.ts`, `with-props.ts`,
`shared-props.ts`, `event-types.ts`) come first and carry no
`customElements.define`, so their order is free.
## The `HTMLElementTagNameMap` augmentation
At the bottom of `src/index.ts`. Because `export { X } from '…'` re-exports
`X` without binding it locally, the tag map needs its **own** `import type` of
every component class alongside the re-export. Those imports are type-only,
erased at build, back no `define()` — their order doesn't matter.
## The test that guards this
`test/load-order.test.ts` is structurally unlike every other test file **on
purpose**: it never statically imports a component module, so nothing is
`customElements.define`d until it dynamically `import()`s `src/index.ts`
partway through — _after_ building a DOM tree out of plain, undefined
elements. That's the only test reproducing a real page's actual order (markup
first, module script second).
Every other test file imports components up front, so components are already
defined before any element is created — they **structurally cannot** catch an
ordering regression. If you add a component with a "parent listens for a
child's announcement" relationship, extend `load-order.test.ts`.

@ -0,0 +1,117 @@
# 07 — Tooling & build
## Scripts
| `npm run …` | Does |
| --------------------- | -------------------------------------------------------- |
| `build` | `rm -rf dist && tsc --outDir dist` |
| `typecheck` | `tsc --noEmit`, then `tsc -p tsconfig.test.json` |
| `lint` | `oxlint src test` |
| `format` | `oxfmt '*.md' 'src/**/*.{ts,js,json,md}' 'test/**/*.ts'` |
| `test` / `test:watch` | `vitest run` / `vitest` |
## TypeScript 7
`package.json` pins `typescript@^7.0.2`. This choice constrains the lint
setup below. `tsconfig.json` highlights:
- `module: ESNext`, `moduleResolution: bundler`, `target: ESNext`
- `allowImportingTsExtensions` + `rewriteRelativeImportExtensions` — source
imports each other with explicit `.ts` extensions, and `tsc` rewrites them
to `.js` in the emitted output
- `declaration` + `declarationMap` — every module ships `.d.ts` + `.d.ts.map`
- `strict`, `isolatedModules`, `skipLibCheck`
- `include: ["src/**/*"]` only — `test/**` never reaches `dist/`. The
root-level `oxfmt.config.ts` is outside this glob too, so it's never
compiled.
## Linting: oxlint (not ESLint)
`oxlint.config.ts``oxlint@^1`, configured with `defineConfig`:
`plugins: ['typescript', 'unicorn', 'oxc']`, `categories` correctness→error /
suspicious+pedantic→warn, `max-lines*` and (in `test/`)
`max-classes-per-file` turned off.
**Why not `@typescript-eslint`:** it has no released version supporting
TypeScript 7 — its peer range caps at `<6.1.0`, and even loading
`@typescript-eslint/parser` crashes against TS 7's package shape. `oxlint`
has its own parser and never touches the `typescript` package, so it works
regardless of TS version.
**The tradeoff:** no type-aware rules — no `no-floating-promises`, no
`no-unnecessary-condition`, etc. Worth revisiting once `@typescript-eslint`
supports TS 7.
## Formatting: oxfmt
`oxfmt.config.ts``oxfmt` (the oxc project's formatter), configured with its
`defineConfig` default export:
```ts
export default defineConfig({
semi: true,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
tabWidth: 2,
});
```
- Same toolchain family as `oxlint`; single native binary, no plugin
ecosystem to pin against TS 7.
- Prettier-compatible options; the values above are Prettier's popular
defaults, so running `oxfmt` over the existing tree is a near no-op.
- oxfmt auto-discovers `oxfmt.config.ts` (search order: `.oxfmtrc.json`
`.oxfmtrc.jsonc``oxfmt.config.ts``oxfmt.config.mts`); no `--config`
flag needed. It reads `.gitignore` automatically.
- One behavioural difference from Prettier: oxfmt formats fenced code blocks
_inside_ Markdown.
- Pre-1.0 — expect its output to shift between releases.
## Testing: Vitest + jsdom
`vitest.config.ts``environment: 'jsdom'`, `include: test/**/*.test.ts`,
`setupFiles: ['./test/setup.ts']`.
- **`test/setup.ts`** stubs `ResizeObserver` — jsdom doesn't implement it and
`leaflet-map.ts` constructs one unconditionally, so without the stub even
_importing_ the component throws. Nothing here depends on it firing.
- **Real Leaflet objects work under jsdom** for everything this library
verifies: option/attribute wiring, event forwarding, child binding. No
browser needed.
- **`onAdd()`-only state:** the `<img>`/`<video>` behind an overlay, a
marker's `dragging` handler, `marker.getElement()` — these exist only once
the layer is added to a real map. Tests touching them append through a
`<leaflet-map>` rather than standalone.
- **`test/core/with-props.test.ts`** tests the mixin against a fake
Leaflet-like class; the exception is child registration, which needs real
`Popup`/`Tooltip`/`Layer` because `#onChildRegister` branches on
`instanceof`.
- **`test/load-order.test.ts`** — see [06](./06-load-order.md); it's the only
test reproducing real page load order.
- `test/**` is typechecked separately via `tsconfig.test.json` (`extends` the
main config, `rootDir: '.'`, `noEmit`, adds `test/**/*` to `include`).
## Build output
`tsc` compiles `src/``dist/` as **individual ESM modules**`.js` +
`.d.ts` + `.d.ts.map` per source file, no bundling step.
- Consumers import `dist/index.js` (or any single module) directly.
- **No CJS, no UMD** build.
- **Leaflet is always external** — never bundled. It's a `dependencies` entry
and a bare `import` in the output.
- `package.json` `exports`: `.``dist/index.js` (+ types), plus
`./dist/*` for deep imports.
- `sideEffects: true` — the component modules call `customElements.define()`
at import time, so a bundler must not tree-shake them away.
## Publishing to two registries
| Registry | Entry | Ships |
| -------- | ------------------------------------------------- | ----------------------------------------------------------------------------- |
| npm | `package.json` `main`/`module`/`types` → `dist/…` | compiled `dist/` + `README.md` (`files` field); `prepublishOnly` runs `build` |
| JSR | `jsr.json` `exports``./src/index.ts` | TypeScript source directly (JSR compiles per-consumer) |
Both are version `0.1.0`; keep them in step. JSR publishes `src/` with its
`.ts` import extensions intact, which JSR supports natively.

@ -0,0 +1,29 @@
# Design docs
These describe how `leaflet-components` is built and why — the load-bearing
decisions, in their current form. They are not a usage guide (that's the
top-level [`README.md`](../README.md)) and not a working cheat-sheet (that's
[`CLAUDE.md`](../CLAUDE.md)).
| Doc | Covers |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| [01 — Architecture](./01-architecture.md) | Element-per-Leaflet-object, the `WithProps` mixin, the lifecycle it owns |
| [02 — Props & attributes](./02-props-and-attributes.md) | The `PropDef` model, the codec factories, two-way attribute↔object sync |
| [03 — Component tree & registration](./03-component-tree.md) | The `leaflet-register` bubbling protocol, `attach` modes, the other announcement events |
| [04 — Events](./04-events.md) | Re-emitting Leaflet events as `leaflet:<type>`, and how they're typed |
| [05 — Per-component special cases](./05-special-cases.md) | Where a component does something the mixin can't express generically |
| [06 — Load order](./06-load-order.md) | Why the export order in `src/index.ts` is load-bearing |
| [07 — Tooling & build](./07-tooling-and-build.md) | TypeScript 7, oxlint, oxfmt, Vitest, the no-bundle build, dual publish |
## The one-paragraph version
Each `leaflet-*` custom element wraps exactly one Leaflet object. A mixin
(`WithProps`) generates the element class from a table of property
descriptors: it derives `observedAttributes`, builds the Leaflet options
object from the current attributes, calls the component's
`createLeafletObject()`, keeps attributes and the live object in sync in both
directions, and re-fires every Leaflet event on the element. Components join
each other through a DOM-event registration protocol that bubbles up to
`<leaflet-map>` at the root — no component ever reads another component's
state or queries the DOM for its relatives. The build is `tsc` with no
bundler; Leaflet is always external.

@ -22,7 +22,7 @@
"build": "rm -rf dist && tsc --outDir dist",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "oxlint src test",
"format": "oxfmt '*.md' 'src/**/*.{ts,js,json,md}' 'test/**/*.ts'",
"format": "oxfmt '*.md' 'docs/**/*.md' 'src/**/*.{ts,js,json,md}' 'test/**/*.ts'",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build"

Loading…
Cancel
Save