refactor: enforce strict TypeScript linting rules

Upgrade ESLint config to use flat config with @typescript-eslint/strict-type-checked and stylistic-type-checked presets.

- Use defineConfig helper, scope to .ts files only
- Replace || with ??, convert type to interface, remove redundant type assertions and non-null assertions
- Annotate intentional violations (unbound-method, non-null-assertion) with eslint-disable
- Use querySelectorAll<T> for typed selection, Map over globalThis.Map
- Add @eslint/js dev dependency
main
Buddy 3 months ago
parent 5b0c3ecdb7
commit e2382f1117

@ -1,24 +1,43 @@
import tsParser from '@typescript-eslint/parser'; import { defineConfig } from 'eslint/config';
import tsPlugin from '@typescript-eslint/eslint-plugin'; import tsPlugin from '@typescript-eslint/eslint-plugin';
import prettierPlugin from 'eslint-plugin-prettier'; import prettierPlugin from 'eslint-plugin-prettier';
import prettierConfig from 'eslint-config-prettier'; import prettierConfig from 'eslint-config-prettier';
export default [ export default defineConfig([
{ {
files: ['src/**/*.ts'], ignores: ['dist/'],
},
...tsPlugin.configs['flat/strict-type-checked'].map(c => ({
...c,
files: ['**/*.ts'],
})),
...tsPlugin.configs['flat/stylistic-type-checked'].map(c => ({
...c,
files: ['**/*.ts'],
})),
{
files: ['**/*.ts'],
languageOptions: { languageOptions: {
parser: tsParser, parserOptions: { project: true },
ecmaVersion: 2020,
sourceType: 'module',
}, },
plugins: { plugins: {
'@typescript-eslint': tsPlugin,
prettier: prettierPlugin, prettier: prettierPlugin,
}, },
rules: { rules: {
...tsPlugin.configs.recommended.rules,
...prettierConfig.rules, ...prettierConfig.rules,
'prettier/prettier': 'error', 'prettier/prettier': 'error',
}, },
}, },
];
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
},
},
]);

22
package-lock.json generated

@ -12,6 +12,7 @@
"leaflet": "1.9.4" "leaflet": "1.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0", "@rollup/plugin-typescript": "^12.3.0",
"@types/leaflet": "1.9.21", "@types/leaflet": "1.9.21",
@ -124,6 +125,27 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@eslint/js": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
}
},
"node_modules/@eslint/object-schema": { "node_modules/@eslint/object-schema": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",

@ -39,6 +39,7 @@
"leaflet": "1.9.4" "leaflet": "1.9.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0", "@rollup/plugin-typescript": "^12.3.0",
"@types/leaflet": "1.9.21", "@types/leaflet": "1.9.21",

@ -24,11 +24,15 @@ export class LeafletCircleMarker extends WithProps(HTMLElement, PROPS) {
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')], [numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']), buildOptions(this, PROPS, ['lat', 'lng']),
); );
// The `on` method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onMove, this); this.#obj.on('move', this.#onMove, this);
registerChildren(this, this.#obj); registerChildren(this, this.#obj);
} }
disconnectedCallback() { disconnectedCallback() {
// The `off` method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onMove, this); this.#obj?.off('move', this.#onMove, this);
unregisterChildren(this); unregisterChildren(this);
this.#obj?.remove(); this.#obj?.remove();

@ -24,11 +24,15 @@ export class LeafletCircle extends WithProps(HTMLElement, PROPS) {
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')], [numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']), buildOptions(this, PROPS, ['lat', 'lng']),
); );
// the on method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onMove, this); this.#obj.on('move', this.#onMove, this);
registerChildren(this, this.#obj); registerChildren(this, this.#obj);
} }
disconnectedCallback() { disconnectedCallback() {
// the off method binds the function to `this`, we can ignore the error
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onMove, this); this.#obj?.off('move', this.#onMove, this);
unregisterChildren(this); unregisterChildren(this);
this.#obj?.remove(); this.#obj?.remove();

@ -65,11 +65,14 @@ export class LeafletControlLayers extends WithProps(HTMLElement, PROPS) {
const el = e.detail.element; const el = e.detail.element;
const layer = e.detail.leafletObject; const layer = e.detail.leafletObject;
const name = el.getAttribute('name'); const name = el.getAttribute('name');
if (!name || !layer) return; if (!name) return;
const base = el.getAttribute('type') === 'base'; const base = el.getAttribute('type') === 'base';
const active = el.hasAttribute('active'); const active = el.hasAttribute('active');
if (base) this.#obj!.addBaseLayer(layer, name); if (base) {
else this.#obj!.addOverlay(layer, name); this.#obj?.addBaseLayer(layer, name);
} else {
this.#obj?.addOverlay(layer, name);
}
if (active) { if (active) {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }), new CustomEvent('leaflet-add-layer', { bubbles: true, detail: { layer } }),

@ -1,4 +1,4 @@
import { DivIcon, DivIconOptions, Icon } from 'leaflet'; import { DivIcon, DivIconOptions } from 'leaflet';
import { defineProps, str } from '../core/props.ts'; import { defineProps, str } from '../core/props.ts';
import { emitIconChanged } from '../core/register.ts'; import { emitIconChanged } from '../core/register.ts';
@ -12,10 +12,16 @@ const PROPS = defineProps({
bgPos: str(), bgPos: str(),
}); });
const JSON_KEYS = new Set(['iconSize', 'iconAnchor', 'popupAnchor', 'tooltipAnchor', 'bgPos']); const JSON_KEYS = new Set<keyof typeof PROPS>([
'iconSize',
'iconAnchor',
'popupAnchor',
'tooltipAnchor',
'bgPos',
]);
export class LeafletDivIcon extends HTMLElement { export class LeafletDivIcon extends HTMLElement {
#obj?: Icon; #obj?: DivIcon;
#observer?: MutationObserver; #observer?: MutationObserver;
connectedCallback() { connectedCallback() {
@ -54,15 +60,17 @@ export class LeafletDivIcon extends HTMLElement {
} }
#applyIcon() { #applyIcon() {
const opts: Record<string, unknown> = {}; const opts: Partial<DivIconOptions> = {};
for (const [key, spec] of Object.entries(PROPS)) { for (const [key, spec] of Object.entries(PROPS)) {
const v = this.getAttribute(spec.attr); const v = this.getAttribute(spec.attr);
if (v === null) continue; if (v === null) continue;
opts[key] = JSON_KEYS.has(key) ? JSON.parse(v) : v; const typedKey = key as keyof typeof PROPS;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
opts[typedKey as keyof DivIconOptions] = JSON_KEYS.has(typedKey) ? JSON.parse(v) : v;
} }
const content = this.innerHTML; const content = this.innerHTML;
if (content) opts.html = content; if (content) opts.html = content;
this.#obj = new DivIcon(opts as DivIconOptions) as unknown as Icon; this.#obj = new DivIcon(opts);
} }
} }

@ -18,7 +18,7 @@ export class LeafletFeatureGroup extends HTMLElement {
for (const [el, type] of getChildren(this) ?? []) { for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup(); if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip(); else if (type === 'tooltip') this.#obj?.unbindTooltip();
else if (type === 'layer') this.#obj?.removeLayer(el as unknown as Layer); else this.#obj?.removeLayer(el as unknown as Layer);
} }
unregisterChildren(this); unregisterChildren(this);
this.#obj?.remove(); this.#obj?.remove();

@ -2,6 +2,7 @@ import { GeoJSON, PathOptions, Layer } from 'leaflet';
import { WithProps, defineProps, num, str, on } from '../core/props.ts'; import { WithProps, defineProps, num, str, on } from '../core/props.ts';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts'; import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
import { buildOptions } from '../core/utils.ts'; import { buildOptions } from '../core/utils.ts';
import type { GeoJsonObject } from 'geojson';
const PROPS = defineProps({ const PROPS = defineProps({
data: str(), data: str(),
@ -28,7 +29,7 @@ export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
connectedCallback() { connectedCallback() {
const raw = this.getAttribute('data'); const raw = this.getAttribute('data');
const data = raw ? JSON.parse(raw) : undefined; const data = raw ? (JSON.parse(raw) as GeoJsonObject) : null;
const styleOpts = Object.fromEntries( const styleOpts = Object.fromEntries(
Object.entries(buildOptions(this, PROPS, ['data'])).filter(([, v]) => v !== ''), Object.entries(buildOptions(this, PROPS, ['data'])).filter(([, v]) => v !== ''),
); );
@ -40,7 +41,7 @@ export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
for (const [el, type] of getChildren(this) ?? []) { for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup(); if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip(); else if (type === 'tooltip') this.#obj?.unbindTooltip();
else if (type === 'layer') this.#obj?.removeLayer(el as unknown as Layer); else this.#obj?.removeLayer(el as unknown as Layer);
} }
unregisterChildren(this); unregisterChildren(this);
this.#obj?.remove(); this.#obj?.remove();
@ -55,6 +56,7 @@ export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
if (!this.#obj) return; if (!this.#obj) return;
if (name === 'data') { if (name === 'data') {
this.#obj.clearLayers(); this.#obj.clearLayers();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (val) this.#obj.addData(JSON.parse(val)); if (val) this.#obj.addData(JSON.parse(val));
} else { } else {
const propName = PROP_BY_ATTR.get(name); const propName = PROP_BY_ATTR.get(name);

@ -22,7 +22,7 @@ export class LeafletImageOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: ImageOverlay; #obj?: ImageOverlay;
connectedCallback() { connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') ?? '';
this.#obj = new ImageOverlay( this.#obj = new ImageOverlay(
url, url,
parseBoundsAttr(this), parseBoundsAttr(this),
@ -49,9 +49,9 @@ export class LeafletImageOverlay extends WithProps(HTMLElement, PROPS) {
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[])); this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
} else if (name === 'alt') { } else if (name === 'alt') {
const el = this.#obj.getElement(); const el = this.#obj.getElement();
if (el) (el as HTMLImageElement).alt = val ?? ''; if (el) el.alt = val ?? '';
} else { } else {
setLayerAttr(this.#obj!, PROPS, PROP_BY_ATTR, name, val); setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
} }
} }
} }

@ -18,7 +18,7 @@ export class LeafletLayerGroup extends HTMLElement {
for (const [el, type] of getChildren(this) ?? []) { for (const [el, type] of getChildren(this) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup(); if (type === 'popup') this.#obj?.unbindPopup();
else if (type === 'tooltip') this.#obj?.unbindTooltip(); else if (type === 'tooltip') this.#obj?.unbindTooltip();
else if (type === 'layer') this.#obj?.removeLayer(el as unknown as Layer); else this.#obj?.removeLayer(el as unknown as Layer);
} }
unregisterChildren(this); unregisterChildren(this);
this.#obj?.remove(); this.#obj?.remove();

@ -12,14 +12,14 @@ const PROPS = defineProps({
lat: num(0, { lat: num(0, {
viewState: true, viewState: true,
event: 'moveend', event: 'moveend',
mapGet: (m: LMap) => m.getCenter()?.lat, mapGet: (m: LMap) => m.getCenter().lat,
mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter()?.lng ?? 0], m.getZoom()), mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter().lng], m.getZoom()),
}), }),
lng: num(0, { lng: num(0, {
viewState: true, viewState: true,
event: 'moveend', event: 'moveend',
mapGet: (m: LMap) => m.getCenter()?.lng, mapGet: (m: LMap) => m.getCenter().lng,
mapSet: (m: LMap, v: number) => m.setView([m.getCenter()?.lat ?? 0, v], m.getZoom()), mapSet: (m: LMap, v: number) => m.setView([m.getCenter().lat, v], m.getZoom()),
}), }),
zoom: num(2, { zoom: num(2, {
viewState: true, viewState: true,
@ -81,7 +81,7 @@ const PROPS = defineProps({
type PropName = keyof typeof PROPS; type PropName = keyof typeof PROPS;
const ATTR_TO_PROP = new globalThis.Map<string, PropName>( const ATTR_TO_PROP = new Map<string, PropName>(
(Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]), (Object.entries(PROPS) as [PropName, PropDef][]).map(([name, spec]) => [spec.attr, name]),
); );
@ -98,7 +98,7 @@ export class LeafletMap extends TypedBase {
#container!: HTMLDivElement; #container!: HTMLDivElement;
#cssLink?: HTMLLinkElement; #cssLink?: HTMLLinkElement;
#syncing = false; #syncing = false;
#mapEventHandlers = new globalThis.Map<string, () => void>(); #mapEventHandlers = new Map<string, () => void>();
#resizeObserver?: ResizeObserver; #resizeObserver?: ResizeObserver;
static get observedAttributes(): string[] { static get observedAttributes(): string[] {
@ -113,7 +113,7 @@ export class LeafletMap extends TypedBase {
Object.defineProperty(LeafletMap.prototype, propName, { Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) { get(this: LeafletMap) {
const val = spec.mapGet && this.#map ? spec.mapGet(this.#map) : undefined; const val = spec.mapGet && this.#map ? spec.mapGet(this.#map) : undefined;
return val !== undefined ? val : +(this.getAttribute(spec.attr) ?? spec.default); return val ?? +(this.getAttribute(spec.attr) ?? spec.default);
}, },
set(this: LeafletMap, v: number) { set(this: LeafletMap, v: number) {
if (spec.event) { if (spec.event) {
@ -157,9 +157,11 @@ export class LeafletMap extends TypedBase {
this.#container = document.createElement('div'); this.#container = document.createElement('div');
this.#container.style.width = '100%'; this.#container.style.width = '100%';
this.#container.style.height = '100%'; this.#container.style.height = '100%';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(this.#container); this.shadowRoot!.appendChild(this.#container);
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ':host { display: block; width: 100%; height: 400px; }'; style.textContent = ':host { display: block; width: 100%; height: 400px; }';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(style); this.shadowRoot!.appendChild(style);
} }
this.#applyCss(); this.#applyCss();
@ -180,7 +182,7 @@ export class LeafletMap extends TypedBase {
this.#resizeObserver.observe(this); this.#resizeObserver.observe(this);
// Register one handler per unique map event, updating all props that share it // Register one handler per unique map event, updating all props that share it
const groups = new globalThis.Map<string, NumProp[]>(); const groups = new Map<string, NumProp[]>();
for (const spec of Object.values(PROPS) as PropDef[]) { for (const spec of Object.values(PROPS) as PropDef[]) {
if (spec.kind === 'num' && spec.event && spec.mapGet) { if (spec.kind === 'num' && spec.event && spec.mapGet) {
const g = groups.get(spec.event) ?? []; const g = groups.get(spec.event) ?? [];
@ -254,11 +256,11 @@ export class LeafletMap extends TypedBase {
} }
const customUrl = this.hasAttribute('css-url'); const customUrl = this.hasAttribute('css-url');
const url = customUrl ? this.getAttribute('css-url')! : DEFAULT_CSS_URL; const url = customUrl ? this.getAttribute('css-url') : DEFAULT_CSS_URL;
let integrity: string | undefined; let integrity: string | null = null;
if (this.hasAttribute('css-integrity')) { if (this.hasAttribute('css-integrity')) {
integrity = this.getAttribute('css-integrity')!; integrity = this.getAttribute('css-integrity') ?? '';
} else if (!customUrl) { } else if (!customUrl) {
integrity = DEFAULT_CSS_INTEGRITY; integrity = DEFAULT_CSS_INTEGRITY;
} }
@ -273,14 +275,14 @@ export class LeafletMap extends TypedBase {
const link = document.createElement('link'); const link = document.createElement('link');
link.rel = 'stylesheet'; link.rel = 'stylesheet';
link.href = url; link.href = url ?? '';
if (integrity) link.setAttribute('integrity', integrity); if (integrity) link.setAttribute('integrity', integrity);
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin); if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
sr.appendChild(link); sr.appendChild(link);
this.#cssLink = link; this.#cssLink = link;
// Derive marker icon path from the CSS URL (replaces leaflet.css → images/) // Derive marker icon path from the CSS URL (replaces leaflet.css → images/)
Icon.Default.imagePath = url.replace(/\/[^/]+$/, '/images/'); Icon.Default.imagePath = url?.replace(/\/[^/]+$/, '/images/');
} }
#syncAttr(name: string, value: string) { #syncAttr(name: string, value: string) {
@ -303,12 +305,12 @@ export class LeafletMap extends TypedBase {
if (this.hasAttribute(spec.attr)) o[propName] = true; if (this.hasAttribute(spec.attr)) o[propName] = true;
} }
} }
return o as MapOptions; return o;
} }
#handleLeafletRegister = (e: LeafletRegisterEvent) => { #handleLeafletRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation(); e.stopPropagation();
if (this.#map && e.detail.leafletObject) { if (this.#map) {
e.detail.leafletObject.addTo(this.#map); e.detail.leafletObject.addTo(this.#map);
} }
}; };

@ -24,12 +24,14 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')], [numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')],
buildOptions(this, PROPS, ['lat', 'lng']), buildOptions(this, PROPS, ['lat', 'lng']),
); );
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('dragend move', this.#onChange, this); this.#obj.on('dragend move', this.#onChange, this);
registerChildren(this, this.#obj); registerChildren(this, this.#obj);
this.addEventListener('icon-changed', this.#onIconChanged); this.addEventListener('icon-changed', this.#onIconChanged);
} }
disconnectedCallback() { disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('dragend move', this.#onChange, this); this.#obj?.off('dragend move', this.#onChange, this);
this.removeEventListener('icon-changed', this.#onIconChanged); this.removeEventListener('icon-changed', this.#onIconChanged);
unregisterChildren(this); unregisterChildren(this);
@ -55,7 +57,7 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
const el = this.#obj.getElement(); const el = this.#obj.getElement();
if (el) (el as HTMLImageElement).alt = val ?? ''; if (el) (el as HTMLImageElement).alt = val ?? '';
} else { } else {
setLayerAttr(this.#obj!, PROPS, PROP_BY_ATTR, name, val); setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
} }
} }
@ -68,11 +70,11 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
this.#syncing = false; this.#syncing = false;
} }
#onIconChanged(e: LeafletIconChangedEvent) { #onIconChanged = (e: LeafletIconChangedEvent) => {
if (!this.#obj) return; if (!this.#obj) return;
if (e.detail.icon) this.#obj.setIcon(e.detail.icon); if (e.detail.icon) this.#obj.setIcon(e.detail.icon);
else this.#obj.setIcon(new Icon.Default()); else this.#obj.setIcon(new Icon.Default());
} };
} }
customElements.define('leaflet-marker', LeafletMarker); customElements.define('leaflet-marker', LeafletMarker);

@ -22,7 +22,9 @@ export class LeafletPolygon extends WithProps(HTMLElement, PROPS) {
registerChildren(this, this.#obj); registerChildren(this, this.#obj);
this.addEventListener('line-updated', this.#syncCoords); this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => this.#syncCoords()); this.#observer = new MutationObserver(() => {
this.#syncCoords();
});
this.#observer.observe(this, { childList: true }); this.#observer.observe(this, { childList: true });
} }
@ -51,7 +53,7 @@ export class LeafletPolygon extends WithProps(HTMLElement, PROPS) {
}; };
#getCoords(): [number, number][] { #getCoords(): [number, number][] {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[]; const lines: LeafletLine[] = Array.from(this.querySelectorAll('leaflet-line'));
return lines.map((line) => line.latlng); return lines.map((line) => line.latlng);
} }
} }

@ -22,7 +22,9 @@ export class LeafletPolyline extends WithProps(HTMLElement, PROPS) {
registerChildren(this, this.#obj); registerChildren(this, this.#obj);
this.addEventListener('line-updated', this.#syncCoords); this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => this.#syncCoords()); this.#observer = new MutationObserver(() => {
this.#syncCoords();
});
this.#observer.observe(this, { childList: true }); this.#observer.observe(this, { childList: true });
} }
@ -51,7 +53,7 @@ export class LeafletPolyline extends WithProps(HTMLElement, PROPS) {
}; };
#getCoords(): [number, number][] { #getCoords(): [number, number][] {
const lines = Array.from(this.querySelectorAll('leaflet-line')) as LeafletLine[]; const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
return lines.map((line) => line.latlng); return lines.map((line) => line.latlng);
} }
} }

@ -26,6 +26,7 @@ export class LeafletPopup extends WithProps(HTMLElement, PROPS) {
if (this.hasAttribute('lat') && this.hasAttribute('lng')) { if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]); this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} }
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onChange, this); this.#obj.on('move', this.#onChange, this);
registerWithParent(this, this.#obj); registerWithParent(this, this.#obj);
@ -40,6 +41,7 @@ export class LeafletPopup extends WithProps(HTMLElement, PROPS) {
} }
disconnectedCallback() { disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onChange, this); this.#obj?.off('move', this.#onChange, this);
this.#observer?.disconnect(); this.#observer?.disconnect();
this.#observer = undefined; this.#observer = undefined;

@ -22,6 +22,7 @@ export class LeafletSVGOverlay extends WithProps(HTMLElement, PROPS) {
const svg = this.querySelector('svg'); const svg = this.querySelector('svg');
const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined; const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined;
this.#obj = new SVGOverlay( this.#obj = new SVGOverlay(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
svg ?? dummy!, svg ?? dummy!,
parseBoundsAttr(this), parseBoundsAttr(this),
buildOptions(this, PROPS, ['bounds']) as ImageOverlayOptions, buildOptions(this, PROPS, ['bounds']) as ImageOverlayOptions,
@ -44,7 +45,7 @@ export class LeafletSVGOverlay extends WithProps(HTMLElement, PROPS) {
if (name === 'bounds') { if (name === 'bounds') {
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[])); this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
} else { } else {
setLayerAttr(this.#obj!, PROPS, PROP_BY_ATTR, name, val); setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
} }
} }
} }

@ -19,7 +19,7 @@ export class LeafletTileLayerWMS extends WithProps(HTMLElement, PROPS) {
#obj?: TileLayer.WMS; #obj?: TileLayer.WMS;
connectedCallback() { connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer.WMS(url, buildOptions(this, PROPS, ['url'])); this.#obj = new TileLayer.WMS(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj); registerChildren(this, this.#obj);
} }
@ -41,8 +41,8 @@ export class LeafletTileLayerWMS extends WithProps(HTMLElement, PROPS) {
} else { } else {
const propName = PROP_BY_ATTR.get(name); const propName = PROP_BY_ATTR.get(name);
if (!propName) return; if (!propName) return;
const spec = PROPS[propName as keyof typeof PROPS]; const spec = PROPS[propName];
if (!setLayerAttr(this.#obj!, PROPS, PROP_BY_ATTR, name, val)) { if (!setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val)) {
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val); const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
(this.#obj.setParams as unknown as (params: Record<string, unknown>) => void)({ (this.#obj.setParams as unknown as (params: Record<string, unknown>) => void)({
[propName]: value, [propName]: value,

@ -18,7 +18,7 @@ export class LeafletTileLayer extends WithProps(HTMLElement, PROPS) {
#obj?: TileLayer; #obj?: TileLayer;
connectedCallback() { connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer(url, buildOptions(this, PROPS, ['url'])); this.#obj = new TileLayer(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj); registerChildren(this, this.#obj);
} }
@ -38,7 +38,7 @@ export class LeafletTileLayer extends WithProps(HTMLElement, PROPS) {
if (name === 'url') { if (name === 'url') {
if (val) this.#obj.setUrl(val); if (val) this.#obj.setUrl(val);
} else { } else {
setLayerAttr(this.#obj!, PROPS, PROP_BY_ATTR, name, val); setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
} }
} }
} }

@ -27,6 +27,7 @@ export class LeafletTooltip extends WithProps(HTMLElement, PROPS) {
if (this.hasAttribute('lat') && this.hasAttribute('lng')) { if (this.hasAttribute('lat') && this.hasAttribute('lng')) {
this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]); this.#obj.setLatLng([numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, 'lng')]);
} }
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('move', this.#onChange, this); this.#obj.on('move', this.#onChange, this);
registerWithParent(this, this.#obj); registerWithParent(this, this.#obj);
@ -41,6 +42,7 @@ export class LeafletTooltip extends WithProps(HTMLElement, PROPS) {
} }
disconnectedCallback() { disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onChange, this); this.#obj?.off('move', this.#onChange, this);
this.#observer?.disconnect(); this.#observer?.disconnect();
this.#observer = undefined; this.#observer = undefined;

@ -23,7 +23,7 @@ export class LeafletVideoOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: VideoOverlay; #obj?: VideoOverlay;
connectedCallback() { connectedCallback() {
const url = this.getAttribute('url') || ''; const url = this.getAttribute('url') ?? '';
this.#obj = new VideoOverlay( this.#obj = new VideoOverlay(
url, url,
parseBoundsAttr(this), parseBoundsAttr(this),
@ -59,10 +59,10 @@ export class LeafletVideoOverlay extends WithProps(HTMLElement, PROPS) {
if (name === 'loop') el.loop = val !== null; if (name === 'loop') el.loop = val !== null;
else if (name === 'autoplay') el.autoplay = val !== null; else if (name === 'autoplay') el.autoplay = val !== null;
else if (name === 'muted') el.muted = val !== null; else if (name === 'muted') el.muted = val !== null;
else if (name === 'playsinline') el.playsInline = val !== null; else el.playsInline = val !== null;
} }
} else { } else {
setLayerAttr(this.#obj!, PROPS, PROP_BY_ATTR, name, val); setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val);
} }
} }

@ -1,31 +1,31 @@
export type NumProp<T = unknown> = { export interface NumProp<T = unknown> {
kind: 'num'; kind: 'num';
attr: string; attr: string;
default: number; default: number;
mapGet?: (m: T) => number | undefined; mapGet?(m: T): number | undefined;
mapSet?: (m: T, v: number) => void; mapSet?(m: T, v: number): void;
viewState?: boolean; viewState?: boolean;
event?: string; event?: string;
}; }
export type StrProp = { export interface StrProp {
kind: 'str'; kind: 'str';
attr: string; attr: string;
default: string; default: string;
}; }
export type BoolOnProp = { export interface BoolOnProp {
kind: 'bool-on'; kind: 'bool-on';
attr: string; attr: string;
}; }
export type BoolOffProp<T = unknown> = { export interface BoolOffProp<T = unknown> {
kind: 'bool-off'; kind: 'bool-off';
attr: string; attr: string;
mapSet?: (m: T, enabled: boolean) => void; mapSet?(m: T, enabled: boolean): void;
}; }
export type PropDef = NumProp<unknown> | StrProp | BoolOnProp | BoolOffProp<unknown>; export type PropDef = NumProp | StrProp | BoolOnProp | BoolOffProp;
export type PropTypeOf<T extends PropDef> = T extends NumProp export type PropTypeOf<T extends PropDef> = T extends NumProp
? number ? number
@ -44,34 +44,46 @@ type StrPropInput = OptionalAttr<StrProp>;
type BoolOnPropInput = OptionalAttr<BoolOnProp>; type BoolOnPropInput = OptionalAttr<BoolOnProp>;
type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>; type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>;
type PropDefInput = type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput | BoolOffPropInput;
| NumPropInput<unknown>
| StrPropInput type ValueFor<T, K extends keyof T> = T[K];
| BoolOnPropInput
| BoolOffPropInput<unknown>;
type PropDefFromInput<T> = type PropDefFromInput<T extends PropDefInput, Key extends string> = (T extends NumPropInput<infer U>
T extends NumPropInput<infer U>
? NumProp<U> ? NumProp<U>
: T extends { kind: 'str' } : T extends { kind: 'str' }
? StrProp ? StrProp
: T extends BoolOffPropInput<infer U> : T extends BoolOffPropInput<infer U>
? BoolOffProp<U> ? BoolOffProp<U>
: BoolOnProp; : BoolOnProp) & { attr: AttributeValue<T, Key> };
type AttributeValue<T extends PropDefInput, Key extends string> = T extends { attr: string }
? ValueFor<T, 'attr'>
: T extends BoolOffPropInput
? AsDisableKebab<Key>
: AsKebab<Key>;
type AsKebab<T extends string> = T extends `${infer L}${infer R}`
? `${L extends Uppercase<L> ? '-' : ''}${Lowercase<L>}${AsKebab<R>}`
: T;
type AsDisableKebab<T extends string> = `disable-${AsKebab<T>}`;
function disableCamelToKebab<S extends string>(s: S): AsDisableKebab<S> {
return ('disable-' + s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())) as AsDisableKebab<S>;
}
function camelToKebab(s: string): string { function camelToKebab(s: string): string {
return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
} }
export function num<T = unknown>( export function num<T = unknown>(
def: number = 0, def = 0,
opts?: string | (Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string }), opts?: Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string },
): NumPropInput<T> { ): NumPropInput<T> {
if (typeof opts === 'string') opts = { attr: opts }; return { kind: 'num', default: def, ...opts };
return { kind: 'num', default: def, ...opts } as NumPropInput<T>;
} }
export function str(def: string = '', attr?: string): StrPropInput { export function str(def = '', attr?: string): StrPropInput {
return { kind: 'str', default: def, ...(attr ? { attr } : {}) }; return { kind: 'str', default: def, ...(attr ? { attr } : {}) };
} }
@ -80,21 +92,21 @@ export function on(attr?: string): BoolOnPropInput {
} }
export function off<T = unknown>(mapSet?: (m: T, enabled: boolean) => void): BoolOffPropInput<T> { export function off<T = unknown>(mapSet?: (m: T, enabled: boolean) => void): BoolOffPropInput<T> {
return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) } as BoolOffPropInput<T>; return { kind: 'bool-off', ...(mapSet ? { mapSet } : {}) };
} }
export function defineProps<T extends Record<string, unknown>>( export function defineProps<T extends Record<string, PropDefInput>>(
input: T, input: T,
): { [K in keyof T]: PropDefFromInput<T[K]> } { ): { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> } {
const output = {} as Record<string, PropDef>; const output = {} as Record<string, PropDef>;
for (const key of Object.keys(input)) { for (const key of Object.keys(input)) {
const val = input[key] as PropDefInput; const val = input[key];
const { attr: override, ...rest } = val as unknown as Record<string, unknown>; const { attr: override, ...rest } = val as unknown as Record<string, unknown>;
const attr = const attr =
override ?? (rest.kind === 'bool-off' ? 'disable-' + camelToKebab(key) : camelToKebab(key)); override ?? (rest.kind === 'bool-off' ? disableCamelToKebab(key) : camelToKebab(key));
output[key] = { ...rest, attr } as PropDef; output[key] = { ...(rest as object), attr } as PropDef;
} }
return output as unknown as { [K in keyof T]: PropDefFromInput<T[K]> }; return output as unknown as { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> };
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -126,6 +138,7 @@ function definePropAccessors(proto: object, props: Record<string, PropDef>) {
set(v: unknown) { set(v: unknown) {
const el = this as HTMLElement; const el = this as HTMLElement;
if (spec.kind === 'bool-on') el.toggleAttribute(spec.attr, !!v); if (spec.kind === 'bool-on') el.toggleAttribute(spec.attr, !!v);
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
else el.setAttribute(spec.attr, `${v}`); else el.setAttribute(spec.attr, `${v}`);
}, },
configurable: true, configurable: true,

@ -1,4 +1,4 @@
import { Icon, Layer, LayerGroup, Popup, Tooltip } from 'leaflet'; import { DivIcon, Icon, Layer, LayerGroup, Popup, Tooltip } from 'leaflet';
import { registerWithParent } from './utils.ts'; import { registerWithParent } from './utils.ts';
// Custom event type for the bubbling registration protocol. Carries the // Custom event type for the bubbling registration protocol. Carries the
@ -22,7 +22,7 @@ declare global {
} }
} }
export function emitIconChanged(el: HTMLElement, icon: Icon | null | undefined) { export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | undefined) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('icon-changed', { new CustomEvent('icon-changed', {
bubbles: true, bubbles: true,

@ -24,7 +24,7 @@ export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression {
// For example, { fillColor: { attr: 'fill-color' } } becomes // For example, { fillColor: { attr: 'fill-color' } } becomes
// { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the // { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the
// Leaflet setter name when an attribute changes at runtime. // Leaflet setter name when an attribute changes at runtime.
export function buildAttrMap(props: Record<string, PropDef>): Map<string, string> { export function buildAttrMap<T extends Record<string, PropDef>>(props: T): Map<string, keyof T> {
return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name])); return new Map(Object.entries(props).map(([name, spec]) => [spec.attr, name]));
} }
@ -44,7 +44,7 @@ export function setLayerAttr(
): boolean { ): boolean {
const propName = attrMap.get(name); const propName = attrMap.get(name);
if (!propName) return false; if (!propName) return false;
const spec = props[propName as keyof typeof props]; const spec = props[propName];
const setter = `set${propName.charAt(0).toUpperCase()}${propName.slice(1)}`; const setter = `set${propName.charAt(0).toUpperCase()}${propName.slice(1)}`;
const fn = (obj as Record<string, unknown>)[setter]; const fn = (obj as Record<string, unknown>)[setter];
if (typeof fn === 'function') { if (typeof fn === 'function') {

Loading…
Cancel
Save