tray sig-card tooltip: portal w. PRV|NXT pager — TDD
Phase 2 of the apps.tooltips integration on the tray. Hovering
.tray-sig-card > .sig-stage-card opens #id_tooltip_portal w. an FYI panel
that mirrors #id_fan_fyi_panel (Energy / Operation entries cycled via
PRV|NXT), but w.o. the stage block, w.o. Reversal entries, & w.o. the fan
stage's click-to-dismiss handler — the panel-body click is reserved for
future drag-and-drop on .tray-sig-card:active.
- _partials/_sig_fyi_panel.html — new partial, the .sig-info + PRV|NXT
block extracted out of game_kit.html, _sig_select_overlay.html, &
_sea_overlay.html. {% include %}d back from those 3 callers; pure
copy-paste extraction (no behavioural change to fan stage, sig select,
or sea select).
- room.html: .tray-sig-card > .sig-stage-card gains data-energies +
data-operations (the only attrs StageCard.buildInfoData reads), keyed
off my_tray_sig.energies_json / .operations_json (existing TarotCard
properties).
- tray-tooltip.js: new sig branch — _showSig() builds the panel inline,
paints via StageCard.renderFyi, & wires PRV|NXT cycle handlers; the
mousemove union now covers the .fyi-prev / .fyi-next btn rects (the
btns hang past the portal's left & right edges) so mouse-over them
keeps the panel alive. Click stopPropagation on the btns prevents the
panel-body click from reaching anything else.
- TrayTooltipSpec: 6 new sig-branch specs (panel structure; first energy
entry rendered; PRV|NXT cycling; body click no-dismiss; pointer over
btn rects keeps panel alive; pointer outside full union clears).
- test_component_tray_tooltip.py: 4 sig FTs (hover populates portal w.
Energy/TESTLIBIDO/effect/1-of-2; PRV|NXT cycle; body click does NOT
dismiss; mouseleave clears).
FT helper note — the sig FT's _hover dispatches a synthetic mouseenter
via JS rather than ActionChains.move_to_element, because the role-card
& sig-card cells sit side-by-side in the tray grid: the pointer's
animated path crosses the role-card on its way to the sig-card &
opens the role tooltip mid-flight, which then occludes the sig stage
by the time the move lands. Direct dispatch lands the event on the
intended trigger w.o. the cross-cell drag-by.
313 epic ITs + 335 Jasmine specs (incl. 6 new) + 6 tray-tooltip FTs all
green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,32 @@
|
||||
// ── tray-tooltip.js ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Phase 1: hover the tray's .tray-role-card > img → copy its sibling .tt's
|
||||
// innerHTML into #id_tooltip_portal and clamp the portal inside the viewport.
|
||||
// Mouseleave the union of [trigger, portal] rects → hide the portal.
|
||||
// Hover-triggered tooltips on the tray's two card cells, both portalled to
|
||||
// #id_tooltip_portal so they escape the tray's overflow:hidden clip.
|
||||
//
|
||||
// Mirrors the Game-Kit token pattern (see gameboard.js:initGameKitTooltips)
|
||||
// but scoped to the tray and without DON/DOFF buttons or a mini portal.
|
||||
// Phase 2 (sig card with PRV/NXT pager) will extend this module via a separate
|
||||
// trigger selector once the FYI panel is extracted from #id_fan_fyi_panel.
|
||||
// Phase 1 — .tray-role-card > img:
|
||||
// Copies its sibling .tt's innerHTML into the portal. No btns.
|
||||
// Phase 2 — .tray-sig-card > .sig-stage-card:
|
||||
// Builds an FYI panel via StageCard.buildInfoData / renderFyi from the
|
||||
// stage card's data-energies + data-operations attrs. PRV/NXT btns cycle
|
||||
// through entries. NO click-to-dismiss (departure from the fan stage's
|
||||
// #id_fan_fyi_panel — the panel-body click is reserved for future
|
||||
// drag-and-drop on .tray-sig-card:active).
|
||||
//
|
||||
// Mouseleave: a document-level mousemove listener hides the portal when the
|
||||
// pointer leaves the union of [trigger, portal] rects (Game-Kit pattern).
|
||||
// For the sig branch, the union additionally covers .fyi-prev / .fyi-next
|
||||
// since their absolute positioning makes them spill past the portal's edges.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TrayTooltip = (function () {
|
||||
var _portal = null;
|
||||
var _activeTrig = null;
|
||||
var _activeKind = null; // "role" | "sig"
|
||||
var _infoData = [];
|
||||
var _infoIdx = 0;
|
||||
var _onDocMove = null;
|
||||
var _observer = null;
|
||||
|
||||
function _inRect(x, y, r) {
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
@@ -26,33 +38,21 @@ var TrayTooltip = (function () {
|
||||
_portal.style.display = "none";
|
||||
_portal.innerHTML = "";
|
||||
_activeTrig = null;
|
||||
_activeKind = null;
|
||||
_infoData = [];
|
||||
_infoIdx = 0;
|
||||
}
|
||||
|
||||
function _show(triggerEl) {
|
||||
var tt = triggerEl.parentElement && triggerEl.parentElement.querySelector(".tt");
|
||||
if (!tt || !_portal) return;
|
||||
|
||||
_portal.innerHTML = tt.innerHTML;
|
||||
_portal.classList.add("active");
|
||||
_portal.style.display = "block";
|
||||
_activeTrig = triggerEl;
|
||||
|
||||
// Position: centre horizontally on the trigger, then clamp inside the
|
||||
// viewport so the portal never crosses an edge. 8px breathing room
|
||||
// matches the Game-Kit clamp.
|
||||
function _position(triggerEl) {
|
||||
var triggerRect = triggerEl.getBoundingClientRect();
|
||||
var halfW = _portal.offsetWidth / 2;
|
||||
var rawLeft = triggerRect.left + triggerRect.width / 2;
|
||||
var minLeft = halfW + 8;
|
||||
var maxLeft = window.innerWidth - halfW - 8;
|
||||
var left = Math.max(minLeft, Math.min(rawLeft, maxLeft));
|
||||
_portal.style.left = Math.round(left) + "px";
|
||||
_portal.style.left = Math.round(Math.max(minLeft, Math.min(rawLeft, maxLeft))) + "px";
|
||||
|
||||
// Anchor vertically: above the trigger when it sits in the lower half,
|
||||
// below otherwise. translate(-50%, …) keeps the centre column aligned.
|
||||
var triggerCY = triggerRect.top + triggerRect.height / 2;
|
||||
var showBelow = triggerCY < window.innerHeight / 2;
|
||||
if (showBelow) {
|
||||
if (triggerCY < window.innerHeight / 2) {
|
||||
_portal.style.top = Math.round(triggerRect.bottom) + "px";
|
||||
_portal.style.transform = "translate(-50%, 0.5rem)";
|
||||
} else {
|
||||
@@ -61,16 +61,109 @@ var TrayTooltip = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
function _showRole(triggerEl) {
|
||||
var tt = triggerEl.parentElement && triggerEl.parentElement.querySelector(".tt");
|
||||
if (!tt || !_portal) return;
|
||||
_portal.innerHTML = tt.innerHTML;
|
||||
_portal.classList.add("active");
|
||||
_portal.style.display = "block";
|
||||
_activeTrig = triggerEl;
|
||||
_activeKind = "role";
|
||||
_position(triggerEl);
|
||||
}
|
||||
|
||||
// Build the FYI panel HTML used by the sig branch — same DOM shape as
|
||||
// _partials/_sig_fyi_panel.html so SCSS rules and StageCard.renderFyi work
|
||||
// unchanged. Kept here (not loaded from the partial) so the portal remains
|
||||
// a single page-level element with no per-trigger duplication.
|
||||
var _SIG_PANEL_HTML = ''
|
||||
+ '<div class="sig-info">'
|
||||
+ '<div class="sig-info-header">'
|
||||
+ '<h4 class="sig-info-title"></h4>'
|
||||
+ '<p class="sig-info-type"></p>'
|
||||
+ '</div>'
|
||||
+ '<p class="sig-info-effect"></p>'
|
||||
+ '<span class="sig-info-index"></span>'
|
||||
+ '</div>'
|
||||
+ '<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>'
|
||||
+ '<button class="btn btn-nav-right fyi-next" type="button">NXT</button>';
|
||||
|
||||
function _showSig(triggerEl) {
|
||||
if (!_portal) return;
|
||||
var card = (typeof StageCard !== "undefined")
|
||||
? StageCard.fromDataset(triggerEl)
|
||||
: { energies: [], operations: [] };
|
||||
_infoData = (typeof StageCard !== "undefined")
|
||||
? StageCard.buildInfoData(card)
|
||||
: [];
|
||||
_infoIdx = 0;
|
||||
|
||||
_portal.innerHTML = _SIG_PANEL_HTML;
|
||||
var infoPanel = _portal.querySelector(".sig-info");
|
||||
if (typeof StageCard !== "undefined") {
|
||||
StageCard.renderFyi(infoPanel, _infoData, _infoIdx);
|
||||
}
|
||||
_portal.classList.add("active");
|
||||
_portal.style.display = "block";
|
||||
_activeTrig = triggerEl;
|
||||
_activeKind = "sig";
|
||||
|
||||
_portal.querySelector(".fyi-prev").addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
if (!_infoData.length) return;
|
||||
_infoIdx = (_infoIdx - 1 + _infoData.length) % _infoData.length;
|
||||
StageCard.renderFyi(infoPanel, _infoData, _infoIdx);
|
||||
});
|
||||
_portal.querySelector(".fyi-next").addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
if (!_infoData.length) return;
|
||||
_infoIdx = (_infoIdx + 1) % _infoData.length;
|
||||
StageCard.renderFyi(infoPanel, _infoData, _infoIdx);
|
||||
});
|
||||
|
||||
_position(triggerEl);
|
||||
}
|
||||
|
||||
function _bindTriggers() {
|
||||
// Server-rendered .tray-role-card > img (Phase 1).
|
||||
// Phase 2 will additionally bind .tray-sig-card > .sig-stage-card.
|
||||
var roleImgs = document.querySelectorAll("#id_tray_grid .tray-role-card > img");
|
||||
roleImgs.forEach(function (img) {
|
||||
if (img._trayTooltipBound) return;
|
||||
img._trayTooltipBound = true;
|
||||
img.addEventListener("mouseenter", function () {
|
||||
if (_activeTrig === img) return;
|
||||
_show(img);
|
||||
_showRole(img);
|
||||
});
|
||||
});
|
||||
|
||||
var sigStages = document.querySelectorAll("#id_tray_grid .tray-sig-card > .sig-stage-card");
|
||||
sigStages.forEach(function (stage) {
|
||||
if (stage._trayTooltipBound) return;
|
||||
stage._trayTooltipBound = true;
|
||||
stage.addEventListener("mouseenter", function () {
|
||||
if (_activeTrig === stage) return;
|
||||
_showSig(stage);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _activeUnion() {
|
||||
if (!_activeTrig || !_portal) return null;
|
||||
var rects = [
|
||||
_activeTrig.getBoundingClientRect(),
|
||||
_portal.getBoundingClientRect(),
|
||||
];
|
||||
if (_activeKind === "sig") {
|
||||
var prv = _portal.querySelector(".fyi-prev");
|
||||
var nxt = _portal.querySelector(".fyi-next");
|
||||
if (prv) rects.push(prv.getBoundingClientRect());
|
||||
if (nxt) rects.push(nxt.getBoundingClientRect());
|
||||
}
|
||||
return {
|
||||
left: Math.min.apply(null, rects.map(function (r) { return r.left; })),
|
||||
top: Math.min.apply(null, rects.map(function (r) { return r.top; })),
|
||||
right: Math.max.apply(null, rects.map(function (r) { return r.right; })),
|
||||
bottom: Math.max.apply(null, rects.map(function (r) { return r.bottom; })),
|
||||
};
|
||||
}
|
||||
|
||||
function init() {
|
||||
@@ -80,32 +173,21 @@ var TrayTooltip = (function () {
|
||||
|
||||
_bindTriggers();
|
||||
|
||||
// Observe later-added role cards (placeCard inserts the class+img
|
||||
// dynamically when the active gamer confirms a role pick).
|
||||
var grid = document.getElementById("id_tray_grid");
|
||||
if (grid && typeof MutationObserver !== "undefined") {
|
||||
var observer = new MutationObserver(_bindTriggers);
|
||||
observer.observe(grid, { childList: true, subtree: true });
|
||||
_observer = observer;
|
||||
_observer = new MutationObserver(_bindTriggers);
|
||||
_observer.observe(grid, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
_onDocMove = function (e) {
|
||||
if (!_portal.classList.contains("active") || !_activeTrig) return;
|
||||
var trigRect = _activeTrig.getBoundingClientRect();
|
||||
var portalRect = _portal.getBoundingClientRect();
|
||||
var union = {
|
||||
left: Math.min(trigRect.left, portalRect.left),
|
||||
top: Math.min(trigRect.top, portalRect.top),
|
||||
right: Math.max(trigRect.right, portalRect.right),
|
||||
bottom: Math.max(trigRect.bottom, portalRect.bottom),
|
||||
};
|
||||
var union = _activeUnion();
|
||||
if (!union) return;
|
||||
if (!_inRect(e.clientX, e.clientY, union)) _hide();
|
||||
};
|
||||
document.addEventListener("mousemove", _onDocMove);
|
||||
}
|
||||
|
||||
var _observer = null;
|
||||
|
||||
function reset() {
|
||||
if (_onDocMove) document.removeEventListener("mousemove", _onDocMove);
|
||||
if (_observer) _observer.disconnect();
|
||||
@@ -113,6 +195,11 @@ var TrayTooltip = (function () {
|
||||
_observer = null;
|
||||
_hide();
|
||||
_portal = null;
|
||||
// Drop the bind sentinel so a re-init re-binds the same DOM nodes
|
||||
// (Jasmine afterEach pattern).
|
||||
document.querySelectorAll("[data-tray-tt-bound]").forEach(function (el) {
|
||||
delete el._trayTooltipBound;
|
||||
});
|
||||
}
|
||||
|
||||
return { init: init, reset: reset };
|
||||
|
||||
@@ -14,7 +14,7 @@ from selenium.webdriver.common.by import By
|
||||
|
||||
from .base import FunctionalTest
|
||||
from apps.applets.models import Applet
|
||||
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
|
||||
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
@@ -161,3 +161,229 @@ class TrayRoleCardTooltipTest(FunctionalTest):
|
||||
|
||||
self._move_far_away()
|
||||
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))
|
||||
|
||||
|
||||
class TraySigCardTooltipTest(FunctionalTest):
|
||||
"""Phase 2 — sig-card tooltip mirrors #id_fan_fyi_panel functionally:
|
||||
Energy/Operation entries cycled via PRV/NXT btns, "1/N" pager, no stage
|
||||
block, no Reversal entries. Extended-mouseleave (union incl. PRV/NXT btn
|
||||
rects) replaces the fan-stage version's click-to-dismiss handler.
|
||||
"""
|
||||
|
||||
EMAILS = [
|
||||
"ts1@test.io", "ts2@test.io", "ts3@test.io",
|
||||
"ts4@test.io", "ts5@test.io", "ts6@test.io",
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.browser.set_window_size(800, 1200)
|
||||
Applet.objects.get_or_create(
|
||||
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||
)
|
||||
Applet.objects.get_or_create(
|
||||
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||
)
|
||||
|
||||
def _make_room_with_sig(self):
|
||||
"""Room w. founder seated, role assigned, & a TarotCard set as sig.
|
||||
|
||||
The seeded card carries 1 energy + 1 operation = 2 FYI entries so the
|
||||
PRV/NXT pager has something to cycle through.
|
||||
"""
|
||||
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
|
||||
deck, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||||
)
|
||||
founder.equipped_deck = deck
|
||||
founder.save(update_fields=["equipped_deck"])
|
||||
# Stable test fixture — slug + arcana + suit unique per (deck_variant, slug).
|
||||
# corner_rank / suit_icon are computed properties on TarotCard;
|
||||
# only the underlying fields go in create().
|
||||
sig_card = TarotCard.objects.create(
|
||||
deck_variant=deck,
|
||||
name="The Tester",
|
||||
arcana="MAJOR",
|
||||
number=98,
|
||||
slug="the-tester-sig-tt",
|
||||
icon="fa-flask",
|
||||
energies=[
|
||||
{"type": "TESTLIBIDO", "effect": "First energy effect."},
|
||||
],
|
||||
operations=[
|
||||
{"type": "TESTOP", "effect": "First operation effect."},
|
||||
],
|
||||
)
|
||||
room = Room.objects.create(name="Sig Tooltip Room", owner=founder)
|
||||
_fill_room_via_orm(room, self.EMAILS)
|
||||
room.table_status = Room.ROLE_SELECT
|
||||
room.save()
|
||||
for slot in room.gate_slots.order_by("slot_number"):
|
||||
seat = TableSeat.objects.create(
|
||||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||
role=("PC" if slot.slot_number == 1 else None),
|
||||
)
|
||||
if slot.slot_number == 1:
|
||||
seat.significator = sig_card
|
||||
seat.save(update_fields=["significator"])
|
||||
return room, sig_card
|
||||
|
||||
def _open_tray(self):
|
||||
self.browser.execute_script("Tray.open();")
|
||||
self.wait_for(
|
||||
lambda: self.assertLess(
|
||||
self.browser.execute_script(
|
||||
"return document.getElementById('id_tray_wrap').getBoundingClientRect().left"
|
||||
),
|
||||
40,
|
||||
)
|
||||
)
|
||||
|
||||
def _hover(self, element):
|
||||
# Synthetic mouseenter — Selenium's ActionChains animates the pointer
|
||||
# from the previous position to the destination. With role-card and
|
||||
# sig-card cells side-by-side in the tray grid, an ActionChains hover
|
||||
# on the sig stage crosses the role-card's hit area on the way and
|
||||
# opens the role tooltip mid-flight, which then occludes the sig stage
|
||||
# by the time the pointer lands. Direct dispatch avoids this and still
|
||||
# exercises the same listener path (mouseenter → _showSig).
|
||||
self.browser.execute_script(
|
||||
"arguments[0].dispatchEvent(new MouseEvent('mouseenter',"
|
||||
"{bubbles:false, cancelable:false}));",
|
||||
element,
|
||||
)
|
||||
|
||||
def _move_far_away(self):
|
||||
self.browser.execute_script(
|
||||
"document.dispatchEvent(new MouseEvent('mousemove',"
|
||||
"{bubbles:true, clientX:9999, clientY:9999}));"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T1 — Hover sig card → portal shows first energy entry #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_hover_sig_card_populates_fyi_portal(self):
|
||||
room, _card = self._make_room_with_sig()
|
||||
self.create_pre_authenticated_session(self.EMAILS[0])
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
self._open_tray()
|
||||
sig_stage = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
|
||||
)
|
||||
)
|
||||
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
|
||||
self.assertFalse(portal.is_displayed())
|
||||
|
||||
self._hover(sig_stage)
|
||||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||||
|
||||
# Portal renders the same .sig-info structure as the fan FYI panel.
|
||||
title = portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip()
|
||||
ttype = portal.find_element(By.CSS_SELECTOR, ".sig-info-type").text.strip()
|
||||
effect = portal.find_element(By.CSS_SELECTOR, ".sig-info-effect").text.strip()
|
||||
index = portal.find_element(By.CSS_SELECTOR, ".sig-info-index").text.strip()
|
||||
self.assertEqual(title, "Energy")
|
||||
self.assertEqual(ttype, "TESTLIBIDO")
|
||||
self.assertEqual(effect, "First energy effect.")
|
||||
self.assertEqual(index, "1 / 2") # 1 energy + 1 operation = 2 entries
|
||||
|
||||
# PRV + NXT buttons are present.
|
||||
portal.find_element(By.CSS_SELECTOR, ".fyi-prev")
|
||||
portal.find_element(By.CSS_SELECTOR, ".fyi-next")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T2 — NXT cycles to operation entry; PRV cycles back #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_prv_nxt_cycle_through_entries(self):
|
||||
room, _card = self._make_room_with_sig()
|
||||
self.create_pre_authenticated_session(self.EMAILS[0])
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
self._open_tray()
|
||||
sig_stage = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
|
||||
)
|
||||
)
|
||||
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
|
||||
|
||||
self._hover(sig_stage)
|
||||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||||
|
||||
nxt = portal.find_element(By.CSS_SELECTOR, ".fyi-next")
|
||||
nxt.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip(),
|
||||
"Operation",
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
portal.find_element(By.CSS_SELECTOR, ".sig-info-type").text.strip(),
|
||||
"TESTOP",
|
||||
)
|
||||
self.assertEqual(
|
||||
portal.find_element(By.CSS_SELECTOR, ".sig-info-index").text.strip(),
|
||||
"2 / 2",
|
||||
)
|
||||
|
||||
prv = portal.find_element(By.CSS_SELECTOR, ".fyi-prev")
|
||||
prv.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip(),
|
||||
"Energy",
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T3 — Clicking the panel body does NOT dismiss (departure from fan). #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_panel_body_click_does_not_dismiss(self):
|
||||
room, _card = self._make_room_with_sig()
|
||||
self.create_pre_authenticated_session(self.EMAILS[0])
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
self._open_tray()
|
||||
sig_stage = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
|
||||
)
|
||||
)
|
||||
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
|
||||
|
||||
self._hover(sig_stage)
|
||||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||||
|
||||
# Click on the panel body (not on PRV / NXT) → portal stays active.
|
||||
portal.find_element(By.CSS_SELECTOR, ".sig-info-effect").click()
|
||||
self.assertTrue(portal.is_displayed())
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T4 — Mouseleave (outside trigger + portal + btn rects) clears. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_mouseleave_outside_union_clears_portal(self):
|
||||
room, _card = self._make_room_with_sig()
|
||||
self.create_pre_authenticated_session(self.EMAILS[0])
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
self._open_tray()
|
||||
sig_stage = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
|
||||
)
|
||||
)
|
||||
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
|
||||
|
||||
self._hover(sig_stage)
|
||||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||||
|
||||
self._move_far_away()
|
||||
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))
|
||||
|
||||
@@ -138,6 +138,139 @@ describe("TrayTooltip", () => {
|
||||
// Clamping — portal left stays inside the viewport //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Phase 2 — sig-card branch //
|
||||
// ---------------------------------------------------------------------- //
|
||||
//
|
||||
// Hovering .tray-sig-card > .sig-stage-card populates the SAME portal with
|
||||
// a .sig-info FYI panel (Energy/Operation entries cycled via PRV/NXT btns).
|
||||
// No click-to-dismiss; mouseleave union covers the overhanging btn rects.
|
||||
|
||||
describe("sig-card branch", () => {
|
||||
let sigCell, sigStage;
|
||||
|
||||
beforeEach((done) => {
|
||||
sigCell = document.createElement("div");
|
||||
sigCell.className = "tray-cell tray-sig-card";
|
||||
|
||||
sigStage = document.createElement("div");
|
||||
sigStage.className = "sig-stage-card sea-sig-card";
|
||||
// Two FYI entries — 1 energy + 1 operation — so PRV/NXT have
|
||||
// something to cycle through.
|
||||
sigStage.dataset.energies = JSON.stringify([
|
||||
{ type: "TESTLIBIDO", effect: "First energy effect." },
|
||||
]);
|
||||
sigStage.dataset.operations = JSON.stringify([
|
||||
{ type: "TESTOP", effect: "First operation effect." },
|
||||
]);
|
||||
|
||||
sigCell.appendChild(sigStage);
|
||||
grid.appendChild(sigCell);
|
||||
|
||||
spyOn(sigStage, "getBoundingClientRect").and.returnValue({
|
||||
left: 100, right: 160, top: 300, bottom: 396,
|
||||
width: 60, height: 96,
|
||||
});
|
||||
// MutationObserver fires asynchronously — yield once so the new
|
||||
// sig cell is bound before the spec body runs.
|
||||
setTimeout(done, 0);
|
||||
});
|
||||
|
||||
it("populates the portal with a .sig-info panel + PRV/NXT btns", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.querySelector(".sig-info-title")).not.toBe(null);
|
||||
expect(portal.querySelector(".sig-info-type")).not.toBe(null);
|
||||
expect(portal.querySelector(".sig-info-effect")).not.toBe(null);
|
||||
expect(portal.querySelector(".sig-info-index")).not.toBe(null);
|
||||
expect(portal.querySelector(".fyi-prev")).not.toBe(null);
|
||||
expect(portal.querySelector(".fyi-next")).not.toBe(null);
|
||||
});
|
||||
|
||||
it("renders the first energy entry on hover", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
|
||||
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTLIBIDO");
|
||||
expect(portal.querySelector(".sig-info-effect").textContent).toBe("First energy effect.");
|
||||
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
|
||||
});
|
||||
|
||||
it("NXT advances to the operation entry; PRV cycles back", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
portal.querySelector(".fyi-next").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(portal.querySelector(".sig-info-title").textContent).toBe("Operation");
|
||||
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTOP");
|
||||
expect(portal.querySelector(".sig-info-index").textContent).toBe("2 / 2");
|
||||
|
||||
portal.querySelector(".fyi-prev").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
|
||||
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
|
||||
});
|
||||
|
||||
it("clicking the panel body does NOT dismiss (departure from fan stage)", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
portal.querySelector(".sig-info-effect").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
expect(portal.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("keeps portal active when pointer is over a fyi-prev / fyi-next btn rect", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
// Stub the portal + btn rects so the union test is deterministic.
|
||||
spyOn(portal, "getBoundingClientRect").and.returnValue({
|
||||
left: 80, right: 280, top: 200, bottom: 320,
|
||||
width: 200, height: 120,
|
||||
});
|
||||
const prv = portal.querySelector(".fyi-prev");
|
||||
const nxt = portal.querySelector(".fyi-next");
|
||||
spyOn(prv, "getBoundingClientRect").and.returnValue({
|
||||
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
spyOn(nxt, "getBoundingClientRect").and.returnValue({
|
||||
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
// Pointer over the PRV btn (left:60–92) — outside the portal's left
|
||||
// edge (80) but inside the btn rect — should keep the portal alive.
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 70, clientY: 256,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
|
||||
// Pointer over the NXT btn (right:268–300, past the portal right
|
||||
// edge 280) — likewise stays alive.
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 290, clientY: 256,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears portal when pointer leaves trigger + portal + btn rect union", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
spyOn(portal, "getBoundingClientRect").and.returnValue({
|
||||
left: 80, right: 280, top: 200, bottom: 320,
|
||||
width: 200, height: 120,
|
||||
});
|
||||
const prv = portal.querySelector(".fyi-prev");
|
||||
const nxt = portal.querySelector(".fyi-next");
|
||||
spyOn(prv, "getBoundingClientRect").and.returnValue({
|
||||
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
spyOn(nxt, "getBoundingClientRect").and.returnValue({
|
||||
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 9999, clientY: 9999,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(false);
|
||||
expect(portal.style.display).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("position clamping", () => {
|
||||
it("clamps to the right when the trigger is near the left edge", () => {
|
||||
// Trigger near x=0 should push portal centre rightward to halfW + 8.
|
||||
|
||||
@@ -138,6 +138,139 @@ describe("TrayTooltip", () => {
|
||||
// Clamping — portal left stays inside the viewport //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Phase 2 — sig-card branch //
|
||||
// ---------------------------------------------------------------------- //
|
||||
//
|
||||
// Hovering .tray-sig-card > .sig-stage-card populates the SAME portal with
|
||||
// a .sig-info FYI panel (Energy/Operation entries cycled via PRV/NXT btns).
|
||||
// No click-to-dismiss; mouseleave union covers the overhanging btn rects.
|
||||
|
||||
describe("sig-card branch", () => {
|
||||
let sigCell, sigStage;
|
||||
|
||||
beforeEach((done) => {
|
||||
sigCell = document.createElement("div");
|
||||
sigCell.className = "tray-cell tray-sig-card";
|
||||
|
||||
sigStage = document.createElement("div");
|
||||
sigStage.className = "sig-stage-card sea-sig-card";
|
||||
// Two FYI entries — 1 energy + 1 operation — so PRV/NXT have
|
||||
// something to cycle through.
|
||||
sigStage.dataset.energies = JSON.stringify([
|
||||
{ type: "TESTLIBIDO", effect: "First energy effect." },
|
||||
]);
|
||||
sigStage.dataset.operations = JSON.stringify([
|
||||
{ type: "TESTOP", effect: "First operation effect." },
|
||||
]);
|
||||
|
||||
sigCell.appendChild(sigStage);
|
||||
grid.appendChild(sigCell);
|
||||
|
||||
spyOn(sigStage, "getBoundingClientRect").and.returnValue({
|
||||
left: 100, right: 160, top: 300, bottom: 396,
|
||||
width: 60, height: 96,
|
||||
});
|
||||
// MutationObserver fires asynchronously — yield once so the new
|
||||
// sig cell is bound before the spec body runs.
|
||||
setTimeout(done, 0);
|
||||
});
|
||||
|
||||
it("populates the portal with a .sig-info panel + PRV/NXT btns", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.querySelector(".sig-info-title")).not.toBe(null);
|
||||
expect(portal.querySelector(".sig-info-type")).not.toBe(null);
|
||||
expect(portal.querySelector(".sig-info-effect")).not.toBe(null);
|
||||
expect(portal.querySelector(".sig-info-index")).not.toBe(null);
|
||||
expect(portal.querySelector(".fyi-prev")).not.toBe(null);
|
||||
expect(portal.querySelector(".fyi-next")).not.toBe(null);
|
||||
});
|
||||
|
||||
it("renders the first energy entry on hover", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
|
||||
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTLIBIDO");
|
||||
expect(portal.querySelector(".sig-info-effect").textContent).toBe("First energy effect.");
|
||||
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
|
||||
});
|
||||
|
||||
it("NXT advances to the operation entry; PRV cycles back", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
portal.querySelector(".fyi-next").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(portal.querySelector(".sig-info-title").textContent).toBe("Operation");
|
||||
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTOP");
|
||||
expect(portal.querySelector(".sig-info-index").textContent).toBe("2 / 2");
|
||||
|
||||
portal.querySelector(".fyi-prev").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
|
||||
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
|
||||
});
|
||||
|
||||
it("clicking the panel body does NOT dismiss (departure from fan stage)", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
portal.querySelector(".sig-info-effect").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
expect(portal.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("keeps portal active when pointer is over a fyi-prev / fyi-next btn rect", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
// Stub the portal + btn rects so the union test is deterministic.
|
||||
spyOn(portal, "getBoundingClientRect").and.returnValue({
|
||||
left: 80, right: 280, top: 200, bottom: 320,
|
||||
width: 200, height: 120,
|
||||
});
|
||||
const prv = portal.querySelector(".fyi-prev");
|
||||
const nxt = portal.querySelector(".fyi-next");
|
||||
spyOn(prv, "getBoundingClientRect").and.returnValue({
|
||||
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
spyOn(nxt, "getBoundingClientRect").and.returnValue({
|
||||
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
// Pointer over the PRV btn (left:60–92) — outside the portal's left
|
||||
// edge (80) but inside the btn rect — should keep the portal alive.
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 70, clientY: 256,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
|
||||
// Pointer over the NXT btn (right:268–300, past the portal right
|
||||
// edge 280) — likewise stays alive.
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 290, clientY: 256,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears portal when pointer leaves trigger + portal + btn rect union", () => {
|
||||
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
spyOn(portal, "getBoundingClientRect").and.returnValue({
|
||||
left: 80, right: 280, top: 200, bottom: 320,
|
||||
width: 200, height: 120,
|
||||
});
|
||||
const prv = portal.querySelector(".fyi-prev");
|
||||
const nxt = portal.querySelector(".fyi-next");
|
||||
spyOn(prv, "getBoundingClientRect").and.returnValue({
|
||||
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
spyOn(nxt, "getBoundingClientRect").and.returnValue({
|
||||
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
|
||||
});
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 9999, clientY: 9999,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(false);
|
||||
expect(portal.style.display).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("position clamping", () => {
|
||||
it("clamps to the right when the trigger is near the left edge", () => {
|
||||
// Trigger near x=0 should push portal centre rightward to halfW + 8.
|
||||
|
||||
@@ -176,16 +176,7 @@
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
|
||||
</div>
|
||||
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
|
||||
<div class="sig-info-header">
|
||||
<h4 class="sig-info-title"></h4>
|
||||
<p class="sig-info-type"></p>
|
||||
</div>
|
||||
<p class="sig-info-effect"></p>
|
||||
<span class="sig-info-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>
|
||||
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
22
src/templates/apps/gameboard/_partials/_sig_fyi_panel.html
Normal file
22
src/templates/apps/gameboard/_partials/_sig_fyi_panel.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% comment %}
|
||||
Shared FYI panel — Energy / Operation entries painted by StageCard.renderFyi
|
||||
in JS. PRV / NXT btns hang past the panel's left/right edges by -1rem each
|
||||
(see _card-deck.scss). Used by:
|
||||
- the fan stage (game_kit.html, paired with #id_fan_stage_block)
|
||||
- the sig select overlay (_sig_select_overlay.html)
|
||||
- the sea select overlay (_sea_overlay.html)
|
||||
- the tray sig-card tooltip (rendered into #id_tooltip_portal at hover time)
|
||||
|
||||
`panel_id` is optional; when blank the wrapper is unidentified (used by the
|
||||
tray, which fills #id_tooltip_portal instead).
|
||||
{% endcomment %}
|
||||
<div class="sig-info"{% if panel_id %} id="{{ panel_id }}"{% endif %}{% if panel_extra_attrs %} {{ panel_extra_attrs }}{% endif %}>
|
||||
<div class="sig-info-header">
|
||||
<h4 class="sig-info-title"></h4>
|
||||
<p class="sig-info-type"></p>
|
||||
</div>
|
||||
<p class="sig-info-effect"></p>
|
||||
<span class="sig-info-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>
|
||||
@@ -52,16 +52,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
|
||||
</div>
|
||||
<div class="sig-info" id="id_sig_tooltip">
|
||||
<div class="sig-info-header">
|
||||
<h4 class="sig-info-title"></h4>
|
||||
<p class="sig-info-type"></p>
|
||||
</div>
|
||||
<p class="sig-info-effect"></p>
|
||||
<span class="sig-info-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>
|
||||
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sig_tooltip" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,16 +29,7 @@
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<ul class="stat-keywords" id="id_fan_stat_reversed"></ul>
|
||||
</div>
|
||||
<div class="sig-info" id="id_fan_fyi_panel" style="display:none">
|
||||
<div class="sig-info-header">
|
||||
<h4 class="sig-info-title"></h4>
|
||||
<p class="sig-info-type"></p>
|
||||
</div>
|
||||
<p class="sig-info-effect"></p>
|
||||
<span class="sig-info-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>
|
||||
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_fan_fyi_panel" panel_extra_attrs='style="display:none"' %}
|
||||
</div>
|
||||
<button id="id_fan_next" class="fan-nav fan-nav--next" aria-label="Next card">›</button>
|
||||
</div>
|
||||
|
||||
@@ -101,12 +101,14 @@
|
||||
<i class="fa-solid fa-dice-d20"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}">{% tooltip my_tray_role_tooltip %}</div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
||||
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}">{% tooltip my_tray_role_tooltip %}</div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}" data-energies="{{ my_tray_sig.energies_json }}" data-operations="{{ my_tray_sig.operations_json }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Tray-tooltip portal — sibling of the tray so it sits at room-page root,
|
||||
not inside the tray's overflow:hidden / mask-image clip. JS populates
|
||||
innerHTML on hover of .tray-role-card > img (and Phase 2: sig card). #}
|
||||
{% comment %}
|
||||
Tray-tooltip portal — sibling of the tray so it sits at room-page root,
|
||||
not inside the tray's overflow:hidden / mask-image clip. JS populates
|
||||
innerHTML on hover of .tray-role-card > img (and Phase 2: sig card).
|
||||
{% endcomment %}
|
||||
{% if room.table_status %}
|
||||
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user