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.
89 lines
13 KiB
Markdown
89 lines
13 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 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/**` 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.
|
|
|
|
### `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 `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`, 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.
|
|
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 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`).
|