diff --git a/CLAUDE.md b/CLAUDE.md index d0a39fe..18a008d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,27 +32,31 @@ This library wraps [Leaflet.js](https://leafletjs.com/) as native Web Components ### `WithProps` mixin: `src/core/with-props.ts` -The `WithProps(Base, PROPS)` mixin factory replaces the old `LeafletElement` base class. Each component defines a `PROPS` table (a const record mapping kebab-case attribute names to `PropDef` descriptors), then extends `WithProps(HTMLElement, 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 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: -- `observedAttributes` getter derived from the PROPS table keys. -- `definePropAccessors` — property getters/setters on the prototype that sync attributes. -- On connect, `initOptions()` builds a Leaflet options object from current attributes + PROPS defaults, then calls `createLeafletObject()`. -- On attribute change, `updateLeafletObject()` by default dispatches to the matching Leaflet setter (e.g. `setOpacity`, `setRadius`). Components override this for custom attribute handling (e.g. lat/lng pairs). -- Every Leaflet event the created object fires is re-emitted on the element as `leaflet:` (e.g. `leaflet:zoomend`, `leaflet:dragend`), carrying the original Leaflet event object as `event.detail`. This is generic and automatic: `WithProps` wraps the object's own `fire()` method, so no per-component or per-event-type registration is needed. Not bubbling — Leaflet already propagates layer events up to the map, so `` would otherwise see each one twice. +- `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. +- On connect, the private `#buildOptions()` builds a Leaflet options object from current attributes + PROPS defaults (skipping any prop marked `positional()`), then `createLeafletObject()` is called with it. +- On attribute change, `attributeChangedCallback` dispatches to a prop's own `set` function if it has one, otherwise the matching Leaflet setter (e.g. `setOpacity`, `setRadius`) if the object has one — otherwise it's a silent no-op (many options are constructor-only). `options.recreate` switches this to rebuilding the whole object instead (for objects Leaflet gives no in-place mutation, like icons). +- Every Leaflet event the created object fires is re-emitted on the element as `leaflet:` (e.g. `leaflet:zoomend`, `leaflet:dragend`), with `detail` matching exactly what a real Leaflet `.on()` listener would receive (`type`/`target`/`sourceTarget` plus the event's own fields). This is generic and automatic: the private `#forwardEvents()` wraps the object's own `fire()` method, so no per-component or per-event-type registration is needed. Not bubbling — Leaflet already propagates layer events up to the map, so `` would otherwise see each one twice. +- `options.attach` controls how the element joins the component tree: `'children'` (default) registers with its parent and adopts registering descendants as layers/popups/tooltips; `'self'` (popups, tooltips, controls) registers with its parent but manages no children; `'none'` (the map, icons) does neither. + +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` -`LeafletMap` extends `HTMLElement` directly and uses Shadow DOM. It is the root of the component tree and terminates all bubbling `leaflet-register` events by calling `layer.addTo(this.map)`. Uses a `ResizeObserver` on the host element to call `map.invalidateSize()` automatically. +`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. ### Child component pattern -All non-map components extend `WithProps(HTMLElement, PROPS)`. To add a new component: +All non-map components extend `WithProps(PROPS, options?)`. To add a new component: -1. Define a `PROPS = {...}` const table mapping kebab-case attributes to `PropDef` entries. -2. Declare `class LeafletFoo extends WithProps(HTMLElement, PROPS)` implementing `createLeafletObject(): L.Layer`. -3. Override `updateLeafletObject(name, val)` only if the default setter-based update won't work (common for coordinate pairs). +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`). +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). +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. -5. Export from `src/index.ts`. +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). +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;` / `declare removeEventListener: LeafletRemoveEventListener;` (see "Typing a component's events" below). ### Special cases @@ -60,6 +64,17 @@ All non-map components extend `WithProps(HTMLElement, PROPS)`. To add a new comp - **`leaflet-popup`** / **`leaflet-tooltip`**: content comes from `innerHTML`, not attributes. `leaflet-popup` watches for DOM mutations to keep Leaflet in sync. - **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers built with `WithProps({})` (an empty props table) — they have no options of their own, but still get the standard lifecycle, child registration, and `leaflet:` event forwarding for free. Children register themselves into them via the standard bubble mechanism. +### Typing a component's events: `src/core/event-types.ts` + +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. +- **Per-component `addEventListener`/`removeEventListener`** typing for `leaflet:` 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;` (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. + +If you add or change what events a component fires, keep `#forwardEvents`'s `detail` shape (`with-props.ts`) in mind: it mirrors Leaflet's own `Evented#fire` merge (original data plus `type`/`target`/`sourceTarget`) so the event-types.ts payload types stay honest — don't type an event's `detail` against a Leaflet interface it wouldn't actually match at runtime. + ### 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`). diff --git a/README.md b/README.md index b8ead90..cebbbb5 100644 --- a/README.md +++ b/README.md @@ -10,26 +10,17 @@ npm install leaflet-components ## Import options -The package ships multiple bundle formats and supports deep imports for tree-shaking. +The package is ESM-only — `tsc` emits `dist/` as individual modules, one per component, with no bundling step. Deep imports work for registering only what you use. ```js -// Default ESM bundle (recommended) — registers all components +// Registers all components import 'leaflet-components'; -// Minified ESM bundle -import 'leaflet-components/dist/index.min.js'; - -// CommonJS -const lc = require('leaflet-components'); - // Deep import — register only one component import 'leaflet-components/dist/components/leaflet-marker.js'; - -// UMD (for script tags, expects Leaflet as window.L) -// ``` -`leaflet` is always an external dependency — you must install it yourself. +`leaflet` is always an external dependency — you must install it yourself. There is no CommonJS or UMD build. ## Usage @@ -51,7 +42,14 @@ Import once to register all custom elements, then use them declaratively in HTML Hover me - + I am a circle @@ -70,100 +68,100 @@ Import once to register all custom elements, then use them declaratively in HTML The root component. All other components must be children of ``. ```html - + ``` ### View state These attributes stay in sync with the map as the user interacts with it — panning updates `lat`/`lng`, zooming updates `zoom`. -| Attribute | Default | Description | -|---|---|---| -| `lat` | `0` | Center latitude | -| `lng` | `0` | Center longitude | -| `zoom` | `2` | Zoom level | -| `min-zoom` | `0` | Minimum zoom level | -| `max-zoom` | — | Maximum zoom level. When unset, Leaflet uses the tile layer's own max zoom. | +| Attribute | Default | Description | +| ---------- | ------- | --------------------------------------------------------------------------- | +| `lat` | `0` | Center latitude | +| `lng` | `0` | Center longitude | +| `zoom` | `2` | Zoom level | +| `min-zoom` | `0` | Minimum zoom level | +| `max-zoom` | — | Maximum zoom level. When unset, Leaflet uses the tile layer's own max zoom. | ### Interaction Boolean options that default to `true` are controlled by the presence of a `disable-*` attribute. Handler-based options can be toggled at any time; the rest only apply at construction. -| Attribute | Controls | Live? | -|---|---|---| -| `disable-dragging` | Mouse/touch panning | ✓ | -| `disable-scroll-wheel-zoom` | Scroll-wheel zoom | ✓ | -| `disable-double-click-zoom` | Double-click zoom | ✓ | -| `disable-touch-zoom` | Pinch-to-zoom | ✓ | -| `disable-box-zoom` | Shift-drag zoom box | ✓ | -| `disable-keyboard` | Keyboard pan/zoom | ✓ | -| `disable-zoom-control` | Built-in zoom control | — | -| `disable-attribution-control` | Built-in attribution | — | -| `disable-close-popup-on-click` | Close popup on map click | — | -| `disable-track-resize` | Auto-resize on window resize | — | -| `disable-bounce-at-zoom-limits` | Bounce animation at min/max zoom | — | -| `disable-tap-hold` | Long-press context menu (mobile) | — | +| Attribute | Controls | Live? | +| ------------------------------- | -------------------------------- | ----- | +| `disable-dragging` | Mouse/touch panning | ✓ | +| `disable-scroll-wheel-zoom` | Scroll-wheel zoom | ✓ | +| `disable-double-click-zoom` | Double-click zoom | ✓ | +| `disable-touch-zoom` | Pinch-to-zoom | ✓ | +| `disable-box-zoom` | Shift-drag zoom box | ✓ | +| `disable-keyboard` | Keyboard pan/zoom | ✓ | +| `disable-zoom-control` | Built-in zoom control | — | +| `disable-attribution-control` | Built-in attribution | — | +| `disable-close-popup-on-click` | Close popup on map click | — | +| `disable-track-resize` | Auto-resize on window resize | — | +| `disable-bounce-at-zoom-limits` | Bounce animation at min/max zoom | — | +| `disable-tap-hold` | Long-press context menu (mobile) | — | Boolean options that default to `false` are enabled by adding the attribute: -| Attribute | Description | -|---|---| -| `prefer-canvas` | Render vector layers on Canvas instead of SVG | +| Attribute | Description | +| ----------------- | ------------------------------------------------------------- | +| `prefer-canvas` | Render vector layers on Canvas instead of SVG | | `world-copy-jump` | Pan to the original world copy when crossing the antimeridian | ### Animation -| Attribute | Default | Description | -|---|---|---| -| `disable-zoom-animation` | — | Disable CSS zoom animation | -| `disable-fade-animation` | — | Disable tile fade-in | -| `disable-marker-zoom-animation` | — | Disable marker zoom animation | -| `zoom-animation-threshold` | `4` | Max zoom delta for animated zoom | +| Attribute | Default | Description | +| ------------------------------- | ------- | -------------------------------- | +| `disable-zoom-animation` | — | Disable CSS zoom animation | +| `disable-fade-animation` | — | Disable tile fade-in | +| `disable-marker-zoom-animation` | — | Disable marker zoom animation | +| `zoom-animation-threshold` | `4` | Max zoom delta for animated zoom | ### Inertia & panning -| Attribute | Default | Description | -|---|---|---| -| `disable-inertia` | — | Disable inertial panning | -| `inertia-deceleration` | `3000` | Deceleration rate (px/s²) | -| `inertia-max-speed` | `Infinity` | Maximum inertia speed (px/s) | -| `ease-linearity` | `0.2` | Pan easing linearity | +| Attribute | Default | Description | +| ---------------------- | ---------- | ---------------------------- | +| `disable-inertia` | — | Disable inertial panning | +| `inertia-deceleration` | `3000` | Deceleration rate (px/s²) | +| `inertia-max-speed` | `Infinity` | Maximum inertia speed (px/s) | +| `ease-linearity` | `0.2` | Pan easing linearity | ### Zoom behaviour -| Attribute | Default | Description | -|---|---|---| -| `zoom-snap` | `1` | Zoom snapping interval; `0` for continuous zoom | -| `zoom-delta` | `1` | Zoom step per keyboard/button press | -| `max-bounds-viscosity` | `0` | How much the bounds resist panning past them (`0`–`1`) | +| Attribute | Default | Description | +| ---------------------- | ------- | ------------------------------------------------------ | +| `zoom-snap` | `1` | Zoom snapping interval; `0` for continuous zoom | +| `zoom-delta` | `1` | Zoom step per keyboard/button press | +| `max-bounds-viscosity` | `0` | How much the bounds resist panning past them (`0`–`1`) | ### Scroll wheel -| Attribute | Default | Description | -|---|---|---| -| `wheel-debounce-time` | `40` | Debounce delay for wheel events (ms) | -| `wheel-px-per-zoom-level` | `60` | Pixels of scroll per zoom level | +| Attribute | Default | Description | +| ------------------------- | ------- | ------------------------------------ | +| `wheel-debounce-time` | `40` | Debounce delay for wheel events (ms) | +| `wheel-px-per-zoom-level` | `60` | Pixels of scroll per zoom level | ### Keyboard & touch -| Attribute | Default | Description | -|---|---|---| -| `keyboard-pan-delta` | `80` | Pan distance per key press (px) | -| `tap-tolerance` | `15` | Max touch movement to trigger a tap (px) | +| Attribute | Default | Description | +| -------------------- | ------- | ---------------------------------------- | +| `keyboard-pan-delta` | `80` | Pan distance per key press (px) | +| `tap-tolerance` | `15` | Max touch movement to trigger a tap (px) | ### Rendering -| Attribute | Default | Description | -|---|---|---| +| Attribute | Default | Description | +| -------------------- | --------- | ---------------------------------------------------------- | | `transform-3d-limit` | `8388608` | Max CSS `translate3d` component value before a layer reset | ### Accessing the underlying map ```js const el = document.querySelector('leaflet-map'); -el.leafletObject; // L.Map instance (undefined before connected) -el.zoom; // current zoom (reads live from map, falls back to attribute) -el.scrollWheelZoom = false; // disable at runtime +el.leafletObject; // L.Map instance (undefined before connected) +el.zoom; // current zoom (reads live from map, falls back to attribute) +el.scrollWheelZoom = false; // disable at runtime ``` All `leaflet-map` properties are two-way: reading returns the live map value; writing updates both the attribute and the map. @@ -186,84 +184,182 @@ Returns `undefined` while the element isn't connected to the DOM. Leaflet's stylesheet is loaded from a CDN via a `` in the shadow DOM. Marker icon paths are derived from the CSS URL automatically. -| Attribute | Default | Description | -|---|---|---| -| `css-url` | `https://unpkg.com/leaflet@1.9.4/dist/leaflet.css` | URL for Leaflet CSS | -| `css-integrity` | `sha256-...` | SRI hash (required with default URL; when `css-url` is custom, no default integrity is used unless explicitly set) | -| `css-crossorigin` | _(auto)_ | `"anonymous"` when integrity is active, otherwise absent. Set explicitly to override. | +| Attribute | Default | Description | +| ----------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `css-url` | `https://unpkg.com/leaflet@1.9.4/dist/leaflet.css` | URL for Leaflet CSS | +| `css-integrity` | `sha256-...` | SRI hash (required with default URL; when `css-url` is custom, no default integrity is used unless explicitly set) | +| `css-crossorigin` | _(auto)_ | `"anonymous"` when integrity is active, otherwise absent. Set explicitly to override. | ### Resize A `ResizeObserver` on the host element automatically calls `map.invalidateSize()` whenever the element's dimensions change — from CSS classes, inline styles, attribute changes, or parent layout. +## Events + +Every event the underlying Leaflet object fires is re-emitted on the element as a `leaflet:` DOM event — `zoomend` becomes `leaflet:zoomend`, `popupopen` becomes `leaflet:popupopen`, and so on. This is generic and automatic for every component; nothing needs to be configured per event. + +```js +const map = document.querySelector('leaflet-map'); +map.addEventListener('leaflet:moveend', (e) => { + console.log(e.detail.target.getCenter()); +}); + +const marker = document.querySelector('leaflet-marker'); +marker.addEventListener('leaflet:dragend', (e) => { + console.log('dragged', e.detail.distance, 'px'); +}); +``` + +`event.detail` is the same object a native Leaflet `.on()` listener receives — it always carries `type`, `target`, and `sourceTarget` in addition to whatever fields that particular event adds (`latlng` for mouse events, `popup` for `popupopen`/`popupclose`, `distance` for `dragend`, etc.). These events don't bubble: Leaflet already propagates layer events up to the map internally, so a bubbling DOM event would make `` see each one twice. + +Which events a component can fire depends on the Leaflet class it wraps — see [Leaflet's own event reference](https://leafletjs.com/reference.html) for the full list per class (Map, Layer, Marker, Path, Popup, TileLayer, etc.). + +## TypeScript + +`leaflet-*` elements are typed. `document.createElement`/`querySelector` infer the right component class for every tag: + +```ts +const map = document.querySelector('leaflet-map'); // LeafletMap | null +``` + +`addEventListener` is typed per component against the real Leaflet event payload for events that component actually fires — ordinary DOM events (`click`, etc.) still work normally, and an event name the component doesn't fire is a compile error: + +```ts +marker.addEventListener('leaflet:dragend', (e) => { + e.detail.distance; // number — DragEndEvent +}); + +// Type error: baselayerchange is only fired by , not . +marker.addEventListener('leaflet:baselayerchange', () => {}); +``` + +There's no generic fallback overload for `leaflet:*` names — that's deliberate, so a typo or a wrong event name doesn't silently type-check. A genuinely dynamic (non-literal) event name string needs a cast. + --- ## Components ### `` -| Attribute | Default | Description | -|---|---|---| -| `url` | `''` | Tile URL template (`https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`) | -| `attribution` | `''` | Attribution text | -| `min-zoom` | `0` | Minimum zoom level | -| `max-zoom` | `18` | Maximum zoom level | -| `opacity` | `1.0` | Tile layer opacity | -| `z-index` | `0` | Z-index | +| Attribute | Default | Description | +| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `url` | `''` | Tile URL template (`https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`) | +| `attribution` | `''` | Attribution text | +| `min-zoom` | `0` | Minimum zoom level | +| `max-zoom` | `18` | Maximum zoom level | +| `opacity` | `1.0` | Tile layer opacity | +| `z-index` | `1` | Z-index | +| `subdomains` | `'abc'` | Subdomains for `{s}` in the URL template | +| `tms` | — | Use TMS tile coordinate scheme | +| `zoom-offset` | `0` | Offset added to the zoom level when requesting tiles | +| `zoom-reverse` | — | Reverse the zoom level when requesting tiles | +| `detect-retina` | — | Request higher-resolution tiles on retina displays | +| `cross-origin` | `''` | CORS setting for tile images | +| `referrer-policy` | — | `referrerPolicy` for tile image requests | +| `error-tile-url` | `''` | Fallback tile image on load error | +| `tile-size` | `256` | Tile size in pixels | +| `no-wrap` | — | Don't wrap tiles horizontally across the antimeridian | +| `bounds` | `''` | Restrict tile loading to this bounding box, as JSON: `[[south,west],[north,east]]` | +| `class-name` | `''` | CSS class for tile images | +| `min-native-zoom` | — | Lowest zoom level the tile source natively supports | +| `max-native-zoom` | — | Highest zoom level the tile source natively supports | +| `keep-buffer` | `2` | Extra rows/columns of tiles to keep loaded outside the viewport | +| `update-when-idle` | — | Update tiles only when the map stops moving | +| `update-when-zooming` | `true` (toggle with `update-when-zooming="false"`) | Update tiles continuously while zooming | +| `update-interval` | `200` | Throttle between tile updates while panning (ms) | +| `pane` | `'tilePane'` | Map pane name | + +Most of these apply only at construction — Leaflet exposes no setter for them, so changing the attribute afterward has no effect. `min-zoom`, `max-zoom`, `opacity`, and `z-index` are live. ### `` -| Attribute | Default | Description | -|---|---|---| -| `url` | `''` | WMS service URL | -| `layers` | `''` | Comma-separated layer names | -| `styles` | `''` | Comma-separated style names | -| `format` | `'image/jpeg'` | Image format | -| `transparent` | — | Request transparent tiles | -| `version` | `'1.1.1'` | WMS version | -| `uppercase` | — | Use uppercase WMS parameter names | +All `` attributes above apply (a WMS layer is a tile layer), plus: + +| Attribute | Default | Description | +| ------------- | -------------- | -------------------------------------------------------------------------------------------- | +| `url` | `''` | WMS service URL | +| `layers` | `''` | Comma-separated layer names | +| `styles` | `''` | Comma-separated style names | +| `format` | `'image/jpeg'` | Image format | +| `transparent` | — | Request transparent tiles | +| `version` | `'1.1.1'` | WMS version | +| `uppercase` | — | Use uppercase WMS parameter names | +| `crs` | — | Coordinate reference system by name: `'EPSG3857'`, `'EPSG4326'`, `'EPSG3395'`, or `'Simple'` | ### `` -| Attribute | Default | Description | -|---|---|---| -| `lat` | `0` | Latitude | -| `lng` | `0` | Longitude | -| `title` | `''` | Tooltip text on hover | -| `alt` | `''` | Alt text for the marker image | -| `draggable` | — | Allow dragging the marker | -| `opacity` | `1.0` | Marker opacity | -| `z-index-offset` | `0` | Z-index offset | +| Attribute | Default | Description | +| ---------------- | ------- | ----------------------------- | +| `lat` | `0` | Latitude | +| `lng` | `0` | Longitude | +| `title` | `''` | Tooltip text on hover | +| `alt` | `''` | Alt text for the marker image | +| `draggable` | — | Allow dragging the marker | +| `opacity` | `1.0` | Marker opacity | +| `z-index-offset` | `0` | Z-index offset | + +A `` or `` child sets the marker's icon (see below); without one, Leaflet's default icon is used. + +### `` + +Sets the marker icon to an image. Not rendered itself — swapped into a parent `` via `setIcon()` whenever an attribute changes. Produces no icon (Leaflet's default is used instead) until `icon-url` is set. + +| Attribute | Default | Description | +| ------------------- | ------- | --------------------------------------------------------------------- | +| `icon-url` | `''` | Icon image URL (required) | +| `icon-retina-url` | `''` | Higher-resolution icon image URL | +| `icon-size` | — | Icon size as JSON: `[width, height]` | +| `icon-anchor` | — | Point of the icon aligned to the marker's location, as JSON: `[x, y]` | +| `popup-anchor` | — | Popup anchor point relative to `icon-anchor`, as JSON: `[x, y]` | +| `tooltip-anchor` | — | Tooltip anchor point relative to `icon-anchor`, as JSON: `[x, y]` | +| `shadow-url` | `''` | Shadow image URL | +| `shadow-retina-url` | `''` | Higher-resolution shadow image URL | +| `shadow-size` | — | Shadow size as JSON: `[width, height]` | +| `shadow-anchor` | — | Shadow anchor point, as JSON: `[x, y]` | +| `class-name` | `''` | CSS class for the icon image | + +### `` + +Sets the marker icon to an HTML element instead of an image. Content comes from `innerHTML` (or the `html` attribute if no markup is given) — either way it's rebuilt on every change, the same as ``. + +| Attribute | Default | Description | +| ---------------- | -------------------- | --------------------------------------------------------------------- | +| `icon-size` | — | Icon size as JSON: `[width, height]` | +| `icon-anchor` | — | Point of the icon aligned to the marker's location, as JSON: `[x, y]` | +| `popup-anchor` | — | Popup anchor point relative to `icon-anchor`, as JSON: `[x, y]` | +| `tooltip-anchor` | — | Tooltip anchor point relative to `icon-anchor`, as JSON: `[x, y]` | +| `class-name` | `'leaflet-div-icon'` | CSS class for the icon element | +| `html` | `''` | HTML content, if not using `innerHTML` | +| `bg-pos` | — | Background position offset, as JSON: `[x, y]` | ### `` All [path style](#path-style) attributes apply. -| Attribute | Default | Description | -|---|---|---| -| `lat` | `0` | Center latitude | -| `lng` | `0` | Center longitude | -| `radius` | `1000` | Radius in meters | +| Attribute | Default | Description | +| --------- | ------- | ---------------- | +| `lat` | `0` | Center latitude | +| `lng` | `0` | Center longitude | +| `radius` | `1000` | Radius in meters | ### `` All [path style](#path-style) attributes apply. -| Attribute | Default | Description | -|---|---|---| -| `lat` | `0` | Center latitude | -| `lng` | `0` | Center longitude | -| `radius` | `10` | Radius in pixels | +| Attribute | Default | Description | +| --------- | ------- | ---------------- | +| `lat` | `0` | Center latitude | +| `lng` | `0` | Center longitude | +| `radius` | `10` | Radius in pixels | ### `` -Coordinates are taken from child `` elements, not from attributes. All [path style](#path-style) attributes apply (excluding `fill*` — polylines don't fill). +Coordinates are taken from child `` elements, not from attributes. All [path style](#path-style) attributes apply, except `fill` defaults to unfilled (a polyline isn't a closed shape, but it can still be filled explicitly). -| Attribute | Default | Description | -|---|---|---| -| `color` | `'#3388ff'` | Stroke color | -| `weight` | `3` | Stroke width (px) | -| `opacity` | `1.0` | Stroke opacity | +| Attribute | Default | Description | +| --------------- | ------- | ------------------------------------------------------------------------------- | +| `smooth-factor` | `1.0` | Simplification factor applied while panning/zooming — higher is more simplified | +| `no-clip` | — | Disable polyline clipping | ### `` @@ -273,60 +369,64 @@ Coordinates are taken from child `` elements, not from attributes. All [path style](#path-style) attributes apply. -| Attribute | Default | Description | -|---|---|---| -| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | +| Attribute | Default | Description | +| --------- | ------- | --------------------------------------------------- | +| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | ### `` Vertex helper — not rendered directly. Used as a child of `` or ``. | Attribute | Default | Description | -|---|---|---| -| `lat` | `0` | Latitude | -| `lng` | `0` | Longitude | +| --------- | ------- | ----------- | +| `lat` | `0` | Latitude | +| `lng` | `0` | Longitude | ### `` -| Attribute | Default | Description | -|---|---|---| -| `url` | `''` | Image URL | -| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | -| `opacity` | `1.0` | Overlay opacity | -| `alt` | `''` | Alt text | -| `interactive` | — | Receive mouse/touch events | -| `cross-origin` | `''` | CORS setting for the image | -| `error-overlay-url` | `''` | Fallback image on load error | -| `z-index` | `0` | Z-index | -| `class-name` | `''` | CSS class for the image element | +| Attribute | Default | Description | +| ------------------- | ------- | --------------------------------------------------- | +| `url` | `''` | Image URL | +| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | +| `opacity` | `1.0` | Overlay opacity | +| `alt` | `''` | Alt text | +| `interactive` | — | Receive mouse/touch events | +| `cross-origin` | `''` | CORS setting for the image | +| `error-overlay-url` | `''` | Fallback image on load error | +| `z-index` | `0` | Z-index | +| `class-name` | `''` | CSS class for the image element | ### `` -| Attribute | Default | Description | -|---|---|---| -| `url` | `''` | Video URL | -| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | -| `opacity` | `1.0` | Overlay opacity | -| `alt` | `''` | Alt text | -| `interactive` | — | Receive mouse/touch events | -| `cross-origin` | `''` | CORS setting for the video | -| `loop` | — | Loop playback | -| `autoplay` | — | Start playing automatically | -| `muted` | — | Mute audio | -| `playsinline` | — | Play inline (mobile) | +| Attribute | Default | Description | +| ------------------- | ------------------------------------------------ | --------------------------------------------------- | +| `url` | `''` | Video URL | +| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | +| `opacity` | `1.0` | Overlay opacity | +| `alt` | `''` | Alt text | +| `interactive` | — | Receive mouse/touch events | +| `cross-origin` | `''` | CORS setting for the video | +| `loop` | — | Loop playback | +| `autoplay` | — | Start playing automatically | +| `muted` | — | Mute audio | +| `playsinline` | — | Play inline (mobile) | +| `z-index` | `0` | Z-index | +| `class-name` | `''` | CSS class for the video element | +| `keep-aspect-ratio` | `true` (toggle with `keep-aspect-ratio="false"`) | Preserve the video's aspect ratio within its bounds | +| `error-overlay-url` | `''` | Fallback image on load error | ### `` Content comes from an inline `` child element. No `url` attribute. -| Attribute | Default | Description | -|---|---|---| -| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | -| `opacity` | `1.0` | Overlay opacity | -| `interactive` | — | Receive mouse/touch events | -| `cross-origin` | `''` | CORS setting | -| `z-index` | `0` | Z-index | -| `class-name` | `''` | CSS class for the SVG element | +| Attribute | Default | Description | +| -------------- | ------- | --------------------------------------------------- | +| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` | +| `opacity` | `1.0` | Overlay opacity | +| `interactive` | — | Receive mouse/touch events | +| `cross-origin` | `''` | CORS setting | +| `z-index` | `0` | Z-index | +| `class-name` | `''` | CSS class for the SVG element | ### `` @@ -338,74 +438,68 @@ Passthrough container with Leaflet's `FeatureGroup` (supports `getBounds()`, sty ### `` -All [path style](#path-style) attributes apply. +All [path style](#path-style) attributes apply — each feature GeoJSON builds gets them as its style. -| Attribute | Default | Description | -|---|---|---| -| `data` | `''` | GeoJSON string | -| `stroke` | `''` | Stroke color (overrides `color`) | -| `line-cap` | `'round'` | Line cap style | -| `line-join` | `'round'` | Line join style | -| `dash-array` | `''` | Dash pattern | -| `dash-offset` | `''` | Dash offset | -| `fill-rule` | `'evenodd'` | Fill rule | +| Attribute | Default | Description | +| --------- | ------- | -------------- | +| `data` | `''` | GeoJSON string | ### `` Content comes from `innerHTML`, not an attribute. Mutations to innerHTML sync automatically via a `MutationObserver`. -| Attribute | Default | Description | -|---|---|---| -| `lat` | `0` | Latitude (omit to auto-attach to the parent layer's position) | -| `lng` | `0` | Longitude | -| `max-width` | `300` | Maximum width (px) | -| `min-width` | `50` | Minimum width (px) | -| `max-height` | `0` | Maximum height (px; `0` = unlimited) | -| `auto-pan` | — | Automatically pan the map to keep the popup visible | -| `close-button` | — | Show a close button | -| `auto-close` | — | Close the popup when another popup is opened | +| Attribute | Default | Description | +| -------------- | ------- | ------------------------------------------------------------- | +| `lat` | `0` | Latitude (omit to auto-attach to the parent layer's position) | +| `lng` | `0` | Longitude | +| `max-width` | `300` | Maximum width (px) | +| `min-width` | `50` | Minimum width (px) | +| `max-height` | `0` | Maximum height (px; `0` = unlimited) | +| `auto-pan` | — | Automatically pan the map to keep the popup visible | +| `close-button` | — | Show a close button | +| `auto-close` | — | Close the popup when another popup is opened | ### `` Content comes from `innerHTML`, not an attribute. -| Attribute | Default | Description | -|---|---|---| -| `lat` | `0` | Latitude (omit to auto-attach to the parent layer's position) | -| `lng` | `0` | Longitude | -| `pane` | — | Map pane name | -| `offset` | — | Pixel offset as JSON: `[x, y]` | +| Attribute | Default | Description | +| ----------- | -------- | ------------------------------------------------------------------------- | +| `lat` | `0` | Latitude (omit to auto-attach to the parent layer's position) | +| `lng` | `0` | Longitude | +| `pane` | — | Map pane name | +| `offset` | — | Pixel offset as JSON: `[x, y]` | | `direction` | `'auto'` | Direction: `'right'`, `'left'`, `'top'`, `'bottom'`, `'center'`, `'auto'` | -| `permanent` | — | Always visible (no hover required) | -| `sticky` | — | Follow the mouse | -| `opacity` | `1.0` | Tooltip opacity | +| `permanent` | — | Always visible (no hover required) | +| `sticky` | — | Follow the mouse | +| `opacity` | `1.0` | Tooltip opacity | ### `` -| Attribute | Default | Description | -|---|---|---| -| `position` | `'topleft'` | Corner position | -| `zoom-in-text` | `'+'` | Zoom-in button label | -| `zoom-in-title` | `'Zoom in'` | Zoom-in button tooltip | -| `zoom-out-text` | `'-'` | Zoom-out button label | +| Attribute | Default | Description | +| ---------------- | ------------ | ----------------------- | +| `position` | `'topleft'` | Corner position | +| `zoom-in-text` | `'+'` | Zoom-in button label | +| `zoom-in-title` | `'Zoom in'` | Zoom-in button tooltip | +| `zoom-out-text` | `'-'` | Zoom-out button label | | `zoom-out-title` | `'Zoom out'` | Zoom-out button tooltip | ### `` -| Attribute | Default | Description | -|---|---|---| -| `position` | `'bottomright'` | Corner position | -| `prefix` | `''` | Text before the attribution | +| Attribute | Default | Description | +| ---------- | --------------- | --------------------------- | +| `position` | `'bottomright'` | Corner position | +| `prefix` | `''` | Text before the attribution | ### `` -| Attribute | Default | Description | -|---|---|---| -| `position` | `'bottomleft'` | Corner position | -| `max-width` | `100` | Maximum width of the scale (px) | -| `metric` | — | Show metric scale (m/km) | -| `imperial` | — | Show imperial scale (mi/ft) | -| `update-when-idle` | — | Update only when the map stops moving | +| Attribute | Default | Description | +| ------------------ | -------------- | ------------------------------------- | +| `position` | `'bottomleft'` | Corner position | +| `max-width` | `100` | Maximum width of the scale (px) | +| `metric` | — | Show metric scale (m/km) | +| `imperial` | — | Show imperial scale (mi/ft) | +| `update-when-idle` | — | Update only when the map stops moving | ### `` @@ -421,21 +515,21 @@ The built-in Leaflet layer switcher. Children with `type="base"` appear as radio ``` -| Attribute | Default | Description | -|---|---|---| -| `position` | `'topright'` | Corner position | -| `collapsed` | `true` (toggle with `collapsed="false"`) | Collapse into an icon until hovered | -| `auto-z-index` | `true` | Assign increasing z-indexes to layers | -| `hide-single-base` | — | Hide the base layers section when only one base layer exists | -| `sort-layers` | — | Sort layers alphabetically | +| Attribute | Default | Description | +| ------------------ | ---------------------------------------- | ------------------------------------------------------------ | +| `position` | `'topright'` | Corner position | +| `collapsed` | `true` (toggle with `collapsed="false"`) | Collapse into an icon until hovered | +| `auto-z-index` | `true` | Assign increasing z-indexes to layers | +| `hide-single-base` | — | Hide the base layers section when only one base layer exists | +| `sort-layers` | — | Sort layers alphabetically | Each child layer inside `` may carry: -| Attribute | Description | -|---|---| -| `type="base"` | Show as a radio button (base layer). Omit or set `type="overlay"` for a checkbox. | -| `name` | Human-readable label displayed in the control. | -| `active` | Add the layer to the map immediately so it starts visible. Internally, the control dispatches a `leaflet-add-layer` event that the map handles. Layers without `active` are removed from the map before the control renders, so they correctly appear unchecked. | +| Attribute | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type="base"` | Show as a radio button (base layer). Omit or set `type="overlay"` for a checkbox. | +| `name` | Human-readable label displayed in the control. | +| `active` | Add the layer to the map immediately so it starts visible. Internally, the control dispatches a `leaflet-add-layer` event that the map handles. Layers without `active` are removed from the map before the control renders, so they correctly appear unchecked. | The control intercepts child `leaflet-register` events and stops propagation — layers inside `` are managed by the layers control, not added directly to the map. @@ -443,19 +537,26 @@ The control intercepts child `leaflet-register` events and stops propagation — The following attributes apply to ``, ``, ``, ``, ``, and ``. -| Attribute | Default | Description | -|---|---|---| -| `color` | `'#3388ff'` | Stroke color | -| `weight` | `3` | Stroke width (px) | -| `opacity` | `1.0` | Stroke opacity | -| `line-cap` | `'round'` | Line cap: `'butt'`, `'round'`, `'square'` | -| `line-join` | `'round'` | Line join: `'miter'`, `'round'`, `'bevel'` | -| `dash-array` | `''` | Dash pattern, e.g. `'5, 10'` | -| `dash-offset` | `''` | Dash offset | -| `fill` | — | Enable fill | -| `fill-color` | `'#3388ff'` | Fill color | -| `fill-opacity` | `0.2` | Fill opacity | -| `fill-rule` | `'evenodd'` | Fill rule: `'nonzero'`, `'evenodd'` | +| Attribute | Default | Description | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------- | +| `stroke` | `true` (toggle with `stroke="false"`) | Draw the stroke | +| `color` | `'#3388ff'` | Stroke color | +| `weight` | `3` | Stroke width (px) | +| `opacity` | `1.0` | Stroke opacity | +| `line-cap` | `'round'` | Line cap: `'butt'`, `'round'`, `'square'` | +| `line-join` | `'round'` | Line join: `'miter'`, `'round'`, `'bevel'` | +| `dash-array` | `''` | Dash pattern, e.g. `'5, 10'` | +| `dash-offset` | `''` | Dash offset | +| `fill` | `true` (toggle with `fill="false"`) | Enable fill | +| `fill-color` | `'#3388ff'` | Fill color | +| `fill-opacity` | `0.2` | Fill opacity | +| `fill-rule` | `'evenodd'` | Fill rule: `'nonzero'`, `'evenodd'` | +| `interactive` | `true` (toggle with `interactive="false"`) | Receive mouse/touch events | +| `bubbling-mouse-events` | `true` (toggle with `bubbling-mouse-events="false"`) | Let mouse events on this layer also reach the map | +| `class-name` | `''` | CSS class for the path element | +| `pane` | `'overlay'` | Map pane name | + +`stroke`, `color`, `weight`, `opacity`, `line-cap`, `line-join`, `dash-array`, `dash-offset`, `fill`, `fill-color`, `fill-opacity`, and `fill-rule` are live — Leaflet has a setter for them (`setStyle`). `interactive`, `bubbling-mouse-events`, `class-name`, and `pane` only apply at construction. ## Nesting rules @@ -463,14 +564,17 @@ The following attributes apply to ``, ``, - **Layer as child of a group** → added via `addLayer`. - **Any layer as child of `leaflet-map`** → added to the map directly. - **`leaflet-polygon` / `leaflet-polyline`** take their coordinates from `` children, not from attributes. +- **`` / `` as child of ``** → swapped in via `setIcon()`. - **Layer as child of ``** → intercepted by the control and registered as a base or overlay entry. The control stops propagation so the layer doesn't reach the map directly. Use `active` to start the layer visible. ## Development ```bash -npm run build # tsc emits individual ESM modules; Rollup bundles ESM, CJS + UMD (minified/unminified) -npm run typecheck # tsc --noEmit -npm run lint # ESLint -npm run format # Prettier +npm run build # tsc emits individual ESM modules to dist/ (no bundling) +npm run typecheck # tsc --noEmit, then tsc -p tsconfig.test.json for test/ +npm run lint # oxlint over src/ and test/ +npm run format # Prettier +npm run test # vitest run +npm run test:watch # vitest, watch mode # serve the project root with any static-file server and open index.html ``` diff --git a/package.json b/package.json index 7db4135..19cf7ca 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build": "rm -rf dist && tsc --outDir dist", "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json", "lint": "oxlint src test", - "format": "prettier --write 'src/**/*.{ts,js,json,md}' 'test/**/*.ts'", + "format": "prettier --write '*.md' 'src/**/*.{ts,js,json,md}' 'test/**/*.ts'", "test": "vitest run", "test:watch": "vitest", "prepublishOnly": "npm run build"