diff --git a/docs/api-reference/widgets/compass-widget.md b/docs/api-reference/widgets/compass-widget.md index ded087d4eb7..2a1eff9b759 100644 --- a/docs/api-reference/widgets/compass-widget.md +++ b/docs/api-reference/widgets/compass-widget.md @@ -136,6 +136,12 @@ The `CompassWidget` accepts the generic [`WidgetProps`](../core/widget.md#widget Tooltip message displayed while hovering a mouse over the widget. +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `transitionDuration` (number, optional) {#transitionduration} * Default: `200` diff --git a/docs/api-reference/widgets/fullscreen-widget.md b/docs/api-reference/widgets/fullscreen-widget.md index f50b2b8c04b..4aabe61195b 100644 --- a/docs/api-reference/widgets/fullscreen-widget.md +++ b/docs/api-reference/widgets/fullscreen-widget.md @@ -85,12 +85,24 @@ A [compatible DOM element](https://developer.mozilla.org/en-US/docs/Web/API/Elem Tooltip message displayed while hovering a mouse over the widget when out of fullscreen. +#### `enterTooltip` (string | HTMLElement | false, optional) {#entertooltip} + +* Default: value of `enterLabel` + +Custom tooltip content for the enter fullscreen button. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `exitLabel` (string, optional) {#exitlabel} * Default: `'Exit Fullscreen'` Tooltip message displayed while hovering a mouse over the widget when fullscreen. +#### `exitTooltip` (string | HTMLElement | false, optional) {#exittooltip} + +* Default: value of `exitLabel` + +Custom tooltip content for the exit fullscreen button. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `onFullscreenChange` (Function, optional) {#onfullscreenchange} ```ts diff --git a/docs/api-reference/widgets/gimbal-widget.md b/docs/api-reference/widgets/gimbal-widget.md index 569cb70b297..b4784109c9c 100644 --- a/docs/api-reference/widgets/gimbal-widget.md +++ b/docs/api-reference/widgets/gimbal-widget.md @@ -139,6 +139,12 @@ The `GimbalWidgetProps` accepts the generic [`WidgetProps`](../core/widget.md#wi Tooltip message displayed while hovering a mouse over the widget. +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `strokeWidth` (number, optional) {#strokewidth} * Default: `1.5` diff --git a/docs/api-reference/widgets/icon-widget.md b/docs/api-reference/widgets/icon-widget.md index 3770d9ca0dc..87735c731fd 100644 --- a/docs/api-reference/widgets/icon-widget.md +++ b/docs/api-reference/widgets/icon-widget.md @@ -93,6 +93,12 @@ Data URL used as the button icon mask. Tooltip message displayed while hovering over the widget. +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `color` (string, optional) {#color} CSS color applied to the icon. diff --git a/docs/api-reference/widgets/loading-widget.md b/docs/api-reference/widgets/loading-widget.md index cf55a158f71..08b480ce86e 100644 --- a/docs/api-reference/widgets/loading-widget.md +++ b/docs/api-reference/widgets/loading-widget.md @@ -77,7 +77,13 @@ The `InfoWidget` accepts the generic [`WidgetProps`](../core/widget.md#widgetpro * Default: `'Loading data'` -Tooltip message displayed while hovering a mouse over the widget. +Text used as the button's `aria-label` and displayed as a styled [tooltip](./tooltips.md) on hover/focus. + +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. #### `onLoadingChange` (Function, optional) {#onloadingchange} diff --git a/docs/api-reference/widgets/overview.md b/docs/api-reference/widgets/overview.md index cff3aacdc91..d1557a8c5bc 100644 --- a/docs/api-reference/widgets/overview.md +++ b/docs/api-reference/widgets/overview.md @@ -252,6 +252,10 @@ new Deck({ }); ``` +## Tooltips + +Built-in button widgets show styled tooltips on hover. See [Widget Tooltips](./tooltips) for customization and usage in custom widgets. + ## Themes and Styling deck.gl widget appearance can be customized using [themes and CSS](./styling). diff --git a/docs/api-reference/widgets/reset-view-widget.md b/docs/api-reference/widgets/reset-view-widget.md index 85bc8d9d95d..d3f71c842a2 100644 --- a/docs/api-reference/widgets/reset-view-widget.md +++ b/docs/api-reference/widgets/reset-view-widget.md @@ -116,6 +116,12 @@ The `ResetViewWidget` accepts the generic [`WidgetProps`](../core/widget.md#widg Tooltip message displayed while hovering a mouse over the widget. +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `initialViewState` (ViewState, optional) {#initialviewstate} * Default: `deck.props.initialViewState` diff --git a/docs/api-reference/widgets/screenshot-widget.md b/docs/api-reference/widgets/screenshot-widget.md index 12d9ed3aa6b..9c81f998aef 100644 --- a/docs/api-reference/widgets/screenshot-widget.md +++ b/docs/api-reference/widgets/screenshot-widget.md @@ -84,6 +84,12 @@ The `ScreenshotWidget` accepts the generic [`WidgetProps`](../core/widget.md#wid Tooltip message displayed while hovering a mouse over the widget. +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `filename` (string, optional) {#filename} * Default: `'screenshot.png'` diff --git a/docs/api-reference/widgets/selector-widget.md b/docs/api-reference/widgets/selector-widget.md index 714ff12f562..7b339c56381 100644 --- a/docs/api-reference/widgets/selector-widget.md +++ b/docs/api-reference/widgets/selector-widget.md @@ -169,7 +169,13 @@ Data URL used as the option icon mask. #### `label` (string, optional) {#label} -Text shown in the menu and used as the button tooltip when selected. +Text shown in the menu and used as the button's `aria-label` and styled [tooltip](./tooltips.md) when selected. + +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. ## Styles diff --git a/docs/api-reference/widgets/styling.md b/docs/api-reference/widgets/styling.md index 5a773a213ec..b72ab96adc6 100644 --- a/docs/api-reference/widgets/styling.md +++ b/docs/api-reference/widgets/styling.md @@ -145,6 +145,13 @@ Additionally, refer to each widget's API reference for variables specific to tha | `--menu-text` | [Color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) | `rgb(24, 24, 26)` | | `--menu-item-hover` | [Color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) | `rgba(0, 0, 0, 0.08)` | +### Tooltip + +| Name | Type | Default | +| ---- | ---- | ------- | +| `--tooltip-max-width` | [Dimension](https://developer.mozilla.org/en-US/docs/Web/CSS/dimension) | `240px` | +| `--tooltip-z-index` | Number | `1000` | + ### Range input | Name | Type | Default | diff --git a/docs/api-reference/widgets/theme-widget.md b/docs/api-reference/widgets/theme-widget.md index 6ee3753a7d6..9fc8eab8538 100644 --- a/docs/api-reference/widgets/theme-widget.md +++ b/docs/api-reference/widgets/theme-widget.md @@ -139,12 +139,24 @@ Callback when the user clicks the theme toggle button. Tooltip message displayed while hovering a mouse over the widget when light mode is available. +#### `lightModeTooltip` (string | HTMLElement | false, optional) {#lightmodetooltip} + +* Default: value of `lightModeLabel` + +Custom tooltip content when in light mode. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `darkModeLabel` (string, optional) {#darkmodelabel} * Default: `'Dark Mode'` Tooltip message displayed while hovering a mouse over the widget when dark mode is available. +#### `darkModeTooltip` (string | HTMLElement | false, optional) {#darkmodetooltip} + +* Default: value of `darkModeLabel` + +Custom tooltip content when in dark mode. Overrides the default label text in the tooltip. Pass `false` to disable. + ## Styles | Name | Type | Default | diff --git a/docs/api-reference/widgets/timeline-widget.md b/docs/api-reference/widgets/timeline-widget.md index 71c4247db03..f20a0d5d487 100644 --- a/docs/api-reference/widgets/timeline-widget.md +++ b/docs/api-reference/widgets/timeline-widget.md @@ -251,6 +251,18 @@ In controlled mode, the widget does not automatically reset time to the beginnin Callback when play/pause button is clicked. +#### `playTooltip` (string | HTMLElement | false, optional) {#playtooltip} + +* Default: `'Play'` + +Custom tooltip content for the play button. Overrides the default label text in the tooltip. Pass `false` to disable. + +#### `pauseTooltip` (string | HTMLElement | false, optional) {#pausetooltip} + +* Default: `'Pause'` + +Custom tooltip content for the pause button. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `formatLabel` (function, optional) {#formatlabel} ```ts diff --git a/docs/api-reference/widgets/toggle-widget.md b/docs/api-reference/widgets/toggle-widget.md index 76cc3478e62..94434f69555 100644 --- a/docs/api-reference/widgets/toggle-widget.md +++ b/docs/api-reference/widgets/toggle-widget.md @@ -114,12 +114,24 @@ Data URL used as the icon when the widget is checked. Tooltip message displayed while hovering over the widget. +#### `tooltip` (string | HTMLElement | false, optional) {#tooltip} + +* Default: value of `label` + +Custom tooltip content. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `onLabel` (string, optional) {#onlabel} * Default: same as `label` Tooltip shown while the widget is checked. +#### `onTooltip` (string | HTMLElement | false, optional) {#ontooltip} + +* Default: value of `onLabel` + +Custom tooltip content when the widget is checked. Overrides the default onLabel text in the tooltip. Pass `false` to disable. + #### `color` (string, optional) {#color} CSS color of the icon. diff --git a/docs/api-reference/widgets/tooltips.md b/docs/api-reference/widgets/tooltips.md new file mode 100644 index 00000000000..e51348fbb96 --- /dev/null +++ b/docs/api-reference/widgets/tooltips.md @@ -0,0 +1,138 @@ +# Widget Tooltips + +from v9.5 + +Built-in button widgets ship with styled tooltips that appear on hover and keyboard focus. These replace the slow native browser title tooltips with themed, customizable alternatives. + +## Customizing Built-in Tooltips + +### `label` vs `tooltip` + +The `label` prop sets the button's accessible name (`aria-label`) and is always a plain string. By default, the tooltip displays the label text. The `tooltip` prop overrides only the visual tooltip — use it for rich HTML content, a different display string, or `false` to hide the tooltip while preserving accessibility. + +### Overriding tooltip text + +Each button widget accepts a tooltip prop that overrides the default label: + +```ts +new ZoomWidget({ zoomInTooltip: 'Zoom In (Ctrl+Plus)' }) +new FullscreenWidget({ enterTooltip: 'Go Fullscreen (F)' }) +``` + +### Overriding with custom HTML + +For rich content (e.g. keyboard shortcut badges), pass an `HTMLElement`: + +```ts +const tip = document.createElement('span'); +tip.innerHTML = 'Zoom In ⌘+'; +new ZoomWidget({ zoomInTooltip: tip }) +``` + +### Disabling tooltips + +Pass `false` to suppress the tooltip for a specific button: + +```ts +new ZoomWidget({ zoomInTooltip: false }) +new CompassWidget({ tooltip: false }) +``` + +### Styling tooltips + +Tooltip appearance inherits the widget theme via CSS variables. You can customize them globally or per-widget: + +```css +.deck-widget { + --tooltip-max-width: 300px; + --tooltip-z-index: 2000; +} +``` + +| Name | Type | Default | +| ---- | ---- | ------- | +| `--tooltip-max-width` | [Dimension](https://developer.mozilla.org/en-US/docs/Web/CSS/dimension) | `240px` | +| `--tooltip-z-index` | Number | `1000` | + +Tooltips also inherit the following [menu variables](./styling.md#menu): `--menu-background`, `--menu-shadow`, `--menu-backdrop-filter`, `--menu-text`. + +## Writing Tooltips in Custom Widgets + +### Using Preact (with \_Tooltip component) + +If your custom widget uses Preact for rendering, import the `_Tooltip` component: + +```tsx +import {_Tooltip as Tooltip} from '@deck.gl/widgets'; +import {render} from 'preact'; + +class MyWidget extends Widget { + className = 'my-widget'; + placement = 'top-left'; + + onRenderHTML(rootElement) { + render( + + + , + rootElement + ); + } +} +``` + +#### TooltipProps + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `content` | `string \| ComponentChildren` | — | Tooltip content to display | +| `placement` | `Placement` | `'right'` | Position relative to the trigger (uses [@floating-ui/dom](https://floating-ui.com/docs/computePosition#placement) placement values) | +| `children` | `ComponentChildren` | — | The trigger element | + +### Without Preact (CSS class + your own logic) + +For custom widgets using vanilla JS, React, or any other framework, implement your own show/hide behavior and apply the `.deck-widget-tooltip` CSS class to get consistent theming: + +```ts +class MyWidget extends Widget { + className = 'my-widget'; + placement = 'top-left'; + + onRenderHTML(rootElement) { + const btn = document.createElement('button'); + btn.setAttribute('aria-label', 'My Action'); + btn.setAttribute('aria-describedby', 'my-tooltip'); + + const tooltip = document.createElement('div'); + tooltip.id = 'my-tooltip'; + tooltip.className = 'deck-widget-tooltip'; + tooltip.setAttribute('role', 'tooltip'); + tooltip.textContent = 'My Action'; + tooltip.hidden = true; + + btn.addEventListener('pointerenter', () => { tooltip.hidden = false; }); + btn.addEventListener('pointerleave', () => { tooltip.hidden = true; }); + + rootElement.replaceChildren(btn, tooltip); + } +} +``` + +The `.deck-widget-tooltip` class provides themed background, shadow, text color, font, border-radius, and max-width — matching the rest of the widget UI. You are responsible for: + +- Positioning (consider [@floating-ui/dom](https://floating-ui.com/docs/getting-started) or CSS anchor positioning) +- Show/hide behavior (pointer events, focus, keyboard dismiss) + +## Accessibility + +Built-in widget tooltips follow these accessibility practices: + +- Buttons use `aria-label` for screen reader announcements +- Tooltip elements have `role="tooltip"` +- Tooltips appear on keyboard focus +- Pressing `Escape` dismisses the tooltip + +Custom widget authors should follow the same patterns. + diff --git a/docs/api-reference/widgets/zoom-widget.md b/docs/api-reference/widgets/zoom-widget.md index b0eae3f1c92..1bd48ecaf54 100644 --- a/docs/api-reference/widgets/zoom-widget.md +++ b/docs/api-reference/widgets/zoom-widget.md @@ -147,12 +147,24 @@ Zoom level delta applied by each button click. Tooltip message displayed while hovering a mouse over the zoom in button. +#### `zoomInTooltip` (string | HTMLElement | false, optional) {#zoomintooltip} + +* Default: value of `zoomInLabel` + +Custom tooltip content for the zoom in button. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `zoomOutLabel` (string, optional) {#zoomoutlabel} * Default: `'Zoom Out'` Tooltip message displayed while hovering a mouse over the zoom out button. +#### `zoomOutTooltip` (string | HTMLElement | false, optional) {#zoomouttooltip} + +* Default: value of `zoomOutLabel` + +Custom tooltip content for the zoom out button. Overrides the default label text in the tooltip. Pass `false` to disable. + #### `transitionDuration` (number, optional) {#transitionduration} * Default: `200` diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json index a339e73cfc1..cb8272b2ffd 100644 --- a/docs/table-of-contents.json +++ b/docs/table-of-contents.json @@ -319,6 +319,7 @@ "items": [ "api-reference/widgets/overview", "api-reference/widgets/styling", + "api-reference/widgets/tooltips", "api-reference/widgets/compass-widget", "api-reference/widgets/context-menu-widget", "api-reference/widgets/fullscreen-widget", diff --git a/modules/widgets/src/compass-widget.tsx b/modules/widgets/src/compass-widget.tsx index 547b1280b1c..2d3971e62ab 100644 --- a/modules/widgets/src/compass-widget.tsx +++ b/modules/widgets/src/compass-widget.tsx @@ -5,6 +5,7 @@ import {Widget, FlyToInterpolator, WebMercatorViewport, _GlobeViewport} from '@deck.gl/core'; import type {Viewport, WidgetPlacement, WidgetProps} from '@deck.gl/core'; import {render} from 'preact'; +import {Tooltip} from './lib/components/tooltip'; export type CompassWidgetProps = WidgetProps & { /** Widget positioning within the view. Default 'top-left'. */ @@ -13,6 +14,8 @@ export type CompassWidgetProps = WidgetProps & { viewId?: string | null; /** Tooltip message. */ label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** Bearing and pitch reset transition duration in ms. */ transitionDuration?: number; /** @@ -36,6 +39,7 @@ export class CompassWidget extends Widget { placement: 'top-left', viewId: null, label: 'Reset Compass', + tooltip: undefined!, transitionDuration: 200, onReset: () => {} }; @@ -60,31 +64,35 @@ export class CompassWidget extends Widget { const widgetViewport = this.viewports[viewId]; const [rz, rx] = this.getRotation(widgetViewport); + const tooltipContent = + this.props.tooltip === false ? undefined : (this.props.tooltip ?? this.props.label); const ui = (
- + + +
); diff --git a/modules/widgets/src/fullscreen-widget.tsx b/modules/widgets/src/fullscreen-widget.tsx index 43cfae69a37..2cfb268d87f 100644 --- a/modules/widgets/src/fullscreen-widget.tsx +++ b/modules/widgets/src/fullscreen-widget.tsx @@ -19,6 +19,10 @@ export type FullscreenWidgetProps = WidgetProps & { enterLabel?: string; /** Tooltip message when fullscreen. */ exitLabel?: string; + /** Custom tooltip content when out of fullscreen. Overrides enterLabel for tooltip display. */ + enterTooltip?: string | HTMLElement | false; + /** Custom tooltip content when fullscreen. Overrides exitLabel for tooltip display. */ + exitTooltip?: string | HTMLElement | false; /** * A compatible DOM element which should be made full screen. By default, the map container element will be made full screen. * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullScreen#Compatible_elements @@ -38,6 +42,8 @@ export class FullscreenWidget extends Widget { viewId: null, enterLabel: 'Enter Fullscreen', exitLabel: 'Exit Fullscreen', + enterTooltip: undefined!, + exitTooltip: undefined!, container: undefined!, onFullscreenChange: () => {} }; @@ -67,6 +73,7 @@ export class FullscreenWidget extends Widget { this.handleClick().catch(err => log.error(err)()); }} label={isFullscreen ? this.props.exitLabel : this.props.enterLabel} + tooltip={isFullscreen ? this.props.exitTooltip : this.props.enterTooltip} className={isFullscreen ? 'deck-widget-fullscreen-exit' : 'deck-widget-fullscreen-enter'} />, rootElement diff --git a/modules/widgets/src/gimbal-widget.tsx b/modules/widgets/src/gimbal-widget.tsx index e53442dcbcd..1bd2ac9f13d 100644 --- a/modules/widgets/src/gimbal-widget.tsx +++ b/modules/widgets/src/gimbal-widget.tsx @@ -5,6 +5,7 @@ import {Widget, LinearInterpolator} from '@deck.gl/core'; import type {Viewport, WidgetPlacement, WidgetProps} from '@deck.gl/core'; import {render} from 'preact'; +import {Tooltip} from './lib/components/tooltip'; export type GimbalWidgetProps = WidgetProps & { placement?: WidgetPlacement; @@ -12,6 +13,8 @@ export type GimbalWidgetProps = WidgetProps & { viewId?: string | null; /** Tooltip message. */ label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** Width of gimbal lines. */ strokeWidth?: number; /** Transition duration in ms when resetting rotation. */ @@ -37,6 +40,7 @@ export class GimbalWidget extends Widget { placement: 'top-left', viewId: null, label: 'Gimbal', + tooltip: undefined!, strokeWidth: 1.5, transitionDuration: 200, onReset: () => {} @@ -62,64 +66,68 @@ export class GimbalWidget extends Widget { const widgetViewport = this.viewports[viewId]; const {rotationOrbit, rotationX} = this.getNormalizedRotation(widgetViewport); // Note - we use CSS 3D transforms instead of SVG 2D transforms + const tooltipContent = + this.props.tooltip === false ? undefined : (this.props.tooltip ?? this.props.label); const ui = (
- + {/* Outer ring */} + + + + + {/* Inner ring */} + + + + +
); diff --git a/modules/widgets/src/icon-widget.tsx b/modules/widgets/src/icon-widget.tsx index 0c5ff5a2027..3cf2a6bc648 100644 --- a/modules/widgets/src/icon-widget.tsx +++ b/modules/widgets/src/icon-widget.tsx @@ -16,6 +16,8 @@ export type IconWidgetProps = WidgetProps & { icon: string; /** Tooltip label */ label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** Icon color, a CSS Color string */ color?: string; /** Callback when the widget is clicked */ @@ -33,6 +35,7 @@ export class IconWidget extends Widget { viewId: null, icon: '', label: '', + tooltip: undefined!, color: '', onClick: undefined! }; @@ -52,7 +55,7 @@ export class IconWidget extends Widget { } onRenderHTML(rootElement: HTMLElement): void { - const {className, style, icon, color, label, onClick} = this.props; + const {className, style, icon, color, label, tooltip, onClick} = this.props; render( { color={color} icon={icon} label={label} + tooltip={tooltip} onClick={onClick} />, rootElement diff --git a/modules/widgets/src/index.ts b/modules/widgets/src/index.ts index f64b8e18bae..350f040173b 100644 --- a/modules/widgets/src/index.ts +++ b/modules/widgets/src/index.ts @@ -66,6 +66,7 @@ export type {DeckWidgetTheme} from './themes'; // Experimental preact components export {ButtonGroup as _ButtonGroup, type ButtonGroupProps} from './lib/components/button-group'; export {IconButton as _IconButton, type IconButtonProps} from './lib/components/icon-button'; +export {Tooltip as _Tooltip, type TooltipProps} from './lib/components/tooltip'; export { DropdownMenu as _DropdownMenu, type DropdownMenuProps, diff --git a/modules/widgets/src/lib/components/icon-button.tsx b/modules/widgets/src/lib/components/icon-button.tsx index 618e2355472..97eee0a7923 100644 --- a/modules/widgets/src/lib/components/icon-button.tsx +++ b/modules/widgets/src/lib/components/icon-button.tsx @@ -5,11 +5,14 @@ import type {ComponentChildren, JSX} from 'preact'; import {useMemo} from 'preact/hooks'; import {getCSSMask} from '../data-url'; +import {Tooltip} from './tooltip'; export type IconButtonProps = { className?: string; icon?: string; label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | ComponentChildren | false; color?: string; style?: JSX.CSSProperties; onClick?: JSX.MouseEventHandler; @@ -18,7 +21,8 @@ export type IconButtonProps = { /** Renders a button component with widget CSS */ export const IconButton = (props: IconButtonProps) => { - const {className = '', style, color, icon, label, onClick, children} = props; + const {className = '', style, color, icon, label, tooltip, onClick, children} = props; + const tooltipContent = tooltip === false ? undefined : (tooltip ?? label); const iconStyle = useMemo(() => { const css: JSX.CSSProperties | undefined = getCSSMask(icon); @@ -26,16 +30,20 @@ export const IconButton = (props: IconButtonProps) => { return {...css, backgroundColor: color}; }, [color, icon]); + const button = ( + + ); + return (
- + {tooltipContent ? {button} : button}
); }; diff --git a/modules/widgets/src/lib/components/tooltip.tsx b/modules/widgets/src/lib/components/tooltip.tsx new file mode 100644 index 00000000000..ddd9235416f --- /dev/null +++ b/modules/widgets/src/lib/components/tooltip.tsx @@ -0,0 +1,111 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {ComponentChildren} from 'preact'; +import {useRef, useState, useEffect, useCallback} from 'preact/hooks'; +import {computePosition, flip, shift, offset, type Placement} from '@floating-ui/dom'; + +export type TooltipProps = { + /** Tooltip content — string or Preact children. */ + content: string | ComponentChildren; + /** Position relative to the trigger. + * @default 'right' + */ + placement?: Placement; + /** The trigger element */ + children: ComponentChildren; +}; + +export const Tooltip = ({content, placement = 'right', children}: TooltipProps) => { + const wrapperRef = useRef(null); + const [visible, setVisible] = useState(false); + const delayRef = useRef>(); + + const show = useCallback(() => { + clearTimeout(delayRef.current); + setVisible(true); + }, []); + + const hide = useCallback(() => { + clearTimeout(delayRef.current); + setVisible(false); + }, []); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') hide(); + }, + [hide] + ); + + const tooltipRefCallback = useCallback( + (tooltip: HTMLDivElement | null) => { + if (!tooltip || !wrapperRef.current) return; + const trigger = wrapperRef.current.firstElementChild as HTMLElement | null; + if (!trigger) return; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + computePosition(trigger, tooltip, { + placement, + strategy: 'fixed', + middleware: [offset(8), flip(), shift({padding: 4})] + }).then(({x, y}) => { + Object.assign(tooltip.style, {left: `${x}px`, top: `${y}px`, opacity: '1'}); + }); + }, + [placement] + ); + + useEffect(() => { + return () => clearTimeout(delayRef.current); + }, []); + + const htmlRef = useCallback( + (el: HTMLDivElement | null) => { + if (el && content instanceof HTMLElement) { + el.replaceChildren(content.cloneNode(true)); + } + }, + [content] + ); + + const renderContent = () => { + if (content instanceof HTMLElement) { + return
; + } + return content; + }; + + if (!content) return <>{children}; + + return ( +
+ {children} + {visible && ( +
+ {renderContent()} +
+ )} +
+ ); +}; diff --git a/modules/widgets/src/loading-widget.tsx b/modules/widgets/src/loading-widget.tsx index eff7c084e2d..e3cf3162934 100644 --- a/modules/widgets/src/loading-widget.tsx +++ b/modules/widgets/src/loading-widget.tsx @@ -15,6 +15,8 @@ export type LoadingWidgetProps = WidgetProps & { viewId?: string | null; /** Tooltip message when loading */ label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** * Callback when the loading state changes. * Called when layers transition between loading and loaded states. @@ -32,6 +34,7 @@ export class LoadingWidget extends Widget { placement: 'top-left', viewId: null, label: 'Loading layer data', + tooltip: undefined!, onLoadingChange: () => {} }; @@ -57,6 +60,7 @@ export class LoadingWidget extends Widget { ), diff --git a/modules/widgets/src/reset-view-widget.tsx b/modules/widgets/src/reset-view-widget.tsx index b43f2251f04..7f63c26fe12 100644 --- a/modules/widgets/src/reset-view-widget.tsx +++ b/modules/widgets/src/reset-view-widget.tsx @@ -17,6 +17,8 @@ export type ResetViewWidgetProps = WidgetProp placement?: WidgetPlacement; /** Tooltip message */ label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** The initial view state to reset the view to. Defaults to deck.props.initialViewState */ initialViewState?: ViewStateMap; /** View to interact with. Required when using multiple views. */ @@ -44,6 +46,7 @@ export class ResetViewWidget extends Widget< id: 'reset-view', placement: 'top-left', label: 'Reset View', + tooltip: undefined!, initialViewState: undefined!, viewId: null, onReset: () => {} @@ -68,6 +71,7 @@ export class ResetViewWidget extends Widget< , rootElement diff --git a/modules/widgets/src/screenshot-widget.tsx b/modules/widgets/src/screenshot-widget.tsx index 3161fb93670..648d2d160f4 100644 --- a/modules/widgets/src/screenshot-widget.tsx +++ b/modules/widgets/src/screenshot-widget.tsx @@ -16,6 +16,8 @@ export type ScreenshotWidgetProps = WidgetProps & { viewId?: string | null; /** Tooltip message */ label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** Filename to save to */ filename?: string; /** Image format */ @@ -35,6 +37,7 @@ export class ScreenshotWidget extends Widget { placement: 'top-left', viewId: null, label: 'Screenshot', + tooltip: undefined!, filename: 'screenshot.png', imageFormat: 'image/png', onCapture: undefined! @@ -59,6 +62,7 @@ export class ScreenshotWidget extends Widget { , rootElement diff --git a/modules/widgets/src/selector-widget.tsx b/modules/widgets/src/selector-widget.tsx index 6045785a4f6..c9d09706436 100644 --- a/modules/widgets/src/selector-widget.tsx +++ b/modules/widgets/src/selector-widget.tsx @@ -23,6 +23,8 @@ export type SelectorWidgetProps = WidgetProps & { options: SelectorWidgetOption[]; /** The initial value. Default to the first option. */ initialValue?: ValueT; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** Callback invoked when the value changes */ onChange?: (value: ValueT) => void; }; @@ -43,6 +45,7 @@ export class SelectorWidget extends Widget {} }; @@ -72,6 +75,7 @@ export class SelectorWidget extends Widget {this.isOpen && ( diff --git a/modules/widgets/src/stylesheet.css b/modules/widgets/src/stylesheet.css index 72b3ad7865b..835619146a9 100644 --- a/modules/widgets/src/stylesheet.css +++ b/modules/widgets/src/stylesheet.css @@ -59,25 +59,25 @@ /* Border radius adjustments for grouped buttons */ .deck-widget-button-group.vertical > *:not(:last-child), -.deck-widget-button-group.vertical > *:not(:last-child) > button { +.deck-widget-button-group.vertical > *:not(:last-child) button { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } .deck-widget-button-group.vertical > *:not(:first-child), -.deck-widget-button-group.vertical > *:not(:first-child) > button { +.deck-widget-button-group.vertical > *:not(:first-child) button { border-top-left-radius: 0; border-top-right-radius: 0; } .deck-widget-button-group.horizontal > *:not(:last-child), -.deck-widget-button-group.horizontal > *:not(:last-child) > button { +.deck-widget-button-group.horizontal > *:not(:last-child) button { border-top-right-radius: 0; border-bottom-right-radius: 0; } .deck-widget-button-group.horizontal > *:not(:first-child), -.deck-widget-button-group.horizontal > *:not(:first-child) > button { +.deck-widget-button-group.horizontal > *:not(:first-child) button { border-top-left-radius: 0; border-bottom-left-radius: 0; } @@ -211,10 +211,10 @@ right: 0; pointer-events: all; } -.deck-widget.deck-widget-timeline[data-placement^=top-] { +.deck-widget.deck-widget-timeline[data-placement^='top-'] { top: 0; } -.deck-widget.deck-widget-timeline[data-placement^=bottom-] { +.deck-widget.deck-widget-timeline[data-placement^='bottom-'] { bottom: 0; } .deck-widget.deck-widget-timeline .deck-widget-button-group { @@ -315,7 +315,8 @@ border-radius: calc(var(--button-corner-radius, 8px) - 1px); border-top-right-radius: 0; border-bottom-right-radius: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + sans-serif; font-size: 13px; } @@ -339,7 +340,8 @@ border-radius: var(--button-corner-radius, 8px); box-shadow: var(--button-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25)); color: rgb(220, 80, 80); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + sans-serif; font-size: 12px; text-align: center; } @@ -405,7 +407,8 @@ border: var(--menu-border, unset); border-radius: var(--button-corner-radius, 8px); box-shadow: var(--menu-shadow, 0px 0px 8px 0px rgba(0, 0, 0, 0.25)); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + sans-serif; font-size: 13px; } @@ -637,20 +640,20 @@ top: var(--range-step-button-size, 24px); height: calc(100% - var(--range-step-button-size, 24px) * 2); } -.deck-widget-scrollbar[data-placement$=-left] .deck-widget-range--vertical { +.deck-widget-scrollbar[data-placement$='-left'] .deck-widget-range--vertical { left: 0; } -.deck-widget-scrollbar[data-placement$=-right] .deck-widget-range--vertical { +.deck-widget-scrollbar[data-placement$='-right'] .deck-widget-range--vertical { right: 0; } .deck-widget-scrollbar .deck-widget-range--horizontal { left: var(--range-step-button-size, 24px); width: calc(100% - var(--range-step-button-size, 24px) * 2); } -.deck-widget-scrollbar[data-placement^=top-] .deck-widget-range--horizontal { +.deck-widget-scrollbar[data-placement^='top-'] .deck-widget-range--horizontal { top: 0; } -.deck-widget-scrollbar[data-placement^=bottom-] .deck-widget-range--horizontal { +.deck-widget-scrollbar[data-placement^='bottom-'] .deck-widget-range--horizontal { bottom: 0; } @@ -697,3 +700,24 @@ margin-top: -1px; cursor: row-resize; } + +/* Tooltip */ +.deck-widget-tooltip-trigger { + display: contents; +} + +.deck-widget-tooltip { + z-index: var(--tooltip-z-index, 1000); + pointer-events: none; + max-width: var(--tooltip-max-width, 240px); + padding: 4px 8px; + 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-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + line-height: 1.4; + white-space: nowrap; +} diff --git a/modules/widgets/src/theme-widget.tsx b/modules/widgets/src/theme-widget.tsx index da0dc6c8760..1dc18e8fb74 100644 --- a/modules/widgets/src/theme-widget.tsx +++ b/modules/widgets/src/theme-widget.tsx @@ -17,10 +17,14 @@ export type ThemeWidgetProps = WidgetProps & { viewId?: string | null; /** Tooltip message when dark mode is selected. */ lightModeLabel?: string; + /** Custom tooltip content when dark mode is selected. Overrides lightModeLabel for tooltip display. */ + lightModeTooltip?: string | HTMLElement | false; /** Styles for light mode theme */ lightModeTheme?: DeckWidgetTheme; /** Tooltip message when light mode is selected. */ darkModeLabel?: string; + /** Custom tooltip content when light mode is selected. Overrides darkModeLabel for tooltip display. */ + darkModeTooltip?: string | HTMLElement | false; /** Styles for dark mode theme */ darkModeTheme?: DeckWidgetTheme; /** Initial theme mode for uncontrolled usage. 'auto' reads the browser default setting */ @@ -45,8 +49,10 @@ export class ThemeWidget extends Widget { placement: 'top-left', viewId: null, lightModeLabel: 'Light Mode', + lightModeTooltip: undefined!, lightModeTheme: LightGlassTheme, darkModeLabel: 'Dark Mode', + darkModeTooltip: undefined!, darkModeTheme: DarkGlassTheme, initialThemeMode: 'auto', themeMode: undefined!, @@ -72,7 +78,7 @@ export class ThemeWidget extends Widget { } onRenderHTML(rootElement: HTMLElement): void { - const {lightModeLabel, darkModeLabel} = this.props; + const {lightModeLabel, darkModeLabel, lightModeTooltip, darkModeTooltip} = this.props; const currentMode = this.getThemeMode(); this._applyTheme(currentMode, rootElement); @@ -80,6 +86,7 @@ export class ThemeWidget extends Widget { , rootElement diff --git a/modules/widgets/src/timeline-widget.tsx b/modules/widgets/src/timeline-widget.tsx index a607db03620..5bcbb09ac40 100644 --- a/modules/widgets/src/timeline-widget.tsx +++ b/modules/widgets/src/timeline-widget.tsx @@ -56,6 +56,10 @@ export type TimelineWidgetProps = WidgetProps & { onPlayingChange?: (playing: boolean) => void; /** Callback to get label from time value */ formatLabel?: (value: number) => string; + /** Custom tooltip content. Overrides label for tooltip display. */ + playTooltip?: string | HTMLElement | false; + /** Custom tooltip content. Overrides label for tooltip display. */ + pauseTooltip?: string | HTMLElement | false; }; export class TimelineWidget extends Widget { @@ -101,7 +105,9 @@ export class TimelineWidget extends Widget { playInterval: 1000, playing: undefined!, onPlayingChange: () => {}, - formatLabel: String + formatLabel: String, + playTooltip: undefined!, + pauseTooltip: undefined! }; constructor(props: TimelineWidgetProps = {}) { @@ -151,7 +157,7 @@ export class TimelineWidget extends Widget { } onRenderHTML(rootElement: HTMLElement): void { - const {timeRange, step, formatLabel} = this.props; + const {timeRange, step, formatLabel, playTooltip, pauseTooltip} = this.props; const isPlaying = this.getPlaying(); const currentTime = this.getTime(); @@ -162,12 +168,14 @@ export class TimelineWidget extends Widget { {isPlaying ? ( ) : ( diff --git a/modules/widgets/src/toggle-widget.tsx b/modules/widgets/src/toggle-widget.tsx index 6fcd34959c6..0daf41e4bfd 100644 --- a/modules/widgets/src/toggle-widget.tsx +++ b/modules/widgets/src/toggle-widget.tsx @@ -20,8 +20,12 @@ export type ToggleWidgetProps = WidgetProps & { onIcon?: string; /** Tooltip label */ label?: string; + /** Custom tooltip content. Overrides label for tooltip display. */ + tooltip?: string | HTMLElement | false; /** Tooltip label when it is checked */ onLabel?: string; + /** Custom tooltip content when checked. Overrides onLabel for tooltip display. */ + onTooltip?: string | HTMLElement | false; /** Icon color, a CSS Color string */ color?: string; /** Icon color when it is checked, a CSS Color string */ @@ -43,7 +47,9 @@ export class ToggleWidget extends Widget { icon: '', onIcon: undefined!, label: '', + tooltip: undefined!, onLabel: undefined!, + onTooltip: undefined!, color: '', onColor: undefined!, onChange: undefined! @@ -71,9 +77,11 @@ export class ToggleWidget extends Widget { style, icon, label, + tooltip, color, onIcon = icon, onLabel = label, + onTooltip = tooltip, onColor = color } = this.props; const on = this.checked; @@ -86,6 +94,7 @@ export class ToggleWidget extends Widget { style={style as JSX.CSSProperties} icon={on ? onIcon : icon} label={on ? onLabel : label} + tooltip={on ? onTooltip : tooltip} color={on ? onColor : color} onClick={this._toggle} />, diff --git a/modules/widgets/src/zoom-widget.tsx b/modules/widgets/src/zoom-widget.tsx index 93faec5d653..1a9143cac62 100644 --- a/modules/widgets/src/zoom-widget.tsx +++ b/modules/widgets/src/zoom-widget.tsx @@ -19,6 +19,10 @@ export type ZoomWidgetProps = WidgetProps & { zoomInLabel?: string; /** Tooltip message on zoom out button. */ zoomOutLabel?: string; + /** Custom tooltip content for zoom in button. Overrides zoomInLabel for tooltip display. */ + zoomInTooltip?: string | HTMLElement | false; + /** Custom tooltip content for zoom out button. Overrides zoomOutLabel for tooltip display. */ + zoomOutTooltip?: string | HTMLElement | false; /** Zoom transition duration in ms. 0 disables the transition */ transitionDuration?: number; /** Zoom level delta applied by each button click. @@ -57,6 +61,8 @@ export class ZoomWidget extends Widget { zoomStep: 1, zoomInLabel: 'Zoom In', zoomOutLabel: 'Zoom Out', + zoomInTooltip: undefined!, + zoomOutTooltip: undefined!, zoomAxis: 'all', viewId: null, onZoom: () => {} @@ -82,11 +88,13 @@ export class ZoomWidget extends Widget { this.handleZoomIn()} label={this.props.zoomInLabel} + tooltip={this.props.zoomInTooltip} className="deck-widget-zoom-in" /> this.handleZoomOut()} label={this.props.zoomOutLabel} + tooltip={this.props.zoomOutTooltip} className="deck-widget-zoom-out" /> diff --git a/test/modules/widgets/compass-widget.spec.ts b/test/modules/widgets/compass-widget.spec.ts index 382bb2cabd5..c238f0a0f23 100644 --- a/test/modules/widgets/compass-widget.spec.ts +++ b/test/modules/widgets/compass-widget.spec.ts @@ -32,7 +32,7 @@ test('CompassWidget', async () => { }); await testInstance.idle(); - testInstance.click('.deck-widget-button > button'); + testInstance.click('.deck-widget-button button'); expect(onReset).toHaveBeenCalledWith({ viewId: 'default-view', bearing: 0, @@ -41,7 +41,7 @@ test('CompassWidget', async () => { expect(viewState.bearing).toBe(0); await testInstance.idle(); - testInstance.click('.deck-widget-button > button'); + testInstance.click('.deck-widget-button button'); expect(onReset).toHaveBeenCalledWith({ viewId: 'default-view', bearing: 0, diff --git a/test/modules/widgets/gimbal-widget.spec.ts b/test/modules/widgets/gimbal-widget.spec.ts index 0eff6af602a..d6045dc16f0 100644 --- a/test/modules/widgets/gimbal-widget.spec.ts +++ b/test/modules/widgets/gimbal-widget.spec.ts @@ -38,7 +38,7 @@ test('GimbalWidget', async () => { expect(outerRing.style.transform).toBe('rotateY(-30deg)'); expect(innerRing.style.transform).toBe('rotateX(45deg)'); - testInstance.click('.deck-widget-button > button'); + testInstance.click('.deck-widget-button button'); expect(onReset).toHaveBeenCalledWith({ viewId: 'orbit', rotationOrbit: 0, diff --git a/test/modules/widgets/selector-widget.spec.ts b/test/modules/widgets/selector-widget.spec.ts index f28b7ecfd3f..ca99c0c3c5b 100644 --- a/test/modules/widgets/selector-widget.spec.ts +++ b/test/modules/widgets/selector-widget.spec.ts @@ -31,7 +31,7 @@ test('SelectorWidget', async () => { await testInstance.idle(); let button = testInstance.findElements('.deck-widget-icon-button')[0] as HTMLButtonElement; - expect(button.title).toBe('Grid'); + expect(button.getAttribute('aria-label')).toBe('Grid'); testInstance.click('.deck-widget-icon-button'); await testInstance.idle(); @@ -45,6 +45,6 @@ test('SelectorWidget', async () => { expect(onChange).toHaveBeenCalledWith('list'); button = testInstance.findElements('.deck-widget-icon-button')[0] as HTMLButtonElement; - expect(button.title).toBe('List'); + expect(button.getAttribute('aria-label')).toBe('List'); expect(testInstance.findElements('.deck-widget-dropdown-item')).toHaveLength(0); }); diff --git a/test/modules/widgets/theme-widget.spec.ts b/test/modules/widgets/theme-widget.spec.ts index db7619d0b9d..32526043448 100644 --- a/test/modules/widgets/theme-widget.spec.ts +++ b/test/modules/widgets/theme-widget.spec.ts @@ -69,7 +69,7 @@ test('ThemeWidget - button label and icon reflect current mode', async () => { let button = testInstance.findElements( '.deck-widget-theme .deck-widget-icon-button' )[0] as HTMLButtonElement; - expect(button.title).toBe('Dark Mode'); + expect(button.getAttribute('aria-label')).toBe('Dark Mode'); expect(button.className).toContain('deck-widget-moon'); testInstance.setProps({ @@ -86,10 +86,89 @@ test('ThemeWidget - button label and icon reflect current mode', async () => { button = testInstance.findElements( '.deck-widget-theme .deck-widget-icon-button' )[0] as HTMLButtonElement; - expect(button.title).toBe('Light Mode'); + expect(button.getAttribute('aria-label')).toBe('Light Mode'); expect(button.className).toContain('deck-widget-sun'); }); +test('ThemeWidget - tooltip matches label mode', async () => { + testInstance = new WidgetTester({ + widgets: [ + new ThemeWidget({ + themeMode: 'dark', + darkModeLabel: 'Dark Mode', + lightModeLabel: 'Light Mode', + darkModeTooltip: 'Currently Dark', + lightModeTooltip: 'Currently Light' + }) + ] + }); + + await testInstance.idle(); + let button = testInstance.findElements( + '.deck-widget-theme .deck-widget-icon-button' + )[0] as HTMLButtonElement; + expect(button.getAttribute('aria-label')).toBe('Dark Mode'); + + button.dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + let tooltip = testInstance.findElements('.deck-widget-tooltip')[0]; + expect(tooltip).toBeTruthy(); + expect(tooltip.textContent).toBe('Currently Dark'); + + button.dispatchEvent(new PointerEvent('pointerleave', {bubbles: true})); + await testInstance.idle(); + + testInstance.setProps({ + widgets: [ + new ThemeWidget({ + themeMode: 'light', + darkModeLabel: 'Dark Mode', + lightModeLabel: 'Light Mode', + darkModeTooltip: 'Currently Dark', + lightModeTooltip: 'Currently Light' + }) + ] + }); + await testInstance.idle(); + + button = testInstance.findElements( + '.deck-widget-theme .deck-widget-icon-button' + )[0] as HTMLButtonElement; + expect(button.getAttribute('aria-label')).toBe('Light Mode'); + + button.dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + tooltip = testInstance.findElements('.deck-widget-tooltip')[0]; + expect(tooltip).toBeTruthy(); + expect(tooltip.textContent).toBe('Currently Light'); +}); + +test('ThemeWidget - tooltip falls back to label when tooltip prop not set', async () => { + testInstance = new WidgetTester({ + widgets: [ + new ThemeWidget({ + themeMode: 'dark', + darkModeLabel: 'Dark Mode', + lightModeLabel: 'Light Mode' + }) + ] + }); + + await testInstance.idle(); + const button = testInstance.findElements( + '.deck-widget-theme .deck-widget-icon-button' + )[0] as HTMLButtonElement; + + button.dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + const tooltip = testInstance.findElements('.deck-widget-tooltip')[0]; + expect(tooltip).toBeTruthy(); + expect(tooltip.textContent).toBe('Dark Mode'); +}); + test('ThemeWidget - applies theme to widget container', async () => { const lightModeTheme = { '--button-background': 'rgb(250, 250, 250)', diff --git a/test/modules/widgets/toggle-widget.spec.ts b/test/modules/widgets/toggle-widget.spec.ts index 4d4e18324db..4539f6e0eb0 100644 --- a/test/modules/widgets/toggle-widget.spec.ts +++ b/test/modules/widgets/toggle-widget.spec.ts @@ -35,13 +35,13 @@ test('ToggleWidget', async () => { const icon = testInstance.findElements('.deck-widget-icon')[0] as HTMLDivElement; expect(root.dataset.checked).toBe('false'); - expect(button.title).toBe('Toggle off'); + expect(button.getAttribute('aria-label')).toBe('Toggle off'); expect(icon.style.backgroundColor).toBe('rgb(255, 0, 0)'); testInstance.click('.deck-widget-icon-button'); expect(onChange).toHaveBeenCalledWith(true); expect(root.dataset.checked).toBe('true'); - expect(button.title).toBe('Toggle on'); + expect(button.getAttribute('aria-label')).toBe('Toggle on'); expect(icon.style.backgroundColor).toBe('rgb(0, 255, 0)'); }); diff --git a/test/modules/widgets/tooltip.spec.ts b/test/modules/widgets/tooltip.spec.ts new file mode 100644 index 00000000000..0d15feb5836 --- /dev/null +++ b/test/modules/widgets/tooltip.spec.ts @@ -0,0 +1,110 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {afterEach, test, expect} from 'vitest'; +import {ZoomWidget} from '@deck.gl/widgets'; +import {WidgetTester} from './common'; + +let testInstance: WidgetTester | undefined; + +afterEach(() => { + testInstance?.destroy(); + testInstance = undefined; +}); + +test('Tooltip - string override', async () => { + testInstance = new WidgetTester({ + widgets: [new ZoomWidget({zoomInTooltip: 'Custom Zoom In', zoomOutTooltip: 'Custom Zoom Out'})] + }); + + await testInstance.idle(); + const buttons = testInstance.findElements('.deck-widget-icon-button') as HTMLButtonElement[]; + expect(buttons[0].getAttribute('aria-label')).toBe('Zoom In'); + + // Trigger tooltip via pointerenter + buttons[0].dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + const tooltip = testInstance.findElements('.deck-widget-tooltip')[0]; + expect(tooltip).toBeTruthy(); + expect(tooltip.textContent).toBe('Custom Zoom In'); +}); + +test('Tooltip - false disables tooltip', async () => { + testInstance = new WidgetTester({ + widgets: [new ZoomWidget({zoomInTooltip: false})] + }); + + await testInstance.idle(); + const button = testInstance.findElements('.deck-widget-icon-button')[0] as HTMLButtonElement; + + // Trigger pointer enter + button.dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + // No tooltip should appear for the zoom-in button + const triggers = testInstance.findElements('.deck-widget-tooltip-trigger'); + const zoomInButton = testInstance.findElements('.deck-widget-zoom-in')[0]; + // The zoom-in button should not be wrapped in a tooltip trigger + expect(zoomInButton.querySelector('.deck-widget-tooltip')).toBeNull(); +}); + +test('Tooltip - HTMLElement content', async () => { + const tip = document.createElement('span'); + tip.innerHTML = 'Zoom ⌘+'; + + testInstance = new WidgetTester({ + widgets: [new ZoomWidget({zoomInTooltip: tip})] + }); + + await testInstance.idle(); + const buttons = testInstance.findElements('.deck-widget-icon-button') as HTMLButtonElement[]; + + // Trigger tooltip + buttons[0].dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + const tooltip = testInstance.findElements('.deck-widget-tooltip')[0]; + expect(tooltip).toBeTruthy(); + expect(tooltip.querySelector('kbd')).toBeTruthy(); + expect(tooltip.textContent).toContain('Zoom'); + expect(tooltip.textContent).toContain('⌘+'); +}); + +test('Tooltip - HTMLElement replacement on content change', async () => { + const tip1 = document.createElement('span'); + tip1.textContent = 'First'; + + testInstance = new WidgetTester({ + widgets: [new ZoomWidget({zoomInTooltip: tip1})] + }); + await testInstance.idle(); + + const buttons = testInstance.findElements('.deck-widget-icon-button') as HTMLButtonElement[]; + buttons[0].dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + let tooltip = testInstance.findElements('.deck-widget-tooltip')[0]; + expect(tooltip.textContent).toContain('First'); + + // Hide tooltip + buttons[0].dispatchEvent(new PointerEvent('pointerleave', {bubbles: true})); + await testInstance.idle(); + + // Change to new element + const tip2 = document.createElement('span'); + tip2.textContent = 'Second'; + testInstance.setProps({ + widgets: [new ZoomWidget({zoomInTooltip: tip2})] + }); + await testInstance.idle(); + + // Re-trigger tooltip + const buttonsAfter = testInstance.findElements('.deck-widget-icon-button') as HTMLButtonElement[]; + buttonsAfter[0].dispatchEvent(new PointerEvent('pointerenter', {bubbles: true})); + await testInstance.idle(); + + tooltip = testInstance.findElements('.deck-widget-tooltip')[0]; + expect(tooltip.textContent).toContain('Second'); +});