refactor: complete WithProps mixin, generic event forwarding, and full prop coverage

Finishes the WithProps(PROPS, options) mixin (src/core/with-props.ts): every
component gets reactive attributes, live-value property getters that read
through to the Leaflet object where possible, and every Leaflet event
re-emitted on the element as `leaflet:<type>` via a generic fire() patch (no
per-component or per-event registration needed).

Prop coverage:
- Centralized pathProps in shared-props.ts (stroke, lineCap, lineJoin,
  dashArray, dashOffset, fillRule, interactive, className,
  bubblingMouseEvents, pane), deduped leaflet-geojson against it.
- Added live getters backed by Leaflet's own accessors: radius (circle,
  circle-marker), bounds (rectangle, image/video/svg-overlay), url
  (image/video-overlay).
- Filled gaps: smoothFactor/noClip on polyline; a shared tileLayerProps
  fragment now used by both tile-layer and tile-layer-wms (WMS previously
  exposed none of its base tile options); crs on WMS; zIndex/className/
  keepAspectRatio/errorOverlayUrl on video-overlay; fixed tile-layer's
  zIndex default and div-icon's className default to match Leaflet.

Tooling:
- Removed dead src/core/events.ts (broken, unused, superseded by the
  generic event forwarding above) and src/core/attributes.ts (emptied by
  an earlier rename, nothing imported it).
- Swapped ESLint + @typescript-eslint for oxlint: no released
  @typescript-eslint version supports the pinned typescript@7, even for
  parsing alone. oxlint has its own parser and lints clean.
- Dropped the Rollup CJS/UMD bundle step; dist/ is ESM-only from tsc now.
  Updated package.json's main/module/exports/unpkg accordingly.
- Updated CLAUDE.md to match: build/lint commands, ESM-only output, the
  event-forwarding mechanism, and layer-group/feature-group now going
  through WithProps({}) instead of raw HTMLElement.
main
Buddy 4 weeks ago
parent bdf5bd56f3
commit 14e35846d5

@ -0,0 +1,17 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"pedantic": "warn"
},
"rules": {
"eslint/max-lines": "off",
"eslint/max-lines-per-function": "off"
},
"env": {
"builtin": true
},
"ignorePatterns": ["dist/"]
}

@ -5,12 +5,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands ## Commands
```bash ```bash
npm run build # tsc emits individual ESM modules, then Rollup bundles ESM/CJS/UMD npm run build # tsc emits individual ESM modules to dist/ (no bundling)
npm run typecheck # tsc --noEmit npm run typecheck # tsc --noEmit
npm run lint # ESLint over src/**/*.{ts,js} npm run lint # oxlint over src/
npm run format # Prettier formatting npm run format # Prettier formatting
``` ```
Linting runs on `oxlint`, not ESLint/`@typescript-eslint`. This project pins `typescript@^7.0.2`, and `@typescript-eslint` has no released version that supports it (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 doesn't touch the `typescript` package, so it works regardless of TS version — the tradeoff is no type-aware rules (no `no-floating-promises`, `no-unnecessary-condition`, etc.). Revisit once `@typescript-eslint` supports TS 7.
There are no tests in this project. To preview components locally, open `index.html` in a browser with any static-file server. There are no tests in this project. To preview components locally, open `index.html` in a browser with any static-file server.
## Architecture ## Architecture
@ -23,9 +25,9 @@ The `WithProps(Base, PROPS)` mixin factory replaces the old `LeafletElement` bas
- `observedAttributes` getter derived from the PROPS table keys. - `observedAttributes` getter derived from the PROPS table keys.
- `definePropAccessors` — property getters/setters on the prototype that sync attributes. - `definePropAccessors` — property getters/setters on the prototype that sync attributes.
- `parseAttributeValue` for boolean, number, and JSON coersion in `attributeChangedCallback`.
- On connect, `initOptions()` builds a Leaflet options object from current attributes + PROPS defaults, then calls `createLeafletObject()`. - On connect, `initOptions()` builds a Leaflet options object from current attributes + PROPS defaults, then calls `createLeafletObject()`.
- On attribute change, `updateLeafletObject()` by default dispatches to the matching Leaflet setter (e.g. `setOpacity`, `setRadius`). Components override this for custom attribute handling (e.g. lat/lng pairs). - On attribute change, `updateLeafletObject()` by default dispatches to the matching Leaflet setter (e.g. `setOpacity`, `setRadius`). Components override this for custom attribute handling (e.g. lat/lng pairs).
- Every Leaflet event the created object fires is re-emitted on the element as `leaflet:<type>` (e.g. `leaflet:zoomend`, `leaflet:dragend`), carrying the original Leaflet event object as `event.detail`. This is generic and automatic: `WithProps` wraps the object's own `fire()` method, so no per-component or per-event-type registration is needed. Not bubbling — Leaflet already propagates layer events up to the map, so `<leaflet-map>` would otherwise see each one twice.
### `leaflet-map`: `src/components/leaflet-map.ts` ### `leaflet-map`: `src/components/leaflet-map.ts`
@ -41,14 +43,12 @@ All non-map components extend `WithProps(HTMLElement, PROPS)`. To add a new comp
4. Call `customElements.define('leaflet-foo', LeafletFoo)` at the bottom. 4. Call `customElements.define('leaflet-foo', LeafletFoo)` at the bottom.
5. Export from `src/index.ts`. 5. Export from `src/index.ts`.
For components that accept children (layers, popups, tooltips), use the `registerChildren`/`unregisterChildren`/`getChildren` WeakMap-backed helpers from `src/core/register.ts` in `connectedCallback`/`disconnectedCallback` instead of maintaining private fields.
### Special cases ### Special cases
- **`leaflet-polygon`** uses `<leaflet-line>` children for vertices. The polygon collects lat/lng from child `leaflet-line` elements rather than having them as direct attributes. - **`leaflet-polygon`** uses `<leaflet-line>` children for vertices. The polygon collects lat/lng from child `leaflet-line` elements rather than having them as direct attributes.
- **`leaflet-popup`** / **`leaflet-tooltip`**: content comes from `innerHTML`, not attributes. `leaflet-popup` watches for DOM mutations to keep Leaflet in sync. - **`leaflet-popup`** / **`leaflet-tooltip`**: content comes from `innerHTML`, not attributes. `leaflet-popup` watches for DOM mutations to keep Leaflet in sync.
- **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers extending `HTMLElement` directly (not via `WithProps`). Children register themselves into them via the standard bubble mechanism. - **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers built with `WithProps({})` (an empty props table) — they have no options of their own, but still get the standard lifecycle, child registration, and `leaflet:` event forwarding for free. Children register themselves into them via the standard bubble mechanism.
### Output ### Output
`tsc` compiles `src/``dist/` as individual ESM modules (`.js` + `.d.ts` + `.d.ts.map`), then `rollup -c` bundles `dist/index.js` (ESM), `dist/index.cjs` (CJS), and `dist/index.umd.js` (UMD) — each with minified variants. Leaflet is always external (never bundled). Imports within source use `.ts` extensions; `rewriteRelativeImportExtensions` in tsconfig strips them to `.js` in the tsc output (`tsconfig.json:16`). `tsc` compiles `src/``dist/` as individual ESM modules (`.js` + `.d.ts` + `.d.ts.map`) — no bundling step. Consumers import `dist/index.js` (or any individual module) directly; there is no CJS or UMD build. Leaflet is always external (never bundled). Imports within source use `.ts` extensions; `rewriteRelativeImportExtensions` in tsconfig strips them to `.js` in the tsc output (`tsconfig.json:16`).

@ -1,43 +0,0 @@
import { defineConfig } from 'eslint/config';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import prettierPlugin from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier';
export default defineConfig([
{
ignores: ['dist/'],
},
...tsPlugin.configs['flat/strict-type-checked'].map(c => ({
...c,
files: ['**/*.ts'],
})),
...tsPlugin.configs['flat/stylistic-type-checked'].map(c => ({
...c,
files: ['**/*.ts'],
})),
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: { project: true },
},
plugins: {
prettier: prettierPlugin,
},
rules: {
...prettierConfig.rules,
'prettier/prettier': 'error',
},
},
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
},
},
]);

2390
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -2,15 +2,13 @@
"name": "leaflet-components", "name": "leaflet-components",
"version": "0.1.0", "version": "0.1.0",
"description": "LeafletJS as Web Components", "description": "LeafletJS as Web Components",
"main": "dist/index.cjs", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"unpkg": "dist/index.umd.js",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js"
"require": "./dist/index.cjs"
}, },
"./dist/*": "./dist/*", "./dist/*": "./dist/*",
"./package.json": "./package.json" "./package.json": "./package.json"
@ -21,9 +19,9 @@
"README.md" "README.md"
], ],
"scripts": { "scripts": {
"build": "rm -rf dist && tsc --outDir dist && rollup -c rollup.config.mjs", "build": "rm -rf dist && tsc --outDir dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint 'src/**/*.{ts,js}'", "lint": "oxlint src",
"format": "prettier --write 'src/**/*.{ts,js,json,md}'", "format": "prettier --write 'src/**/*.{ts,js,json,md}'",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
@ -39,19 +37,9 @@
"leaflet": "1.9.4" "leaflet": "1.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/leaflet": "1.9.21", "@types/leaflet": "1.9.21",
"@typescript-eslint/eslint-plugin": "8.60.1", "oxlint": "^1.79.0",
"@typescript-eslint/parser": "8.60.1",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.6",
"prettier": "3.8.3", "prettier": "3.8.3",
"rollup": "^4.61.1", "typescript": "^7.0.2"
"rollup-plugin-dts": "^6.4.1",
"tslib": "^2.8.1",
"typescript": "6.0.3"
} }
} }

@ -1,25 +0,0 @@
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
import dts from 'rollup-plugin-dts';
export default [
{
input: 'src/index.ts',
external: ['leaflet'],
plugins: [typescript({ declaration: false, declarationMap: false })],
output: [
{ format: 'es', file: 'dist/index.js' },
{ format: 'es', file: 'dist/index.min.js', plugins: [terser()] },
{ format: 'cjs', file: 'dist/index.cjs' },
{ format: 'cjs', file: 'dist/index.min.cjs', plugins: [terser()] },
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.js', globals: { leaflet: 'L' } },
{ format: 'umd', name: 'LeafletComponents', file: 'dist/index.umd.min.js', globals: { leaflet: 'L' }, plugins: [terser()] },
],
},
{
input: 'src/index.ts',
external: ['leaflet'],
plugins: [dts()],
output: { format: 'es', file: 'dist/index.d.ts' },
},
];

@ -1,66 +1,17 @@
import { CircleMarker } from 'leaflet'; import { CircleMarker, type CircleMarkerOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; import { num } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { latLngProps, pathProps } from '../core/shared-props.ts';
import { isPathStyleAttr, numAttr, updatePathStyle } from '../core/attributes.ts'; import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({ export class LeafletCircleMarker extends WithProps({
lat: num(), ...latLngProps,
lng: num(), radius: num<CircleMarker>(10, { get: (obj) => obj.getRadius() }),
radius: num(10), ...pathProps,
color: str('#3388ff'), }) {
weight: num(3), declare readonly leafletObject?: CircleMarker;
opacity: num(1.0),
fill: on(), createLeafletObject(options: CircleMarkerOptions): CircleMarker {
fillColor: str('#3388ff'), return new CircleMarker([this.lat, this.lng], options);
fillOpacity: num(0.2),
});
export class LeafletCircleMarker extends WithProps(HTMLElement, PROPS) {
#obj?: CircleMarker;
#syncing = false;
connectedCallback() {
this.#obj = new CircleMarker(
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
// The `on` method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onMove, this);
registerChildren(this, this.#obj);
}
disconnectedCallback() {
// The `off` method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onMove, this);
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} else if (name === 'radius') {
this.#obj.setRadius(numAttr(this, PROPS, 'radius'));
} else if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
}
#onMove() {
if (!this.#obj || this.#syncing) return;
this.#syncing = true;
const pos = this.#obj.getLatLng();
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
} }
} }

@ -1,66 +1,19 @@
import { Circle } from 'leaflet'; import { Circle, type CircleOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; import { num, positional } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { latLngProps, pathProps } from '../core/shared-props.ts';
import { isPathStyleAttr, numAttr, updatePathStyle } from '../core/attributes.ts'; import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({ export class LeafletCircle extends WithProps({
lat: num(), ...latLngProps,
lng: num(), ...pathProps,
radius: num(1000), // Leaflet has no default radius -- it throws without one -- so unlike every
color: str('#3388ff'), // other option, this one is always passed, from the default when unset.
weight: num(3), radius: positional(num<Circle>(1000, { get: (obj) => obj.getRadius() })),
opacity: num(1.0), }) {
fill: on(), declare readonly leafletObject?: Circle;
fillColor: str('#3388ff'),
fillOpacity: num(0.2), createLeafletObject(options: CircleOptions): Circle {
}); return new Circle([this.lat, this.lng], { ...options, radius: this.radius });
export class LeafletCircle extends WithProps(HTMLElement, PROPS) {
#obj?: Circle;
#syncing = false;
connectedCallback() {
this.#obj = new Circle(
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
// the on method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onMove, this);
registerChildren(this, this.#obj);
}
disconnectedCallback() {
// the off method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onMove, this);
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} else if (name === 'radius') {
this.#obj.setRadius(numAttr(this, PROPS, 'radius'));
} else if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
}
#onMove() {
if (!this.#obj || this.#syncing) return;
this.#syncing = true;
const pos = this.#obj.getLatLng();
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
} }
} }

@ -1,31 +1,18 @@
import { Control, ControlPosition } from 'leaflet'; import { Control, type ControlPosition } from 'leaflet';
import { WithProps, defineProps, str } from '../core/props.ts'; import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import { registerWithParent } from '../core/register.ts'; export class LeafletControlAttribution extends WithProps(
{
const PROPS = defineProps({ position: choice<ControlPosition>('bottomright'),
position: str('bottomright'),
prefix: str(), prefix: str(),
}); },
{ attach: 'self' },
export class LeafletControlAttribution extends WithProps(HTMLElement, PROPS) { ) {
#obj?: Control.Attribution; declare readonly leafletObject?: Control.Attribution;
connectedCallback() {
this.#obj = new Control.Attribution({
position: this.getAttribute('position') as ControlPosition | undefined,
prefix: this.getAttribute('prefix') ?? undefined,
});
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() { createLeafletObject(options: Control.AttributionOptions): Control.Attribution {
return this.#obj; return new Control.Attribution(options);
} }
} }

@ -1,78 +1,81 @@
import { Control, ControlPosition, Layer } from 'leaflet'; import { Control, type ControlPosition, type Layer } from 'leaflet';
import { WithProps, defineProps, str } from '../core/props.ts'; import { bool, choice } from '../core/props.ts';
import { LeafletRegisterEvent, registerWithParent } from '../core/register.ts'; import { WithProps } from '../core/with-props.ts';
import type { LeafletRegisterEvent } from '../core/register.ts';
const PROPS = defineProps({ interface ChildLayer {
position: str('topright'), layer: Layer;
}); name: string;
base: boolean;
active: boolean;
}
export class LeafletControlLayers extends WithProps(HTMLElement, PROPS) { export class LeafletControlLayers extends WithProps(
#obj?: Control.Layers; {
position: choice<ControlPosition>('topright'),
collapsed: bool(true),
autoZIndex: bool(true),
hideSingleBase: bool(),
sortLayers: bool(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Layers;
connectedCallback() { // Children are read straight off the DOM rather than through registration:
// any that already exist are listed as base layers or overlays by their
// `name`, and `type="base"` / `active` decide how.
createLeafletObject(options: Control.LayersOptions): Control.Layers {
const baseLayers: Record<string, Layer> = {}; const baseLayers: Record<string, Layer> = {};
const overlays: Record<string, Layer> = {}; const overlays: Record<string, Layer> = {};
const inactiveLayers: Layer[] = []; for (const child of this.#childLayers()) {
(child.base ? baseLayers : overlays)[child.name] = child.layer;
for (const child of this.querySelectorAll(':scope > *')) { }
const layer = (child as unknown as { leafletObject?: Layer }).leafletObject; return new Control.Layers(baseLayers, overlays, options);
const name = child.getAttribute('name');
if (!name || !layer) continue;
const base = child.getAttribute('type') === 'base';
const active = child.hasAttribute('active');
(base ? baseLayers : overlays)[name] = layer;
if (!active) inactiveLayers.push(layer);
} }
for (const layer of inactiveLayers) { connectedCallback(): void {
super.connectedCallback();
for (const child of this.#childLayers()) {
if (child.active) continue;
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer } }), new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer: child.layer } }),
); );
} }
this.#obj = new Control.Layers(baseLayers, overlays, {
position: this.getAttribute('position') as ControlPosition | undefined,
collapsed: !this.hasAttribute('collapsed') || this.getAttribute('collapsed') !== 'false',
autoZIndex:
!this.hasAttribute('auto-z-index') || this.getAttribute('auto-z-index') !== 'false',
hideSingleBase: this.hasAttribute('hide-single-base'),
sortLayers: this.hasAttribute('sort-layers'),
});
registerWithParent(this, this.#obj);
this.addEventListener('leaflet-register', this.#onChildRegister); this.addEventListener('leaflet-register', this.#onChildRegister);
} }
disconnectedCallback() { disconnectedCallback(): void {
this.removeEventListener('leaflet-register', this.#onChildRegister); this.removeEventListener('leaflet-register', this.#onChildRegister);
this.#obj?.remove(); super.disconnectedCallback();
this.#obj = undefined;
} }
attributeChangedCallback(name: string) { #childLayers(): ChildLayer[] {
if (name === 'position' && this.#obj) { const children: ChildLayer[] = [];
this.#obj.setPosition(this.getAttribute('position') as ControlPosition); for (const child of this.querySelectorAll(':scope > *')) {
} const layer = (child as { leafletObject?: Layer }).leafletObject;
const name = child.getAttribute('name');
if (!name || !layer) continue;
children.push({
layer,
name,
base: child.getAttribute('type') === 'base',
active: child.hasAttribute('active'),
});
} }
return children;
get leafletObject() {
return this.#obj;
} }
// Children that connect after us announce themselves instead.
#onChildRegister = (e: LeafletRegisterEvent) => { #onChildRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation(); e.stopPropagation();
const el = e.detail.element; const el = e.detail.element;
const layer = e.detail.leafletObject; const layer = e.detail.leafletObject;
const name = el.getAttribute('name'); const name = el.getAttribute('name');
if (!name) return; if (!name) return;
const base = el.getAttribute('type') === 'base'; if (el.getAttribute('type') === 'base') this.leafletObject?.addBaseLayer(layer, name);
const active = el.hasAttribute('active'); else this.leafletObject?.addOverlay(layer, name);
if (base) { if (el.hasAttribute('active')) {
this.#obj?.addBaseLayer(layer, name);
} else {
this.#obj?.addOverlay(layer, name);
}
if (active) {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }), new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }),
); );

@ -1,38 +1,21 @@
import { Control, ControlPosition } from 'leaflet'; import { Control, type ControlPosition } from 'leaflet';
import { WithProps, defineProps, num, str, on } from '../core/props.ts'; import { bool, choice, num } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import { registerWithParent } from '../core/register.ts'; export class LeafletControlScale extends WithProps(
{
const PROPS = defineProps({ position: choice<ControlPosition>('bottomleft'),
position: str('bottomleft'),
maxWidth: num(100), maxWidth: num(100),
metric: on(), metric: bool(true),
imperial: on(), imperial: bool(true),
updateWhenIdle: on(), updateWhenIdle: bool(),
}); },
{ attach: 'self' },
export class LeafletControlScale extends WithProps(HTMLElement, PROPS) { ) {
#obj?: Control.Scale; declare readonly leafletObject?: Control.Scale;
connectedCallback() {
this.#obj = new Control.Scale({
position: this.getAttribute('position') as ControlPosition | undefined,
maxWidth: +(this.getAttribute('max-width') ?? 100),
metric: !this.hasAttribute('metric') || this.getAttribute('metric') !== 'false',
imperial: !this.hasAttribute('imperial') || this.getAttribute('imperial') !== 'false',
updateWhenIdle:
!this.hasAttribute('update-when-idle') || this.getAttribute('update-when-idle') !== 'false',
});
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() { createLeafletObject(options: Control.ScaleOptions): Control.Scale {
return this.#obj; return new Control.Scale(options);
} }
} }

@ -1,37 +1,23 @@
import { Control, ControlPosition } from 'leaflet'; import { Control, type ControlPosition } from 'leaflet';
import { WithProps, defineProps, str } from '../core/props.ts'; import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import { registerWithParent } from '../core/register.ts'; // The button labels default to Leaflet's own markup, which hides the glyph
// from screen readers in favour of the title.
const PROPS = defineProps({ export class LeafletControlZoom extends WithProps(
position: str('topleft'), {
zoomInText: str('+'), position: choice<ControlPosition>('topleft'),
zoomInText: str('<span aria-hidden="true">+</span>'),
zoomInTitle: str('Zoom in'), zoomInTitle: str('Zoom in'),
zoomOutText: str('-'), zoomOutText: str('<span aria-hidden="true"></span>'),
zoomOutTitle: str('Zoom out'), zoomOutTitle: str('Zoom out'),
}); },
{ attach: 'self' },
export class LeafletControlZoom extends WithProps(HTMLElement, PROPS) { ) {
#obj?: Control.Zoom; declare readonly leafletObject?: Control.Zoom;
connectedCallback() {
this.#obj = new Control.Zoom({
position: this.getAttribute('position') as ControlPosition | undefined,
zoomInText: this.getAttribute('zoom-in-text') ?? '+',
zoomInTitle: this.getAttribute('zoom-in-title') ?? 'Zoom in',
zoomOutText: this.getAttribute('zoom-out-text') ?? '-',
zoomOutTitle: this.getAttribute('zoom-out-title') ?? 'Zoom out',
});
registerWithParent(this, this.#obj);
}
disconnectedCallback() {
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() { createLeafletObject(options: Control.ZoomOptions): Control.Zoom {
return this.#obj; return new Control.Zoom(options);
} }
} }

@ -1,76 +1,47 @@
import { DivIcon, DivIconOptions } from 'leaflet'; import { DivIcon, type DivIconOptions, type PointExpression } from 'leaflet';
import { defineProps, str } from '../core/props.ts'; import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts'; import { emitIconChanged } from '../core/register.ts';
import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({
iconSize: str(), // Like leaflet-icon, rebuilt on every change -- including changes to the
iconAnchor: str(), // markup, which is what the icon renders when `html` isn't set.
popupAnchor: str(), export class LeafletDivIcon extends WithProps(
tooltipAnchor: str(), {
className: str(), iconSize: json<PointExpression>([0, 0]),
iconAnchor: json<PointExpression>([0, 0]),
popupAnchor: json<PointExpression>([0, 0]),
tooltipAnchor: json<PointExpression>([0, 0]),
className: str('leaflet-div-icon'),
html: str(), html: str(),
bgPos: str(), bgPos: json<PointExpression>([0, 0]),
}); },
{ attach: 'none', recreate: true },
const JSON_KEYS = new Set<keyof typeof PROPS>([ ) {
'iconSize', declare readonly leafletObject?: DivIcon;
'iconAnchor',
'popupAnchor',
'tooltipAnchor',
'bgPos',
]);
export class LeafletDivIcon extends HTMLElement {
#obj?: DivIcon;
#observer?: MutationObserver; #observer?: MutationObserver;
connectedCallback() { createLeafletObject(options: DivIconOptions): DivIcon {
this.#applyIcon(); return new DivIcon(this.innerHTML ? { ...options, html: this.innerHTML } : options);
emitIconChanged(this, this.#obj); }
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => { this.#observer = new MutationObserver(() => {
this.#applyIcon(); this.recreateLeafletObject();
emitIconChanged(this, this.#obj);
});
this.#observer.observe(this, {
childList: true,
characterData: true,
subtree: true,
}); });
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
} }
disconnectedCallback() { disconnectedCallback(): void {
this.#observer?.disconnect(); this.#observer?.disconnect();
this.#observer = undefined; this.#observer = undefined;
super.disconnectedCallback();
emitIconChanged(this, null); emitIconChanged(this, null);
} }
static get observedAttributes() { leafletObjectCreated(): void {
return Object.values(PROPS).map((s) => s.attr); emitIconChanged(this, this.leafletObject);
}
attributeChangedCallback() {
this.#applyIcon();
if (!this.#obj) return;
emitIconChanged(this, this.#obj);
}
get leafletObject() {
return this.#obj;
}
#applyIcon() {
const opts: Partial<DivIconOptions> = {};
for (const [key, spec] of Object.entries(PROPS)) {
const v = this.getAttribute(spec.attr);
if (v === null) continue;
const typedKey = key as keyof typeof PROPS;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
opts[typedKey as keyof DivIconOptions] = JSON_KEYS.has(typedKey) ? JSON.parse(v) : v;
}
const content = this.innerHTML;
if (content) opts.html = content;
this.#obj = new DivIcon(opts);
} }
} }

@ -1,33 +1,12 @@
import { FeatureGroup, Layer } from 'leaflet'; import { FeatureGroup } from 'leaflet';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts'; import { WithProps } from '../core/with-props.ts';
import { buildOptions } from '../core/props.ts'; // Like leaflet-layer-group, but its children share events and a bounding box.
export class LeafletFeatureGroup extends WithProps({}) {
declare readonly leafletObject?: FeatureGroup;
export class LeafletFeatureGroup extends HTMLElement { createLeafletObject(): FeatureGroup {
#obj?: FeatureGroup; return new FeatureGroup([]);
static get observedAttributes() {
return [];
}
connectedCallback() {
this.#obj = new FeatureGroup([], buildOptions(this, {}));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip();
else this.#obj?.removeLayer(el as unknown as Layer);
}
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
} }
} }

@ -1,69 +1,26 @@
import { GeoJSON, PathOptions, Layer } from 'leaflet'; import { GeoJSON, type PathOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
import type { GeoJsonObject } from 'geojson'; import type { GeoJsonObject } from 'geojson';
import { json, positional } from '../core/props.ts';
const PROPS = defineProps({ import { pathProps } from '../core/shared-props.ts';
data: str(), import { WithProps } from '../core/with-props.ts';
stroke: str(),
color: str('#3388ff'), export class LeafletGeoJSON extends WithProps({
weight: num(3), data: positional(
opacity: num(1.0), json<GeoJsonObject | null>(null, {
lineCap: str('round'), set(obj: GeoJSON, value) {
lineJoin: str('round'), obj.clearLayers();
dashArray: str(), if (value) obj.addData(value);
dashOffset: str(), },
fill: on(), }),
fillColor: str('#3388ff'), ),
fillOpacity: num(0.2), ...pathProps,
fillRule: str('evenodd'), }) {
}); declare readonly leafletObject?: GeoJSON;
const PROP_BY_ATTR = new Map<string, string>( // Every prop but `data` is a style option, and GeoJSON takes those nested
Object.entries(PROPS).map(([name, spec]) => [spec.attr, name]), // under `style` so they apply to each feature it builds.
); createLeafletObject(options: PathOptions): GeoJSON {
return new GeoJSON(this.data, { style: options });
export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
#obj?: GeoJSON;
connectedCallback() {
const raw = this.getAttribute('data');
const data = raw ? (JSON.parse(raw) as GeoJsonObject) : null;
const styleOpts = Object.fromEntries(
Object.entries(buildOptions(this, PROPS, ['data'])).filter(([, v]) => v !== ''),
);
this.#obj = new GeoJSON(data, { style: styleOpts as PathOptions });
registerChildren(this, this.#obj);
}
disconnectedCallback() {
for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip();
else this.#obj?.removeLayer(el as unknown as Layer);
}
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'data') {
this.#obj.clearLayers();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (val) this.#obj.addData(JSON.parse(val));
} else {
const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const spec = PROPS[propName as keyof typeof PROPS];
const styleVal = spec.kind === 'bool-on' ? val !== null : val;
this.#obj.setStyle({ [propName]: styleVal } as PathOptions);
}
} }
} }

@ -1,68 +1,38 @@
import { Icon, IconOptions } from 'leaflet'; import { Icon, type IconOptions, type PointExpression } from 'leaflet';
import { defineProps, str } from '../core/props.ts'; import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts'; import { emitIconChanged } from '../core/register.ts';
import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({ const PROPS = {
iconUrl: str(), iconUrl: str(),
iconRetinaUrl: str(), iconRetinaUrl: str(),
iconSize: str(), iconSize: json<PointExpression>([0, 0]),
iconAnchor: str(), iconAnchor: json<PointExpression>([0, 0]),
popupAnchor: str(), popupAnchor: json<PointExpression>([0, 0]),
tooltipAnchor: str(), tooltipAnchor: json<PointExpression>([0, 0]),
shadowUrl: str(), shadowUrl: str(),
shadowRetinaUrl: str(), shadowRetinaUrl: str(),
shadowSize: str(), shadowSize: json<PointExpression>([0, 0]),
shadowAnchor: str(), shadowAnchor: json<PointExpression>([0, 0]),
className: str(), className: str(),
}); } as const;
const JSON_KEYS = new Set([ // An Icon has no setters, so every attribute change builds a new one and the
'iconSize', // parent marker is told to swap it in.
'iconAnchor', export class LeafletIcon extends WithProps(PROPS, { attach: 'none', recreate: true }) {
'popupAnchor', declare readonly leafletObject?: Icon;
'tooltipAnchor',
'shadowSize',
'shadowAnchor',
]);
export class LeafletIcon extends HTMLElement { createLeafletObject(options: Partial<IconOptions>): Icon | undefined {
#obj?: Icon; return options.iconUrl ? new Icon(options as IconOptions) : undefined;
connectedCallback() {
this.#applyIcon();
emitIconChanged(this, this.#obj);
}
disconnectedCallback() {
emitIconChanged(this, null);
}
static get observedAttributes() {
return Object.values(PROPS).map((s) => s.attr);
} }
attributeChangedCallback() { leafletObjectCreated(): void {
this.#applyIcon(); emitIconChanged(this, this.leafletObject);
if (!this.#obj) return;
emitIconChanged(this, this.#obj);
} }
get leafletObject() { disconnectedCallback(): void {
return this.#obj; super.disconnectedCallback();
} emitIconChanged(this, null);
#applyIcon() {
const opts: Record<string, unknown> = {};
for (const [key, spec] of Object.entries(PROPS)) {
const v = this.getAttribute(spec.attr);
if (v === null) continue;
opts[key] = JSON_KEYS.has(key) ? JSON.parse(v) : v;
}
if (opts.iconUrl) {
this.#obj = new Icon(opts as unknown as IconOptions);
} else {
this.#obj = undefined;
}
} }
} }

@ -1,58 +1,33 @@
import { ImageOverlay, LatLngBounds, LatLngExpression } from 'leaflet'; import {
import type { ImageOverlayOptions } from 'leaflet'; ImageOverlay,
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; type CrossOrigin,
import { registerChildren, unregisterChildren } from '../core/register.ts'; type ImageOverlayOptions,
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts'; type LatLngBoundsExpression,
} from 'leaflet';
const PROPS = defineProps({ import { bool, choice, json, num, positional, str } from '../core/props.ts';
url: str(), import { getBounds, urlProp } from '../core/shared-props.ts';
bounds: str(), import { WithProps } from '../core/with-props.ts';
export class LeafletImageOverlay extends WithProps({
url: urlProp,
bounds: positional(json<LatLngBoundsExpression, ImageOverlay>([], { get: getBounds })),
opacity: num(1.0), opacity: num(1.0),
alt: str(), alt: str('', {
interactive: on(), set(obj: ImageOverlay, value) {
crossOrigin: str(), const el = obj.getElement();
if (el) el.alt = value;
},
}),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
errorOverlayUrl: str(), errorOverlayUrl: str(),
zIndex: num(), zIndex: num(),
className: str(), className: str(),
}); }) {
declare readonly leafletObject?: ImageOverlay;
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletImageOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: ImageOverlay;
connectedCallback() {
const url = this.getAttribute('url') ?? '';
this.#obj = new ImageOverlay(
url,
parseBoundsAttr(this),
buildOptions(this, PROPS, ['url', 'bounds']) as ImageOverlayOptions,
);
registerChildren(this, this.#obj);
}
disconnectedCallback() { createLeafletObject(options: ImageOverlayOptions): ImageOverlay {
unregisterChildren(this); return new ImageOverlay(this.url, this.bounds, options);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'url') {
if (val) this.#obj.setUrl(val);
} else if (name === 'bounds') {
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
} else if (name === 'alt') {
const el = this.#obj.getElement();
if (el) el.alt = val ?? '';
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
} }
} }

@ -1,33 +1,13 @@
import { LayerGroup, Layer } from 'leaflet'; import { LayerGroup } from 'leaflet';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts'; import { WithProps } from '../core/with-props.ts';
import { buildOptions } from '../core/props.ts'; // A passthrough container: it has no options of its own, and children add
// themselves to it through the standard registration bubble.
export class LeafletLayerGroup extends WithProps({}) {
declare readonly leafletObject?: LayerGroup;
export class LeafletLayerGroup extends HTMLElement { createLeafletObject(): LayerGroup {
#obj?: LayerGroup; return new LayerGroup([]);
static get observedAttributes() {
return [];
}
connectedCallback() {
this.#obj = new LayerGroup([], buildOptions(this, {}));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip();
else this.#obj?.removeLayer(el as unknown as Layer);
}
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
} }
} }

@ -1,44 +1,70 @@
import { Icon, Layer, Map as LMap, MapOptions } from 'leaflet'; import { Icon, Map as LMap, type MapOptions } from 'leaflet';
import { defineProps, num, off, on, type NumProp, type PropDef } from '../core/props.ts'; import { bool, disabled, num, positional, str } from '../core/props.ts';
import { LeafletRegisterEvent } from '../core/register.ts'; import { WithProps } from '../core/with-props.ts';
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='; const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
const CSS_ATTRS = ['css-url', 'css-integrity', 'css-crossorigin'] as const; interface CssHost extends HTMLElement {
applyCss(): void;
}
// The css-* attributes describe the stylesheet in the shadow root rather than
// anything about the Leaflet map, so a change just re-links it.
function relinkCss(_map: LMap, _value: string, el: HTMLElement): void {
(el as CssHost).applyCss();
}
const PROPS = defineProps({ // The root of the component tree. Every other component bubbles a
// View state — excluded from #buildOptions, initialised via setView() // `leaflet-register` event up to here, which is where it stops.
lat: num(0, { export class LeafletMap extends WithProps(
viewState: true, {
// View state. Not constructor options -- the map is positioned with setView
// once it exists -- and written back whenever the user pans or zooms.
lat: positional(
num<LMap>(0, {
event: 'moveend', event: 'moveend',
mapGet: (m: LMap) => m.getCenter().lat, get: (map) => map.getCenter().lat,
mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter().lng], m.getZoom()), set: (map, value) => {
map.setView([value, map.getCenter().lng], map.getZoom());
},
}), }),
lng: num(0, { ),
viewState: true, lng: positional(
num<LMap>(0, {
event: 'moveend', event: 'moveend',
mapGet: (m: LMap) => m.getCenter().lng, get: (map) => map.getCenter().lng,
mapSet: (m: LMap, v: number) => m.setView([m.getCenter().lat, v], m.getZoom()), set: (map, value) => {
map.setView([map.getCenter().lat, value], map.getZoom());
},
}), }),
zoom: num(2, { ),
viewState: true, zoom: positional(
num<LMap>(2, {
event: 'zoomend', event: 'zoomend',
mapGet: (m: LMap) => m.getZoom(), get: (map) => map.getZoom(),
mapSet: (m: LMap, v: number) => m.setZoom(v), set: (map, value) => {
map.setZoom(value);
},
}), }),
),
// Live numeric options — have Leaflet setters // Numeric options Leaflet lets us change after construction.
minZoom: num(0, { minZoom: num<LMap>(0, {
mapGet: (m: LMap) => m.getMinZoom(), get: (map) => map.getMinZoom(),
mapSet: (m: LMap, v: number) => m.setMinZoom(v), set: (map, value) => {
map.setMinZoom(value);
},
}), }),
maxZoom: num(Infinity, { maxZoom: num<LMap>(Infinity, {
mapGet: (m: LMap) => m.getMaxZoom(), get: (map) => map.getMaxZoom(),
mapSet: (m: LMap, v: number) => m.setMaxZoom(v), set: (map, value) => {
map.setMaxZoom(value);
},
}), }),
// Constructor-only numeric options // Constructor-only numeric options.
zoomSnap: num(1), zoomSnap: num(1),
zoomDelta: num(1), zoomDelta: num(1),
keyboardPanDelta: num(80), keyboardPanDelta: num(80),
@ -52,208 +78,95 @@ const PROPS = defineProps({
zoomAnimationThreshold: num(4), zoomAnimationThreshold: num(4),
transform3DLimit: num(8388608), transform3DLimit: num(8388608),
// Boolean defaults-true → disable-* attribute; handler-based options have live mapSet // Options Leaflet defaults to true, turned off with disable-* attributes.
scrollWheelZoom: off((m: LMap, v: boolean) => // The interaction handlers can be toggled after construction.
v ? m.scrollWheelZoom.enable() : m.scrollWheelZoom.disable(), scrollWheelZoom: disabled<LMap>({
), set: (map, on) => (on ? map.scrollWheelZoom.enable() : map.scrollWheelZoom.disable()),
dragging: off((m: LMap, v: boolean) => (v ? m.dragging.enable() : m.dragging.disable())), }),
touchZoom: off((m: LMap, v: boolean) => (v ? m.touchZoom.enable() : m.touchZoom.disable())), dragging: disabled<LMap>({
doubleClickZoom: off((m: LMap, v: boolean) => set: (map, on) => (on ? map.dragging.enable() : map.dragging.disable()),
v ? m.doubleClickZoom.enable() : m.doubleClickZoom.disable(), }),
), touchZoom: disabled<LMap>({
boxZoom: off((m: LMap, v: boolean) => (v ? m.boxZoom.enable() : m.boxZoom.disable())), set: (map, on) => (on ? map.touchZoom.enable() : map.touchZoom.disable()),
keyboard: off((m: LMap, v: boolean) => (v ? m.keyboard.enable() : m.keyboard.disable())), }),
closePopupOnClick: off(), doubleClickZoom: disabled<LMap>({
trackResize: off(), set: (map, on) => (on ? map.doubleClickZoom.enable() : map.doubleClickZoom.disable()),
zoomControl: off(), }),
attributionControl: off(), boxZoom: disabled<LMap>({
inertia: off(), set: (map, on) => (on ? map.boxZoom.enable() : map.boxZoom.disable()),
zoomAnimation: off(), }),
fadeAnimation: off(), keyboard: disabled<LMap>({
markerZoomAnimation: off(), set: (map, on) => (on ? map.keyboard.enable() : map.keyboard.disable()),
bounceAtZoomLimits: off(), }),
tapHold: off(), closePopupOnClick: disabled(),
trackResize: disabled(),
// Boolean defaults-false → normal attribute zoomControl: disabled(),
preferCanvas: on(), attributionControl: disabled(),
worldCopyJump: on(), inertia: disabled(),
}); zoomAnimation: disabled(),
fadeAnimation: disabled(),
type PropName = keyof typeof PROPS; markerZoomAnimation: disabled(),
bounceAtZoomLimits: disabled(),
const ATTR_TO_PROP = new Map<string, PropName>( tapHold: disabled(),
(Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]),
); // Options Leaflet defaults to false.
preferCanvas: bool(),
// Derive property types directly from the PROPS table so adding a prop to the worldCopyJump: bool(),
// table automatically makes it part of the LeafletMap instance type.
type PropTypes = { // Not Leaflet options at all -- see relinkCss above.
[K in keyof typeof PROPS]: (typeof PROPS)[K] extends { kind: 'num' } ? number : boolean; cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
}; cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
cssCrossorigin: positional(str('', { set: relinkCss })),
const TypedBase = HTMLElement as unknown as new () => HTMLElement & PropTypes; },
{ attach: 'none' },
) {
declare readonly leafletObject?: LMap;
export class LeafletMap extends TypedBase { #container?: HTMLDivElement;
#map?: LMap;
#container!: HTMLDivElement;
#cssLink?: HTMLLinkElement; #cssLink?: HTMLLinkElement;
#syncing = false;
#mapEventHandlers = new Map<string, () => void>();
#resizeObserver?: ResizeObserver; #resizeObserver?: ResizeObserver;
static get observedAttributes(): string[] { createLeafletObject(options: MapOptions): LMap {
return [...Object.values(PROPS).map((s) => s.attr), ...CSS_ATTRS]; const map = new LMap(this.#container ?? this.#buildShadowRoot(), options);
} // getCenter() and getZoom() throw until the view is set. Reading this.lat,
// this.lng and this.zoom here is safe for the same reason: the object
// Generates a getter/setter on the prototype for every entry in PROPS. // doesn't exist yet, so the getters fall back to the attributes.
// The functions are defined inside the class body so they can access private fields. map.setView([this.lat, this.lng], this.zoom);
static { return map;
for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) {
if (spec.kind === 'num') {
Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) {
const val = spec.mapGet && this.#map ? spec.mapGet(this.#map) : undefined;
return val ?? +(this.getAttribute(spec.attr) ?? spec.default);
},
set(this: LeafletMap, v: number) {
if (spec.event) {
this.#syncAttr(spec.attr, `${v}`);
} else {
this.setAttribute(spec.attr, `${v}`);
}
},
configurable: true,
enumerable: true,
});
} else if (spec.kind === 'bool-off') {
Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) {
return !this.hasAttribute(spec.attr);
},
set(this: LeafletMap, v: boolean) {
this.toggleAttribute(spec.attr, !v);
},
configurable: true,
enumerable: true,
});
} else {
Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) {
return this.hasAttribute(spec.attr);
},
set(this: LeafletMap, v: boolean) {
this.toggleAttribute(spec.attr, v);
},
configurable: true,
enumerable: true,
});
}
}
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.#container = document.createElement('div');
this.#container.style.width = '100%';
this.#container.style.height = '100%';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(this.#container);
const style = document.createElement('style');
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(style);
} }
this.#applyCss();
// Read view-state from attributes directly — the getters call getCenter()/getZoom() connectedCallback(): void {
// which throw if invoked before setView(), so we can't use them here. this.#buildShadowRoot();
const lat = +(this.getAttribute('lat') ?? 0); this.applyCss();
const lng = +(this.getAttribute('lng') ?? 0); super.connectedCallback();
const zoom = +(this.getAttribute('zoom') ?? 2);
// Assign #map only after setView so the getters' `this.#map` guard is this.#resizeObserver = new ResizeObserver(() => this.leafletObject?.invalidateSize());
// equivalent to "map is ready" — getCenter()/getZoom() throw before setView.
const map = new LMap(this.#container, this.#buildOptions());
map.setView([lat, lng], zoom);
this.#map = map;
this.#resizeObserver = new ResizeObserver(() => this.#map?.invalidateSize());
this.#resizeObserver.observe(this); this.#resizeObserver.observe(this);
// Register one handler per unique map event, updating all props that share it this.addEventListener('leaflet-register', this.#onRegister);
const groups = new Map<string, NumProp[]>();
for (const spec of Object.values(PROPS) as PropDef[]) {
if (spec.kind === 'num' && spec.event && spec.mapGet) {
const g = groups.get(spec.event) ?? [];
g.push(spec);
groups.set(spec.event, g);
}
}
for (const [event, specs] of groups) {
const handler = () => {
for (const s of specs) {
if (this.#map && s.mapGet) {
const v = s.mapGet(this.#map);
if (v !== undefined) this.#syncAttr(s.attr, `${v}`);
}
}
};
this.#mapEventHandlers.set(event, handler);
this.#map.on(event, handler);
}
this.addEventListener(
'leaflet-register',
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
);
this.addEventListener('leaflet-add-layer', this.#onAddLayer); this.addEventListener('leaflet-add-layer', this.#onAddLayer);
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer); this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
} }
disconnectedCallback() { disconnectedCallback(): void {
this.#resizeObserver?.disconnect(); this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined; this.#resizeObserver = undefined;
this.removeEventListener( this.removeEventListener('leaflet-register', this.#onRegister);
'leaflet-register',
this.#handleLeafletRegister as EventListenerOrEventListenerObject,
);
this.removeEventListener('leaflet-add-layer', this.#onAddLayer); this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer); this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
if (!this.#map) return; super.disconnectedCallback();
for (const [event, handler] of this.#mapEventHandlers) {
this.#map.off(event, handler);
}
this.#mapEventHandlers.clear();
this.#map.remove();
this.#map = undefined;
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
if (oldValue === newValue || !this.#map || this.#syncing) return;
if (CSS_ATTRS.includes(name as (typeof CSS_ATTRS)[number])) {
this.#applyCss();
return;
}
const propName = ATTR_TO_PROP.get(name);
if (!propName) return;
const spec = PROPS[propName] as PropDef;
if (spec.kind === 'num') {
if (spec.mapSet && newValue !== null) spec.mapSet(this.#map, +newValue);
} else if (spec.kind === 'bool-off') {
spec.mapSet?.(this.#map, newValue === null);
}
// skip spec.kind === 'bool-on': constructor-only, no live update
} }
#applyCss() { // Replaces the <link> in the shadow root, and derives the marker icon path
const sr = this.shadowRoot; // from the same URL. Called on connect and whenever a css-* attribute
if (!sr) return; // changes. Absence and emptiness mean different things here, so this reads
// the attributes rather than the properties.
applyCss(): void {
const root = this.shadowRoot;
if (!root) return;
if (this.#cssLink) { this.#cssLink?.remove();
this.#cssLink.remove();
this.#cssLink = undefined; this.#cssLink = undefined;
}
const customUrl = this.hasAttribute('css-url'); const customUrl = this.hasAttribute('css-url');
const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL; const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
@ -267,8 +180,7 @@ export class LeafletMap extends TypedBase {
let crossorigin: string | undefined; let crossorigin: string | undefined;
if (this.hasAttribute('css-crossorigin')) { if (this.hasAttribute('css-crossorigin')) {
const val = this.getAttribute('css-crossorigin'); crossorigin = this.getAttribute('css-crossorigin') ?? undefined;
crossorigin = val ?? undefined;
} else if (integrity) { } else if (integrity) {
crossorigin = 'anonymous'; crossorigin = 'anonymous';
} }
@ -278,54 +190,42 @@ export class LeafletMap extends TypedBase {
link.href = url ?? ''; link.href = url ?? '';
if (integrity) link.setAttribute('integrity', integrity); if (integrity) link.setAttribute('integrity', integrity);
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin); if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
sr.appendChild(link); root.append(link);
this.#cssLink = link; this.#cssLink = link;
// Derive marker icon path from the CSS URL (replaces leaflet.css → images/) Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
Icon.Default.imagePath = url?.replace(/\/[^/]+$/, '/images/');
} }
#syncAttr(name: string, value: string) { #buildShadowRoot(): HTMLDivElement {
if (this.#syncing || this.getAttribute(name) === value) return; if (this.#container) return this.#container;
this.#syncing = true;
this.setAttribute(name, value);
this.#syncing = false;
}
#buildOptions(): MapOptions { const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
const o: Record<string, unknown> = {}; const container = document.createElement('div');
for (const [propName, spec] of Object.entries(PROPS) as [PropName, PropDef][]) { container.style.width = '100%';
if (spec.kind === 'num') { container.style.height = '100%';
if (spec.viewState) continue; root.append(container);
const v = this.getAttribute(spec.attr);
if (v !== null) o[propName] = +v; const style = document.createElement('style');
} else if (spec.kind === 'bool-off') { style.textContent = ':host { display: block; width: 100%; height: 400px; }';
if (this.hasAttribute(spec.attr)) o[propName] = false; root.append(style);
} else {
if (this.hasAttribute(spec.attr)) o[propName] = true; this.#container = container;
} return container;
}
return o;
} }
#handleLeafletRegister = (e: LeafletRegisterEvent) => { #onRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation(); e.stopPropagation();
if (this.#map) { const map = this.leafletObject;
e.detail.leafletObject.addTo(this.#map); if (map) e.detail.leafletObject.addTo(map);
}
}; };
#onAddLayer = (e: Event) => { #onAddLayer = (e: LeafletLayerEvent) => {
this.#map?.addLayer((e as CustomEvent<{ layer: Layer }>).detail.layer); this.leafletObject?.addLayer(e.detail.layer);
}; };
#onRemoveLayer = (e: Event) => { #onRemoveLayer = (e: LeafletLayerEvent) => {
this.#map?.removeLayer((e as CustomEvent<{ layer: Layer }>).detail.layer); this.leafletObject?.removeLayer(e.detail.layer);
}; };
get leafletObject() {
return this.#map;
}
} }
customElements.define('leaflet-map', LeafletMap); customElements.define('leaflet-map', LeafletMap);

@ -1,79 +1,54 @@
import { Icon, Marker } from 'leaflet'; import { Icon, Marker, type MarkerOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; import { bool, num, str } from '../core/props.ts';
import { LeafletIconChangedEvent, registerChildren, unregisterChildren } from '../core/register.ts'; import { latLngProps } from '../core/shared-props.ts';
import { buildAttrMap, numAttr, setLayerAttr } from '../core/attributes.ts'; import { WithProps } from '../core/with-props.ts';
import type { LeafletIconChangedEvent } from '../core/register.ts';
const PROPS = defineProps({
lat: num(), const PROPS = {
lng: num(), ...latLngProps,
title: str(), // title and alt end up on the <img> Leaflet renders, so live updates go there.
alt: str(), title: str('', {
draggable: on(), set: (obj: Marker, value) => {
const el = obj.getElement();
if (el) el.title = value;
},
}),
alt: str('', {
set: (obj: Marker, value) => {
const el = obj.getElement() as HTMLImageElement | undefined;
if (el) el.alt = value;
},
}),
draggable: bool(false, {
set(obj: Marker, value) {
if (value) obj.dragging?.enable();
else obj.dragging?.disable();
},
}),
opacity: num(1.0), opacity: num(1.0),
zIndexOffset: num(), zIndexOffset: num(),
}); } as const;
const PROP_BY_ATTR = buildAttrMap(PROPS); export class LeafletMarker extends WithProps(PROPS) {
declare readonly leafletObject?: Marker;
export class LeafletMarker extends WithProps(HTMLElement, PROPS) { createLeafletObject(options: MarkerOptions): Marker {
#obj?: Marker; return new Marker([this.lat, this.lng], options);
#syncing = false; }
connectedCallback() { connectedCallback(): void {
this.#obj = new Marker( super.connectedCallback();
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('dragend move', this.#onChange, this);
registerChildren(this, this.#obj);
this.addEventListener('icon-changed', this.#onIconChanged); this.addEventListener('icon-changed', this.#onIconChanged);
} }
disconnectedCallback() { disconnectedCallback(): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('dragend move', this.#onChange, this);
this.removeEventListener('icon-changed', this.#onIconChanged); this.removeEventListener('icon-changed', this.#onIconChanged);
unregisterChildren(this); super.disconnectedCallback();
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} else if (name === 'draggable') {
if (val !== null) this.#obj.dragging?.enable();
else this.#obj.dragging?.disable();
} else if (name === 'title') {
const el = this.#obj.getElement();
if (el) (el as HTMLImageElement).title = val ?? '';
} else if (name === 'alt') {
const el = this.#obj.getElement();
if (el) (el as HTMLImageElement).alt = val ?? '';
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
}
#onChange() {
if (!this.#obj || this.#syncing) return;
this.#syncing = true;
const pos = this.#obj.getLatLng();
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
} }
// A <leaflet-icon> or <leaflet-div-icon> child announces itself this way.
#onIconChanged = (e: LeafletIconChangedEvent) => { #onIconChanged = (e: LeafletIconChangedEvent) => {
if (!this.#obj) return; this.leafletObject?.setIcon(e.detail.icon ?? new Icon.Default());
if (e.detail.icon) this.#obj.setIcon(e.detail.icon);
else this.#obj.setIcon(new Icon.Default());
}; };
} }

@ -1,59 +1,39 @@
import { Polygon } from 'leaflet'; import { Polygon, type PolylineOptions } from 'leaflet';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { pathProps } from '../core/shared-props.ts';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; import { WithProps } from '../core/with-props.ts';
import { isPathStyleAttr, updatePathStyle } from '../core/attributes.ts';
import type { LeafletLine } from './leaflet-line.ts'; import type { LeafletLine } from './leaflet-line.ts';
const PROPS = defineProps({ export class LeafletPolygon extends WithProps({ ...pathProps }) {
color: str('#3388ff'), declare readonly leafletObject?: Polygon;
weight: num(3),
opacity: num(1.0),
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
});
export class LeafletPolygon extends WithProps(HTMLElement, PROPS) {
#obj?: Polygon;
#observer?: MutationObserver; #observer?: MutationObserver;
connectedCallback() { createLeafletObject(options: PolylineOptions): Polygon {
this.#obj = new Polygon(this.#getCoords(), buildOptions(this, PROPS)); return new Polygon(this.#coords(), options);
registerChildren(this, this.#obj); }
// Vertices come from <leaflet-line> children rather than an attribute, so we
// re-read them whenever one is added, removed or moved.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('line-updated', this.#syncCoords); this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => { this.#observer = new MutationObserver(this.#syncCoords);
this.#syncCoords();
});
this.#observer.observe(this, { childList: true }); this.#observer.observe(this, { childList: true });
} }
disconnectedCallback() { disconnectedCallback(): void {
this.#observer?.disconnect(); this.#observer?.disconnect();
this.#observer = undefined; this.#observer = undefined;
this.removeEventListener('line-updated', this.#syncCoords); this.removeEventListener('line-updated', this.#syncCoords);
unregisterChildren(this); super.disconnectedCallback();
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
} }
#syncCoords = () => { #syncCoords = () => {
this.#obj?.setLatLngs(this.#getCoords()); this.leafletObject?.setLatLngs(this.#coords());
}; };
#getCoords(): [number, number][] { #coords(): [number, number][] {
const lines: LeafletLine[] = Array.from(this.querySelectorAll('leaflet-line')); const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
return lines.map((line) => line.latlng); return lines.map((line) => line.latlng);
} }
} }

@ -1,58 +1,45 @@
import { Polyline } from 'leaflet'; import { Polyline, type PolylineOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; import { bool, num } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { pathProps, style } from '../core/shared-props.ts';
import { isPathStyleAttr, updatePathStyle } from '../core/attributes.ts'; import { WithProps } from '../core/with-props.ts';
import type { LeafletLine } from './leaflet-line.ts'; import type { LeafletLine } from './leaflet-line.ts';
const PROPS = defineProps({ export class LeafletPolyline extends WithProps({
color: str('#3388ff'), ...pathProps,
weight: num(3), // Unlike closed shapes, a polyline is unfilled by default.
opacity: num(1.0), fill: bool(false, { set: style('fill') }),
fill: on(), smoothFactor: num(1.0),
fillColor: str('#3388ff'), noClip: bool(),
fillOpacity: num(0.2), }) {
}); declare readonly leafletObject?: Polyline;
export class LeafletPolyline extends WithProps(HTMLElement, PROPS) {
#obj?: Polyline;
#observer?: MutationObserver; #observer?: MutationObserver;
connectedCallback() { createLeafletObject(options: PolylineOptions): Polyline {
this.#obj = new Polyline(this.#getCoords(), buildOptions(this, PROPS)); return new Polyline(this.#coords(), options);
registerChildren(this, this.#obj); }
// Vertices come from <leaflet-line> children rather than an attribute, so we
// re-read them whenever one is added, removed or moved.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('line-updated', this.#syncCoords); this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => { this.#observer = new MutationObserver(this.#syncCoords);
this.#syncCoords();
});
this.#observer.observe(this, { childList: true }); this.#observer.observe(this, { childList: true });
} }
disconnectedCallback() { disconnectedCallback(): void {
this.#observer?.disconnect(); this.#observer?.disconnect();
this.#observer = undefined; this.#observer = undefined;
this.removeEventListener('line-updated', this.#syncCoords); this.removeEventListener('line-updated', this.#syncCoords);
unregisterChildren(this); super.disconnectedCallback();
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
} }
#syncCoords = () => { #syncCoords = () => {
this.#obj?.setLatLngs(this.#getCoords()); this.leafletObject?.setLatLngs(this.#coords());
}; };
#getCoords(): [number, number][] { #coords(): [number, number][] {
const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line')); const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
return lines.map((line) => line.latlng); return lines.map((line) => line.latlng);
} }

@ -1,74 +1,46 @@
import { Popup } from 'leaflet'; import { Popup, type PopupOptions } from 'leaflet';
import { WithProps, defineProps, num, on, buildOptions } from '../core/props.ts'; import { bool, num } from '../core/props.ts';
import { numAttr } from '../core/attributes.ts'; import { latLngProps } from '../core/shared-props.ts';
import { registerWithParent } from '../core/register.ts'; import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({ export class LeafletPopup extends WithProps(
lat: num(), {
lng: num(), ...latLngProps,
maxWidth: num(300), maxWidth: num(300),
minWidth: num(50), minWidth: num(50),
maxHeight: num(), maxHeight: num(),
autoPan: on(), autoPan: bool(true),
closeButton: on(), closeButton: bool(true),
autoClose: on(), autoClose: bool(true),
}); },
{ attach: 'self' },
) {
declare readonly leafletObject?: Popup;
export class LeafletPopup extends WithProps(HTMLElement, PROPS) {
#obj?: Popup;
#observer?: MutationObserver; #observer?: MutationObserver;
#syncing = false;
connectedCallback() { // Content is the element's markup, not an attribute. A popup only carries a
this.#obj = new Popup({ // position of its own when it isn't bound to a parent layer.
...buildOptions(this, PROPS, ['lat', 'lng']), createLeafletObject(options: PopupOptions): Popup {
content: this.innerHTML, const popup = new Popup({ ...options, content: this.innerHTML });
});
if (this.hasAttribute('lat') && this.hasAttribute('lng')) { if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]); popup.setLatLng([this.lat, this.lng]);
}
return popup;
} }
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onChange, this);
registerWithParent(this, this.#obj);
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => { this.#observer = new MutationObserver(() => {
this.#obj?.setContent(this.innerHTML); this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, {
childList: true,
characterData: true,
subtree: true,
}); });
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
} }
disconnectedCallback() { disconnectedCallback(): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onChange, this);
this.#observer?.disconnect(); this.#observer?.disconnect();
this.#observer = undefined; this.#observer = undefined;
this.#obj?.remove(); super.disconnectedCallback();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
}
}
#onChange() {
if (!this.#obj || this.#syncing) return;
const pos = this.#obj.getLatLng();
if (!pos) return;
this.#syncing = true;
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
} }
} }

@ -1,43 +1,16 @@
import { Rectangle } from 'leaflet'; import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; import { json, positional } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { pathProps, getBounds } from '../core/shared-props.ts';
import { isPathStyleAttr, parseBoundsAttr, updatePathStyle } from '../core/attributes.ts'; import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({ export class LeafletRectangle extends WithProps({
bounds: str(), bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
color: str('#3388ff'), ...pathProps,
weight: num(3), }) {
opacity: num(1.0), declare readonly leafletObject?: Rectangle;
fill: on(),
fillColor: str('#3388ff'),
fillOpacity: num(0.2),
});
export class LeafletRectangle extends WithProps(HTMLElement, PROPS) { createLeafletObject(options: PolylineOptions): Rectangle {
#obj?: Rectangle; return new Rectangle(this.bounds, options);
connectedCallback() {
this.#obj = new Rectangle(parseBoundsAttr(this), buildOptions(this, PROPS, ['bounds']));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'bounds') {
this.#obj.setBounds(parseBoundsAttr(this));
} else if (isPathStyleAttr(name)) {
updatePathStyle(this.#obj, name, val);
}
} }
} }

@ -1,52 +1,27 @@
import { SVGOverlay, LatLngBounds, LatLngExpression } from 'leaflet'; import {
import type { ImageOverlayOptions } from 'leaflet'; SVGOverlay,
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; type CrossOrigin,
import { registerChildren, unregisterChildren } from '../core/register.ts'; type ImageOverlayOptions,
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts'; type LatLngBoundsExpression,
} from 'leaflet';
const PROPS = defineProps({ import { bool, choice, json, num, positional, str } from '../core/props.ts';
bounds: str(), import { getBounds } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletSVGOverlay extends WithProps({
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
opacity: num(1.0), opacity: num(1.0),
interactive: on(), interactive: bool(),
crossOrigin: str(), crossOrigin: choice<CrossOrigin>(''),
zIndex: num(), zIndex: num(),
className: str(), className: str(),
}); }) {
declare readonly leafletObject?: SVGOverlay;
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletSVGOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: SVGOverlay;
connectedCallback() {
const svg = this.querySelector('svg');
const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined;
this.#obj = new SVGOverlay(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
svg ?? dummy!,
parseBoundsAttr(this),
buildOptions(this, PROPS, ['bounds']) as ImageOverlayOptions,
);
registerChildren(this, this.#obj);
}
disconnectedCallback() { createLeafletObject(options: ImageOverlayOptions): SVGOverlay {
unregisterChildren(this); const svg =
this.#obj?.remove(); this.querySelector('svg') ?? document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.#obj = undefined; return new SVGOverlay(svg, this.bounds, options);
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'bounds') {
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
} }
} }

@ -1,54 +1,51 @@
import { TileLayer } from 'leaflet'; import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet';
import { WithProps, defineProps, str, on, buildOptions } from '../core/props.ts'; import { bool, str, type PropDef } from '../core/props.ts';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import { buildAttrMap, parseAttributeValue, setLayerAttr } from '../core/attributes.ts'; import { WithProps } from '../core/with-props.ts';
const PROPS = defineProps({ // WMS request parameters have no individual setters -- they're merged into the
url: str(), // query string through setParams().
layers: str(), function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void {
styles: str(), return (obj, value) => {
format: str('image/jpeg'), obj.setParams({ [key]: value } as unknown as WMSParams);
transparent: on(), };
version: str('1.1.1'), }
uppercase: on(),
});
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletTileLayerWMS extends WithProps(HTMLElement, PROPS) {
#obj?: TileLayer.WMS;
connectedCallback() {
const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer.WMS(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) { // `crs` takes a CRS instance, not a primitive, so it's looked up by name from
if (!this.#obj) return; // Leaflet's built-in set rather than decoded like a plain value. Constructor-
if (name === 'url') { // only, like the rest of tileLayerProps -- Leaflet has no setter for it.
if (val) this.#obj.setUrl(val); const NAMED_CRS = {
} else { EPSG3857: CRS.EPSG3857,
const propName = PROP_BY_ATTR.get(name); EPSG4326: CRS.EPSG4326,
if (!propName) return; EPSG3395: CRS.EPSG3395,
const spec = PROPS[propName]; Simple: CRS.Simple,
if (!setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val)) { };
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val); type CrsName = keyof typeof NAMED_CRS;
(this.#obj.setParams as unknown as (params: Record<string, unknown>) => void)({
[propName]: value, const crsProp: PropDef<CRS> = {
}); default: CRS.EPSG3857,
} decode: (raw) => NAMED_CRS[raw as CrsName] ?? CRS.EPSG3857,
} encode: (value) =>
value === CRS.EPSG3857
? null
: ((Object.keys(NAMED_CRS) as CrsName[]).find((name) => NAMED_CRS[name] === value) ?? null),
};
export class LeafletTileLayerWMS extends WithProps({
url: urlProp,
...tileLayerProps,
layers: str('', { set: param('layers') }),
styles: str('', { set: param('styles') }),
format: str('image/jpeg', { set: param('format') }),
transparent: bool(false, { set: param('transparent') }),
version: str('1.1.1', { set: param('version') }),
uppercase: bool(),
crs: crsProp,
}) {
declare readonly leafletObject?: TileLayer.WMS;
createLeafletObject(options: WMSOptions): TileLayer.WMS {
return new TileLayer.WMS(this.url, options);
} }
} }

@ -1,45 +1,15 @@
import { TileLayer } from 'leaflet'; import { TileLayer, type TileLayerOptions } from 'leaflet';
import { registerChildren, unregisterChildren } from '../core/register.ts'; import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import { WithProps, defineProps, num, str, buildOptions } from '../core/props.ts'; import { WithProps } from '../core/with-props.ts';
import { buildAttrMap, setLayerAttr } from '../core/attributes.ts';
export class LeafletTileLayer extends WithProps({
const PROPS = defineProps({ url: urlProp,
url: str(), ...tileLayerProps,
attribution: str(), }) {
minZoom: num(), declare readonly leafletObject?: TileLayer;
maxZoom: num(18),
opacity: num(1.0), createLeafletObject(options: TileLayerOptions): TileLayer {
zIndex: num(), return new TileLayer(this.url, options);
});
const PROP_BY_ATTR = buildAttrMap(PROPS);
export class LeafletTileLayer extends WithProps(HTMLElement, PROPS) {
#obj?: TileLayer;
connectedCallback() {
const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'url') {
if (val) this.#obj.setUrl(val);
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
} }
} }

@ -1,75 +1,46 @@
import { Tooltip } from 'leaflet'; import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet';
import type { TooltipOptions } from 'leaflet'; import { bool, choice, json, num, str } from '../core/props.ts';
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; import { latLngProps } from '../core/shared-props.ts';
import { numAttr } from '../core/attributes.ts'; import { WithProps } from '../core/with-props.ts';
import { registerWithParent } from '../core/register.ts';
export class LeafletTooltip extends WithProps(
const PROPS = defineProps({ {
lat: num(), ...latLngProps,
lng: num(),
pane: str(), pane: str(),
offset: str(), offset: json<PointExpression>([0, 0]),
direction: str('auto'), direction: choice<Direction>('auto'),
permanent: on(), permanent: bool(),
sticky: on(), sticky: bool(),
opacity: num(1.0), opacity: num(1.0),
}); },
{ attach: 'self' },
) {
declare readonly leafletObject?: Tooltip;
export class LeafletTooltip extends WithProps(HTMLElement, PROPS) {
#obj?: Tooltip;
#observer?: MutationObserver; #observer?: MutationObserver;
#syncing = false;
connectedCallback() { // Content is the element's markup, not an attribute. A tooltip only carries a
this.#obj = new Tooltip({ // position of its own when it isn't bound to a parent layer.
...buildOptions(this, PROPS, ['lat', 'lng']), createLeafletObject(options: TooltipOptions): Tooltip {
content: this.innerHTML, const tooltip = new Tooltip({ ...options, content: this.innerHTML });
} as unknown as TooltipOptions);
if (this.hasAttribute('lat') && this.hasAttribute('lng')) { if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]); tooltip.setLatLng([this.lat, this.lng]);
}
return tooltip;
} }
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onChange, this);
registerWithParent(this, this.#obj);
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => { this.#observer = new MutationObserver(() => {
this.#obj?.setContent(this.innerHTML); this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, {
childList: true,
characterData: true,
subtree: true,
}); });
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
} }
disconnectedCallback() { disconnectedCallback(): void {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onChange, this);
this.#observer?.disconnect(); this.#observer?.disconnect();
this.#observer = undefined; this.#observer = undefined;
this.#obj?.remove(); super.disconnectedCallback();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string) {
if (!this.#obj || this.#syncing) return;
if (name === 'lat' || name === 'lng') {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
}
}
#onChange() {
if (!this.#obj || this.#syncing) return;
const pos = this.#obj.getLatLng();
if (!pos) return;
this.#syncing = true;
this.setAttribute('lat', `${pos.lat}`);
this.setAttribute('lng', `${pos.lng}`);
this.#syncing = false;
} }
} }

@ -1,73 +1,48 @@
import { VideoOverlay, LatLngBounds, LatLngExpression } from 'leaflet'; import {
import type { VideoOverlayOptions } from 'leaflet'; VideoOverlay,
import { WithProps, defineProps, num, str, on, buildOptions } from '../core/props.ts'; type CrossOrigin,
import { registerChildren, unregisterChildren } from '../core/register.ts'; type LatLngBoundsExpression,
import { buildAttrMap, parseBoundsAttr, setLayerAttr } from '../core/attributes.ts'; type VideoOverlayOptions,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds, urlProp } from '../core/shared-props.ts';
import { WithProps } from '../core/with-props.ts';
// Playback options live on the <video> element Leaflet builds, so live updates
// go there rather than through a Leaflet setter.
function media(
key: 'loop' | 'autoplay' | 'muted' | 'playsInline',
): (obj: VideoOverlay, value: boolean) => void {
return (obj, value) => {
const el = obj.getElement();
if (el) el[key] = value;
};
}
const PROPS = defineProps({ export class LeafletVideoOverlay extends WithProps({
url: str(), url: urlProp,
bounds: str(), bounds: positional(json<LatLngBoundsExpression, VideoOverlay>([], { get: getBounds })),
opacity: num(1.0), opacity: num(1.0),
alt: str(), alt: str(),
interactive: on(), interactive: bool(),
crossOrigin: str(), crossOrigin: choice<CrossOrigin>(''),
loop: on(), loop: bool(false, { set: media('loop') }),
autoplay: on(), autoplay: bool(false, { set: media('autoplay') }),
muted: on(), muted: bool(false, { set: media('muted') }),
playsInline: on('playsinline'), playsInline: bool(false, { attribute: 'playsinline', set: media('playsInline') }),
}); zIndex: num(),
className: str(),
const PROP_BY_ATTR = buildAttrMap(PROPS); keepAspectRatio: bool(true),
errorOverlayUrl: str(),
export class LeafletVideoOverlay extends WithProps(HTMLElement, PROPS) { }) {
#obj?: VideoOverlay; declare readonly leafletObject?: VideoOverlay;
connectedCallback() { createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
const url = this.getAttribute('url') ?? ''; return new VideoOverlay(this.url, this.bounds, options);
this.#obj = new VideoOverlay(
url,
parseBoundsAttr(this),
buildOptions(this, PROPS, ['url', 'bounds']) as VideoOverlayOptions,
);
registerChildren(this, this.#obj);
}
disconnectedCallback() {
unregisterChildren(this);
this.#obj?.remove();
this.#obj = undefined;
}
get leafletObject() {
return this.#obj;
}
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
if (!this.#obj) return;
if (name === 'url') {
if (val) this.#obj.setUrl(val);
} else if (name === 'bounds') {
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
} else if (
name === 'loop' ||
name === 'autoplay' ||
name === 'muted' ||
name === 'playsinline'
) {
const el = this.#obj.getElement();
if (el) {
if (name === 'loop') el.loop = val !== null;
else if (name === 'autoplay') el.autoplay = val !== null;
else if (name === 'muted') el.muted = val !== null;
else el.playsInline = val !== null;
}
} else {
setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
}
} }
getElement(): HTMLVideoElement | undefined { getElement(): HTMLVideoElement | undefined {
return this.#obj?.getElement(); return this.leafletObject?.getElement();
} }
} }

@ -1,97 +0,0 @@
import type { LatLngBoundsExpression } from 'leaflet';
import { Path } from 'leaflet';
import type { PropDef } from './props.ts';
// Reads a numeric attribute from an element, falling back to the default
// value declared in the PROPS table. Handles the common pattern of
// reading lat/lng/radius/opacity with a guaranteed number return.
export function numAttr(el: HTMLElement, props: Record<string, PropDef>, name: string): number {
const v = el.getAttribute(name);
if (v !== null) return +v;
return (props[name] as { default: number }).default;
}
// Reads the `bounds` attribute from an element and parses it as JSON
// into a LatLngBoundsExpression. Returns an empty array when no
// attribute is set. Shared by rectangle, image-overlay, video-overlay,
// and svg-overlay, all of which accept a `bounds` attribute.
export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression {
const raw = el.getAttribute('bounds');
return raw ? (JSON.parse(raw) as LatLngBoundsExpression) : [];
}
// Builds a reverse lookup map from HTML attribute name → PROP key.
// For example, { fillColor: { attr: 'fill-color' } } becomes
// { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the
// Leaflet setter name when an attribute changes at runtime.
export function buildAttrMap<T extends Record<string, PropDef>>(props: T): Map<string, keyof T> {
return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name]));
}
// Generic dispatcher for runtime attribute changes on Leaflet objects.
// Given the attribute name, it looks up the PROP key, constructs the
// matching Leaflet setter name (e.g. "opacity" → "setOpacity"), and
// calls it with the parsed value. Returns true if a setter was found
// and called, false otherwise (caller can fall back, as WMS does with
// setParams). Handles bool-on props by passing the presence/absence of
// the attribute rather than its string value.
export function setLayerAttr(
obj: object,
props: Record<string, PropDef>,
attrMap: Map<string, string>,
name: string,
val: string | null,
): boolean {
const propName = attrMap.get(name);
if (!propName) return false;
const spec = props[propName];
const setter = `set${propName.charAt(0).toUpperCase()}${propName.slice(1)}`;
const fn = (obj as Record<string, unknown>)[setter];
if (typeof fn === 'function') {
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
(fn as (v: unknown) => void)(value);
return true;
}
return false;
}
// Parses an HTML attribute string into a typed JS value:
// null → null, "true"/"false" → boolean, numeric strings → number,
// valid JSON → parsed object/array, otherwise the raw string.
// This is the bridge between HTML attribute strings and Leaflet options.
export function parseAttributeValue(value: string | null): unknown {
if (value === null) return null;
if (value === 'true') return true;
if (value === 'false') return false;
const num = +value;
if (!isNaN(num) && value !== '') return num;
try {
return JSON.parse(value);
} catch {
return value;
}
}
const PATH_STYLE_ATTRS = new Set([
'color',
'weight',
'opacity',
'fill',
'fill-color',
'fill-opacity',
'stroke',
'dash-array',
'dash-offset',
'line-cap',
'line-join',
'fill-rule',
]);
export function isPathStyleAttr(name: string): boolean {
return PATH_STYLE_ATTRS.has(name);
}
export function updatePathStyle(obj: Path, name: string, value: string | null) {
const key = name.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
obj.setStyle({ [key]: parseAttributeValue(value) });
}

@ -1,183 +1,127 @@
export interface NumProp<T = unknown> { // Every element property is described by a PropDef: how its value encodes to
kind: 'num'; // and decodes from an HTML attribute, and how it is pushed into (and read back
attr: string; // out of) the Leaflet object. `WithProps` in with-props.ts is the only consumer
default: number; // -- components just declare a table of these and never touch the plumbing.
mapGet?(m: T): number | undefined; export interface PropDef<T = unknown, TObj = unknown> {
mapSet?(m: T, v: number): void; // Attribute name. Defaults to the kebab-cased property name. A function
viewState?: boolean; // receives the property name and returns the attribute (see `disabled`).
attribute?: string | ((name: string) => string);
// The property value when the attribute is absent. Keep this equal to
// Leaflet's own default: an absent attribute is left out of the options
// object entirely, so it is Leaflet's default that actually takes effect.
default: T;
// Set through `positional()` for values the Leaflet constructor takes as an
// argument (coordinates, urls, bounds) rather than as an option.
option?: false;
decode(raw: string): T;
// Returning null removes the attribute, which restores Leaflet's default.
encode(value: T): string | null;
// Pushes a new value into the Leaflet object. Defaults to calling the
// matching setter when the object has one (`opacity` -> `setOpacity`).
set?(obj: TObj, value: T, el: HTMLElement): void;
// Reads the live value back out of the Leaflet object. Used by the property
// getter, and by `event` below to write the value back to the attribute.
get?(obj: TObj): T | undefined;
// Leaflet event after which `get` is re-read and synced to the attribute,
// e.g. `move` keeps lat/lng current while a marker is dragged.
event?: string; event?: string;
} }
export interface StrProp { export type PropTable<T> = Record<string, PropDef<unknown, T>>;
kind: 'str';
attr: string;
default: string;
}
export interface BoolOnProp {
kind: 'bool-on';
attr: string;
}
export interface BoolOffProp<T = unknown> {
kind: 'bool-off';
attr: string;
mapSet?(m: T, enabled: boolean): void;
}
export type PropDef = NumProp | StrProp | BoolOnProp | BoolOffProp;
export type PropTypeOf<T extends PropDef> = T extends NumProp
? number
: T extends StrProp
? string
: boolean;
export type PropTypesFromTable<T extends Record<string, PropDef>> = {
[K in keyof T]: PropTypeOf<T[K]>;
};
type OptionalAttr<T> = Omit<T, 'attr'> & { attr?: string }; // Everything a codec factory doesn't fill in for you. `option` is not here:
// it has to come from `positional()` to be visible in PropOptionValues.
export type PropOptions<TObj, T> = Partial<
Omit<PropDef<T, TObj>, 'default' | 'decode' | 'encode' | 'option'>
>;
type NumPropInput<T = unknown> = OptionalAttr<NumProp<T>>; // The value type of a single prop, and of a whole table.
type StrPropInput = OptionalAttr<StrProp>; export type PropValue<P> = P extends { default: infer T } ? T : never;
type BoolOnPropInput = OptionalAttr<BoolOnProp>;
type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>;
type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput | BoolOffPropInput; export type PropValues<T> = { [K in keyof T]: PropValue<T[K]> };
type ValueFor<T, K extends keyof T> = T[K]; // The options object handed to `createLeafletObject`. Partial because a prop
// only appears when its attribute is present; `option: false` props never do.
export type PropOptionValues<T> = Partial<{
[K in keyof T as T[K] extends { option: false } ? never : K]: PropValue<T[K]>;
}>;
type PropDefFromInput<T extends PropDefInput, Key extends string> = (T extends NumPropInput<infer U> // Marks a prop the Leaflet constructor takes as an argument, so it is left out
? NumProp<U> // of the options object handed to createLeafletObject().
: T extends { kind: 'str' } export function positional<T extends PropDef>(def: T): T & { option: false } {
? StrProp return { ...def, option: false };
: T extends BoolOffPropInput<infer U>
? BoolOffProp<U>
: BoolOnProp) & { attr: AttributeValue<T, Key> };
type AttributeValue<T extends PropDefInput, Key extends string> = T extends { attr: string }
? ValueFor<T, 'attr'>
: T extends BoolOffPropInput
? AsDisableKebab<Key>
: AsKebab<Key>;
type AsKebab<T extends string> = T extends `${infer L}${infer R}`
? `${L extends Uppercase<L> ? '-' : ''}${Lowercase<L>}${AsKebab<R>}`
: T;
type AsDisableKebab<T extends string> = `disable-${AsKebab<T>}`;
function disableCamelToKebab<S extends string>(s: S): AsDisableKebab<S> {
return ('disable-' + s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())) as AsDisableKebab<S>;
} }
function camelToKebab(s: string): string { export function kebab(name: string): string {
return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); return name.replaceAll(/[A-Z]/gu, (c) => `-${c.toLowerCase()}`);
} }
export function num<T = unknown>( export function num<TObj = unknown>(
def = 0, def = 0,
opts?: Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string }, opts?: PropOptions<TObj, number>,
): NumPropInput<T> { ): PropDef<number, TObj> {
return { kind: 'num', default: def, ...opts }; return { default: def, decode: Number, encode: String, ...opts };
}
export function str(def = '', attr?: string): StrPropInput {
return { kind: 'str', default: def, ...(attr ? { attr } : {}) };
}
export function on(attr?: string): BoolOnPropInput {
return { kind: 'bool-on', ...(attr ? { attr } : {}) };
} }
export function off<T = unknown>(mapSet?: (m: T, enabled: boolean) => void): BoolOffPropInput<T> { export function str<TObj = unknown>(
return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) }; def = '',
opts?: PropOptions<TObj, string>,
): PropDef<string, TObj> {
return { default: def, decode: (raw) => raw, encode: (value) => value, ...opts };
} }
export function defineProps<T extends Record<string, PropDefInput>>( // A string attribute whose values Leaflet types as a union -- ControlPosition,
input: T, // CrossOrigin, tooltip Direction. Nothing is validated at runtime; this is how
): { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> } { // the options object comes out with the type Leaflet's constructor expects.
const output = {} as Record<string, PropDef>; export function choice<T extends string, TObj = unknown>(
for (const key of Object.keys(input)) { def: T,
const val = input[key]; opts?: PropOptions<TObj, T>,
const { attr: override, ...rest } = val as unknown as Record<string, unknown>; ): PropDef<T, TObj> {
const attr = return { default: def, decode: (raw) => raw as T, encode: (value) => value, ...opts };
override ?? (rest.kind === 'bool-off' ? disableCamelToKebab(key) : camelToKebab(key));
output[key] = { ...(rest as object), attr } as PropDef;
}
return output as unknown as { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> };
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // A boolean attribute: present is true, `="false"` is false, absent is `def`.
type Ctor<T = object> = new (...args: any[]) => T; // Use `bool(true)` for options Leaflet already defaults to true, so that
// `<leaflet-popup auto-pan="false">` can turn them off.
export function WithProps<TBase extends Ctor<HTMLElement>, TProps extends Record<string, PropDef>>( export function bool<TObj = unknown>(
Base: TBase, def = false,
props: TProps, opts?: PropOptions<TObj, boolean>,
): TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>> { ): PropDef<boolean, TObj> {
class WithProps extends Base { return {
static get observedAttributes(): string[] { default: def,
return Object.values(props).map((s) => s.attr); decode: (raw) => raw !== 'false',
} encode: (value) => (value === def ? null : value ? '' : 'false'),
} ...opts,
definePropAccessors(WithProps.prototype, props); };
return WithProps as TBase & Ctor<HTMLElement & PropTypesFromTable<TProps>>;
} }
function definePropAccessors(proto: object, props: Record<string, PropDef>) { // The inverse of `bool(true)`: `<leaflet-map disable-dragging>` reads as
for (const [name, spec] of Object.entries(props)) { // `dragging === false`. Named `disable-<kebab>` unless `attribute` says else.
Object.defineProperty(proto, name, { export function disabled<TObj = unknown>(
get() { opts?: PropOptions<TObj, boolean>,
const el = this as HTMLElement; ): PropDef<boolean, TObj> {
const val = el.getAttribute(spec.attr); return {
if (spec.kind === 'num') return val !== null ? +val : spec.default; attribute: (name) => `disable-${kebab(name)}`,
if (spec.kind === 'bool-on') return el.hasAttribute(spec.attr); default: true,
return val ?? (spec as { default: string }).default; decode: (raw) => raw === 'false',
}, encode: (value) => (value ? null : ''),
set(v: unknown) { ...opts,
const el = this as HTMLElement; };
if (spec.kind === 'bool-on') el.toggleAttribute(spec.attr, !!v);
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
else el.setAttribute(spec.attr, `${v}`);
},
configurable: true,
enumerable: true,
});
}
} }
// Builds the initial Leaflet options object from the PROPS table and the // For attributes holding JSON: bounds, icon sizes and anchors, GeoJSON data.
// element's current attributes at connection time. Props listed in export function json<T, TObj = unknown>(def: T, opts?: PropOptions<TObj, T>): PropDef<T, TObj> {
// `exclude` are skipped (they're passed separately to Leaflet return {
// constructors, like coordinates or URLs). Null attributes use the default: def,
// default from PROPS if one exists (skipping empty-string defaults to decode: (raw) => JSON.parse(raw) as T,
// avoid Leaflet rejecting them). encode: (value) => JSON.stringify(value),
// ...opts,
// The return type is derived from the PROPS table: each prop name maps };
// to its kind's value type (number for `num`, boolean for `bool-on`/`bool-off`,
// string for `str`). The `const` type parameter makes literal exclude arrays
// narrow correctly, so excluded keys are stripped from the return type.
export function buildOptions<
TProps extends Record<string, PropDef>,
const TExclude extends readonly string[] = [],
>(
el: HTMLElement,
props: TProps,
exclude: TExclude = [] as unknown as TExclude,
): Omit<PropTypesFromTable<TProps>, TExclude[number]> {
const opts = {} as Record<string, unknown>;
for (const [propName, spec] of Object.entries(props)) {
if (exclude.includes(propName)) continue;
const val = el.getAttribute(spec.attr);
if (val === null) {
if ('default' in spec && spec.default !== '') opts[propName] = spec.default;
continue;
}
if (spec.kind === 'num') opts[propName] = +val;
else if (spec.kind === 'bool-on') opts[propName] = true;
else if (spec.kind === 'bool-off') opts[propName] = false;
else opts[propName] = val;
}
return opts as Omit<PropTypesFromTable<TProps>, TExclude[number]>;
} }

@ -1,4 +1,4 @@
import { DivIcon, Icon, Layer, LayerGroup, Popup, Tooltip } from 'leaflet'; import { DivIcon, Icon, Layer } from 'leaflet';
// Custom event type for the bubbling registration protocol. Carries the // Custom event type for the bubbling registration protocol. Carries the
// Leaflet object and the originating element so the nearest parent can // Leaflet object and the originating element so the nearest parent can
@ -30,41 +30,6 @@ export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | u
); );
} }
// Tracks whether a child registered as a plain layer, popup, or tooltip --
// used by the controls panel and by cleanup logic.
type ChildEntry = 'layer' | 'popup' | 'tooltip';
// Builds the event handler for a given parent layer. On each
// leaflet-register event from a descendant, it checks the Leaflet type:
// Popup → bindPopup, Tooltip → bindTooltip, Layer → addLayer (if the
// parent supports it). The handler stops propagation so the event
// bubbles no further (the nearest parent claims the child).
function createChildRegisterHandler(layer: Layer, children: Map<HTMLElement, ChildEntry>) {
return function (e: LeafletRegisterEvent) {
const obj = e.detail.leafletObject;
const el = e.detail.element;
if (obj instanceof Popup) {
e.stopPropagation();
layer.bindPopup(obj);
children.set(el, 'popup');
} else if (obj instanceof Tooltip) {
e.stopPropagation();
layer.bindTooltip(obj);
children.set(el, 'tooltip');
} else if (obj instanceof Layer && 'addLayer' in layer) {
e.stopPropagation();
(layer as unknown as LayerGroup).addLayer(obj);
children.set(el, 'layer');
}
};
}
// Module-scoped WeakMaps keyed by the parent element so we don't pollute
// component instances with #children / #handler fields. This keeps the
// data accessible to any caller that holds a reference to the element.
const childrenMap = new WeakMap<HTMLElement, Map<HTMLElement, ChildEntry>>();
const handlerMap = new WeakMap<HTMLElement, (e: LeafletRegisterEvent) => void>();
// Dispatches a custom `leaflet-register` event upward through the DOM // Dispatches a custom `leaflet-register` event upward through the DOM
// tree, carrying a Leaflet object and its host element. Parent components // tree, carrying a Leaflet object and its host element. Parent components
// (map, circles, groups, etc.) intercept this event and add the layer, // (map, circles, groups, etc.) intercept this event and add the layer,
@ -80,33 +45,3 @@ export function registerWithParent(el: HTMLElement, obj: unknown) {
}), }),
); );
} }
// Called during connectedCallback: creates the child tracker and event
// handler, wires up the listener, and registers this element with its
// own parent (so nested structures like polygon→line→point work).
// Returns the children map for the caller to keep a reference.
export function registerChildren(el: HTMLElement, layer: Layer): Map<HTMLElement, ChildEntry> {
const children = new Map<HTMLElement, ChildEntry>();
const handler = createChildRegisterHandler(layer, children);
childrenMap.set(el, children);
handlerMap.set(el, handler);
el.addEventListener('leaflet-register', handler);
registerWithParent(el, layer);
return children;
}
// Called during disconnectedCallback: tears down the event listener and
// removes the WeakMap entries so both the handler and children map can
// be GC'd when the element is removed from the DOM.
export function unregisterChildren(el: HTMLElement): void {
const handler = handlerMap.get(el);
if (handler) el.removeEventListener('leaflet-register', handler);
childrenMap.delete(el);
handlerMap.delete(el);
}
// Public accessor for the children map. Used by the controls panel and
// by any component that needs to inspect its registered descendants.
export function getChildren(el: HTMLElement): Map<HTMLElement, ChildEntry> | undefined {
return childrenMap.get(el);
}

@ -0,0 +1,156 @@
import type {
CrossOrigin,
FillRule,
LatLng,
LatLngBounds,
LatLngBoundsExpression,
LineCapShape,
LineJoinShape,
PathOptions,
ReferrerPolicy,
} from 'leaflet';
import { bool, choice, json, num, positional, str, type PropDef } from './props.ts';
// Leaflet classes share no common interface, so the prop fragments below
// describe structurally what they need from the object they update.
export interface Positioned {
getLatLng(): LatLng | undefined;
setLatLng(latlng: [number, number]): unknown;
}
export interface Styleable {
setStyle(style: PathOptions): unknown;
}
export interface Sourced {
setUrl(url: string): unknown;
getUrl?(): string;
}
export interface Bounded {
getBounds(): LatLngBounds;
}
// Shared `get` for any `bounds` prop backed by Leaflet's getBounds(). Returns
// a plain [[south, west], [north, east]] pair rather than the LatLngBounds
// instance, matching the JSON-encoded shape the attribute round-trips through.
export function getBounds(obj: Bounded): LatLngBoundsExpression {
const b = obj.getBounds();
return [
[b.getSouth(), b.getWest()],
[b.getNorth(), b.getEast()],
];
}
interface PositionedHost extends HTMLElement {
lat: number;
lng: number;
}
// A `set` for any option Leaflet only exposes through setStyle().
export function style<T>(key: keyof PathOptions): (obj: Styleable, value: T) => void {
return (obj, value) => {
obj.setStyle({ [key]: value } as PathOptions);
};
}
// The source url. Passed positionally by every Leaflet constructor that takes
// one, and ignored when blank so clearing the attribute can't request nothing.
export const urlProp = positional(
str<Sourced>('', {
set(obj, value) {
if (value) obj.setUrl(value);
},
// Only ImageOverlay/VideoOverlay implement getUrl(); TileLayer doesn't,
// so this falls back to the attribute there, same as omitting `get`.
get: (obj) => obj.getUrl?.(),
}),
);
// lat/lng travel together: both are passed positionally to the Leaflet
// constructor rather than as options, setting either re-issues setLatLng with
// the other's current value, and both are written back whenever the object
// moves -- which is what keeps the attributes current while a marker is
// dragged. Shared by marker, circle, circle-marker, popup and tooltip.
export const latLngProps = {
lat: positional(
num<Positioned>(0, {
event: 'move',
get: (obj) => obj.getLatLng()?.lat,
set(obj, value, el) {
obj.setLatLng([value, (el as PositionedHost).lng]);
},
}),
),
lng: positional(
num<Positioned>(0, {
event: 'move',
get: (obj) => obj.getLatLng()?.lng,
set(obj, value, el) {
obj.setLatLng([(el as PositionedHost).lat, value]);
},
}),
),
} as const;
// The style options every Path accepts. Defaults match Leaflet's own, so an
// absent attribute and an unset option mean the same thing.
export const pathProps = {
stroke: bool<Styleable>(true, { set: style('stroke') }),
color: str<Styleable>('#3388ff', { set: style('color') }),
weight: num<Styleable>(3, { set: style('weight') }),
opacity: num<Styleable>(1.0, { set: style('opacity') }),
lineCap: choice<LineCapShape, Styleable>('round', { set: style('lineCap') }),
lineJoin: choice<LineJoinShape, Styleable>('round', { set: style('lineJoin') }),
dashArray: str<Styleable>('', { set: style('dashArray') }),
dashOffset: str<Styleable>('', { set: style('dashOffset') }),
fill: bool<Styleable>(true, { set: style('fill') }),
fillColor: str<Styleable>('#3388ff', { set: style('fillColor') }),
fillOpacity: num<Styleable>(0.2, { set: style('fillOpacity') }),
fillRule: choice<FillRule, Styleable>('evenodd', { set: style('fillRule') }),
// Constructor-only: Leaflet has no setter for these, so changing the
// attribute after creation has no effect (same as leaflet-map's zoomSnap).
className: str(),
interactive: bool(true),
bubblingMouseEvents: bool(true),
pane: str('overlay'),
} as const;
// The GridLayer/TileLayer options every tile source accepts, shared by
// leaflet-tile-layer and leaflet-tile-layer-wms (WMSOptions extends
// TileLayerOptions). Most are constructor-only -- Leaflet exposes no setter
// for them -- so changing the attribute after creation has no effect, same as
// leaflet-map's zoomSnap.
export const tileLayerProps = {
attribution: str(),
minZoom: num(0),
maxZoom: num(18),
opacity: num(1.0),
zIndex: num(1),
subdomains: str('abc'),
tms: bool(),
zoomOffset: num(0),
zoomReverse: bool(),
detectRetina: bool(),
crossOrigin: choice<CrossOrigin>(''),
// Leaflet's ReferrerPolicy type has no "unset" member (unlike the DOM's),
// so this is a plain PropDef rather than choice(), with `undefined` as the
// fallback the property getter reports when the attribute is absent.
referrerPolicy: {
default: undefined,
decode: (raw) => raw as ReferrerPolicy,
encode: (value) => value ?? null,
} as PropDef<ReferrerPolicy | undefined>,
errorTileUrl: str(),
tileSize: num(256),
noWrap: bool(),
bounds: json<LatLngBoundsExpression>([]),
className: str(),
minNativeZoom: num(),
maxNativeZoom: num(),
keepBuffer: num(2),
updateWhenIdle: bool(),
updateWhenZooming: bool(true),
updateInterval: num(200),
pane: str('tilePane'),
} as const;

@ -0,0 +1,307 @@
import { Layer, Popup, Tooltip, type Class } from 'leaflet';
import {
kebab,
type PropDef,
type PropOptionValues,
type PropTable,
type PropValues,
} from './props.ts';
import { registerWithParent, type LeafletRegisterEvent } from './register.ts';
// How an element joins the component tree:
// children -- register with the nearest parent and adopt registering
// descendants as layers, popups and tooltips (every layer)
// self -- register with the nearest parent only (popups, tooltips,
// controls: they have a parent but manage no children here)
// none -- neither (the map is the root; icons aren't part of the tree)
export type Attach = 'children' | 'self' | 'none';
export interface ElementOptions {
attach?: Attach;
// Rebuild the Leaflet object on every attribute change instead of calling
// setters, for objects Leaflet gives us no way to mutate in place (icons).
recreate?: boolean;
}
// The members WithProps contributes on top of the property accessors.
export interface LeafletElement<TObj, TProps> {
readonly leafletObject?: TObj;
// The one method every component must implement. `options` holds the decoded
// value of every prop whose attribute is present, keyed by property name, so
// it can be handed straight to the Leaflet constructor.
createLeafletObject(options: PropOptionValues<TProps>): TObj | undefined;
// Called after the object is created and after every recreate.
leafletObjectCreated(): void;
recreateLeafletObject(): void;
connectedCallback(): void;
disconnectedCallback(): void;
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
}
export type LeafletElementConstructor<TObj, TProps> = new () => HTMLElement &
PropValues<TProps> &
LeafletElement<TObj, TProps>;
interface ResolvedProp<TObj extends Class> {
name: string;
attribute: string;
setter: keyof TObj;
def: PropDef<unknown, TObj>;
}
type ChildEntry = { type: 'layer' | 'popup' | 'tooltip'; object: Layer };
type AnyMethod = (...args: unknown[]) => unknown;
// Looks up a Leaflet method by name and binds it, or returns undefined. Leaflet
// objects are duck-typed here on purpose: Layer, Control and Icon share no
// common interface, and which setters exist varies per class.
// function method<TObj extends Record<string, unknown>, TName extends keyof TObj>(obj: TObj, name: TName | string): TObj[TName] | undefined {
function method<TObj extends Class, TName extends keyof TObj>(
obj: TObj,
name: TName | string,
): AnyMethod | undefined {
const value = obj[name as TName];
return typeof value === 'function' ? value.bind(obj) : undefined;
}
function resolve<TObj extends Class>(props: PropTable<TObj>): ResolvedProp<TObj>[] {
return Object.entries(props).map(([name, def]) => ({
name,
attribute:
typeof def.attribute === 'function' ? def.attribute(name) : (def.attribute ?? kebab(name)),
setter: `set${name.charAt(0).toUpperCase()}${name.slice(1)}` as keyof TObj,
def,
}));
}
// Builds a custom element base class from a table of property definitions.
//
// The generated class owns the whole lifecycle: it derives observedAttributes,
// defines two-way property accessors, builds the Leaflet options object, wires
// the object into the component tree, keeps attributes in sync with the object
// (in both directions, without cycles), and re-fires every Leaflet event on the
// element as `leaflet:<type>`. Subclasses implement createLeafletObject().
export function WithProps<TObj extends Class, TProps extends PropTable<TObj>>(
props: TProps,
options: ElementOptions = {},
): LeafletElementConstructor<TObj, TProps> {
const resolved = resolve(props);
const byAttribute = new Map(resolved.map((prop) => [prop.attribute, prop]));
const attach = options.attach ?? 'children';
class WithPropsElement extends HTMLElement {
static readonly observedAttributes = resolved.map((prop) => prop.attribute);
#obj?: TObj;
#connected = false;
#children = new Map<HTMLElement, ChildEntry>();
#listeners: [event: string, handler: () => void][] = [];
// Set while writing an attribute on the object's behalf, so the resulting
// attributeChangedCallback doesn't push the value straight back into
// Leaflet. This is the only place a cycle could form.
#syncing = false;
get leafletObject(): TObj | undefined {
return this.#obj;
}
createLeafletObject(_options: PropOptionValues<TProps>): TObj | undefined {
throw new Error(`<${this.localName}> does not implement createLeafletObject()`);
}
leafletObjectCreated(): void {
// Overridden by components that need to react to a new Leaflet object.
}
connectedCallback(): void {
if (this.#connected) return;
this.#connected = true;
this.#createObject();
if (attach === 'children') {
this.addEventListener('leaflet-register', this.#onChildRegister);
}
if (attach !== 'none' && this.#obj) registerWithParent(this, this.#obj);
this.leafletObjectCreated();
}
disconnectedCallback(): void {
if (attach === 'children') {
this.removeEventListener('leaflet-register', this.#onChildRegister);
}
this.#releaseChildren();
this.#destroyObject();
this.#connected = false;
}
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
if (oldValue === newValue || this.#syncing || !this.#connected) return;
const prop = byAttribute.get(name);
if (!prop) return;
if (options.recreate) {
this.#recreateLeafletObject();
return;
}
const obj = this.#obj;
if (!obj) return;
const value = newValue === null ? prop.def.default : prop.def.decode(newValue);
if (prop.def.set) prop.def.set(obj, value, this);
else method(obj, prop.setter)?.(value);
}
// 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 {
this.#destroyObject();
this.#createObject();
this.leafletObjectCreated();
}
#createObject(): void {
const obj = this.createLeafletObject(this.#buildOptions());
this.#obj = obj;
if (!obj) return;
this.#watchObject(obj);
this.#forwardEvents(obj);
}
#destroyObject(): void {
const obj = this.#obj;
this.#obj = undefined;
if (!obj) return;
const off = method(obj, 'off');
for (const [event, handler] of this.#listeners) off?.(event, handler);
this.#listeners = [];
method(obj, 'remove')?.();
}
#buildOptions(): PropOptionValues<TProps> {
const opts: Record<string, unknown> = {};
for (const prop of resolved) {
if (prop.def.option === false) continue;
const raw = this.getAttribute(prop.attribute);
if (raw !== null) opts[prop.name] = prop.def.decode(raw);
}
return opts as PropOptionValues<TProps>;
}
// Registers one Leaflet listener per distinct event, updating every prop
// that names it. Dragging a marker fires `move` once and writes both lat
// and lng.
#watchObject(obj: TObj): void {
const on = method(obj, 'on');
if (!on) return;
const groups = new Map<string, ResolvedProp<TObj>[]>();
for (const prop of resolved) {
if (!prop.def.event || !prop.def.get) continue;
const group = groups.get(prop.def.event) ?? [];
group.push(prop);
groups.set(prop.def.event, group);
}
for (const [event, group] of groups) {
const handler = () => {
for (const prop of group) this.#syncAttribute(prop);
};
this.#listeners.push([event, handler]);
on(event, handler);
}
}
#syncAttribute(prop: ResolvedProp<TObj>): void {
const obj = this.#obj;
const value = obj && prop.def.get?.(obj);
if (value === undefined) return;
const raw = prop.def.encode(value);
if (this.getAttribute(prop.attribute) === raw) return;
this.#syncing = true;
try {
if (raw === null) this.removeAttribute(prop.attribute);
else this.setAttribute(prop.attribute, raw);
} finally {
this.#syncing = false;
}
}
// Leaflet has no wildcard listener, so we wrap the instance's own `fire` --
// it's an object we created and hold alone. Every Leaflet event becomes a
// `leaflet:<type>` DOM event carrying the Leaflet event as its detail,
// dispatched after Leaflet's handlers so synced attributes are current.
// Not bubbling: Leaflet already propagates layer events up to the map, so
// <leaflet-map> would otherwise see each of them twice.
#forwardEvents(obj: object): void {
const target = obj as {
fire?: (type: string, data?: object, propagate?: boolean) => unknown;
};
const fire = target.fire;
if (typeof fire !== 'function') return;
target.fire = (type, data, propagate) => {
const result = fire.call(obj, type, data, propagate);
this.dispatchEvent(new CustomEvent(`leaflet:${type}`, { detail: data ?? {} }));
return result;
};
}
// Claims descendants that announce themselves with `leaflet-register`.
// Skips our own announcement, which is dispatched on this element too.
#onChildRegister = (e: LeafletRegisterEvent) => {
const obj = this.#obj;
const child = e.detail.leafletObject;
const el = e.detail.element;
if (!obj || el === this) return;
if (child instanceof Popup) {
e.stopPropagation();
method(obj, 'bindPopup')?.(child);
this.#children.set(el, { type: 'popup', object: child });
} else if (child instanceof Tooltip) {
e.stopPropagation();
method(obj, 'bindTooltip')?.(child);
this.#children.set(el, { type: 'tooltip', object: child });
} else if (child instanceof Layer && 'addLayer' in obj) {
e.stopPropagation();
method(obj, 'addLayer')?.(child);
this.#children.set(el, { type: 'layer', object: child });
}
};
#releaseChildren(): void {
const obj = this.#obj;
if (obj) {
for (const entry of this.#children.values()) {
if (entry.type === 'popup') method(obj, 'unbindPopup')?.();
else if (entry.type === 'tooltip') method(obj, 'unbindTooltip')?.();
else method(obj, 'removeLayer')?.(entry.object);
}
}
this.#children.clear();
}
}
for (const prop of resolved) {
Object.defineProperty(WithPropsElement.prototype, prop.name, {
configurable: true,
enumerable: true,
get(this: WithPropsElement) {
const obj = this.leafletObject;
if (obj && prop.def.get) {
const live = prop.def.get(obj);
if (live !== undefined) return live;
}
const raw = this.getAttribute(prop.attribute);
return raw === null ? prop.def.default : prop.def.decode(raw);
},
set(this: WithPropsElement, value: unknown) {
const raw = prop.def.encode(value);
if (raw === null) this.removeAttribute(prop.attribute);
else this.setAttribute(prop.attribute, raw);
},
});
}
return WithPropsElement as unknown as LeafletElementConstructor<TObj, TProps>;
}

@ -1,4 +1,3 @@
export * from './core/attributes.ts';
export * from './core/register.ts'; export * from './core/register.ts';
export * from './core/props.ts'; export * from './core/props.ts';
export { LeafletMap } from './components/leaflet-map.ts'; export { LeafletMap } from './components/leaflet-map.ts';

Loading…
Cancel
Save