tray cards: shadow, hover-tilt w. focus persistence, role-card tooltip — TDD
- _tray.scss: drop-shadow on cell child elements (img → filter:drop-shadow so the silhouette is the shadow caster, div → box-shadow); 7° hover-tilt on .tray-role-card > img (-7°) and .tray-sig-card > .sig-stage-card (+7° via the standalone `rotate` property so the existing -5° baseline transform composes); :focus persists the tilt after click; cursor: pointer
- tray.js: set tabIndex=0 on placeCard's role cell + on template-rendered .tray-role-card / .tray-sig-card cells at init() so :focus latches the hover state; clear tabindex in reset() for Jasmine afterEach
- TraySpec: 4 new specs covering placeCard tabindex, reset cleanup, init-time tabindex on template-rendered sig & role cards, no-tabindex on bare cells
- New tray-tooltip.js (#id_tooltip_portal) — Phase 1 of the apps.tooltips integration: hovering .tray-role-card > img copies its sibling .tt's innerHTML into the page-root portal, anchors above/below the trigger, & clamps to the viewport horizontally; mousemove outside the union of [trigger, portal] rects clears the portal (Game-Kit pattern, no btns)
- room.html: #id_tooltip_portal mounted at room-page root (outside tray's overflow:hidden); .tt block rendered inline inside .tray-role-card via {% tooltip %} templatetag w. title=role display name & description="[Placeholder description]"
- epic/views.py: my_tray_role_tooltip context dict ({title, description}) keyed off the seated role
- TrayTooltipSpec: 8 specs covering portal population, .active class, sibling-.tt fallback, viewport-edge clamp left/right, and union-rect mouseleave
- 2 FTs in test_component_tray_tooltip.py: hover role img → portal title=Player + description=Placeholder; mouseleave → portal clears
Phase 2 (sig-card tooltip mirroring #id_fan_fyi_panel via a DRY refactor) deferred per plan.
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:
125
src/apps/epic/static/apps/epic/tray-tooltip.js
Normal file
125
src/apps/epic/static/apps/epic/tray-tooltip.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// ── 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TrayTooltip = (function () {
|
||||
var _portal = null;
|
||||
var _activeTrig = null;
|
||||
var _onDocMove = null;
|
||||
|
||||
function _inRect(x, y, r) {
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
}
|
||||
|
||||
function _hide() {
|
||||
if (!_portal) return;
|
||||
_portal.classList.remove("active");
|
||||
_portal.style.display = "none";
|
||||
_portal.innerHTML = "";
|
||||
_activeTrig = null;
|
||||
}
|
||||
|
||||
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.
|
||||
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";
|
||||
|
||||
// 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) {
|
||||
_portal.style.top = Math.round(triggerRect.bottom) + "px";
|
||||
_portal.style.transform = "translate(-50%, 0.5rem)";
|
||||
} else {
|
||||
_portal.style.top = Math.round(triggerRect.top) + "px";
|
||||
_portal.style.transform = "translate(-50%, calc(-100% - 0.5rem))";
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
img.addEventListener("mouseenter", function () {
|
||||
if (_activeTrig === img) return;
|
||||
_show(img);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
_portal = document.getElementById("id_tooltip_portal");
|
||||
if (!_portal) return;
|
||||
_portal.style.display = "none";
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
_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),
|
||||
};
|
||||
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();
|
||||
_onDocMove = null;
|
||||
_observer = null;
|
||||
_hide();
|
||||
_portal = null;
|
||||
}
|
||||
|
||||
return { init: init, reset: reset };
|
||||
})();
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
TrayTooltip.init();
|
||||
});
|
||||
}
|
||||
@@ -53,20 +53,34 @@ var Tray = (function () {
|
||||
// Compute the square cell size from the tray's interior dimension and set
|
||||
// --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
|
||||
// Portrait: divide height / 8. Landscape: divide width / 8.
|
||||
// The tray's bevel ring (--tray-bevel, an inset box-shadow) eats into the
|
||||
// felt area but is invisible to clientHeight/clientWidth — subtract it
|
||||
// explicitly on both axes so cells stay square inside the bevel.
|
||||
// In portrait the tray may be display:none; we show it with visibility:hidden
|
||||
// briefly so clientHeight returns a real value, then restore display:none.
|
||||
function _bevelPx() {
|
||||
if (!_tray) return 0;
|
||||
var raw = getComputedStyle(_tray).getPropertyValue('--tray-bevel').trim();
|
||||
if (!raw) return 0;
|
||||
if (raw.endsWith('rem')) {
|
||||
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
return parseFloat(raw) * rem;
|
||||
}
|
||||
return parseFloat(raw) || 0;
|
||||
}
|
||||
function _computeCellSize() {
|
||||
if (!_tray) return;
|
||||
var size;
|
||||
var bevel = _bevelPx();
|
||||
if (_isLandscape()) {
|
||||
size = Math.floor(_tray.clientWidth / 8);
|
||||
size = Math.floor((_tray.clientWidth - 2 * bevel) / 8);
|
||||
} else {
|
||||
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
|
||||
if (wasHidden) {
|
||||
_tray.style.visibility = 'hidden';
|
||||
_tray.style.display = 'grid';
|
||||
}
|
||||
size = Math.floor(_tray.clientHeight / 8);
|
||||
size = Math.floor((_tray.clientHeight - 2 * bevel) / 8);
|
||||
if (wasHidden) {
|
||||
_tray.style.display = 'none';
|
||||
_tray.style.visibility = '';
|
||||
@@ -252,6 +266,7 @@ var Tray = (function () {
|
||||
if (!firstCell) { if (onComplete) onComplete(); return; }
|
||||
|
||||
firstCell.classList.add('tray-role-card');
|
||||
firstCell.tabIndex = 0; // enable :focus persistence for the hover-tilt
|
||||
firstCell.dataset.role = roleCode;
|
||||
firstCell.textContent = '';
|
||||
if (_roleIconsUrl) {
|
||||
@@ -342,6 +357,16 @@ var Tray = (function () {
|
||||
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
|
||||
if (!_btn) return;
|
||||
|
||||
// Make occupied tray cells focusable so :focus persists the hover-tilt
|
||||
// after a click. Template-rendered .tray-sig-card / .tray-role-card
|
||||
// cells need this at init; cells assigned later (e.g. placeCard) set
|
||||
// tabIndex inline at assignment time.
|
||||
if (_grid) {
|
||||
_grid.querySelectorAll('.tray-role-card, .tray-sig-card').forEach(function (cell) {
|
||||
cell.tabIndex = 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (_isLandscape()) {
|
||||
// Show tray before measuring so offsetHeight includes it.
|
||||
if (_tray) _tray.style.display = 'grid';
|
||||
@@ -494,6 +519,7 @@ var Tray = (function () {
|
||||
if (_grid) {
|
||||
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
|
||||
el.classList.remove('tray-role-card', 'arc-in');
|
||||
el.removeAttribute('tabindex');
|
||||
el.textContent = '';
|
||||
delete el.dataset.role;
|
||||
});
|
||||
|
||||
@@ -287,6 +287,13 @@ def _role_select_context(room, user):
|
||||
"starter_roles": starter_roles,
|
||||
"assigned_seats": assigned_seats,
|
||||
"my_tray_role": _my_role,
|
||||
"my_tray_role_tooltip": (
|
||||
{
|
||||
"title": _ROLE_SCRAWL_NAMES.get(_my_role, ""),
|
||||
"description": "[Placeholder description]",
|
||||
}
|
||||
if _my_role else None
|
||||
),
|
||||
"my_tray_scrawl_static_path": (
|
||||
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
|
||||
if _my_role else None
|
||||
|
||||
163
src/functional_tests/test_component_tray_tooltip.py
Normal file
163
src/functional_tests/test_component_tray_tooltip.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Component FT — tray tooltip (Phase 1: role-card img).
|
||||
|
||||
Hovering the tray's role-card <img> populates #id_tooltip_portal with the
|
||||
role's display name as the title and a description, matching the existing
|
||||
Game-Kit tooltip portal pattern (apps.tooltips). Mousing well off the card
|
||||
clears the portal.
|
||||
|
||||
Phase 2 (sig-card tooltip mirroring #id_fan_fyi_panel) lives in a separate
|
||||
test class and is not yet covered here.
|
||||
"""
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
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.lyric.models import User
|
||||
|
||||
|
||||
def _equip_earthman_deck(user):
|
||||
deck, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||||
)
|
||||
user.equipped_deck = deck
|
||||
user.save(update_fields=["equipped_deck"])
|
||||
|
||||
|
||||
def _fill_room_via_orm(room, emails):
|
||||
for i, email in enumerate(emails, start=1):
|
||||
gamer, _ = User.objects.get_or_create(email=email)
|
||||
slot = room.gate_slots.get(slot_number=i)
|
||||
slot.gamer = gamer
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.save()
|
||||
room.gate_status = Room.OPEN
|
||||
room.save()
|
||||
|
||||
|
||||
class TrayRoleCardTooltipTest(FunctionalTest):
|
||||
EMAILS = [
|
||||
"tt1@test.io", "tt2@test.io", "tt3@test.io",
|
||||
"tt4@test.io", "tt5@test.io", "tt6@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_role(self, role="PC"):
|
||||
"""Room in ROLE_SELECT with founder seated and assigned `role`."""
|
||||
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
|
||||
_equip_earthman_deck(founder)
|
||||
room = Room.objects.create(name="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"):
|
||||
TableSeat.objects.create(
|
||||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||
role=(role if slot.slot_number == 1 else None),
|
||||
)
|
||||
return room
|
||||
|
||||
def _open_tray(self):
|
||||
# Tray is closed off-screen by default. Open it AND wait for the slide
|
||||
# transition (left: 736px → 0px, ~0.35s) to land before hovering;
|
||||
# otherwise getBoundingClientRect returns the closed-state rect that
|
||||
# ActionChains sees as off-viewport.
|
||||
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,
|
||||
"tray wrap should slide to ~0",
|
||||
)
|
||||
)
|
||||
|
||||
def _hover(self, element):
|
||||
ActionChains(self.browser).move_to_element(element).perform()
|
||||
|
||||
def _move_far_away(self):
|
||||
# Synthesize a mousemove event well outside the trigger+portal union.
|
||||
# ActionChains origins require a visible element; firing the event
|
||||
# directly via JS sidesteps that and exercises the same listener path.
|
||||
self.browser.execute_script(
|
||||
"document.dispatchEvent(new MouseEvent('mousemove',"
|
||||
"{bubbles:true, clientX:9999, clientY:9999}));"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T1 — Hover role img → portal shows title (role display name) #
|
||||
# + description ("[Placeholder description]"). #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_hover_role_img_populates_tooltip_portal(self):
|
||||
room = self._make_room_with_role(role="PC")
|
||||
self.create_pre_authenticated_session(self.EMAILS[0])
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
self._open_tray()
|
||||
# The role card is rendered server-side as the first tray cell.
|
||||
role_img = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card img"
|
||||
)
|
||||
)
|
||||
|
||||
# Portal exists at page root, hidden initially.
|
||||
portal = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_tooltip_portal")
|
||||
)
|
||||
self.assertFalse(
|
||||
portal.is_displayed(),
|
||||
"tooltip portal should start hidden before any hover",
|
||||
)
|
||||
|
||||
# Hover the role art → portal becomes visible with title + description.
|
||||
self._hover(role_img)
|
||||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||||
|
||||
title = portal.find_element(By.CSS_SELECTOR, ".tt-title").text
|
||||
self.assertEqual(title.strip(), "Player") # PC → "Player"
|
||||
|
||||
description = portal.find_element(By.CSS_SELECTOR, ".tt-description").text
|
||||
self.assertEqual(description.strip(), "[Placeholder description]")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T2 — Mousing well off the card clears the portal. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_mouseleave_clears_tooltip_portal(self):
|
||||
room = self._make_room_with_role(role="EC")
|
||||
self.create_pre_authenticated_session(self.EMAILS[0])
|
||||
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||
|
||||
self._open_tray()
|
||||
role_img = self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card img"
|
||||
)
|
||||
)
|
||||
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
|
||||
|
||||
self._hover(role_img)
|
||||
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
|
||||
# Title reflects the EC mapping.
|
||||
self.assertEqual(
|
||||
portal.find_element(By.CSS_SELECTOR, ".tt-title").text.strip(),
|
||||
"Economist",
|
||||
)
|
||||
|
||||
self._move_far_away()
|
||||
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))
|
||||
@@ -21,6 +21,7 @@
|
||||
<script src="Spec.js"></script>
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="TrayTooltipSpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<script src="SeaDealSpec.js"></script>
|
||||
<script src="FanStageSpec.js"></script>
|
||||
@@ -34,6 +35,7 @@
|
||||
<script src="/static/apps/epic/stage-card.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/tray-tooltip.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
<script src="/static/apps/epic/sea.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
|
||||
@@ -470,6 +470,11 @@ describe("Tray", () => {
|
||||
expect(firstCell.dataset.role).toBe("NC");
|
||||
});
|
||||
|
||||
it("sets tabIndex=0 on the placed cell so :focus persists the hover-tilt", () => {
|
||||
Tray.placeCard("PC", null);
|
||||
expect(firstCell.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("grid cell count stays at 8", () => {
|
||||
Tray.placeCard("PC", null);
|
||||
expect(grid.children.length).toBe(8);
|
||||
@@ -518,5 +523,57 @@ describe("Tray", () => {
|
||||
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
||||
expect(firstCell.dataset.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reset() also clears tabindex from the placed cell", () => {
|
||||
Tray.placeCard("PC", null);
|
||||
Tray.reset();
|
||||
expect(firstCell.hasAttribute("tabindex")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// init() — focusable tray cards //
|
||||
// ---------------------------------------------------------------------- //
|
||||
//
|
||||
// .tray-sig-card is rendered server-side by room.html when the seat has a
|
||||
// significator; .tray-role-card may be too if the seat already has a role.
|
||||
// init() must mark these cells tabbable so the SCSS :focus rule persists
|
||||
// the hover-tilt animation after the user clicks the card.
|
||||
|
||||
describe("init() — focusable tray cards", () => {
|
||||
let grid;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = document.createElement("div");
|
||||
grid.id = "id_tray_grid";
|
||||
document.body.appendChild(grid);
|
||||
});
|
||||
|
||||
afterEach(() => grid.remove());
|
||||
|
||||
function _addCell(extraClass) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "tray-cell" + (extraClass ? " " + extraClass : "");
|
||||
grid.appendChild(cell);
|
||||
return cell;
|
||||
}
|
||||
|
||||
it("sets tabIndex=0 on a template-rendered .tray-sig-card", () => {
|
||||
const sigCell = _addCell("tray-sig-card");
|
||||
Tray.init();
|
||||
expect(sigCell.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("sets tabIndex=0 on a template-rendered .tray-role-card", () => {
|
||||
const roleCell = _addCell("tray-role-card");
|
||||
Tray.init();
|
||||
expect(roleCell.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT set tabindex on bare .tray-cell elements", () => {
|
||||
const empty = _addCell();
|
||||
Tray.init();
|
||||
expect(empty.hasAttribute("tabindex")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
174
src/static/tests/TrayTooltipSpec.js
Normal file
174
src/static/tests/TrayTooltipSpec.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ── TrayTooltipSpec.js ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for TrayTooltip — apps.tooltips portal-population on hover of a
|
||||
// tray-cell child element. Phase 1 covers .tray-role-card > img only; Phase 2
|
||||
// (sig-card with PRV/NXT pager) is a separate sprint.
|
||||
//
|
||||
// Public API under test:
|
||||
// TrayTooltip.init() — binds document hover/move listeners
|
||||
// TrayTooltip.reset() — detaches listeners + hides portal (afterEach)
|
||||
//
|
||||
// DOM contract assumed by the module:
|
||||
// #id_tooltip_portal — fixed portal at page root, hidden by default
|
||||
// #id_tray_grid — tray's grid container
|
||||
// .tray-role-card — cell carrying the role art
|
||||
// > img — hover trigger
|
||||
// > .tt — server-rendered tooltip content (.tt-title, .tt-description, …)
|
||||
//
|
||||
// Behaviour:
|
||||
// * Hovering the img copies its sibling .tt's innerHTML into the portal,
|
||||
// marks the portal .active, and shows it (display: block).
|
||||
// * Pointer leaving the union of [trigger, portal] rects clears the portal.
|
||||
// * Position is clamped to the viewport: portal.left ≥ halfPortalW + 8;
|
||||
// portal.left ≤ viewportW − halfPortalW − 8.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TrayTooltip", () => {
|
||||
let portal, grid, cell, img, tt;
|
||||
|
||||
beforeEach(() => {
|
||||
portal = document.createElement("div");
|
||||
portal.id = "id_tooltip_portal";
|
||||
portal.style.display = "none";
|
||||
document.body.appendChild(portal);
|
||||
|
||||
grid = document.createElement("div");
|
||||
grid.id = "id_tray_grid";
|
||||
|
||||
cell = document.createElement("div");
|
||||
cell.className = "tray-cell tray-role-card";
|
||||
cell.dataset.role = "PC";
|
||||
|
||||
img = document.createElement("img");
|
||||
img.alt = "PC";
|
||||
|
||||
tt = document.createElement("div");
|
||||
tt.className = "tt";
|
||||
tt.style.display = "none";
|
||||
tt.innerHTML =
|
||||
'<h4 class="tt-title">Player</h4>' +
|
||||
'<p class="tt-description">[Placeholder description]</p>';
|
||||
|
||||
cell.appendChild(img);
|
||||
cell.appendChild(tt);
|
||||
grid.appendChild(cell);
|
||||
document.body.appendChild(grid);
|
||||
|
||||
// Force a known geometry so clamp math has something to clamp against.
|
||||
// (jsdom-style env: getBoundingClientRect() returns zeroes by default;
|
||||
// override via stubs.)
|
||||
spyOn(img, "getBoundingClientRect").and.returnValue({
|
||||
left: 100, right: 148, top: 200, bottom: 248, width: 48, height: 48,
|
||||
});
|
||||
|
||||
TrayTooltip.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TrayTooltip.reset();
|
||||
portal.remove();
|
||||
grid.remove();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Hover → portal becomes active //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("on mouseenter of role-card img", () => {
|
||||
it("copies .tt innerHTML into the portal", () => {
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.innerHTML).toContain('class="tt-title"');
|
||||
expect(portal.innerHTML).toContain("Player");
|
||||
expect(portal.innerHTML).toContain("[Placeholder description]");
|
||||
});
|
||||
|
||||
it("marks the portal .active and shows it", () => {
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
expect(portal.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("does nothing if there is no sibling .tt", () => {
|
||||
tt.remove();
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.classList.contains("active")).toBe(false);
|
||||
expect(portal.style.display).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Mouseleave (extended) → portal clears //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("on pointer leaving the trigger+portal union", () => {
|
||||
beforeEach(() => {
|
||||
// Pin the portal to a known location after activation so the
|
||||
// mousemove union test is deterministic.
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
spyOn(portal, "getBoundingClientRect").and.returnValue({
|
||||
left: 80, right: 280, top: 120, bottom: 200, width: 200, height: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the portal when the pointer is well outside both rects", () => {
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 500, clientY: 500,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(false);
|
||||
expect(portal.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps the portal active while the pointer is inside the trigger", () => {
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 120, clientY: 220,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the portal active while the pointer is inside the portal", () => {
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 180, clientY: 160,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Clamping — portal left stays inside the viewport //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
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.
|
||||
img.getBoundingClientRect.and.returnValue({
|
||||
left: 0, right: 48, top: 200, bottom: 248, width: 48, height: 48,
|
||||
});
|
||||
// Stub portal width so halfW is predictable AFTER innerHTML is set.
|
||||
const origDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype, "offsetWidth"
|
||||
);
|
||||
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
|
||||
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
|
||||
const left = parseFloat(portal.style.left);
|
||||
expect(left).toBeGreaterThanOrEqual(108); // halfW(100) + 8
|
||||
|
||||
if (origDescriptor) Object.defineProperty(HTMLElement.prototype, "offsetWidth", origDescriptor);
|
||||
});
|
||||
|
||||
it("clamps to the left when the trigger is near the right edge", () => {
|
||||
const vw = window.innerWidth;
|
||||
img.getBoundingClientRect.and.returnValue({
|
||||
left: vw - 10, right: vw, top: 200, bottom: 248, width: 10, height: 48,
|
||||
});
|
||||
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
|
||||
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
|
||||
const left = parseFloat(portal.style.left);
|
||||
expect(left).toBeLessThanOrEqual(vw - 100 - 8); // viewport − halfW − 8
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ $handle-rect-w: 10000px;
|
||||
$handle-rect-h: 72px;
|
||||
$handle-exposed: 48px;
|
||||
$handle-r: 1rem;
|
||||
$tray-bevel: 0.3rem; // inner bevel ring; grid must sit inside this
|
||||
|
||||
#id_tray_wrap.role-select-phase {
|
||||
#id_tray_handle { visibility: hidden; pointer-events: none; }
|
||||
@@ -128,9 +129,18 @@ $handle-r: 1rem;
|
||||
}
|
||||
|
||||
.tray-role-card {
|
||||
padding: 0;
|
||||
padding: 0.5rem; // breathing room around role art (post-bleed-trim)
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
// Dotted borders on .tray-cell would otherwise shrink the content box
|
||||
// and push a `width/height: 100%` img off-centre. Flex centring +
|
||||
// sizing the img to the full grid track keeps it visually centred
|
||||
// while preserving the dotted grid markings.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
&:focus { outline: none; }
|
||||
|
||||
img {
|
||||
display: block;
|
||||
@@ -138,7 +148,14 @@ $handle-r: 1rem;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
transform: scale(1.4); // crop SVG's internal margins
|
||||
transition: rotate 0.25s ease;
|
||||
}
|
||||
|
||||
// Hover/touch tilts the scrawl 7° counter-clockwise; :focus persists the
|
||||
// tilt after a click (cell receives tabindex="0" from tray.js).
|
||||
&:hover > img,
|
||||
&:focus > img {
|
||||
rotate: -7deg;
|
||||
}
|
||||
|
||||
// Cell stays static; only the scrawl image fades in.
|
||||
@@ -156,9 +173,19 @@ $handle-r: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
&:focus { outline: none; }
|
||||
|
||||
.sig-stage-card.sea-sig-card {
|
||||
--sig-card-w: calc(var(--tray-cell-size, 48px) * 5 / 8);
|
||||
// `rotate` is independent of `transform`, so the existing -5° baseline
|
||||
// (set on .sig-stage-card.sea-sig-card via transform) is preserved and
|
||||
// this rotates 7° clockwise on top of it.
|
||||
transition: rotate 0.25s ease;
|
||||
}
|
||||
&:hover > .sig-stage-card,
|
||||
&:focus > .sig-stage-card {
|
||||
rotate: 7deg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +207,7 @@ $handle-r: 1rem;
|
||||
}
|
||||
|
||||
#id_tray {
|
||||
--tray-bevel: #{$tray-bevel}; // exposed to JS via getComputedStyle for cell-size math
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start
|
||||
@@ -190,9 +218,10 @@ $handle-r: 1rem;
|
||||
border-left:2.5rem solid rgba(var(--quaUser), 1);
|
||||
border-top: 2.5rem solid rgba(var(--quaUser), 1);
|
||||
border-bottom: 2.5rem solid rgba(var(--quaUser), 1);
|
||||
padding: $tray-bevel; // inset grid inside the bevel ring on every felt-facing side
|
||||
box-shadow:
|
||||
-0.25rem 0 0.5rem rgba(0, 0, 0, 0.55),
|
||||
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge
|
||||
inset 0 0 0 $tray-bevel rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge
|
||||
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
|
||||
inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quiUser), 0.5), // left wall depth (hue)
|
||||
inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // top wall depth
|
||||
@@ -217,6 +246,20 @@ $handle-r: 1rem;
|
||||
border-right: 2px dotted rgba(var(--priUser), 0.35);
|
||||
border-bottom: 2px dotted rgba(var(--priUser), 0.35);
|
||||
position: relative;
|
||||
|
||||
// Whatever a cell holds (role-card img, sig stage card, future Celtic Cross
|
||||
// / natus wheel / dice) gets a soft drop shadow to lift it off the felt.
|
||||
// Applied to the child rather than the cell itself so the dotted grid
|
||||
// borders stay shadow-free.
|
||||
> * {
|
||||
box-shadow: 1px 1px 5px rgba(0, 0, 0, 1);
|
||||
}
|
||||
// Img children (SVG/PNG with transparent regions): use filter drop-shadow
|
||||
// instead so the shadow traces the rendered silhouette, not the SVG viewport.
|
||||
> img {
|
||||
box-shadow: none;
|
||||
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tray: landscape reorientation ─────────────────────────────────────────
|
||||
@@ -285,7 +328,7 @@ $handle-r: 1rem;
|
||||
|
||||
box-shadow:
|
||||
0 0.25rem 0.5rem rgba(0, 0, 0, 0.55),
|
||||
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring
|
||||
inset 0 0 0 $tray-bevel rgba(var(--quiUser), 0.45), // prominent bevel ring
|
||||
inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth
|
||||
inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // bottom wall depth (hue)
|
||||
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
|
||||
@@ -308,9 +351,11 @@ $handle-r: 1rem;
|
||||
grid-auto-rows: var(--tray-cell-size, 48px);
|
||||
// Anchor grid to the handle-side (bottom) of the tray so the first row
|
||||
// is visible when partially open; additional rows grow upward.
|
||||
// Inset by --tray-bevel so the grid sits inside the bevel ring rather
|
||||
// than under it (matches the portrait padding inset of the same width).
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: var(--tray-bevel, 0.3rem);
|
||||
left: var(--tray-bevel, 0.3rem);
|
||||
}
|
||||
|
||||
// In landscape the first row sits at the bottom; border-top divides it from
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<script src="Spec.js"></script>
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="TrayTooltipSpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<script src="SeaDealSpec.js"></script>
|
||||
<script src="FanStageSpec.js"></script>
|
||||
@@ -34,6 +35,7 @@
|
||||
<script src="/static/apps/epic/stage-card.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/tray-tooltip.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
<script src="/static/apps/epic/sea.js"></script>
|
||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||
|
||||
@@ -470,6 +470,11 @@ describe("Tray", () => {
|
||||
expect(firstCell.dataset.role).toBe("NC");
|
||||
});
|
||||
|
||||
it("sets tabIndex=0 on the placed cell so :focus persists the hover-tilt", () => {
|
||||
Tray.placeCard("PC", null);
|
||||
expect(firstCell.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("grid cell count stays at 8", () => {
|
||||
Tray.placeCard("PC", null);
|
||||
expect(grid.children.length).toBe(8);
|
||||
@@ -518,5 +523,57 @@ describe("Tray", () => {
|
||||
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
||||
expect(firstCell.dataset.role).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reset() also clears tabindex from the placed cell", () => {
|
||||
Tray.placeCard("PC", null);
|
||||
Tray.reset();
|
||||
expect(firstCell.hasAttribute("tabindex")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// init() — focusable tray cards //
|
||||
// ---------------------------------------------------------------------- //
|
||||
//
|
||||
// .tray-sig-card is rendered server-side by room.html when the seat has a
|
||||
// significator; .tray-role-card may be too if the seat already has a role.
|
||||
// init() must mark these cells tabbable so the SCSS :focus rule persists
|
||||
// the hover-tilt animation after the user clicks the card.
|
||||
|
||||
describe("init() — focusable tray cards", () => {
|
||||
let grid;
|
||||
|
||||
beforeEach(() => {
|
||||
grid = document.createElement("div");
|
||||
grid.id = "id_tray_grid";
|
||||
document.body.appendChild(grid);
|
||||
});
|
||||
|
||||
afterEach(() => grid.remove());
|
||||
|
||||
function _addCell(extraClass) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "tray-cell" + (extraClass ? " " + extraClass : "");
|
||||
grid.appendChild(cell);
|
||||
return cell;
|
||||
}
|
||||
|
||||
it("sets tabIndex=0 on a template-rendered .tray-sig-card", () => {
|
||||
const sigCell = _addCell("tray-sig-card");
|
||||
Tray.init();
|
||||
expect(sigCell.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("sets tabIndex=0 on a template-rendered .tray-role-card", () => {
|
||||
const roleCell = _addCell("tray-role-card");
|
||||
Tray.init();
|
||||
expect(roleCell.tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("does NOT set tabindex on bare .tray-cell elements", () => {
|
||||
const empty = _addCell();
|
||||
Tray.init();
|
||||
expect(empty.hasAttribute("tabindex")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
174
src/static_src/tests/TrayTooltipSpec.js
Normal file
174
src/static_src/tests/TrayTooltipSpec.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ── TrayTooltipSpec.js ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Unit specs for TrayTooltip — apps.tooltips portal-population on hover of a
|
||||
// tray-cell child element. Phase 1 covers .tray-role-card > img only; Phase 2
|
||||
// (sig-card with PRV/NXT pager) is a separate sprint.
|
||||
//
|
||||
// Public API under test:
|
||||
// TrayTooltip.init() — binds document hover/move listeners
|
||||
// TrayTooltip.reset() — detaches listeners + hides portal (afterEach)
|
||||
//
|
||||
// DOM contract assumed by the module:
|
||||
// #id_tooltip_portal — fixed portal at page root, hidden by default
|
||||
// #id_tray_grid — tray's grid container
|
||||
// .tray-role-card — cell carrying the role art
|
||||
// > img — hover trigger
|
||||
// > .tt — server-rendered tooltip content (.tt-title, .tt-description, …)
|
||||
//
|
||||
// Behaviour:
|
||||
// * Hovering the img copies its sibling .tt's innerHTML into the portal,
|
||||
// marks the portal .active, and shows it (display: block).
|
||||
// * Pointer leaving the union of [trigger, portal] rects clears the portal.
|
||||
// * Position is clamped to the viewport: portal.left ≥ halfPortalW + 8;
|
||||
// portal.left ≤ viewportW − halfPortalW − 8.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TrayTooltip", () => {
|
||||
let portal, grid, cell, img, tt;
|
||||
|
||||
beforeEach(() => {
|
||||
portal = document.createElement("div");
|
||||
portal.id = "id_tooltip_portal";
|
||||
portal.style.display = "none";
|
||||
document.body.appendChild(portal);
|
||||
|
||||
grid = document.createElement("div");
|
||||
grid.id = "id_tray_grid";
|
||||
|
||||
cell = document.createElement("div");
|
||||
cell.className = "tray-cell tray-role-card";
|
||||
cell.dataset.role = "PC";
|
||||
|
||||
img = document.createElement("img");
|
||||
img.alt = "PC";
|
||||
|
||||
tt = document.createElement("div");
|
||||
tt.className = "tt";
|
||||
tt.style.display = "none";
|
||||
tt.innerHTML =
|
||||
'<h4 class="tt-title">Player</h4>' +
|
||||
'<p class="tt-description">[Placeholder description]</p>';
|
||||
|
||||
cell.appendChild(img);
|
||||
cell.appendChild(tt);
|
||||
grid.appendChild(cell);
|
||||
document.body.appendChild(grid);
|
||||
|
||||
// Force a known geometry so clamp math has something to clamp against.
|
||||
// (jsdom-style env: getBoundingClientRect() returns zeroes by default;
|
||||
// override via stubs.)
|
||||
spyOn(img, "getBoundingClientRect").and.returnValue({
|
||||
left: 100, right: 148, top: 200, bottom: 248, width: 48, height: 48,
|
||||
});
|
||||
|
||||
TrayTooltip.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TrayTooltip.reset();
|
||||
portal.remove();
|
||||
grid.remove();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Hover → portal becomes active //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("on mouseenter of role-card img", () => {
|
||||
it("copies .tt innerHTML into the portal", () => {
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.innerHTML).toContain('class="tt-title"');
|
||||
expect(portal.innerHTML).toContain("Player");
|
||||
expect(portal.innerHTML).toContain("[Placeholder description]");
|
||||
});
|
||||
|
||||
it("marks the portal .active and shows it", () => {
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
expect(portal.style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("does nothing if there is no sibling .tt", () => {
|
||||
tt.remove();
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(portal.classList.contains("active")).toBe(false);
|
||||
expect(portal.style.display).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Mouseleave (extended) → portal clears //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
describe("on pointer leaving the trigger+portal union", () => {
|
||||
beforeEach(() => {
|
||||
// Pin the portal to a known location after activation so the
|
||||
// mousemove union test is deterministic.
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
spyOn(portal, "getBoundingClientRect").and.returnValue({
|
||||
left: 80, right: 280, top: 120, bottom: 200, width: 200, height: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the portal when the pointer is well outside both rects", () => {
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 500, clientY: 500,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(false);
|
||||
expect(portal.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps the portal active while the pointer is inside the trigger", () => {
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 120, clientY: 220,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the portal active while the pointer is inside the portal", () => {
|
||||
document.dispatchEvent(new MouseEvent("mousemove", {
|
||||
bubbles: true, clientX: 180, clientY: 160,
|
||||
}));
|
||||
expect(portal.classList.contains("active")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------- //
|
||||
// Clamping — portal left stays inside the viewport //
|
||||
// ---------------------------------------------------------------------- //
|
||||
|
||||
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.
|
||||
img.getBoundingClientRect.and.returnValue({
|
||||
left: 0, right: 48, top: 200, bottom: 248, width: 48, height: 48,
|
||||
});
|
||||
// Stub portal width so halfW is predictable AFTER innerHTML is set.
|
||||
const origDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype, "offsetWidth"
|
||||
);
|
||||
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
|
||||
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
|
||||
const left = parseFloat(portal.style.left);
|
||||
expect(left).toBeGreaterThanOrEqual(108); // halfW(100) + 8
|
||||
|
||||
if (origDescriptor) Object.defineProperty(HTMLElement.prototype, "offsetWidth", origDescriptor);
|
||||
});
|
||||
|
||||
it("clamps to the left when the trigger is near the right edge", () => {
|
||||
const vw = window.innerWidth;
|
||||
img.getBoundingClientRect.and.returnValue({
|
||||
left: vw - 10, right: vw, top: 200, bottom: 248, width: 10, height: 48,
|
||||
});
|
||||
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
|
||||
|
||||
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
|
||||
const left = parseFloat(portal.style.left);
|
||||
expect(left).toBeLessThanOrEqual(vw - 100 - 8); // viewport − halfW − 8
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "core/base.html" %}
|
||||
{% load static %}
|
||||
{% load static tooltip_tags %}
|
||||
|
||||
{% block title_text %}Gameboard{% endblock title_text %}
|
||||
{% block header_text %}<span>Game</span>room{% endblock header_text %}
|
||||
@@ -101,9 +101,15 @@
|
||||
<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 }}"></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 }}"><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). #}
|
||||
{% if room.table_status %}
|
||||
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
|
||||
{% endif %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -117,4 +123,5 @@
|
||||
<script src="{% static 'apps/epic/sig-select.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/tray.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
|
||||
Reference in New Issue
Block a user