diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 6153b430c..4fe8932f6 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -343,21 +343,62 @@ def render(): if "title=" not in renderer_js: print("ERROR: expected renderStateCell to emit a title= attribute") failed = True - if "state2_color || '#FFFFFF'}${titleAttr}" not in renderer_js: - print("ERROR: expected the split (state2) cell to also carry the title= tooltip, not just the first half") - failed = True - - # --- Test 13: the read-only (History / Yesterday Without Predbat) state cells also get tooltips --- + # Scoped to renderStateCell's own body (not a whole-file count) since the read-only path + # (Test 13 below) has its own, separate titleAttr usage with the same variable name. + state_cell_fn_start = renderer_js.index("function renderStateCell(") + state_cell_fn_end = renderer_js.index("function renderRateCell(", state_cell_fn_start) + state_cell_fn_src = renderer_js[state_cell_fn_start:state_cell_fn_end] + if state_cell_fn_src.count("${titleAttr}") != 2: + print("ERROR: expected both the first and split (state2) cells to carry the title= tooltip, found {} references".format(state_cell_fn_src.count("${titleAttr}"))) + failed = True + + # --- Test 13a: tap/focus disclosure for touch and keyboard users, alongside title= for mouse + # hover - a touch interaction can't trigger :hover/title at all, so the two mechanisms never + # fire together and there's nothing to reconcile between them (see PR discussion). --- + print("Test renderStateCell also wires a tap/focus disclosure panel, not just hover") + if "toggleForceDropdown" not in renderer_js: + print("ERROR: expected renderStateCell to reuse the existing toggleForceDropdown() tap-to-toggle mechanism") + failed = True + if "clickable-state-cell" not in renderer_js: + print("ERROR: expected renderStateCell to mark reason cells with the clickable-state-cell class") + failed = True + if "reason-text" not in renderer_js: + print("ERROR: expected renderStateCell to render the reason panel content with the reason-text class") + failed = True + if renderer_js.count("dropdown-content") < 2: # at least the CSS rule plus renderStateCell's own usage + print("ERROR: expected renderStateCell to reuse the existing .dropdown-content panel mechanism") + failed = True + if 'tabindex="0"' not in renderer_js or "onkeydown" not in renderer_js: + print("ERROR: expected the reason cell to be keyboard-focusable and keyboard-operable, not just tap/click - a bare isn't reachable by keyboard") + failed = True + # Regression: the document-level "click outside closes the dropdown" handler (in get_plan_css(), + # a separate function from get_plan_renderer_js()) only whitelisted .clickable-time-cell, so + # opening the new reason panel (class clickable-state-cell) would have its own click event + # immediately re-close it via that same handler (Copilot review on #4349). + plan_css = web_helper.get_plan_css() + outside_click_start = plan_css.index("Close dropdowns when clicking outside") + outside_click_src = plan_css[outside_click_start : outside_click_start + 400] + if ".clickable-state-cell" not in outside_click_src: + print("ERROR: the click-outside handler doesn't whitelist .clickable-state-cell, so tapping a reason cell would open then immediately close its own panel") + failed = True + + # --- Test 13b: the read-only (History / Yesterday Without Predbat) state cells also get tooltips --- print("Test the non-editable state cell path also emits a title= tooltip") if "function reasonTitleAttr" not in renderer_js: print("ERROR: expected a shared reasonTitleAttr() helper used by both state-cell paths") failed = True - if "state_color || '#FFFFFF'}${titleAttr}" not in renderer_js: + # Scoped to the read-only branch specifically (between its own reasonTitleAttr() call and the + # next cell section) - the editable path (renderStateCell, Test 12 above) builds its split-cell + # HTML differently (extra tap/focus attributes sit between the bgcolor and titleAttr), so a + # single literal substring can no longer match both paths' shapes. + readonly_branch_start = renderer_js.index("const titleAttr = reasonTitleAttr(row, reasonTemplates);") + readonly_branch_end = renderer_js.index("// Limit cell", readonly_branch_start) + readonly_branch_src = renderer_js[readonly_branch_start:readonly_branch_end] + if "state_color || '#FFFFFF'}${titleAttr}" not in readonly_branch_src: print("ERROR: expected the read-only state cell (editable=false) to carry the title= tooltip") failed = True - # Both the editable and read-only paths render a split second half, so the tooltip appears twice - if renderer_js.count("state2_color || '#FFFFFF'}${titleAttr}") != 2: - print("ERROR: expected both the editable and read-only split cells to carry the title= tooltip") + if "state2_color || '#FFFFFF'}${titleAttr}" not in readonly_branch_src: + print("ERROR: expected the read-only split (state2) cell to carry the title= tooltip") failed = True # Templates must come from the dataset being rendered, not the plan view's global - otherwise # the History/Yesterday views would render against the wrong (or a missing) template table diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 02a9ed769..0aab61f44 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -5697,6 +5697,41 @@ def get_plan_css(): z-index: 2000; } + .clickable-state-cell { + cursor: pointer; + position: relative; + transition: background-color 0.2s; + z-index: 1; + } + + .clickable-state-cell:has(.dropdown-content[style*="display: block"]) { + z-index: 2000; + } + + .clickable-state-cell:hover { + filter: brightness(0.9); + } + + .clickable-state-cell:focus-visible { + outline: 2px solid #2196F3; + outline-offset: -2px; + } + + body.dark-mode .clickable-state-cell:hover { + filter: brightness(1.2); + } + + .reason-text { + font-size: 13px; + line-height: 1.4; + color: #333; + max-width: 260px; + } + + body.dark-mode .reason-text { + color: #eee; + } + .clickable-time-cell:hover { filter: brightness(0.9); } @@ -6313,7 +6348,7 @@ def get_plan_css(): // Close dropdowns when clicking outside document.addEventListener("click", function(event) { - if (!event.target.matches('.clickable-time-cell') && !event.target.closest('.dropdown-content')) { + if (!event.target.matches('.clickable-time-cell') && !event.target.matches('.clickable-state-cell') && !event.target.closest('.dropdown-content')) { closeDropdowns(); } }); @@ -6755,17 +6790,45 @@ def get_plan_renderer_js(): const rowspanAttr = row.rowspan_state > 0 ? ` rowspan="${row.rowspan_state}"` : ''; const colspanAttr = row.split ? '' : ' colspan=2'; - const titleAttr = reasonTitleAttr(row, templates); + // reasonText is needed raw (not just as a title= attribute) for reasonCellAttrs() below, + // which also uses it for the tap/focus panel content - templates comes from the dataset + // being rendered (jsonData.reason_templates), not window.planData, so History/Yesterday + // views look up against their own template table rather than the plan view's. + const reasonText = renderReasonText(row.reasons, templates); + // Keep both title= (free instant hover for desktop/mouse) and the tap/focus panel below + // (for touch and keyboard, neither of which can trigger a hover state at all) - the two + // never fire together in practice, since a touch interaction can't trigger :hover/title + // in the first place, so there's nothing to reconcile between them. + const titleAttr = reasonText ? ` title="${escapeAttr(reasonText)}"` : ''; + + function reasonCellAttrs(extraClass) { + if (!reasonText) { + return { clickAttrs: extraClass ? ` class="${extraClass}"` : '', panel: '' }; + } + const dropdownId = `reasonDropdown_${dropdownCounter++}`; + const classAttr = `clickable-state-cell${extraClass ? ' ' + extraClass : ''}`; + // tabindex + onkeydown make this reachable and operable by keyboard, not just tap - + // a bare onclick on a (the existing pattern used for time/rate cell dropdowns) + // is mouse/touch-only, since isn't focusable by default. + const keydown = `if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleForceDropdown('${dropdownId}')}`; + return { + clickAttrs: ` onclick="toggleForceDropdown('${dropdownId}')" onkeydown="${keydown}" tabindex="0" role="button" aria-label="Why this slot" class="${classAttr}"`, + panel: ``, + }; + } - let html = ``; + const first = reasonCellAttrs(overrideClass); + let html = ``; html += row.state_text || ''; + html += first.panel; html += ''; // Second state cell if split - same combined reason text as the first half, since // row.reasons is a single list covering both halves of a split (e.g. charging and // freeze-exporting in the same slot), not two separately-attributed sentences. if (row.split && row.state2_text) { - html += `${row.state2_text}`; + const second = reasonCellAttrs(''); + html += `${row.state2_text}${second.panel}`; } return html;