-
Notifications
You must be signed in to change notification settings - Fork 2.3k
feat(widgets): styled tooltips #10414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| ```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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In my opinion subclassing is a bit technical for applications.
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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. The docs now show the subclass member form only: |
||
| }); | ||
| ``` | ||
|
|
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -35,7 +37,8 @@ export abstract class Widget< | |
| id: 'widget', | ||
| style: {}, | ||
| _container: null, | ||
| className: '' | ||
| className: '', | ||
| onAfterRenderHTML: undefined! | ||
| }; | ||
|
|
||
| /** Unique identifier of the widget. */ | ||
|
|
@@ -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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This all started by me trying to avoid having to subclass widgets to customize them.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed the prop interface. Core now only adds the protected
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -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(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Usage of
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 1cdcf1a. Added |
||
|
|
||
| // Experimental preact components | ||
| export {ButtonGroup as _ButtonGroup, type ButtonGroupProps} from './lib/components/button-group'; | ||
|
|
||
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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`; | ||
| } | ||
| } | ||
| 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'); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.