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

98 lines
15 KiB
Markdown

# 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/`](./docs/README.md) — 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
```bash
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 # 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. 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.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`.
To preview components in a real browser, open `index.html` with any static-file server.
## Architecture
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(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.
- 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/elements/leaflet-map.ts`
`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
All non-map element classes extend `WithProps(PROPS, options?)`. To add a new component:
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. 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).
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 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).
### 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 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 `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.
`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, 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`).