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
|
||||
|
||||
Reference in New Issue
Block a user