test: add a Vitest + jsdom test suite

No test coverage existed before this. Adds vitest + jsdom (2 new
devDependencies) and a suite that runs entirely without a browser: real
Leaflet objects work fine under jsdom for everything this library needs to
verify (option/attribute wiring, event forwarding), given a ResizeObserver
stub in test/setup.ts (jsdom's only real gap here).

- test/core/with-props.test.ts: the WithProps mixin itself, against a fake
  Leaflet-like class -- attribute<->setter dispatch, get/attribute/default
  fallback order, event-driven attribute sync-back, positional/recreate/
  attach modes, and the generic fire()-patch event forwarding. Child
  registration (popup/tooltip/layer binding) uses real Popup/Tooltip/Marker
  instances since #onChildRegister discriminates by instanceof.
- test/core/props.test.ts: the codec functions in isolation.
- test/components/*.test.ts: grouped smoke tests across all components.
- test/integration.test.ts: full tree wiring (map + tile-layer +
  feature-group + marker + popup).

Caught and fixed one real bug along the way: urlProp's "live url getter"
from the last refactor was dead code -- neither ImageOverlay nor
VideoOverlay actually expose getUrl(). Removed it and the now-pointless
getUrl?() from the Sourced interface in shared-props.ts.

tsconfig.test.json keeps test/ out of the tsc build (dist/ stays
test-free) while still typechecking it; oxlint.config.ts now covers test/
too, with max-classes-per-file relaxed there since testing a class
factory means many small one-off element classes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Buddy 4 weeks ago
parent c339ce1d06
commit 9b6c7ca598

@ -6,14 +6,25 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash
npm run build # tsc emits individual ESM modules to dist/ (no bundling)
npm run typecheck # tsc --noEmit
npm run lint # oxlint over src/
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.
There are no tests in this project. To preview components locally, open `index.html` in a browser with any static-file server.
### 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

@ -11,6 +11,17 @@ export default defineConfig({
'eslint/max-lines': 'off',
'eslint/max-lines-per-function': 'off',
},
overrides: [
{
// Each test scenario for the WithProps factory needs its own small
// custom element class -- that's the nature of testing a class factory,
// not a sign the file should be split up.
files: ['test/**/*.ts'],
rules: {
'eslint/max-classes-per-file': 'off',
},
},
],
env: {
builtin: true,
},

2170
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -20,9 +20,11 @@
],
"scripts": {
"build": "rm -rf dist && tsc --outDir dist",
"typecheck": "tsc --noEmit",
"lint": "oxlint src",
"format": "prettier --write 'src/**/*.{ts,js,json,md}'",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"lint": "oxlint src test",
"format": "prettier --write 'src/**/*.{ts,js,json,md}' 'test/**/*.ts'",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "npm run build"
},
"keywords": [
@ -38,8 +40,10 @@
},
"devDependencies": {
"@types/leaflet": "1.9.21",
"jsdom": "^30.0.1",
"oxlint": "^1.79.0",
"prettier": "3.8.3",
"typescript": "^7.0.2"
"typescript": "^7.0.2",
"vitest": "^4.1.11"
}
}

@ -24,7 +24,6 @@ export interface Styleable {
export interface Sourced {
setUrl(url: string): unknown;
getUrl?(): string;
}
export interface Bounded {
@ -56,14 +55,12 @@ export function style<T>(key: keyof PathOptions): (obj: Styleable, value: T) =>
// The source url. Passed positionally by every Leaflet constructor that takes
// one, and ignored when blank so clearing the attribute can't request nothing.
// No `get`: none of TileLayer/ImageOverlay/VideoOverlay expose a getUrl().
export const urlProp = positional(
str<Sourced>('', {
set(obj, value) {
if (value) obj.setUrl(value);
},
// Only ImageOverlay/VideoOverlay implement getUrl(); TileLayer doesn't,
// so this falls back to the attribute there, same as omitting `get`.
get: (obj) => obj.getUrl?.(),
}),
);

@ -0,0 +1,101 @@
import { Control, FeatureGroup, LayerGroup } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../../src/components/leaflet-control-zoom.ts';
import '../../src/components/leaflet-control-scale.ts';
import '../../src/components/leaflet-control-attribution.ts';
import '../../src/components/leaflet-control-layers.ts';
import '../../src/components/leaflet-tile-layer.ts';
import '../../src/components/leaflet-layer-group.ts';
import '../../src/components/leaflet-feature-group.ts';
import '../../src/components/leaflet-marker.ts';
describe('leaflet-control-zoom / scale / attribution', () => {
it('create their matching Control class with position and options', () => {
const zoom = document.createElement('leaflet-control-zoom') as HTMLElement & {
leafletObject?: Control.Zoom;
};
zoom.setAttribute('position', 'bottomleft');
document.body.append(zoom);
expect(zoom.leafletObject).toBeInstanceOf(Control.Zoom);
expect(zoom.leafletObject?.options.position).toBe('bottomleft');
zoom.remove();
const scale = document.createElement('leaflet-control-scale') as HTMLElement & {
leafletObject?: Control.Scale;
};
scale.setAttribute('max-width', '150');
document.body.append(scale);
expect(scale.leafletObject).toBeInstanceOf(Control.Scale);
expect(scale.leafletObject?.options.maxWidth).toBe(150);
scale.remove();
const attribution = document.createElement('leaflet-control-attribution') as HTMLElement & {
leafletObject?: Control.Attribution;
};
attribution.setAttribute('prefix', 'Test');
document.body.append(attribution);
expect(attribution.leafletObject).toBeInstanceOf(Control.Attribution);
expect(attribution.leafletObject?.options.prefix).toBe('Test');
attribution.remove();
});
});
describe('leaflet-control-layers', () => {
it('sorts existing children into base layers vs overlays by the type attribute', () => {
const el = document.createElement('leaflet-control-layers') as HTMLElement & {
leafletObject?: Control.Layers;
};
const base = document.createElement('leaflet-tile-layer');
base.setAttribute('url', 'https://a.example/{z}/{x}/{y}.png');
base.setAttribute('name', 'Base');
base.setAttribute('type', 'base');
base.setAttribute('active', '');
const overlay = document.createElement('leaflet-tile-layer');
overlay.setAttribute('url', 'https://b.example/{z}/{x}/{y}.png');
overlay.setAttribute('name', 'Overlay');
el.append(base, overlay);
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Control.Layers);
// Control.Layers has no public getter for its layer list, so this reaches
// into Leaflet's own private `_layers` field -- brittle across Leaflet
// versions, but it's the only way to verify the base/overlay split.
const layersInternal = el.leafletObject as unknown as {
_layers: { name: string; overlay?: boolean }[];
};
const byName = Object.fromEntries(
// oxlint-disable-next-line no-underscore-dangle -- Leaflet's own field name
layersInternal._layers.map((l) => [l.name, Boolean(l.overlay)]),
);
expect(byName['Base']).toBe(false);
expect(byName['Overlay']).toBe(true);
el.remove();
});
});
describe('leaflet-layer-group / leaflet-feature-group', () => {
it('are passthrough WithProps({}) containers that adopt registering children', () => {
const group = document.createElement('leaflet-layer-group') as HTMLElement & {
leafletObject?: LayerGroup;
};
document.body.append(group);
expect(group.leafletObject).toBeInstanceOf(LayerGroup);
const marker = document.createElement('leaflet-marker');
marker.setAttribute('lat', '1');
marker.setAttribute('lng', '2');
group.append(marker);
expect(group.leafletObject?.getLayers()).toHaveLength(1);
group.remove();
const feature = document.createElement('leaflet-feature-group') as HTMLElement & {
leafletObject?: FeatureGroup;
};
document.body.append(feature);
expect(feature.leafletObject).toBeInstanceOf(FeatureGroup);
feature.remove();
});
});

@ -0,0 +1,56 @@
import { GeoJSON } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../../src/components/leaflet-geojson.ts';
const FEATURE = {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-0.13, 51.5],
[-0.12, 51.5],
[-0.12, 51.51],
[-0.13, 51.51],
[-0.13, 51.5],
],
],
},
properties: {},
};
describe('leaflet-geojson', () => {
it('builds a GeoJSON layer from the data attribute with pathProps nested as style', () => {
const el = document.createElement('leaflet-geojson') as HTMLElement & {
leafletObject?: GeoJSON;
};
el.setAttribute('data', JSON.stringify(FEATURE));
el.setAttribute('color', '#e67e22');
el.setAttribute('fill-opacity', '0.4');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(GeoJSON);
expect(el.leafletObject?.getLayers()).toHaveLength(1);
const style = el.leafletObject?.options.style as { color?: string; fillOpacity?: number };
expect(style.color).toBe('#e67e22');
expect(style.fillOpacity).toBe(0.4);
el.remove();
});
it('replaces its layers when the data attribute changes', () => {
const el = document.createElement('leaflet-geojson') as HTMLElement & {
leafletObject?: GeoJSON;
};
el.setAttribute('data', JSON.stringify(FEATURE));
document.body.append(el);
expect(el.leafletObject?.getLayers()).toHaveLength(1);
el.setAttribute(
'data',
JSON.stringify({ type: 'FeatureCollection', features: [FEATURE, FEATURE] }),
);
expect(el.leafletObject?.getLayers()).toHaveLength(2);
el.remove();
});
});

@ -0,0 +1,62 @@
import { DivIcon, Icon } from 'leaflet';
import { describe, expect, it, vi } from 'vitest';
import '../../src/components/leaflet-icon.ts';
import '../../src/components/leaflet-div-icon.ts';
describe('leaflet-icon', () => {
it('creates nothing until icon-url is set, then an Icon once it is', () => {
const el = document.createElement('leaflet-icon') as HTMLElement & {
leafletObject?: Icon;
};
document.body.append(el);
expect(el.leafletObject).toBeUndefined();
el.setAttribute('icon-url', 'https://example.com/icon.png');
expect(el.leafletObject).toBeInstanceOf(Icon);
expect(el.leafletObject?.options.iconUrl).toBe('https://example.com/icon.png');
el.remove();
});
it('rebuilds (not mutates) the Icon on every attribute change and re-emits icon-changed', () => {
const el = document.createElement('leaflet-icon') as HTMLElement & {
leafletObject?: Icon;
};
el.setAttribute('icon-url', 'https://example.com/a.png');
const onChanged = vi.fn();
el.addEventListener('icon-changed', onChanged);
document.body.append(el);
const first = el.leafletObject;
onChanged.mockClear();
el.setAttribute('icon-size', JSON.stringify([32, 32]));
expect(el.leafletObject).not.toBe(first);
expect(onChanged).toHaveBeenCalledOnce();
el.remove();
});
});
describe('leaflet-div-icon', () => {
it('defaults className to Leaflets own default and renders markup as html', () => {
const el = document.createElement('leaflet-div-icon') as HTMLElement & {
leafletObject?: DivIcon;
};
el.innerHTML = '<span>*</span>';
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(DivIcon);
expect(el.leafletObject?.options.className).toBe('leaflet-div-icon');
expect(el.leafletObject?.options.html).toBe('<span>*</span>');
el.remove();
});
it('emits icon-changed(null) on disconnect', () => {
const el = document.createElement('leaflet-div-icon');
document.body.append(el);
const onChanged = vi.fn();
el.addEventListener('icon-changed', onChanged);
el.remove();
expect(onChanged).toHaveBeenCalledOnce();
expect((onChanged.mock.calls[0][0] as CustomEvent).detail.icon).toBeNull();
});
});

@ -0,0 +1,126 @@
import { Marker, Popup, Tooltip } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../../src/components/leaflet-map.ts';
import '../../src/components/leaflet-marker.ts';
import '../../src/components/leaflet-popup.ts';
import '../../src/components/leaflet-tooltip.ts';
import '../../src/components/leaflet-icon.ts';
describe('leaflet-marker', () => {
it('creates a Marker at lat/lng and keeps lat/lng synced while it moves', () => {
const el = document.createElement('leaflet-marker') as HTMLElement & {
leafletObject?: Marker;
lat: number;
lng: number;
};
el.setAttribute('lat', '51.5');
el.setAttribute('lng', '-0.09');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Marker);
expect(el.leafletObject?.getLatLng()).toMatchObject({ lat: 51.5, lng: -0.09 });
el.leafletObject?.setLatLng([1, 2]);
expect(el.lat).toBe(1);
expect(el.lng).toBe(2);
// event: 'move' syncs the attribute too
expect(el.getAttribute('lat')).toBe('1');
expect(el.getAttribute('lng')).toBe('2');
el.remove();
});
it('draggable toggles the Leaflet dragging handler', () => {
// The dragging handler is only initialized in onAdd(), so the marker
// needs to actually be added to a map first.
const map = document.createElement('leaflet-map');
document.body.append(map);
const el = document.createElement('leaflet-marker') as HTMLElement & {
leafletObject?: Marker;
};
el.setAttribute('lat', '0');
el.setAttribute('lng', '0');
el.setAttribute('draggable', '');
map.append(el);
expect(el.leafletObject?.dragging?.enabled()).toBe(true);
el.removeAttribute('draggable');
expect(el.leafletObject?.dragging?.enabled()).toBe(false);
map.remove();
});
it('swaps in a <leaflet-icon> child via the icon-changed handshake', () => {
const el = document.createElement('leaflet-marker') as HTMLElement & {
leafletObject?: Marker;
};
el.setAttribute('lat', '0');
el.setAttribute('lng', '0');
document.body.append(el);
const icon = document.createElement('leaflet-icon');
icon.setAttribute('icon-url', 'https://example.com/icon.png');
el.append(icon);
expect(el.leafletObject?.options.icon).toBe(
(icon as HTMLElement & { leafletObject?: unknown }).leafletObject,
);
el.remove();
});
});
describe('leaflet-popup', () => {
it('takes its content from innerHTML and stays in sync on mutation', async () => {
const el = document.createElement('leaflet-popup') as HTMLElement & {
leafletObject?: Popup;
};
el.innerHTML = '<b>hello</b>';
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Popup);
expect(el.leafletObject?.getContent()).toBe('<b>hello</b>');
el.innerHTML = '<b>updated</b>';
// MutationObserver callbacks run as a microtask.
await Promise.resolve();
expect(el.leafletObject?.getContent()).toBe('<b>updated</b>');
el.remove();
});
it('only sets its own latlng when lat/lng attributes are present', () => {
const withLatLng = document.createElement('leaflet-popup') as HTMLElement & {
leafletObject?: Popup;
};
withLatLng.setAttribute('lat', '1');
withLatLng.setAttribute('lng', '2');
document.body.append(withLatLng);
expect(withLatLng.leafletObject?.getLatLng()).toMatchObject({ lat: 1, lng: 2 });
withLatLng.remove();
const withoutLatLng = document.createElement('leaflet-popup') as HTMLElement & {
leafletObject?: Popup;
};
document.body.append(withoutLatLng);
expect(withoutLatLng.leafletObject?.getLatLng()).toBeUndefined();
withoutLatLng.remove();
});
});
describe('leaflet-tooltip', () => {
it('creates a Tooltip with content and direction/permanent/sticky options', () => {
const el = document.createElement('leaflet-tooltip') as HTMLElement & {
leafletObject?: Tooltip;
};
el.innerHTML = 'hover text';
el.setAttribute('direction', 'top');
el.setAttribute('permanent', '');
el.setAttribute('sticky', '');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Tooltip);
expect(el.leafletObject?.getContent()).toBe('hover text');
expect(el.leafletObject?.options.direction).toBe('top');
expect(el.leafletObject?.options.permanent).toBe(true);
expect(el.leafletObject?.options.sticky).toBe(true);
el.remove();
});
});

@ -0,0 +1,100 @@
import { ImageOverlay, SVGOverlay, VideoOverlay, latLngBounds } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../../src/components/leaflet-map.ts';
import '../../src/components/leaflet-image-overlay.ts';
import '../../src/components/leaflet-video-overlay.ts';
import '../../src/components/leaflet-svg-overlay.ts';
const BOUNDS: [[number, number], [number, number]] = [
[40.71, -74.24],
[40.78, -74.12],
];
describe('leaflet-image-overlay', () => {
it('creates an ImageOverlay with a live bounds getter (url has no Leaflet getter to read back)', () => {
const el = document.createElement('leaflet-image-overlay') as HTMLElement & {
leafletObject?: ImageOverlay;
url: string;
bounds: unknown;
};
el.setAttribute('url', 'https://example.com/a.png');
el.setAttribute('bounds', JSON.stringify(BOUNDS));
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(ImageOverlay);
expect(el.url).toBe('https://example.com/a.png');
expect(el.bounds).toEqual(BOUNDS);
el.leafletObject?.setBounds(
latLngBounds([
[1, 2],
[3, 4],
]),
);
// el.bounds reads live via getBounds()
expect(el.bounds).toEqual([
[1, 2],
[3, 4],
]);
el.remove();
});
});
describe('leaflet-video-overlay', () => {
it('creates a VideoOverlay and applies the newly added options', () => {
const el = document.createElement('leaflet-video-overlay') as HTMLElement & {
leafletObject?: VideoOverlay;
};
el.setAttribute('url', 'https://example.com/a.mp4');
el.setAttribute('bounds', JSON.stringify(BOUNDS));
el.setAttribute('z-index', '5');
el.setAttribute('class-name', 'my-video');
el.setAttribute('keep-aspect-ratio', 'false');
el.setAttribute('error-overlay-url', 'https://example.com/error.png');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(VideoOverlay);
expect(el.leafletObject?.options.zIndex).toBe(5);
expect(el.leafletObject?.options.className).toBe('my-video');
expect(el.leafletObject?.options.keepAspectRatio).toBe(false);
expect(el.leafletObject?.options.errorOverlayUrl).toBe('https://example.com/error.png');
el.remove();
});
it('applies playback attributes directly to the underlying <video> element once it exists', () => {
// The <video> element is only created in onAdd(), so the overlay needs to
// actually be added to a map before getElement() returns anything.
const map = document.createElement('leaflet-map');
document.body.append(map);
const el = document.createElement('leaflet-video-overlay') as HTMLElement & {
leafletObject?: VideoOverlay;
getElement(): HTMLVideoElement | undefined;
};
el.setAttribute('url', 'https://example.com/a.mp4');
el.setAttribute('bounds', JSON.stringify(BOUNDS));
el.setAttribute('muted', '');
el.setAttribute('loop', '');
map.append(el);
const video = el.getElement();
expect(video?.muted).toBe(true);
expect(video?.loop).toBe(true);
map.remove();
});
});
describe('leaflet-svg-overlay', () => {
it('creates an SVGOverlay with a live bounds getter', () => {
const el = document.createElement('leaflet-svg-overlay') as HTMLElement & {
leafletObject?: SVGOverlay;
bounds: unknown;
};
el.setAttribute('bounds', JSON.stringify(BOUNDS));
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(SVGOverlay);
expect(el.bounds).toEqual(BOUNDS);
el.remove();
});
});

@ -0,0 +1,147 @@
import { Circle, CircleMarker, Polygon, Polyline, Rectangle } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../../src/components/leaflet-circle.ts';
import '../../src/components/leaflet-circle-marker.ts';
import '../../src/components/leaflet-rectangle.ts';
import '../../src/components/leaflet-polygon.ts';
import '../../src/components/leaflet-polyline.ts';
import '../../src/components/leaflet-line.ts';
describe('leaflet-circle', () => {
it('creates a Circle at lat/lng with radius and pathProps applied', () => {
const el = document.createElement('leaflet-circle') as HTMLElement & {
leafletObject?: Circle;
radius: number;
};
el.setAttribute('lat', '51.5');
el.setAttribute('lng', '-0.09');
el.setAttribute('radius', '500');
el.setAttribute('color', 'red');
el.setAttribute('interactive', 'false');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Circle);
expect(el.leafletObject?.getLatLng()).toMatchObject({ lat: 51.5, lng: -0.09 });
expect(el.leafletObject?.getRadius()).toBe(500);
expect(el.leafletObject?.options.color).toBe('red');
expect(el.leafletObject?.options.interactive).toBe(false);
el.remove();
});
it('radius property reads the live value and reacts to attribute changes', () => {
const el = document.createElement('leaflet-circle') as HTMLElement & {
leafletObject?: Circle;
radius: number;
};
document.body.append(el);
// Leaflet's own default, no attribute set
expect(el.radius).toBe(1000);
el.setAttribute('radius', '750');
expect(el.leafletObject?.getRadius()).toBe(750);
expect(el.radius).toBe(750);
// Change bypassing the attribute entirely.
el.leafletObject?.setRadius(42);
// Property getter still reflects Leaflet's live state.
expect(el.radius).toBe(42);
el.remove();
});
});
describe('leaflet-circle-marker', () => {
it('creates a CircleMarker with a live radius getter', () => {
const el = document.createElement('leaflet-circle-marker') as HTMLElement & {
leafletObject?: CircleMarker;
radius: number;
};
el.setAttribute('lat', '1');
el.setAttribute('lng', '2');
el.setAttribute('radius', '15');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(CircleMarker);
expect(el.leafletObject?.getRadius()).toBe(15);
el.leafletObject?.setRadius(30);
expect(el.radius).toBe(30);
el.remove();
});
});
describe('leaflet-rectangle', () => {
it('creates a Rectangle from the bounds attribute with a live bounds getter', () => {
const el = document.createElement('leaflet-rectangle') as HTMLElement & {
leafletObject?: Rectangle;
bounds: [[number, number], [number, number]];
};
el.setAttribute(
'bounds',
JSON.stringify([
[51.49, -0.08],
[51.5, -0.06],
]),
);
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Rectangle);
expect(el.bounds).toEqual([
[51.49, -0.08],
[51.5, -0.06],
]);
el.remove();
});
});
describe('leaflet-polygon', () => {
it('collects vertices from <leaflet-line> children and updates on change', () => {
const el = document.createElement('leaflet-polygon') as HTMLElement & {
leafletObject?: Polygon;
};
const line1 = document.createElement('leaflet-line');
line1.setAttribute('lat', '1');
line1.setAttribute('lng', '2');
const line2 = document.createElement('leaflet-line');
line2.setAttribute('lat', '3');
line2.setAttribute('lng', '4');
el.append(line1, line2);
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Polygon);
expect(el.leafletObject?.getLatLngs()).toEqual([
[
{ lat: 1, lng: 2 },
{ lat: 3, lng: 4 },
],
]);
line1.setAttribute('lat', '9');
expect(el.leafletObject?.getLatLngs()).toEqual([
[
{ lat: 9, lng: 2 },
{ lat: 3, lng: 4 },
],
]);
el.remove();
});
});
describe('leaflet-polyline', () => {
it('applies smoothFactor/noClip and is unfilled by default', () => {
const el = document.createElement('leaflet-polyline') as HTMLElement & {
leafletObject?: Polyline;
};
el.setAttribute('smooth-factor', '2.5');
el.setAttribute('no-clip', '');
const line = document.createElement('leaflet-line');
line.setAttribute('lat', '1');
line.setAttribute('lng', '2');
el.append(line);
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(Polyline);
expect(el.leafletObject?.options.smoothFactor).toBe(2.5);
expect(el.leafletObject?.options.noClip).toBe(true);
expect(el.leafletObject?.options.fill).toBe(false);
el.remove();
});
});

@ -0,0 +1,68 @@
import { TileLayer } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../../src/components/leaflet-tile-layer.ts';
import '../../src/components/leaflet-tile-layer-wms.ts';
describe('leaflet-tile-layer', () => {
it('creates a TileLayer from the url attribute with tileLayerProps options', () => {
const el = document.createElement('leaflet-tile-layer') as HTMLElement & {
leafletObject?: TileLayer;
};
el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
el.setAttribute('subdomains', 'abcd');
el.setAttribute('tms', '');
el.setAttribute('max-native-zoom', '17');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(TileLayer);
// Leaflet itself splits a string subdomains option into a character array.
expect(el.leafletObject?.options.subdomains).toEqual(['a', 'b', 'c', 'd']);
expect(el.leafletObject?.options.tms).toBe(true);
expect(el.leafletObject?.options.maxNativeZoom).toBe(17);
el.remove();
});
it('defaults zIndex to 1, matching Leaflet', () => {
const el = document.createElement('leaflet-tile-layer') as HTMLElement & {
leafletObject?: TileLayer;
zIndex: number;
};
el.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
document.body.append(el);
expect(el.leafletObject?.options.zIndex).toBe(1);
el.remove();
});
});
describe('leaflet-tile-layer-wms', () => {
it('shares base tileLayerProps with leaflet-tile-layer (previously missing entirely)', () => {
const el = document.createElement('leaflet-tile-layer-wms') as HTMLElement & {
leafletObject?: TileLayer.WMS;
};
el.setAttribute('url', 'https://wms.example/service');
el.setAttribute('layers', 'basic');
el.setAttribute('attribution', 'Example');
el.setAttribute('min-zoom', '2');
document.body.append(el);
expect(el.leafletObject).toBeInstanceOf(TileLayer.WMS);
expect(el.leafletObject?.options.attribution).toBe('Example');
expect(el.leafletObject?.options.minZoom).toBe(2);
expect(el.leafletObject?.wmsParams.layers).toBe('basic');
el.remove();
});
it('resolves crs by name to the matching Leaflet CRS instance', async () => {
const { CRS } = await import('leaflet');
const el = document.createElement('leaflet-tile-layer-wms') as HTMLElement & {
leafletObject?: TileLayer.WMS;
};
el.setAttribute('url', 'https://wms.example/service');
el.setAttribute('layers', 'basic');
el.setAttribute('crs', 'EPSG4326');
document.body.append(el);
expect(el.leafletObject?.options.crs).toBe(CRS.EPSG4326);
el.remove();
});
});

@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import { bool, choice, disabled, json, kebab, num, positional, str } from '../../src/core/props.ts';
const noopGet = () => 1;
describe('kebab', () => {
it('converts camelCase to kebab-case', () => {
expect(kebab('fillColor')).toBe('fill-color');
expect(kebab('zIndex')).toBe('z-index');
expect(kebab('zIndexOffset')).toBe('z-index-offset');
});
it('leaves single lowercase words unchanged', () => {
expect(kebab('opacity')).toBe('opacity');
});
});
describe('num', () => {
it('defaults to 0 and decodes/encodes via Number/String', () => {
const def = num();
expect(def.default).toBe(0);
expect(def.decode('42')).toBe(42);
expect(def.encode(42)).toBe('42');
});
it('accepts a custom default and passes through extra opts', () => {
const def = num(10, { get: noopGet });
expect(def.default).toBe(10);
expect(def.get).toBe(noopGet);
});
});
describe('str', () => {
it('defaults to empty string and decode/encode are identity', () => {
const def = str();
expect(def.default).toBe('');
expect(def.decode('hello')).toBe('hello');
expect(def.encode('hello')).toBe('hello');
});
});
describe('choice', () => {
it('decode/encode are identity, typed as the literal union', () => {
const def = choice<'a' | 'b'>('a');
expect(def.default).toBe('a');
expect(def.decode('b')).toBe('b');
expect(def.encode('b')).toBe('b');
});
});
describe('bool', () => {
it('decodes any value other than the literal string "false" as true', () => {
const def = bool();
expect(def.decode('')).toBe(true);
expect(def.decode('true')).toBe(true);
expect(def.decode('anything')).toBe(true);
expect(def.decode('false')).toBe(false);
});
it('encode returns null when the value matches the default (removes the attribute)', () => {
const defFalse = bool(false);
expect(defFalse.encode(false)).toBeNull();
expect(defFalse.encode(true)).toBe('');
const defTrue = bool(true);
expect(defTrue.encode(true)).toBeNull();
expect(defTrue.encode(false)).toBe('false');
});
});
describe('disabled', () => {
it('names its attribute disable-<kebab-name>', () => {
const def = disabled();
expect(def.attribute).toBeTypeOf('function');
expect((def.attribute as (name: string) => string)('dragging')).toBe('disable-dragging');
});
it('defaults to true (enabled); presence of the attribute disables unless its value is "false"', () => {
const def = disabled();
expect(def.default).toBe(true);
// Bare `disable-foo` (raw === '') means "disable this" -> false.
expect(def.decode('')).toBe(false);
expect(def.decode('anything')).toBe(false);
// `disable-foo="false"` is a double-negative escape hatch -> stays enabled.
expect(def.decode('false')).toBe(true);
});
it('encodes false as a present attribute, true as absent', () => {
const def = disabled();
expect(def.encode(true)).toBeNull();
expect(def.encode(false)).toBe('');
});
});
describe('json', () => {
it('decodes via JSON.parse and encodes via JSON.stringify', () => {
const def = json<{ a: number }>({ a: 0 });
expect(def.decode('{"a":5}')).toEqual({ a: 5 });
expect(def.encode({ a: 5 })).toBe('{"a":5}');
});
});
describe('positional', () => {
it('adds option: false while preserving every other field', () => {
const base = num(5, { get: () => 1 });
const pos = positional(base);
expect(pos.option).toBe(false);
expect(pos.default).toBe(5);
expect(pos.get).toBe(base.get);
expect(pos.decode).toBe(base.decode);
});
});

@ -0,0 +1,481 @@
import { Marker, Popup, Tooltip } from 'leaflet';
import { describe, expect, it, vi } from 'vitest';
import { num, positional, str, type PropOptionValues } from '../../src/core/props.ts';
import { WithProps, type LeafletElementConstructor } from '../../src/core/with-props.ts';
import type { LeafletRegisterEvent } from '../../src/core/register.ts';
// A minimal stand-in for a Leaflet object: duck-typed setters/getters plus the
// on/off/fire trio WithProps needs to watch and forward events. None of the
// tests in this file need real Leaflet -- WithProps only calls methods by
// name, it never checks `instanceof` -- except the child-registration group
// at the bottom, which exercises #onChildRegister's real `instanceof
// Popup/Tooltip/Layer` checks and so needs real Leaflet objects.
class FakeObj {
radius = 0;
opacity = 1;
removed = false;
#listeners = new Map<string, Set<(data?: unknown) => void>>();
setRadius(v: number): void {
this.radius = v;
}
getRadius(): number {
return this.radius;
}
setOpacity(v: number): void {
this.opacity = v;
}
bindPopup(): void {}
unbindPopup(): void {}
bindTooltip(): void {}
unbindTooltip(): void {}
addLayer(): void {}
removeLayer(): void {}
on(type: string, handler: (data?: unknown) => void): this {
let set = this.#listeners.get(type);
if (!set) {
set = new Set();
this.#listeners.set(type, set);
}
set.add(handler);
return this;
}
off(type: string, handler: (data?: unknown) => void): this {
this.#listeners.get(type)?.delete(handler);
return this;
}
fire(type: string, data?: unknown): this {
for (const handler of this.#listeners.get(type) ?? []) handler(data);
return this;
}
remove(): void {
this.removed = true;
}
}
const BASE_PROPS = {
radius: num<FakeObj>(5, { get: (obj) => obj.getRadius() }),
opacity: num<FakeObj>(1),
} as const;
function define(
tag: string,
props: Parameters<typeof WithProps>[0] = BASE_PROPS,
elementOptions?: Parameters<typeof WithProps>[1],
): {
Ctor: LeafletElementConstructor<FakeObj, typeof props>;
create: ReturnType<typeof vi.fn<(options: PropOptionValues<typeof props>) => FakeObj>>;
} {
const create = vi.fn((options: PropOptionValues<typeof props>) => {
const obj = new FakeObj();
const opts = options as { radius?: number; opacity?: number };
if (opts.radius !== undefined) obj.setRadius(opts.radius);
if (opts.opacity !== undefined) obj.setOpacity(opts.opacity);
return obj;
});
class El extends WithProps<FakeObj, typeof props>(props, elementOptions) {
createLeafletObject(opts: PropOptionValues<typeof props>): FakeObj {
return create(opts);
}
}
customElements.define(tag, El);
return { Ctor: El as unknown as LeafletElementConstructor<FakeObj, typeof props>, create };
}
function childRegisterEvent(el: HTMLElement, leafletObject: unknown): LeafletRegisterEvent {
return new CustomEvent('leaflet-register', {
bubbles: true,
composed: true,
detail: { leafletObject, element: el },
}) as LeafletRegisterEvent;
}
describe('WithProps: observedAttributes', () => {
it('kebab-cases prop names and derives them from the PROPS table', () => {
const props = { fooBar: str<FakeObj>('x'), radius: num<FakeObj>(0) } as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
customElements.define('wp-observed-attrs', El);
expect((El as unknown as { observedAttributes: string[] }).observedAttributes).toEqual([
'foo-bar',
'radius',
]);
});
});
describe('WithProps: creation and options', () => {
it('builds the options object only from present attributes, decoded', () => {
const { Ctor, create } = define('wp-create-options');
const el = document.createElement('wp-create-options') as InstanceType<typeof Ctor>;
el.setAttribute('radius', '20');
document.body.append(el);
expect(create).toHaveBeenCalledWith({ radius: 20 });
expect(el.leafletObject).toBeInstanceOf(FakeObj);
el.remove();
});
it('omits props with option:false (positional) from the options object', () => {
const props = { radius: positional(num<FakeObj>(5)), opacity: num<FakeObj>(1) } as const;
const create = vi.fn((_o: PropOptionValues<typeof props>) => new FakeObj());
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(opts: PropOptionValues<typeof props>): FakeObj {
return create(opts);
}
}
customElements.define('wp-positional', El);
const el = document.createElement('wp-positional');
el.setAttribute('radius', '50');
el.setAttribute('opacity', '0.5');
document.body.append(el);
expect(create).toHaveBeenCalledWith({ opacity: 0.5 });
el.remove();
});
it('calls leafletObjectCreated() after creation', () => {
const props = BASE_PROPS;
const created = vi.fn();
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
leafletObjectCreated(): void {
created();
}
}
customElements.define('wp-created-hook', El);
document.body.append(document.createElement('wp-created-hook'));
expect(created).toHaveBeenCalledOnce();
});
});
describe('WithProps: attribute changes update the Leaflet object', () => {
it('dispatches to the matching setNAME method by default', () => {
const tag = 'wp-attr-setter';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
node.setAttribute('radius', '42');
expect(node.leafletObject?.radius).toBe(42);
node.remove();
});
it('falls back to the default value when the attribute is removed', () => {
const tag = 'wp-attr-remove-falls-back';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
node.setAttribute('radius', '99');
document.body.append(node);
expect(node.leafletObject?.radius).toBe(99);
node.removeAttribute('radius');
// BASE_PROPS default.
expect(node.leafletObject?.radius).toBe(5);
node.remove();
});
it('uses a custom `set` function instead of the setter when provided', () => {
const props = {
radius: num<FakeObj>(5, {
set(obj, value) {
obj.setRadius(value * 2);
},
}),
} as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
const tag = 'wp-custom-set';
customElements.define(tag, El);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
node.setAttribute('radius', '10');
expect(node.leafletObject?.radius).toBe(20);
node.remove();
});
it('is a silent no-op when there is neither a custom `set` nor a matching setter (constructor-only props)', () => {
const props = { unsettable: str<FakeObj>('x') } as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
const tag = 'wp-constructor-only';
customElements.define(tag, El);
const node = document.createElement(tag);
document.body.append(node);
expect(() => node.setAttribute('unsettable', 'y')).not.toThrow();
node.remove();
});
it('ignores attribute churn before the element is connected', () => {
const tag = 'wp-not-connected';
const { create } = define(tag);
const node = document.createElement(tag);
node.setAttribute('radius', '1');
node.setAttribute('radius', '2');
expect(create).not.toHaveBeenCalled();
});
});
describe('WithProps: recreate option', () => {
it('rebuilds the whole object on any attribute change instead of calling a setter', () => {
const props = { radius: num<FakeObj>(5) } as const;
const created = vi.fn();
class El extends WithProps<FakeObj, typeof props>(props, { recreate: true }) {
createLeafletObject(opts: PropOptionValues<typeof props>): FakeObj {
const obj = new FakeObj();
if (opts.radius !== undefined) obj.setRadius(opts.radius);
return obj;
}
leafletObjectCreated(): void {
created();
}
}
const tag = 'wp-recreate';
customElements.define(tag, El);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const first = node.leafletObject;
expect(created).toHaveBeenCalledTimes(1);
node.setAttribute('radius', '7');
expect(node.leafletObject).not.toBe(first);
expect(node.leafletObject?.radius).toBe(7);
expect(created).toHaveBeenCalledTimes(2);
node.remove();
});
});
describe('WithProps: property accessors', () => {
it('get reads the live Leaflet value when `get` is defined', () => {
const tag = 'wp-get-live';
define(tag);
const node = document.createElement(tag) as HTMLElement & {
leafletObject?: FakeObj;
radius: number;
};
document.body.append(node);
// Bypass the attribute entirely.
node.leafletObject!.setRadius(123);
expect(node.radius).toBe(123);
node.remove();
});
it('get falls back to the attribute, then the default, when unconnected', () => {
const tag = 'wp-get-fallback';
define(tag);
const node = document.createElement(tag) as HTMLElement & { radius: number };
// Default, not connected yet.
expect(node.radius).toBe(5);
node.setAttribute('radius', '9');
// Attribute, still not connected.
expect(node.radius).toBe(9);
});
it('set encodes the value onto the attribute', () => {
const tag = 'wp-set-encodes';
define(tag);
const node = document.createElement(tag) as HTMLElement & { radius: number };
node.radius = 33;
expect(node.getAttribute('radius')).toBe('33');
});
});
describe('WithProps: event-driven attribute sync-back', () => {
it('re-syncs the attribute after the named Leaflet event fires', () => {
const props = {
radius: num<FakeObj>(5, { get: (obj) => obj.getRadius(), event: 'radiuschange' }),
} as const;
class El extends WithProps<FakeObj, typeof props>(props) {
createLeafletObject(): FakeObj {
return new FakeObj();
}
}
const tag = 'wp-event-sync';
customElements.define(tag, El);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
node.leafletObject!.setRadius(77);
// Not synced yet.
expect(node.getAttribute('radius')).toBeNull();
node.leafletObject!.fire('radiuschange');
expect(node.getAttribute('radius')).toBe('77');
node.remove();
});
});
describe('WithProps: generic leaflet: event forwarding', () => {
it('re-emits any fired Leaflet event as a non-bubbling leaflet:<type> CustomEvent', () => {
const tag = 'wp-forward-events';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
const wrapper = document.createElement('div');
wrapper.append(node);
document.body.append(wrapper);
const onNode = vi.fn();
const onWrapper = vi.fn();
node.addEventListener('leaflet:zoomend', onNode);
wrapper.addEventListener('leaflet:zoomend', onWrapper);
node.leafletObject!.fire('zoomend', { foo: 1 });
expect(onNode).toHaveBeenCalledOnce();
const event = onNode.mock.calls[0][0] as CustomEvent;
expect(event.detail).toEqual({ foo: 1 });
// Does not bubble.
expect(onWrapper).not.toHaveBeenCalled();
wrapper.remove();
});
it('dispatches the DOM event after native on() handlers have already run', () => {
const tag = 'wp-forward-order';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
let nativeRanFirst = false;
node.leafletObject!.on('customevent', () => {
nativeRanFirst = true;
});
node.addEventListener('leaflet:customevent', () => {
expect(nativeRanFirst).toBe(true);
});
node.leafletObject!.fire('customevent');
node.remove();
});
});
describe('WithProps: attach modes', () => {
it('attach "children" (default) registers with the parent and listens for children', () => {
const tag = 'wp-attach-children';
define(tag);
const parent = document.createElement('div');
document.body.append(parent);
const onRegister = vi.fn();
parent.addEventListener('leaflet-register', onRegister);
const node = document.createElement(tag);
parent.append(node);
expect(onRegister).toHaveBeenCalledOnce();
parent.remove();
});
it('attach "self" registers with the parent but does not bind child registrations', () => {
const tag = 'wp-attach-self';
define(tag, BASE_PROPS, { attach: 'self' });
const parent = document.createElement('div');
document.body.append(parent);
const onRegister = vi.fn();
parent.addEventListener('leaflet-register', onRegister);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
parent.append(node);
// It still registers itself.
expect(onRegister).toHaveBeenCalledOnce();
const bindPopup = vi.spyOn(node.leafletObject!, 'bindPopup');
const fakeChild = document.createElement('div');
node.append(fakeChild);
fakeChild.dispatchEvent(
new CustomEvent('leaflet-register', {
bubbles: true,
composed: true,
detail: { leafletObject: new Popup(), element: fakeChild },
}),
);
// "self" doesn't listen for children.
expect(bindPopup).not.toHaveBeenCalled();
parent.remove();
});
it('attach "none" does not register with the parent at all', () => {
const tag = 'wp-attach-none';
define(tag, BASE_PROPS, { attach: 'none' });
const parent = document.createElement('div');
document.body.append(parent);
const onRegister = vi.fn();
parent.addEventListener('leaflet-register', onRegister);
parent.append(document.createElement(tag));
expect(onRegister).not.toHaveBeenCalled();
parent.remove();
});
});
// These need real Leaflet Popup/Tooltip/Marker instances because
// #onChildRegister discriminates children by `instanceof Popup/Tooltip/Layer`,
// not duck typing.
describe('WithProps: child registration binds popups, tooltips and layers', () => {
it('binds a registering Popup child via bindPopup', () => {
const tag = 'wp-bind-popup';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const bindPopup = vi.spyOn(node.leafletObject!, 'bindPopup');
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Popup()));
expect(bindPopup).toHaveBeenCalledOnce();
node.remove();
});
it('binds a registering Tooltip child via bindTooltip', () => {
const tag = 'wp-bind-tooltip';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const bindTooltip = vi.spyOn(node.leafletObject!, 'bindTooltip');
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Tooltip()));
expect(bindTooltip).toHaveBeenCalledOnce();
node.remove();
});
it('adds a registering Layer child via addLayer', () => {
const tag = 'wp-bind-layer';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const addLayer = vi.spyOn(node.leafletObject!, 'addLayer');
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Marker([0, 0])));
expect(addLayer).toHaveBeenCalledOnce();
node.remove();
});
it('releases children (unbind/remove) on disconnect', () => {
const tag = 'wp-release-children';
define(tag);
const node = document.createElement(tag) as HTMLElement & { leafletObject?: FakeObj };
document.body.append(node);
const leafletObject = node.leafletObject!;
const child = document.createElement('div');
node.append(child);
child.dispatchEvent(childRegisterEvent(child, new Popup()));
const unbindPopup = vi.spyOn(leafletObject, 'unbindPopup');
node.remove();
expect(unbindPopup).toHaveBeenCalledOnce();
});
});

@ -0,0 +1,84 @@
import { FeatureGroup, Map as LMap, Marker, Popup, TileLayer } from 'leaflet';
import { describe, expect, it } from 'vitest';
import '../src/components/leaflet-map.ts';
import '../src/components/leaflet-tile-layer.ts';
import '../src/components/leaflet-feature-group.ts';
import '../src/components/leaflet-marker.ts';
import '../src/components/leaflet-popup.ts';
// Exercises the registration-bubbling protocol end to end across a small real
// tree, rather than one component at a time: <leaflet-register> events bubble
// from marker -> feature-group -> map, and popup binds to its parent marker
// instead of bubbling past it.
describe('component tree wiring', () => {
it('wires a map + tile-layer + feature-group + marker + popup tree together', () => {
const map = document.createElement('leaflet-map') as HTMLElement & {
leafletObject?: LMap;
};
map.setAttribute('lat', '51.5');
map.setAttribute('lng', '-0.09');
map.setAttribute('zoom', '13');
const tiles = document.createElement('leaflet-tile-layer') as HTMLElement & {
leafletObject?: TileLayer;
};
tiles.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
const group = document.createElement('leaflet-feature-group') as HTMLElement & {
leafletObject?: FeatureGroup;
};
const marker = document.createElement('leaflet-marker') as HTMLElement & {
leafletObject?: Marker;
};
marker.setAttribute('lat', '51.51');
marker.setAttribute('lng', '-0.1');
const popup = document.createElement('leaflet-popup') as HTMLElement & {
leafletObject?: Popup;
};
popup.innerHTML = 'hello';
marker.append(popup);
group.append(marker);
map.append(tiles, group);
document.body.append(map);
// tile-layer and feature-group both registered directly onto the map.
expect(map.leafletObject?.hasLayer(tiles.leafletObject!)).toBe(true);
expect(map.leafletObject?.hasLayer(group.leafletObject!)).toBe(true);
// marker registered onto the feature-group (which, per Leaflet's own
// LayerGroup.onAdd, also adds each child directly onto the map so it
// renders -- hasLayer is true on both, membership is what distinguishes them).
expect(group.leafletObject?.hasLayer(marker.leafletObject!)).toBe(true);
expect(map.leafletObject?.hasLayer(marker.leafletObject!)).toBe(true);
expect(group.leafletObject?.getLayers()).toEqual([marker.leafletObject]);
// popup bound to the marker, not registered as a map layer at all.
expect(marker.leafletObject?.getPopup()).toBe(popup.leafletObject);
// A marker added later goes through the same path.
const marker2 = document.createElement('leaflet-marker') as HTMLElement & {
leafletObject?: Marker;
};
marker2.setAttribute('lat', '1');
marker2.setAttribute('lng', '2');
group.append(marker2);
expect(group.leafletObject?.hasLayer(marker2.leafletObject!)).toBe(true);
// Disconnecting a child removes it from the map (WithProps calls the
// Leaflet object's own .remove(), which detaches from its _map).
// leafletObject is gone once disconnected, so capture the reference first.
// Note this is a pre-existing Leaflet quirk, not something WithProps
// introduces: Layer#remove() only detaches from the map, it does not tell
// an owning LayerGroup/FeatureGroup to forget it (that needs the group's
// own removeLayer()) -- so the group's hasLayer() stays stale/true here.
const removedMarkerObj = marker.leafletObject!;
marker.remove();
expect(map.leafletObject?.hasLayer(removedMarkerObj)).toBe(false);
expect(group.leafletObject?.hasLayer(removedMarkerObj)).toBe(true);
map.remove();
});
});

@ -0,0 +1,10 @@
// jsdom has no ResizeObserver. leaflet-map only uses it to call
// invalidateSize() on resize, which no test here depends on incidentally --
// without a stub, just importing the component throws.
class ResizeObserverStub implements ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
globalThis.ResizeObserver ??= ResizeObserverStub;

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true,
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*", "test/**/*"]
}

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
include: ['test/**/*.test.ts'],
setupFiles: ['./test/setup.ts'],
},
});
Loading…
Cancel
Save