Skip to content

Commit da2fe19

Browse files
committed
feat(widgets) Styled tooltips
1 parent 26f9a3c commit da2fe19

10 files changed

Lines changed: 561 additions & 10 deletions

File tree

modules/core/src/lib/widget-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export class WidgetManager {
212212

213213
/** Destroy an old widget */
214214
private _removeWidget(widget: Widget) {
215-
widget.onRemove?.();
215+
widget._onRemove();
216216

217217
if (widget.rootElement) {
218218
widget.rootElement.remove();
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
// deck.gl
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) vis.gl contributors
4+
5+
const TOOLTIP_ATTR = 'data-deck-widget-tooltip';
6+
const TOOLTIP_CONTENT_ATTR = 'data-deck-widget-tooltip-content';
7+
const TOOLTIP_GENERATED_ATTR = 'data-deck-widget-tooltip-generated';
8+
const TOOLTIP_GENERATED_CONTENT_ATTR = 'data-deck-widget-tooltip-generated-content';
9+
const OFFSET = 8;
10+
const PADDING = 4;
11+
12+
type WidgetTooltipOptions = {
13+
getRootElement: () => HTMLElement | null | undefined;
14+
getShortcut: () => string | HTMLElement | undefined;
15+
};
16+
17+
type Side = 'right' | 'left' | 'bottom' | 'top';
18+
19+
export class WidgetTooltip {
20+
private anchor: HTMLElement | null = null;
21+
private element: HTMLDivElement | null = null;
22+
private listenerRoot: HTMLElement | null = null;
23+
24+
constructor(private options: WidgetTooltipOptions) {}
25+
26+
prepare(): void {
27+
this.hide();
28+
const root = this.options.getRootElement();
29+
if (!root) {
30+
return;
31+
}
32+
for (const target of root.querySelectorAll<HTMLElement>(`[${TOOLTIP_GENERATED_ATTR}]`)) {
33+
const tooltip = target.getAttribute(TOOLTIP_ATTR);
34+
if (tooltip) {
35+
target.setAttribute('title', tooltip);
36+
}
37+
}
38+
}
39+
40+
update(): void {
41+
this.hide();
42+
const root = this.options.getRootElement();
43+
if (!root) {
44+
return;
45+
}
46+
this.addListeners(root);
47+
this.syncTargets(root);
48+
}
49+
50+
remove(): void {
51+
this.hide();
52+
this.removeListeners();
53+
}
54+
55+
hide(): void {
56+
this.element?.remove();
57+
this.element = null;
58+
this.anchor = null;
59+
}
60+
61+
private addListeners(root: HTMLElement): void {
62+
if (this.listenerRoot === root) {
63+
return;
64+
}
65+
this.removeListeners();
66+
this.listenerRoot = root;
67+
root.addEventListener('pointerover', this.onPointerOver);
68+
root.addEventListener('pointerout', this.onPointerOut);
69+
root.addEventListener('focusin', this.onFocusIn);
70+
root.addEventListener('focusout', this.onFocusOut);
71+
root.addEventListener('keydown', this.onKeyDown);
72+
}
73+
74+
private removeListeners(): void {
75+
const root = this.listenerRoot;
76+
if (!root) {
77+
return;
78+
}
79+
root.removeEventListener('pointerover', this.onPointerOver);
80+
root.removeEventListener('pointerout', this.onPointerOut);
81+
root.removeEventListener('focusin', this.onFocusIn);
82+
root.removeEventListener('focusout', this.onFocusOut);
83+
root.removeEventListener('keydown', this.onKeyDown);
84+
this.listenerRoot = null;
85+
}
86+
87+
private syncTargets(root: HTMLElement): void {
88+
for (const target of root.querySelectorAll<HTMLElement>(
89+
`[${TOOLTIP_GENERATED_ATTR}]:not([title])`
90+
)) {
91+
this.clearGeneratedTarget(target);
92+
}
93+
94+
for (const target of root.querySelectorAll<HTMLElement>('[title]')) {
95+
const title = target.getAttribute('title');
96+
const wasGenerated = target.hasAttribute(TOOLTIP_GENERATED_ATTR);
97+
if (title) {
98+
target.setAttribute(TOOLTIP_ATTR, title);
99+
target.setAttribute(TOOLTIP_GENERATED_ATTR, '');
100+
if (!target.hasAttribute('aria-label') || wasGenerated) {
101+
target.setAttribute('aria-label', title);
102+
}
103+
} else if (wasGenerated) {
104+
this.clearGeneratedTarget(target);
105+
}
106+
target.removeAttribute('title');
107+
}
108+
109+
for (const target of root.querySelectorAll<HTMLElement>(`[${TOOLTIP_ATTR}]`)) {
110+
target
111+
.querySelector<HTMLElement>(`[${TOOLTIP_CONTENT_ATTR}][${TOOLTIP_GENERATED_CONTENT_ATTR}]`)
112+
?.remove();
113+
if (!target.querySelector(`[${TOOLTIP_CONTENT_ATTR}]`)) {
114+
const shortcut = this.options.getShortcut();
115+
if (shortcut) {
116+
target.append(this.createContentSource(shortcut));
117+
}
118+
}
119+
}
120+
}
121+
122+
private clearGeneratedTarget(target: HTMLElement): void {
123+
const tooltip = target.getAttribute(TOOLTIP_ATTR);
124+
if (target.getAttribute('aria-label') === tooltip) {
125+
target.removeAttribute('aria-label');
126+
}
127+
target.removeAttribute(TOOLTIP_ATTR);
128+
target.removeAttribute(TOOLTIP_GENERATED_ATTR);
129+
}
130+
131+
private onPointerOver = (event: PointerEvent): void => {
132+
const target = this.getTarget(event.target);
133+
if (target) {
134+
this.show(target);
135+
}
136+
};
137+
138+
private onPointerOut = (event: PointerEvent): void => {
139+
if (!this.anchor) {
140+
return;
141+
}
142+
const relatedTarget = event.relatedTarget;
143+
if (!(relatedTarget instanceof Node && this.anchor.contains(relatedTarget))) {
144+
this.hide();
145+
}
146+
};
147+
148+
private onFocusIn = (event: FocusEvent): void => {
149+
const target = this.getTarget(event.target);
150+
if (target?.matches(':focus-visible')) {
151+
this.show(target);
152+
}
153+
};
154+
155+
private onFocusOut = (): void => this.hide();
156+
157+
private onKeyDown = (event: KeyboardEvent): void => {
158+
if (event.key === 'Escape') {
159+
this.hide();
160+
}
161+
};
162+
163+
private getTarget(target: EventTarget | null): HTMLElement | null {
164+
const anchor = target instanceof Element ? target.closest(`[${TOOLTIP_ATTR}]`) : null;
165+
return anchor instanceof HTMLElement ? anchor : null;
166+
}
167+
168+
private show(anchor: HTMLElement): void {
169+
const text = anchor.getAttribute(TOOLTIP_ATTR);
170+
const root = this.options.getRootElement();
171+
if (!text || !root) {
172+
this.hide();
173+
return;
174+
}
175+
176+
this.hide();
177+
this.anchor = anchor;
178+
179+
const tooltip = document.createElement('div');
180+
tooltip.className = 'deck-widget-tooltip';
181+
tooltip.setAttribute('role', 'tooltip');
182+
183+
const label = document.createElement('span');
184+
label.className = 'deck-widget-tooltip-label';
185+
label.textContent = text;
186+
tooltip.append(label);
187+
188+
const content = this.getContent(anchor);
189+
if (content) {
190+
tooltip.append(content);
191+
}
192+
193+
const container = getContainer(root);
194+
container.append(tooltip);
195+
this.element = tooltip;
196+
this.position(anchor, tooltip, container);
197+
}
198+
199+
private getContent(anchor: HTMLElement): HTMLElement | Text | null {
200+
const source = anchor.querySelector<HTMLElement>(`[${TOOLTIP_CONTENT_ATTR}]`);
201+
if (!source) {
202+
return null;
203+
}
204+
if (source.dataset.tooltipContentType === 'text') {
205+
return document.createTextNode(source.textContent || '');
206+
}
207+
const clone = source.cloneNode(true) as HTMLElement;
208+
clone.removeAttribute('hidden');
209+
clone.classList.remove('deck-widget-tooltip-content-source');
210+
clone.classList.add('deck-widget-tooltip-content');
211+
return clone;
212+
}
213+
214+
private createContentSource(content: string | HTMLElement): HTMLElement {
215+
const source = document.createElement('span');
216+
source.hidden = true;
217+
source.setAttribute(TOOLTIP_CONTENT_ATTR, '');
218+
source.setAttribute(TOOLTIP_GENERATED_CONTENT_ATTR, '');
219+
if (typeof content === 'string') {
220+
source.dataset.tooltipContentType = 'text';
221+
source.textContent = content;
222+
} else {
223+
source.className = 'deck-widget-tooltip-content-source';
224+
source.innerHTML = content.outerHTML;
225+
}
226+
return source;
227+
}
228+
229+
private position(anchor: HTMLElement, tooltip: HTMLDivElement, container: HTMLElement): void {
230+
const anchorRect = anchor.getBoundingClientRect();
231+
const containerRect = container.getBoundingClientRect();
232+
233+
Object.assign(tooltip.style, {
234+
maxWidth: `${Math.max(0, containerRect.width - PADDING * 2)}px`,
235+
left: '0px',
236+
top: '0px'
237+
});
238+
239+
const tooltipRect = tooltip.getBoundingClientRect();
240+
const space = {
241+
right: containerRect.right - anchorRect.right,
242+
left: anchorRect.left - containerRect.left,
243+
bottom: containerRect.bottom - anchorRect.bottom,
244+
top: anchorRect.top - containerRect.top
245+
};
246+
const side = getSide(space, tooltipRect);
247+
let x = anchorRect.left - containerRect.left + (anchorRect.width - tooltipRect.width) / 2;
248+
let y = anchorRect.top - containerRect.top + (anchorRect.height - tooltipRect.height) / 2;
249+
250+
if (side === 'right') x = anchorRect.right - containerRect.left + OFFSET;
251+
if (side === 'left') x = anchorRect.left - containerRect.left - tooltipRect.width - OFFSET;
252+
if (side === 'bottom') y = anchorRect.bottom - containerRect.top + OFFSET;
253+
if (side === 'top') y = anchorRect.top - containerRect.top - tooltipRect.height - OFFSET;
254+
255+
tooltip.style.left = `${clamp(
256+
x,
257+
PADDING,
258+
containerRect.width - tooltipRect.width - PADDING
259+
)}px`;
260+
tooltip.style.top = `${clamp(
261+
y,
262+
PADDING,
263+
containerRect.height - tooltipRect.height - PADDING
264+
)}px`;
265+
}
266+
}
267+
268+
function getContainer(root: HTMLElement): HTMLElement {
269+
const parent = root.parentElement;
270+
const grandparent = parent?.parentElement;
271+
return grandparent?.parentElement?.classList.contains('deck-widget-container')
272+
? grandparent
273+
: parent || root;
274+
}
275+
276+
function getSide(space: Record<Side, number>, tooltipRect: DOMRect): Side {
277+
for (const side of ['right', 'left'] as const) {
278+
if (space[side] >= tooltipRect.width + OFFSET) return side;
279+
}
280+
for (const side of ['bottom', 'top'] as const) {
281+
if (space[side] >= tooltipRect.height + OFFSET) return side;
282+
}
283+
return (Object.entries(space).sort((a, b) => b[1] - a[1])[0]?.[0] || 'right') as Side;
284+
}
285+
286+
function clamp(value: number, min: number, max: number): number {
287+
return Math.min(Math.max(value, min), Math.max(min, max));
288+
}

modules/core/src/lib/widget.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import type {WidgetManager, WidgetPlacement} from './widget-manager';
1111
import type {ViewOrViews} from './view-manager';
1212
import {deepEqual} from '../utils/deep-equal';
1313
import {applyStyles, removeStyles} from '../utils/apply-styles';
14+
import {WidgetTooltip} from './widget-tooltip';
1415

1516
export type WidgetProps = {
1617
id?: string;
1718
/** CSS inline style overrides. */
1819
style?: Partial<CSSStyleDeclaration>;
1920
/** Additional CSS class. */
2021
className?: string;
22+
/** Keyboard shortcut or other hint rendered after the standard tooltip label. */
23+
shortcut?: string | HTMLElement;
2124
/**
2225
* The container that this widget is being attached to. Default to `viewId`.
2326
* If set to `'root'`, the widget is placed relative to the whole deck.gl canvas.
@@ -35,7 +38,8 @@ export abstract class Widget<
3538
id: 'widget',
3639
style: {},
3740
_container: null,
38-
className: ''
41+
className: '',
42+
shortcut: undefined!
3943
};
4044

4145
/** Unique identifier of the widget. */
@@ -57,6 +61,10 @@ export abstract class Widget<
5761
widgetManager?: WidgetManager;
5862
deck?: Deck<ViewsT>;
5963
rootElement?: HTMLDivElement | null;
64+
private _tooltip = new WidgetTooltip({
65+
getRootElement: () => this.rootElement,
66+
getShortcut: () => this.props.shortcut
67+
});
6068

6169
constructor(props: PropsT) {
6270
this.props = {
@@ -93,8 +101,10 @@ export abstract class Widget<
93101

94102
/** Update the HTML to reflect latest props and state */
95103
updateHTML(): void {
104+
this._tooltip.prepare();
96105
if (this.rootElement) {
97106
this.onRenderHTML(this.rootElement);
107+
this._tooltip.update();
98108
}
99109
}
100110

@@ -149,6 +159,12 @@ export abstract class Widget<
149159
return this.onAdd(params) ?? this.onCreateRootElement();
150160
}
151161

162+
/** Internal API called by Deck when the widget is removed */
163+
_onRemove(): void {
164+
this._tooltip.remove();
165+
this.onRemove();
166+
}
167+
152168
/** Overridable by subclass - called when the widget is first added to a Deck instance
153169
* @returns an optional UI element that should be appended to the Deck container
154170
*/

modules/widgets/src/scrollbar-widget.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export class ScrollbarWidget extends Widget<ScrollbarWidgetProps> {
100100

101101
override onViewportChange(viewport: Viewport) {
102102
this.viewport = viewport;
103-
this.onRenderHTML();
103+
this.updateHTML();
104104
}
105105

106106
override onRenderHTML(): void {

0 commit comments

Comments
 (0)