Coverage for scanpath_studio/truncation_tooltip.py: 100%
13 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-07 21:10 +0000
1"""UX-196 — hover a cut-off dropdown label to read all of it.
3Streamlit's selectbox ends a label that does not fit with an ellipsis, in the
4open list and in the closed box alike, and gives neither a ``title`` — so
5"OneStop · Information seeking (repeated)" can only be told apart from
6"OneStop · Information seeking" by picking it. Wrapping is not an option: the
7list is virtualized, with fixed row heights (see
8``styles.wide_dropdown_css``).
10So this sets the native ``title`` tooltip on hover, and only when the label is
11actually truncated. It applies to every selectbox and multiselect option in
12the app, because the problem is the widget's rather than one picker's.
14Browser-only, like the easter egg: there is no session state and no setting,
15so nothing to expose on the deep link / CLI / headless API.
17Two details that are load-bearing:
19- **The listener runs in the parent's realm, not the iframe's.** Streamlit
20 re-mounts this embed on every rerun; a listener created by the removed
21 iframe's script belongs to a dead context. So the script injects a
22 ``<script>`` element into the parent document, once (guarded by its id),
23 and the code runs there.
24- **It is one delegated ``mouseover`` listener.** The option rows are portalled
25 and recycled by the virtualizer, so there is nothing stable to bind to — but
26 every hover bubbles to the document.
27"""
29from __future__ import annotations
31import json
33from scanpath_studio.html_embed import embed_html_iframe
35SCRIPT_ID = "sps-truncation-tooltip"
37# What gets a tooltip: a dropdown row, or the closed box's current value (a
38# react-aria combobox `<input>`, whose text is its `value`).
39TARGET_SELECTOR = '[role="option"], input[role="combobox"]'
41# Runs in the parent document. `title` is refreshed on every hover because the
42# virtualizer recycles a row element for a different option as the list scrolls.
43_PARENT_JS = """
44(function () {
45 const doc = document;
46 const clipped = (el) =>
47 [el, ...el.querySelectorAll("*")].some(
48 (node) => node.scrollWidth > node.clientWidth + 1
49 );
50 doc.addEventListener("mouseover", (ev) => {
51 const el = ev.target instanceof Element && ev.target.closest(__SELECTOR__);
52 if (!el) return;
53 const text = (el.value ?? el.innerText ?? "").trim();
54 if (text && clipped(el)) el.title = text;
55 else el.removeAttribute("title");
56 }, true);
57})();
58"""
60_JS = """
61(function () {
62 const doc = window.parent.document;
63 if (!doc.head || doc.getElementById(__ID__)) return;
64 const script = doc.createElement("script");
65 script.id = __ID__;
66 script.textContent = __BODY__;
67 doc.head.appendChild(script);
68})();
69"""
72def tooltip_script() -> str:
73 """The same-origin ``<script>`` that installs the hover listener once."""
74 parent = _PARENT_JS.replace("__SELECTOR__", json.dumps(TARGET_SELECTOR))
75 body = _JS.replace("__ID__", json.dumps(SCRIPT_ID)).replace(
76 "__BODY__", json.dumps(parent)
77 )
78 return f"<script>{body}</script>"
81def render_truncation_tooltips() -> None:
82 """Install the listener (a no-op in the browser after the first run)."""
83 embed_html_iframe(tooltip_script(), height=0)