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

1"""UX-196 — hover a cut-off dropdown label to read all of it. 

2 

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``). 

9 

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. 

13 

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. 

16 

17Two details that are load-bearing: 

18 

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""" 

28 

29from __future__ import annotations 

30 

31import json 

32 

33from scanpath_studio.html_embed import embed_html_iframe 

34 

35SCRIPT_ID = "sps-truncation-tooltip" 

36 

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"]' 

40 

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""" 

59 

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""" 

70 

71 

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>" 

79 

80 

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)