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
|
// 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.
|
// --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
|
||||||
// Portrait: divide height / 8. Landscape: divide width / 8.
|
// 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
|
// 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.
|
// 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() {
|
function _computeCellSize() {
|
||||||
if (!_tray) return;
|
if (!_tray) return;
|
||||||
var size;
|
var size;
|
||||||
|
var bevel = _bevelPx();
|
||||||
if (_isLandscape()) {
|
if (_isLandscape()) {
|
||||||
size = Math.floor(_tray.clientWidth / 8);
|
size = Math.floor((_tray.clientWidth - 2 * bevel) / 8);
|
||||||
} else {
|
} else {
|
||||||
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
|
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
|
||||||
if (wasHidden) {
|
if (wasHidden) {
|
||||||
_tray.style.visibility = 'hidden';
|
_tray.style.visibility = 'hidden';
|
||||||
_tray.style.display = 'grid';
|
_tray.style.display = 'grid';
|
||||||
}
|
}
|
||||||
size = Math.floor(_tray.clientHeight / 8);
|
size = Math.floor((_tray.clientHeight - 2 * bevel) / 8);
|
||||||
if (wasHidden) {
|
if (wasHidden) {
|
||||||
_tray.style.display = 'none';
|
_tray.style.display = 'none';
|
||||||
_tray.style.visibility = '';
|
_tray.style.visibility = '';
|
||||||
@@ -252,6 +266,7 @@ var Tray = (function () {
|
|||||||
if (!firstCell) { if (onComplete) onComplete(); return; }
|
if (!firstCell) { if (onComplete) onComplete(); return; }
|
||||||
|
|
||||||
firstCell.classList.add('tray-role-card');
|
firstCell.classList.add('tray-role-card');
|
||||||
|
firstCell.tabIndex = 0; // enable :focus persistence for the hover-tilt
|
||||||
firstCell.dataset.role = roleCode;
|
firstCell.dataset.role = roleCode;
|
||||||
firstCell.textContent = '';
|
firstCell.textContent = '';
|
||||||
if (_roleIconsUrl) {
|
if (_roleIconsUrl) {
|
||||||
@@ -342,6 +357,16 @@ var Tray = (function () {
|
|||||||
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
|
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
|
||||||
if (!_btn) return;
|
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()) {
|
if (_isLandscape()) {
|
||||||
// Show tray before measuring so offsetHeight includes it.
|
// Show tray before measuring so offsetHeight includes it.
|
||||||
if (_tray) _tray.style.display = 'grid';
|
if (_tray) _tray.style.display = 'grid';
|
||||||
@@ -494,6 +519,7 @@ var Tray = (function () {
|
|||||||
if (_grid) {
|
if (_grid) {
|
||||||
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
|
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
|
||||||
el.classList.remove('tray-role-card', 'arc-in');
|
el.classList.remove('tray-role-card', 'arc-in');
|
||||||
|
el.removeAttribute('tabindex');
|
||||||
el.textContent = '';
|
el.textContent = '';
|
||||||
delete el.dataset.role;
|
delete el.dataset.role;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,6 +287,13 @@ def _role_select_context(room, user):
|
|||||||
"starter_roles": starter_roles,
|
"starter_roles": starter_roles,
|
||||||
"assigned_seats": assigned_seats,
|
"assigned_seats": assigned_seats,
|
||||||
"my_tray_role": _my_role,
|
"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": (
|
"my_tray_scrawl_static_path": (
|
||||||
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
|
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
|
||||||
if _my_role else None
|
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="Spec.js"></script>
|
||||||
<script src="RoleSelectSpec.js"></script>
|
<script src="RoleSelectSpec.js"></script>
|
||||||
<script src="TraySpec.js"></script>
|
<script src="TraySpec.js"></script>
|
||||||
|
<script src="TrayTooltipSpec.js"></script>
|
||||||
<script src="SigSelectSpec.js"></script>
|
<script src="SigSelectSpec.js"></script>
|
||||||
<script src="SeaDealSpec.js"></script>
|
<script src="SeaDealSpec.js"></script>
|
||||||
<script src="FanStageSpec.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/stage-card.js"></script>
|
||||||
<script src="/static/apps/epic/role-select.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.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/sig-select.js"></script>
|
||||||
<script src="/static/apps/epic/sea.js"></script>
|
<script src="/static/apps/epic/sea.js"></script>
|
||||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||||
|
|||||||
@@ -470,6 +470,11 @@ describe("Tray", () => {
|
|||||||
expect(firstCell.dataset.role).toBe("NC");
|
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", () => {
|
it("grid cell count stays at 8", () => {
|
||||||
Tray.placeCard("PC", null);
|
Tray.placeCard("PC", null);
|
||||||
expect(grid.children.length).toBe(8);
|
expect(grid.children.length).toBe(8);
|
||||||
@@ -518,5 +523,57 @@ describe("Tray", () => {
|
|||||||
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
||||||
expect(firstCell.dataset.role).toBeUndefined();
|
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-rect-h: 72px;
|
||||||
$handle-exposed: 48px;
|
$handle-exposed: 48px;
|
||||||
$handle-r: 1rem;
|
$handle-r: 1rem;
|
||||||
|
$tray-bevel: 0.3rem; // inner bevel ring; grid must sit inside this
|
||||||
|
|
||||||
#id_tray_wrap.role-select-phase {
|
#id_tray_wrap.role-select-phase {
|
||||||
#id_tray_handle { visibility: hidden; pointer-events: none; }
|
#id_tray_handle { visibility: hidden; pointer-events: none; }
|
||||||
@@ -128,9 +129,18 @@ $handle-r: 1rem;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tray-role-card {
|
.tray-role-card {
|
||||||
padding: 0;
|
padding: 0.5rem; // breathing room around role art (post-bleed-trim)
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: transparent;
|
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 {
|
img {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -138,7 +148,14 @@ $handle-r: 1rem;
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: center;
|
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.
|
// Cell stays static; only the scrawl image fades in.
|
||||||
@@ -156,9 +173,19 @@ $handle-r: 1rem;
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
&:focus { outline: none; }
|
||||||
|
|
||||||
.sig-stage-card.sea-sig-card {
|
.sig-stage-card.sea-sig-card {
|
||||||
--sig-card-w: calc(var(--tray-cell-size, 48px) * 5 / 8);
|
--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 {
|
#id_tray {
|
||||||
|
--tray-bevel: #{$tray-bevel}; // exposed to JS via getComputedStyle for cell-size math
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start
|
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-left:2.5rem solid rgba(var(--quaUser), 1);
|
||||||
border-top: 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);
|
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:
|
box-shadow:
|
||||||
-0.25rem 0 0.5rem rgba(0, 0, 0, 0.55),
|
-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(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.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
|
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-right: 2px dotted rgba(var(--priUser), 0.35);
|
||||||
border-bottom: 2px dotted rgba(var(--priUser), 0.35);
|
border-bottom: 2px dotted rgba(var(--priUser), 0.35);
|
||||||
position: relative;
|
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 ─────────────────────────────────────────
|
// ─── Tray: landscape reorientation ─────────────────────────────────────────
|
||||||
@@ -285,7 +328,7 @@ $handle-r: 1rem;
|
|||||||
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0.25rem 0.5rem rgba(0, 0, 0, 0.55),
|
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(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 -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
|
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);
|
grid-auto-rows: var(--tray-cell-size, 48px);
|
||||||
// Anchor grid to the handle-side (bottom) of the tray so the first row
|
// Anchor grid to the handle-side (bottom) of the tray so the first row
|
||||||
// is visible when partially open; additional rows grow upward.
|
// 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;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: var(--tray-bevel, 0.3rem);
|
||||||
left: 0;
|
left: var(--tray-bevel, 0.3rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// In landscape the first row sits at the bottom; border-top divides it from
|
// 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="Spec.js"></script>
|
||||||
<script src="RoleSelectSpec.js"></script>
|
<script src="RoleSelectSpec.js"></script>
|
||||||
<script src="TraySpec.js"></script>
|
<script src="TraySpec.js"></script>
|
||||||
|
<script src="TrayTooltipSpec.js"></script>
|
||||||
<script src="SigSelectSpec.js"></script>
|
<script src="SigSelectSpec.js"></script>
|
||||||
<script src="SeaDealSpec.js"></script>
|
<script src="SeaDealSpec.js"></script>
|
||||||
<script src="FanStageSpec.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/stage-card.js"></script>
|
||||||
<script src="/static/apps/epic/role-select.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.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/sig-select.js"></script>
|
||||||
<script src="/static/apps/epic/sea.js"></script>
|
<script src="/static/apps/epic/sea.js"></script>
|
||||||
<script src="/static/apps/gameboard/game-kit.js"></script>
|
<script src="/static/apps/gameboard/game-kit.js"></script>
|
||||||
|
|||||||
@@ -470,6 +470,11 @@ describe("Tray", () => {
|
|||||||
expect(firstCell.dataset.role).toBe("NC");
|
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", () => {
|
it("grid cell count stays at 8", () => {
|
||||||
Tray.placeCard("PC", null);
|
Tray.placeCard("PC", null);
|
||||||
expect(grid.children.length).toBe(8);
|
expect(grid.children.length).toBe(8);
|
||||||
@@ -518,5 +523,57 @@ describe("Tray", () => {
|
|||||||
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
||||||
expect(firstCell.dataset.role).toBeUndefined();
|
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" %}
|
{% extends "core/base.html" %}
|
||||||
{% load static %}
|
{% load static tooltip_tags %}
|
||||||
|
|
||||||
{% block title_text %}Gameboard{% endblock title_text %}
|
{% block title_text %}Gameboard{% endblock title_text %}
|
||||||
{% block header_text %}<span>Game</span>room{% endblock header_text %}
|
{% block header_text %}<span>Game</span>room{% endblock header_text %}
|
||||||
@@ -101,9 +101,15 @@
|
|||||||
<i class="fa-solid fa-dice-d20"></i>
|
<i class="fa-solid fa-dice-d20"></i>
|
||||||
</button>
|
</button>
|
||||||
</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 }}"></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>
|
</div>
|
||||||
{% endif %}
|
{% 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" %}
|
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -117,4 +123,5 @@
|
|||||||
<script src="{% static 'apps/epic/sig-select.js' %}"></script>
|
<script src="{% static 'apps/epic/sig-select.js' %}"></script>
|
||||||
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
||||||
<script src="{% static 'apps/epic/tray.js' %}"></script>
|
<script src="{% static 'apps/epic/tray.js' %}"></script>
|
||||||
|
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
|||||||
Reference in New Issue
Block a user