diff --git a/CLAUDE.md b/CLAUDE.md index 18a008d..2a92946 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ Tests run under Vitest + jsdom (`test/**/*.test.ts`), with a single setup file ( - Some Leaflet DOM state (the ``/`` 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 `` 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`. @@ -55,12 +56,12 @@ All non-map components extend `WithProps(PROPS, options?)`. To add a new compone 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). +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;` / `declare removeEventListener: LeafletRemoveEventListener;` (see "Typing a component's events" below). ### Special cases -- **`leaflet-polygon`** uses `` children for vertices. The polygon collects lat/lng from child `leaflet-line` elements rather than having them as direct attributes. +- **`leaflet-polygon`** / **`leaflet-polyline`** take their vertices from `` children rather than an attribute. This is purely event-driven, not a lookup: `` 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 `` 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. diff --git a/src/components/leaflet-control-layers.ts b/src/components/leaflet-control-layers.ts index 17c7d35..25e30d1 100644 --- a/src/components/leaflet-control-layers.ts +++ b/src/components/leaflet-control-layers.ts @@ -1,15 +1,8 @@ -import { Control, type ControlPosition, type Layer } from 'leaflet'; +import { Control, type ControlPosition } from 'leaflet'; import { bool, choice } from '../core/props.ts'; import { WithProps } from '../core/with-props.ts'; import type { LeafletRegisterEvent } from '../core/register.ts'; -interface ChildLayer { - layer: Layer; - name: string; - base: boolean; - active: boolean; -} - export class LeafletControlLayers extends WithProps( { position: choice('topright'), @@ -22,26 +15,12 @@ export class LeafletControlLayers extends WithProps( ) { declare readonly leafletObject?: Control.Layers; - // Children are read straight off the DOM rather than through registration: - // any that already exist are listed as base layers or overlays by their - // `name`, and `type="base"` / `active` decide how. createLeafletObject(options: Control.LayersOptions): Control.Layers { - const baseLayers: Record = {}; - const overlays: Record = {}; - for (const child of this.#childLayers()) { - (child.base ? baseLayers : overlays)[child.name] = child.layer; - } - return new Control.Layers(baseLayers, overlays, options); + return new Control.Layers({}, {}, options); } connectedCallback(): void { super.connectedCallback(); - for (const child of this.#childLayers()) { - if (child.active) continue; - this.dispatchEvent( - new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer: child.layer } }), - ); - } this.addEventListener('leaflet-register', this.#onChildRegister); } @@ -50,23 +29,11 @@ export class LeafletControlLayers extends WithProps( super.disconnectedCallback(); } - #childLayers(): ChildLayer[] { - const children: ChildLayer[] = []; - for (const child of this.querySelectorAll(':scope > *')) { - const layer = (child as { leafletObject?: Layer }).leafletObject; - const name = child.getAttribute('name'); - if (!name || !layer) continue; - children.push({ - layer, - name, - base: child.getAttribute('type') === 'base', - active: child.hasAttribute('active'), - }); - } - return children; - } - - // Children that connect after us announce themselves instead. + // Every child -- present at construction or added later -- announces + // itself this way: a parent's connectedCallback always finishes before a + // child's does (even for an already-built subtree attached in one go), so + // there is no "read the children that are already here" case to handle + // separately. #onChildRegister = (e: LeafletRegisterEvent) => { e.stopPropagation(); const el = e.detail.element; diff --git a/src/components/leaflet-line.ts b/src/components/leaflet-line.ts index 6da62e1..1b417e8 100644 --- a/src/components/leaflet-line.ts +++ b/src/components/leaflet-line.ts @@ -1,14 +1,30 @@ +import { emitLineRemove, emitLineSync } from '../core/register.ts'; + export class LeafletLine extends HTMLElement { static get observedAttributes() { return ['lat', 'lng']; } + // Cached because disconnectedCallback fires after this is already detached + // from it -- see emitLineRemove. + #parent: ParentNode | null = null; + get latlng(): [number, number] { return [+(this.getAttribute('lat') ?? 0), +(this.getAttribute('lng') ?? 0)]; } - attributeChangedCallback() { - this.dispatchEvent(new CustomEvent('line-updated', { bubbles: true })); + connectedCallback(): void { + this.#parent = this.parentNode; + emitLineSync(this, this.latlng); + } + + attributeChangedCallback(): void { + emitLineSync(this, this.latlng); + } + + disconnectedCallback(): void { + emitLineRemove(this.#parent ?? this, this); + this.#parent = null; } } diff --git a/src/components/leaflet-polygon.ts b/src/components/leaflet-polygon.ts index 5c3bae4..463a928 100644 --- a/src/components/leaflet-polygon.ts +++ b/src/components/leaflet-polygon.ts @@ -5,44 +5,45 @@ import { type LeafletAddEventListener, type LeafletRemoveEventListener, } from '../core/with-props.ts'; -import type { LeafletLine } from './leaflet-line.ts'; import type { PathEvents } from '../core/event-types.ts'; +import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts'; +import { VertexTracker } from '../core/vertex-tracker.ts'; export class LeafletPolygon extends WithProps({ ...pathProps }) { declare readonly leafletObject?: Polygon; declare addEventListener: LeafletAddEventListener; declare removeEventListener: LeafletRemoveEventListener; - #observer?: MutationObserver; + #vertices = new VertexTracker(); createLeafletObject(options: PolylineOptions): Polygon { - return new Polygon(this.#coords(), options); + return new Polygon(this.#vertices.coords(), options); } - // Vertices come from children rather than an attribute, so we - // re-read them whenever one is added, removed or moved. + // Vertices come from children rather than an attribute -- + // each one announces its own position via leaflet-line-sync/-remove, we + // never read a child's state directly. connectedCallback(): void { super.connectedCallback(); - this.addEventListener('line-updated', this.#syncCoords); - this.#observer = new MutationObserver(this.#syncCoords); - this.#observer.observe(this, { childList: true }); + this.addEventListener('leaflet-line-sync', this.#onLineSync); + this.addEventListener('leaflet-line-remove', this.#onLineRemove); } disconnectedCallback(): void { - this.#observer?.disconnect(); - this.#observer = undefined; - this.removeEventListener('line-updated', this.#syncCoords); + this.removeEventListener('leaflet-line-sync', this.#onLineSync); + this.removeEventListener('leaflet-line-remove', this.#onLineRemove); super.disconnectedCallback(); } - #syncCoords = () => { - this.leafletObject?.setLatLngs(this.#coords()); + #onLineSync = (e: LeafletLineSyncEvent) => { + this.#vertices.sync(e.detail.element, e.detail.latlng); + this.leafletObject?.setLatLngs(this.#vertices.coords()); }; - #coords(): [number, number][] { - const lines = Array.from(this.querySelectorAll('leaflet-line')); - return lines.map((line) => line.latlng); - } + #onLineRemove = (e: LeafletLineRemoveEvent) => { + this.#vertices.remove(e.detail.element); + this.leafletObject?.setLatLngs(this.#vertices.coords()); + }; } customElements.define('leaflet-polygon', LeafletPolygon); diff --git a/src/components/leaflet-polyline.ts b/src/components/leaflet-polyline.ts index 4ff905f..205cf35 100644 --- a/src/components/leaflet-polyline.ts +++ b/src/components/leaflet-polyline.ts @@ -6,8 +6,9 @@ import { type LeafletAddEventListener, type LeafletRemoveEventListener, } from '../core/with-props.ts'; -import type { LeafletLine } from './leaflet-line.ts'; import type { PathEvents } from '../core/event-types.ts'; +import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts'; +import { VertexTracker } from '../core/vertex-tracker.ts'; export class LeafletPolyline extends WithProps({ ...pathProps, @@ -20,36 +21,36 @@ export class LeafletPolyline extends WithProps({ declare addEventListener: LeafletAddEventListener; declare removeEventListener: LeafletRemoveEventListener; - #observer?: MutationObserver; + #vertices = new VertexTracker(); createLeafletObject(options: PolylineOptions): Polyline { - return new Polyline(this.#coords(), options); + return new Polyline(this.#vertices.coords(), options); } - // Vertices come from children rather than an attribute, so we - // re-read them whenever one is added, removed or moved. + // Vertices come from children rather than an attribute -- + // each one announces its own position via leaflet-line-sync/-remove, we + // never read a child's state directly. connectedCallback(): void { super.connectedCallback(); - this.addEventListener('line-updated', this.#syncCoords); - this.#observer = new MutationObserver(this.#syncCoords); - this.#observer.observe(this, { childList: true }); + this.addEventListener('leaflet-line-sync', this.#onLineSync); + this.addEventListener('leaflet-line-remove', this.#onLineRemove); } disconnectedCallback(): void { - this.#observer?.disconnect(); - this.#observer = undefined; - this.removeEventListener('line-updated', this.#syncCoords); + this.removeEventListener('leaflet-line-sync', this.#onLineSync); + this.removeEventListener('leaflet-line-remove', this.#onLineRemove); super.disconnectedCallback(); } - #syncCoords = () => { - this.leafletObject?.setLatLngs(this.#coords()); + #onLineSync = (e: LeafletLineSyncEvent) => { + this.#vertices.sync(e.detail.element, e.detail.latlng); + this.leafletObject?.setLatLngs(this.#vertices.coords()); }; - #coords(): [number, number][] { - const lines = Array.from(this.querySelectorAll('leaflet-line')); - return lines.map((line) => line.latlng); - } + #onLineRemove = (e: LeafletLineRemoveEvent) => { + this.#vertices.remove(e.detail.element); + this.leafletObject?.setLatLngs(this.#vertices.coords()); + }; } customElements.define('leaflet-polyline', LeafletPolyline); diff --git a/src/core/register.ts b/src/core/register.ts index af45c66..7b56ce6 100644 --- a/src/core/register.ts +++ b/src/core/register.ts @@ -12,9 +12,14 @@ export type LeafletLayerEvent = CustomEvent<{ layer: Layer }>; export type LeafletIconChangedEvent = CustomEvent<{ icon: Icon | null }>; -// Fired by on itself when its lat/lng changes; polygon/polyline -// listen for it (bubbling) to know when to re-read their vertices. -export type LeafletLineUpdatedEvent = CustomEvent; +// Fired by on itself -- on connect and on every lat/lng +// change -- carrying its own current position. polygon/polyline listen for +// this (bubbling) to track vertices without ever reading a child's state +// directly. +export type LeafletLineSyncEvent = CustomEvent<{ element: HTMLElement; latlng: [number, number] }>; + +// Fired by on disconnect, so a listening parent can drop it. +export type LeafletLineRemoveEvent = CustomEvent<{ element: HTMLElement }>; declare global { interface HTMLElementEventMap { @@ -22,7 +27,8 @@ declare global { 'leaflet-add-layer': LeafletLayerEvent; 'leaflet-remove-layer': LeafletLayerEvent; 'icon-changed': LeafletIconChangedEvent; - 'line-updated': LeafletLineUpdatedEvent; + 'leaflet-line-sync': LeafletLineSyncEvent; + 'leaflet-line-remove': LeafletLineRemoveEvent; } } @@ -35,6 +41,22 @@ export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | u ); } +export function emitLineSync(el: HTMLElement, latlng: [number, number]): void { + el.dispatchEvent( + new CustomEvent('leaflet-line-sync', { bubbles: true, detail: { element: el, latlng } }), + ); +} + +// Dispatched on `from`, not `el`: by the time disconnectedCallback runs, `el` +// has already been detached from its parent, so a bubbling dispatch from +// `el` itself would have nowhere to bubble to. Callers pass the parent they +// cached while still connected. +export function emitLineRemove(from: EventTarget, el: HTMLElement): void { + from.dispatchEvent( + new CustomEvent('leaflet-line-remove', { bubbles: true, detail: { element: el } }), + ); +} + // Dispatches a custom `leaflet-register` event upward through the DOM // tree, carrying a Leaflet object and its host element. Parent components // (map, circles, groups, etc.) intercept this event and add the layer, diff --git a/src/core/vertex-tracker.ts b/src/core/vertex-tracker.ts new file mode 100644 index 0000000..3b8608e --- /dev/null +++ b/src/core/vertex-tracker.ts @@ -0,0 +1,37 @@ +// Tracks an ordered set of vertices contributed by children, +// built entirely from the leaflet-line-sync/leaflet-line-remove events they +// fire (see register.ts) -- never by querying the DOM or reading a child's +// property directly. Shared by leaflet-polygon and leaflet-polyline. +// +// Order matters (it's the vertex sequence), so membership changes are +// inserted at their actual document position via compareDocumentPosition -- +// that's a structural question about the tree, not a read of child state, +// and there's no other sane source of truth for "which vertex comes first." +export type LatLngTuple = [number, number]; + +export class VertexTracker { + #vertices = new Map(); + #order: HTMLElement[] = []; + + sync(element: HTMLElement, latlng: LatLngTuple): void { + if (!this.#vertices.has(element)) this.#insertOrdered(element); + this.#vertices.set(element, latlng); + } + + remove(element: HTMLElement): void { + this.#vertices.delete(element); + this.#order = this.#order.filter((el) => el !== element); + } + + coords(): LatLngTuple[] { + return this.#order.map((el) => this.#vertices.get(el)!); + } + + #insertOrdered(el: HTMLElement): void { + const idx = this.#order.findIndex( + (existing) => (existing.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) !== 0, + ); + if (idx === -1) this.#order.push(el); + else this.#order.splice(idx, 0, el); + } +} diff --git a/src/index.ts b/src/index.ts index 38ccfab..55f6748 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,22 +1,43 @@ +// Export order here is load-bearing, not cosmetic: customElements.define() +// upgrades every matching element already in the document immediately and +// synchronously (including running connectedCallback if already connected), +// so a "child" custom element that fires a one-shot announcement on connect +// must be defined *after* every "container" type that needs to hear it -- +// otherwise every instance already in a static HTML page upgrades and +// announces itself before the listening parent exists, and that +// announcement is lost for good (connectedCallback never fires again for an +// element that stays connected). This is the same reason leaflet-map is +// exported first: nothing can register with it before it exists to listen. +// +// The hierarchy, root to leaf: +// LeafletMap +// -> LeafletControlLayers, LeafletLayerGroup, LeafletFeatureGroup +// (all listen for "leaflet-register" from arbitrary layer children) +// -> every concrete layer type (Marker, Circle, Polygon, TileLayer, ...) +// -> LeafletLine (child of Polygon/Polyline specifically) +// -> LeafletPopup, LeafletTooltip (child of any layer) +// -> LeafletIcon, LeafletDivIcon (child of Marker specifically) +// Standalone controls (Zoom/Attribution/Scale) have no such relationship and +// can go anywhere. export * from './core/register.ts'; export * from './core/props.ts'; export { LeafletMap } from './components/leaflet-map.ts'; +export { LeafletControlLayers } from './components/leaflet-control-layers.ts'; +export { LeafletLayerGroup } from './components/leaflet-layer-group.ts'; +export { LeafletFeatureGroup } from './components/leaflet-feature-group.ts'; +export { LeafletPolygon } from './components/leaflet-polygon.ts'; +export { LeafletPolyline } from './components/leaflet-polyline.ts'; export { LeafletMarker } from './components/leaflet-marker.ts'; export { LeafletCircle } from './components/leaflet-circle.ts'; export { LeafletCircleMarker } from './components/leaflet-circle-marker.ts'; -export { LeafletLine } from './components/leaflet-line.ts'; -export { LeafletPolygon } from './components/leaflet-polygon.ts'; -export { LeafletPolyline } from './components/leaflet-polyline.ts'; export { LeafletRectangle } from './components/leaflet-rectangle.ts'; export { LeafletTileLayer } from './components/leaflet-tile-layer.ts'; export { LeafletTileLayerWMS } from './components/leaflet-tile-layer-wms.ts'; export { LeafletImageOverlay } from './components/leaflet-image-overlay.ts'; export { LeafletVideoOverlay } from './components/leaflet-video-overlay.ts'; export { LeafletSVGOverlay } from './components/leaflet-svg-overlay.ts'; -export { LeafletLayerGroup } from './components/leaflet-layer-group.ts'; -export { LeafletFeatureGroup } from './components/leaflet-feature-group.ts'; export { LeafletGeoJSON } from './components/leaflet-geojson.ts'; -export { LeafletControlLayers } from './components/leaflet-control-layers.ts'; +export { LeafletLine } from './components/leaflet-line.ts'; export { LeafletControlZoom } from './components/leaflet-control-zoom.ts'; export { LeafletControlAttribution } from './components/leaflet-control-attribution.ts'; export { LeafletControlScale } from './components/leaflet-control-scale.ts'; @@ -26,7 +47,8 @@ export { LeafletIcon } from './components/leaflet-icon.ts'; export { LeafletDivIcon } from './components/leaflet-div-icon.ts'; // `export { X } from '...'` re-exports X but doesn't bind it locally, so the -// tag map below needs its own type-only imports of the same classes. +// tag map below needs its own type-only imports of the same classes. Order +// doesn't matter here -- these are erased, no customElements.define behind them. import type { LeafletMap } from './components/leaflet-map.ts'; import type { LeafletMarker } from './components/leaflet-marker.ts'; import type { LeafletCircle } from './components/leaflet-circle.ts'; diff --git a/test/components/shapes.test.ts b/test/components/shapes.test.ts index 080a90e..0a53b29 100644 --- a/test/components/shapes.test.ts +++ b/test/components/shapes.test.ts @@ -109,6 +109,33 @@ describe('leaflet-polygon', () => { ]); el.remove(); }); + + it('tracks vertices added or removed after connecting, in DOM order', () => { + const el = document.createElement('leaflet-polygon'); + const line1 = document.createElement('leaflet-line'); + line1.setAttribute('lat', '1'); + line1.setAttribute('lng', '2'); + el.append(line1); + document.body.append(el); + + expect(el.leafletObject?.getLatLngs()).toEqual([[{ lat: 1, lng: 2 }]]); + + // Inserted before line1 -- should come first despite registering second. + const line0 = document.createElement('leaflet-line'); + line0.setAttribute('lat', '5'); + line0.setAttribute('lng', '6'); + el.insertBefore(line0, line1); + expect(el.leafletObject?.getLatLngs()).toEqual([ + [ + { lat: 5, lng: 6 }, + { lat: 1, lng: 2 }, + ], + ]); + + line0.remove(); + expect(el.leafletObject?.getLatLngs()).toEqual([[{ lat: 1, lng: 2 }]]); + el.remove(); + }); }); describe('leaflet-polyline', () => { diff --git a/test/load-order.test.ts b/test/load-order.test.ts new file mode 100644 index 0000000..3e39913 --- /dev/null +++ b/test/load-order.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +// A real page has all its markup already parsed by the browser's +// HTML parser before the deferred module script (which calls +// customElements.define for every tag) ever runs. customElements.define() +// upgrades every matching element already in the document immediately and +// synchronously -- including firing connectedCallback -- so a "child" tag +// whose one-shot connect-time announcement is missed because its listening +// "parent" tag wasn't defined yet is broken in exactly this scenario, even +// though it looks fine in every other test in this suite (they all import +// the component modules -- and so call customElements.define -- before +// creating any element, which sidesteps the whole problem). +// +// This file deliberately does NOT statically import any component module, +// so nothing here is defined yet when the tree below is built. +describe('real page load order (markup before customElements.define)', () => { + it('wires control-layers, layer-group and polygon/line correctly', async () => { + const map = document.createElement('leaflet-map'); + map.setAttribute('lat', '51.5'); + map.setAttribute('lng', '-0.09'); + map.setAttribute('zoom', '13'); + + const controlLayers = document.createElement('leaflet-control-layers'); + const baseTile = document.createElement('leaflet-tile-layer'); + baseTile.setAttribute('type', 'base'); + baseTile.setAttribute('name', 'Base'); + baseTile.setAttribute('active', ''); + baseTile.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png'); + controlLayers.append(baseTile); + + const group = document.createElement('leaflet-layer-group'); + const groupedMarker = document.createElement('leaflet-marker'); + groupedMarker.setAttribute('lat', '1'); + groupedMarker.setAttribute('lng', '2'); + group.append(groupedMarker); + + const polygon = document.createElement('leaflet-polygon'); + const vertices = [ + ['51.509', '-0.08'], + ['51.503', '-0.06'], + ['51.51', '-0.047'], + ].map(([lat, lng]) => { + const line = document.createElement('leaflet-line'); + line.setAttribute('lat', lat); + line.setAttribute('lng', lng); + return line; + }); + polygon.append(...vertices); + + map.append(controlLayers, group, polygon); + document.body.append(map); + + // Nothing is upgraded yet -- these are plain, undefined elements. + expect(map.leafletObject).toBeUndefined(); + + // Mirrors index.ts's real export order, which is what actually runs when + // a page does `import 'leaflet-components'`. + const lc = await import('../src/index.ts'); + + expect(map).toBeInstanceOf(lc.LeafletMap); + expect(map.leafletObject).toBeDefined(); + expect(polygon.leafletObject?.getLatLngs()).toEqual([ + [ + { lat: 51.509, lng: -0.08 }, + { lat: 51.503, lng: -0.06 }, + { lat: 51.51, lng: -0.047 }, + ], + ]); + + const controlLayersInternal = controlLayers.leafletObject as unknown as { + _layers: { name: string }[]; + }; + // oxlint-disable-next-line no-underscore-dangle -- Leaflet's own field name + expect(controlLayersInternal._layers.map((l) => l.name)).toEqual(['Base']); + + expect(group.leafletObject?.hasLayer(groupedMarker.leafletObject!)).toBe(true); + + map.remove(); + }); +});