fix(sidebar): correct testid + JS swap so +/− actually renders

The prior attempt used data-testid=stSidebarNavSectionHeader, which is
not what Streamlit 1.57 emits — the correct testid is stNavSectionHeader
(verified against the bundled JS in streamlit/static/static/js/).
The section header is also a <div> with onClick, not a <button>, and
the React component keeps the expanded state in a prop without
surfacing aria-expanded on the DOM. Pure CSS can therefore neither
locate the header nor switch the glyph by state, which is why the
chevron was unchanged in the rendered UI.

Switch strategies:
- CSS now targets the correct stNavSectionHeader / stIconMaterial
  selectors, drops the Material Symbols font from the icon span, and
  restyles it so a plain ascii character reads as proper typography
  (size, weight, color, hover).
- Add _SWAP_NAV_SECTION_INDICATOR_JS — small inline script that
  rewrites the icon's text node from "expand_more"/"expand_less" to
  "+"/"−" (U+2212), throttled via requestAnimationFrame, re-applied
  on every DOM mutation by a MutationObserver. Bundled into the same
  iframe injection as the existing brand/upload/findings scripts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:52:47 +00:00
parent 209b5fb1aa
commit fe4b5dc755

View File

@@ -336,51 +336,36 @@ body, .stApp {
} }
/* ---------- Section header expand indicator ---------- /* ---------- Section header expand indicator ----------
Swap Streamlit's default ``expand_more`` chevron in each sidebar Streamlit's nav section header uses a Material Symbols ligature
section header for a soft ``+`` / ```` pair. ``+`` when collapsed, icon (``expand_more`` / ``expand_less``) and does NOT expose
```` (U+2212, visually balanced with +) when expanded. ``aria-expanded`` on the header — the React component keeps that
state internally. Pure CSS therefore can't switch the glyph based
We hide any built-in icon Streamlit injects (svg or Material on state, so the visible swap is performed by
Symbols span — exact element differs by Streamlit version) and ``_SWAP_NAV_SECTION_INDICATOR_JS`` (rewrites the icon's text node
render our own glyph as a right-aligned pseudo-element keyed off to ``+`` / ```` and re-applies on mutation). This block only
``aria-expanded``. The button itself gets a touch of right padding handles the static styling so the rewritten glyph reads as a
so the label doesn't run into the indicator. */ normal typographic plus/minus instead of a Material font ligature
[data-testid="stSidebarNavSectionHeader"] { that would still try to resolve ``+`` as an icon name. */
[data-testid="stNavSectionHeader"] {
position: relative !important; position: relative !important;
} }
[data-testid="stSidebarNavSectionHeader"] button { [data-testid="stNavSectionHeader"] [data-testid="stIconMaterial"] {
width: 100% !important; /* Drop the Material Symbols font so the JS-swapped ``+`` / ````
padding-right: 22px !important; characters render as plain typography. ``font-feature-settings``
background: transparent !important; is reset so no ligature kicks in. */
border: none !important; font-family: var(--font-sans) !important;
font-feature-settings: normal !important;
-webkit-font-feature-settings: normal !important;
-moz-font-feature-settings: normal !important;
font-weight: 500 !important;
font-size: 16px !important;
line-height: 1 !important;
color: var(--ink-tertiary) !important;
width: auto !important;
height: auto !important;
transition: color 0.15s ease !important;
} }
[data-testid="stSidebarNavSectionHeader"] button svg, [data-testid="stNavSectionHeader"]:hover [data-testid="stIconMaterial"] {
[data-testid="stSidebarNavSectionHeader"] button [data-testid="stIconMaterial"],
[data-testid="stSidebarNavSectionHeader"] button span.material-symbols-rounded,
[data-testid="stSidebarNavSectionHeader"] button span.material-symbols-outlined,
[data-testid="stSidebarNavSectionHeader"] button span.material-icons,
[data-testid="stSidebarNavSectionHeader"] button span.material-icons-outlined {
display: none !important;
}
[data-testid="stSidebarNavSectionHeader"] button[aria-expanded]::after {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
font-weight: 400;
line-height: 1;
color: var(--ink-tertiary);
transition: color 0.15s ease;
pointer-events: none;
}
[data-testid="stSidebarNavSectionHeader"] button[aria-expanded="false"]::after {
content: "+";
}
[data-testid="stSidebarNavSectionHeader"] button[aria-expanded="true"]::after {
content: "\2212";
}
[data-testid="stSidebarNavSectionHeader"] button:hover::after {
color: var(--ink) !important; color: var(--ink) !important;
} }
@@ -1161,6 +1146,47 @@ _WIRE_COLLAPSIBLE_FINDINGS_JS = """
""" """
_SWAP_NAV_SECTION_INDICATOR_JS = """
<script>
(function () {
// Replace Streamlit's ``expand_more`` / ``expand_less`` Material
// ligature in sidebar nav section headers with plain ``+`` / ````.
// The section header isn't a button and doesn't carry
// ``aria-expanded``, so a pure-CSS swap can't switch the glyph
// based on state — we walk the icon's text node directly.
function swap(doc) {
var headers = doc.querySelectorAll('[data-testid="stNavSectionHeader"]');
headers.forEach(function (h) {
var icon = h.querySelector('[data-testid="stIconMaterial"]');
if (!icon) return;
var text = (icon.textContent || '').trim();
var glyph = null;
if (text === 'expand_more') glyph = '+';
else if (text === 'expand_less') glyph = ''; // U+2212
else if (text === '+' || text === '') return; // already swapped
else return;
icon.textContent = glyph;
});
}
var doc;
try { doc = window.parent.document; }
catch (e) { doc = document; }
swap(doc);
var win = doc.defaultView || window.parent || window;
if ('MutationObserver' in win) {
var raf = 0;
try {
new win.MutationObserver(function () {
if (raf) return;
raf = win.requestAnimationFrame(function () { raf = 0; swap(doc); });
}).observe(doc.body, { childList: true, subtree: true, characterData: true });
} catch (e) {}
}
})();
</script>
"""
_RENAME_UPLOAD_BUTTON_JS = """ _RENAME_UPLOAD_BUTTON_JS = """
<script> <script>
(function () { (function () {
@@ -1234,7 +1260,8 @@ def hide_streamlit_chrome(*, gate_license: bool = True) -> None:
st.iframe( st.iframe(
_INJECT_BRAND_JS _INJECT_BRAND_JS
+ _RENAME_UPLOAD_BUTTON_JS + _RENAME_UPLOAD_BUTTON_JS
+ _WIRE_COLLAPSIBLE_FINDINGS_JS, + _WIRE_COLLAPSIBLE_FINDINGS_JS
+ _SWAP_NAV_SECTION_INDICATOR_JS,
height=1, height=1,
) )
# Stamp a session-start record into the audit log the first time # Stamp a session-start record into the audit log the first time