diff --git a/examples/reference/indicators/TooltipIcon.ipynb b/examples/reference/indicators/TooltipIcon.ipynb new file mode 100644 index 0000000000..7ee9f9118f --- /dev/null +++ b/examples/reference/indicators/TooltipIcon.ipynb @@ -0,0 +1,102 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "pn.extension()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``TooltipIcon`` is a tooltip indicator providing a Tooltip. The `value` will be the text inside of the tooltip. \n", + "\n", + "#### Parameters:\n", + "\n", + "For details on other options for customizing the component see the [layout](../../how_to/layout/index.md) and [styling](../../how_to/styling/index.md) how-to guides.\n", + "\n", + "* **``value``** (`str` or `bokeh.models.Tooltip`): The text inside the tooltip.\n", + "\n", + "___" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The `TooltipIcon` indicator can be instantiated with either a string:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "pn.widgets.TooltipIcon(value=\"This is a simple tooltip by using a string\")\n", + "\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "or as a `bokeh.models.Tooltip`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from bokeh.models import Tooltip\n", + "\n", + "pn.widgets.TooltipIcon(value=Tooltip(content=\"This is a tooltip using a bokeh.models.Tooltip\", position=\"right\"))\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "tags": [ + "parameters" + ] + }, + "source": [ + "The `TooltipIcon` can be used to add more information to a widgets:\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pn.Row(\n", + " pn.widgets.Button(name=\"Click me!\"), \n", + " pn.widgets.TooltipIcon(value=\"Nothing happens when you click the button!\")\n", + ")" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/panel/models/__init__.py b/panel/models/__init__.py index fef419df40..2966af014e 100644 --- a/panel/models/__init__.py +++ b/panel/models/__init__.py @@ -14,6 +14,6 @@ from .state import State # noqa from .trend import TrendIndicator # noqa from .widgets import ( # noqa - Audio, CustomSelect, FileDownload, Player, Progress, SingleSelect, Video, - VideoStream, + Audio, CustomSelect, FileDownload, Player, Progress, SingleSelect, + TooltipIcon, Video, VideoStream, ) diff --git a/panel/models/index.ts b/panel/models/index.ts index 3aa5aa205f..bc8ba021a1 100644 --- a/panel/models/index.ts +++ b/panel/models/index.ts @@ -29,6 +29,7 @@ export {State} from "./state" export {Tabs} from "./tabs" export {Terminal} from "./terminal" export {TextToSpeech} from "./text_to_speech" +export {TooltipIcon} from "./tooltip_icon" export {TrendIndicator} from "./trend" export {VegaPlot} from "./vega" export {Video} from "./video" diff --git a/panel/models/tooltip_icon.ts b/panel/models/tooltip_icon.ts new file mode 100644 index 0000000000..8525d42bc1 --- /dev/null +++ b/panel/models/tooltip_icon.ts @@ -0,0 +1,123 @@ +import { Control, ControlView } from '@bokehjs/models/widgets/control' +import { Tooltip, TooltipView } from '@bokehjs/models/ui/tooltip' + +import { build_view, IterViews } from '@bokehjs/core/build_views' +import { div, label, StyleSheetLike } from '@bokehjs/core/dom' +import * as p from '@bokehjs/core/properties' + +import inputs_css, * as inputs from '@bokehjs/styles/widgets/inputs.css' +import icons_css from '@bokehjs/styles/icons.css' + +export class TooltipIconView extends ControlView { + declare model: TooltipIcon + + protected description: TooltipView + + protected desc_el: HTMLElement + + public *controls() {} + + override *children(): IterViews { + yield* super.children() + yield this.description + } + + override async lazy_initialize(): Promise { + await super.lazy_initialize() + + const { description } = this.model + this.description = await build_view(description, { parent: this }) + } + + override remove(): void { + this.description?.remove() + super.remove() + } + + override stylesheets(): StyleSheetLike[] { + return [...super.stylesheets(), inputs_css, icons_css] + } + + override render(): void { + super.render() + + const icon_el = div({ class: inputs.icon }) + this.desc_el = div({ class: inputs.description }, icon_el) + + const { desc_el, description } = this + description.model.target = desc_el + + let persistent = false + + const toggle = (visible: boolean) => { + description.model.setv({ + visible, + closable: persistent, + }) + icon_el.classList.toggle(inputs.opaque, visible && persistent) + } + + this.on_change(description.model.properties.visible, () => { + const { visible } = description.model + if (!visible) { + persistent = false + } + toggle(visible) + }) + desc_el.addEventListener('mouseenter', () => { + toggle(true) + }) + desc_el.addEventListener('mouseleave', () => { + if (!persistent) toggle(false) + }) + document.addEventListener('mousedown', (event) => { + const path = event.composedPath() + if (path.includes(description.el)) { + return + } else if (path.includes(desc_el)) { + persistent = !persistent + toggle(persistent) + } else { + persistent = false + toggle(false) + } + }) + window.addEventListener('blur', () => { + persistent = false + toggle(false) + }) + + // Label to get highlight when icon is hovered + this.shadow_el.appendChild(label(this.desc_el)) + } + + change_input(): void {} +} + +export namespace TooltipIcon { + export type Attrs = p.AttrsOf + + export type Props = Control.Props & { + description: p.Property + } +} + +export interface TooltipIcon extends TooltipIcon.Attrs {} + +export class TooltipIcon extends Control { + declare properties: TooltipIcon.Props + declare __view_type__: TooltipIconView + static __module__ = 'panel.models.widgets' + + constructor(attrs?: Partial) { + super(attrs) + } + + static { + this.prototype.default_view = TooltipIconView + + this.define(({ Ref }) => ({ + description: [Ref(Tooltip), new Tooltip()], + })) + } +} diff --git a/panel/models/widgets.py b/panel/models/widgets.py index ba040c27f0..04988c3346 100644 --- a/panel/models/widgets.py +++ b/panel/models/widgets.py @@ -6,6 +6,7 @@ Any, Bool, Either, Enum, Float, Instance, Int, List, Nullable, Override, String, Tuple, ) +from bokeh.models.ui import Tooltip from bokeh.models.ui.icons import Icon from bokeh.models.widgets import InputWidget, Select, Widget @@ -186,3 +187,11 @@ class CustomSelect(Select): """) size = Int(default=1) + + +class TooltipIcon(Widget): + description = Instance( + Tooltip, + default=Tooltip(content="Help text", position="right"), + help="""The tooltip held by the icon""" + ) diff --git a/panel/tests/io/test_resources.py b/panel/tests/io/test_resources.py index c5ead54601..cefd8db279 100644 --- a/panel/tests/io/test_resources.py +++ b/panel/tests/io/test_resources.py @@ -38,7 +38,7 @@ def test_resolve_custom_path_abs_input_relative_to(): assert str(resolve_custom_path(Button, (PANEL_DIR / 'widgets' / 'button.py'), relative=True)) == 'button.py' def test_resources_cdn(): - resources = Resources(mode='cdn') + resources = Resources(mode='cdn', minified=True) assert resources.js_raw == ['Bokeh.set_log_level("info");'] assert resources.js_files == [ f'https://cdn.bokeh.org/bokeh/{bk_prefix}/bokeh-{BOKEH_VERSION}.min.js', @@ -49,7 +49,7 @@ def test_resources_cdn(): ] def test_resources_server_absolute(): - resources = Resources(mode='server', absolute=True) + resources = Resources(mode='server', absolute=True, minified=True) assert resources.js_raw == ['Bokeh.set_log_level("info");'] assert resources.js_files == [ 'http://localhost:5006/static/js/bokeh.min.js', @@ -60,7 +60,7 @@ def test_resources_server_absolute(): ] def test_resources_server(): - resources = Resources(mode='server') + resources = Resources(mode='server', minified=True) assert resources.js_raw == ['Bokeh.set_log_level("info");'] assert resources.js_files == [ 'static/js/bokeh.min.js', diff --git a/panel/tests/ui/widgets/test_indicators.py b/panel/tests/ui/widgets/test_indicators.py new file mode 100644 index 0000000000..29d393a70b --- /dev/null +++ b/panel/tests/ui/widgets/test_indicators.py @@ -0,0 +1,59 @@ +import time + +import pytest + +pytestmark = pytest.mark.ui + +from bokeh.models import Tooltip + +from panel.io.server import serve +from panel.widgets import TooltipIcon + +try: + from playwright.sync_api import expect +except ImportError: + pytestmark = pytest.mark.skip("playwright not available") + + +@pytest.mark.parametrize( + "value", ["Test", Tooltip(content="Test", position="right")], ids=["str", "Tooltip"] +) +def test_plaintext_tooltip(page, port, value): + tooltip_icon = TooltipIcon(value="Test") + + serve(tooltip_icon, port=port, threaded=True, show=False) + + time.sleep(0.2) + + page.goto(f"http://localhost:{port}") + + icon = page.locator(".bk-icon") + expect(icon).to_have_count(1) + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(0) + + # Hovering over the icon should show the tooltip + page.hover(".bk-icon") + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(1) + assert tooltip.locator("div").first.text_content().strip() == "Test" + + # Removing hover should hide the tooltip + page.hover("body") + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(0) + + # Clicking the icon should show the tooltip + page.click(".bk-icon") + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(1) + + # Removing the hover should keep the tooltip + page.hover("body") + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(1) + + # Clicking should remove the tooltip + page.click("body") + tooltip = page.locator(".bk-Tooltip") + expect(tooltip).to_have_count(0) diff --git a/panel/widgets/__init__.py b/panel/widgets/__init__.py index e03c97e919..ff0a268cec 100644 --- a/panel/widgets/__init__.py +++ b/panel/widgets/__init__.py @@ -39,7 +39,7 @@ from .file_selector import FileSelector # noqa from .indicators import ( # noqa BooleanStatus, Dial, Gauge, LinearGauge, LoadingSpinner, Number, Progress, - Tqdm, Trend, + TooltipIcon, Tqdm, Trend, ) from .input import ( # noqa ArrayInput, Checkbox, ColorPicker, DatePicker, DatetimeInput, @@ -125,6 +125,7 @@ "SpeechToText", "Spinner", "StaticText", + "Switch", "Tabulator", "Terminal", "TextAreaInput", @@ -133,6 +134,7 @@ "TextToSpeech", "Toggle", "ToggleGroup", + "TooltipIcon", "Tqdm", "Trend", "Utterance", diff --git a/panel/widgets/base.py b/panel/widgets/base.py index e4b656b2bf..dc47943908 100644 --- a/panel/widgets/base.py +++ b/panel/widgets/base.py @@ -111,15 +111,19 @@ def _process_param_change(self, params: Dict[str, Any]) -> Dict[str, Any]: params['stylesheets'] = [ ImportedStyleSheet(url=ss) for ss in css ] + params['stylesheets'] - if 'description' in params: - from ..pane.markup import Markdown - parser = Markdown._get_parser('markdown-it', ()) - html = parser.render(params['description']) - params['description'] = Tooltip( - content=HTML(html), position='right', - stylesheets=[':host { white-space: initial; max-width: 300px; }'], - syncable=False - ) + if "description" in params: + description = params["description"] + if isinstance(description, str): + from ..pane.markup import Markdown + parser = Markdown._get_parser('markdown-it', ()) + html = parser.render(description) + params['description'] = Tooltip( + content=HTML(html), position='right', + stylesheets=[':host { white-space: initial; max-width: 300px; }'], + syncable=False + ) + elif isinstance(description, Tooltip): + description.syncable = False return params def _get_model( diff --git a/panel/widgets/indicators.py b/panel/widgets/indicators.py index 33c6598e51..a2b55e143e 100644 --- a/panel/widgets/indicators.py +++ b/panel/widgets/indicators.py @@ -29,14 +29,16 @@ import numpy as np import param -from bokeh.models import ColumnDataSource, FixedTicker +from bokeh.models import ColumnDataSource, FixedTicker, Tooltip from bokeh.plotting import figure from tqdm.asyncio import tqdm as _tqdm +from .._param import Align from ..io.resources import CDN_DIST from ..layout import Column, Panel, Row from ..models import ( - HTML, Progress as _BkProgress, TrendIndicator as _BkTrendIndicator, + HTML, Progress as _BkProgress, TooltipIcon as _BkTooltipIcon, + TrendIndicator as _BkTrendIndicator, ) from ..pane.markup import Str from ..reactive import SyncableData @@ -1307,6 +1309,22 @@ def reset(self): self.value = self.param.value.default self.text = self.param.text.default + +class TooltipIcon(Widget): + + value = param.ClassSelector(default="Description", class_=(str, Tooltip), doc=""" + The description in the tooltip.""") + + align = Align(default='center', doc=""" + Whether the object should be aligned with the start, end or + center of its container. If set as a tuple it will declare + (vertical, horizontal) alignment.""") + + _widget_type = _BkTooltipIcon + + _rename: ClassVar[Mapping[str, str | None]] = {'name': None, 'value': 'description'} + + __all__ = [ "BooleanIndicator", "BooleanStatus", @@ -1317,6 +1335,7 @@ def reset(self): "Number", "Progress", "String", + "TooltipIcon", "Tqdm", "Trend", "ValueIndicator",