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

21 KiB

CLAUDE.md

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

Longer-form design docs live in docs/ — one file per major decision (the WithProps mixin, the prop model, the registration protocol, event forwarding, per-component special cases, load order, tooling). This file stays a terse working cheat-sheet; docs/ is the "why".

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/, test/, scripts/
npm run format     # oxfmt 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.

Formatting runs on oxfmt (oxc's Prettier-compatible formatter), configured in oxfmt.config.ts (a defineConfig({...}) default export) — seeded from the old prettier.config.js via oxfmt --migrate=prettier, so the settings (semi, singleQuote, trailingComma: 'all', printWidth: 100, tabWidth: 2) match what Prettier used. The config file sits at the repo root, outside both tsconfig include globs, so build/typecheck never compile it. oxfmt is pre-1.0; it also formats fenced code blocks inside Markdown, which Prettier left alone.

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) and imports src/core/globals.ts so the ambient HTMLElementTagNameMap / HTMLElementEventMap augmentations are in scope for the whole test program (tests import element modules directly, not the npm entry that would otherwise pull them in). 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/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.defined 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.

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.

elements/ vs components/

Every component is split across two files with the same basename:

  • src/elements/leaflet-foo.ts — a const PROPS, a const Base = WithProps(PROPS) (explicitly typed — see below), then export default class LeafletFooElement extends Base {...}. 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 / src/index.npm.ts) is what actually registers the tag.

Most consumers just import the package root (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-web-components/elements, .../elements/leaflet-foo.js, .../components/leaflet-foo.js) map straight onto dist/ — the per-file ones are npm-only, since JSR (jsr.json exports) has no subpath patterns and exposes just . and ./elements.

Two package entry points: src/index.ts vs src/index.npm.ts

  • src/index.ts is the shared entry: every element class, every helper, and the load-bearing import './components/*.ts' side effects. No declare global. This is what jsr.json publishes as ..
  • src/index.npm.ts is the npm entry — import './core/globals.ts'; export * from './index.ts';. package.json's ./main/module/types all point at its build (dist/index.npm.*).
  • src/core/globals.ts holds the two ambient declare global blocks (HTMLElementTagNameMap, HTMLElementEventMap). It is imported only by src/index.npm.ts and test/setup.ts, and is listed in jsr.json's publish.exclude, so it never enters JSR's module graph — JSR's "no slow types" check rejects declare global anywhere it can reach. npm consumers get the augmentations automatically through the package root; JSR (jsr:@buddy/leaflet-components) consumers do not, and add their own tag-map/event-map typing if they want it. The whole src/ tree still sees the augmentations at build/typecheck time because globals.ts is under tsconfig.json's include.

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 typed LeafletElementConstructor<TObj, TProps>. 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 it. 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.

The const PROPS / const Base shape (JSR "no slow types")

An element file must not write class Foo extends WithProps({ ... }) directly. JSR's fast-check rejects a call expression as a superclass ("super class expression too complex") and rejects any leaked type it can't resolve without full inference. So every element file does:

const PROPS: { lat: PropDef<number, LMap>; /* ...one line per prop... */ } = { ... };
const Base: LeafletElementConstructor<TheLeafletClass, typeof PROPS> = WithProps(PROPS /*, opts */);
export default class LeafletFooElement extends Base { ... }
  • PROPS needs an explicit type annotation. Spell out { key: PropDef<T, Obj>; ... }, using typeof pathProps / typeof latLngProps / typeof tileLayerProps in an intersection for the shared fragments (which now carry their own explicit types in shared-props.ts), and Positional<T, Obj> (alias in props.ts for PropDef<T, Obj> & { option: false }) for positional() props. as const alone is only enough when every value is a plain identifier reference (e.g. leaflet-circle, leaflet-tile-layer, leaflet-polygon); any inline num(…) / str(…) / positional(…) in the table forces the full annotation. Empty tables use Record<never, never> (not Record<string, never>, which would impose a never index signature on the subclass).
  • Base needs : LeafletElementConstructor<TheLeafletClass, typeof PROPS> — without it JSR can't resolve the base's type either. TheLeafletClass is whatever createLeafletObject returns; the subclass still redeclares declare readonly leafletObject?: X to narrow it.

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/elements/leaflet-map.ts

LeafletMapElement extends a Base = 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

All non-map element classes extend a Base = WithProps(PROPS, options?). To add a new component:

  1. In src/elements/leaflet-foo.ts, define a const PROPS table mapping property names to PropDef entries (reuse fragments from src/core/shared-props.ts where they fit — pathProps, latLngProps, tileLayerProps, urlProp), with an explicit type annotation (see "The const PROPS / const Base shape" above).
  2. In the same file, const Base: LeafletElementConstructor<TheLeafletClass, typeof PROPS> = WithProps(PROPS /*, opts */); then export default class LeafletFooElement extends Base 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. Add src/components/leaflet-foo.ts: import LeafletFooElement from '../elements/leaflet-foo.ts'; then customElements.define('leaflet-foo', LeafletFooElement); then export { LeafletFooElement };.
  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 in src/core/globals.ts (npm-only; imports the class names from ../elements/index.ts). 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).

Special cases

  • leaflet-polygon / leaflet-polyline take their vertices from <leaflet-line> children rather than an attribute. This is purely event-driven, not a lookup: <leaflet-line> fires leaflet-line-sync (on connect and on every lat/lng change) and leaflet-line-remove (on disconnect) on itself, each carrying its own position (src/core/register.ts); the polygon/polyline never reads a child's property or queries the DOM for them. src/core/vertex-tracker.ts's VertexTracker (shared by both) turns that event stream into an ordered coordinate list, inserting a newly-registered vertex at its actual document position via compareDocumentPosition rather than assuming registration order matches DOM order. One non-obvious wrinkle <leaflet-line> has to work around: disconnectedCallback fires after a node is already detached from its parent, so a bubbling dispatch from the node itself has nowhere to go on removal — it caches parentNode in connectedCallback and dispatches leaflet-line-remove from that cached reference instead.
  • 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 from an empty props table (const PROPS: Record<never, never> = {}) — 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 (in src/core/globals.ts, alongside the HTMLElementEventMap augmentation for the internal bubbling events) maps every leaflet-* tag to its LeafletFooElement class, so document.createElement/querySelector infer the right type. globals.ts is npm-only (imported by src/index.npm.ts and test/setup.ts, excluded from JSR) — see "Two package entry points" above.
  • 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 & dual publish

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/. There is no CJS or UMD build. Imports within source use .ts extensions; rewriteRelativeImportExtensions strips them to .js in the emitted .js (the .d.ts keep .ts specifiers, which TS resolves to the sibling .d.ts).

  • leaflet is a peerDependency (^1.9.4), also kept in devDependencies for local dev/tests — a second copy would break the instanceof-based nesting protocol. @types/leaflet + @types/geojson are regular dependencies (the emitted .d.ts reference them; leaflet ships no types).
  • files ships dist/ and src/ — the .d.ts.map point at ../../src/*.ts, so source has to be there for "go to definition" (and it matches JSR).
  • sideEffects is the explicit list dist/index.js, dist/index.npm.js, dist/components/*.js — the only modules that customElements.define() on import; everything else is tree-shakeable.
  • No ./dist/* export. The per-file ./elements/*.js / ./components/*.js subpaths are npm-only (JSR has no subpath patterns).

Two registries, different entry files. Both carry a version; scripts/sync-version.mjs (the version lifecycle hook, and re-run in prepublishOnly) copies package.json's version into jsr.json.

Registry Package name Entry Ships
npm leaflet-web-components package.json ./main/module/typesdist/index.npm.* dist/ + src/ + README.md + LICENSE (files field); prepublishOnly runs sync-version + typecheck + test + build
JSR @buddy/leaflet-components jsr.json exports./src/index.ts (+ ./elements) TypeScript source directly, minus jsr.json's publish.exclude (src/index.npm.ts, src/core/globals.ts, test/, docs/, scripts/, dist/, configs, CLAUDE.md)

The npm entry (src/index.npm.ts) adds the declare global augmentations via src/core/globals.ts; the JSR entry (src/index.ts) can't, because JSR's "no slow types" check forbids declare global in its published graph. That constraint is also why every element file uses the explicitly-typed const PROPS / const Base shape (see the WithProps section). npx jsr publish --dry-run is the authoritative check; it must print "Success" with no slow-type errors.