You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/CLAUDE.md

9.6 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

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 formatting
npm run test       # vitest run
npm run test:watch # vitest, watch mode

Linting runs on oxlint, not ESLint/@typescript-eslint. This project pins typescript@^7.0.2, and @typescript-eslint has no released version that supports it (peer range caps at <6.1.0, and even loading @typescript-eslint/parser crashes against TS 7's package shape). oxlint has its own parser and doesn't touch the typescript package, so it works regardless of TS version — the tradeoff is no type-aware rules (no no-floating-promises, no-unnecessary-condition, etc.). Revisit once @typescript-eslint supports TS 7.

Testing

Tests run under Vitest + jsdom (test/**/*.test.ts), with a single setup file (test/setup.ts) that stubs ResizeObserver -- jsdom doesn't implement it, and leaflet-map.ts constructs one unconditionally. Real Leaflet objects work fine under jsdom for everything this library actually needs to verify (option/attribute wiring, event forwarding); no browser is required. Two things worth knowing:

  • 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/** is excluded from the main tsconfig.json (so test code never ends up in dist/) and typechecked separately via tsconfig.test.json.

To preview components in a real browser, open index.html with any static-file server.

Architecture

This library wraps Leaflet.js as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object.

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:

  • 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:<type> (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 <leaflet-map> 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 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(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).
  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 PropDefs 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, 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<TheEvents>; / declare removeEventListener: LeafletRemoveEventListener<TheEvents>; (see "Typing a component's events" below).

Special cases

  • leaflet-polygon uses <leaflet-line> children for vertices. The polygon collects lat/lng from child leaflet-line elements rather than having them as direct attributes.
  • leaflet-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:<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.

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).