refactor: split element classes from customElements.define

Every component is now two modules sharing a basename:

- src/elements/leaflet-foo.ts -- `export default class LeafletFooElement
  extends WithProps(...)`, the class only, no customElements.define, no
  side effects. src/elements/index.ts is an order-free barrel.
- src/components/leaflet-foo.ts -- three lines: import the class, define
  the tag, re-export. Importing this (or src/index.ts) registers the tag.

Plugin authors can now import a class without triggering the built-in
define, to subclass it or register it under a different tag name. Package
subpath exports `leaflet-components/elements`,
`leaflet-components/elements/leaflet-foo.js`, and
`leaflet-components/components/leaflet-foo.js` map onto dist/; jsr.json
gains an `./elements` entry.

The load-bearing ordering moves from the export statements in src/index.ts
to its `./components/*` side-effect imports (the elements barrel carries no
define, so its order is free). Classes are renamed LeafletFoo ->
LeafletFooElement, including in the HTMLElementTagNameMap augmentation.

Docs (CLAUDE.md, docs/, README.md) updated for the new layout.
main
Buddy 2 weeks ago
parent 2c77784345
commit f9c07a263d

@ -28,7 +28,7 @@ Tests run under Vitest + jsdom (`test/**/*.test.ts`), with a single setup file (
- Some Leaflet DOM state (the `<img>`/`<video>` element behind an overlay, a marker's `dragging` handler) is only created in `onAdd()`, i.e. once the layer is actually added to a real map -- tests touching that state append through a `<leaflet-map>` rather than standalone. - Some Leaflet DOM state (the `<img>`/`<video>` element behind an overlay, a marker's `dragging` handler) is only created in `onAdd()`, i.e. once the layer is actually added to a real map -- tests touching that state append through a `<leaflet-map>` rather than standalone.
- `test/core/with-props.test.ts` covers the `WithProps` mixin itself against a fake Leaflet-like class (no real Leaflet needed for most of it) -- the one exception is child registration (popup/tooltip/layer binding), which needs real `Popup`/`Tooltip`/`Layer` instances because `#onChildRegister` discriminates by `instanceof`, not duck typing. - `test/core/with-props.test.ts` covers the `WithProps` mixin itself against a fake Leaflet-like class (no real Leaflet needed for most of it) -- the one exception is child registration (popup/tooltip/layer binding), which needs real `Popup`/`Tooltip`/`Layer` instances because `#onChildRegister` discriminates by `instanceof`, not duck typing.
- `test/load-order.test.ts` is structurally different from every other test file on purpose: it never statically imports a component module, so nothing is `customElements.define`d until it dynamically `import()`s `src/index.ts` partway through -- after building the DOM tree with plain, undefined elements first. This is the only test that reproduces a real page's actual load order (markup parsed, _then_ the deferred module script defines everything) rather than the "components already defined before any element is created" order every other test uses. That distinction is exactly what caught the `src/index.ts` export-order bug described below -- if you add a component with a similar "parent listens for a child's announcement" relationship, extend this test rather than trusting the others to catch an ordering regression, since they structurally can't. - `test/load-order.test.ts` is structurally different from every other test file on purpose: it never statically imports a component module, so nothing is `customElements.define`d until it dynamically `import()`s `src/index.ts` partway through -- after building the DOM tree with plain, undefined elements first. This is the only test that reproduces a real page's actual load order (markup parsed, _then_ the deferred module script defines everything) rather than the "components already defined before any element is created" order every other test uses. That distinction is exactly what catches ordering bugs in the `./components/*` import sequence in `src/index.ts` -- if you add a component with a similar "parent listens for a child's announcement" relationship, extend this test rather than trusting the others to catch an ordering regression, since they structurally can't.
`test/**` is excluded from the main `tsconfig.json` (so test code never ends up in `dist/`) and typechecked separately via `tsconfig.test.json`. `test/**` is excluded from the main `tsconfig.json` (so test code never ends up in `dist/`) and typechecked separately via `tsconfig.test.json`.
@ -38,9 +38,18 @@ To preview components in a real browser, open `index.html` with any static-file
This library wraps [Leaflet.js](https://leafletjs.com/) as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object. This library wraps [Leaflet.js](https://leafletjs.com/) as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object.
### `elements/` vs `components/`
Every component is split across two files with the same basename:
- **`src/elements/leaflet-foo.ts`** — `export default class LeafletFooElement extends WithProps(PROPS) {...}`. The class only; **no `customElements.define`**, no side effects. `src/elements/index.ts` is a barrel re-exporting all of them by name (`LeafletFooElement`), order-free because nothing here registers a tag.
- **`src/components/leaflet-foo.ts`** — three lines: `import LeafletFooElement from '../elements/leaflet-foo.ts'`, `customElements.define('leaflet-foo', LeafletFooElement)`, `export { LeafletFooElement }`. Importing this module (or `src/index.ts`) is what actually registers the tag.
Most consumers just import `src/index.ts` (or a `components/*` module) and get the tags defined. The `elements/*` classes exist for plugin authors who want to subclass an element or register it under a different tag name without triggering the built-in `define`. Package subpath exports (`leaflet-components/elements`, `leaflet-components/elements/leaflet-foo.js`, `leaflet-components/components/leaflet-foo.js`) map straight onto `dist/`.
### `WithProps` mixin: `src/core/with-props.ts` ### `WithProps` mixin: `src/core/with-props.ts`
`WithProps(PROPS, options?)` is a mixin factory: it always extends `HTMLElement` internally (there's no separate base-class parameter) and returns a constructor. Each component defines a `PROPS` table (a const record mapping property names to `PropDef` descriptors from `src/core/props.ts` — kebab-cased automatically for the attribute name) and extends `WithProps(PROPS)`. The mixin handles: `WithProps(PROPS, options?)` is a mixin factory: it always extends `HTMLElement` internally (there's no separate base-class parameter) and returns a constructor. Each element class (in `src/elements/`) defines a `PROPS` table (a const record mapping property names to `PropDef` descriptors from `src/core/props.ts` — kebab-cased automatically for the attribute name) and extends `WithProps(PROPS)`. The mixin handles:
- `static observedAttributes`, derived from the PROPS table keys. - `static observedAttributes`, derived from the PROPS table keys.
- Property getters/setters on the prototype (one `Object.defineProperty` per prop) that read the live Leaflet value when the prop defines a `get`, falling back to the attribute, then the default; setting encodes the value onto the attribute. - Property getters/setters on the prototype (one `Object.defineProperty` per prop) that read the live Leaflet value when the prop defines a `get`, falling back to the attribute, then the default; setting encodes the value onto the attribute.
@ -51,19 +60,19 @@ This library wraps [Leaflet.js](https://leafletjs.com/) as native Web Components
TypeScript typing for the above is opt-in per component, not part of `WithProps` itself — see "Typing a component's events" below. TypeScript typing for the above is opt-in per component, not part of `WithProps` itself — see "Typing a component's events" below.
### `leaflet-map`: `src/components/leaflet-map.ts` ### `leaflet-map`: `src/elements/leaflet-map.ts`
`LeafletMap` extends `WithProps(PROPS, { attach: 'none' })` like everything else, but builds its own Shadow DOM root in `connectedCallback` instead of relying on `createLeafletObject` alone, and terminates all bubbling `leaflet-register` events by calling `layer.addTo(this.map)` since it's the root of the component tree. Uses a `ResizeObserver` on the host element to call `map.invalidateSize()` automatically. `LeafletMapElement` extends `WithProps(PROPS, { attach: 'none' })` like everything else, but builds its own Shadow DOM root in `connectedCallback` instead of relying on `createLeafletObject` alone, and terminates all bubbling `leaflet-register` events by calling `layer.addTo(this.map)` since it's the root of the component tree. Uses a `ResizeObserver` on the host element to call `map.invalidateSize()` automatically. `src/components/leaflet-map.ts` is the usual three-line `import` + `define` + re-export.
### Child component pattern ### Child component pattern
All non-map components extend `WithProps(PROPS, options?)`. To add a new component: All non-map element classes extend `WithProps(PROPS, options?)`. To add a new component:
1. Define a `PROPS = {...}` const table mapping property names to `PropDef` entries (reuse fragments from `src/core/shared-props.ts` where they fit — `pathProps`, `latLngProps`, `tileLayerProps`, `urlProp`). 1. In `src/elements/leaflet-foo.ts`, define a `PROPS = {...}` const table mapping property names to `PropDef` entries (reuse fragments from `src/core/shared-props.ts` where they fit — `pathProps`, `latLngProps`, `tileLayerProps`, `urlProp`).
2. Declare `class LeafletFoo extends WithProps(PROPS)` implementing `createLeafletObject(options): L.Layer`, and `declare readonly leafletObject?: TheLeafletClass;` (the mixin's own inference of the object type from the PROPS table alone isn't reliable enough to skip this). 2. In the same file, `export default class LeafletFooElement extends WithProps(PROPS)` implementing `createLeafletObject(options): L.Layer`, and `declare readonly leafletObject?: TheLeafletClass;` (the mixin's own inference of the object type from the PROPS table alone isn't reliable enough to skip this).
3. Provide a `set` function on individual `PropDef`s only where the default setter-based dispatch won't work (common for coordinate pairs — see `latLngProps` in shared-props.ts). 3. Provide a `set` function on individual `PropDef`s only where the default setter-based dispatch won't work (common for coordinate pairs — see `latLngProps` in shared-props.ts).
4. Call `customElements.define('leaflet-foo', LeafletFoo)` at the bottom. 4. Add `src/components/leaflet-foo.ts`: `import LeafletFooElement from '../elements/leaflet-foo.ts';` then `customElements.define('leaflet-foo', LeafletFooElement);` then `export { LeafletFooElement };`.
5. Export from `src/index.ts`, and add the tag to the `HTMLElementTagNameMap` augmentation at the bottom of that file (needs its own `import type` — a re-export doesn't bind the name locally). **Export order in `src/index.ts` is load-bearing**, not cosmetic: if the new component listens for a bubbling announcement from some other tag (a `leaflet-register`-style child, or something like `leaflet-line-sync`), it must be exported _before_ that other tag. `customElements.define()` upgrades every matching element already in the document immediately, so on a real static-HTML page, whichever tag gets defined first wins the race — a child tag defined before its listening parent will fire its one-shot connect-time announcement into a parent that doesn't exist yet, and that announcement is gone for good. See the comment at the top of `index.ts` for the full ordering and worked example. 5. Add `LeafletFooElement` to the `src/elements/index.ts` barrel (order-free), add a side-effect `import './components/leaflet-foo.ts';` to `src/index.ts`, and add the tag to the `HTMLElementTagNameMap` augmentation at the bottom of `src/index.ts` (which imports the class names from `./elements/index.ts` — a `export *` doesn't bind them locally). **The order of the `./components/*` imports in `src/index.ts` is load-bearing**, not cosmetic: if the new component listens for a bubbling announcement from some other tag (a `leaflet-register`-style child, or something like `leaflet-line-sync`), its `import` must come _before_ that other tag's. `customElements.define()` upgrades every matching element already in the document immediately, so on a real static-HTML page, whichever tag gets defined first wins the race — a child tag defined before its listening parent will fire its one-shot connect-time announcement into a parent that doesn't exist yet, and that announcement is gone for good. See the comment at the top of `index.ts` for the full ordering and worked example. (The `elements/*` modules and the `elements/index.ts` barrel carry no `define`, so their order never matters.)
6. If the component fires meaningful Leaflet events (beyond nothing), compose or reuse an event map in `src/core/event-types.ts` and add `declare addEventListener: LeafletAddEventListener<TheEvents>;` / `declare removeEventListener: LeafletRemoveEventListener<TheEvents>;` (see "Typing a component's events" below). 6. If the component fires meaningful Leaflet events (beyond nothing), compose or reuse an event map in `src/core/event-types.ts` and add `declare addEventListener: LeafletAddEventListener<TheEvents>;` / `declare removeEventListener: LeafletRemoveEventListener<TheEvents>;` (see "Typing a component's events" below).
### Special cases ### Special cases
@ -76,7 +85,7 @@ All non-map components extend `WithProps(PROPS, options?)`. To add a new compone
Two separate TypeScript augmentations make the components usable from TS, neither of which costs anything at runtime: Two separate TypeScript augmentations make the components usable from TS, neither of which costs anything at runtime:
- **`HTMLElementTagNameMap`** (bottom of `src/index.ts`) maps every `leaflet-*` tag to its class, so `document.createElement`/`querySelector` infer the right type. Since `export { X } from '...'` re-exports don't bind `X` locally, the tag map needs its own `import type` of each class alongside the re-export. - **`HTMLElementTagNameMap`** (bottom of `src/index.ts`) maps every `leaflet-*` tag to its `LeafletFooElement` class, so `document.createElement`/`querySelector` infer the right type. Since `export *` doesn't bind the names locally, the tag map needs its own `import type { ... } from './elements/index.ts'`.
- **Per-component `addEventListener`/`removeEventListener`** typing for `leaflet:<name>` events. `event-types.ts` defines small reusable event-name → Leaflet-payload-type fragments (`MouseEvents`, `PopupBindEvents`, `DragEvents`, etc. — mirroring how `shared-props.ts` shares prop fragments), composed per family (`PathEvents`, `MarkerEvents`, `MapEvents`, `TileLayerEvents`, `DivOverlayLayerEvents`, `GroupEvents`). A component applies its family with `declare addEventListener: LeafletAddEventListener<TheEvents>;` (and the `Remove` counterpart), the same `declare`-to-narrow-without-runtime-code idiom as `declare readonly leafletObject?: X`. Components that don't fire meaningful custom events (controls, icons, `leaflet-line`) skip this and keep the default `HTMLElementEventMap` typing. - **Per-component `addEventListener`/`removeEventListener`** typing for `leaflet:<name>` events. `event-types.ts` defines small reusable event-name → Leaflet-payload-type fragments (`MouseEvents`, `PopupBindEvents`, `DragEvents`, etc. — mirroring how `shared-props.ts` shares prop fragments), composed per family (`PathEvents`, `MarkerEvents`, `MapEvents`, `TileLayerEvents`, `DivOverlayLayerEvents`, `GroupEvents`). A component applies its family with `declare addEventListener: LeafletAddEventListener<TheEvents>;` (and the `Remove` counterpart), the same `declare`-to-narrow-without-runtime-code idiom as `declare readonly leafletObject?: X`. Components that don't fire meaningful custom events (controls, icons, `leaflet-line`) skip this and keep the default `HTMLElementEventMap` typing.
`LeafletAddEventListener`/`LeafletRemoveEventListener` (in `with-props.ts`) deliberately have no generic `(type: string, ...)` fallback overload, unlike the real DOM API — a fallback would silently accept any unrecognized `leaflet:*` name too, which defeats the point of typing this at all. The cost is that a genuinely dynamic (non-literal) event name string needs a cast. `LeafletAddEventListener`/`LeafletRemoveEventListener` (in `with-props.ts`) deliberately have no generic `(type: string, ...)` fallback overload, unlike the real DOM API — a fallback would silently accept any unrecognized `leaflet:*` name too, which defeats the point of typing this at all. The cost is that a genuinely dynamic (non-literal) event name string needs a cast.
@ -85,4 +94,4 @@ If you add or change what events a component fires, keep `#forwardEvents`'s `det
### Output ### Output
`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`). `tsc` compiles `src/``dist/` as individual ESM modules (`.js` + `.d.ts` + `.d.ts.map`) — no bundling step, so `dist/elements/` and `dist/components/` mirror `src/`. Consumers import `dist/index.js` (or, via the `package.json` subpath exports, `leaflet-components/elements`, `leaflet-components/elements/leaflet-foo.js`, or `leaflet-components/components/leaflet-foo.js`) 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`).

@ -16,8 +16,17 @@ The package is ESM-only — `tsc` emits `dist/` as individual modules, one per c
// Registers all components // Registers all components
import 'leaflet-components'; import 'leaflet-components';
// Deep import — register only one component // Register only one component (defines the <leaflet-marker> tag)
import 'leaflet-components/dist/components/leaflet-marker.js'; import 'leaflet-components/components/leaflet-marker.js';
```
Each component is two modules with the same basename: `components/leaflet-marker.js` is the side-effecting one that calls `customElements.define()`, and `elements/leaflet-marker.js` is just the class (`default` export, **no** `define`). Import from `elements/` when you want to subclass a component or register it under a different tag name:
```js
// The class only — nothing is registered
import LeafletMarkerElement from 'leaflet-components/elements/leaflet-marker.js';
// …or the whole set, by name
import { LeafletMarkerElement, LeafletCircleElement } from 'leaflet-components/elements';
``` ```
`leaflet` is always an external dependency — you must install it yourself. There is no CommonJS or UMD build. `leaflet` is always an external dependency — you must install it yourself. There is no CommonJS or UMD build.
@ -597,7 +606,7 @@ This library's own components are built from a small toolkit — a mixin, some p
1. Describe your attributes as a `PROPS` table, then extend `WithProps(PROPS, options?)`. 1. Describe your attributes as a `PROPS` table, then extend `WithProps(PROPS, options?)`.
2. Implement `createLeafletObject(options)`, returning whatever Leaflet object your plugin provides. 2. Implement `createLeafletObject(options)`, returning whatever Leaflet object your plugin provides.
3. `declare readonly leafletObject?: TheType;``WithProps`'s own inference of the object type from the `PROPS` table alone isn't reliable enough to skip this (every component in this package does it). 3. `declare readonly leafletObject?: TheType;``WithProps`'s own inference of the object type from the `PROPS` table alone isn't reliable enough to skip this (every component in this package does it).
4. Call `customElements.define('my-plugin-layer', MyPluginLayer)`. 4. Call `customElements.define('my-plugin-layer', MyPluginLayer)`. (If you want to let _your_ consumers subclass or re-register, mirror this package's split: put the class in its own module as a `default` export and keep the `define()` call in a separate side-effecting module.)
```ts ```ts
import { MyClusterGroup, type MyClusterGroupOptions } from 'some-leaflet-plugin'; import { MyClusterGroup, type MyClusterGroupOptions } from 'some-leaflet-plugin';

@ -7,21 +7,41 @@ Every `leaflet-*` custom element maps 1:1 to a single Leaflet object — a
element owns that object for its connected lifetime, holds the only reference element owns that object for its connected lifetime, holds the only reference
to it, and exposes it as `element.leafletObject`. to it, and exposes it as `element.leafletObject`.
Nothing about the wrapping is per-object hand-written plumbing. A component Nothing about the wrapping is per-object hand-written plumbing. An element
file is small (often 3060 lines): a table of property descriptors, a class is small (often 3060 lines): a table of property descriptors, a
`createLeafletObject()` that calls one Leaflet constructor, a couple of `createLeafletObject()` that calls one Leaflet constructor, and a couple of
`declare` lines for types, and `customElements.define()`. Everything else — `declare` lines for types. Everything else — attributes, option building,
attributes, option building, two-way sync, event forwarding, tree membership two-way sync, event forwarding, tree membership — comes from the `WithProps`
— comes from the `WithProps` mixin. mixin.
## `elements/` and `components/`
Each component is two files sharing a basename:
- **`src/elements/leaflet-foo.ts`** exports `default class LeafletFooElement
extends WithProps(PROPS)` — the class alone, no side effects. Import it (or
the `src/elements/index.ts` barrel, which re-exports every class by name)
to get the constructor **without** registering a tag; useful for
subclassing or defining it under a different name.
- **`src/components/leaflet-foo.ts`** is three lines: import the class,
`customElements.define('leaflet-foo', LeafletFooElement)`, re-export it.
Importing this module (or `src/index.ts`, which imports all of them in a
load-bearing order — see [06](./06-load-order.md)) is what registers the
tag.
`package.json` maps `leaflet-components/elements`,
`leaflet-components/elements/leaflet-foo.js` and
`leaflet-components/components/leaflet-foo.js` onto the matching `dist/`
files.
## The `WithProps` mixin ## The `WithProps` mixin
`src/core/with-props.ts`. `WithProps(PROPS, options?)` is a mixin **factory**: `src/core/with-props.ts`. `WithProps(PROPS, options?)` is a mixin **factory**:
it always extends `HTMLElement` internally (there is no base-class parameter) it always extends `HTMLElement` internally (there is no base-class parameter)
and returns a constructor. A component does: and returns a constructor. An element class does:
```ts ```ts
export class LeafletMarker extends WithProps(PROPS) { export default class LeafletMarkerElement extends WithProps(PROPS) {
declare readonly leafletObject?: Marker; declare readonly leafletObject?: Marker;
createLeafletObject(options: MarkerOptions): Marker { createLeafletObject(options: MarkerOptions): Marker {
return new Marker([this.lat, this.lng], options); return new Marker([this.lat, this.lng], options);
@ -97,8 +117,8 @@ anything about the concrete class.
## `leaflet-map` is special ## `leaflet-map` is special
`src/components/leaflet-map.ts`. It still extends `WithProps(PROPS)` with `src/elements/leaflet-map.ts`. `LeafletMapElement` still extends
`attach: 'none'`, but additionally: `WithProps(PROPS)` with `attach: 'none'`, but additionally:
- builds its own **Shadow DOM** in `connectedCallback` (a `<div>` container - builds its own **Shadow DOM** in `connectedCallback` (a `<div>` container
for Leaflet, a `<style>` for `:host`, and a `<link>` to Leaflet's CSS), for Leaflet, a `<style>` for `:host`, and a `<link>` to Leaflet's CSS),

@ -63,7 +63,7 @@ A component narrows its listener types with a `declare` field — the same
zero-runtime idiom as `declare readonly leafletObject?: Marker`: zero-runtime idiom as `declare readonly leafletObject?: Marker`:
```ts ```ts
class LeafletCircle extends WithProps(PROPS) { class LeafletCircleElement extends WithProps(PROPS) {
declare addEventListener: LeafletAddEventListener<PathEvents>; declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>; declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
} }

@ -5,7 +5,7 @@ prop table alone.
## `leaflet-map` — shadow DOM, CSS, resize ## `leaflet-map` — shadow DOM, CSS, resize
`src/components/leaflet-map.ts`. `src/elements/leaflet-map.ts`.
- **Shadow root** built in `connectedCallback` (not via - **Shadow root** built in `connectedCallback` (not via
`createLeafletObject`): a `<div>` at `100% × 100%` for Leaflet to render `createLeafletObject`): a `<div>` at `100% × 100%` for Leaflet to render
@ -28,7 +28,7 @@ prop table alone.
## `leaflet-polygon` / `leaflet-polyline` — vertices from children ## `leaflet-polygon` / `leaflet-polyline` — vertices from children
`src/components/leaflet-polygon.ts`, `leaflet-polyline.ts`, `src/elements/leaflet-polygon.ts`, `leaflet-polyline.ts`,
`src/core/vertex-tracker.ts`. `src/core/vertex-tracker.ts`.
Vertices come from `<leaflet-line>` children, **event-driven, never a Vertices come from `<leaflet-line>` children, **event-driven, never a
@ -52,7 +52,7 @@ reference instead (`emitLineRemove(from, el)` in `register.ts`).
## `leaflet-popup` / `leaflet-tooltip` — content is markup ## `leaflet-popup` / `leaflet-tooltip` — content is markup
`src/components/leaflet-popup.ts`, `leaflet-tooltip.ts`. `src/elements/leaflet-popup.ts`, `leaflet-tooltip.ts`.
- Content is `this.innerHTML`, passed as the `content` option, not an - Content is `this.innerHTML`, passed as the `content` option, not an
attribute. attribute.
@ -75,7 +75,7 @@ registers with _its_ parent, so a group can nest in a group.
## `leaflet-control-layers` — dual role ## `leaflet-control-layers` — dual role
`src/components/leaflet-control-layers.ts`. `attach: 'self'` (it's a control — `src/elements/leaflet-control-layers.ts`. `attach: 'self'` (it's a control —
registers with the map, no children through the standard path), but it _also_ registers with the map, no children through the standard path), but it _also_
attaches its own `leaflet-register` listener to intercept its child layer attaches its own `leaflet-register` listener to intercept its child layer
entries (`<… name="OSM" type="base" active>`): `addBaseLayer` when entries (`<… name="OSM" type="base" active>`): `addBaseLayer` when
@ -88,14 +88,14 @@ only dispatcher.)
## `leaflet-geojson` — style nesting ## `leaflet-geojson` — style nesting
`src/components/leaflet-geojson.ts`. Extends `pathProps`, but GeoJSON takes `src/elements/leaflet-geojson.ts`. Extends `pathProps`, but GeoJSON takes
style options nested under a `style` key so they apply to each generated style options nested under a `style` key so they apply to each generated
feature: `new GeoJSON(data, { style: options })`. `data` is `positional` + feature: `new GeoJSON(data, { style: options })`. `data` is `positional` +
`json`; its `set` does `clearLayers()` then `addData(value)`. `json`; its `set` does `clearLayers()` then `addData(value)`.
## `leaflet-tile-layer-wms` — extensible CRS ## `leaflet-tile-layer-wms` — extensible CRS
`src/components/leaflet-tile-layer-wms.ts`. `src/elements/leaflet-tile-layer-wms.ts`.
- The `crs` **attribute** only covers the four CRSes Leaflet ships by name - The `crs` **attribute** only covers the four CRSes Leaflet ships by name
(`EPSG3857`, `EPSG4326`, `EPSG3395`, `Simple`) via a `NAMED_CRS` lookup. (`EPSG3857`, `EPSG4326`, `EPSG3395`, `Simple`) via a `NAMED_CRS` lookup.

@ -2,9 +2,14 @@
## The rule ## The rule
**Export order in `src/index.ts` is load-bearing.** A component that listens **The order of the `./components/*` side-effect imports in `src/index.ts` is
for a bubbling announcement from another tag must be exported — and therefore load-bearing.** A component that listens for a bubbling announcement from
`customElements.define()`d — **before** that other tag. another tag must be imported — and therefore `customElements.define()`d —
**before** that other tag.
(Only the `./components/*` modules call `customElements.define`. The element
classes themselves live in `src/elements/*` and the `src/elements/index.ts`
barrel; those carry no side effect, so their order is always free.)
## Why ## Why
@ -14,7 +19,9 @@ in the document **immediately and synchronously**, running
On a real static-HTML page the markup is fully parsed before the deferred On a real static-HTML page the markup is fully parsed before the deferred
module script runs, so at `define()` time the elements already exist. The module script runs, so at `define()` time the elements already exist. The
first `define()` for a given tag wins the race: first `define()` for a given tag wins the race, and that ordering is fixed by
the sequence of `import './components/leaflet-*.ts'` statements in
`src/index.ts`:
- If a **child** tag is defined before its listening **parent** tag, every - If a **child** tag is defined before its listening **parent** tag, every
instance of that child in the page upgrades and fires its one-shot instance of that child in the page upgrades and fires its one-shot
@ -32,29 +39,31 @@ So parents must be defined first.
Root to leaf (this is the comment at the top of the file, kept in sync): Root to leaf (this is the comment at the top of the file, kept in sync):
``` ```
LeafletMap ← nothing can register before the root exists LeafletMapElement ← nothing can register before the root exists
LeafletControlLayers, LeafletLayerGroup, LeafletControlLayersElement, LeafletLayerGroupElement,
LeafletFeatureGroup ← listen for leaflet-register from arbitrary layers LeafletFeatureGroupElement ← listen for leaflet-register from arbitrary layers
every concrete layer type (Marker, Circle, every concrete layer type (Marker, Circle,
Polygon, TileLayer, …) Polygon, TileLayer, …)
LeafletLine ← child of Polygon/Polyline LeafletLineElement ← child of Polygon/Polyline
LeafletPopup, LeafletTooltip ← child of any layer LeafletPopupElement, LeafletTooltipElement ← child of any layer
LeafletIcon, LeafletDivIcon ← child of Marker LeafletIconElement, LeafletDivIconElement ← child of Marker
``` ```
Standalone controls (`LeafletControlZoom`, `LeafletControlAttribution`, Standalone controls (`LeafletControlZoomElement`,
`LeafletControlScale`) have no such relationship and can go anywhere. `LeafletControlAttributionElement`, `LeafletControlScaleElement`) have no such
relationship and can go anywhere.
The core re-exports (`register.ts`, `props.ts`, `with-props.ts`, The core re-exports (`register.ts`, `props.ts`, `with-props.ts`,
`shared-props.ts`, `event-types.ts`) come first and carry no `shared-props.ts`, `event-types.ts`) and the `export * from
`customElements.define`, so their order is free. './elements/index.ts'` come first and carry no `customElements.define`, so
their order is free.
## The `HTMLElementTagNameMap` augmentation ## The `HTMLElementTagNameMap` augmentation
At the bottom of `src/index.ts`. Because `export { X } from '…'` re-exports At the bottom of `src/index.ts`. Because `export *` doesn't bind the
`X` without binding it locally, the tag map needs its **own** `import type` of re-exported names locally, the tag map needs its **own**
every component class alongside the re-export. Those imports are type-only, `import type { … } from './elements/index.ts'`. That import is type-only,
erased at build, back no `define()` — their order doesn't matter. erased at build, backs no `define()` — its order doesn't matter.
## The test that guards this ## The test that guards this

@ -95,23 +95,27 @@ export default defineConfig({
## Build output ## Build output
`tsc` compiles `src/``dist/` as **individual ESM modules**`.js` + `tsc` compiles `src/``dist/` as **individual ESM modules**`.js` +
`.d.ts` + `.d.ts.map` per source file, no bundling step. `.d.ts` + `.d.ts.map` per source file, no bundling step. `dist/elements/` and
`dist/components/` mirror the `src/` split one-to-one.
- Consumers import `dist/index.js` (or any single module) directly. - Consumers import `dist/index.js` (or any single module) directly.
- **No CJS, no UMD** build. - **No CJS, no UMD** build.
- **Leaflet is always external** — never bundled. It's a `dependencies` entry - **Leaflet is always external** — never bundled. It's a `dependencies` entry
and a bare `import` in the output. and a bare `import` in the output.
- `package.json` `exports`: `.``dist/index.js` (+ types), plus - `package.json` `exports`: `.``dist/index.js` (+ types);
`./dist/*` for deep imports. `./elements``dist/elements/index.js` (the class barrel, no `define`);
- `sideEffects: true` — the component modules call `customElements.define()` `./elements/*.js` and `./components/*.js` → the matching `dist/` module;
at import time, so a bundler must not tree-shake them away. `./dist/*` still there for deep imports.
- `sideEffects: true` — the `components/*` modules (and `index.js`) call
`customElements.define()` at import time, so a bundler must not tree-shake
them away. The `elements/*` modules have no side effect.
## Publishing to two registries ## Publishing to two registries
| Registry | Entry | Ships | | Registry | Entry | Ships |
| -------- | ------------------------------------------------- | ----------------------------------------------------------------------------- | | -------- | -------------------------------------------------------- | ----------------------------------------------------------------------------- |
| npm | `package.json` `main`/`module`/`types` → `dist/…` | compiled `dist/` + `README.md` (`files` field); `prepublishOnly` runs `build` | | npm | `package.json` `main`/`module`/`types` → `dist/…` | compiled `dist/` + `README.md` (`files` field); `prepublishOnly` runs `build` |
| JSR | `jsr.json` `exports``./src/index.ts` | TypeScript source directly (JSR compiles per-consumer) | | JSR | `jsr.json` `exports``./src/index.ts` (+ `./elements`) | TypeScript source directly (JSR compiles per-consumer) |
Both are version `0.1.0`; keep them in step. JSR publishes `src/` with its Both are version `0.1.0`; keep them in step. JSR publishes `src/` with its
`.ts` import extensions intact, which JSR supports natively. `.ts` import extensions intact, which JSR supports natively.

@ -12,7 +12,7 @@ top-level [`README.md`](../README.md)) and not a working cheat-sheet (that's
| [03 — Component tree & registration](./03-component-tree.md) | The `leaflet-register` bubbling protocol, `attach` modes, the other announcement events | | [03 — Component tree & registration](./03-component-tree.md) | The `leaflet-register` bubbling protocol, `attach` modes, the other announcement events |
| [04 — Events](./04-events.md) | Re-emitting Leaflet events as `leaflet:<type>`, and how they're typed | | [04 — Events](./04-events.md) | Re-emitting Leaflet events as `leaflet:<type>`, and how they're typed |
| [05 — Per-component special cases](./05-special-cases.md) | Where a component does something the mixin can't express generically | | [05 — Per-component special cases](./05-special-cases.md) | Where a component does something the mixin can't express generically |
| [06 — Load order](./06-load-order.md) | Why the export order in `src/index.ts` is load-bearing | | [06 — Load order](./06-load-order.md) | Why the `./components/*` import order in `src/index.ts` is load-bearing |
| [07 — Tooling & build](./07-tooling-and-build.md) | TypeScript 7, oxlint, oxfmt, Vitest, the no-bundle build, dual publish | | [07 — Tooling & build](./07-tooling-and-build.md) | TypeScript 7, oxlint, oxfmt, Vitest, the no-bundle build, dual publish |
## The one-paragraph version ## The one-paragraph version

@ -1,5 +1,8 @@
{ {
"name": "@buddy/leaflet-components", "name": "@buddy/leaflet-components",
"version": "0.1.0", "version": "0.1.0",
"exports": "./src/index.ts" "exports": {
".": "./src/index.ts",
"./elements": "./src/elements/index.ts"
}
} }

@ -10,6 +10,18 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./elements": {
"types": "./dist/elements/index.d.ts",
"import": "./dist/elements/index.js"
},
"./elements/*.js": {
"types": "./dist/elements/*.d.ts",
"import": "./dist/elements/*.js"
},
"./components/*.js": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.js"
},
"./dist/*": "./dist/*", "./dist/*": "./dist/*",
"./package.json": "./package.json" "./package.json": "./package.json"
}, },

@ -1,25 +1,5 @@
import { CircleMarker, type CircleMarkerOptions } from 'leaflet'; import LeafletCircleMarkerElement from '../elements/leaflet-circle-marker.ts';
import { num } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletCircleMarker extends WithProps({ customElements.define('leaflet-circle-marker', LeafletCircleMarkerElement);
...latLngProps,
radius: num<CircleMarker>(10, { get: (obj) => obj.getRadius() }),
...pathProps,
}) {
declare readonly leafletObject?: CircleMarker;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: CircleMarkerOptions): CircleMarker { export { LeafletCircleMarkerElement };
return new CircleMarker([this.lat, this.lng], options);
}
}
customElements.define('leaflet-circle-marker', LeafletCircleMarker);

@ -1,27 +1,5 @@
import { Circle, type CircleOptions } from 'leaflet'; import LeafletCircleElement from '../elements/leaflet-circle.ts';
import { num, positional } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletCircle extends WithProps({ customElements.define('leaflet-circle', LeafletCircleElement);
...latLngProps,
...pathProps,
// Leaflet has no default radius -- it throws without one -- so unlike every
// other option, this one is always passed, from the default when unset.
radius: positional(num<Circle>(1000, { get: (obj) => obj.getRadius() })),
}) {
declare readonly leafletObject?: Circle;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: CircleOptions): Circle { export { LeafletCircleElement };
return new Circle([this.lat, this.lng], { ...options, radius: this.radius });
}
}
customElements.define('leaflet-circle', LeafletCircle);

@ -1,19 +1,5 @@
import { Control, type ControlPosition } from 'leaflet'; import LeafletControlAttributionElement from '../elements/leaflet-control-attribution.ts';
import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletControlAttribution extends WithProps( customElements.define('leaflet-control-attribution', LeafletControlAttributionElement);
{
position: choice<ControlPosition>('bottomright'),
prefix: str(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Attribution;
createLeafletObject(options: Control.AttributionOptions): Control.Attribution { export { LeafletControlAttributionElement };
return new Control.Attribution(options);
}
}
customElements.define('leaflet-control-attribution', LeafletControlAttribution);

@ -1,53 +1,5 @@
import { Control, type ControlPosition } from 'leaflet'; import LeafletControlLayersElement from '../elements/leaflet-control-layers.ts';
import { bool, choice } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletRegisterEvent } from '../core/register.ts';
export class LeafletControlLayers extends WithProps( customElements.define('leaflet-control-layers', LeafletControlLayersElement);
{
position: choice<ControlPosition>('topright'),
collapsed: bool(true),
autoZIndex: bool(true),
hideSingleBase: bool(),
sortLayers: bool(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Layers;
createLeafletObject(options: Control.LayersOptions): Control.Layers { export { LeafletControlLayersElement };
return new Control.Layers({}, {}, options);
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-register', this.#onChildRegister);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-register', this.#onChildRegister);
super.disconnectedCallback();
}
// Every child -- present at construction or added later -- announces
// itself this way: a parent's connectedCallback always finishes before a
// child's does (even for an already-built subtree attached in one go), so
// there is no "read the children that are already here" case to handle
// separately.
#onChildRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
const el = e.detail.element;
const layer = e.detail.leafletObject;
const name = el.getAttribute('name');
if (!name) return;
if (el.getAttribute('type') === 'base') this.leafletObject?.addBaseLayer(layer, name);
else this.leafletObject?.addOverlay(layer, name);
if (el.hasAttribute('active')) {
this.dispatchEvent(
new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }),
);
}
};
}
customElements.define('leaflet-control-layers', LeafletControlLayers);

@ -1,22 +1,5 @@
import { Control, type ControlPosition } from 'leaflet'; import LeafletControlScaleElement from '../elements/leaflet-control-scale.ts';
import { bool, choice, num } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
export class LeafletControlScale extends WithProps( customElements.define('leaflet-control-scale', LeafletControlScaleElement);
{
position: choice<ControlPosition>('bottomleft'),
maxWidth: num(100),
metric: bool(true),
imperial: bool(true),
updateWhenIdle: bool(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Scale;
createLeafletObject(options: Control.ScaleOptions): Control.Scale { export { LeafletControlScaleElement };
return new Control.Scale(options);
}
}
customElements.define('leaflet-control-scale', LeafletControlScale);

@ -1,24 +1,5 @@
import { Control, type ControlPosition } from 'leaflet'; import LeafletControlZoomElement from '../elements/leaflet-control-zoom.ts';
import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
// The button labels default to Leaflet's own markup, which hides the glyph customElements.define('leaflet-control-zoom', LeafletControlZoomElement);
// from screen readers in favour of the title.
export class LeafletControlZoom extends WithProps(
{
position: choice<ControlPosition>('topleft'),
zoomInText: str('<span aria-hidden="true">+</span>'),
zoomInTitle: str('Zoom in'),
zoomOutText: str('<span aria-hidden="true"></span>'),
zoomOutTitle: str('Zoom out'),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Zoom;
createLeafletObject(options: Control.ZoomOptions): Control.Zoom { export { LeafletControlZoomElement };
return new Control.Zoom(options);
}
}
customElements.define('leaflet-control-zoom', LeafletControlZoom);

@ -1,48 +1,5 @@
import { DivIcon, type DivIconOptions, type PointExpression } from 'leaflet'; import LeafletDivIconElement from '../elements/leaflet-div-icon.ts';
import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts';
import { WithProps } from '../core/with-props.ts';
// Like leaflet-icon, rebuilt on every change -- including changes to the customElements.define('leaflet-div-icon', LeafletDivIconElement);
// markup, which is what the icon renders when `html` isn't set.
export class LeafletDivIcon extends WithProps(
{
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(),
bgPos: json<PointExpression>([0, 0]),
},
{ attach: 'none', recreate: true },
) {
declare readonly leafletObject?: DivIcon;
#observer?: MutationObserver; export { LeafletDivIconElement };
createLeafletObject(options: DivIconOptions): DivIcon {
return new DivIcon(this.innerHTML ? { ...options, html: this.innerHTML } : options);
}
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.recreateLeafletObject();
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
super.disconnectedCallback();
emitIconChanged(this, null);
}
leafletObjectCreated(): void {
emitIconChanged(this, this.leafletObject);
}
}
customElements.define('leaflet-div-icon', LeafletDivIcon);

@ -1,20 +1,5 @@
import { FeatureGroup } from 'leaflet'; import LeafletFeatureGroupElement from '../elements/leaflet-feature-group.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
// Like leaflet-layer-group, but its children share events and a bounding box. customElements.define('leaflet-feature-group', LeafletFeatureGroupElement);
export class LeafletFeatureGroup extends WithProps({}) {
declare readonly leafletObject?: FeatureGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
createLeafletObject(): FeatureGroup { export { LeafletFeatureGroupElement };
return new FeatureGroup([]);
}
}
customElements.define('leaflet-feature-group', LeafletFeatureGroup);

@ -1,34 +1,5 @@
import { GeoJSON, type PathOptions } from 'leaflet'; import LeafletGeoJSONElement from '../elements/leaflet-geojson.ts';
import type { GeoJsonObject } from 'geojson';
import { json, positional } from '../core/props.ts';
import { pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
export class LeafletGeoJSON extends WithProps({ customElements.define('leaflet-geojson', LeafletGeoJSONElement);
data: positional(
json<GeoJsonObject | null>(null, {
set(obj: GeoJSON, value) {
obj.clearLayers();
if (value) obj.addData(value);
},
}),
),
...pathProps,
}) {
declare readonly leafletObject?: GeoJSON;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
// Every prop but `data` is a style option, and GeoJSON takes those nested export { LeafletGeoJSONElement };
// under `style` so they apply to each feature it builds.
createLeafletObject(options: PathOptions): GeoJSON {
return new GeoJSON(this.data, { style: options });
}
}
customElements.define('leaflet-geojson', LeafletGeoJSON);

@ -1,39 +1,5 @@
import { Icon, type IconOptions, type PointExpression } from 'leaflet'; import LeafletIconElement from '../elements/leaflet-icon.ts';
import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts';
import { WithProps } from '../core/with-props.ts';
const PROPS = { customElements.define('leaflet-icon', LeafletIconElement);
iconUrl: str(),
iconRetinaUrl: str(),
iconSize: json<PointExpression>([0, 0]),
iconAnchor: json<PointExpression>([0, 0]),
popupAnchor: json<PointExpression>([0, 0]),
tooltipAnchor: json<PointExpression>([0, 0]),
shadowUrl: str(),
shadowRetinaUrl: str(),
shadowSize: json<PointExpression>([0, 0]),
shadowAnchor: json<PointExpression>([0, 0]),
className: str(),
} as const;
// An Icon has no setters, so every attribute change builds a new one and the export { LeafletIconElement };
// parent marker is told to swap it in.
export class LeafletIcon extends WithProps(PROPS, { attach: 'none', recreate: true }) {
declare readonly leafletObject?: Icon;
createLeafletObject(options: Partial<IconOptions>): Icon | undefined {
return options.iconUrl ? new Icon(options as IconOptions) : undefined;
}
leafletObjectCreated(): void {
emitIconChanged(this, this.leafletObject);
}
disconnectedCallback(): void {
super.disconnectedCallback();
emitIconChanged(this, null);
}
}
customElements.define('leaflet-icon', LeafletIcon);

@ -1,41 +1,5 @@
import { import LeafletImageOverlayElement from '../elements/leaflet-image-overlay.ts';
ImageOverlay,
type CrossOrigin,
type ImageOverlayOptions,
type LatLngBoundsExpression,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds, urlProp } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletImageOverlay extends WithProps({ customElements.define('leaflet-image-overlay', LeafletImageOverlayElement);
url: urlProp,
bounds: positional(json<LatLngBoundsExpression, ImageOverlay>([], { get: getBounds })),
opacity: num(1.0),
alt: str('', {
set(obj: ImageOverlay, value) {
const el = obj.getElement();
if (el) el.alt = value;
},
}),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
errorOverlayUrl: str(),
zIndex: num(),
className: str(),
}) {
declare readonly leafletObject?: ImageOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: ImageOverlayOptions): ImageOverlay { export { LeafletImageOverlayElement };
return new ImageOverlay(this.url, this.bounds, options);
}
}
customElements.define('leaflet-image-overlay', LeafletImageOverlay);

@ -1,21 +1,5 @@
import { LayerGroup } from 'leaflet'; import LeafletLayerGroupElement from '../elements/leaflet-layer-group.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
// A passthrough container: it has no options of its own, and children add customElements.define('leaflet-layer-group', LeafletLayerGroupElement);
// themselves to it through the standard registration bubble.
export class LeafletLayerGroup extends WithProps({}) {
declare readonly leafletObject?: LayerGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
createLeafletObject(): LayerGroup { export { LeafletLayerGroupElement };
return new LayerGroup([]);
}
}
customElements.define('leaflet-layer-group', LeafletLayerGroup);

@ -1,31 +1,5 @@
import { emitLineRemove, emitLineSync } from '../core/register.ts'; import LeafletLineElement from '../elements/leaflet-line.ts';
export class LeafletLine extends HTMLElement { customElements.define('leaflet-line', LeafletLineElement);
static get observedAttributes() {
return ['lat', 'lng'];
}
// Cached because disconnectedCallback fires after this is already detached export { LeafletLineElement };
// from it -- see emitLineRemove.
#parent: ParentNode | null = null;
get latlng(): [number, number] {
return [+(this.getAttribute('lat') ?? 0), +(this.getAttribute('lng') ?? 0)];
}
connectedCallback(): void {
this.#parent = this.parentNode;
emitLineSync(this, this.latlng);
}
attributeChangedCallback(): void {
emitLineSync(this, this.latlng);
}
disconnectedCallback(): void {
emitLineRemove(this.#parent ?? this, this);
this.#parent = null;
}
}
customElements.define('leaflet-line', LeafletLine);

@ -1,238 +1,5 @@
import { Icon, Map as LMap, type MapOptions } from 'leaflet'; import LeafletMapElement from '../elements/leaflet-map.ts';
import { bool, disabled, num, positional, str } from '../core/props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
import type { MapEvents } from '../core/event-types.ts';
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; customElements.define('leaflet-map', LeafletMapElement);
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
interface CssHost extends HTMLElement { export { LeafletMapElement };
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();
}
// The root of the component tree. Every other component bubbles a
// `leaflet-register` event up to here, which is where it stops.
export class LeafletMap extends WithProps(
{
// 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',
get: (map) => map.getCenter().lat,
set: (map, value) => {
map.setView([value, map.getCenter().lng], map.getZoom());
},
}),
),
lng: positional(
num<LMap>(0, {
event: 'moveend',
get: (map) => map.getCenter().lng,
set: (map, value) => {
map.setView([map.getCenter().lat, value], map.getZoom());
},
}),
),
zoom: positional(
num<LMap>(2, {
event: 'zoomend',
get: (map) => map.getZoom(),
set: (map, value) => {
map.setZoom(value);
},
}),
),
// Numeric options Leaflet lets us change after construction.
minZoom: num<LMap>(0, {
get: (map) => map.getMinZoom(),
set: (map, value) => {
map.setMinZoom(value);
},
}),
maxZoom: num<LMap>(Infinity, {
get: (map) => map.getMaxZoom(),
set: (map, value) => {
map.setMaxZoom(value);
},
}),
// Constructor-only numeric options.
zoomSnap: num(1),
zoomDelta: num(1),
keyboardPanDelta: num(80),
wheelDebounceTime: num(40),
wheelPxPerZoomLevel: num(60),
inertiaDeceleration: num(3000),
inertiaMaxSpeed: num(Infinity),
easeLinearity: num(0.2),
maxBoundsViscosity: num(0),
tapTolerance: num(15),
zoomAnimationThreshold: num(4),
transform3DLimit: num(8388608),
// Options Leaflet defaults to true, turned off with disable-* attributes.
// The interaction handlers can be toggled after construction.
scrollWheelZoom: disabled<LMap>({
set: (map, on) => (on ? map.scrollWheelZoom.enable() : map.scrollWheelZoom.disable()),
}),
dragging: disabled<LMap>({
set: (map, on) => (on ? map.dragging.enable() : map.dragging.disable()),
}),
touchZoom: disabled<LMap>({
set: (map, on) => (on ? map.touchZoom.enable() : map.touchZoom.disable()),
}),
doubleClickZoom: disabled<LMap>({
set: (map, on) => (on ? map.doubleClickZoom.enable() : map.doubleClickZoom.disable()),
}),
boxZoom: disabled<LMap>({
set: (map, on) => (on ? map.boxZoom.enable() : map.boxZoom.disable()),
}),
keyboard: disabled<LMap>({
set: (map, on) => (on ? map.keyboard.enable() : map.keyboard.disable()),
}),
closePopupOnClick: disabled(),
trackResize: disabled(),
zoomControl: disabled(),
attributionControl: disabled(),
inertia: disabled(),
zoomAnimation: disabled(),
fadeAnimation: disabled(),
markerZoomAnimation: disabled(),
bounceAtZoomLimits: disabled(),
tapHold: disabled(),
// Options Leaflet defaults to false.
preferCanvas: bool(),
worldCopyJump: bool(),
// Not Leaflet options at all -- see relinkCss above.
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
cssCrossorigin: positional(str('', { set: relinkCss })),
},
{ attach: 'none' },
) {
declare readonly leafletObject?: LMap;
declare addEventListener: LeafletAddEventListener<MapEvents>;
declare removeEventListener: LeafletRemoveEventListener<MapEvents>;
#container?: HTMLDivElement;
#cssLink?: HTMLLinkElement;
#resizeObserver?: ResizeObserver;
createLeafletObject(options: MapOptions): LMap {
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
// doesn't exist yet, so the getters fall back to the attributes.
map.setView([this.lat, this.lng], this.zoom);
return map;
}
connectedCallback(): void {
this.#buildShadowRoot();
this.applyCss();
super.connectedCallback();
this.#resizeObserver = new ResizeObserver(() => this.leafletObject?.invalidateSize());
this.#resizeObserver.observe(this);
this.addEventListener('leaflet-register', this.#onRegister);
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
}
disconnectedCallback(): void {
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;
this.removeEventListener('leaflet-register', this.#onRegister);
this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
super.disconnectedCallback();
}
// Replaces the <link> in the shadow root, and derives the marker icon path
// from the same URL. Called on connect and whenever a css-* attribute
// 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;
this.#cssLink?.remove();
this.#cssLink = undefined;
const customUrl = this.hasAttribute('css-url');
const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
let integrity: string | null = null;
if (this.hasAttribute('css-integrity')) {
integrity = this.getAttribute('css-integrity') ?? '';
} else if (!customUrl) {
integrity = DEFAULT_CSS_INTEGRITY;
}
let crossorigin: string | undefined;
if (this.hasAttribute('css-crossorigin')) {
crossorigin = this.getAttribute('css-crossorigin') ?? undefined;
} else if (integrity) {
crossorigin = 'anonymous';
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url ?? '';
if (integrity) link.setAttribute('integrity', integrity);
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
root.append(link);
this.#cssLink = link;
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
}
#buildShadowRoot(): HTMLDivElement {
if (this.#container) return this.#container;
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
const container = document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
root.append(container);
const style = document.createElement('style');
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
root.append(style);
this.#container = container;
return container;
}
#onRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
const map = this.leafletObject;
if (map) e.detail.leafletObject.addTo(map);
};
#onAddLayer = (e: LeafletLayerEvent) => {
this.leafletObject?.addLayer(e.detail.layer);
};
#onRemoveLayer = (e: LeafletLayerEvent) => {
this.leafletObject?.removeLayer(e.detail.layer);
};
}
customElements.define('leaflet-map', LeafletMap);

@ -1,62 +1,5 @@
import { Icon, Marker, type MarkerOptions } from 'leaflet'; import LeafletMarkerElement from '../elements/leaflet-marker.ts';
import { bool, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletIconChangedEvent } from '../core/register.ts';
import type { MarkerEvents } from '../core/event-types.ts';
const PROPS = { customElements.define('leaflet-marker', LeafletMarkerElement);
...latLngProps,
// title and alt end up on the <img> Leaflet renders, so live updates go there.
title: str('', {
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),
zIndexOffset: num(),
} as const;
export class LeafletMarker extends WithProps(PROPS) { export { LeafletMarkerElement };
declare readonly leafletObject?: Marker;
declare addEventListener: LeafletAddEventListener<MarkerEvents>;
declare removeEventListener: LeafletRemoveEventListener<MarkerEvents>;
createLeafletObject(options: MarkerOptions): Marker {
return new Marker([this.lat, this.lng], options);
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('icon-changed', this.#onIconChanged);
}
disconnectedCallback(): void {
this.removeEventListener('icon-changed', this.#onIconChanged);
super.disconnectedCallback();
}
// A <leaflet-icon> or <leaflet-div-icon> child announces itself this way.
#onIconChanged = (e: LeafletIconChangedEvent) => {
this.leafletObject?.setIcon(e.detail.icon ?? new Icon.Default());
};
}
customElements.define('leaflet-marker', LeafletMarker);

@ -1,49 +1,5 @@
import { Polygon, type PolylineOptions } from 'leaflet'; import LeafletPolygonElement from '../elements/leaflet-polygon.ts';
import { pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
import { VertexTracker } from '../core/vertex-tracker.ts';
export class LeafletPolygon extends WithProps({ ...pathProps }) { customElements.define('leaflet-polygon', LeafletPolygonElement);
declare readonly leafletObject?: Polygon;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#vertices = new VertexTracker(); export { LeafletPolygonElement };
createLeafletObject(options: PolylineOptions): Polygon {
return new Polygon(this.#vertices.coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute --
// each one announces its own position via leaflet-line-sync/-remove, we
// never read a child's state directly.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-line-sync', this.#onLineSync);
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
super.disconnectedCallback();
}
#onLineSync = (e: LeafletLineSyncEvent) => {
this.#vertices.sync(e.detail.element, e.detail.latlng);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
#onLineRemove = (e: LeafletLineRemoveEvent) => {
this.#vertices.remove(e.detail.element);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
}
customElements.define('leaflet-polygon', LeafletPolygon);

@ -1,56 +1,5 @@
import { Polyline, type PolylineOptions } from 'leaflet'; import LeafletPolylineElement from '../elements/leaflet-polyline.ts';
import { bool, num } from '../core/props.ts';
import { pathProps, style } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
import { VertexTracker } from '../core/vertex-tracker.ts';
export class LeafletPolyline extends WithProps({ customElements.define('leaflet-polyline', LeafletPolylineElement);
...pathProps,
// Unlike closed shapes, a polyline is unfilled by default.
fill: bool(false, { set: style('fill') }),
smoothFactor: num(1.0),
noClip: bool(),
}) {
declare readonly leafletObject?: Polyline;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#vertices = new VertexTracker(); export { LeafletPolylineElement };
createLeafletObject(options: PolylineOptions): Polyline {
return new Polyline(this.#vertices.coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute --
// each one announces its own position via leaflet-line-sync/-remove, we
// never read a child's state directly.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-line-sync', this.#onLineSync);
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
super.disconnectedCallback();
}
#onLineSync = (e: LeafletLineSyncEvent) => {
this.#vertices.sync(e.detail.element, e.detail.latlng);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
#onLineRemove = (e: LeafletLineRemoveEvent) => {
this.#vertices.remove(e.detail.element);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
}
customElements.define('leaflet-polyline', LeafletPolyline);

@ -1,54 +1,5 @@
import { Popup, type PopupOptions } from 'leaflet'; import LeafletPopupElement from '../elements/leaflet-popup.ts';
import { bool, num } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
export class LeafletPopup extends WithProps( customElements.define('leaflet-popup', LeafletPopupElement);
{
...latLngProps,
maxWidth: num(300),
minWidth: num(50),
maxHeight: num(),
autoPan: bool(true),
closeButton: bool(true),
autoClose: bool(true),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Popup;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
#observer?: MutationObserver; export { LeafletPopupElement };
// Content is the element's markup, not an attribute. A popup only carries a
// position of its own when it isn't bound to a parent layer.
createLeafletObject(options: PopupOptions): Popup {
const popup = new Popup({ ...options, content: this.innerHTML });
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
popup.setLatLng([this.lat, this.lng]);
}
return popup;
}
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
super.disconnectedCallback();
}
}
customElements.define('leaflet-popup', LeafletPopup);

@ -1,24 +1,5 @@
import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet'; import LeafletRectangleElement from '../elements/leaflet-rectangle.ts';
import { json, positional } from '../core/props.ts';
import { pathProps, getBounds } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletRectangle extends WithProps({ customElements.define('leaflet-rectangle', LeafletRectangleElement);
bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
...pathProps,
}) {
declare readonly leafletObject?: Rectangle;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: PolylineOptions): Rectangle { export { LeafletRectangleElement };
return new Rectangle(this.bounds, options);
}
}
customElements.define('leaflet-rectangle', LeafletRectangle);

@ -1,35 +1,5 @@
import { import LeafletSVGOverlayElement from '../elements/leaflet-svg-overlay.ts';
SVGOverlay,
type CrossOrigin,
type ImageOverlayOptions,
type LatLngBoundsExpression,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export class LeafletSVGOverlay extends WithProps({ customElements.define('leaflet-svg-overlay', LeafletSVGOverlayElement);
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
opacity: num(1.0),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
zIndex: num(),
className: str(),
}) {
declare readonly leafletObject?: SVGOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: ImageOverlayOptions): SVGOverlay { export { LeafletSVGOverlayElement };
const svg =
this.querySelector('svg') ?? document.createElementNS('http://www.w3.org/2000/svg', 'svg');
return new SVGOverlay(svg, this.bounds, options);
}
}
customElements.define('leaflet-svg-overlay', LeafletSVGOverlay);

@ -1,88 +1,5 @@
import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet'; import LeafletTileLayerWMSElement from '../elements/leaflet-tile-layer-wms.ts';
import { bool, str, type PropDef } from '../core/props.ts';
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { TileLayerEvents } from '../core/event-types.ts';
import type { LeafletCRSChangedEvent } from '../core/register.ts';
// WMS request parameters have no individual setters -- they're merged into the customElements.define('leaflet-tile-layer-wms', LeafletTileLayerWMSElement);
// query string through setParams().
function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void {
return (obj, value) => {
obj.setParams({ [key]: value } as unknown as WMSParams);
};
}
// `crs` takes a CRS instance, not a primitive. The attribute only covers the export { LeafletTileLayerWMSElement };
// 4 CRSes Leaflet ships by name -- a CRS Leaflet doesn't ship comes from a
// nested child announcing itself via leaflet-crs-changed instead (see
// #onCrsChanged), which takes priority over this when present. Constructor-
// only, like the rest of tileLayerProps -- Leaflet has no setter for it.
const NAMED_CRS = {
EPSG3857: CRS.EPSG3857,
EPSG4326: CRS.EPSG4326,
EPSG3395: CRS.EPSG3395,
Simple: CRS.Simple,
};
type CrsName = keyof typeof NAMED_CRS;
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;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
// Set by a nested CRS-providing child; overrides the `crs` attribute's
// named lookup when present.
#childCrs?: CRS;
createLeafletObject(options: WMSOptions): TileLayer.WMS {
return new TileLayer.WMS(
this.url,
this.#childCrs ? { ...options, crs: this.#childCrs } : options,
);
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-crs-changed', this.#onCrsChanged);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-crs-changed', this.#onCrsChanged);
super.disconnectedCallback();
}
// A CRS Leaflet doesn't ship comes from any custom element nested here
// that fires this event -- no base class required, just this event shape
// (see register.ts). `crs` is constructor-only, so picking it up means
// rebuilding the whole layer.
#onCrsChanged = (e: LeafletCRSChangedEvent) => {
this.#childCrs = e.detail.crs ?? undefined;
this.recreateLeafletObject();
};
}
customElements.define('leaflet-tile-layer-wms', LeafletTileLayerWMS);

@ -1,23 +1,5 @@
import { TileLayer, type TileLayerOptions } from 'leaflet'; import LeafletTileLayerElement from '../elements/leaflet-tile-layer.ts';
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { TileLayerEvents } from '../core/event-types.ts';
export class LeafletTileLayer extends WithProps({ customElements.define('leaflet-tile-layer', LeafletTileLayerElement);
url: urlProp,
...tileLayerProps,
}) {
declare readonly leafletObject?: TileLayer;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
createLeafletObject(options: TileLayerOptions): TileLayer { export { LeafletTileLayerElement };
return new TileLayer(this.url, options);
}
}
customElements.define('leaflet-tile-layer', LeafletTileLayer);

@ -1,54 +1,5 @@
import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet'; import LeafletTooltipElement from '../elements/leaflet-tooltip.ts';
import { bool, choice, json, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
export class LeafletTooltip extends WithProps( customElements.define('leaflet-tooltip', LeafletTooltipElement);
{
...latLngProps,
pane: str(),
offset: json<PointExpression>([0, 0]),
direction: choice<Direction>('auto'),
permanent: bool(),
sticky: bool(),
opacity: num(1.0),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Tooltip;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
#observer?: MutationObserver; export { LeafletTooltipElement };
// Content is the element's markup, not an attribute. A tooltip only carries a
// position of its own when it isn't bound to a parent layer.
createLeafletObject(options: TooltipOptions): Tooltip {
const tooltip = new Tooltip({ ...options, content: this.innerHTML });
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
tooltip.setLatLng([this.lat, this.lng]);
}
return tooltip;
}
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
super.disconnectedCallback();
}
}
customElements.define('leaflet-tooltip', LeafletTooltip);

@ -1,56 +1,5 @@
import { import LeafletVideoOverlayElement from '../elements/leaflet-video-overlay.ts';
VideoOverlay,
type CrossOrigin,
type LatLngBoundsExpression,
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,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
// Playback options live on the <video> element Leaflet builds, so live updates customElements.define('leaflet-video-overlay', LeafletVideoOverlayElement);
// 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;
};
}
export class LeafletVideoOverlay extends WithProps({ export { LeafletVideoOverlayElement };
url: urlProp,
bounds: positional(json<LatLngBoundsExpression, VideoOverlay>([], { get: getBounds })),
opacity: num(1.0),
alt: str(),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
loop: bool(false, { set: media('loop') }),
autoplay: bool(false, { set: media('autoplay') }),
muted: bool(false, { set: media('muted') }),
playsInline: bool(false, { attribute: 'playsinline', set: media('playsInline') }),
zIndex: num(),
className: str(),
keepAspectRatio: bool(true),
errorOverlayUrl: str(),
}) {
declare readonly leafletObject?: VideoOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
return new VideoOverlay(this.url, this.bounds, options);
}
getElement(): HTMLVideoElement | undefined {
return this.leafletObject?.getElement();
}
}
customElements.define('leaflet-video-overlay', LeafletVideoOverlay);

@ -0,0 +1,32 @@
// The element classes on their own -- no `customElements.define()` runs from
// anything in this file or the modules it re-exports. Import a `components/*`
// module (or `src/index.ts`) instead when you want the tags actually
// registered; reach for these when you want to subclass an element, register
// it under a different tag name, or otherwise customise before defining.
//
// Every class is the `default` export of its own module, surfaced here under
// a name. Order is irrelevant -- nothing here has a load-time side effect.
export { default as LeafletMapElement } from './leaflet-map.ts';
export { default as LeafletControlLayersElement } from './leaflet-control-layers.ts';
export { default as LeafletLayerGroupElement } from './leaflet-layer-group.ts';
export { default as LeafletFeatureGroupElement } from './leaflet-feature-group.ts';
export { default as LeafletPolygonElement } from './leaflet-polygon.ts';
export { default as LeafletPolylineElement } from './leaflet-polyline.ts';
export { default as LeafletMarkerElement } from './leaflet-marker.ts';
export { default as LeafletCircleElement } from './leaflet-circle.ts';
export { default as LeafletCircleMarkerElement } from './leaflet-circle-marker.ts';
export { default as LeafletRectangleElement } from './leaflet-rectangle.ts';
export { default as LeafletTileLayerElement } from './leaflet-tile-layer.ts';
export { default as LeafletTileLayerWMSElement } from './leaflet-tile-layer-wms.ts';
export { default as LeafletImageOverlayElement } from './leaflet-image-overlay.ts';
export { default as LeafletVideoOverlayElement } from './leaflet-video-overlay.ts';
export { default as LeafletSVGOverlayElement } from './leaflet-svg-overlay.ts';
export { default as LeafletGeoJSONElement } from './leaflet-geojson.ts';
export { default as LeafletLineElement } from './leaflet-line.ts';
export { default as LeafletControlZoomElement } from './leaflet-control-zoom.ts';
export { default as LeafletControlAttributionElement } from './leaflet-control-attribution.ts';
export { default as LeafletControlScaleElement } from './leaflet-control-scale.ts';
export { default as LeafletPopupElement } from './leaflet-popup.ts';
export { default as LeafletTooltipElement } from './leaflet-tooltip.ts';
export { default as LeafletIconElement } from './leaflet-icon.ts';
export { default as LeafletDivIconElement } from './leaflet-div-icon.ts';

@ -0,0 +1,23 @@
import { CircleMarker, type CircleMarkerOptions } from 'leaflet';
import { num } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export default class LeafletCircleMarkerElement extends WithProps({
...latLngProps,
radius: num<CircleMarker>(10, { get: (obj) => obj.getRadius() }),
...pathProps,
}) {
declare readonly leafletObject?: CircleMarker;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: CircleMarkerOptions): CircleMarker {
return new CircleMarker([this.lat, this.lng], options);
}
}

@ -0,0 +1,25 @@
import { Circle, type CircleOptions } from 'leaflet';
import { num, positional } from '../core/props.ts';
import { latLngProps, pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export default class LeafletCircleElement extends WithProps({
...latLngProps,
...pathProps,
// Leaflet has no default radius -- it throws without one -- so unlike every
// other option, this one is always passed, from the default when unset.
radius: positional(num<Circle>(1000, { get: (obj) => obj.getRadius() })),
}) {
declare readonly leafletObject?: Circle;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: CircleOptions): Circle {
return new Circle([this.lat, this.lng], { ...options, radius: this.radius });
}
}

@ -0,0 +1,17 @@
import { Control, type ControlPosition } from 'leaflet';
import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
export default class LeafletControlAttributionElement extends WithProps(
{
position: choice<ControlPosition>('bottomright'),
prefix: str(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Attribution;
createLeafletObject(options: Control.AttributionOptions): Control.Attribution {
return new Control.Attribution(options);
}
}

@ -0,0 +1,51 @@
import { Control, type ControlPosition } from 'leaflet';
import { bool, choice } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletRegisterEvent } from '../core/register.ts';
export default class LeafletControlLayersElement extends WithProps(
{
position: choice<ControlPosition>('topright'),
collapsed: bool(true),
autoZIndex: bool(true),
hideSingleBase: bool(),
sortLayers: bool(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Layers;
createLeafletObject(options: Control.LayersOptions): Control.Layers {
return new Control.Layers({}, {}, options);
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-register', this.#onChildRegister);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-register', this.#onChildRegister);
super.disconnectedCallback();
}
// Every child -- present at construction or added later -- announces
// itself this way: a parent's connectedCallback always finishes before a
// child's does (even for an already-built subtree attached in one go), so
// there is no "read the children that are already here" case to handle
// separately.
#onChildRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
const el = e.detail.element;
const layer = e.detail.leafletObject;
const name = el.getAttribute('name');
if (!name) return;
if (el.getAttribute('type') === 'base') this.leafletObject?.addBaseLayer(layer, name);
else this.leafletObject?.addOverlay(layer, name);
if (el.hasAttribute('active')) {
this.dispatchEvent(
new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }),
);
}
};
}

@ -0,0 +1,20 @@
import { Control, type ControlPosition } from 'leaflet';
import { bool, choice, num } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
export default class LeafletControlScaleElement extends WithProps(
{
position: choice<ControlPosition>('bottomleft'),
maxWidth: num(100),
metric: bool(true),
imperial: bool(true),
updateWhenIdle: bool(),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Scale;
createLeafletObject(options: Control.ScaleOptions): Control.Scale {
return new Control.Scale(options);
}
}

@ -0,0 +1,22 @@
import { Control, type ControlPosition } from 'leaflet';
import { choice, str } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
// The button labels default to Leaflet's own markup, which hides the glyph
// from screen readers in favour of the title.
export default class LeafletControlZoomElement extends WithProps(
{
position: choice<ControlPosition>('topleft'),
zoomInText: str('<span aria-hidden="true">+</span>'),
zoomInTitle: str('Zoom in'),
zoomOutText: str('<span aria-hidden="true"></span>'),
zoomOutTitle: str('Zoom out'),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Control.Zoom;
createLeafletObject(options: Control.ZoomOptions): Control.Zoom {
return new Control.Zoom(options);
}
}

@ -0,0 +1,46 @@
import { DivIcon, type DivIconOptions, type PointExpression } from 'leaflet';
import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts';
import { WithProps } from '../core/with-props.ts';
// Like leaflet-icon, rebuilt on every change -- including changes to the
// markup, which is what the icon renders when `html` isn't set.
export default class LeafletDivIconElement extends WithProps(
{
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(),
bgPos: json<PointExpression>([0, 0]),
},
{ attach: 'none', recreate: true },
) {
declare readonly leafletObject?: DivIcon;
#observer?: MutationObserver;
createLeafletObject(options: DivIconOptions): DivIcon {
return new DivIcon(this.innerHTML ? { ...options, html: this.innerHTML } : options);
}
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.recreateLeafletObject();
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
super.disconnectedCallback();
emitIconChanged(this, null);
}
leafletObjectCreated(): void {
emitIconChanged(this, this.leafletObject);
}
}

@ -0,0 +1,18 @@
import { FeatureGroup } from 'leaflet';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
// Like leaflet-layer-group, but its children share events and a bounding box.
export default class LeafletFeatureGroupElement extends WithProps({}) {
declare readonly leafletObject?: FeatureGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
createLeafletObject(): FeatureGroup {
return new FeatureGroup([]);
}
}

@ -0,0 +1,32 @@
import { GeoJSON, type PathOptions } from 'leaflet';
import type { GeoJsonObject } from 'geojson';
import { json, positional } from '../core/props.ts';
import { pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
export default class LeafletGeoJSONElement extends WithProps({
data: positional(
json<GeoJsonObject | null>(null, {
set(obj: GeoJSON, value) {
obj.clearLayers();
if (value) obj.addData(value);
},
}),
),
...pathProps,
}) {
declare readonly leafletObject?: GeoJSON;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
// Every prop but `data` is a style option, and GeoJSON takes those nested
// under `style` so they apply to each feature it builds.
createLeafletObject(options: PathOptions): GeoJSON {
return new GeoJSON(this.data, { style: options });
}
}

@ -0,0 +1,40 @@
import { Icon, type IconOptions, type PointExpression } from 'leaflet';
import { json, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts';
import { WithProps } from '../core/with-props.ts';
const PROPS = {
iconUrl: str(),
iconRetinaUrl: str(),
iconSize: json<PointExpression>([0, 0]),
iconAnchor: json<PointExpression>([0, 0]),
popupAnchor: json<PointExpression>([0, 0]),
tooltipAnchor: json<PointExpression>([0, 0]),
shadowUrl: str(),
shadowRetinaUrl: str(),
shadowSize: json<PointExpression>([0, 0]),
shadowAnchor: json<PointExpression>([0, 0]),
className: str(),
} as const;
// An Icon has no setters, so every attribute change builds a new one and the
// parent marker is told to swap it in.
export default class LeafletIconElement extends WithProps(PROPS, {
attach: 'none',
recreate: true,
}) {
declare readonly leafletObject?: Icon;
createLeafletObject(options: Partial<IconOptions>): Icon | undefined {
return options.iconUrl ? new Icon(options as IconOptions) : undefined;
}
leafletObjectCreated(): void {
emitIconChanged(this, this.leafletObject);
}
disconnectedCallback(): void {
super.disconnectedCallback();
emitIconChanged(this, null);
}
}

@ -0,0 +1,39 @@
import {
ImageOverlay,
type CrossOrigin,
type ImageOverlayOptions,
type LatLngBoundsExpression,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds, urlProp } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export default class LeafletImageOverlayElement extends WithProps({
url: urlProp,
bounds: positional(json<LatLngBoundsExpression, ImageOverlay>([], { get: getBounds })),
opacity: num(1.0),
alt: str('', {
set(obj: ImageOverlay, value) {
const el = obj.getElement();
if (el) el.alt = value;
},
}),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
errorOverlayUrl: str(),
zIndex: num(),
className: str(),
}) {
declare readonly leafletObject?: ImageOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: ImageOverlayOptions): ImageOverlay {
return new ImageOverlay(this.url, this.bounds, options);
}
}

@ -0,0 +1,19 @@
import { LayerGroup } from 'leaflet';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { GroupEvents } from '../core/event-types.ts';
// A passthrough container: it has no options of its own, and children add
// themselves to it through the standard registration bubble.
export default class LeafletLayerGroupElement extends WithProps({}) {
declare readonly leafletObject?: LayerGroup;
declare addEventListener: LeafletAddEventListener<GroupEvents>;
declare removeEventListener: LeafletRemoveEventListener<GroupEvents>;
createLeafletObject(): LayerGroup {
return new LayerGroup([]);
}
}

@ -0,0 +1,29 @@
import { emitLineRemove, emitLineSync } from '../core/register.ts';
export default class LeafletLineElement extends HTMLElement {
static get observedAttributes() {
return ['lat', 'lng'];
}
// Cached because disconnectedCallback fires after this is already detached
// from it -- see emitLineRemove.
#parent: ParentNode | null = null;
get latlng(): [number, number] {
return [+(this.getAttribute('lat') ?? 0), +(this.getAttribute('lng') ?? 0)];
}
connectedCallback(): void {
this.#parent = this.parentNode;
emitLineSync(this, this.latlng);
}
attributeChangedCallback(): void {
emitLineSync(this, this.latlng);
}
disconnectedCallback(): void {
emitLineRemove(this.#parent ?? this, this);
this.#parent = null;
}
}

@ -0,0 +1,236 @@
import { Icon, Map as LMap, type MapOptions } from 'leaflet';
import { bool, disabled, num, positional, str } from '../core/props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletLayerEvent, LeafletRegisterEvent } from '../core/register.ts';
import type { MapEvents } from '../core/event-types.ts';
const DEFAULT_CSS_URL = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
const DEFAULT_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
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();
}
// The root of the component tree. Every other component bubbles a
// `leaflet-register` event up to here, which is where it stops.
export default class LeafletMapElement extends WithProps(
{
// 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',
get: (map) => map.getCenter().lat,
set: (map, value) => {
map.setView([value, map.getCenter().lng], map.getZoom());
},
}),
),
lng: positional(
num<LMap>(0, {
event: 'moveend',
get: (map) => map.getCenter().lng,
set: (map, value) => {
map.setView([map.getCenter().lat, value], map.getZoom());
},
}),
),
zoom: positional(
num<LMap>(2, {
event: 'zoomend',
get: (map) => map.getZoom(),
set: (map, value) => {
map.setZoom(value);
},
}),
),
// Numeric options Leaflet lets us change after construction.
minZoom: num<LMap>(0, {
get: (map) => map.getMinZoom(),
set: (map, value) => {
map.setMinZoom(value);
},
}),
maxZoom: num<LMap>(Infinity, {
get: (map) => map.getMaxZoom(),
set: (map, value) => {
map.setMaxZoom(value);
},
}),
// Constructor-only numeric options.
zoomSnap: num(1),
zoomDelta: num(1),
keyboardPanDelta: num(80),
wheelDebounceTime: num(40),
wheelPxPerZoomLevel: num(60),
inertiaDeceleration: num(3000),
inertiaMaxSpeed: num(Infinity),
easeLinearity: num(0.2),
maxBoundsViscosity: num(0),
tapTolerance: num(15),
zoomAnimationThreshold: num(4),
transform3DLimit: num(8388608),
// Options Leaflet defaults to true, turned off with disable-* attributes.
// The interaction handlers can be toggled after construction.
scrollWheelZoom: disabled<LMap>({
set: (map, on) => (on ? map.scrollWheelZoom.enable() : map.scrollWheelZoom.disable()),
}),
dragging: disabled<LMap>({
set: (map, on) => (on ? map.dragging.enable() : map.dragging.disable()),
}),
touchZoom: disabled<LMap>({
set: (map, on) => (on ? map.touchZoom.enable() : map.touchZoom.disable()),
}),
doubleClickZoom: disabled<LMap>({
set: (map, on) => (on ? map.doubleClickZoom.enable() : map.doubleClickZoom.disable()),
}),
boxZoom: disabled<LMap>({
set: (map, on) => (on ? map.boxZoom.enable() : map.boxZoom.disable()),
}),
keyboard: disabled<LMap>({
set: (map, on) => (on ? map.keyboard.enable() : map.keyboard.disable()),
}),
closePopupOnClick: disabled(),
trackResize: disabled(),
zoomControl: disabled(),
attributionControl: disabled(),
inertia: disabled(),
zoomAnimation: disabled(),
fadeAnimation: disabled(),
markerZoomAnimation: disabled(),
bounceAtZoomLimits: disabled(),
tapHold: disabled(),
// Options Leaflet defaults to false.
preferCanvas: bool(),
worldCopyJump: bool(),
// Not Leaflet options at all -- see relinkCss above.
cssUrl: positional(str(DEFAULT_CSS_URL, { set: relinkCss })),
cssIntegrity: positional(str(DEFAULT_CSS_INTEGRITY, { set: relinkCss })),
cssCrossorigin: positional(str('', { set: relinkCss })),
},
{ attach: 'none' },
) {
declare readonly leafletObject?: LMap;
declare addEventListener: LeafletAddEventListener<MapEvents>;
declare removeEventListener: LeafletRemoveEventListener<MapEvents>;
#container?: HTMLDivElement;
#cssLink?: HTMLLinkElement;
#resizeObserver?: ResizeObserver;
createLeafletObject(options: MapOptions): LMap {
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
// doesn't exist yet, so the getters fall back to the attributes.
map.setView([this.lat, this.lng], this.zoom);
return map;
}
connectedCallback(): void {
this.#buildShadowRoot();
this.applyCss();
super.connectedCallback();
this.#resizeObserver = new ResizeObserver(() => this.leafletObject?.invalidateSize());
this.#resizeObserver.observe(this);
this.addEventListener('leaflet-register', this.#onRegister);
this.addEventListener('leaflet-add-layer', this.#onAddLayer);
this.addEventListener('leaflet-remove-layer', this.#onRemoveLayer);
}
disconnectedCallback(): void {
this.#resizeObserver?.disconnect();
this.#resizeObserver = undefined;
this.removeEventListener('leaflet-register', this.#onRegister);
this.removeEventListener('leaflet-add-layer', this.#onAddLayer);
this.removeEventListener('leaflet-remove-layer', this.#onRemoveLayer);
super.disconnectedCallback();
}
// Replaces the <link> in the shadow root, and derives the marker icon path
// from the same URL. Called on connect and whenever a css-* attribute
// 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;
this.#cssLink?.remove();
this.#cssLink = undefined;
const customUrl = this.hasAttribute('css-url');
const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
let integrity: string | null = null;
if (this.hasAttribute('css-integrity')) {
integrity = this.getAttribute('css-integrity') ?? '';
} else if (!customUrl) {
integrity = DEFAULT_CSS_INTEGRITY;
}
let crossorigin: string | undefined;
if (this.hasAttribute('css-crossorigin')) {
crossorigin = this.getAttribute('css-crossorigin') ?? undefined;
} else if (integrity) {
crossorigin = 'anonymous';
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url ?? '';
if (integrity) link.setAttribute('integrity', integrity);
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
root.append(link);
this.#cssLink = link;
Icon.Default.imagePath = url?.replace(/\/[^/]+$/u, '/images/');
}
#buildShadowRoot(): HTMLDivElement {
if (this.#container) return this.#container;
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' });
const container = document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
root.append(container);
const style = document.createElement('style');
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
root.append(style);
this.#container = container;
return container;
}
#onRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
const map = this.leafletObject;
if (map) e.detail.leafletObject.addTo(map);
};
#onAddLayer = (e: LeafletLayerEvent) => {
this.leafletObject?.addLayer(e.detail.layer);
};
#onRemoveLayer = (e: LeafletLayerEvent) => {
this.leafletObject?.removeLayer(e.detail.layer);
};
}

@ -0,0 +1,60 @@
import { Icon, Marker, type MarkerOptions } from 'leaflet';
import { bool, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletIconChangedEvent } from '../core/register.ts';
import type { MarkerEvents } from '../core/event-types.ts';
const PROPS = {
...latLngProps,
// title and alt end up on the <img> Leaflet renders, so live updates go there.
title: str('', {
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),
zIndexOffset: num(),
} as const;
export default class LeafletMarkerElement extends WithProps(PROPS) {
declare readonly leafletObject?: Marker;
declare addEventListener: LeafletAddEventListener<MarkerEvents>;
declare removeEventListener: LeafletRemoveEventListener<MarkerEvents>;
createLeafletObject(options: MarkerOptions): Marker {
return new Marker([this.lat, this.lng], options);
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('icon-changed', this.#onIconChanged);
}
disconnectedCallback(): void {
this.removeEventListener('icon-changed', this.#onIconChanged);
super.disconnectedCallback();
}
// A <leaflet-icon> or <leaflet-div-icon> child announces itself this way.
#onIconChanged = (e: LeafletIconChangedEvent) => {
this.leafletObject?.setIcon(e.detail.icon ?? new Icon.Default());
};
}

@ -0,0 +1,47 @@
import { Polygon, type PolylineOptions } from 'leaflet';
import { pathProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
import { VertexTracker } from '../core/vertex-tracker.ts';
export default class LeafletPolygonElement extends WithProps({ ...pathProps }) {
declare readonly leafletObject?: Polygon;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#vertices = new VertexTracker();
createLeafletObject(options: PolylineOptions): Polygon {
return new Polygon(this.#vertices.coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute --
// each one announces its own position via leaflet-line-sync/-remove, we
// never read a child's state directly.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-line-sync', this.#onLineSync);
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
super.disconnectedCallback();
}
#onLineSync = (e: LeafletLineSyncEvent) => {
this.#vertices.sync(e.detail.element, e.detail.latlng);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
#onLineRemove = (e: LeafletLineRemoveEvent) => {
this.#vertices.remove(e.detail.element);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
}

@ -0,0 +1,54 @@
import { Polyline, type PolylineOptions } from 'leaflet';
import { bool, num } from '../core/props.ts';
import { pathProps, style } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
import { VertexTracker } from '../core/vertex-tracker.ts';
export default class LeafletPolylineElement extends WithProps({
...pathProps,
// Unlike closed shapes, a polyline is unfilled by default.
fill: bool(false, { set: style('fill') }),
smoothFactor: num(1.0),
noClip: bool(),
}) {
declare readonly leafletObject?: Polyline;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#vertices = new VertexTracker();
createLeafletObject(options: PolylineOptions): Polyline {
return new Polyline(this.#vertices.coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute --
// each one announces its own position via leaflet-line-sync/-remove, we
// never read a child's state directly.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-line-sync', this.#onLineSync);
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
super.disconnectedCallback();
}
#onLineSync = (e: LeafletLineSyncEvent) => {
this.#vertices.sync(e.detail.element, e.detail.latlng);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
#onLineRemove = (e: LeafletLineRemoveEvent) => {
this.#vertices.remove(e.detail.element);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
}

@ -0,0 +1,52 @@
import { Popup, type PopupOptions } from 'leaflet';
import { bool, num } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
export default class LeafletPopupElement extends WithProps(
{
...latLngProps,
maxWidth: num(300),
minWidth: num(50),
maxHeight: num(),
autoPan: bool(true),
closeButton: bool(true),
autoClose: bool(true),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Popup;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
#observer?: MutationObserver;
// Content is the element's markup, not an attribute. A popup only carries a
// position of its own when it isn't bound to a parent layer.
createLeafletObject(options: PopupOptions): Popup {
const popup = new Popup({ ...options, content: this.innerHTML });
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
popup.setLatLng([this.lat, this.lng]);
}
return popup;
}
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
super.disconnectedCallback();
}
}

@ -0,0 +1,22 @@
import { Rectangle, type LatLngBoundsExpression, type PolylineOptions } from 'leaflet';
import { json, positional } from '../core/props.ts';
import { pathProps, getBounds } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export default class LeafletRectangleElement extends WithProps({
bounds: positional(json<LatLngBoundsExpression, Rectangle>([], { get: getBounds })),
...pathProps,
}) {
declare readonly leafletObject?: Rectangle;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: PolylineOptions): Rectangle {
return new Rectangle(this.bounds, options);
}
}

@ -0,0 +1,33 @@
import {
SVGOverlay,
type CrossOrigin,
type ImageOverlayOptions,
type LatLngBoundsExpression,
} from 'leaflet';
import { bool, choice, json, num, positional, str } from '../core/props.ts';
import { getBounds } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.ts';
export default class LeafletSVGOverlayElement extends WithProps({
bounds: positional(json<LatLngBoundsExpression, SVGOverlay>([], { get: getBounds })),
opacity: num(1.0),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
zIndex: num(),
className: str(),
}) {
declare readonly leafletObject?: SVGOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: ImageOverlayOptions): SVGOverlay {
const svg =
this.querySelector('svg') ?? document.createElementNS('http://www.w3.org/2000/svg', 'svg');
return new SVGOverlay(svg, this.bounds, options);
}
}

@ -0,0 +1,86 @@
import { CRS, TileLayer, type WMSOptions, type WMSParams } from 'leaflet';
import { bool, str, type PropDef } from '../core/props.ts';
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { TileLayerEvents } from '../core/event-types.ts';
import type { LeafletCRSChangedEvent } from '../core/register.ts';
// WMS request parameters have no individual setters -- they're merged into the
// query string through setParams().
function param<T>(key: keyof WMSParams): (obj: TileLayer.WMS, value: T) => void {
return (obj, value) => {
obj.setParams({ [key]: value } as unknown as WMSParams);
};
}
// `crs` takes a CRS instance, not a primitive. The attribute only covers the
// 4 CRSes Leaflet ships by name -- a CRS Leaflet doesn't ship comes from a
// nested child announcing itself via leaflet-crs-changed instead (see
// #onCrsChanged), which takes priority over this when present. Constructor-
// only, like the rest of tileLayerProps -- Leaflet has no setter for it.
const NAMED_CRS = {
EPSG3857: CRS.EPSG3857,
EPSG4326: CRS.EPSG4326,
EPSG3395: CRS.EPSG3395,
Simple: CRS.Simple,
};
type CrsName = keyof typeof NAMED_CRS;
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 default class LeafletTileLayerWMSElement 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;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
// Set by a nested CRS-providing child; overrides the `crs` attribute's
// named lookup when present.
#childCrs?: CRS;
createLeafletObject(options: WMSOptions): TileLayer.WMS {
return new TileLayer.WMS(
this.url,
this.#childCrs ? { ...options, crs: this.#childCrs } : options,
);
}
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('leaflet-crs-changed', this.#onCrsChanged);
}
disconnectedCallback(): void {
this.removeEventListener('leaflet-crs-changed', this.#onCrsChanged);
super.disconnectedCallback();
}
// A CRS Leaflet doesn't ship comes from any custom element nested here
// that fires this event -- no base class required, just this event shape
// (see register.ts). `crs` is constructor-only, so picking it up means
// rebuilding the whole layer.
#onCrsChanged = (e: LeafletCRSChangedEvent) => {
this.#childCrs = e.detail.crs ?? undefined;
this.recreateLeafletObject();
};
}

@ -0,0 +1,21 @@
import { TileLayer, type TileLayerOptions } from 'leaflet';
import { tileLayerProps, urlProp } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { TileLayerEvents } from '../core/event-types.ts';
export default class LeafletTileLayerElement extends WithProps({
url: urlProp,
...tileLayerProps,
}) {
declare readonly leafletObject?: TileLayer;
declare addEventListener: LeafletAddEventListener<TileLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<TileLayerEvents>;
createLeafletObject(options: TileLayerOptions): TileLayer {
return new TileLayer(this.url, options);
}
}

@ -0,0 +1,52 @@
import { Tooltip, type Direction, type PointExpression, type TooltipOptions } from 'leaflet';
import { bool, choice, json, num, str } from '../core/props.ts';
import { latLngProps } from '../core/shared-props.ts';
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { DivOverlayLayerEvents } from '../core/event-types.ts';
export default class LeafletTooltipElement extends WithProps(
{
...latLngProps,
pane: str(),
offset: json<PointExpression>([0, 0]),
direction: choice<Direction>('auto'),
permanent: bool(),
sticky: bool(),
opacity: num(1.0),
},
{ attach: 'self' },
) {
declare readonly leafletObject?: Tooltip;
declare addEventListener: LeafletAddEventListener<DivOverlayLayerEvents>;
declare removeEventListener: LeafletRemoveEventListener<DivOverlayLayerEvents>;
#observer?: MutationObserver;
// Content is the element's markup, not an attribute. A tooltip only carries a
// position of its own when it isn't bound to a parent layer.
createLeafletObject(options: TooltipOptions): Tooltip {
const tooltip = new Tooltip({ ...options, content: this.innerHTML });
if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
tooltip.setLatLng([this.lat, this.lng]);
}
return tooltip;
}
connectedCallback(): void {
super.connectedCallback();
this.#observer = new MutationObserver(() => {
this.leafletObject?.setContent(this.innerHTML);
});
this.#observer.observe(this, { childList: true, characterData: true, subtree: true });
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
super.disconnectedCallback();
}
}

@ -0,0 +1,54 @@
import {
VideoOverlay,
type CrossOrigin,
type LatLngBoundsExpression,
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,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { PathEvents } from '../core/event-types.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;
};
}
export default class LeafletVideoOverlayElement extends WithProps({
url: urlProp,
bounds: positional(json<LatLngBoundsExpression, VideoOverlay>([], { get: getBounds })),
opacity: num(1.0),
alt: str(),
interactive: bool(),
crossOrigin: choice<CrossOrigin>(''),
loop: bool(false, { set: media('loop') }),
autoplay: bool(false, { set: media('autoplay') }),
muted: bool(false, { set: media('muted') }),
playsInline: bool(false, { attribute: 'playsinline', set: media('playsInline') }),
zIndex: num(),
className: str(),
keepAspectRatio: bool(true),
errorOverlayUrl: str(),
}) {
declare readonly leafletObject?: VideoOverlay;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
createLeafletObject(options: VideoOverlayOptions): VideoOverlay {
return new VideoOverlay(this.url, this.bounds, options);
}
getElement(): HTMLVideoElement | undefined {
return this.leafletObject?.getElement();
}
}

@ -1,22 +1,28 @@
// Export order here is load-bearing, not cosmetic: customElements.define() // The order of the `./components/*` side-effect imports below is load-bearing,
// upgrades every matching element already in the document immediately and // not cosmetic: each of those modules calls `customElements.define()`, and
// synchronously (including running connectedCallback if already connected), // `customElements.define()` upgrades every matching element already in the
// so a "child" custom element that fires a one-shot announcement on connect // document immediately and synchronously (including running connectedCallback
// must be defined *after* every "container" type that needs to hear it -- // if already connected). So a "child" custom element that fires a one-shot
// otherwise every instance already in a static HTML page upgrades and // announcement on connect must be defined *after* every "container" type that
// announces itself before the listening parent exists, and that // needs to hear it -- otherwise every instance already in a static HTML page
// upgrades and announces itself before the listening parent exists, and that
// announcement is lost for good (connectedCallback never fires again for an // announcement is lost for good (connectedCallback never fires again for an
// element that stays connected). This is the same reason leaflet-map is // element that stays connected). This is the same reason leaflet-map is
// exported first: nothing can register with it before it exists to listen. // imported first: nothing can register with it before it exists to listen.
//
// The element classes themselves (`./elements/*`, re-exported just below via
// `./elements/index.ts`) carry no `customElements.define`, so their order is
// free -- only the `./components/*` imports further down register anything.
// //
// The hierarchy, root to leaf: // The hierarchy, root to leaf:
// LeafletMap // LeafletMapElement
// -> LeafletControlLayers, LeafletLayerGroup, LeafletFeatureGroup // -> LeafletControlLayersElement, LeafletLayerGroupElement,
// LeafletFeatureGroupElement
// (all listen for "leaflet-register" from arbitrary layer children) // (all listen for "leaflet-register" from arbitrary layer children)
// -> every concrete layer type (Marker, Circle, Polygon, TileLayer, ...) // -> every concrete layer type (Marker, Circle, Polygon, TileLayer, ...)
// -> LeafletLine (child of Polygon/Polyline specifically) // -> LeafletLineElement (child of Polygon/Polyline specifically)
// -> LeafletPopup, LeafletTooltip (child of any layer) // -> LeafletPopupElement, LeafletTooltipElement (child of any layer)
// -> LeafletIcon, LeafletDivIcon (child of Marker specifically) // -> LeafletIconElement, LeafletDivIconElement (child of Marker)
// Standalone controls (Zoom/Attribution/Scale) have no such relationship and // Standalone controls (Zoom/Attribution/Scale) have no such relationship and
// can go anywhere. // can go anywhere.
export * from './core/register.ts'; export * from './core/register.ts';
@ -28,84 +34,94 @@ export * from './core/props.ts';
export * from './core/with-props.ts'; export * from './core/with-props.ts';
export * from './core/shared-props.ts'; export * from './core/shared-props.ts';
export * from './core/event-types.ts'; export * from './core/event-types.ts';
export { LeafletMap } from './components/leaflet-map.ts';
export { LeafletControlLayers } from './components/leaflet-control-layers.ts';
export { LeafletLayerGroup } from './components/leaflet-layer-group.ts';
export { LeafletFeatureGroup } from './components/leaflet-feature-group.ts';
export { LeafletPolygon } from './components/leaflet-polygon.ts';
export { LeafletPolyline } from './components/leaflet-polyline.ts';
export { LeafletMarker } from './components/leaflet-marker.ts';
export { LeafletCircle } from './components/leaflet-circle.ts';
export { LeafletCircleMarker } from './components/leaflet-circle-marker.ts';
export { LeafletRectangle } from './components/leaflet-rectangle.ts';
export { LeafletTileLayer } from './components/leaflet-tile-layer.ts';
export { LeafletTileLayerWMS } from './components/leaflet-tile-layer-wms.ts';
export { LeafletImageOverlay } from './components/leaflet-image-overlay.ts';
export { LeafletVideoOverlay } from './components/leaflet-video-overlay.ts';
export { LeafletSVGOverlay } from './components/leaflet-svg-overlay.ts';
export { LeafletGeoJSON } from './components/leaflet-geojson.ts';
export { LeafletLine } from './components/leaflet-line.ts';
export { LeafletControlZoom } from './components/leaflet-control-zoom.ts';
export { LeafletControlAttribution } from './components/leaflet-control-attribution.ts';
export { LeafletControlScale } from './components/leaflet-control-scale.ts';
export { LeafletPopup } from './components/leaflet-popup.ts';
export { LeafletTooltip } from './components/leaflet-tooltip.ts';
export { LeafletIcon } from './components/leaflet-icon.ts';
export { LeafletDivIcon } from './components/leaflet-div-icon.ts';
// `export { X } from '...'` re-exports X but doesn't bind it locally, so the // Every element class, with no tag registered. Plugin authors who want a
// tag map below needs its own type-only imports of the same classes. Order // class without the `customElements.define` side effect can also import this
// doesn't matter here -- these are erased, no customElements.define behind them. // module directly, as `leaflet-components/elements`.
import type { LeafletMap } from './components/leaflet-map.ts'; export * from './elements/index.ts';
import type { LeafletMarker } from './components/leaflet-marker.ts';
import type { LeafletCircle } from './components/leaflet-circle.ts'; // Register every tag. Side-effect imports only -- the classes are already
import type { LeafletCircleMarker } from './components/leaflet-circle-marker.ts'; // exported above. Order is load-bearing (see the comment at the top).
import type { LeafletLine } from './components/leaflet-line.ts'; import './components/leaflet-map.ts';
import type { LeafletPolygon } from './components/leaflet-polygon.ts'; import './components/leaflet-control-layers.ts';
import type { LeafletPolyline } from './components/leaflet-polyline.ts'; import './components/leaflet-layer-group.ts';
import type { LeafletRectangle } from './components/leaflet-rectangle.ts'; import './components/leaflet-feature-group.ts';
import type { LeafletTileLayer } from './components/leaflet-tile-layer.ts'; import './components/leaflet-polygon.ts';
import type { LeafletTileLayerWMS } from './components/leaflet-tile-layer-wms.ts'; import './components/leaflet-polyline.ts';
import type { LeafletImageOverlay } from './components/leaflet-image-overlay.ts'; import './components/leaflet-marker.ts';
import type { LeafletVideoOverlay } from './components/leaflet-video-overlay.ts'; import './components/leaflet-circle.ts';
import type { LeafletSVGOverlay } from './components/leaflet-svg-overlay.ts'; import './components/leaflet-circle-marker.ts';
import type { LeafletLayerGroup } from './components/leaflet-layer-group.ts'; import './components/leaflet-rectangle.ts';
import type { LeafletFeatureGroup } from './components/leaflet-feature-group.ts'; import './components/leaflet-tile-layer.ts';
import type { LeafletGeoJSON } from './components/leaflet-geojson.ts'; import './components/leaflet-tile-layer-wms.ts';
import type { LeafletControlLayers } from './components/leaflet-control-layers.ts'; import './components/leaflet-image-overlay.ts';
import type { LeafletControlZoom } from './components/leaflet-control-zoom.ts'; import './components/leaflet-video-overlay.ts';
import type { LeafletControlAttribution } from './components/leaflet-control-attribution.ts'; import './components/leaflet-svg-overlay.ts';
import type { LeafletControlScale } from './components/leaflet-control-scale.ts'; import './components/leaflet-geojson.ts';
import type { LeafletPopup } from './components/leaflet-popup.ts'; import './components/leaflet-line.ts';
import type { LeafletTooltip } from './components/leaflet-tooltip.ts'; import './components/leaflet-control-zoom.ts';
import type { LeafletIcon } from './components/leaflet-icon.ts'; import './components/leaflet-control-attribution.ts';
import type { LeafletDivIcon } from './components/leaflet-div-icon.ts'; import './components/leaflet-control-scale.ts';
import './components/leaflet-popup.ts';
import './components/leaflet-tooltip.ts';
import './components/leaflet-icon.ts';
import './components/leaflet-div-icon.ts';
// `export * from './elements/index.ts'` above re-exports these names but
// doesn't bind them locally, so the tag map needs its own type-only import.
// Erased at build, backs no `define()` -- order doesn't matter.
import type {
LeafletMapElement,
LeafletMarkerElement,
LeafletCircleElement,
LeafletCircleMarkerElement,
LeafletLineElement,
LeafletPolygonElement,
LeafletPolylineElement,
LeafletRectangleElement,
LeafletTileLayerElement,
LeafletTileLayerWMSElement,
LeafletImageOverlayElement,
LeafletVideoOverlayElement,
LeafletSVGOverlayElement,
LeafletLayerGroupElement,
LeafletFeatureGroupElement,
LeafletGeoJSONElement,
LeafletControlLayersElement,
LeafletControlZoomElement,
LeafletControlAttributionElement,
LeafletControlScaleElement,
LeafletPopupElement,
LeafletTooltipElement,
LeafletIconElement,
LeafletDivIconElement,
} from './elements/index.ts';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'leaflet-map': LeafletMap; 'leaflet-map': LeafletMapElement;
'leaflet-marker': LeafletMarker; 'leaflet-marker': LeafletMarkerElement;
'leaflet-circle': LeafletCircle; 'leaflet-circle': LeafletCircleElement;
'leaflet-circle-marker': LeafletCircleMarker; 'leaflet-circle-marker': LeafletCircleMarkerElement;
'leaflet-line': LeafletLine; 'leaflet-line': LeafletLineElement;
'leaflet-polygon': LeafletPolygon; 'leaflet-polygon': LeafletPolygonElement;
'leaflet-polyline': LeafletPolyline; 'leaflet-polyline': LeafletPolylineElement;
'leaflet-rectangle': LeafletRectangle; 'leaflet-rectangle': LeafletRectangleElement;
'leaflet-tile-layer': LeafletTileLayer; 'leaflet-tile-layer': LeafletTileLayerElement;
'leaflet-tile-layer-wms': LeafletTileLayerWMS; 'leaflet-tile-layer-wms': LeafletTileLayerWMSElement;
'leaflet-image-overlay': LeafletImageOverlay; 'leaflet-image-overlay': LeafletImageOverlayElement;
'leaflet-video-overlay': LeafletVideoOverlay; 'leaflet-video-overlay': LeafletVideoOverlayElement;
'leaflet-svg-overlay': LeafletSVGOverlay; 'leaflet-svg-overlay': LeafletSVGOverlayElement;
'leaflet-layer-group': LeafletLayerGroup; 'leaflet-layer-group': LeafletLayerGroupElement;
'leaflet-feature-group': LeafletFeatureGroup; 'leaflet-feature-group': LeafletFeatureGroupElement;
'leaflet-geojson': LeafletGeoJSON; 'leaflet-geojson': LeafletGeoJSONElement;
'leaflet-control-layers': LeafletControlLayers; 'leaflet-control-layers': LeafletControlLayersElement;
'leaflet-control-zoom': LeafletControlZoom; 'leaflet-control-zoom': LeafletControlZoomElement;
'leaflet-control-attribution': LeafletControlAttribution; 'leaflet-control-attribution': LeafletControlAttributionElement;
'leaflet-control-scale': LeafletControlScale; 'leaflet-control-scale': LeafletControlScaleElement;
'leaflet-popup': LeafletPopup; 'leaflet-popup': LeafletPopupElement;
'leaflet-tooltip': LeafletTooltip; 'leaflet-tooltip': LeafletTooltipElement;
'leaflet-icon': LeafletIcon; 'leaflet-icon': LeafletIconElement;
'leaflet-div-icon': LeafletDivIcon; 'leaflet-div-icon': LeafletDivIconElement;
} }
} }

@ -6,14 +6,16 @@
// that typecheck with "Unused '@ts-expect-error' directive". // that typecheck with "Unused '@ts-expect-error' directive".
import type { DragEndEvent, Popup } from 'leaflet'; import type { DragEndEvent, Popup } from 'leaflet';
import { describe, expectTypeOf, it } from 'vitest'; import { describe, expectTypeOf, it } from 'vitest';
import type { LeafletMap } from '../../src/components/leaflet-map.ts'; import type {
import type { LeafletMarker } from '../../src/components/leaflet-marker.ts'; LeafletControlZoomElement,
import type { LeafletControlZoom } from '../../src/components/leaflet-control-zoom.ts'; LeafletMapElement,
LeafletMarkerElement,
} from '../../src/elements/index.ts';
describe('HTMLElementTagNameMap augmentation', () => { describe('HTMLElementTagNameMap augmentation', () => {
it('createElement/querySelector infer the right component class', () => { it('createElement/querySelector infer the right component class', () => {
expectTypeOf(document.createElement('leaflet-map')).toEqualTypeOf<LeafletMap>(); expectTypeOf(document.createElement('leaflet-map')).toEqualTypeOf<LeafletMapElement>();
expectTypeOf(document.createElement('leaflet-marker')).toEqualTypeOf<LeafletMarker>(); expectTypeOf(document.createElement('leaflet-marker')).toEqualTypeOf<LeafletMarkerElement>();
}); });
}); });
@ -45,7 +47,9 @@ describe('per-component addEventListener typing', () => {
it('components with no custom events keep the default HTMLElementEventMap typing', () => { it('components with no custom events keep the default HTMLElementEventMap typing', () => {
const zoom = document.createElement('leaflet-control-zoom'); const zoom = document.createElement('leaflet-control-zoom');
expectTypeOf(zoom.addEventListener).toEqualTypeOf<LeafletControlZoom['addEventListener']>(); expectTypeOf(zoom.addEventListener).toEqualTypeOf<
LeafletControlZoomElement['addEventListener']
>();
zoom.addEventListener('click', (e) => { zoom.addEventListener('click', (e) => {
expectTypeOf(e).toEqualTypeOf<HTMLElementEventMap['click']>(); expectTypeOf(e).toEqualTypeOf<HTMLElementEventMap['click']>();
}); });

@ -57,7 +57,7 @@ describe('real page load order (markup before customElements.define)', () => {
// a page does `import 'leaflet-components'`. // a page does `import 'leaflet-components'`.
const lc = await import('../src/index.ts'); const lc = await import('../src/index.ts');
expect(map).toBeInstanceOf(lc.LeafletMap); expect(map).toBeInstanceOf(lc.LeafletMapElement);
expect(map.leafletObject).toBeDefined(); expect(map.leafletObject).toBeDefined();
expect(polygon.leafletObject?.getLatLngs()).toEqual([ expect(polygon.leafletObject?.getLatLngs()).toEqual([
[ [

Loading…
Cancel
Save