Skip to content

Commit

Permalink
Add toggle icon (#6034)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 authored Dec 15, 2023
1 parent d61713d commit 88b676b
Show file tree
Hide file tree
Showing 15 changed files with 494 additions and 203 deletions.
127 changes: 127 additions & 0 deletions examples/reference/widgets/ToggleIcon.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import panel as pn\n",
"pn.extension()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The ``ToggleIcon`` widget allows toggling a single condition between ``True``/``False`` states. This widget is interchangeable with the ``Checkbox`` widget.\n",
"\n",
"Discover more on using widgets to add interactivity to your applications in the [how-to guides on interactivity](../how_to/interactivity/index.md). Alternatively, learn [how to set up callbacks and (JS-)links between parameters](../../how_to/links/index.md) or [how to use them as part of declarative UIs with Param](../../how_to/param/index.html).\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",
"##### Core\n",
"\n",
"* **``value``** (boolean): Whether the icon is toggled on or off\n",
"* **`icon`** (str): The name of the icon to display from [tabler-icons.io](https://tabler-icons.io)/\n",
"* **`active_icon`** (str): The name of the icon to display when toggled from [tabler-icons.io](https://tabler-icons.io)/\n",
"\n",
"##### Display\n",
"\n",
"* **``disabled``** (boolean): Whether the widget is editable\n",
"* **``name``** (str): The title of the widget\n",
"\n",
"___"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle = pn.widgets.ToggleIcon()\n",
"toggle"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"``Toggle.value`` is either True or False depending on whether the icon is toggled:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle.value"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The `value` can be modified by clicking the icon, or setting it explicitly."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle.value = True"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"By default, when `value` is `True`, the `active_icon` (`heart-filled`) is the filled version of the `icon` (`heart`).\n",
"\n",
"If you'd like this to be changed, manually set the `active_icon`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"toggle = pn.widgets.ToggleIcon(icon=\"thumb-down\", active_icon=\"thumb-up\")\n",
"toggle"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Controls\n",
"\n",
"The `Toggle` widget exposes a number of options which can be changed from both Python and Javascript. Try out the effect of these parameters interactively:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"pn.Row(toggle.controls(jslink=True), toggle).show()"
]
}
],
"metadata": {
"language_info": {
"name": "python",
"pygments_lexer": "ipython3"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
192 changes: 45 additions & 147 deletions panel/chat/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,15 @@
from typing import ClassVar, List

import param
import requests

from ..io.cache import cache
from ..io.resources import CDN_DIST
from ..io.state import state
from ..pane.image import SVG
from ..layout import Column
from ..reactive import ReactiveHTML
from ..widgets.base import CompositeWidget
from ..widgets.icon import ToggleIcon

# if user cannot connect to internet
HEART_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-heart" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
</svg>
""" # noqa: E501

HEART_FILLED_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-heart-filled" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.979 3.074a6 6 0 0 1 4.988 1.425l.037 .033l.034 -.03a6 6 0 0 1 4.733 -1.44l.246 .036a6 6 0 0 1 3.364 10.008l-.18 .185l-.048 .041l-7.45 7.379a1 1 0 0 1 -1.313 .082l-.094 -.082l-7.493 -7.422a6 6 0 0 1 3.176 -10.215z" stroke-width="0" fill="currentColor" />
</svg>
""" # noqa: E501

MISSING_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-help-square" width="15" height="15" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14z"></path>
<path d="M12 16v.01"></path>
<path d="M12 13a2 2 0 0 0 .914 -3.782a1.98 1.98 0 0 0 -2.414 .483"></path>
</svg>
""" # noqa: E501

MISSING_FILLED_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-help-square-filled" width="15" height="15" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19 2a3 3 0 0 1 2.995 2.824l.005 .176v14a3 3 0 0 1 -2.824 2.995l-.176 .005h-14a3 3 0 0 1 -2.995 -2.824l-.005 -.176v-14a3 3 0 0 1 2.824 -2.995l.176 -.005h14zm-7 13a1 1 0 0 0 -.993 .883l-.007 .117l.007 .127a1 1 0 0 0 1.986 0l.007 -.117l-.007 -.127a1 1 0 0 0 -.993 -.883zm1.368 -6.673a2.98 2.98 0 0 0 -3.631 .728a1 1 0 0 0 1.44 1.383l.171 -.18a.98 .98 0 0 1 1.11 -.15a1 1 0 0 1 -.34 1.886l-.232 .012a1 1 0 0 0 .111 1.994a3 3 0 0 0 1.371 -5.673z" stroke-width="0" fill="currentColor"></path>
</svg>
""" # noqa: E501


class ChatReactionIcons(ReactiveHTML):

class ChatReactionIcons(CompositeWidget):
"""
A widget to display reaction icons that can be clicked on.
Expand Down Expand Up @@ -81,127 +51,55 @@ class ChatReactionIcons(ReactiveHTML):

value = param.List(doc="The active reactions.")

_reactions = param.List(
doc="""
The list of reactions, which is the same as the keys of the options dict;
primarily needed as a workaround for quirks of ReactiveHTML."""
)
css_classes = param.List(default=["reaction-icons"], doc="The CSS classes of the widget.")

_svgs = param.List(
doc="""
The list of SVGs corresponding to the active reactions."""
)

_base_url = param.String(
default="https://tabler-icons.io/static/tabler-icons/icons/",
_rendered_icons = param.Dict(
default={},
doc="""
The base URL for the SVGs.""",
The rendered icons mapping reaction to icon.""",
)

_template = """
<div id="reaction-icons" class="reaction-icons">
{% for option in options %}
<span
type="button"
id="reaction-{{ loop.index0 }}"
onclick="${script('toggle_value')}"
style="cursor: pointer; width: ${model.width}px; height: ${model.height}px;"
title="{{ _reactions[loop.index0]|title }}"
>
${_svgs[{{ loop.index0 }}]}
</span>
{% endfor %}
</div>
"""

_scripts = {
"toggle_value": """
svg = event.target.shadowRoot.querySelector("svg");
const reaction = svg.getAttribute("alt");
const icon_name = data.options[reaction];
let src;
if (data.value.includes(reaction)) {
src = `${data._base_url}${icon_name}.svg`;
data.value = data.value.filter(r => r !== reaction);
} else {
src = reaction in data.active_icons
? `${data._base_url}${data.active_icons[reaction]}.svg`
: `${data._base_url}${icon_name}-filled.svg`;
data.value = [...data.value, reaction];
}
event.target.src = src;
"""
}

_stylesheets: ClassVar[List[str]] = [f"{CDN_DIST}css/chat_reaction_icons.css"]

def _get_label(self, active: bool, reaction: str, icon: str):
if active and reaction in self.active_icons:
icon_label = self.active_icons[reaction]
elif active:
icon_label = f"{icon}-filled"
else:
icon_label = icon
return icon_label

@cache
def _fetch_svg(self, icon_label: str):
if icon_label in ("heart", "heart-filled"):
return HEART_FILLED_SVG if "filled" in icon_label else HEART_SVG
elif icon_label in ("help-square", "help-square-filled"):
return MISSING_FILLED_SVG if "filled" in icon_label else MISSING_SVG

src = f"{self._base_url}{icon_label}.svg"
with requests.get(src) as response:
response.raise_for_status()
svg = response.text
return svg

def _stylize_svg(self, svg, reaction):
if b"dark" in state.session_args.get("theme", []):
svg = svg.replace('stroke="currentColor"', 'stroke="white"')
svg = svg.replace('fill="currentColor"', 'fill="white"')
if self.width:
svg = svg.replace('width="24"', f'width="{self.width}px"')
if self.height:
svg = svg.replace('height="24"', f'height="{self.height}px"')
svg = svg.replace("<svg", f'<svg alt="{reaction}"')
return svg

@param.depends(
"value",
"options",
"active_icons",
"width",
"height",
watch=True,
on_init=True,
)
def _update_icons(self):
self._reactions = list(self.options.keys())
svgs = []
for reaction, icon in self.options.items():
active = reaction in self.value
icon_label = self._get_label(active, reaction, icon)
try:
svg = self._fetch_svg(icon_label)
except Exception:
svg = MISSING_FILLED_SVG if active else MISSING_SVG
svg = self._stylize_svg(svg, reaction)
# important not to encode to keep the alt text!
svg_pane = SVG(
svg,
sizing_mode=None,
alt_text=reaction,
encode=False,
_composite_type = Column

def __init__(self, **params):
super().__init__(**params)
self._render_icons()

@param.depends("options", watch=True)
def _render_icons(self):
self._rendered_icons = {}
for option, icon in self.options.items():
active_icon = self.active_icons.get(option, "")
icon = ToggleIcon(
icon=icon,
active_icon=active_icon,
value=option in self.value,
name=option,
margin=0,
)
svgs.append(svg_pane)
self._svgs = svgs
icon.param.watch(self._update_value, "value")
self._rendered_icons[option] = icon
self._composite[:] = list(self._rendered_icons.values())

for reaction in self.value:
if reaction not in self._reactions:
self.value.remove(reaction)
@param.depends("value", watch=True)
def _update_icons(self):
for option, icon in self._rendered_icons.items():
icon.value = option in self.value

@param.depends("active_icons", watch=True)
def _update_active_icons(self):
for option, icon in self._rendered_icons.items():
icon.active_icon = self.active_icons.get(option, "")

def _update_value(self, event):
icon = event.obj.name
value = event.new
if value and icon not in self.value:
self.value.append(icon)
elif not value and icon in self.value:
self.value.remove(icon)


class ChatCopyIcon(ReactiveHTML):
Expand Down
11 changes: 5 additions & 6 deletions panel/chat/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ class ChatMessage(PaneBase):
reactions = param.List(doc="""
Reactions to associate with the message.""")

reaction_icons = param.ClassSelector(class_=(ChatReactionIcons, dict), doc="""
reaction_icons = param.ClassSelector(class_=ChatReactionIcons, doc="""
A mapping of reactions to their reaction icons; if not provided
defaults to `{"favorite": "heart"}`.""",)
defaults to `{"favorite": "heart"}`.""", allow_refs=False)

timestamp = param.Date(doc="""
Timestamp of the message. Defaults to the creation time.""")
Expand Down Expand Up @@ -227,11 +227,10 @@ def __init__(self, object=None, **params):
elif state.browser_info.timezone:
tz = ZoneInfo(state.browser_info.timezone)
params["timestamp"] = datetime.datetime.now(tz=tz)
if params.get("reaction_icons") is None:
params["reaction_icons"] = {"favorite": "heart"}
if isinstance(params["reaction_icons"], dict):
reaction_icons = params.get("reaction_icons", {"favorite": "heart"})
if isinstance(reaction_icons, dict):
params["reaction_icons"] = ChatReactionIcons(
options=params["reaction_icons"], width=15, height=15
options=reaction_icons, width=15, height=15
)
super().__init__(object=object, **params)
self.reaction_icons.link(self, value="reactions", bidirectional=True)
Expand Down
10 changes: 10 additions & 0 deletions panel/dist/css/chat_message.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,13 @@
.avatar.rotating-placeholder {
animation: icon-rotation 1.28s infinite cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

.reaction-icons {
display: flex;
flex-direction: column;
align-items: start;
justify-content: end;
width: fit-content;
margin-block: 0px;
margin-inline: 2px;
}
8 changes: 0 additions & 8 deletions panel/dist/css/chat_reaction_icons.css
Original file line number Diff line number Diff line change
@@ -1,8 +0,0 @@
.reaction-icons {
display: flex;
flex-direction: column;
align-items: start;
justify-content: end;
width: fit-content;
margin-inline: 2px;
}
Empty file added panel/dist/css/icon.css
Empty file.
Loading

0 comments on commit 88b676b

Please sign in to comment.