Repository: swisstopo/swissgeol-viewer-app
Pull request #1507: Fix translated text in dropdowns not being updated
Author: login: daniel-va
Created: 2025-04-01T11:46:17Z | Merged: 2025-04-01T12:32:25Z

Fixes an issue where translations in dropdowns was not being updated.
The problem was that the dropdowns were within a draggable element, which, after having been moved, caused the dropdown to loose its reactivity.

Also solves the issue where dropdowns were sometimes losing their content.
This had a similar reason, as Lit removed and re-added some elements containing dropdowns,
which destroyed the contents.


Diff:
diff --git a/ui/src/features/core/base/core-base-popup-box.element.ts b/ui/src/features/core/base/core-base-popup-box.element.ts
index e8e972cd8..a34a01647 100644
--- a/ui/src/features/core/base/core-base-popup-box.element.ts
+++ b/ui/src/features/core/base/core-base-popup-box.element.ts
@@ -2,6 +2,8 @@ import { css, html, LitElement } from 'lit';
 import { applyTransition } from 'src/styles/theme';
 
 export abstract class CoreBasePopupBox extends LitElement {
+  private timeoutForToggle: unknown = null;
+
   protected constructor() {
     super();
     this.show = this.show.bind(this);
@@ -9,20 +11,28 @@ export abstract class CoreBasePopupBox extends LitElement {
   }
 
   show(): void {
+    this.clearToggle();
     this.classList.remove('is-hidden');
-    setTimeout(() => {
+    this.timeoutForToggle = setTimeout(() => {
       this.classList.add('is-visible');
     });
   }
 
   hide(): void {
+    this.clearToggle();
     this.classList.remove('is-visible');
-    setTimeout(() => {
+    this.timeoutForToggle = setTimeout(() => {
       this.classList.add('is-hidden');
     }, 250);
   }
 
-  readonly render = () => html``;
+  private clearToggle(): void {
+    if (this.timeoutForToggle !== null) {
+      clearTimeout(this.timeoutForToggle as number);
+    }
+  }
+
+  readonly render = () => html`<slot></slot>`;
 
   static readonly styles = css`
     :host,
diff --git a/ui/src/features/core/base/core-base-popup.element.ts b/ui/src/features/core/base/core-base-popup.element.ts
index 71f77d20e..3fb0135bb 100644
--- a/ui/src/features/core/base/core-base-popup.element.ts
+++ b/ui/src/features/core/base/core-base-popup.element.ts
@@ -1,21 +1,28 @@
-import { css, html, LitElement } from 'lit';
+import { css, LitElement, render } from 'lit';
 import { property } from 'lit/decorators.js';
 import { CoreBasePopupBox } from 'src/features/core/base/core-base-popup-box.element';
 
+export interface PopupProps {
+  position?: PopupPosition;
+
+  align?: PopupAlign;
+}
+
 export abstract class CoreBasePopup<
   TBox extends CoreBasePopupBox,
 > extends LitElement {
-  @property()
+  @property({ type: String, reflect: true })
   accessor position: PopupPosition | null = null;
 
-  @property()
+  @property({ type: String, reflect: true })
   accessor align: PopupAlign | null = null;
 
-  private _target: Element | null = null;
+  @property({ type: Object })
+  accessor content: unknown = null;
 
-  protected box: TBox | null = null;
+  private _target: Element | null = null;
 
-  private isInitialized = false;
+  protected box!: TBox;
 
   private _isShown = false;
 
@@ -23,6 +30,9 @@ export abstract class CoreBasePopup<
     super();
     this.show = this.show.bind(this);
     this.hide = this.hide.bind(this);
+
+    this.box = document.createElement(`${this.tagName}-box`) as TBox;
+    this.box.hide();
   }
 
   get target(): Element | null {
@@ -74,38 +84,22 @@ export abstract class CoreBasePopup<
 
   connectedCallback(): void {
     super.connectedCallback();
-    if (!this.isInitialized) {
-      this.requestUpdate();
-    }
-  }
-
-  async updated(): Promise<void> {
-    if (!this.isInitialized) {
-      this.initialize();
-    }
+    document.body.appendChild(this.box);
+    this.box.addEventListener('hide', this.hide);
+    this.registerTarget();
   }
 
   disconnectedCallback(): void {
-    this.unsyncSlot();
-    this.hide();
-    this.box?.remove();
     this.unregisterTarget();
-    this.isInitialized = false;
+    this.box.removeEventListener('hide', this.hide);
+    this.hide();
     super.disconnectedCallback();
   }
 
-  private initialize(): void {
-    this.box ??= this.findBoxElement();
-    document.body.appendChild(this.box);
-    this.box.hide();
-    this.box.addEventListener('hide', this.hide);
-
-    this.syncSlot();
-
-    if (this._target == null) {
-      this.registerTarget();
-    }
-    this.isInitialized = true;
+  willUpdate(): void {
+    render(this.content, this.box);
+    this.box.style.setProperty('--count', `${this.box.childElementCount}`);
+    this.updatePosition({ allowViewportCheck: true });
   }
 
   abstract get enterEvents(): string[];
@@ -120,47 +114,6 @@ export abstract class CoreBasePopup<
 
   abstract get defaultAlign(): PopupAlign;
 
-  abstract findBoxElement(): TBox;
-
-  private syncSlot(): void {
-    if (this.box == null) {
-      throw new Error(
-        "can't sync slot as the box has not yet been initialized",
-      );
-    }
-    const slot = this.shadowRoot!.querySelector('slot')!;
-    const assignedNodes = slot.assignedNodes({ flatten: true });
-    const box = this.box.shadowRoot!;
-    while (box.lastChild != null) {
-      box.removeChild(box.lastChild);
-    }
-    this.box.style.setProperty(
-      '--count',
-      `${assignedNodes.filter((it) => it instanceof HTMLElement).length}`,
-    );
-    if (assignedNodes.length === 0) {
-      this.updatePosition();
-      return;
-    }
-    this.updatePosition({ allowViewportCheck: true });
-    assignedNodes.forEach((node) => {
-      if (node instanceof Text) {
-        box.appendChild(node.cloneNode(true));
-      } else {
-        box.appendChild(node);
-      }
-    });
-    this.updatePosition({ allowViewportCheck: true });
-  }
-
-  private unsyncSlot(): void {
-    if (this.box == null) {
-      return;
-    }
-    const slot = this.shadowRoot!.querySelector('slot')!;
-    slot.append(...this.box.shadowRoot!.childNodes);
-  }
-
   protected show(event?: Event): void {
     if (this._isShown || this.box == null) {
       return;
@@ -253,7 +206,7 @@ export abstract class CoreBasePopup<
   }
 
   private adjustPositionToViewport(position: PopupPosition): void {
-    const box = this.box!.getBoundingClientRect();
+    const box = this.box.getBoundingClientRect();
     switch (position) {
       case 'top':
         if (box.y < 0) {
@@ -278,13 +231,7 @@ export abstract class CoreBasePopup<
     }
   }
 
-  abstract renderBox(): unknown;
-  readonly render = () => {
-    return html`
-      <slot></slot>
-      ${this.renderBox()}
-    `;
-  };
+  readonly render = () => '';
 
   static readonly styles = css`
     :host {
diff --git a/ui/src/features/core/core-dropdown.element.ts b/ui/src/features/core/core-dropdown.element.ts
index e872bb664..dabed996a 100644
--- a/ui/src/features/core/core-dropdown.element.ts
+++ b/ui/src/features/core/core-dropdown.element.ts
@@ -1,6 +1,9 @@
 import { css, html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { CoreBasePopup } from 'src/features/core/base/core-base-popup.element';
+import {
+  CoreBasePopup,
+  PopupProps,
+} from 'src/features/core/base/core-base-popup.element';
 import { CoreDropdownBox } from 'src/features/core/core-dropdown-box.element';
 import { CoreButton } from 'src/features/core/core-button.element';
 import { Subject, Subscription } from 'rxjs';
@@ -57,6 +60,7 @@ export class CoreDropdown extends CoreBasePopup<CoreDropdownBox> {
   disconnectedCallback(): void {
     super.disconnectedCallback();
     this.subscription?.unsubscribe();
+    this.subscription = null;
   }
 
   override show(event?: Event): void {
@@ -77,17 +81,23 @@ export class CoreDropdown extends CoreBasePopup<CoreDropdownBox> {
     }
   }
 
-  readonly findBoxElement = () => {
-    return this.shadowRoot!.querySelector(
-      'ngm-core-dropdown-box',
-    )! as CoreDropdownBox;
-  };
-
-  readonly renderBox = () => html`
-    <ngm-core-dropdown-box></ngm-core-dropdown-box>
-  `;
-
   static readonly styles = css`
     ${CoreBasePopup.styles}
   `;
 }
+
+export function dropdown(content: unknown): unknown;
+export function dropdown(props: PopupProps, content: unknown): unknown;
+export function dropdown(
+  propsOrContent: unknown,
+  contentOrNone?: unknown,
+): unknown {
+  const content = contentOrNone ?? propsOrContent;
+  const props =
+    contentOrNone === undefined ? {} : (propsOrContent as PopupProps);
+  return html` <ngm-core-dropdown
+    .position="${props.position}"
+    .align="${props.align}"
+    .content="${content}"
+  ></ngm-core-dropdown>`;
+}
diff --git a/ui/src/features/core/core-element.element.ts b/ui/src/features/core/core-element.element.ts
index 2e46635fd..b7189dda9 100644
--- a/ui/src/features/core/core-element.element.ts
+++ b/ui/src/features/core/core-element.element.ts
@@ -8,10 +8,11 @@ export class CoreElement extends LitElement {
   @state()
   private accessor language!: string;
 
-  private readonly _subscription = new Subscription();
+  private _subscription = new Subscription();
 
   connectedCallback() {
     const handleLanguageChanged = (language) => {
+      this.willChangeLanguage(language);
       this.language = language;
     };
     i18next.on('languageChanged', handleLanguageChanged);
@@ -25,6 +26,7 @@ export class CoreElement extends LitElement {
   disconnectedCallback() {
     super.disconnectedCallback();
     this._subscription.unsubscribe();
+    this._subscription = new Subscription();
   }
 
   willUpdate(_changedProperties: PropertyValues): void {
@@ -37,6 +39,10 @@ export class CoreElement extends LitElement {
     /* Empty method to be implemented by child classes. */
   }
 
+  willChangeLanguage(_language: void) {
+    /* Empty method to be implemented by child classes. */
+  }
+
   protected register(teardown: TeardownLogic): void {
     this._subscription.add(teardown);
   }
diff --git a/ui/src/features/core/core-tooltip.element.ts b/ui/src/features/core/core-tooltip.element.ts
index 4fea04a48..cc33d7eb8 100644
--- a/ui/src/features/core/core-tooltip.element.ts
+++ b/ui/src/features/core/core-tooltip.element.ts
@@ -1,6 +1,9 @@
 import { html } from 'lit';
 import { customElement } from 'lit/decorators.js';
-import { CoreBasePopup } from 'src/features/core/base/core-base-popup.element';
+import {
+  CoreBasePopup,
+  PopupProps,
+} from 'src/features/core/base/core-base-popup.element';
 import { CoreTooltipBox } from 'src/features/core/core-tooltip-box.element';
 
 @customElement('ngm-core-tooltip')
@@ -23,11 +26,20 @@ export class CoreTooltip extends CoreBasePopup<CoreTooltipBox> {
       'pointerup',
     ];
   }
+}
 
-  readonly findBoxElement = () =>
-    this.shadowRoot!.querySelector('ngm-core-tooltip-box')! as CoreTooltipBox;
-
-  readonly renderBox = () => html`
-    <ngm-core-tooltip-box></ngm-core-tooltip-box>
-  `;
+export function tooltip(content: unknown): unknown;
+export function tooltip(props: PopupProps, content: unknown): unknown;
+export function tooltip(
+  propsOrContent: unknown,
+  contentOrNone?: unknown,
+): unknown {
+  const content = contentOrNone ?? propsOrContent;
+  const props =
+    contentOrNone === undefined ? {} : (propsOrContent as PopupProps);
+  return html`<ngm-core-tooltip
+    .position="${props.position}"
+    .align="${props.align}"
+    .content="${content}"
+  ></ngm-core-tooltip>`;
 }
diff --git a/ui/src/features/core/index.ts b/ui/src/features/core/index.ts
index 362d7e6d8..36088d6c0 100644
--- a/ui/src/features/core/index.ts
+++ b/ui/src/features/core/index.ts
@@ -1,2 +1,5 @@
 export * from './core-element.element';
 export * from './core-modal.element';
+
+export { dropdown } from './core-dropdown.element';
+export { tooltip } from './core-tooltip.element';
diff --git a/ui/src/features/layer/display/layer-display-list-item.element.ts b/ui/src/features/layer/display/layer-display-list-item.element.ts
index 9ba913493..6db77eea6 100644
--- a/ui/src/features/layer/display/layer-display-list-item.element.ts
+++ b/ui/src/features/layer/display/layer-display-list-item.element.ts
@@ -3,7 +3,7 @@ import { css, html } from 'lit';
 import { applyTransition, applyTypography } from 'src/styles/theme';
 import { SliderChangeEvent } from 'src/features/core/core-slider.element';
 import { classMap } from 'lit/directives/class-map.js';
-import { CoreElement } from 'src/features/core';
+import { CoreElement, dropdown, tooltip } from 'src/features/core';
 import { LayerConfig, LayerType } from 'src/layertree';
 import MainStore from 'src/store/main';
 import { Entity, Viewer } from 'cesium';
@@ -203,7 +203,7 @@ export class LayerDisplayListItem extends CoreElement {
         >
           ${Math.round(this.opacity * 100)}%
         </ngm-core-button>
-        <ngm-core-tooltip>${i18next.t('dtd_opacity')}</ngm-core-tooltip>
+        ${tooltip(i18next.t('dtd_opacity'))}
         ${this.layer == null ? '' : this.renderActions()}
       </div>
     </div>
@@ -229,7 +229,7 @@ export class LayerDisplayListItem extends CoreElement {
     >
       <ngm-core-icon icon="menu"></ngm-core-icon>
     </ngm-core-button>
-    <ngm-core-dropdown>
+    ${dropdown(html`
       <ngm-core-dropdown-item role="button" @click="${this.zoomToLayer}">
         <ngm-core-icon icon="zoomPlus"></ngm-core-icon>
         ${i18next.t('dtd_zoom_to')}
@@ -292,7 +292,7 @@ export class LayerDisplayListItem extends CoreElement {
         <ngm-core-icon icon="trash"></ngm-core-icon>
         ${i18next.t('dtd_remove')}
       </ngm-core-dropdown-item>
-    </ngm-core-dropdown>
+    `)}
   `;
 
   private readonly renderOpacity = () => html`
diff --git a/ui/src/features/layer/display/layer-display-list.element.ts b/ui/src/features/layer/display/layer-display-list.element.ts
index 2edcfa83c..b8fd3283a 100644
--- a/ui/src/features/layer/display/layer-display-list.element.ts
+++ b/ui/src/features/layer/display/layer-display-list.element.ts
@@ -69,7 +69,12 @@ export class LayerDisplayList extends CoreElement {
     }
   }
 
+  willChangeLanguage(): void {
+    this.initializeDragging();
+  }
+
   disconnectedCallback(): void {
+    super.disconnectedCallback();
     this.sortable?.destroy();
   }
 


Reviews:
user: login: Tugark
body: Some minor remarks; mostly because I'm not familiar with Lit or other frameworks used.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:13:01Z
user: login: daniel-va
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:17:45Z
user: login: daniel-va
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:18:38Z
user: login: daniel-va
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:18:44Z
user: login: daniel-va
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:19:21Z
user: login: daniel-va
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:19:55Z
user: login: Tugark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:26:05Z
user: login: Tugark
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:26:58Z

Review comments:
diff_hunk: @@ -2,27 +2,37 @@ import { css, html, LitElement } from 'lit';
 import { applyTransition } from 'src/styles/theme';
 
 export abstract class CoreBasePopupBox extends LitElement {
+  private timeoutForToggle: unknown = null;
+
   protected constructor() {
     super();
     this.show = this.show.bind(this);
     this.hide = this.hide.bind(this);
   }
 
   show(): void {
+    this.clearToggle();
     this.classList.remove('is-hidden');
-    setTimeout(() => {
+    this.timeoutForToggle = setTimeout(() => {
       this.classList.add('is-visible');
     });
   }
 
   hide(): void {
+    this.clearToggle();
     this.classList.remove('is-visible');
-    setTimeout(() => {
+    this.timeoutForToggle = setTimeout(() => {
       this.classList.add('is-hidden');
     }, 250);
   }
 
-  readonly render = () => html``;
+  private clearToggle(): void {
+    if (this.timeoutForToggle !== null) {
+      clearTimeout(this.timeoutForToggle as number);
path: ui/src/features/core/base/core-base-popup-box.element.ts
user: login: Tugark
body: I would specify `timeoutForToggle` as `number|null` and then get rid of the explicit cast here. Or is there a reason for doing it this way? As per the docs, `setTimeout` returns a positive integer id.
created_at: 2025-04-01T12:03:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -74,38 +84,23 @@ export abstract class CoreBasePopup<
 
   connectedCallback(): void {
     super.connectedCallback();
-    if (!this.isInitialized) {
-      this.requestUpdate();
-    }
-  }
-
-  async updated(): Promise<void> {
-    if (!this.isInitialized) {
-      this.initialize();
-    }
+    document.body.appendChild(this.box);
+    this.box.addEventListener('hide', this.hide);
+    this.registerTarget();
   }
 
   disconnectedCallback(): void {
-    this.unsyncSlot();
-    this.hide();
-    this.box?.remove();
     this.unregisterTarget();
-    this.isInitialized = false;
+    this.box.removeEventListener('hide', this.hide);
+    this.hide();
     super.disconnectedCallback();
   }
 
-  private initialize(): void {
-    this.box ??= this.findBoxElement();
-    document.body.appendChild(this.box);
-    this.box.hide();
-    this.box.addEventListener('hide', this.hide);
-
-    this.syncSlot();
-
-    if (this._target == null) {
-      this.registerTarget();
-    }
-    this.isInitialized = true;
+  willUpdate(): void {
+    console.log('renderino!');
path: ui/src/features/core/base/core-base-popup.element.ts
user: login: Tugark
body: Should probably be removed
created_at: 2025-04-01T12:04:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,17 +81,23 @@ export class CoreDropdown extends CoreBasePopup<CoreDropdownBox> {
     }
   }
 
-  readonly findBoxElement = () => {
-    return this.shadowRoot!.querySelector(
-      'ngm-core-dropdown-box',
-    )! as CoreDropdownBox;
-  };
-
-  readonly renderBox = () => html`
-    <ngm-core-dropdown-box></ngm-core-dropdown-box>
-  `;
-
   static readonly styles = css`
     ${CoreBasePopup.styles}
   `;
 }
+
+export function dropdown(content: unknown): unknown;
+export function dropdown(props: PopupProps, content: unknown): unknown;
path: ui/src/features/core/core-dropdown.element.ts
user: login: Tugark
body: I guess these items are somehow required by the UI framework, right? Otherwise, they should be removed?
created_at: 2025-04-01T12:06:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +26,20 @@ export class CoreTooltip extends CoreBasePopup<CoreTooltipBox> {
       'pointerup',
     ];
   }
+}
 
-  readonly findBoxElement = () =>
-    this.shadowRoot!.querySelector('ngm-core-tooltip-box')! as CoreTooltipBox;
-
-  readonly renderBox = () => html`
-    <ngm-core-tooltip-box></ngm-core-tooltip-box>
-  `;
+export function tooltip(content: unknown): unknown;
+export function tooltip(props: PopupProps, content: unknown): unknown;
path: ui/src/features/core/core-tooltip.element.ts
user: login: Tugark
body: Here as well.
created_at: 2025-04-01T12:06:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +26,20 @@ export class CoreTooltip extends CoreBasePopup<CoreTooltipBox> {
       'pointerup',
     ];
   }
+}
 
-  readonly findBoxElement = () =>
-    this.shadowRoot!.querySelector('ngm-core-tooltip-box')! as CoreTooltipBox;
-
-  readonly renderBox = () => html`
-    <ngm-core-tooltip-box></ngm-core-tooltip-box>
-  `;
+export function tooltip(content: unknown): unknown;
+export function tooltip(props: PopupProps, content: unknown): unknown;
+export function tooltip(
+  propsOrContent: unknown,
+  contentOrNone?: unknown,
+): unknown {
+  const content = contentOrNone ?? propsOrContent;
+  const props =
+    contentOrNone === undefined ? {} : (propsOrContent as PopupProps);
path: ui/src/features/core/core-tooltip.element.ts
user: login: Tugark
body: That one is quite hard to read; but I assume this is another framework specific element, right?
(I mean there's some implicit stuff going on; i.e. that if `contenOrNone` is defined, `propsOrContent` will always be `props` and not `content`).
created_at: 2025-04-01T12:12:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,27 +2,37 @@ import { css, html, LitElement } from 'lit';
 import { applyTransition } from 'src/styles/theme';
 
 export abstract class CoreBasePopupBox extends LitElement {
+  private timeoutForToggle: unknown = null;
+
   protected constructor() {
     super();
     this.show = this.show.bind(this);
     this.hide = this.hide.bind(this);
   }
 
   show(): void {
+    this.clearToggle();
     this.classList.remove('is-hidden');
-    setTimeout(() => {
+    this.timeoutForToggle = setTimeout(() => {
       this.classList.add('is-visible');
     });
   }
 
   hide(): void {
+    this.clearToggle();
     this.classList.remove('is-visible');
-    setTimeout(() => {
+    this.timeoutForToggle = setTimeout(() => {
       this.classList.add('is-hidden');
     }, 250);
   }
 
-  readonly render = () => html``;
+  private clearToggle(): void {
+    if (this.timeoutForToggle !== null) {
+      clearTimeout(this.timeoutForToggle as number);
path: ui/src/features/core/base/core-base-popup-box.element.ts
user: login: daniel-va
body: The typescript config within this project is somehow setup in a way that includes the NodeJS type definitions for `setTimeout`, which doesn't return `number`, but `NodeJS.Timeout`. However, `NodeJS.Timeout` is not available as a type, which is why we have to use this rather awkward typing.

I have this on a long list of things I would like to fix in this project, but for now, it will probably have to stay.
created_at: 2025-04-01T12:17:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,17 +81,23 @@ export class CoreDropdown extends CoreBasePopup<CoreDropdownBox> {
     }
   }
 
-  readonly findBoxElement = () => {
-    return this.shadowRoot!.querySelector(
-      'ngm-core-dropdown-box',
-    )! as CoreDropdownBox;
-  };
-
-  readonly renderBox = () => html`
-    <ngm-core-dropdown-box></ngm-core-dropdown-box>
-  `;
-
   static readonly styles = css`
     ${CoreBasePopup.styles}
   `;
 }
+
+export function dropdown(content: unknown): unknown;
+export function dropdown(props: PopupProps, content: unknown): unknown;
path: ui/src/features/core/core-dropdown.element.ts
user: login: daniel-va
body: These are just overloads to make the use of `dropdown` safer. Without them, the first argument would never have type checking while a second one is specified.
created_at: 2025-04-01T12:18:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +26,20 @@ export class CoreTooltip extends CoreBasePopup<CoreTooltipBox> {
       'pointerup',
     ];
   }
+}
 
-  readonly findBoxElement = () =>
-    this.shadowRoot!.querySelector('ngm-core-tooltip-box')! as CoreTooltipBox;
-
-  readonly renderBox = () => html`
-    <ngm-core-tooltip-box></ngm-core-tooltip-box>
-  `;
+export function tooltip(content: unknown): unknown;
+export function tooltip(props: PopupProps, content: unknown): unknown;
path: ui/src/features/core/core-tooltip.element.ts
user: login: daniel-va
body: See above.
created_at: 2025-04-01T12:18:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +26,20 @@ export class CoreTooltip extends CoreBasePopup<CoreTooltipBox> {
       'pointerup',
     ];
   }
+}
 
-  readonly findBoxElement = () =>
-    this.shadowRoot!.querySelector('ngm-core-tooltip-box')! as CoreTooltipBox;
-
-  readonly renderBox = () => html`
-    <ngm-core-tooltip-box></ngm-core-tooltip-box>
-  `;
+export function tooltip(content: unknown): unknown;
+export function tooltip(props: PopupProps, content: unknown): unknown;
+export function tooltip(
+  propsOrContent: unknown,
+  contentOrNone?: unknown,
+): unknown {
+  const content = contentOrNone ?? propsOrContent;
+  const props =
+    contentOrNone === undefined ? {} : (propsOrContent as PopupProps);
path: ui/src/features/core/core-tooltip.element.ts
user: login: daniel-va
body: That's just TypeScript overload stuff - don't know how familiar you are with these, but this is rather tame 😋 
created_at: 2025-04-01T12:19:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +26,20 @@ export class CoreTooltip extends CoreBasePopup<CoreTooltipBox> {
       'pointerup',
     ];
   }
+}
 
-  readonly findBoxElement = () =>
-    this.shadowRoot!.querySelector('ngm-core-tooltip-box')! as CoreTooltipBox;
-
-  readonly renderBox = () => html`
-    <ngm-core-tooltip-box></ngm-core-tooltip-box>
-  `;
+export function tooltip(content: unknown): unknown;
+export function tooltip(props: PopupProps, content: unknown): unknown;
+export function tooltip(
+  propsOrContent: unknown,
+  contentOrNone?: unknown,
+): unknown {
+  const content = contentOrNone ?? propsOrContent;
+  const props =
+    contentOrNone === undefined ? {} : (propsOrContent as PopupProps);
path: ui/src/features/core/core-tooltip.element.ts
user: login: daniel-va
body: (To add, prettier does to an excellent job at creating unreadable code here)
created_at: 2025-04-01T12:19:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -77,17 +81,23 @@ export class CoreDropdown extends CoreBasePopup<CoreDropdownBox> {
     }
   }
 
-  readonly findBoxElement = () => {
-    return this.shadowRoot!.querySelector(
-      'ngm-core-dropdown-box',
-    )! as CoreDropdownBox;
-  };
-
-  readonly renderBox = () => html`
-    <ngm-core-dropdown-box></ngm-core-dropdown-box>
-  `;
-
   static readonly styles = css`
     ${CoreBasePopup.styles}
   `;
 }
+
+export function dropdown(content: unknown): unknown;
+export function dropdown(props: PopupProps, content: unknown): unknown;
path: ui/src/features/core/core-dropdown.element.ts
user: login: Tugark
body: Nice, thank you :)
created_at: 2025-04-01T12:26:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,11 +26,20 @@ export class CoreTooltip extends CoreBasePopup<CoreTooltipBox> {
       'pointerup',
     ];
   }
+}
 
-  readonly findBoxElement = () =>
-    this.shadowRoot!.querySelector('ngm-core-tooltip-box')! as CoreTooltipBox;
-
-  readonly renderBox = () => html`
-    <ngm-core-tooltip-box></ngm-core-tooltip-box>
-  `;
+export function tooltip(content: unknown): unknown;
+export function tooltip(props: PopupProps, content: unknown): unknown;
+export function tooltip(
+  propsOrContent: unknown,
+  contentOrNone?: unknown,
+): unknown {
+  const content = contentOrNone ?? propsOrContent;
+  const props =
+    contentOrNone === undefined ? {} : (propsOrContent as PopupProps);
path: ui/src/features/core/core-tooltip.element.ts
user: login: Tugark
body: Yeah, if you read it with the overloads you mentioned, it makes sense. Other than that, it was ugly (even though it turned out the same :D)
created_at: 2025-04-01T12:26:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-01T12:21:07Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=swisstopo_swissgeol-viewer-app&pullRequest=1507) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=swisstopo_swissgeol-viewer-app&pullRequest=1507&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=swisstopo_swissgeol-viewer-app&pullRequest=1507&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=swisstopo_swissgeol-viewer-app&pullRequest=1507&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=swisstopo_swissgeol-viewer-app&pullRequest=1507&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=swisstopo_swissgeol-viewer-app&pullRequest=1507&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=swisstopo_swissgeol-viewer-app&pullRequest=1507)


author_association: NONE