feat: make WMS crs extensible via a nested provider element, not a registry

Replaces the mutable registerCRS() registry (never released beyond this
branch) with a web-components-first design: any custom element, no base
class required, can provide a CRS Leaflet doesn't ship by nesting inside
<leaflet-tile-layer-wms> and firing a bubbling leaflet-crs-changed event
(detail: { crs: CRS | null }, mirroring icon-changed's { icon: null }
pattern for "revert to default"). The plain `crs="EPSG4326"` attribute
stays as a convenience shortcut for the 4 CRSes Leaflet itself ships; a
nested provider takes priority over it when both are present.

This surfaced a real, previously-undiscovered bug in with-props.ts:
recreateLeafletObject() was declared on the public LeafletElement
interface but only ever implemented as a private #recreateLeafletObject()
-- calling it would throw "is not a function" at runtime despite
type-checking cleanly. Fixed by making it public, and it now also
re-dispatches registerWithParent() for attach !== 'none' components,
which it never did before (previously only safe for attach: 'none'
components like icons, since recreate would otherwise rebuild a layer
without ever re-adding it to whatever registered it the first time --
exactly what leaflet-tile-layer-wms needs when a nested provider's crs
arrives after it already constructed itself with the default).
main
Buddy 4 weeks ago
parent b90616d3c5
commit ef7828128b

@ -286,6 +286,27 @@ All `<leaflet-tile-layer>` attributes above apply (a WMS layer is a tile layer),
| `uppercase` | — | Use uppercase WMS parameter names |
| `crs` | — | Coordinate reference system by name: `'EPSG3857'`, `'EPSG4326'`, `'EPSG3395'`, or `'Simple'` |
For a CRS Leaflet doesn't ship (a custom projection from a plugin, say), nest a CRS-providing element instead of using the `crs` attribute. It's just a custom element that fires a `leaflet-crs-changed` event on itself, bubbling, carrying the actual `L.CRS` value — no base class required:
```js
class MyCRSProvider extends HTMLElement {
connectedCallback() {
this.dispatchEvent(
new CustomEvent('leaflet-crs-changed', { bubbles: true, detail: { crs: myPluginCRS } }),
);
}
}
customElements.define('my-crs-provider', MyCRSProvider);
```
```html
<leaflet-tile-layer-wms url="..." layers="...">
<my-crs-provider></my-crs-provider>
</leaflet-tile-layer-wms>
```
A nested provider takes priority over the `crs` attribute when both are present. `crs` is construction-only (Leaflet has no live setter for it), so picking one up — from either source — rebuilds the whole layer; place the provider so it connects before you need the correct projection to take effect, since there's nothing to revert to partway through a request.
### `<leaflet-marker>`
| Attribute | Default | Description |

@ -7,6 +7,7 @@ import {
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { TileLayerEvents } from '../core/event-types.ts';
import type { LeafletCRSChangedEvent } from '../core/register.ts';
// WMS request parameters have no individual setters -- they're merged into the
// query string through setParams().
@ -16,8 +17,10 @@ function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void
};
}
// `crs` takes a CRS instance, not a primitive, so it's looked up by name from
// Leaflet's built-in set rather than decoded like a plain value. Constructor-
// `crs` takes a CRS instance, not a primitive. The attribute only covers the
// 4 CRSes Leaflet ships by name -- a CRS Leaflet doesn't ship comes from a
// nested child announcing itself via leaflet-crs-changed instead (see
// #onCrsChanged), which takes priority over this when present. Constructor-
// only, like the rest of tileLayerProps -- Leaflet has no setter for it.
const NAMED_CRS = {
EPSG3857: CRS.EPSG3857,
@ -51,9 +54,35 @@ export class LeafletTileLayerWMS extends WithProps({
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
// Set by a nested CRS-providing child; overrides the `crs` attribute's
// named lookup when present.
#childCrs?: CRS;
createLeafletObject(options: WMSOptions): TileLayer.WMS {
return new TileLayer.WMS(this.url, options);
return new TileLayer.WMS(
this.url,
this.#childCrs ? { ...options, crs: this.#childCrs } : options,
);
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-crs-changed', this.#onCrsChanged);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-crs-changed', this.#onCrsChanged);
super.disconnectedCallback();
}
// A CRS Leaflet doesn't ship comes from any custom element nested here
// that fires this event -- no base class required, just this event shape
// (see register.ts). `crs` is constructor-only, so picking it up means
// rebuilding the whole layer.
#onCrsChanged = (e: LeafletCRSChangedEvent) => {
this.#childCrs = e.detail.crs ?? undefined;
this.recreateLeafletObject();
};
}
customElements.define('leaflet-tile-layer-wms', LeafletTileLayerWMS);

@ -1,4 +1,4 @@
import { DivIcon, Icon, Layer } from 'leaflet';
import { DivIcon, Icon, Layer, type CRS } from 'leaflet';
// Custom event type for the bubbling registration protocol. Carries the
// Leaflet object and the originating element so the nearest parent can
@ -21,6 +21,15 @@ export type LeafletLineSyncEvent = CustomEvent<{ element: HTMLElement; latlng: [
// Fired by <leaflet-line> on disconnect, so a listening parent can drop it.
export type LeafletLineRemoveEvent = CustomEvent<{ element: HTMLElement }>;
// A CRS is a plain value (methods + a couple of properties, see Leaflet's
// own `CRS` interface), not a Layer -- it doesn't fit the leaflet-register
// protocol above. Any custom element nested inside a component that accepts
// a `crs` (currently just leaflet-tile-layer-wms) can provide one by firing
// this itself, bubbling, on connect -- no base class required, just this
// event shape. `crs: null` (e.g. on disconnect) reverts to that component's
// own default, mirroring icon-changed's `icon: null`.
export type LeafletCRSChangedEvent = CustomEvent<{ crs: CRS | null }>;
declare global {
interface HTMLElementEventMap {
'leaflet-register': LeafletRegisterEvent;
@ -29,6 +38,7 @@ declare global {
'icon-changed': LeafletIconChangedEvent;
'leaflet-line-sync': LeafletLineSyncEvent;
'leaflet-line-remove': LeafletLineRemoveEvent;
'leaflet-crs-changed': LeafletCRSChangedEvent;
}
}

@ -178,7 +178,7 @@ export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
const prop = byAttribute.get(name);
if (!prop) return;
if (options.recreate) {
this.#recreateLeafletObject();
this.recreateLeafletObject();
return;
}
const obj = this.#obj;
@ -189,10 +189,14 @@ export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
}
// Throws the current object away and builds a fresh one from the current
// attributes, leaving the element's place in the tree untouched.
#recreateLeafletObject(): void {
// attributes, leaving the element's place in the tree untouched. Also
// re-registers the new object with the parent (attach !== 'none') --
// registerWithParent only fires once from connectedCallback otherwise,
// so without this a recreated layer would never get (re-)added anywhere.
recreateLeafletObject(): void {
this.#destroyObject();
this.#createObject();
if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj);
this.leafletObjectCreated();
}

@ -1,8 +1,22 @@
import { TileLayer } from 'leaflet';
import { CRS, TileLayer } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../../src/components/leaflet-tile-layer.ts';
import '../../src/components/leaflet-tile-layer-wms.ts';
// Stands in for a plugin's CRS-providing element: a plain custom element,
// no base class from this package, that fires leaflet-crs-changed on
// connect -- exactly the shape a third party would write.
class TestCRSProvider extends HTMLElement {
crs?: CRS;
connectedCallback(): void {
this.dispatchEvent(
new CustomEvent('leaflet-crs-changed', { bubbles: true, detail: { crs: this.crs ?? null } }),
);
}
}
customElements.define('test-crs-provider', TestCRSProvider);
describe('leaflet-tile-layer', () => {
it('creates a TileLayer from the url attribute with tileLayerProps options', () => {
const el = document.createElement('leaflet-tile-layer');
@ -45,8 +59,7 @@ describe('leaflet-tile-layer-wms', () => {
el.remove();
});
it('resolves crs by name to the matching Leaflet CRS instance', async () => {
const { CRS } = await import('leaflet');
it('resolves crs by name to the matching Leaflet CRS instance', () => {
const el = document.createElement('leaflet-tile-layer-wms');
el.setAttribute('url', 'https://wms.example/service');
el.setAttribute('layers', 'basic');
@ -56,4 +69,44 @@ describe('leaflet-tile-layer-wms', () => {
expect(el.leafletObject?.options.crs).toBe(CRS.EPSG4326);
el.remove();
});
it('picks up a CRS Leaflet does not ship from a nested provider element', () => {
const pluginCrs = { ...CRS.EPSG3857 } as CRS;
const el = document.createElement('leaflet-tile-layer-wms');
el.setAttribute('url', 'https://wms.example/service');
el.setAttribute('layers', 'basic');
document.body.append(el);
// No attribute and no provider yet -- Leaflet's own default (null, falls
// back to the map's crs internally) applies, nothing of ours sets it.
expect(el.leafletObject?.options.crs).toBeNull();
const provider = document.createElement('test-crs-provider') as TestCRSProvider;
provider.crs = pluginCrs;
const objectBeforeRecreate = el.leafletObject;
// Connecting fires leaflet-crs-changed, bubbling to el.
el.append(provider);
// crs is construction-only: picking it up means a rebuilt object.
expect(el.leafletObject).not.toBe(objectBeforeRecreate);
expect(el.leafletObject?.options.crs).toBe(pluginCrs);
el.remove();
});
it('a nested provider overrides the crs attribute', () => {
const pluginCrs = { ...CRS.EPSG3857 } as CRS;
const el = document.createElement('leaflet-tile-layer-wms');
el.setAttribute('url', 'https://wms.example/service');
el.setAttribute('layers', 'basic');
el.setAttribute('crs', 'EPSG4326');
const provider = document.createElement('test-crs-provider') as TestCRSProvider;
provider.crs = pluginCrs;
el.append(provider);
document.body.append(el);
expect(el.leafletObject?.options.crs).toBe(pluginCrs);
el.remove();
});
});

@ -254,6 +254,41 @@ describe('WithProps: recreate option', () => {
expect(created).toHaveBeenCalledTimes(2);
node.remove();
});
it('re-registers the new object with the parent (recreateLeafletObject is a public method)', () => {
const props = { radius: num<FakeObj>(5) } as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
const tag = 'wp-recreate-reregisters';
customElements.define(tag, El);
const parent = document.createElement('div');
document.body.append(parent);
const registrations: unknown[] = [];
parent.addEventListener('leaflet-register', (e: LeafletRegisterEvent) => {
registrations.push(e.detail.leafletObject);
});
const node = document.createElement(tag) as HTMLElement & {
leafletObject?: FakeObj;
recreateLeafletObject(): void;
};
parent.append(node);
expect(registrations).toEqual([node.leafletObject]);
const first = node.leafletObject;
// recreateLeafletObject is declared on the public LeafletElement interface
// but, until fixed, only a private implementation existed -- this call
// would have thrown "is not a function" at runtime despite type-checking.
node.recreateLeafletObject();
expect(node.leafletObject).not.toBe(first);
expect(registrations).toEqual([first, node.leafletObject]);
parent.remove();
});
});
describe('WithProps: property accessors', () => {

Loading…
Cancel
Save