Search bar for Plotly Legends (No Dash)

Greetings,

I have a Figure object with many traces such that the legend is quite unruly. Is there (or could there eventually be) any way to introduce a search bar for the legends without using Dash? Thanks!

Agree this would be extremely useful! (have not found this functionality to date)

The temporary workaround I use for this w/ a >~1500 item legend involves “serializing the legend with a prefix”, combined with using hover to display that column. It allows you to get to a specific row in the legend easily/quickly/ repeatably

  1. Use value_counts() to build a pandas df w/ the unique legend items, sorted
unq_legend_df = pd.DataFrame(your_df['col_choice'].value_counts()).reset_index().sort_values('col_choice').reset_index(drop=True)
  1. String concatenate the index of the df to the unique list w/ an underscore
unq_legend_df['col_choice_pre'] = unq_legend_df.index.astype(str)+'_'+unq_legend_df['col_choice']
  1. Merge the dataframe back into your primary dataframe
new_df = pd.merge(your_df, unq_legend_df, how='left',on='col_choice').sort_values('col_choice')
  1. set your color value of your plotly graph to
col_choice_pre       
#aka  this column ==== new_df['col_choice_pre']

Do you have an example of what this looks like?

It looks like someone brighter than me could probably code something up fairly easily in BeautifulSoup that adds the right JavaScript functionality for this. I, myself am able to play with the div elements’ attributes and toggle traces on/off. The question would be, what is the easiest way to accomplish the goal i.e. does the search bar itself toggle the traces or does it change what is displayed in the legend and the user toggles normally. This would be a fun little project

See below. Though the example only shows 5 lines, you can imagine with 1500+ lines/ scatter series representing lines, allows you to:

  1. find your point of interest with hover & make note of the “serialized number prefix”
  2. go to the legend and scroll (not the mouse wheel b/c it is too coarse of resolution)
  3. find your point, and isolate it (quickly and repeatably without having to worry about the full label of the series is, and instead just go off the prefix number)
import pandas as pd
import plotly.express as px


#build the dummy data list
import itertools
import numpy as np
val = list(range(11))
x_lst = list(itertools.chain(val,val,val,val,val))
y_lst = list(itertools.chain(val,list(np.array(val)*-1),list(np.array(val)*2),reversed(val), reversed(list(np.array(val)*2))))
col_lst = list(itertools.chain(['aaa']*11,['bbb']*11,['ccc']*11,['ddd']*11,['eee']*11))

your_df = pd.DataFrame({'x_val':x_lst,'y_val':y_lst,'col_choice':col_lst})

#the useful part from above
unq_legend_df = pd.DataFrame(your_df['col_choice'].value_counts()).reset_index().sort_values('col_choice').reset_index(drop=True)
unq_legend_df['col_choice_pre'] = unq_legend_df.index.astype(str)+'_'+unq_legend_df['col_choice']
new_df = pd.merge(your_df, unq_legend_df, how='left',on='col_choice').sort_values('col_choice')

#the figure
fig = px.scatter(new_df, x='x_val',y='y_val',title='Serializing the Legend - Example', color = 'col_choice_pre')
fig.show()

I just had Claude do it for me:

"""
plotly_legend_search
====================

Adds a search box to the legend of a Plotly figure exported to HTML.  No Dash,
no server: the search is a small piece of JavaScript injected through Plotly's
``post_script`` hook, so it ships inside the HTML file you distribute.

Quick start
-----------
    import plotly.graph_objects as go
    from plotly_legend_search import legend_search

    fig = go.Figure(...)
    legend_search(fig)                 # tags the figure in place (also returns it)
    fig.write_html("plot.html")        # search box is baked in
    fig.show()                         # also works with HTML-based renderers

Other entry points
------------------
* ``legend_search_script(**opts)`` -> the raw JS, for
  ``fig.write_html(..., post_script=legend_search_script())`` or ``pio.to_html``.
* ``install(**opts)`` / ``uninstall()`` -> patch *every* ``go.Figure`` so all
  ``to_html`` / ``write_html`` / ``show`` calls get the search box.

What the box does
-----------------
* Typing filters which traces are listed in the legend (traces stay plotted).
  Matching is case-insensitive substring by default; space-separated terms are
  ANDed, ``-term`` excludes, and ``/regex/i`` switches to a regular expression.
* Enter            -> show only the matching traces (others become "legendonly")
* Shift+Enter      -> hide the matching traces
* Esc              -> clear the search
* Buttons (optional) do the same as Enter / Shift+Enter, plus "show all".

Pie / funnelarea traces (whose legend entries are slices, not traces) are left
alone.  Requires plotly.js >= 2.0 (plotly.py >= 5).
"""

from __future__ import annotations

import inspect
import json
import math
from contextlib import contextmanager
from typing import Optional, Sequence, Union

import plotly.graph_objects as go
import plotly.io as pio

__all__ = [
    "legend_search",
    "legend_search_script",
    "LegendSearchFigure",
    "install",
    "uninstall",
]

_SENTINEL_ATTR = "_legend_search_opts"
_EM = "\u2003"  # em space: one em wide, survives SVG whitespace collapsing

_DEFAULTS = dict(
    placeholder="Search traces…",
    width=200,  # px, total width of the search row (input + buttons)
    height=None,  # px, row height; None -> derived from the legend font size
    buttons=True,  # show "only / hide / all" buttons next to the input
    legend="legend",  # which legend hosts the box ("legend", "legend2", ...)
    scope="all",  # "all" -> filter traces in every legend; "legend" -> host legend only
    match_on=("name", "legendgroup", "legendgrouptitle"),
    case_sensitive=False,
    debounce=120,  # ms between the last keystroke and the legend update
    query="",  # optional initial search text
)


# --------------------------------------------------------------------------- #
# JavaScript (runs once per plot, after Plotly.newPlot resolves)
# --------------------------------------------------------------------------- #
_JS_TEMPLATE = r"""
(function () {
  "use strict";
  var OPTS = __OPTS__;
  var gd = document.getElementById("{plot_id}");
  var P = window.Plotly;
  if (!gd || !P || gd.__legendSearch) return;

  var fl = gd._fullLayout || {};
  var LEG = OPTS.legend || "legend";
  var legOpts = fl[LEG];
  if (!fl.showlegend || !legOpts || legOpts.visible === false) return;

  /* ---------------- helpers ---------------- */
  var decoder = document.createElement("textarea");
  function plain(s) {
    if (s == null) return "";
    decoder.innerHTML = String(s).replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
    return decoder.value;
  }
  var cv = document.createElement("canvas").getContext("2d");
  function rgba(color, a) {
    cv.fillStyle = "#000"; cv.fillStyle = color || "#000";
    var c = cv.fillStyle, r, g, b, m;
    if (c.charAt(0) === "#") {
      r = parseInt(c.substr(1, 2), 16); g = parseInt(c.substr(3, 2), 16); b = parseInt(c.substr(5, 2), 16);
    } else if ((m = c.match(/[\d.]+/g))) { r = +m[0]; g = +m[1]; b = +m[2]; }
    return "rgba(" + r + "," + g + "," + b + "," + a + ")";
  }
  function isTransparent(color) {
    cv.fillStyle = "#000"; cv.fillStyle = color || "#000";
    var m = String(cv.fillStyle).match(/rgba\([^)]*,\s*([\d.]+)\)/);
    return !!m && +m[1] === 0;
  }

  /* ---------------- collect searchable traces ---------------- */
  var PER_LABEL = { pie: 1, funnelarea: 1 };
  var items = [], seen = {};
  (gd._fullData || []).forEach(function (fd) {
    var i = fd.index;
    if (i == null || seen[i] || i >= gd.data.length) return;
    if (PER_LABEL[fd.type] || fd.visible === false || !fd.showlegend) return;
    if (OPTS.scope !== "all" && (fd.legend || "legend") !== LEG) return;
    seen[i] = 1;
    var parts = [];
    if (OPTS.match_on.indexOf("name") >= 0) parts.push(plain(fd.name));
    if (OPTS.match_on.indexOf("legendgroup") >= 0) parts.push(plain(fd.legendgroup));
    if (OPTS.match_on.indexOf("legendgrouptitle") >= 0 && fd.legendgrouptitle)
      parts.push(plain(fd.legendgrouptitle.text));
    var text = parts.join("\u0001");
    items.push({ i: i, text: OPTS.case_sensitive ? text : text.toLowerCase(), shown: true });
  });
  if (!items.length) return;
  gd.__legendSearch = true;

  /* ---------------- reserve room in the legend title ---------------- */
  var fontSize = (legOpts.font && legOpts.font.size) || 12;
  var H = OPTS.height || Math.max(20, Math.round(fontSize * 1.85));
  var EM = " ";
  var nEm = Math.ceil(OPTS.width / H);
  var spacer = '<span style="font-size:' + H + 'px">' + new Array(nEm + 1).join(EM) + "</span>";
  var origTitle = (legOpts.title && legOpts.title.text) || "";
  // The Python wrapper writes the spacer into the figure before export, so it
  // is drawn by the very first render. Only the raw-script path needs a relayout.
  var prepared = origTitle.indexOf(EM) >= 0;
  var upd = null;
  if (!prepared) {
    upd = { showlegend: true };
    upd[LEG + ".title.text"] = origTitle ? spacer + "<br>" + origTitle : spacer;
  }
  // Legends with a single entry auto-hide; force it on with the first filter.
  var legendForced = !!upd || (gd.layout && gd.layout.showlegend === true);

  /* ---------------- build the widget ---------------- */
  if (!document.getElementById("pls-style")) {
    var st = document.createElement("style");
    st.id = "pls-style";
    st.textContent = [
      ".pls-box{position:absolute;z-index:3;display:flex;color:var(--pls-fg);align-items:center;gap:3px;box-sizing:border-box;line-height:normal}",
      ".pls-pinned{background:var(--pls-bg);box-shadow:0 0 0 3px var(--pls-bg),0 4px 6px -2px var(--pls-ring)}",
      ".pls-field{position:relative;flex:1 1 auto;min-width:0;height:100%}",
      ".pls-input{width:100%;height:100%;box-sizing:border-box;margin:0;padding:0 .5em 0 1.9em;border:1px solid var(--pls-border);",
      "border-radius:4px;background:var(--pls-bg);color:var(--pls-fg);font:inherit;outline:none;-webkit-appearance:none;appearance:none}",
      ".pls-has-count .pls-input{padding-right:4.2em}",
      ".pls-input::placeholder{color:var(--pls-fg);opacity:.5}",
      ".pls-input:focus{border-color:var(--pls-focus);box-shadow:0 0 0 2px var(--pls-ring)}",
      ".pls-input::-webkit-search-cancel-button{display:none}",
      ".pls-ico{position:absolute;left:.55em;top:50%;width:1em;height:1em;transform:translateY(-50%);opacity:.55;pointer-events:none}",
      ".pls-count{position:absolute;right:.55em;top:50%;transform:translateY(-50%);font-size:.85em;opacity:.6;pointer-events:none;white-space:nowrap}",
      ".pls-btn{flex:0 0 auto;height:100%;padding:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;",
      "border:1px solid var(--pls-border);border-radius:4px;background:var(--pls-bg);color:var(--pls-fg);cursor:pointer;opacity:.8}",
      ".pls-btn:hover{opacity:1;border-color:var(--pls-focus)}",
      ".pls-btn svg{width:58%;height:58%}",
      ".pls-nomatch .pls-input{border-color:#d62728}.pls-nomatch .pls-count{color:#d62728;opacity:1}",
      ".pls-empty .infolayer .traces,.pls-empty .infolayer .scrollbar{visibility:hidden}",
      "@media print{.pls-box{display:none}}"
    ].join("");
    document.head.appendChild(st);
  }

  var fg = (legOpts.font && legOpts.font.color) || (fl.font && fl.font.color) || "#444";
  var bg = legOpts.bgcolor && !isTransparent(legOpts.bgcolor) ? legOpts.bgcolor : fl.paper_bgcolor || "#fff";
  var box = document.createElement("div");
  box.className = "pls-box";
  box.style.cssText =
    "--pls-fg:" + fg + ";--pls-bg:" + bg + ";--pls-border:" + rgba(fg, 0.3) +
    ";--pls-focus:" + rgba(fg, 0.6) + ";--pls-ring:" + rgba(fg, 0.15) +
    ";font-family:" + ((legOpts.font && legOpts.font.family) || "sans-serif").replace(/"/g, "'") +
    ";font-size:" + fontSize + "px;height:" + H + "px;visibility:hidden;";

  var SVG = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"';
  var field = document.createElement("div");
  field.className = "pls-field";
  field.innerHTML = '<svg class="pls-ico" ' + SVG + '><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5L21 21"/></svg>';
  var input = document.createElement("input");
  input.type = "search";
  input.className = "pls-input";
  input.placeholder = OPTS.placeholder;
  input.spellcheck = false;
  input.autocomplete = "off";
  input.setAttribute("aria-label", "Search legend");
  var count = document.createElement("span");
  count.className = "pls-count";
  field.appendChild(input);
  field.appendChild(count);
  box.appendChild(field);

  function button(title, path, fn) {
    var b = document.createElement("button");
    b.type = "button";
    b.className = "pls-btn";
    b.title = title;
    b.setAttribute("aria-label", title);
    b.style.width = H + "px";
    b.innerHTML = "<svg " + SVG + ">" + path + "</svg>";
    b.addEventListener("click", function (e) { e.preventDefault(); fn(); });
    box.appendChild(b);
  }
  var EYE = '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>';
  if (OPTS.buttons) {
    button("Show only matches (Enter)", EYE, function () { isolate(); });
    button("Hide matches (Shift+Enter)", EYE + '<path d="M4 4l16 16"/>', function () { hideMatches(); });
    button("Show all traces", '<path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/>', function () { showAll(); });
  }
  ["mousedown", "pointerdown", "click", "dblclick", "wheel", "keydown"].forEach(function (ev) {
    box.addEventListener(ev, function (e) { e.stopPropagation(); }, { passive: ev === "wheel" });
  });

  if (getComputedStyle(gd).position === "static") gd.style.position = "relative";
  gd.appendChild(box);

  /* ---------------- positioning ---------------- */
  function place() {
    var leg = gd.querySelector("g." + LEG);
    var title = leg && leg.querySelector(".legendtitletext");
    if (!title) return;
    var anchor = null, spans = title.querySelectorAll("tspan");
    for (var k = 0; k < spans.length; k++) {
      if (spans[k].textContent.indexOf(EM) >= 0 && !spans[k].querySelector("tspan")) { anchor = spans[k]; break; }
    }
    var r = (anchor || title).getBoundingClientRect(), g = gd.getBoundingClientRect();
    if (!r.width) return;
    var top = r.top - g.top + (r.height - H) / 2;
    // The title scrolls with long legends: pin the box to the legend's top edge.
    var bg = leg.querySelector("rect.bg");
    var bgr = bg && bg.getBoundingClientRect();
    var pinned = false;
    if (bgr && bgr.height) {
      var minTop = bgr.top - g.top + 2;
      if (top < minTop) { top = minTop; pinned = true; }
    }
    box.classList.toggle("pls-pinned", pinned);
    box.style.left = Math.round(r.left - g.left) + "px";
    box.style.top = Math.round(top) + "px";
    box.style.width = Math.max(H * 3, Math.round(r.width)) + "px";
    box.style.visibility = "visible";
  }
  var raf = 0;
  function schedulePlace() { cancelAnimationFrame(raf); raf = requestAnimationFrame(place); }
  gd.on("plotly_afterplot", schedulePlace);
  window.addEventListener("resize", schedulePlace);
  // legends that overflow scroll their contents; follow the title if it moves
  if (window.MutationObserver) {
    new MutationObserver(schedulePlace).observe(gd, { attributes: true, subtree: true, attributeFilter: ["transform"] });
  }

  /* ---------------- matching ---------------- */
  var matcher = function () { return true; }, query = "";
  function compile(q) {
    query = q;
    if (!q) return function () { return true; };
    var rx = q.match(/^\/(.+)\/([a-z]*)$/);
    if (rx) {
      var flags = rx[2].replace(/[gy]/g, "");
      var re = new RegExp(rx[1], flags);  // may throw -> caller shows error
      return function (t) { return re.test(t); };
    }
    var terms = (OPTS.case_sensitive ? q : q.toLowerCase()).split(/\s+/).filter(Boolean);
    var inc = [], exc = [];
    terms.forEach(function (t) { if (t.length > 1 && t.charAt(0) === "-") exc.push(t.slice(1)); else inc.push(t); });
    return function (t) {
      for (var a = 0; a < inc.length; a++) if (t.indexOf(inc[a]) < 0) return false;
      for (var b = 0; b < exc.length; b++) if (t.indexOf(exc[b]) >= 0) return false;
      return true;
    };
  }

  function apply() {
    var q = input.value.trim();
    try { matcher = compile(q); }
    catch (err) { box.classList.add("pls-nomatch", "pls-has-count"); count.textContent = "bad regex"; return; }
    var n = 0;
    items.forEach(function (it) { it.match = matcher(it.text); if (it.match) n++; });
    count.textContent = q ? n + "/" + items.length : "";
    box.classList.toggle("pls-has-count", !!q);
    var none = !!q && n === 0;
    box.classList.toggle("pls-nomatch", none);
    gd.classList.toggle("pls-empty", none);   // keep the legend (and the box) alive
    if (none) return;
    var idx = [], vals = [];
    items.forEach(function (it) {
      if (it.shown !== it.match) { idx.push(it.i); vals.push(it.match); it.shown = it.match; }
    });
    if (!idx.length) return;
    // scroll a long legend back to the top so the first match is in view
    if (gd.layout[LEG]) delete gd.layout[LEG]._scrollY;
    if (gd._fullLayout[LEG]) delete gd._fullLayout[LEG]._scrollY;
    if (legendForced) P.restyle(gd, { showlegend: vals }, idx);
    else { legendForced = true; P.update(gd, { showlegend: vals }, { showlegend: true }, idx); }
  }

  function setVisible(fn) {
    var idx = [], vals = [];
    items.forEach(function (it) {
      var v = fn(it);
      if (v !== undefined) { idx.push(it.i); vals.push(v); }
    });
    if (idx.length) P.restyle(gd, { visible: vals }, idx);
  }
  function current() { apply(); return query; }
  function isolate() {
    if (!current()) return showAll();
    setVisible(function (it) { return it.match ? true : "legendonly"; });
  }
  function hideMatches() {
    if (!current()) return;
    setVisible(function (it) { return it.match ? "legendonly" : undefined; });
  }
  function showAll() { setVisible(function () { return true; }); }

  /* ---------------- legend double-click (isolate) ---------------- */
  // Plotly's isolate leaves traces with showlegend:false visible, treating them
  // as non-legend decorations. Filtered-out traces are hidden via showlegend, so
  // Plotly would wrongly keep them on. The event fires synchronously just before
  // Plotly's handler runs, so briefly show it their real legend state.
  gd.on("plotly_legenddoubleclick", function () {
    var hidden = {}, any = false;
    items.forEach(function (it) { if (!it.shown) { hidden[it.i] = 1; any = true; } });
    if (!any) return;
    var patched = [];
    (gd._fullData || []).forEach(function (fd) {
      if (hidden[fd.index] && fd.showlegend === false) { fd.showlegend = true; patched.push(fd); }
    });
    // Plotly's restyle rebuilds _fullData from gd.data (where the filter still
    // lives); restoring these objects only matters if nothing was redrawn.
    setTimeout(function () { patched.forEach(function (fd) { fd.showlegend = false; }); }, 0);
  });

  var timer = 0;
  input.addEventListener("input", function () {
    clearTimeout(timer);
    timer = setTimeout(apply, items.length > 300 ? Math.max(OPTS.debounce, 250) : OPTS.debounce);
  });
  input.addEventListener("keydown", function (e) {
    if (e.key === "Enter") { e.preventDefault(); clearTimeout(timer); e.shiftKey ? hideMatches() : isolate(); }
    else if (e.key === "Escape") {
      if (input.value) { input.value = ""; clearTimeout(timer); apply(); } else input.blur();
    }
  });
  input.addEventListener("search", function () { if (!input.value) { clearTimeout(timer); apply(); } });

  /* ---------------- go ---------------- */
  function start() {
    place();
    if (OPTS.query) { input.value = OPTS.query; apply(); }
  }
  if (upd) P.relayout(gd, upd).then(start); else start();
})();
"""


def _normalize_opts(**opts) -> dict:
    unknown = set(opts) - set(_DEFAULTS)
    if unknown:
        raise TypeError(f"Unknown legend_search option(s): {', '.join(sorted(unknown))}")
    out = dict(_DEFAULTS, **opts)
    if isinstance(out["match_on"], str):
        out["match_on"] = [out["match_on"]]
    out["match_on"] = list(out["match_on"])
    bad = set(out["match_on"]) - {"name", "legendgroup", "legendgrouptitle"}
    if bad:
        raise ValueError(f"match_on accepts 'name', 'legendgroup', 'legendgrouptitle'; got {sorted(bad)}")
    if out["scope"] not in ("all", "legend"):
        raise ValueError("scope must be 'all' or 'legend'")
    out["width"] = int(out["width"])
    out["height"] = int(out["height"]) if out["height"] else None
    out["debounce"] = int(out["debounce"])
    return out


def legend_search_script(
    placeholder: str = _DEFAULTS["placeholder"],
    width: int = _DEFAULTS["width"],
    height: Optional[int] = _DEFAULTS["height"],
    buttons: bool = _DEFAULTS["buttons"],
    legend: str = _DEFAULTS["legend"],
    scope: str = _DEFAULTS["scope"],
    match_on: Union[str, Sequence[str]] = _DEFAULTS["match_on"],
    case_sensitive: bool = _DEFAULTS["case_sensitive"],
    debounce: int = _DEFAULTS["debounce"],
    query: str = _DEFAULTS["query"],
) -> str:
    """Return the JavaScript to pass as ``post_script`` to plotly's HTML export."""
    opts = _normalize_opts(
        placeholder=placeholder, width=width, height=height, buttons=buttons,
        legend=legend, scope=scope, match_on=match_on,
        case_sensitive=case_sensitive, debounce=debounce, query=query,
    )
    # "</" would terminate the surrounding <script> tag; "{plot_id}" is reserved.
    blob = json.dumps(opts).replace("</", "<\\/").replace("{plot_id}", "{plot\\u005fid}")
    return _JS_TEMPLATE.replace("__OPTS__", blob)


# --------------------------------------------------------------------------- #
# Figure integration
# --------------------------------------------------------------------------- #
def _merge_post_script(existing, ours: str):
    if not existing:
        return ours
    if isinstance(existing, (list, tuple)):
        return [*existing, ours]
    return [existing, ours]


def _legend_font_size(fig: go.Figure, leg: str) -> float:
    """Best-effort guess of the legend font size plotly.js will use."""
    lay = fig.layout
    tl = lay.template.layout if lay.template is not None else None
    candidates = []
    for src in (lay, tl):
        if src is None:
            continue
        try:
            candidates.append(src[leg].font.size)
        except (KeyError, ValueError, AttributeError):
            pass
    candidates += [lay.font.size, tl.font.size if tl is not None else None]
    return next((c for c in candidates if c), 12)


@contextmanager
def _prepared(fig: go.Figure, opts: dict):
    """
    Temporarily write the spacer into the legend title so plotly.js reserves
    room for the search box in its first render, and yield the matching script.
    The figure is restored afterwards.
    """
    opts = _normalize_opts(**opts)
    leg = opts["legend"]
    height = opts["height"] or max(20, int(_legend_font_size(fig, leg) * 1.85 + 0.5))
    opts["height"] = height
    script = legend_search_script(**opts)
    try:
        title = fig.layout[leg].title
    except (KeyError, ValueError, AttributeError):
        yield script  # unknown legend id: let the JS fallback deal with it
        return
    orig = title.text
    if orig and _EM in orig:  # already prepared
        yield script
        return
    spacer = f'<span style="font-size:{height}px">{_EM * math.ceil(opts["width"] / height)}</span>'
    title.text = f"{spacer}<br>{orig}" if orig else spacer
    try:
        yield script
    finally:
        title.text = orig


def _export(func, fig, args, kwargs, opts):
    """Call a plotly.io export function with our post_script merged in."""
    with _prepared(fig, opts) as script:
        if func is pio.show:
            kwargs = dict(kwargs)
            kwargs["post_script"] = _merge_post_script(kwargs.get("post_script"), script)
            return func(fig, *args, **kwargs)
        # Bind to plotly.io's signature so a positional post_script also merges.
        bound = inspect.signature(func).bind(fig, *args, **kwargs)
        bound.arguments["post_script"] = _merge_post_script(bound.arguments.get("post_script"), script)
        return func(*bound.args, **bound.kwargs)


class LegendSearchFigure(go.Figure):
    """A ``go.Figure`` whose HTML output includes a legend search box."""

    def _ls_opts(self) -> dict:
        return getattr(self, _SENTINEL_ATTR, None) or {}

    def to_html(self, *args, **kwargs):
        return _export(pio.to_html, self, args, kwargs, self._ls_opts())

    def write_html(self, *args, **kwargs):
        return _export(pio.write_html, self, args, kwargs, self._ls_opts())

    def show(self, *args, **kwargs):
        # HTML-based renderers ("notebook", "browser", "iframe", ...) honour
        # post_script; the JupyterLab/VS Code "plotly_mimetype" renderer ignores it.
        return _export(pio.show, self, args, kwargs, self._ls_opts())


def legend_search(fig: go.Figure, **opts) -> LegendSearchFigure:
    """
    Enable the legend search box on ``fig`` (modified in place, also returned).

    After this, ``fig.write_html``, ``fig.to_html`` and ``fig.show`` include the
    search box.  Accepts the same keyword options as ``legend_search_script``:
    placeholder, width, height, buttons, legend, scope, match_on,
    case_sensitive, debounce, query.
    """
    if not isinstance(fig, go.Figure):
        raise TypeError("legend_search expects a plotly.graph_objects.Figure")
    _normalize_opts(**opts)  # validate early
    object.__setattr__(fig, _SENTINEL_ATTR, dict(opts))
    if type(fig) is go.Figure:
        fig.__class__ = LegendSearchFigure
    elif not isinstance(fig, LegendSearchFigure):
        # A user subclass of go.Figure: graft our methods onto a combined class.
        fig.__class__ = type(f"LegendSearch{type(fig).__name__}", (LegendSearchFigure, type(fig)), {})
    return fig


# --------------------------------------------------------------------------- #
# Optional: global switch
# --------------------------------------------------------------------------- #
_ORIGINALS: dict = {}


def install(**opts) -> None:
    """Give every ``go.Figure`` a legend search box in to_html/write_html/show."""
    defaults = _normalize_opts(**opts)
    if not _ORIGINALS:
        _ORIGINALS.update(to_html=go.Figure.to_html, write_html=go.Figure.write_html, show=go.Figure.show)

    def opts_for(fig):
        own = getattr(fig, _SENTINEL_ATTR, None)
        return own if own is not None else defaults

    def to_html(self, *args, **kwargs):
        return _export(pio.to_html, self, args, kwargs, opts_for(self))

    def write_html(self, *args, **kwargs):
        return _export(pio.write_html, self, args, kwargs, opts_for(self))

    def show(self, *args, **kwargs):
        return _export(pio.show, self, args, kwargs, opts_for(self))

    go.Figure.to_html, go.Figure.write_html, go.Figure.show = to_html, write_html, show


def uninstall() -> None:
    """Undo ``install()``."""
    if _ORIGINALS:
        go.Figure.to_html = _ORIGINALS["to_html"]
        go.Figure.write_html = _ORIGINALS["write_html"]
        go.Figure.show = _ORIGINALS["show"]
        _ORIGINALS.clear()