Skip to content

Commit a8d7aa2

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

10 files changed

Lines changed: 372 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: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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 GENERATED_ATTR = 'data-deck-widget-tooltip-generated';
7+
const OFFSET = 8;
8+
9+
export class WidgetTooltip {
10+
private anchor: HTMLElement | null = null;
11+
private element: HTMLDivElement | null = null;
12+
private listenerRoot: HTMLElement | null = null;
13+
private prepared = false;
14+
15+
prepare(root: HTMLElement | null | undefined): void {
16+
this.hide();
17+
this.prepared = Boolean(root);
18+
if (!root) return;
19+
20+
// Preact does not reapply unchanged title props after they are removed below.
21+
for (const target of root.querySelectorAll<HTMLElement>(`[${GENERATED_ATTR}]`)) {
22+
const tooltip = target.getAttribute(TOOLTIP_ATTR);
23+
if (tooltip) target.setAttribute('title', tooltip);
24+
}
25+
}
26+
27+
update(root: HTMLElement | null | undefined, shortcut?: string | HTMLElement): void {
28+
this.hide();
29+
if (!root) return;
30+
31+
this.addListeners(root);
32+
this.syncTargets(root, this.prepared);
33+
this.prepared = false;
34+
this.shortcut = shortcut;
35+
}
36+
37+
remove(): void {
38+
this.hide();
39+
this.removeListeners();
40+
}
41+
42+
private shortcut?: string | HTMLElement;
43+
44+
private hide(): void {
45+
this.element?.remove();
46+
this.element = null;
47+
this.anchor = null;
48+
}
49+
50+
private addListeners(root: HTMLElement): void {
51+
if (this.listenerRoot === root) return;
52+
53+
this.removeListeners();
54+
this.listenerRoot = root;
55+
root.addEventListener('pointerover', this.onPointerOver);
56+
root.addEventListener('pointerout', this.onPointerOut);
57+
root.addEventListener('focusin', this.onFocusIn);
58+
root.addEventListener('focusout', this.onFocusOut);
59+
root.addEventListener('keydown', this.onKeyDown);
60+
}
61+
62+
private removeListeners(): void {
63+
if (!this.listenerRoot) return;
64+
65+
this.listenerRoot.removeEventListener('pointerover', this.onPointerOver);
66+
this.listenerRoot.removeEventListener('pointerout', this.onPointerOut);
67+
this.listenerRoot.removeEventListener('focusin', this.onFocusIn);
68+
this.listenerRoot.removeEventListener('focusout', this.onFocusOut);
69+
this.listenerRoot.removeEventListener('keydown', this.onKeyDown);
70+
this.listenerRoot = null;
71+
}
72+
73+
private syncTargets(root: HTMLElement, prepared: boolean): void {
74+
if (prepared) {
75+
for (const target of root.querySelectorAll<HTMLElement>(`[${GENERATED_ATTR}]:not([title])`)) {
76+
this.clearGeneratedTarget(target);
77+
}
78+
}
79+
80+
for (const target of root.querySelectorAll<HTMLElement>('[title]')) {
81+
const title = target.getAttribute('title');
82+
const wasGenerated = target.hasAttribute(GENERATED_ATTR);
83+
if (title) {
84+
target.setAttribute(TOOLTIP_ATTR, title);
85+
target.setAttribute(GENERATED_ATTR, '');
86+
if (!target.hasAttribute('aria-label') || wasGenerated) {
87+
target.setAttribute('aria-label', title);
88+
}
89+
} else if (wasGenerated) {
90+
this.clearGeneratedTarget(target);
91+
}
92+
target.removeAttribute('title');
93+
}
94+
}
95+
96+
private clearGeneratedTarget(target: HTMLElement): void {
97+
const tooltip = target.getAttribute(TOOLTIP_ATTR);
98+
if (target.getAttribute('aria-label') === tooltip) target.removeAttribute('aria-label');
99+
target.removeAttribute(TOOLTIP_ATTR);
100+
target.removeAttribute(GENERATED_ATTR);
101+
}
102+
103+
private onPointerOver = (event: PointerEvent): void => {
104+
const target = this.getTarget(event.target);
105+
if (target) this.show(target);
106+
};
107+
108+
private onPointerOut = (event: PointerEvent): void => {
109+
if (
110+
this.anchor &&
111+
!(event.relatedTarget instanceof Node && this.anchor.contains(event.relatedTarget))
112+
) {
113+
this.hide();
114+
}
115+
};
116+
117+
private onFocusIn = (event: FocusEvent): void => {
118+
const target = this.getTarget(event.target);
119+
if (target?.matches(':focus-visible')) this.show(target);
120+
};
121+
122+
private onFocusOut = (): void => this.hide();
123+
124+
private onKeyDown = (event: KeyboardEvent): void => {
125+
if (event.key === 'Escape') this.hide();
126+
};
127+
128+
private getTarget(target: EventTarget | null): HTMLElement | null {
129+
const anchor = target instanceof Element ? target.closest(`[${TOOLTIP_ATTR}]`) : null;
130+
return anchor instanceof HTMLElement ? anchor : null;
131+
}
132+
133+
private show(anchor: HTMLElement): void {
134+
const text = anchor.getAttribute(TOOLTIP_ATTR);
135+
const root = this.listenerRoot;
136+
if (!text || !root) return;
137+
138+
this.hide();
139+
this.anchor = anchor;
140+
141+
const tooltip = document.createElement('div');
142+
tooltip.className = 'deck-widget-tooltip';
143+
tooltip.setAttribute('role', 'tooltip');
144+
tooltip.append(document.createTextNode(text));
145+
if (this.shortcut) {
146+
tooltip.append(
147+
typeof this.shortcut === 'string'
148+
? document.createTextNode(this.shortcut)
149+
: this.shortcut.cloneNode(true)
150+
);
151+
}
152+
153+
const container = getContainer(root);
154+
container.append(tooltip);
155+
this.element = tooltip;
156+
this.position(anchor, tooltip, container);
157+
}
158+
159+
private position(anchor: HTMLElement, tooltip: HTMLDivElement, container: HTMLElement): void {
160+
const anchorRect = anchor.getBoundingClientRect();
161+
const containerRect = container.getBoundingClientRect();
162+
const tooltipRect = tooltip.getBoundingClientRect();
163+
let left = anchorRect.right - containerRect.left + OFFSET;
164+
const top = anchorRect.top - containerRect.top + (anchorRect.height - tooltipRect.height) / 2;
165+
166+
if (left + tooltipRect.width > containerRect.width) {
167+
left = anchorRect.left - containerRect.left - tooltipRect.width - OFFSET;
168+
}
169+
tooltip.style.left = `${clamp(left, 0, containerRect.width - tooltipRect.width)}px`;
170+
tooltip.style.top = `${clamp(top, 0, containerRect.height - tooltipRect.height)}px`;
171+
}
172+
}
173+
174+
function getContainer(root: HTMLElement): HTMLElement {
175+
const parent = root.parentElement;
176+
const grandparent = parent?.parentElement;
177+
return grandparent?.parentElement?.classList.contains('deck-widget-container')
178+
? grandparent
179+
: parent || root;
180+
}
181+
182+
function clamp(value: number, min: number, max: number): number {
183+
return Math.min(Math.max(value, min), Math.max(min, max));
184+
}

modules/core/src/lib/widget.ts

Lines changed: 14 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,7 @@ export abstract class Widget<
5761
widgetManager?: WidgetManager;
5862
deck?: Deck<ViewsT>;
5963
rootElement?: HTMLDivElement | null;
64+
private _tooltip = new WidgetTooltip();
6065

6166
constructor(props: PropsT) {
6267
this.props = {
@@ -85,6 +90,7 @@ export abstract class Widget<
8590
applyStyles(el, props.style);
8691
}
8792

93+
this._tooltip.prepare(this.rootElement);
8894
Object.assign(this.props, props);
8995

9096
// Update the HTML to match the new props
@@ -95,6 +101,7 @@ export abstract class Widget<
95101
updateHTML(): void {
96102
if (this.rootElement) {
97103
this.onRenderHTML(this.rootElement);
104+
this._tooltip.update(this.rootElement, this.props.shortcut);
98105
}
99106
}
100107

@@ -149,6 +156,12 @@ export abstract class Widget<
149156
return this.onAdd(params) ?? this.onCreateRootElement();
150157
}
151158

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

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 {

modules/widgets/src/stylesheet.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,39 @@
105105
z-index: 100;
106106
}
107107

108+
.deck-widget-tooltip {
109+
position: absolute;
110+
z-index: 1000;
111+
pointer-events: none;
112+
box-sizing: border-box;
113+
max-width: 240px;
114+
padding: 4px 8px;
115+
border: var(--menu-border, unset);
116+
border-radius: calc(var(--button-corner-radius, 8px) - 2px);
117+
box-shadow: var(--menu-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25));
118+
background: var(--menu-background, #fff);
119+
backdrop-filter: var(--menu-backdrop-filter, unset);
120+
color: var(--menu-text, rgb(24, 24, 26));
121+
font-size: 12px;
122+
line-height: 16px;
123+
display: flex;
124+
gap: 8px;
125+
align-items: center;
126+
justify-content: space-between;
127+
white-space: normal;
128+
overflow-wrap: break-word;
129+
}
130+
131+
.deck-widget-tooltip kbd {
132+
font-family: inherit;
133+
font-size: 11px;
134+
line-height: 14px;
135+
padding: 0 4px;
136+
border: var(--button-inner-stroke, 1px solid rgba(0, 0, 0, 0.2));
137+
border-radius: 3px;
138+
background: var(--button-background, #fff);
139+
}
140+
108141
/* Fullscreen styles */
109142
.deck-widget.deck-widget-fullscreen button.deck-widget-fullscreen-enter .deck-widget-icon {
110143
mask-image: var(

test/modules/widgets/scrollbar-widget.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ test('ScrollbarWidget - decorations', async () => {
184184
expect(decoration).toBeTruthy();
185185
expect(decoration.style.left).toBe('20%');
186186
expect(decoration.style.width).toBe('20%');
187-
expect(decorationContent.title).toBe('Highlight');
187+
expect(decorationContent.title).toBe('');
188188
expect(decorationContent.style.backgroundColor).toBe('rgb(255, 255, 0)');
189189
expect(scrollbar.getAttribute('aria-valuenow')).toBe('200');
190190

test/modules/widgets/selector-widget.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ test('SelectorWidget', async () => {
3131

3232
await testInstance.idle();
3333
let button = testInstance.findElements('.deck-widget-icon-button')[0] as HTMLButtonElement;
34-
expect(button.title).toBe('Grid');
34+
expect(button.title).toBe('');
3535

3636
testInstance.click('.deck-widget-icon-button');
3737
await testInstance.idle();
@@ -45,6 +45,6 @@ test('SelectorWidget', async () => {
4545

4646
expect(onChange).toHaveBeenCalledWith('list');
4747
button = testInstance.findElements('.deck-widget-icon-button')[0] as HTMLButtonElement;
48-
expect(button.title).toBe('List');
48+
expect(button.title).toBe('');
4949
expect(testInstance.findElements('.deck-widget-dropdown-item')).toHaveLength(0);
5050
});

test/modules/widgets/theme-widget.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ test('ThemeWidget - button label and icon reflect current mode', async () => {
6969
let button = testInstance.findElements(
7070
'.deck-widget-theme .deck-widget-icon-button'
7171
)[0] as HTMLButtonElement;
72-
expect(button.title).toBe('Dark Mode');
72+
expect(button.title).toBe('');
7373
expect(button.className).toContain('deck-widget-moon');
7474

7575
testInstance.setProps({
@@ -86,7 +86,7 @@ test('ThemeWidget - button label and icon reflect current mode', async () => {
8686
button = testInstance.findElements(
8787
'.deck-widget-theme .deck-widget-icon-button'
8888
)[0] as HTMLButtonElement;
89-
expect(button.title).toBe('Light Mode');
89+
expect(button.title).toBe('');
9090
expect(button.className).toContain('deck-widget-sun');
9191
});
9292

test/modules/widgets/toggle-widget.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ test('ToggleWidget', async () => {
3535
const icon = testInstance.findElements('.deck-widget-icon')[0] as HTMLDivElement;
3636

3737
expect(root.dataset.checked).toBe('false');
38-
expect(button.title).toBe('Toggle off');
38+
expect(button.title).toBe('');
3939
expect(icon.style.backgroundColor).toBe('rgb(255, 0, 0)');
4040

4141
testInstance.click('.deck-widget-icon-button');
4242

4343
expect(onChange).toHaveBeenCalledWith(true);
4444
expect(root.dataset.checked).toBe('true');
45-
expect(button.title).toBe('Toggle on');
45+
expect(button.title).toBe('');
4646
expect(icon.style.backgroundColor).toBe('rgb(0, 255, 0)');
4747
});

0 commit comments

Comments
 (0)