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 prettierPlugin from 'eslint-plugin-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: {
parser: tsParser,
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: { project: true },
},
plugins: {
'@typescript-eslint': tsPlugin,
prettier: prettierPlugin,
},
rules: {
...tsPlugin.configs.recommended.rules,
...prettierConfig.rules,
'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"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.3.0",
"@types/leaflet": "1.9.21",
@ -124,6 +125,27 @@
"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": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",

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

@ -24,11 +24,15 @@ export class LeafletCircleMarker extends WithProps(HTMLElement, PROPS) {
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, '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);
registerChildren(this, this.#obj);
}
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);
unregisterChildren(this);
this.#obj?.remove();

@ -24,11 +24,15 @@ export class LeafletCircle extends WithProps(HTMLElement, PROPS) {
[numAttr(this, PROPS, 'lat'), numAttr(this, PROPS, '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);
registerChildren(this, this.#obj);
}
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);
unregisterChildren(this);
this.#obj?.remove();

@ -65,11 +65,14 @@ export class LeafletControlLayers extends WithProps(HTMLElement, PROPS) {
const el = e.detail.element;
const layer = e.detail.leafletObject;
const name = el.getAttribute('name');
if (!name || !layer) return;
if (!name) return;
const base = el.getAttribute('type') === 'base';
const active = el.hasAttribute('active');
if (base) this.#obj!.addBaseLayer(layer, name);
else this.#obj!.addOverlay(layer, name);
if (base) {
this.#obj?.addBaseLayer(layer, name);
} else {
this.#obj?.addOverlay(layer, name);
}
if (active) {
this.dispatchEvent(
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 { emitIconChanged } from '../core/register.ts';
@ -12,10 +12,16 @@ const PROPS = defineProps({
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 {
#obj?: Icon;
#obj?: DivIcon;
#observer?: MutationObserver;
connectedCallback() {
@ -54,15 +60,17 @@ export class LeafletDivIcon extends HTMLElement {
}
#applyIcon() {
const opts: Record<string, unknown> = {};
const opts: Partial<DivIconOptions> = {};
for (const [key, spec] of Object.entries(PROPS)) {
const v = this.getAttribute(spec.attr);
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;
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) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
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);
this.#obj?.remove();

@ -2,6 +2,7 @@ import { GeoJSON, PathOptions, Layer } from 'leaflet';
import { WithProps, defineProps, num, str, on } from '../core/props.ts';
import { registerChildren, unregisterChildren, getChildren } from '../core/register.ts';
import { buildOptions } from '../core/utils.ts';
import type { GeoJsonObject } from 'geojson';
const PROPS = defineProps({
data: str(),
@ -28,7 +29,7 @@ export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
connectedCallback() {
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(
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) ?? []) {
if (type === 'popup') this.#obj?.unbindPopup();
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);
this.#obj?.remove();
@ -55,6 +56,7 @@ export class LeafletGeoJSON extends WithProps(HTMLElement, PROPS) {
if (!this.#obj) return;
if (name === 'data') {
this.#obj.clearLayers();
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (val) this.#obj.addData(JSON.parse(val));
} else {
const propName = PROP_BY_ATTR.get(name);

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

@ -12,14 +12,14 @@ const PROPS = defineProps({
lat: num(0, {
viewState: true,
event: 'moveend',
mapGet: (m: LMap) => m.getCenter()?.lat,
mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter()?.lng ?? 0], m.getZoom()),
mapGet: (m: LMap) => m.getCenter().lat,
mapSet: (m: LMap, v: number) => m.setView([v, m.getCenter().lng], m.getZoom()),
}),
lng: num(0, {
viewState: true,
event: 'moveend',
mapGet: (m: LMap) => m.getCenter()?.lng,
mapSet: (m: LMap, v: number) => m.setView([m.getCenter()?.lat ?? 0, v], m.getZoom()),
mapGet: (m: LMap) => m.getCenter().lng,
mapSet: (m: LMap, v: number) => m.setView([m.getCenter().lat, v], m.getZoom()),
}),
zoom: num(2, {
viewState: true,
@ -81,7 +81,7 @@ const PROPS = defineProps({
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]),
);
@ -98,7 +98,7 @@ export class LeafletMap extends TypedBase {
#container!: HTMLDivElement;
#cssLink?: HTMLLinkElement;
#syncing = false;
#mapEventHandlers = new globalThis.Map<string, () => void>();
#mapEventHandlers = new Map<string, () => void>();
#resizeObserver?: ResizeObserver;
static get observedAttributes(): string[] {
@ -113,7 +113,7 @@ export class LeafletMap extends TypedBase {
Object.defineProperty(LeafletMap.prototype, propName, {
get(this: LeafletMap) {
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) {
if (spec.event) {
@ -157,9 +157,11 @@ export class LeafletMap extends TypedBase {
this.#container = document.createElement('div');
this.#container.style.width = '100%';
this.#container.style.height = '100%';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(this.#container);
const style = document.createElement('style');
style.textContent = ':host { display: block; width: 100%; height: 400px; }';
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.shadowRoot!.appendChild(style);
}
this.#applyCss();
@ -180,7 +182,7 @@ export class LeafletMap extends TypedBase {
this.#resizeObserver.observe(this);
// 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[]) {
if (spec.kind === 'num' && spec.event && spec.mapGet) {
const g = groups.get(spec.event) ?? [];
@ -254,11 +256,11 @@ export class LeafletMap extends TypedBase {
}
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')) {
integrity = this.getAttribute('css-integrity')!;
integrity = this.getAttribute('css-integrity') ?? '';
} else if (!customUrl) {
integrity = DEFAULT_CSS_INTEGRITY;
}
@ -273,14 +275,14 @@ export class LeafletMap extends TypedBase {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.href = url ?? '';
if (integrity) link.setAttribute('integrity', integrity);
if (crossorigin !== undefined) link.setAttribute('crossorigin', crossorigin);
sr.appendChild(link);
this.#cssLink = link;
// 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) {
@ -303,12 +305,12 @@ export class LeafletMap extends TypedBase {
if (this.hasAttribute(spec.attr)) o[propName] = true;
}
}
return o as MapOptions;
return o;
}
#handleLeafletRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
if (this.#map && e.detail.leafletObject) {
if (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')],
buildOptions(this, PROPS, ['lat', 'lng']),
);
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj.on('dragend move', this.#onChange, this);
registerChildren(this, this.#obj);
this.addEventListener('icon-changed', this.#onIconChanged);
}
disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('dragend move', this.#onChange, this);
this.removeEventListener('icon-changed', this.#onIconChanged);
unregisterChildren(this);
@ -55,7 +57,7 @@ export class LeafletMarker extends WithProps(HTMLElement, PROPS) {
const el = this.#obj.getElement();
if (el) (el as HTMLImageElement).alt = val ?? '';
} 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;
}
#onIconChanged(e: LeafletIconChangedEvent) {
#onIconChanged = (e: LeafletIconChangedEvent) => {
if (!this.#obj) return;
if (e.detail.icon) this.#obj.setIcon(e.detail.icon);
else this.#obj.setIcon(new Icon.Default());
}
};
}
customElements.define('leaflet-marker', LeafletMarker);

@ -22,7 +22,9 @@ export class LeafletPolygon extends WithProps(HTMLElement, PROPS) {
registerChildren(this, this.#obj);
this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => this.#syncCoords());
this.#observer = new MutationObserver(() => {
this.#syncCoords();
});
this.#observer.observe(this, { childList: true });
}
@ -51,7 +53,7 @@ export class LeafletPolygon extends WithProps(HTMLElement, PROPS) {
};
#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);
}
}

@ -22,7 +22,9 @@ export class LeafletPolyline extends WithProps(HTMLElement, PROPS) {
registerChildren(this, this.#obj);
this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(() => this.#syncCoords());
this.#observer = new MutationObserver(() => {
this.#syncCoords();
});
this.#observer.observe(this, { childList: true });
}
@ -51,7 +53,7 @@ export class LeafletPolyline extends WithProps(HTMLElement, PROPS) {
};
#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);
}
}

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

@ -22,6 +22,7 @@ export class LeafletSVGOverlay extends WithProps(HTMLElement, PROPS) {
const svg = this.querySelector('svg');
const dummy = !svg ? document.createElementNS('http://www.w3.org/2000/svg', 'svg') : undefined;
this.#obj = new SVGOverlay(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
svg ?? dummy!,
parseBoundsAttr(this),
buildOptions(this, PROPS, ['bounds']) as ImageOverlayOptions,
@ -44,7 +45,7 @@ export class LeafletSVGOverlay extends WithProps(HTMLElement, PROPS) {
if (name === 'bounds') {
this.#obj.setBounds(new LatLngBounds(parseBoundsAttr(this) as LatLngExpression[]));
} 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;
connectedCallback() {
const url = this.getAttribute('url') || '';
const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer.WMS(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj);
}
@ -41,8 +41,8 @@ export class LeafletTileLayerWMS extends WithProps(HTMLElement, PROPS) {
} else {
const propName = PROP_BY_ATTR.get(name);
if (!propName) return;
const spec = PROPS[propName as keyof typeof PROPS];
if (!setLayerAttr(this.#obj!, PROPS, PROP_BY_ATTR, name, val)) {
const spec = PROPS[propName];
if (!setLayerAttr(this.#obj, PROPS, PROP_BY_ATTR, name, val)) {
const value = spec.kind === 'bool-on' ? val !== null : parseAttributeValue(val);
(this.#obj.setParams as unknown as (params: Record<string, unknown>) => void)({
[propName]: value,

@ -18,7 +18,7 @@ export class LeafletTileLayer extends WithProps(HTMLElement, PROPS) {
#obj?: TileLayer;
connectedCallback() {
const url = this.getAttribute('url') || '';
const url = this.getAttribute('url') ?? '';
this.#obj = new TileLayer(url, buildOptions(this, PROPS, ['url']));
registerChildren(this, this.#obj);
}
@ -38,7 +38,7 @@ export class LeafletTileLayer extends WithProps(HTMLElement, PROPS) {
if (name === 'url') {
if (val) this.#obj.setUrl(val);
} 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')) {
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);
registerWithParent(this, this.#obj);
@ -41,6 +42,7 @@ export class LeafletTooltip extends WithProps(HTMLElement, PROPS) {
}
disconnectedCallback() {
// eslint-disable-next-line @typescript-eslint/unbound-method
this.#obj?.off('move', this.#onChange, this);
this.#observer?.disconnect();
this.#observer = undefined;

@ -23,7 +23,7 @@ export class LeafletVideoOverlay extends WithProps(HTMLElement, PROPS) {
#obj?: VideoOverlay;
connectedCallback() {
const url = this.getAttribute('url') || '';
const url = this.getAttribute('url') ?? '';
this.#obj = new VideoOverlay(
url,
parseBoundsAttr(this),
@ -59,10 +59,10 @@ export class LeafletVideoOverlay extends WithProps(HTMLElement, PROPS) {
if (name === 'loop') el.loop = val !== null;
else if (name === 'autoplay') el.autoplay = val !== null;
else if (name === 'muted') el.muted = val !== null;
else if (name === 'playsinline') el.playsInline = val !== null;
else el.playsInline = val !== null;
}
} 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';
attr: string;
default: number;
mapGet?: (m: T) => number | undefined;
mapSet?: (m: T, v: number) => void;
mapGet?(m: T): number | undefined;
mapSet?(m: T, v: number): void;
viewState?: boolean;
event?: string;
};
}
export type StrProp = {
export interface StrProp {
kind: 'str';
attr: string;
default: string;
};
}
export type BoolOnProp = {
export interface BoolOnProp {
kind: 'bool-on';
attr: string;
};
}
export type BoolOffProp<T = unknown> = {
export interface BoolOffProp<T = unknown> {
kind: 'bool-off';
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
? number
@ -44,34 +44,46 @@ type StrPropInput = OptionalAttr<StrProp>;
type BoolOnPropInput = OptionalAttr<BoolOnProp>;
type BoolOffPropInput<T = unknown> = OptionalAttr<BoolOffProp<T>>;
type PropDefInput =
| NumPropInput<unknown>
| StrPropInput
| BoolOnPropInput
| BoolOffPropInput<unknown>;
type PropDefInput = NumPropInput | StrPropInput | BoolOnPropInput | BoolOffPropInput;
type ValueFor<T, K extends keyof T> = T[K];
type PropDefFromInput<T> =
T extends NumPropInput<infer U>
type PropDefFromInput<T extends PropDefInput, Key extends string> = (T extends NumPropInput<infer U>
? NumProp<U>
: T extends { kind: 'str' }
? StrProp
: T extends BoolOffPropInput<infer 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 {
return s.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
}
export function num<T = unknown>(
def: number = 0,
opts?: string | (Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string }),
def = 0,
opts?: Omit<NumProp<T>, 'kind' | 'default' | 'attr'> & { attr?: string },
): NumPropInput<T> {
if (typeof opts === 'string') opts = { attr: opts };
return { kind: 'num', default: def, ...opts } as NumPropInput<T>;
return { kind: 'num', default: def, ...opts };
}
export function str(def: string = '', attr?: string): StrPropInput {
export function str(def = '', attr?: string): StrPropInput {
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> {
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,
): { [K in keyof T]: PropDefFromInput<T[K]> } {
): { [K in keyof T]: PropDefFromInput<T[K], Extract<K, string>> } {
const output = {} as Record<string, PropDef>;
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.kind === 'bool-off' ? 'disable-' + camelToKebab(key) : camelToKebab(key));
output[key] = { ...rest, attr } as PropDef;
override ?? (rest.kind === 'bool-off' ? disableCamelToKebab(key) : camelToKebab(key));
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
@ -126,6 +138,7 @@ function definePropAccessors(proto: object, props: Record<string, PropDef>) {
set(v: unknown) {
const el = this as HTMLElement;
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}`);
},
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';
// 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(
new CustomEvent('icon-changed', {
bubbles: true,

@ -24,7 +24,7 @@ export function parseBoundsAttr(el: HTMLElement): LatLngBoundsExpression {
// For example, { fillColor: { attr: 'fill-color' } } becomes
// { 'fill-color' → 'fillColor' }. Used by setLayerAttr to find the
// 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]));
}
@ -44,7 +44,7 @@ export function setLayerAttr(
): boolean {
const propName = attrMap.get(name);
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 fn = (obj as Record<string, unknown>)[setter];
if (typeof fn === 'function') {

Loading…
Cancel
Save