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:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user