Build your own custom components with ReactiveHTML

Initially, I was hesitant to try ReactiveHTML but now that I’ve learned it I feel like it’s so powerful–I can build so many custom components easily.

As a simple example, I’ve always wanted a slimmer version of pn.Card:

TLDR Full Code:

import param
import panel as pn
pn.extension()

class Details(pn.reactive.ReactiveHTML):
    title = param.String()
    contents = param.Parameter()

    _template = """
        <details>
            <summary>${title}</summary>
            ${contents}
        </details>
    """


details = Details(
    title="Hey click me!", contents="Just wanted to show you something cool!"
)
details

Tutorial:

  1. I look up how to add collapsible sections in Markdown

  2. I paste the gist of it into a ReactiveHTML _template

class Details(pn.reactive.ReactiveHTML):

    _template = """
        <details>
            <summary>Click me</summary>
            Contents
        </details>
    """
  1. I replace whatever needs to be changeable with ${parameter_name}:

class Details(pn.reactive.ReactiveHTML):

    _template = """
        <details>
            <summary>${title}</summary>
            ${contents}
        </details>
    """
  1. I add those parameters to the class.
class Details(pn.reactive.ReactiveHTML):

    title = param.String()
    contents = param.Parameter()

    _template = """
        <details>
            <summary>${title}</summary>
            ${contents}
        </details>
    """
  1. Try it out!

  2. And you can update the title/contents dynamically!
    image

4 Likes

Wrapping a div + id around contents allows panel objects to be embedded too:

import param
import panel as pn

pn.extension()


class Details(pn.reactive.ReactiveHTML):
    title = param.String()
    contents = param.ClassSelector(class_=object)

    _template = """
        <details>
            <summary>${title}</summary>
            <div id="inner">${contents}</div>
        </details>
    """

details = Details(
    title="Hey click me!",
    contents="Just wanted to show you something cool!"
)
details

details.contents = pn.indicators.LoadingSpinner()

Unfortunately, this collapses the expanded contents.

Unfortunately can’t add open dynamically

It’s actually doable with a kwarg; thanks @Marc!

import param
import panel as pn

pn.extension()


class Details(pn.reactive.ReactiveHTML):
    title = param.String()
    contents = param.ClassSelector(class_=object)
    open = param.Boolean()

    _template = """
        <details id="details" open=${open}>
            <summary>${title}</summary>

            <div id="inner">${contents}</div>

        </details>
    """

details = Details(
    title="Hey click me!",
    contents="Just wanted to show you something cool!",
    open=False
)
pn.Column(
    details, details.param.open
).servable()
3 Likes

Tutorial here:
https://blog.holoviz.org/building_custom_panel_widgets_using_reactivehtml.html

2 Likes

Nice blog post! More of these please!

One suggestion in case you are interested: Explain a bit more in detail what is we are seeing in each example gif. It may not be obvious to someone that does not already know what to expect.

1 Like

The modern details can be found here:



class Details(JSComponent):
    """
    A simple component wrapper for the HTML details element.
    
    This component provides an expandable details/summary element that can be
    used to hide/show content. It's useful for creating collapsible sections
    within a panel application.
    """
    
    title = param.String(default="Details", doc="""
        The text to display in the summary element (the clickable header).""")  
        
    object = param.String(default="", doc="""
        The HTML content to display within the details element.""")  
    
    collapsed = param.Boolean(default=True, doc="""
        Whether the details element is expanded by default.""")  

    _esm = """
    export function render({ model }) {
        const details = document.createElement("details");
        const summary = document.createElement("summary");
        
        summary.innerHTML = model.title;
        details.innerHTML = model.object;
        details.open = !model.collapsed;
        details.insertBefore(summary, details.firstChild);
        
        details.addEventListener("toggle", () => {
            model.collapsed = !details.open;
        });
        
        model.on("title", () => {
            summary.innerHTML = model.title;
        });
        
        model.on("object", () => {
            const currentSummary = details.querySelector("summary");
            details.innerHTML = model.object;
            details.insertBefore(currentSummary, details.firstChild);
        });
        
        model.on("collapsed", () => {
            details.open = !model.collapsed;
        });
        
        return details;
    }
    """

This one supports arbitrary Panel objects:

import param
import panel as pn
from panel.custom import Child, JSComponent

class Details(JSComponent):
    """
    A component wrapper for the HTML details element with proper content rendering.
    
    This component provides an expandable details/summary element that can be
    used to hide/show content. It supports any Panel component as content,
    including Markdown panes for rendering markdown content.
    """
    
    title = param.String(default="Details", doc="""
        The text to display in the summary element (the clickable header).""")
        
    object = Child(doc="""
        The content to display within the details element. Can be any Panel component,
        including a Markdown pane for rendering markdown content.""")
    
    collapsed = param.Boolean(default=True, doc="""
        Whether the details element is collapsed by default.""")
    
    _esm = """
    export function render({ model, view }) {
        // Create container elements
        const details = document.createElement('details');
        const summary = document.createElement('summary');
        const contentContainer = document.createElement('div');
        
        summary.innerHTML = model.title;
        summary.style.cursor = 'pointer';
        
        details.appendChild(summary);
        details.appendChild(contentContainer);
        details.open = !model.collapsed;
        
        contentContainer.className = 'details-content';
        
        details.addEventListener('toggle', () => {
            model.send_msg({ collapsed: !details.open });
            model.collapsed = !details.open;
        });
        
        model.on('title', () => {
            summary.innerHTML = model.title;
        });
        
        model.on('msg:custom', (event) => {
            if (event.type === 'update_collapsed') {
                details.open = !event.collapsed;
            }
        });
        
        contentContainer.appendChild(model.get_child('object'));
        
        return details;
    }
    """

    @param.depends("collapsed", watch=True)
    def _send_collapsed_update(self):
        """Send message to JS when collapsed state changes in Python"""
        self._send_msg({"type": "update_collapsed", "collapsed": self.collapsed})

    def _handle_msg(self, msg):
        """Handle messages from JS"""
        if 'collapsed' in msg:
            collapsed = msg['collapsed']
            with param.discard_events(self):
                self.collapsed = collapsed


Details(object=pn.pane.Markdown("Check this out\n```python\nprint('Hello, World!')\n```"), collapsed=False, margin=(10, 15))

Could probably write a blog post about this :slight_smile:

Image

2 Likes