Search code examples
pythonjupyter-notebookvuetify.jsipywidgetsipyvuetify

Jupyter Notebook Custom Dropdown Widget


it's been hours that I try to get a customized dropdown in Jupyter Notebook using widgets.

My goal is to have a dropdown that lists color names and has a little square/round/whatever showing the color.

I understand that HTML alone doesn't allow for a select option to be something else than text. I tried a bit ipywidgets to warm up and test the form logic, then I moved to ipyvuetify (1.10.0 then 3.0.0.a2) to hopefully be able to customize my dropdown. I tried all that came through my mind using the v.Select options with v_slots and children, and finally and created a v.VuetifyTemplate to be able to be closer to Vuetify's documentation.

Here is what I have now:

import ipyvuetify as v
import traitlets
from typing import Annotated
import enum

class ColorSelector(v.VuetifyTemplate):
    items = traitlets.List(traitlets.Dict(traitlets.Unicode()), default_value=[{"name": "test", "value": "test"}]).tag(sync=True)
    selected = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
    label = traitlets.Unicode(default_value="Color", allow_none=True).tag(sync=True)
    
    @traitlets.default("template")
    def _template(self):
        return '''
        <template>
            <v-select 
                :items="items" 
                item-title="name" 
                item-value="value" 
                v-model="selected" 
                label="'''+self.label+'''"
                class="mx-3">
                
                <template v-slot:item="{ props, item }">
                  <v-list-item v-bind="props" :key="item.value" @click="item_click(item.value)">
                    <div>
                      <v-icon left small :color="item.value"></v-icon>
                      <span>{{ item.value }}</span>
                    </div>
                  </v-list-item>
                </template>
            </v-select>
        </template>
        '''
    
    def vue_item_click(self, value):
      print(repr(value))
      self.selected = value

# Just a piece of the data for demo (Note: colors are HTML colors, so the #-value is optional for the demo)
class TestColor(str, enum.Enum):
    PALEVIOLETRED: Annotated[str, "#DB7093"] = "PaleVioletRed"
    YELLOWGREEN: Annotated[str, "#9ACD32"] = "YellowGreen"
    LIGHTCORAL: Annotated[str, "#F08080"] = "LightCoral"
    THISTLE: Annotated[str, "#D8BFD8"] = "Thistle"

wid_favorite_color = ColorSelector(
    items = [
            {
                "name": c.name,
                "value": c.value
            }
            for c in TestColor
    ],
    label = "Favorite Color"
)

display(wid_favorite_color)

Sadly, the customization doesn't show up, but also the selection doesn't work properly.

Result showing incorrect selection

The documentation at Vuetify's Select item slot doesn't say anything about having to restore the @click behavior. Following the Example at ipyvuetify for menus, I added the @click callback and a :key attribute, but if I use item.value, the result shown in the dropdown input is [object Object], if I use item.name nothing is displayed. Also, the menu doesn't close when a selection is made.

There is definitely something that I don't get. Help! I just want a dropdown with colors! 😭


Solution

  • I finally made it. First, reloading the notebook (actually restarting VSCode) solved almost half of the issues I had. Probably the change of version for ipyvuetify did not happen properly on the rendering side (notebook client).

    Working Result

    With that, debugging was much more consistent and I could relate back to the documentation and some examples. Well, enough talk, here is the solution:

    import ipyvuetify as v
    import traitlets
    from typing import Annotated
    import enum
    
    class ColorSelector(v.VuetifyTemplate):
        items = traitlets.List(traitlets.Dict(traitlets.Unicode()), default_value=[{"name": "test", "value": "test", "hex": "#FF0000"}]).tag(sync=True)
        selected = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
        label = traitlets.Unicode(default_value="Color", allow_none=True).tag(sync=True)
        
        @traitlets.default("template")
        def _template(self):
            return f'''
            <template>
                <v-select 
                    :items="items" 
                    item-title="value"
                    item-value="name" 
                    v-model="selected" 
                    label="{self.label}"
                    class="mx-3">
                    <template v-slot:selection="{{ item, index }}">
                        <v-icon icon="mdi-square-rounded" :color="item.raw.hex" size="x-large" style="margin-right: 0.5em"></v-icon>
                        <span>{{{{ item.title }}}}<span>
                    </template>
                    <template v-slot:item="{{ props, item }}">
                      <v-list-item v-bind="props">
                        <template v-slot:prepend>
                            <v-icon icon="mdi-square-rounded" :color="item.raw.hex" size="x-large"></v-icon>
                        </template>
                      </v-list-item>
                    </template>
                </v-select>
            </template>
            '''
    
    # Just a piece of the data for demo
    class TestColor(str, enum.Enum):
        INDIANRED: Annotated[str, "#CD5C5C"] = "IndianRed"
        LIGHTCORAL: Annotated[str, "#F08080"] = "LightCoral"
        SALMON: Annotated[str, "#FA8072"] = "Salmon"
        DARKSALMON: Annotated[str, "#E9967A"] = "DarkSalmon"
        LIGHTSALMON: Annotated[str, "#FFA07A"] = "LightSalmon"
        CRIMSON: Annotated[str, "#DC143C"] = "Crimson"
        RED: Annotated[str, "#FF0000"] = "Red"
    
    wid_favorite_color = ColorSelector(
        items = [
                {
                    "name": c.name,
                    "value": c.value,
                    "hex": TestColor.__annotations__[c.name].__metadata__[0]
                }
                for c in TestColor
        ],
        label = "Favorite Color"
    )
    
    display(wid_favorite_color)
    
    • Removed @click, it was indeed not necessary
    • Use item.raw.hex when needed. I finally learned that the item wasn't the JS counter part of the Python object. item.raw is, but the name is mapped to item.title and value to item.value, but this depends on the properies you set for item-title and item-value of the v-select component.
    • Once it was mostly working, I could clean a bit the use of v-list-item using a simple v-icon inside the prepend slot.
    • Used selection slot to display also the color in the input
    • Once the braces for the JS side was okay, doubled them in the template to use f-string and substitute self.label in a more pythonic way.

    Note:

    Of course, if you copy this code, you don't need all the enum and Annotated stuff, with the complicated TestColor.__annotations__[c.name].__metadata__[0]. I need this in my project because the form is linked to data that is validated using Pydantic.