Skip to content

Commit

Permalink
Support using an SVG for ToggleIcon (#6127)
Browse files Browse the repository at this point in the history
  • Loading branch information
ahuang11 authored and philippjfr committed Jan 17, 2024
1 parent 0a7e278 commit fc680f9
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 15 deletions.
25 changes: 24 additions & 1 deletion examples/reference/widgets/ToggleIcon.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"##### Core\n",
"\n",
"* **`active_icon`** (str): The name of the icon to display when toggled from [tabler-icons.io](https://tabler-icons.io)/\n",
"* **`icon`** (str): The name of the icon to display from [tabler-icons.io](https://tabler-icons.io)/\n",
"* **`icon`** (str): The name of the icon to display from [tabler-icons.io](https://tabler-icons.io)/ or an SVG.\n",
"* **`value`** (boolean): Whether the icon is toggled on or off\n",
"\n",
"##### Display\n",
Expand Down Expand Up @@ -113,6 +113,29 @@
"pn.widgets.ToggleIcon(icon=\"thumb-down\", active_icon=\"thumb-up\", size='3em')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"You can also use SVGs for icons."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"SVG = \"\"\"\n",
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon icon-tabler icon-tabler-ad-off\" 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=\"M9 5h10a2 2 0 0 1 2 2v10m-2 2h-14a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2\" /><path d=\"M7 15v-4a2 2 0 0 1 2 -2m2 2v4\" /><path d=\"M7 13h4\" /><path d=\"M17 9v4\" /><path d=\"M16.115 12.131c.33 .149 .595 .412 .747 .74\" /><path d=\"M3 3l18 18\" /></svg>\n",
"\"\"\"\n",
"ACTIVE_SVG = \"\"\"\n",
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"icon icon-tabler icon-tabler-ad-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=\"M19 4h-14a3 3 0 0 0 -3 3v10a3 3 0 0 0 3 3h14a3 3 0 0 0 3 -3v-10a3 3 0 0 0 -3 -3zm-10 4a3 3 0 0 1 2.995 2.824l.005 .176v4a1 1 0 0 1 -1.993 .117l-.007 -.117v-1h-2v1a1 1 0 0 1 -1.993 .117l-.007 -.117v-4a3 3 0 0 1 3 -3zm0 2a1 1 0 0 0 -.993 .883l-.007 .117v1h2v-1a1 1 0 0 0 -1 -1zm8 -2a1 1 0 0 1 .993 .883l.007 .117v6a1 1 0 0 1 -.883 .993l-.117 .007h-1.5a2.5 2.5 0 1 1 .326 -4.979l.174 .029v-2.05a1 1 0 0 1 .883 -.993l.117 -.007zm-1.41 5.008l-.09 -.008a.5 .5 0 0 0 -.09 .992l.09 .008h.5v-.5l-.008 -.09a.5 .5 0 0 0 -.318 -.379l-.084 -.023z\" stroke-width=\"0\" fill=\"currentColor\" /></svg>\n",
"\"\"\"\n",
"\n",
"pn.widgets.ToggleIcon(icon=SVG, active_icon=ACTIVE_SVG, size='3em')"
]
},
{
"cell_type": "markdown",
"metadata": {},
Expand Down
2 changes: 1 addition & 1 deletion panel/models/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class ToggleIcon(Widget):
The name of the icon to display when toggled.""")

icon = String(default="heart", help="""
The name of the icon to display.""")
The name of the icon or SVG to display.""")

size = String(default="1em", help="""
The size of the icon as a valid CSS font-size.""")
Expand Down
53 changes: 42 additions & 11 deletions panel/models/icon.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { TablerIcon, TablerIconView } from "@bokehjs/models/ui/icons/tabler_icon";
import { SVGIcon, SVGIconView } from "@bokehjs/models/ui/icons/svg_icon";
import { Control, ControlView } from '@bokehjs/models/widgets/control';
import type { IterViews } from '@bokehjs/core/build_views';
import * as p from "@bokehjs/core/properties";
import { build_view } from '@bokehjs/core/build_views';


export class ToggleIconView extends ControlView {
model: ToggleIcon;
icon_view: TablerIconView;
icon_view: TablerIconView | SVGIconView;
was_svg_icon: boolean

public *controls() { }

Expand All @@ -19,18 +20,19 @@ export class ToggleIconView extends ControlView {
override async lazy_initialize(): Promise<void> {
await super.lazy_initialize();

const size = this.calculate_size();
const icon_model = new TablerIcon({ icon_name: this.model.icon, size: size });
this.icon_view = await build_view(icon_model, { parent: this });

this.icon_view.el.addEventListener('click', () => this.toggle_value());
this.was_svg_icon = this.is_svg_icon(this.model.icon)
this.icon_view = await this.build_icon_model(this.model.icon, this.was_svg_icon);
}

override *children(): IterViews {
yield* super.children();
yield this.icon_view;
}

is_svg_icon(icon: string): boolean {
return icon.trim().startsWith('<svg');
}

toggle_value(): void {
if (this.model.disabled) {
return;
Expand All @@ -48,7 +50,6 @@ export class ToggleIconView extends ControlView {

render(): void {
super.render();

this.icon_view.render();
this.update_icon()
this.update_cursor()
Expand All @@ -59,9 +60,39 @@ export class ToggleIconView extends ControlView {
this.icon_view.el.style.cursor = this.model.disabled ? 'not-allowed' : 'pointer';
}

update_icon(): void {
async build_icon_model(icon: string, is_svg_icon: boolean): Promise<TablerIconView | SVGIconView > {
const size = this.calculate_size();
let icon_model;
if (is_svg_icon) {
icon_model = new SVGIcon({ svg: icon, size: size });
} else {
icon_model = new TablerIcon({ icon_name: icon, size: size });
}
const icon_view = await build_view(icon_model, { parent: this });
icon_view.el.addEventListener('click', () => this.toggle_value());
return icon_view;
}

async update_icon(): Promise<void> {
const icon = this.model.value ? this.get_active_icon() : this.model.icon;
this.icon_view.model.icon_name = icon;
const is_svg_icon = this.is_svg_icon(icon)

if (this.was_svg_icon !== is_svg_icon) {
// If the icon type has changed, we need to rebuild the icon view
// and invalidate the old one.
const icon_view = await this.build_icon_model(icon, is_svg_icon);
icon_view.render();
this.icon_view.remove();
this.icon_view = icon_view;
this.was_svg_icon = is_svg_icon;
this.update_cursor();
this.shadow_el.appendChild(this.icon_view.el);
}
else if (is_svg_icon) {
(this.icon_view as SVGIconView).model.svg = icon;
} else {
(this.icon_view as TablerIconView).model.icon_name = icon;
}
this.icon_view.el.style.lineHeight = '0';
}

Expand Down Expand Up @@ -106,7 +137,7 @@ export class ToggleIcon extends Control {
this.define<ToggleIcon.Props>(({ Boolean, Nullable, String }) => ({
active_icon: [String, ""],
icon: [String, "heart"],
size: [Nullable(String), null ],
size: [Nullable(String), null],
value: [Boolean, false],
}));
}
Expand Down
73 changes: 73 additions & 0 deletions panel/tests/ui/widgets/test_icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

pytestmark = pytest.mark.ui

SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-ad-off" 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="M9 5h10a2 2 0 0 1 2 2v10m-2 2h-14a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2" /><path d="M7 15v-4a2 2 0 0 1 2 -2m2 2v4" /><path d="M7 13h4" /><path d="M17 9v4" /><path d="M16.115 12.131c.33 .149 .595 .412 .747 .74" /><path d="M3 3l18 18" /></svg>
""" # noqa: E501
ACTIVE_SVG = """
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-ad-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="M19 4h-14a3 3 0 0 0 -3 3v10a3 3 0 0 0 3 3h14a3 3 0 0 0 3 -3v-10a3 3 0 0 0 -3 -3zm-10 4a3 3 0 0 1 2.995 2.824l.005 .176v4a1 1 0 0 1 -1.993 .117l-.007 -.117v-1h-2v1a1 1 0 0 1 -1.993 .117l-.007 -.117v-4a3 3 0 0 1 3 -3zm0 2a1 1 0 0 0 -.993 .883l-.007 .117v1h2v-1a1 1 0 0 0 -1 -1zm8 -2a1 1 0 0 1 .993 .883l.007 .117v6a1 1 0 0 1 -.883 .993l-.117 .007h-1.5a2.5 2.5 0 1 1 .326 -4.979l.174 .029v-2.05a1 1 0 0 1 .883 -.993l.117 -.007zm-1.41 5.008l-.09 -.008a.5 .5 0 0 0 -.09 .992l.09 .008h.5v-.5l-.008 -.09a.5 .5 0 0 0 -.318 -.379l-.084 -.023z" stroke-width="0" fill="currentColor" /></svg>
""" # noqa: E501

def test_toggle_icon_click(page):
icon = ToggleIcon()
Expand Down Expand Up @@ -100,3 +106,70 @@ def cb(event):
icon.value = True
icon.icon = "heart"
assert page.locator('.ti-heart')

# update active icon_name to svg
icon.active_icon = ACTIVE_SVG
assert page.locator('.icon-tabler-ad-filled')


def test_toggle_icon_svg(page):
icon = ToggleIcon(icon=SVG, active_icon=ACTIVE_SVG)
serve_component(page, icon)

# test defaults
assert icon.icon == SVG
assert not icon.value
assert page.locator('.icon-tabler-ad-off')

events = []
def cb(event):
events.append(event)
icon.param.watch(cb, "value")

# test icon click updates value
page.click('.bk-SVGIcon')
wait_until(lambda: len(events) == 1, page)
assert icon.value
assert page.locator('.icon-tabler-ad-filled')

def test_toggle_icon_tabler_to_svg(page):
tabler = "ad-off"

icon = ToggleIcon(icon=tabler, active_icon=ACTIVE_SVG)
serve_component(page, icon)

# test defaults
assert icon.icon == tabler
assert not icon.value
assert page.locator('.icon-tabler-ad-off')

events = []
def cb(event):
events.append(event)
icon.param.watch(cb, "value")

# test icon click updates value
page.click('.bk-TablerIcon')
wait_until(lambda: len(events) == 1, page)
assert icon.value
assert page.locator('.icon-tabler-ad-filled')

def test_toggle_icon_svg_to_tabler(page):
icon = ToggleIcon(icon=SVG, active_icon="ad-filled")
serve_component(page, icon)

# test defaults
assert icon.icon == SVG
assert not icon.value
assert page.locator('.icon-tabler-ad-off')

events = []
def cb(event):
events.append(event)
icon.param.watch(cb, "value")

# test icon click updates value
page.click('.bk-SVGIcon')
wait_until(lambda: len(events) == 1, page)
assert icon.value
assert page.locator('.icon-tabler-ad-filled')
4 changes: 4 additions & 0 deletions panel/tests/widgets/test_icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ def test_custom_values(self):
def test_empty_icon(self):
with pytest.raises(ValueError, match="The icon parameter must not "):
ToggleIcon(icon="")

def test_icon_svg_empty_active_icon(self):
with pytest.raises(ValueError, match="The active_icon parameter must not "):
ToggleIcon(icon="<svg></svg>")
11 changes: 9 additions & 2 deletions panel/widgets/icon.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ class ToggleIcon(Widget):

active_icon = param.String(default='', doc="""
The name of the icon to display when toggled from
tabler-icons.io](https://tabler-icons.io)/""")
tabler-icons.io](https://tabler-icons.io)/ or an SVG.""")

icon = param.String(default='heart', doc="""
The name of the icon to display from
[tabler-icons.io](https://tabler-icons.io)/""")
[tabler-icons.io](https://tabler-icons.io)/ or an SVG.""")

size = param.String(default=None, doc="""
An explicit size specified as a CSS font-size, e.g. '1.5em' or '20px'.""")
Expand All @@ -33,5 +33,12 @@ class ToggleIcon(Widget):

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

@param.depends("icon", "active_icon", watch=True, on_init=True)
def _update_icon(self):
if not self.icon:
raise ValueError('The icon parameter must not be empty.')

icon_is_svg = self.icon.startswith('<svg')
if icon_is_svg and not self.active_icon:
raise ValueError('The active_icon parameter must not be empty if icon is an SVG.')

0 comments on commit fc680f9

Please sign in to comment.