Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/api-reference/core/widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ Additional inline CSS styles on the top HTML element of the widget. camelCase CS

Additional CSS classnames on the top HTML element.

#### `onAfterRenderHTML` (function, optional) {#onafterrenderhtml-prop}

Called after the widget has rendered HTML into its root element. Receives the root `HTMLElement`.

If a widget subclass implements the protected `onAfterRenderHTML()` method, that method runs before this callback.

#### `_container` (string | HTMLDivElement, optional) {#_container}

Experimental. The container that this widget is being attached to. Default to `viewId`.
Expand Down Expand Up @@ -91,6 +97,10 @@ Updates the widget. Called by the specific widget when state has changed. Calls

This function is implemented by the specific widget subclass to update the HTML for the widget

#### `onAfterRenderHTML` {#onafterrenderhtml}

Optional. Called after `onRenderHTML()` has updated the widget HTML, and before the `onAfterRenderHTML` prop callback.

#### `onAdd` {#onadd}

Required. Called when the widget is added to a Deck instance.
Expand Down
3 changes: 3 additions & 0 deletions docs/api-reference/widgets/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ This module contains the following widgets:
- [ScreenshotWidget](./screenshot-widget.md)
- [StatsWidget](./stats-widget.md)
- [ThemeWidget](./theme-widget.md)
- [Widget Tooltip](./widget-tooltip.md)

## Installation

Expand Down Expand Up @@ -133,6 +134,8 @@ new Deck({

Widgets with UI (e.g. a button or panel) can be positioned relative to the deck.gl view they are controlling, via the `viewId` and `placement` props. See [WidgetProps](../core/widget.md#widgetprops).

Custom widgets can opt into [themed text tooltips](./widget-tooltip.md) by calling `updateWidgetTooltip` from `@deck.gl/widgets` in `onAfterRenderHTML()`.

The `viewId` controls which HTML container will mount to, and the `placement` prop will position it relative to the container it is in, like so:

```ts
Expand Down
57 changes: 57 additions & 0 deletions docs/api-reference/widgets/widget-tooltip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Widget Tooltip

`updateWidgetTooltip` adds delegated, theme-aware text tooltips to a custom widget. It is opt-in and does not change the behavior of bundled widgets.
Comment thread
ibgreen-openai marked this conversation as resolved.
Outdated

```ts
import {Widget} from '@deck.gl/core';
import {updateWidgetTooltip} from '@deck.gl/widgets';
import {render} from 'preact';
import '@deck.gl/widgets/stylesheet.css';
```

## Usage

Pass `updateWidgetTooltip` as the `onAfterRenderHTML` prop. Descendants with a `data-deck-widget-tooltip` attribute become tooltip anchors.

```tsx
class ResetWidget extends Widget {
onRenderHTML(rootElement: HTMLElement) {
render(
<button
aria-label="Reset view"
data-deck-widget-tooltip="Reset view"
onClick={() => this.resetView()}
>
Reset
</button>,
rootElement
);
}
}

const widget = new ResetWidget({
onAfterRenderHTML: updateWidgetTooltip

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't natural usage in a custom widget be to define it as a class member rather than as a prop?

Sub classes an existing widget to opt-in seems more natural too.

onAfterRenderHTML as a prop seems like it could be a useful for other purposes, but the use case is unclear in these docs

@ibgreen-openai ibgreen-openai Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sub classes an existing widget to opt-in seems more natural too.

In my opinion subclassing is a bit technical for applications.

onAfterRenderHTML as a prop seems like it could be a useful for other purposes, but the use case is unclear in these docs

Yes... it seems to me that it could be an escape hatch to let simple customizations to be done without massively changing / rewriting a widget in a subclass.
Basically turning big hacks into smaller, more maintainable hacks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The docs now show the subclass member form only: protected override onAfterRenderHTML = updateWidgetTooltip;. The callback prop has been removed.

});
```

Subclassing remains available when a custom widget always needs the helper:

```ts
protected override onAfterRenderHTML = updateWidgetTooltip;
```

Tooltip labels are plain text. Set `aria-label` separately when the anchor needs an accessible name, such as an icon-only button. If an anchor also has a native `title`, the helper removes it to avoid competing browser tooltips. The helper shows tooltips on pointer hover and keyboard focus, and hides them on pointer leave, blur, Escape, or the next render.

## `updateWidgetTooltip`

Installs delegated tooltip event handling on a widget root and removes any visible tooltip from the previous render.

Parameters:

- `rootElement` (`HTMLElement`) - Widget root containing elements with `data-deck-widget-tooltip`.

The `.deck-widget-tooltip` style uses the standard widget theme variables. Applications may override `--tooltip-max-width` and `--tooltip-z-index` when needed.

## Source

[modules/widgets/src/lib/widget-tooltip.ts](https://github.com/visgl/deck.gl/tree/master/modules/widgets/src/lib/widget-tooltip.ts)
2 changes: 2 additions & 0 deletions docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Release date: TBD

A new experimental `ViewLayout` system with a helper function `buildViewsFromViewLayout()` allows advanced nested and relative view layouts to be specified using a declarative layout tree.

The new `updateWidgetTooltip` helper lets custom widgets opt into themed text tooltips.

## deck.gl v9.3

Release date: April 13, 2026
Expand Down
10 changes: 9 additions & 1 deletion modules/core/src/lib/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type WidgetProps = {
style?: Partial<CSSStyleDeclaration>;
/** Additional CSS class. */
className?: string;
/** Called after the widget has rendered HTML into its root element. */
onAfterRenderHTML?: (rootElement: HTMLElement) => void;
/**
* The container that this widget is being attached to. Default to `viewId`.
* If set to `'root'`, the widget is placed relative to the whole deck.gl canvas.
Expand All @@ -35,7 +37,8 @@ export abstract class Widget<
id: 'widget',
style: {},
_container: null,
className: ''
className: '',
onAfterRenderHTML: undefined!
};

/** Unique identifier of the widget. */
Expand Down Expand Up @@ -95,6 +98,8 @@ export abstract class Widget<
updateHTML(): void {
if (this.rootElement) {
this.onRenderHTML(this.rootElement);
this.onAfterRenderHTML(this.rootElement);
this.props.onAfterRenderHTML?.(this.rootElement);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The member interface makes sense. When do you use the prop interface for tooltips?

@ibgreen-openai ibgreen-openai Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When do you use the prop interface for tooltips?

This all started by me trying to avoid having to subclass widgets to customize them.
So I thought, we can let the application inject some HTML rendering code without subclassing using a prop.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the prop interface. Core now only adds the protected onAfterRenderHTML() lifecycle hook, and widget subclasses call the helper through that member.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I think this is shaping up to a nice design for extending widget functionality without a ton of code duplication or core changes.

It's targeted too - only widgets concerned with tooltips needed a code change

}
}

Expand Down Expand Up @@ -144,6 +149,9 @@ export abstract class Widget<
/** Called to render HTML into the root element */
abstract onRenderHTML(rootElement: HTMLElement): void;

/** Overridable by subclass - called after HTML is rendered into the root element. */
protected onAfterRenderHTML(rootElement: HTMLElement): void {}

/** Internal API called by Deck when the widget is first added to a Deck instance */
_onAdd(params: {deck: Deck<any>; viewId: string | null}): HTMLDivElement {
return this.onAdd(params) ?? this.onCreateRootElement();
Expand Down
1 change: 1 addition & 0 deletions modules/widgets/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type {ContentBounds, ScrollbarWidgetProps, ScrollbarDecoration} from './s

export {LightTheme, DarkTheme, LightGlassTheme, DarkGlassTheme} from './themes';
export type {DeckWidgetTheme} from './themes';
export {updateWidgetTooltip} from './lib/widget-tooltip';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation discovery for generic util functions like this and buildViewsFromViewLayout could be improved.

Could you a quick API reference page for updateWidgetTooltip with suggested usage?

Usage of data-deck-widget-tooltip isn't documented

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1cdcf1a. Added docs/api-reference/widgets/widget-tooltip.md, linked it from the widgets overview, and documented the opt-in usage pattern, data-deck-widget-tooltip, plain-text behavior, accessibility, and source.


// Experimental preact components
export {ButtonGroup as _ButtonGroup, type ButtonGroupProps} from './lib/components/button-group';
Expand Down
103 changes: 103 additions & 0 deletions modules/widgets/src/lib/widget-tooltip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

const TOOLTIP_ATTR = 'data-deck-widget-tooltip';
const OFFSET = 8;
const widgetTooltips = new WeakMap<HTMLElement, WidgetTooltip>();

/** Updates delegated tooltip handling after a widget renders its HTML. */
export function updateWidgetTooltip(rootElement: HTMLElement): void {
for (const target of rootElement.querySelectorAll(`[${TOOLTIP_ATTR}][title]`)) {
target.removeAttribute('title');
}

let tooltip = widgetTooltips.get(rootElement);
if (!tooltip) {
tooltip = new WidgetTooltip();
widgetTooltips.set(rootElement, tooltip);
}
tooltip.update(rootElement);
}

class WidgetTooltip {
private element: HTMLDivElement | null = null;
private listenerRoot: HTMLElement | null = null;

update(root: HTMLElement): void {
this.hide();
if (this.listenerRoot === root) return;

this.listenerRoot = root;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the listeners from the previous container

root.addEventListener('pointerover', this.onPointerOver);
root.addEventListener('pointerout', this.onPointerOut);
root.addEventListener('focusin', this.onFocusIn);
root.addEventListener('focusout', this.onFocusOut);
root.addEventListener('keydown', this.onKeyDown);
}

private hide(): void {
this.element?.remove();
this.element = null;
}

private onPointerOver = (event: PointerEvent): void => {
const target = this.getTarget(event.target);
if (target) this.show(target);
};

private onPointerOut = (event: PointerEvent): void => {
const target = this.getTarget(event.target);
if (target && !(event.relatedTarget instanceof Node && target.contains(event.relatedTarget))) {
this.hide();
}
};

private onFocusIn = (event: FocusEvent): void => {
const target = this.getTarget(event.target);
if (target?.matches(':focus-visible')) this.show(target);
};

private onFocusOut = (): void => this.hide();

private onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') this.hide();
};

private getTarget(target: EventTarget | null): HTMLElement | null {
const anchor = target instanceof Element ? target.closest(`[${TOOLTIP_ATTR}]`) : null;
return anchor instanceof HTMLElement ? anchor : null;
}

private show(anchor: HTMLElement): void {
const text = anchor.getAttribute(TOOLTIP_ATTR);
const root = this.listenerRoot;
if (!text || !root) return;

this.hide();
const tooltip = document.createElement('div');
tooltip.className = 'deck-widget-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.append(document.createTextNode(text));

root.append(tooltip);
this.element = tooltip;
this.position(anchor, tooltip, root);
}

private position(anchor: HTMLElement, tooltip: HTMLDivElement, root: HTMLElement): void {
const anchorRect = anchor.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const right = anchorRect.right + OFFSET;
const left =
right + tooltipRect.width <= window.innerWidth
? right
: anchorRect.left - tooltipRect.width - OFFSET;

tooltip.style.left = `${left - rootRect.left}px`;
tooltip.style.top = `${
anchorRect.top - rootRect.top + (anchorRect.height - tooltipRect.height) / 2
}px`;
}
}
20 changes: 20 additions & 0 deletions modules/widgets/src/stylesheet.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.deck-widget {
position: relative;
margin: var(--widget-margin, 12px);
box-sizing: border-box;
}
Expand Down Expand Up @@ -105,6 +106,25 @@
z-index: 100;
}

.deck-widget-tooltip {
position: absolute;
z-index: var(--tooltip-z-index, 1000);
pointer-events: none;
box-sizing: border-box;
max-width: var(--tooltip-max-width, 240px);
padding: 4px 8px;
border: var(--menu-border, unset);
border-radius: calc(var(--button-corner-radius, 8px) - 2px);
box-shadow: var(--menu-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25));
background: var(--menu-background, #fff);
backdrop-filter: var(--menu-backdrop-filter, unset);
color: var(--menu-text, rgb(24, 24, 26));
font-size: 12px;
line-height: 16px;
white-space: normal;
overflow-wrap: break-word;
}

/* Fullscreen styles */
.deck-widget.deck-widget-fullscreen button.deck-widget-fullscreen-enter .deck-widget-icon {
mask-image: var(
Expand Down
21 changes: 21 additions & 0 deletions test/modules/core/lib/widget-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ const mockDeckInstance = {
height: 400
};

test('Widget#onAfterRenderHTML prop runs after protected hook', () => {
const calls: string[] = [];
class AfterRenderWidget extends TestWidget {
override onRenderHTML(): void {
calls.push('render');
}

protected override onAfterRenderHTML(): void {
calls.push('hook');
}
}

const widget = new AfterRenderWidget({
onAfterRenderHTML: () => calls.push('prop')
});
widget.rootElement = document.createElement('div');
widget.updateHTML();

expect(calls).toEqual(['render', 'hook', 'prop']);
});

test('WidgetManager#setProps', () => {
const container = document.createElement('div');
const widgetManager = new WidgetManager({deck: mockDeckInstance, parentElement: container});
Expand Down
74 changes: 74 additions & 0 deletions test/modules/widgets/widget-tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {afterEach, test, expect} from 'vitest';
import {updateWidgetTooltip} from '@deck.gl/widgets';

let rootElement: HTMLDivElement | undefined;

afterEach(() => {
rootElement?.remove();
rootElement = undefined;
});

function createTooltipTarget(label: string) {
rootElement = document.createElement('div');
rootElement.className = 'deck-widget';

const button = document.createElement('button');
button.setAttribute('data-deck-widget-tooltip', label);
button.setAttribute('aria-label', label);
button.title = label;

const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
svg.append(path);
button.append(svg);
rootElement.append(button);
document.body.append(rootElement);
updateWidgetTooltip(rootElement);

return {button, path};
}

function dispatchPointerOver(element: Element): void {
element.dispatchEvent(new PointerEvent('pointerover', {bubbles: true}));
}

function dispatchPointerOut(element: Element): void {
element.dispatchEvent(new PointerEvent('pointerout', {bubbles: true}));
}

test('updateWidgetTooltip shows and hides themed text tooltips', () => {
const {button} = createTooltipTarget('Zoom In');
expect(button.title).toBe('');

dispatchPointerOver(button);
expect(rootElement?.querySelector('.deck-widget-tooltip')?.textContent).toBe('Zoom In');
dispatchPointerOut(button);
expect(rootElement?.querySelector('.deck-widget-tooltip')).toBe(null);

button.focus();
expect(rootElement?.querySelector('.deck-widget-tooltip')?.textContent).toBe('Zoom In');
button.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
expect(rootElement?.querySelector('.deck-widget-tooltip')).toBe(null);

button.blur();
button.focus();
expect(rootElement?.querySelector('.deck-widget-tooltip')?.textContent).toBe('Zoom In');
button.blur();
expect(rootElement?.querySelector('.deck-widget-tooltip')).toBe(null);
});

test('updateWidgetTooltip resolves SVG targets and updated labels', () => {
const {button, path} = createTooltipTarget('Reset north');

dispatchPointerOver(path);
expect(rootElement?.querySelector('.deck-widget-tooltip')?.textContent).toBe('Reset north');

button.setAttribute('data-deck-widget-tooltip', 'Reset view');
updateWidgetTooltip(rootElement!);
dispatchPointerOver(button);
expect(rootElement?.querySelector('.deck-widget-tooltip')?.textContent).toBe('Reset view');
});
Loading