From b90616d3c5f269ac894e2f88eef76ae9af5a2f2c Mon Sep 17 00:00:00 2001 From: Buddy Date: Sat, 22 Aug 2026 14:53:12 -0700 Subject: [PATCH] docs: add plugin authoring guide to README Documents the toolkit third-party components are built from, now that it's exported from the package root: the WithProps + PROPS table pattern, the prop builder reference, reusing the shared fragments (pathProps/latLngProps/tileLayerProps/urlProp), how nesting works (attach modes, and why Leaflet plugin classes register correctly without any special-casing on either side), typing events with LeafletAddEventListener and the event-types.ts fragments, the emitIconChanged protocol for custom marker icons, and the customElements.define ordering pitfall for a plugin with multiple interdependent custom elements of its own. --- README.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/README.md b/README.md index cebbbb5..0a23308 100644 --- a/README.md +++ b/README.md @@ -567,6 +567,123 @@ The following attributes apply to ``, ``, - **`` / `` as child of ``** → swapped in via `setIcon()`. - **Layer as child of ``** → intercepted by the control and registered as a base or overlay entry. The control stops propagation so the layer doesn't reach the map directly. Use `active` to start the layer visible. +## Building your own components + +This library's own components are built from a small toolkit — a mixin, some prop-codec builders, and a few reusable fragments — and that whole toolkit is exported from the package root. A component wrapping a Leaflet plugin (a custom layer, control, or icon) is built exactly the same way `` or `` are, whether or not it ever ends up in this package. + +### The pattern + +1. Describe your attributes as a `PROPS` table, then extend `WithProps(PROPS, options?)`. +2. Implement `createLeafletObject(options)`, returning whatever Leaflet object your plugin provides. +3. `declare readonly leafletObject?: TheType;` — `WithProps`'s own inference of the object type from the `PROPS` table alone isn't reliable enough to skip this (every component in this package does it). +4. Call `customElements.define('my-plugin-layer', MyPluginLayer)`. + +```ts +import { MyClusterGroup, type MyClusterGroupOptions } from 'some-leaflet-plugin'; +import { WithProps, bool, num } from 'leaflet-components'; + +class MyClusterLayer extends WithProps({ + radius: num(80), + disableClusteringAtZoom: num(18), + spiderfy: bool(true), +}) { + declare readonly leafletObject?: MyClusterGroup; + + createLeafletObject(options: MyClusterGroupOptions): MyClusterGroup { + return new MyClusterGroup(options); + } +} + +customElements.define('my-cluster-layer', MyClusterLayer); +``` + +That's it — no other integration point is needed. `WithProps` handles `observedAttributes`, attribute↔property sync, building the options object from attributes on connect, dispatching attribute changes to the matching Leaflet setter (or a `set` you provide per prop, for anything a setter can't handle), and re-emitting every event your object fires as `leaflet:`. + +### Attribute builders (`PROPS` table entries) + +| Builder | Attribute holds | Notes | +| ------------------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `str(default?, opts?)` | a string | | +| `num(default?, opts?)` | a number | | +| `bool(default?, opts?)` | a boolean | present/absent by default; use `bool(true, ...)` for an option Leaflet defaults to `true`, so `attr="false"` can opt out | +| `choice<'a' \| 'b'>(default, opts?)` | a string literal union | not runtime-validated, just typed | +| `json(default, opts?)` | JSON | decodes/encodes via `JSON.parse`/`JSON.stringify` — for points, bounds, arbitrary objects | +| `disabled(opts?)` | a boolean | the inverse of `bool(true)`: attribute is auto-named `disable-`, presence means `false` | +| `positional(def)` | — | wraps any of the above; marks a prop your Leaflet constructor takes as an argument rather than an option, so it's excluded from the options object `createLeafletObject` receives | + +Each builder's `opts` can include `set(obj, value, el)` (called on attribute change instead of the default `setXyz()` lookup — needed when there's no matching setter, or the update needs more than one value, like a lat/lng pair), `get(obj)` (backs the live property getter, and — paired with `event` — keeps the attribute in sync whenever your object fires that event), and `attribute` (override the default kebab-cased name). + +### Reusing the built-in fragments + +Common attribute groups are already factored out and exported: `pathProps` (Leaflet `Path` styling — color, weight, dashes, fill, etc.), `latLngProps` (a synced lat/lng pair, for anything positioned on the map), `tileLayerProps` (the base `GridLayer`/`TileLayer` options), and `urlProp`. A custom vector layer plugin, for instance, can just spread `pathProps` in instead of redeclaring styling attributes: + +```ts +import { WithProps, pathProps, positional, json } from 'leaflet-components'; + +class MyShapeLayer extends WithProps({ + ...pathProps, + data: positional(json(null)), +}) { + /* ... */ +} +``` + +### Nesting into the tree + +By default (`attach: 'children'`, the default when you omit `options`), your component registers itself with its nearest ancestor and accepts registering descendants as child layers/popups/tooltips. Use `attach: 'self'` for something that has a parent but manages no children of its own (a popup, a tooltip, a control); use `attach: 'none'` for something that's neither (an icon). + +The registration check on the _receiving_ side keys off `instanceof Layer` / `instanceof Popup` / `instanceof Tooltip` — Leaflet's own base classes, not this package's. Since real Leaflet plugins are conventionally built by extending those same classes, a plugin layer or control nests correctly under ``, ``, ``, or `` with no special-casing on either side. + +### Typing events + +`LeafletAddEventListener`/`LeafletRemoveEventListener` narrow `addEventListener`/`removeEventListener` for `leaflet:` events, the same way every component in this package does: + +```ts +import { + WithProps, + type LeafletAddEventListener, + type LeafletRemoveEventListener, + type PathEvents, +} from 'leaflet-components'; + +class MyShapeLayer extends WithProps({ + /* ... */ +}) { + declare addEventListener: LeafletAddEventListener; + declare removeEventListener: LeafletRemoveEventListener; + /* ... */ +} +``` + +`event-types.ts`'s fragments (`MouseEvents`, `PopupBindEvents`, `DragEvents`, `TileEvents`, ...) and their compositions (`PathEvents`, `MarkerEvents`, `TileLayerEvents`, `GroupEvents`, `MapEvents`, ...) are all exported for reuse; compose your own if your plugin fires events none of them cover. If you also want `document.createElement('my-cluster-layer')` to infer your class, augment `HTMLElementTagNameMap` the same way this package does for its own tags: + +```ts +declare global { + interface HTMLElementTagNameMap { + 'my-cluster-layer': MyClusterLayer; + } +} +``` + +### Custom marker icons + +`` listens for a generic `icon-changed` event and calls `setIcon()` with whatever `detail.icon` holds — it doesn't check which component fired it. A component wrapping a plugin that provides its own icon (e.g. a themed marker icon set) just needs to call `emitIconChanged`: + +```ts +import { emitIconChanged } from 'leaflet-components'; + +// after building or updating your icon: +emitIconChanged(this, myPluginIcon); +// and on disconnect: +emitIconChanged(this, null); +``` + +and it plugs into any `` immediately, the same as this package's own ``/``. + +### One ordering pitfall, if your plugin has multiple custom elements + +`customElements.define()` upgrades every matching element already parsed into the page immediately and synchronously. If one of your custom elements listens for a bubbling announcement fired by another of your own custom elements on connect (the way `` listens for ``), the listening one must be registered (`customElements.define`d) first — otherwise, on a real static-HTML page, every instance of the "announcer" tag upgrades and fires its one-shot announcement before the "listener" tag even exists, and it's lost for good. This only matters for relationships _within_ your own plugin; it doesn't affect nesting into this package's components, which is handled by the bubbling registration protocol above, not a one-shot announcement. + ## Development ```bash